From a30eb3200af7317dd92a5b88b709ba3e1d5ed356 Mon Sep 17 00:00:00 2001 From: Horldsence Date: Wed, 14 Oct 2026 19:43:14 +0800 Subject: [PATCH] =?UTF-8?q?armor:=20ESP32-C3=20=E8=A3=85=E7=94=B2=E6=9D=BF?= =?UTF-8?q?=E5=9B=BA=E4=BB=B6=EF=BC=88=E4=B8=80=E4=B8=BB=E4=B8=89=E4=BB=8E?= =?UTF-8?q?=EF=BC=89+=20=E7=83=A7=E5=BD=95=E8=84=9A=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=E4=B8=80=E7=9B=B4=E6=B2=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这一块代码本来就写好了、根 README 里也按"已实现"描述,但一直没有进仓库, 只存在于工作区里。同一个提交里带上 `.env.example` 的两个新变量(ARMOR_TEAM、 ARMOR_THRESHOLD)——它们的说明文字就是为这套固件写的。 内容: - armor-protocol:规则层(血量、每板独立 50ms 受击冷却、受击后只有那一路灯带全黑、 按键 FSM、上报编码)。纯计算,主机上 22 个测试,已跑通。 - armor-firmware:四路压电片 ADC 连续采样、四路 WS2812(C3 只有一个 RMT 通道可用, 靠 rmt_tx_switch_gpio 轮流驱动)、NVS、BOOT 键、WiFi + UDP 广播。 - scripts:build_armor.sh / flash_armor.sh / armor_probe.py(不开界面单看广播)。 armor/ 是独立 workspace(目标 RISC-V 的 ESP32-C3),与外层 Xtensa 的图传固件互不相干, 各自 .cargo/config.toml 就是这条边界。构建产物由根 .gitignore 的 `/armor/target/` 覆盖, 本次没有把任何 target/ 或 build/ 下的东西加进来。 顺带把 `.DS_Store` 加进 .gitignore。 --- .env.example | 10 + .gitignore | 3 + armor/.cargo/config.toml | 31 + armor/Cargo.lock | 1718 +++++++++++++++++++++++ armor/Cargo.toml | 15 + armor/README.md | 351 +++++ armor/armor-firmware/Cargo.toml | 26 + armor/armor-firmware/build.rs | 3 + armor/armor-firmware/partitions.csv | 10 + armor/armor-firmware/sdkconfig.defaults | 29 + armor/armor-firmware/src/esp.rs | 27 + armor/armor-firmware/src/leds.rs | 312 ++++ armor/armor-firmware/src/main.rs | 556 ++++++++ armor/armor-firmware/src/net.rs | 142 ++ armor/armor-firmware/src/piezo.rs | 213 +++ armor/armor-firmware/src/settings.rs | 65 + armor/armor-protocol/.cargo/config.toml | 7 + armor/armor-protocol/Cargo.toml | 9 + armor/armor-protocol/src/lib.rs | 872 ++++++++++++ scripts/armor_probe.py | 167 +++ scripts/build_armor.sh | 49 + scripts/flash_armor.sh | 75 + 22 files changed, 4690 insertions(+) create mode 100644 armor/.cargo/config.toml create mode 100644 armor/Cargo.lock create mode 100644 armor/Cargo.toml create mode 100644 armor/README.md create mode 100644 armor/armor-firmware/Cargo.toml create mode 100644 armor/armor-firmware/build.rs create mode 100644 armor/armor-firmware/partitions.csv create mode 100644 armor/armor-firmware/sdkconfig.defaults create mode 100644 armor/armor-firmware/src/esp.rs create mode 100644 armor/armor-firmware/src/leds.rs create mode 100644 armor/armor-firmware/src/main.rs create mode 100644 armor/armor-firmware/src/net.rs create mode 100644 armor/armor-firmware/src/piezo.rs create mode 100644 armor/armor-firmware/src/settings.rs create mode 100644 armor/armor-protocol/.cargo/config.toml create mode 100644 armor/armor-protocol/Cargo.toml create mode 100644 armor/armor-protocol/src/lib.rs create mode 100755 scripts/armor_probe.py create mode 100755 scripts/build_armor.sh create mode 100755 scripts/flash_armor.sh diff --git a/.env.example b/.env.example index 454dbed..84aa21b 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,16 @@ WIFI_PASS=your-password # Optional +# Armor plates (armor/). Which team this board belongs to: 0 = team one (red), +# 1 = team two (blue). Decides the LED colour and the `team` field in the health +# reports, so it is baked in at build time - reflash to switch teams. +#ARMOR_TEAM=0 + +# Armor plates: raw 12-bit ADC level above which a piezo sample counts as a hit +# (default 2000, same as the reference firmware). Calibrate from the `peak[...]` +# field in the serial status line: idle noise well below, a real tap well above. +#ARMOR_THRESHOLD=2000 + # Capture resolution: qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga #CAM_RES=vga # JPEG quality 0-63, lower is better quality diff --git a/.gitignore b/.gitignore index 4c223ea..8a22b3c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ # pnpm 在本机把 store 落在了工作区里(沙箱下 HOME 里的 store 不可写)。 /.pnpm-store/ + +# macOS 的目录元数据。不锚定,子目录里的也一起忽略。 +.DS_Store diff --git a/armor/.cargo/config.toml b/armor/.cargo/config.toml new file mode 100644 index 0000000..3d19e4b --- /dev/null +++ b/armor/.cargo/config.toml @@ -0,0 +1,31 @@ +# 装甲板的目标是 ESP32-C3(RISC-V),与外层 esp32/.cargo/config.toml 的 +# Xtensa 目标互不相干。这个文件离得近,所以会覆盖外层同名键(尤其是 MCU)。 +[build] +target = "riscv32imc-esp-espidf" + +[target.riscv32imc-esp-espidf] +linker = "ldproxy" +runner = "espflash flash --monitor" +rustflags = ["--cfg", "espidf_time64"] + +[unstable] +# `esp` 工具链只带了 host 的预编译 std,没有 esp 目标(与图传板那套一样)。 +# 所以必须自己编 std:键名是 `build-std`(连字符),写成 `build_std` 会被 cargo +# 当成未知键静默忽略,然后在链接阶段报找不到 `core`。 +build-std = ["std", "panic_abort"] + +[env] +MCU = "esp32c3" + +# 本机装了两套 ESP-IDF: +# +# /Users/peng/.espressif/v6.0.1/esp-idf eim 装的,工具链齐全(含 RISC-V) +# /Users/peng/.espressif/esp-idf/v5.5.5 只有 Xtensa 工具链(图传板在用) +# +# C3 是 RISC-V,所以这里指向 v6.0.1。esp-idf-sys 0.38.1 支持 6.0–6.2。 +# +# `ESP_IDF_TOOLS_INSTALL_DIR` 刻意 `force = false`:默认 `global`(即 ~/.espressif), +# 但如果环境里已经给了 `custom:<可写目录>`(沙箱、CI),就用环境里的那个。 +# 形状必须与外层 esp32/.cargo/config.toml 一致(都是表),否则 cargo 合并配置会报错。 +IDF_PATH = { value = "/Users/peng/.espressif/v6.0.1/esp-idf", force = true } +ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = false } diff --git a/armor/Cargo.lock b/armor/Cargo.lock new file mode 100644 index 0000000..7386def --- /dev/null +++ b/armor/Cargo.lock @@ -0,0 +1,1718 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "armor-firmware" +version = "0.1.0" +dependencies = [ + "anyhow", + "armor-protocol", + "embedded-svc", + "embuild", + "esp-idf-svc", + "log", +] + +[[package]] +name = "armor-protocol" +version = "0.1.0" +dependencies = [ + "protocol", +] + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "build-time" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1219c19fc29b7bfd74b7968b420aff5bc951cf517800176e795d6b2300dd382" +dependencies = [ + "chrono", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "compile-fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed69047ed42e52c7e38d6421eeb8ceefb4f2a2b52eed59137f7bad7908f6800" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "cvt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-core", + "futures-sink", + "heapless", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "embedded-svc" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfc6d05bac4af70b0795d1f1b6ddd44aa85ad04b05d34d64b1b9fce36de107e" +dependencies = [ + "defmt", + "embedded-io", + "embedded-io-async", + "enumset", + "heapless", + "log", + "num_enum", + "serde", + "strum 0.27.2", + "strum_macros 0.27.2", +] + +[[package]] +name = "embuild" +version = "0.33.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c41359cdde377dfd2e8aa34c09f9798a3ff9b6e4a9de81167c32d6b289521ed" +dependencies = [ + "anyhow", + "bindgen", + "bitflags 1.3.2", + "cmake", + "filetime", + "globwalk", + "home", + "log", + "regex", + "remove_dir_all", + "serde", + "serde_json", + "shlex 1.3.0", + "strum 0.24.1", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", + "serde", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esp-idf-hal" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52353908ed84c0290500557d7097ebf95985a6a9c98ea89cfb1ff9606ff7608f" +dependencies = [ + "atomic-waker", + "embassy-sync", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io", + "embedded-io-async", + "embuild", + "enumset", + "esp-idf-sys", + "heapless", + "log", + "nb 1.1.0", +] + +[[package]] +name = "esp-idf-svc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80528df10e8ee316043f0a53bcbec3126a217168813afdcae11078a5f34e60c9" +dependencies = [ + "embassy-futures", + "embedded-hal-async", + "embedded-svc", + "embuild", + "enumset", + "esp-idf-hal", + "futures-io", + "heapless", + "log", + "num_enum", + "uncased", +] + +[[package]] +name = "esp-idf-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77deab475cab0b375e20c7450b0d265f928480af712214c84e848df0f57c29f4" +dependencies = [ + "anyhow", + "build-time", + "cargo_metadata", + "cmake", + "compile-fmt", + "const_format", + "embuild", + "envy", + "libc", + "regex", + "serde", + "strum 0.24.1", + "toml", + "which", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fs_at" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" +dependencies = [ + "aligned", + "cfg-if", + "cvt", + "libc", + "nix", + "windows-sys 0.52.0", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "serde_core", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normpath" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "protocol" +version = "0.1.0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "remove_dir_all" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a694f9e0eb3104451127f6cc1e5de55f59d3b1fc8c5ddfaeb6f1e716479ceb4a" +dependencies = [ + "cfg-if", + "cvt", + "fs_at", + "libc", + "normpath", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros 0.24.3", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/armor/Cargo.toml b/armor/Cargo.toml new file mode 100644 index 0000000..ab6296e --- /dev/null +++ b/armor/Cargo.toml @@ -0,0 +1,15 @@ +# 装甲板固件(ESP32-C3):一主三从,主板一颗 MCU,四路压电片 + 四路 WS2812 都接在它上面。 +# +# 独立 workspace,不参与外层 esp32/ crate 的构建:两者目标芯片不同 +# (外层是 Xtensa 的 ESP32-S3 图传,这里是 RISC-V 的 ESP32-C3),各自的 +# `.cargo/config.toml` 就是这条边界的体现。 +[workspace] +resolver = "2" +members = ["armor-firmware", "armor-protocol"] + +[profile.release] +opt-level = "s" + +[profile.dev] +debug = true +opt-level = "z" diff --git a/armor/README.md b/armor/README.md new file mode 100644 index 0000000..3ad9537 --- /dev/null +++ b/armor/README.md @@ -0,0 +1,351 @@ +# 装甲板(ESP32-C3 / Rust / ESP-IDF)—— 一主三从,与裁判终端联动 + +一辆车四块装甲板(前/后/左/右),**只有主板一颗 MCU**:另外三块"从"板里没有单片机, +各自只有一路 WS2812 和一路压电片,直接接线到主板上。主板负责四路击打检测、四路灯带、 +整车血量,并把血量广播给本项目裁判终端。 + +固件是跑在 ESP-IDF 上的 Rust,目标芯片 **ESP32-C3(RISC-V)**。 + +```text +armor/ +├── Cargo.toml workspace:两个 crate,各自独立构建 +├── armor-protocol/ 规则层 + 线路格式(纯计算,主机上 22 个单元测试) +├── armor-firmware/ 固件:ADC / RMT / NVS / 按键 / WiFi+UDP +│ ├── src/leds.rs 四路 WS2812(单 RMT 通道轮流切 GPIO) +│ ├── src/piezo.rs 四路压电片(40 kHz ADC 连续采样) +│ ├── src/net.rs UDP 广播 ArmorHealth +│ ├── src/settings.rs NVS:队伍与颜色 +│ └── sdkconfig.defaults / partitions.csv +└── .cargo/config.toml riscv32imc-esp-espidf + MCU=esp32c3 +``` + +**为什么拆成两个 crate。** 规则层(算不算一次受击、扣几滴血、这一刻灯该显示什么、 +按一下键算短按还是长按)是最容易写错、又最不适合"烧上去看现象"的部分 —— 压电片的 +尖峰在硬件上根本没法按需复现。放在 `armor-protocol` 里就能喂 `Duration` 假时间来测。 +固件侧只做外设接线。 + +## 接线 + +| 功能 | GPIO | 说明 | +| --- | --- | --- | +| WS2812 前 | **IO10** | 每条灯带 3 颗灯珠,GRB、MSB first | +| WS2812 后 | **IO5** | | +| WS2812 左 | **IO6** | | +| WS2812 右 | **IO7** | | +| 压电片 前 | GPIO0(ADC1_CH0) | 0 dB 衰减、12 bit | +| 压电片 后 | GPIO4(ADC1_CH4) | | +| 压电片 左 | GPIO3(ADC1_CH3) | | +| 压电片 右 | GPIO1(ADC1_CH1) | | +| BOOT 键 | GPIO9 | 低有效,内部上拉 | +| 调试串口 UART0 TXD | GPIO21 | 115200 8N1,日志从这里出来(主控制台) | +| 调试串口 UART0 RXD | GPIO20 | 目前空闲,留给以后的命令通道 | + +引脚与参考实现完全一致(灯:前 10 / 后 5 / 左 6 / 右 7;压电:前 0 / 后 4 / 左 3 / +右 1)。调试口用 UART0 而不是 USB Serial/JTAG,是为了让"RXD/TXD"这对丝印就是日志口。 + +**日志同时从板上原生 USB 口出来。** `sdkconfig.defaults` 里把 USB Serial/JTAG 配成了 +**次**控制台(`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`):主控制台仍是 UART0, +所以"RXD/TXD 就是调试口"这条成立;而只用原生 USB 线烧录、没接 USB-TTL 的人也能看到日志, +否则那种接法下应用日志一个字符都看不到(只有 ROM 的启动信息)。 + +## 规则 + +| | 值 | 在哪 | +| --- | --- | --- | +| 整车血量 | 100 | `Rules::max_hp` | +| 每次受击扣血 | 3 | `Rules::damage_per_hit` | +| 受击后变暗 | **只有被打的那一路**全黑 50 ms,其余三路不动 | `Rules::hit_blink` + `Armor::strip` | +| 同一板位重复判定抑制 | 50 ms | `Rules::hit_cooldown` | +| 受击阈值 | 原始 12 bit ADC > 2000(`ARMOR_THRESHOLD` 可改) | `Rules::threshold` | +| 血量归零 | 四路全黑,直到复位 | `Armor::strip` | +| 正常颜色 | 队伍一红(`0xFF0000`)、队伍二蓝(`0x0000FF`) | `Rgb::for_team` | +| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` | +| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` | + +**整车一条血量,四格同值。** 板子算账、裁判端只显示:每条上报的 `hp` 相同, +`index` 分别是 0..3,所以裁判端已经画好的四格血量界面不用改。 + +**为什么要"每板独立冷却"。** 参考实现用全局 50 ms 冷却:一块板挨打后,别块板在这 +50 ms 内的击打会被吃掉。四路各自独立才是"每面各自挨打"的正确语义,这里有单元测试 +钉住(`cooldown_is_per_plate_not_global`)。 + +## 上报(UDP) + +主板每 200 ms 广播四条 `protocol::ArmorHealth` 到 `255.255.255.255:5601`: + +```text +magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index(1) | hp(2) +``` + +- **血量一变立刻发**,没变就按 200 ms 心跳重发。裁判端靠心跳判断板子还在线,一次 + 丢包不该让界面退回"未接入"。 +- `sender_id` 用 MAC 派生的板子 id(`protocol::id_from_mac`),不是 0 —— 0 是裁判 + 终端的保留 id,抓包时一块装甲板发的包不该长得像裁判端发的。 +- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。 +- 广播而不是单播:裁判终端的 IP 是 DHCP 给的,会变。图传板用 Hello 广播绕开这件事, + 装甲板沿用同一个约定。 + +### 裁判端怎么收 + +`client/src/armor.rs` 里的 `ArmorHealthStore`(裁判端角色才启动): + +- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。 +- **四条都在 1.5 s 内收到**才交给界面显示;否则回退占位符。心跳 200 ms,1.5 s 是连丢 + 7 个包,偶发丢包不会让四个格子各自跳。 +- 队伍端不用它(界面不显示对手血量),所以那台机器不会去占 5601。 +- 血量变化会打日志(`armor: team 0 hp 100 -> 97`):窗口是给别人看的,抓日志的人 + 也得能看见掉血。 + +**裁判端界面原本看不到血量。** 协议与 `ArmorSource` 早已就位,但 Slint 里那 4 格只是 +绿/灰方块 —— `hps` 的数值从没被渲染,于是"板子被打到 0"在界面上完全看不出来(现场 +表现就是"敲死了裁判系统没掉血")。现在 `TeamHealth` 多了一个 `hp-text` 与 `critical`: +队伍名旁边直接显示血量数字,归零显示 `0 · 已击毁`,低于 30 或归零时数字与四格转红。 + +## 构建 + +凭据与队伍号走 `.env`(与图传固件共用同一个文件): + +```bash +# .env +WIFI_SSID=your-ssid +WIFI_PASS=your-password +ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝) +ARMOR_THRESHOLD=2000 # 受击阈值(原始 12 bit ADC),看日志里的 peak 来定 + +scripts/build_armor.sh # 编译 +ARMOR_TEAM=1 scripts/build_armor.sh # 临时当队伍二编译 +ARMOR_THRESHOLD=1200 scripts/build_armor.sh # 换受击阈值(看日志里的 peak 来定) +``` + +产物:`armor/target/riscv32imc-esp-espidf/release/armor`。 + +`ARMOR_TEAM` 是**编译期**注入的:换队伍重刷固件即可,不用先擦 NVS。NVS 里另外记着 +`team` 与 `color`(命名空间 `armor`,与参考实现同名),两者不一致时以 `ARMOR_TEAM` +为准并覆盖,日志里会说一声。 + +本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` / +`ESP_IDF_TOOLS_INSTALL_DIR`),不需要先 `activate` ESP-IDF。删掉那两行就交给 +esp-idf-sys 自己下载它钉住的版本。 + +### 用的是 ESP-IDF v6.0.1,不是图传板那套 v5.5.5 + +这台机器上有两套 ESP-IDF: + +| | 路径 | 工具链 | +| --- | --- | --- | +| v6.0.1(eim 装的) | `/Users/peng/.espressif/v6.0.1/esp-idf` | Xtensa + **RISC-V(`esp-15.2.0_20251204`)** | +| v5.5.5(图传板在用) | `/Users/peng/.espressif/esp-idf/v5.5.5` | 只有 Xtensa | + +C3 是 **RISC-V**,而 v5.5.5 那套缺 `riscv32-esp-elf`(它要的是 `esp-14.2.0_20260121`, +本机没有,而 GitHub release 又下不动)。所以装甲板固件指向 v6.0.1 —— 它的工具链齐全, +且 `esp-idf-sys` 0.38.1 支持的版本范围是 4.4.4+、5.0–5.5、**6.0–6.2**。 + +两套 IDF 共存没有冲突:各自 `.cargo/config.toml` 里的 `IDF_PATH` 是每套 crate 自己钉的。 + +`ESP_IDF_TOOLS_INSTALL_DIR` 默认 `global`(即 `~/.espressif`,工具所在处),但**没有**加 +`force`:沙箱或 CI 里可以用 `ESP_IDF_TOOLS_INSTALL_DIR=custom:<可写目录>` 覆盖(工具目录 +不可写时把 `tools/` 软链到已装好的工具即可,Python venv 会建在那个可写目录里)。 + +### 主机侧测试 + +规则层在 PC 上跑,不需要板子: + +```bash +cd armor/armor-protocol && cargo test # 22 个测试 +cd protocol && cargo test # 线路格式往返(含 ArmorHealth) +``` + +(`armor-protocol/.cargo/config.toml` 把 target 覆盖回 host:它坐在 `armor/` 目录树 +里,否则会继承 RISC-V 目标,链接必然失败。这与 `protocol/`、`client/` 的做法一致。) + +## 烧录与实测 + +```bash +scripts/flash_armor.sh # 自动找串口,烧完看 20 秒日志 +ESP_PORT=/dev/cu.usbmodem1101 scripts/flash_armor.sh +scripts/flash_armor.sh --monitor 60 # 看 60 秒 +scripts/flash_armor.sh --no-monitor # 只烧不抓 +``` + +烧的是 ELF(`espflash` 自己从 ELF 里读分区表与 bootloader),芯片 `esp32c3`。 + +不打开裁判端界面,也可以单独确认板子在广播、血量对不对: + +```bash +python3 scripts/armor_probe.py 15 +``` + +它手写协议常量(不依赖 Rust 侧代码),所以是线路格式的**独立**检查:如果固件编码写错, +这个脚本会立刻报不一致。 + +### 串口日志长什么样(实机抓的) + +```text +I (321) armor: pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7 +I (321) armor: pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1 +I (4864) esp_netif_handlers: sta ip: 192.168.1.117, mask: 255.255.255.0, gw: 192.168.1.1 +I (5034) armor: joined "SR-602-2G4" on 192.168.1.117 +I (5034) armor::net: reporting to 255.255.255.255:5601 over UDP +I (5034) armor::net: this board's id is 0xA820D321, from MAC [98, A3, 16, 4C, 67, C8] +I (6044) armor: status: hp=100 hits=0 dead=false plates[front=0 rear=0 left=0 right=0] + peak[front=490 rear=395 left=456 right=475] thr=2000 + strips[front=#FF0000 rear=#FF0000 left=#FF0000 right=#FF0000] + ap=SR-602-2G4 rssi=-36 ch=6 ip=192.168.1.117 reports=5 errs=0 +I (63104) armor: hit plate=front raw=4095 hp=1 (hits=33) +I (63314) armor: hit plate=front raw=4095 hp=0 (hits=34) +W (63314) armor: hp reached 0: all four strips stay dark until the next reset +W (306504) armor: BOOT long press: hp reset to 100 (new round) +``` + +(日志里的长行是这里折行显示的,实机是一行。) + +## 实测(2026-10-14,ESP32-C3 devkit,MAC `98:a3:16:4c:67:c8`) + +| 项 | 结果 | +| --- | --- | +| 编译 | `cargo build --release` 通过;text 1,081,093 B,app 镜像 **1,082,848 B**,占 4 MB flash 的 26% | +| 启动 | 无 panic/重启;状态行每秒一条 | +| WiFi | 关联 `SR-602-2G4`(rssi −36,ch 6),DHCP 拿到 `192.168.1.117` | +| 上报 | `scripts/armor_probe.py` 收到队伍一四条、约 5 包/秒、`sender_id 0xA820D321`、四格同值 | +| 裁判端 | `cam-client --role referee` 日志 `team 0 plate 0..3 is online (hp 100) via 192.168.1.117`,界面显示血量数字 | +| 受击 | 敲前装甲板:`hit plate=front raw=2663..4095`,每次 −3(100→97→94…),只有 front 计数 | +| 变暗 | 敲击瞬间**只有前那一路**全黑 50 ms,其余不变(现场确认) | +| 死亡 | 34 击到 0;状态行 `dead=true strips[front=off rear=off left=off right=off]`,四路全黑 | +| 复位 | BOOT 长按 → 板子 `reset to 100`,裁判端日志 `armor: team 0 hp 0 -> 100` | +| 底噪 | 未敲击时四路 peak 约 380–510,阈值 2000 有明显余量 | +| 未接线 | 后/左/右三路压电片与灯带本次未接(用户说明),代码按四路齐全写 | + +### 裁判端这边踩到的坑:界面根本不显示血量 + +协议、`ArmorSource`、`ArmorHealthStore` 都对,但改之前 Slint 里那 4 格只是绿/灰方块, +`hps` 的**数值从不渲染**——板子被打到 0,界面四格还是绿的,看起来像"没掉血"。现在 +队伍名旁边直接显示数字(归零显示 `0 · 已击毁`,低于 30 转红),并且客户端日志会打 +`armor: team 0 hp 100 -> 97`。 + +## 实现要点(都是踩过的坑) + +### 1. ESP32-C3 的 RMT 只有 96 个 symbol 的通道内存 + +C3 的 RMT 只有一个 block,总计 96 个 symbol,按 48 为单位分给两个 TX 通道。`mem_block_symbols = 64` +会被向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 就会失败: + +```text +E rmt: rmt_tx_register_to_group(168): no free tx channels +``` + +四条灯带不可能各占一个通道(只有 2 个 TX 通道)。所以这里只建**一个**通道、让它独占 +96 个 symbol(一帧 = 3 珠 × 24 bit + reset = 73 个 symbol,装得下),再用 +`rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送;每路约 90 µs。 + +**为什么不用 `esp-idf-hal` 的 `TxChannelDriver`。** 它把 `rmt_channel_handle_t` 藏在 +结构体里(`as_ptr()` 是 `pub(crate)`),拿不到句柄就调不了 `rmt_tx_switch_gpio()`。 +所以 `leds.rs` 直接用 sys 绑定:通道、官方 copy encoder、transmit 都是 IDF 原生的那 +几个函数,用法与参考实现的 `led_strip_encoder.c` 一一对应。 + +**每路内容没变就不重发。** 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍; +这里四路颜色独立(被打的那一路要单独变暗),于是只在内容变化时才走 +`disable → switch → enable → transmit`,另外每秒强制全刷一次(掉电、静电都可能让 +某条灯带丢帧,缓存不能当真相)。 + +### 2. 压电片必须连续(DMA)采样 + +一次击打的波形只有几十微秒宽,oneshot 轮询几乎必然从尖峰旁边擦过去,表现就是"敲得响 +才算"。这里与参考实现一致:四通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个 +采样点,一帧就是主循环的节拍。 + +`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 的约束是 +`A: Adc` 且 `O: AdcChannels`:`ADC1` 满足前者、`ADCU1` 满足后者,同一个 +类型参数凑不齐,四路 ADC1 通道拼不出来。所以 `piezo.rs` 同样直接用 sys 绑定,与参考 +实现逐行对应。 + +采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序 +轮转"这个假设。 + +### 3. 一次击打会被算成几十次 + +40 kHz 下,压电片一次击打会连续几十个采样点过阈值。**去抖不在采样层做**,而是 +`Armor::on_sample` 的冷却窗口把它收敛成一次受击 —— 这样"算不算受击"这条规则可以在 +主机上测(`repeat_samples_inside_the_cooldown_are_one_hit`)。 + +### 4. 控制台放在 UART0 + +`sdkconfig.defaults` 里显式写了 `CONFIG_ESP_CONSOLE_UART_DEFAULT=y`:C3 的 ROM 默认 +控制台就是 UART0,引脚正好是 RXD=GPIO20 / TXD=GPIO21,与要求一致。换成 USB +Serial/JTAG 输出只要把这一行换成 `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG`。 + +### 5. 应用分区给 2 MB + +默认的 1 MB 单 app 槽对带 WiFi 的 Rust 固件太紧,`partitions.csv` 按参考实现给 +factory 划了 2 MB。 + +### 6. WiFi 省电关掉 + +省电模式下上报会晚一个信标间隔(上百毫秒),而"受击后 50 ms 变暗"本来就是要给人即时 +反馈的。板子是车载供电,这点电流不值得换不确定性。另外主循环里每 5 s 检查一次连接, +断了就重连 —— 一块静默失联的装甲板在场上是最难查的故障。 + +### 7. 判断"连上没有"要问 IDF,不要信 esp-idf-svc 的事件缓存 + +`BlockingWifi::is_connected()/is_up()/wait_netif_up()` 读的是 esp-idf-svc 自己维护的 +WiFi 事件状态。实测在 **IDF v6.0.1 + esp-idf-svc 0.53** 上它会停在"已连接":日志说 +一切正常、`get_ip_info()` 却是 `0.0.0.0`,而 IDF 的 `esp_wifi_sta_get_ap_info()` 明明 +说没关联。后果是 `join()` 假装连上了、看门狗永远不会重试,板子静默失联。 + +所以固件里一律用 IDF 的 API 判断: + +- 关联:`esp_wifi_sta_get_ap_info()` +- 地址:`esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")` + `esp_netif_get_ip_info()` + +而且 **WiFi 是尽力而为**:`join()` 失败只警告、不退出。一块因为没网就不亮灯的装甲板, +比"裁判端看不到血量"糟糕得多。看门狗每 5 s 检查一次"关联 + 有 IP",不满足就断开重连 +(重连时只等 3 s IP,别让主循环错过受击)。 + +### 8. 日志里"有没有连上"要说清楚 + +状态行长这样: + +```text +status: hp=0 hits=37 dead=true plates[front=37 rear=0 left=0 right=0] + peak[front=504 rear=379 left=459 right=486] thr=2000 + strips[front=off rear=off left=off right=off] + ap=SR-602-2G4 rssi=-29 ch=6 ip=192.168.1.117 reports=1745 errs=0 +``` + +- `peak[...]` 是**这一秒内每路 ADC 的峰值**,用来校准阈值:没敲时的底噪要明显低于 + 阈值,正常敲击要明显高于它(实测底噪 ~400–500,敲击 2000–4095)。只报"有没有过阈值" + 没法判断阈值该调到哪。 +- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。 +- `ap=` / `ip=` / `reports=` 一眼分清"板子没连上"和"包发出去了但对端没显示"。 + +### 9. USB 控制台看不到复位后的头两秒 + +主控制台是 UART0,另外把 USB Serial/JTAG 配成了次控制台,所以插原生 USB 也能看日志。 +但复位时 USB 会重新枚举,**复位后最初约 1–2 秒的日志会丢**(我们实测错过过启动横幅)。 +要看完整启动过程就用 UART0 那对脚接 USB-TTL——那才是设计中的调试口。 + +## 与参考实现(`reference/armor`)的差异 + +| | 参考实现 | 这里 | 为什么 | +| --- | --- | --- | --- | +| 主从链路 | ESP-NOW 配对,两块板对拷 | 无:三块从板只是直接接线的灯与传感器 | 按实际硬件(只有主板一颗 MCU) | +| DBUS 透传 | UART0 收发 10 字节 DBUS,USB CDC 打 CSV | 不做 | 这套硬件没有 DBUS 需求;UART0 让给日志 | +| 受击变暗 | 四条灯带一起全黑 50 ms | 只暗被打的那一路 | 每面独立灯带的意义就在这里 | +| 冷却窗口 | 全局 50 ms | 每板独立 50 ms | 别块板的击打不该被吃掉 | +| 灯带刷新 | 每 10 ms 无条件刷四遍 | 只在内容变化时刷 + 每秒强制全刷 | 四路独立控制后,省掉四轮 RMT 开销 | +| 血量 | 只有击打计数,无血量 | 整车 100 血、每击 −3、0 血全黑,按 `ArmorHealth` 上报 | 与裁判端已画好的四格血量对齐 | +| 颜色切换 | BOOT 短按切红/蓝,存 NVS | 颜色由 `ARMOR_TEAM` 决定(红/蓝),短按打状态 | 队别不该靠现场按键切错 | +| 按键 | 移植 OneButton 状态机(短按配对,长按切色) | 本地 FSM:长按复位血量、短按打状态 | 只有两种动作,且要能在主机上测 | +| 上报 sender_id | —(不走 UDP) | MAC 派生 id | 0 是裁判终端保留 id | + +## 还没做的 + +- **另外三路(后/左/右)的压电片与灯带还没接**(2026-10-14 实机只有前一路)。 + 代码按四路齐全写,接线后无需改代码;但"四路同时受击"、"每路独立变暗"这两件事 + 还没在实板上验证过。 +- **UART0 RX(GPIO20)上的命令通道**:日志方向已经能用;反向的 `status` / `reset` + 命令还没接(当前复位走 BOOT 长按)。 +- **从板加 MCU 的方案**:如果以后从板也装单片机(走 UART 或无线回传), + `armor-protocol` 里已经预留了板位枚举与上报格式,加一个 `slave` bin 即可。 +- **双击 / 多击**:`Button` 只实现短按与长按,参考实现里未使用的 OneButton 功能没有移植。 +- **下发血量**:当前板子本地算账(裁判端只显示)。若以后要裁判端权威(下发血量/复位), + 需要新增一条下行消息并在 `client` 里发。 diff --git a/armor/armor-firmware/Cargo.toml b/armor/armor-firmware/Cargo.toml new file mode 100644 index 0000000..1b68516 --- /dev/null +++ b/armor/armor-firmware/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "armor-firmware" +version = "0.1.0" +edition = "2021" +resolver = "2" +rust-version = "1.82" + +# 一个 bin:主板固件。三块"从"装甲板里没有 MCU,只有一路 WS2812 + 一路压电片 +# 直接接在主板 GPIO 上,所以没有第二份固件可烧(见 armor/README.md)。 +[[bin]] +name = "armor" +path = "src/main.rs" +harness = false # 不在固件里跑主机测试框架;规则层的测试在 armor-protocol + +[dependencies] +log = "0.4" +anyhow = "1" +embedded-svc = "0.29" +esp-idf-svc = "0.53" + +# 规则层 + 线路格式:血量、去抖、按键、灯带输出,以及上报给裁判终端的 +# `protocol::ArmorHealth`(armor-protocol 再导出)。规则层能在主机上单测。 +armor-protocol = { path = "../armor-protocol" } + +[build-dependencies] +embuild = "0.33.5" diff --git a/armor/armor-firmware/build.rs b/armor/armor-firmware/build.rs new file mode 100644 index 0000000..112ec3f --- /dev/null +++ b/armor/armor-firmware/build.rs @@ -0,0 +1,3 @@ +fn main() { + embuild::espidf::sysenv::output(); +} diff --git a/armor/armor-firmware/partitions.csv b/armor/armor-firmware/partitions.csv new file mode 100644 index 0000000..d049fc9 --- /dev/null +++ b/armor/armor-firmware/partitions.csv @@ -0,0 +1,10 @@ +# 装甲板固件(4 MB flash)的自定义分区表。 +# +# nvs : 颜色 / 队伍等应用设置,外加 WiFi 校准数据 +# phy_init : 射频校准数据 +# factory : 唯一的分区镜像(2 MB 余量;固件实测不到 1 MB,但默认的 1 MB 单 app +# 槽对带 WiFi 的固件太紧) +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x200000, diff --git a/armor/armor-firmware/sdkconfig.defaults b/armor/armor-firmware/sdkconfig.defaults new file mode 100644 index 0000000..841afc1 --- /dev/null +++ b/armor/armor-firmware/sdkconfig.defaults @@ -0,0 +1,29 @@ +# 装甲板固件(ESP32-C3)的默认配置。 +# +# CONFIG_IDF_TARGET 必须显式写出来:本机装的是通用 ESP-IDF,默认目标是 ESP32, +# 不写这一行会按 ESP32 去配 ADC/RMT 通道,编译期就报"没有这个外设"。 +# +# 串口调试走 UART0(RXD = GPIO20 / TXD = GPIO21,115200 8N1)——这是 C3 的 ROM +# 默认控制台引脚,和参考固件里给 DBUS 用的那两个脚是同一对。想改成 USB +# Serial/JTAG 输出,把 CONFIG_ESP_CONSOLE_UART_DEFAULT 换成 +# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG 即可。 +CONFIG_IDF_TARGET="esp32c3" + +# 4 MB flash;应用分区放到 2 MB(见 partitions.csv),默认的 1 MB 单 app 槽对 +# WiFi + 日志的 Rust 固件太紧。 +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y + +# 调试口 = UART0(GPIO20 RX / GPIO21 TX)。 +# +# 同时打开 USB Serial/JTAG 作为**次**控制台:日志会同时从板上那个原生 USB 口出来。 +# 这样现场两种接法都能看到日志 —— 接了 USB-TTL 到 RXD/TXD 的看 UART0,直接插板上 +# USB 口的看 USB。主控制台仍是 UART0,所以"RXD/TXD 就是调试口"这条成立。 +# +# 只用原生 USB 口烧录(不接 USB-TTL)时,这一条是能看见日志的唯一办法。 +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y + +# Rust 的 main 与规则层在同一个任务栈上跑,另外 log 会用到一些栈;默认 3.5 KB +# 在带 WiFi 的固件里容易爆栈。 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 diff --git a/armor/armor-firmware/src/esp.rs b/armor/armor-firmware/src/esp.rs new file mode 100644 index 0000000..9d5eca8 --- /dev/null +++ b/armor/armor-firmware/src/esp.rs @@ -0,0 +1,27 @@ +//! 把 IDF 的 `esp_err_t` 返回值变成可读的 `anyhow::Error`。 +//! +//! 固件里到处都要判 `ESP_OK` 并把错误码翻译成人能看懂的名字;不翻译的话现场只会 +//! 看到 `0x105`,而 IDF 的错误码表并不在脑子里。 + +use anyhow::{anyhow, Result}; +use esp_idf_svc::sys as ffi; + +/// `ESP_OK` 之外的返回值转成 `Err`,`what` 是出错的那一步的名字。 +pub fn check(what: &str, rc: ffi::esp_err_t) -> Result<()> { + if rc == ffi::ESP_OK { + return Ok(()); + } + + Err(anyhow!("{what} failed: {} (0x{rc:x})", name(rc))) +} + +/// 错误码的名字,例如 `ESP_ERR_INVALID_ARG`。 +#[must_use] +pub fn name(rc: ffi::esp_err_t) -> String { + // SAFETY: esp_err_to_name 对任意 esp_err_t 都返回一个静态 C 字符串。 + unsafe { + core::ffi::CStr::from_ptr(ffi::esp_err_to_name(rc)) + .to_string_lossy() + .into_owned() + } +} diff --git a/armor/armor-firmware/src/leds.rs b/armor/armor-firmware/src/leds.rs new file mode 100644 index 0000000..d850170 --- /dev/null +++ b/armor/armor-firmware/src/leds.rs @@ -0,0 +1,312 @@ +//! 四路 WS2812 灯带:一个 RMT TX 通道 + 官方 copy encoder,靠切 GPIO 轮流驱动。 +//! +//! # 为什么不用 `esp-idf-hal` 的 `TxChannelDriver` +//! +//! 它把 `rmt_channel_handle_t` 藏在结构体里(`as_ptr()` 是 `pub(crate)`),而 +//! "一个通道依次驱动四路灯带"必须用 `rmt_tx_switch_gpio()` 换脚。ESP32-C3 的 RMT +//! 只有 **2 个 TX 通道**,四路灯带不可能各占一个,所以这里直接用 `esp-idf-svc` 的 +//! sys 绑定:通道、encoder、transmit 都是 IDF 原生的那几个函数,用法与参考实现的 +//! `led_strip_encoder.c` 一一对应。 +//! +//! # ESP32-C3 的 RMT 内存限制(照抄参考实现的踩坑记录) +//! +//! C3 的 RMT 只有一个 block,总计 **96 个 symbol 的通道内存**,按 +//! `SOC_RMT_MEM_WORDS_PER_CHANNEL = 48` 为单位分配。`mem_block_symbols = 64` 会被 +//! 向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 会以 +//! `no free tx channels` 失败: +//! +//! ```text +//! E rmt: rmt_tx_register_to_group(168): no free tx channels +//! ``` +//! +//! 所以这里只建**一个**通道、让它独占 96 个 symbol(一帧 = 3 珠 × 24 bit + reset +//! = 73 个 symbol,装得下),再用 `rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送。 +//! 每路约 90 µs,10 ms 周期里的开销可以忽略。 +//! +//! # 与参考实现的差异 +//! +//! 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍。这里四路颜色各自独立 +//! (被打的那一路要单独变暗),于是: +//! +//! - 内容没变的路不发(`refresh` 里比较上一次的 [`Strip`]),省掉每次 +//! `disable → switch → enable → transmit` 的四轮开销; +//! - 每秒仍强制全刷一次:灯带可能因为上电次序、掉电、静电而丢帧,缓存不能当真相。 + +use core::ffi::c_void; +use core::time::Duration; + +use anyhow::Result; +use esp_idf_svc::hal::rmt::{PinState, Pulse, Symbol}; +use esp_idf_svc::hal::units::Hertz; +use esp_idf_svc::sys as ffi; +use log::{info, warn}; + +use armor_protocol::{Plate, Rgb, Strip}; + +use crate::esp; + +/// RMT 计时分辨率:10 MHz,即 1 tick = 0.1 µs。 +const RMT_RESOLUTION_HZ: u32 = 10_000_000; + +/// 每条灯带 3 颗灯珠(与参考实现一致)。 +const LEDS_PER_STRIP: usize = 3; + +/// 一帧的符号数:24 bit/珠 × 3 珠 + 1 个 reset 符号。 +const FRAME_SYMBOLS: usize = LEDS_PER_STRIP * 24 + 1; + +/// 独占 C3 全部 RMT 通道内存,只建一个通道(见模块文档)。 +const MEM_BLOCK_SYMBOLS: usize = 96; + +/// WS2812 的位时序:T0H / T0L / T1H / T1L。 +const T0H_NS: u64 = 300; +const T0L_NS: u64 = 900; +const T1H_NS: u64 = 900; +const T1L_NS: u64 = 300; + +/// 帧尾低电平时长;WS2812 要 >50 µs 才锁存。 +const RESET_US: u64 = 50; + +/// 灯带数据脚(GPIO 号,按 [`Plate`] 的 index 排列):前 10 / 后 5 / 左 6 / 右 7。 +const STRIP_GPIO: [i32; 4] = [10, 5, 6, 7]; + +/// `rmt_tx_wait_all_done` 的超时(毫秒)。一帧只要 ~90 µs,1 s 是"卡死就报错"的量级。 +const TX_TIMEOUT_MS: i32 = 1000; + +/// 强制全刷的周期:缓存不可信(掉电、静电都可能让灯带丢帧)。 +const FULL_REFRESH_MS: u64 = 1000; + +/// 四路 WS2812。 +pub struct Leds { + channel: ffi::rmt_channel_handle_t, + encoder: ffi::rmt_encoder_handle_t, + /// 通道当前是否处于 enable 状态;切 GPIO 只能在 INIT 状态下做。 + enabled: bool, + bit0: Symbol, + bit1: Symbol, + reset: Symbol, + /// 每路的预编码帧。放在 `Box` 里:`rmt_transmit` 只借用指针,堆上的地址不会 + /// 因为结构体搬动而失效。 + frames: Box<[[ffi::rmt_symbol_word_t; FRAME_SYMBOLS]; 4]>, + /// 上一次真正发出去的内容,用来跳过没变化的刷新。 + current: [Option; 4], + last_full_refresh_ms: u64, +} + +impl Leds { + /// 建通道与 encoder,把四路灯带全刷成黑色(上电先灭)。 + pub fn new() -> Result { + let mut config = ffi::rmt_tx_channel_config_t::default(); + // 先落在前装甲板那一路;其余三路靠 switch_gpio 换过去。 + config.gpio_num = STRIP_GPIO[Plate::Front.index() as usize] as _; + config.clk_src = ffi::soc_periph_rmt_clk_src_t_RMT_CLK_SRC_DEFAULT; + config.resolution_hz = RMT_RESOLUTION_HZ; + config.mem_block_symbols = MEM_BLOCK_SYMBOLS; + config.trans_queue_depth = 4; + + let mut channel: ffi::rmt_channel_handle_t = core::ptr::null_mut(); + esp::check( + "rmt_new_tx_channel", + // SAFETY: config 与 channel 都是本函数的局部量,调用期间有效。 + unsafe { ffi::rmt_new_tx_channel(&config, &mut channel) }, + )?; + + let mut encoder: ffi::rmt_encoder_handle_t = core::ptr::null_mut(); + let copy_config = ffi::rmt_copy_encoder_config_t::default(); + if let Err(err) = esp::check( + "rmt_new_copy_encoder", + // SAFETY: 同上,copy encoder 只是把用户给的 symbol 原样搬进硬件。 + unsafe { ffi::rmt_new_copy_encoder(©_config, &mut encoder) }, + ) { + // SAFETY: 通道刚建好且未使用,可以直接删。 + unsafe { ffi::rmt_del_channel(channel) }; + return Err(err); + } + + let resolution = Hertz(RMT_RESOLUTION_HZ); + let bit0 = Symbol::new_with( + resolution, + PinState::High, + Duration::from_nanos(T0H_NS), + PinState::Low, + Duration::from_nanos(T0L_NS), + )?; + let bit1 = Symbol::new_with( + resolution, + PinState::High, + Duration::from_nanos(T1H_NS), + PinState::Low, + Duration::from_nanos(T1L_NS), + )?; + let reset_low = + Pulse::new_with_duration(resolution, PinState::Low, Duration::from_micros(RESET_US))?; + // reset = 一整段(两半都是低电平)的符号。 + let reset = Symbol::new(reset_low, reset_low); + + // `rmt_symbol_word_t` 是 union,bindgen 不给它 `Default`,只能自己写一个全零符号。 + let blank = ffi::rmt_symbol_word_t { val: 0 }; + + let mut leds = Self { + channel, + encoder, + enabled: false, + bit0, + bit1, + reset, + frames: Box::new([[blank; FRAME_SYMBOLS]; 4]), + current: [None; 4], + last_full_refresh_ms: 0, + }; + + info!( + "ws2812: 1 RMT channel x {MEM_BLOCK_SYMBOLS} symbols, {LEDS_PER_STRIP} leds/strip on GPIO {:?}", + STRIP_GPIO + ); + + // 上电先灭:不知道灯带上一次显示的是什么。 + for plate in Plate::ALL { + leds.write(plate, Strip::Black)?; + } + + Ok(leds) + } + + /// 把预编码的帧发到指定那一路。 + fn write(&mut self, plate: Plate, strip: Strip) -> Result<()> { + let slot = plate.index() as usize; + + self.encode(slot, strip.color()); + + // 切 GPIO 只能在 INIT 状态下做,所以先 disable。 + if self.enabled { + esp::check("rmt_disable", unsafe { ffi::rmt_disable(self.channel) })?; + self.enabled = false; + } + + esp::check( + "rmt_tx_switch_gpio", + // SAFETY: 通道处于 INIT 状态;gpio 是 C3 上的合法输出脚。 + unsafe { ffi::rmt_tx_switch_gpio(self.channel, STRIP_GPIO[slot] as _, false) }, + )?; + + esp::check("rmt_enable", unsafe { ffi::rmt_enable(self.channel) })?; + self.enabled = true; + + let tx_config = ffi::rmt_transmit_config_t { + loop_count: 0, // 不循环 + ..Default::default() + }; + + esp::check( + "rmt_transmit", + // SAFETY: frames[slot] 在 Box 上,地址稳定;调用期间不会被移动。 + unsafe { + ffi::rmt_transmit( + self.channel, + self.encoder, + self.frames[slot].as_ptr() as *const c_void, + core::mem::size_of_val(&self.frames[slot]), + &tx_config, + ) + }, + )?; + + esp::check( + "rmt_tx_wait_all_done", + // SAFETY: 纯等待,超时给 1 s。 + unsafe { ffi::rmt_tx_wait_all_done(self.channel, TX_TIMEOUT_MS) }, + )?; + + self.current[slot] = Some(strip); + Ok(()) + } + + /// 把 `rgb` 编成一条灯带的整帧(GRB、MSB first,3 珠同色)。 + fn encode(&mut self, slot: usize, rgb: Rgb) { + let grb = rgb.to_grb(); + let frame = &mut self.frames[slot]; + + // 一颗灯珠的 24 个符号只算一遍,再复制给另外两颗 —— 四条灯带都是 3 珠, + // 中间那两步没必要重复算。 + let mut one_led = [ffi::rmt_symbol_word_t { val: 0 }; 24]; + for (byte_index, byte) in grb.iter().enumerate() { + for bit in 0..8 { + let symbol = if (byte >> (7 - bit)) & 1 == 1 { + self.bit1 + } else { + self.bit0 + }; + one_led[byte_index * 8 + bit] = symbol.into(); + } + } + + for led in 0..LEDS_PER_STRIP { + frame[led * 24..(led + 1) * 24].copy_from_slice(&one_led); + } + + // 帧尾:>50 µs 低电平,灯带才会锁存这一帧。 + frame[FRAME_SYMBOLS - 1] = self.reset.into(); + } + + /// 按 [`Plate`] 的 index 刷四路灯带。 + /// + /// 内容与上一次相同的路直接跳过(除非到了每秒一次的强制全刷),所以稳态下 + /// 这个函数几乎不花时间。 + pub fn refresh(&mut self, strips: [Strip; 4], now_ms: u64) -> Result<()> { + let full = now_ms.saturating_sub(self.last_full_refresh_ms) >= FULL_REFRESH_MS; + + for plate in Plate::ALL { + let strip = strips[plate.index() as usize]; + + if !full && self.current[plate.index() as usize] == Some(strip) { + continue; + } + + self.write(plate, strip)?; + } + + if full { + self.last_full_refresh_ms = now_ms; + } + + Ok(()) + } +} + +impl Drop for Leds { + fn drop(&mut self) { + // 固件里 main 永不返回,这段只在异常路径(比如 new() 之后的早期返回)跑到。 + unsafe { + if self.enabled { + ffi::rmt_disable(self.channel); + } + if !self.encoder.is_null() { + ffi::rmt_del_encoder(self.encoder); + } + if !self.channel.is_null() { + ffi::rmt_del_channel(self.channel); + } + } + } +} + +/// 供 main 打印一次用:把四路输出拼成一行,便于对照。 +pub fn describe(strips: [Strip; 4]) -> String { + Plate::ALL + .iter() + .map(|plate| { + let strip = strips[plate.index() as usize]; + let text = if strip.is_black() { + "off".to_string() + } else { + format!("#{:06X}", strip.color().to_u32()) + }; + format!("{}={text}", plate.name()) + }) + .collect::>() + .join(" ") +} + +/// 记一次刷新失败:诊断信息比重启有用,现场灯带接错线也不该把固件打挂。 +pub fn log_failure(err: &anyhow::Error) { + warn!("ws2812 refresh failed: {err:#}"); +} diff --git a/armor/armor-firmware/src/main.rs b/armor/armor-firmware/src/main.rs new file mode 100644 index 0000000..c2f2cd7 --- /dev/null +++ b/armor/armor-firmware/src/main.rs @@ -0,0 +1,556 @@ +//! 装甲板主板固件(ESP32-C3):一主三从,与裁判终端联动。 +//! +//! # 这套硬件是什么 +//! +//! 一辆车四块装甲板(前/后/左/右),但只有**主板一颗 MCU**:另外三块"从"板里没有 +//! 单片机,各自只有一路 WS2812 和一路压电片,直接接线到主板上。所以这份固件同时管 +//! 四路输入与四路输出: +//! +//! ```text +//! 压电片 前/后/左/右 ──ADC──┐ +//! ├──► armor-protocol(血量 / 去抖 / 灯色) +//! BOOT 键 ──────── GPIO9 ──┘ │ +//! ├──► WS2812 前/后/左/右 +//! └──► UDP 广播 ──► 裁判终端 :5601 +//! ``` +//! +//! # 规则(见 `armor-protocol`,那里有单元测试) +//! +//! - 整车一条血量:100 起,每次受击 −3,减到 0 不变负;0 血四路全黑。 +//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动。 +//! - 颜色由队伍决定:队伍一红、队伍二蓝;`ARMOR_TEAM` 编译期注入。 +//! - 血量账在板子上算,按 `protocol::ArmorHealth` 广播给本项目裁判终端。 +//! +//! # 构建与烧录 +//! +//! ```text +//! scripts/build_armor.sh # 用 .env 里的 WIFI_SSID/WIFI_PASS/ARMOR_TEAM +//! scripts/flash_armor.sh # 编译 + espflash + 串口日志 +//! ``` +//! +//! 串口调试走 UART0:RXD = GPIO20 / TXD = GPIO21,115200 8N1(日志从 TX 出来, +//! RX 目前空闲,留给以后加命令通道)。USB Serial/JTAG 配成了次控制台,所以插原生 +//! USB 口也能看到同一份日志。完整接线表、协议与踩坑见 `armor/README.md`。 + +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use embedded_svc::wifi::{AuthMethod, ClientConfiguration, Configuration, PmfConfiguration}; +use esp_idf_svc::eventloop::EspSystemEventLoop; +use esp_idf_svc::hal::gpio::{PinDriver, Pull}; +use esp_idf_svc::hal::peripherals::Peripherals; +use esp_idf_svc::nvs::EspDefaultNvsPartition; +use esp_idf_svc::wifi::{BlockingWifi, EspWifi}; +use log::{error, info, warn}; + +use armor_protocol::{Armor, Button, ButtonEvent, Plate, ReportSchedule, Rules, TEAM_2}; + +mod esp; +mod leds; +mod net; +mod piezo; +mod settings; + +/// WiFi 账号来自 .env(构建期注入,固件里不留密码)。 +const SSID: &str = env!("WIFI_SSID"); +const PASSWORD: &str = env!("WIFI_PASS"); + +/// 编译期注入的队伍号:`0` = 队伍一(红),`1` = 队伍二(蓝)。不写就是队伍一。 +const TEAM_ENV: &str = match option_env!("ARMOR_TEAM") { + Some(value) => value, + None => "0", +}; + +/// 受击阈值(原始 12 bit ADC)。参考实现用 2000,但压电片的幅度取决于贴装、线长 +/// 与有没有放大级,所以这里留一个编译期入口:`ARMOR_THRESHOLD=1200`。 +/// +/// 怎么定这个值:看串口 status 行里的 `peak[...]`。没敲时的底噪要明显低于阈值, +/// 正常敲击要明显高于它。 +const THRESHOLD_ENV: &str = match option_env!("ARMOR_THRESHOLD") { + Some(value) => value, + None => "2000", +}; + +/// 血量不变时的重发周期。裁判端靠它判断板子还在线 —— 一次丢包不该让界面变回 +/// "装甲板未接入"。 +const REPORT_HEARTBEAT_MS: u64 = 200; + +/// 状态行的打印周期(UART 调试口)。 +const STATUS_INTERVAL_MS: u64 = 1000; + +/// WiFi 断线检查周期。 +const WIFI_CHECK_MS: u64 = 5000; + +/// 毫秒时基。`esp_timer` 是微秒计数器,转毫秒后 u64 不会被绕回。 +fn now_ms() -> u64 { + // SAFETY: 纯读计时器。 + (unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64) / 1000 +} + +fn main() -> Result<()> { + // esp-idf-sys 的运行时补丁必须跑一次,日志系统也要先起来。 + esp_idf_svc::sys::link_patches(); + esp_idf_svc::log::EspLogger::initialize_default(); + + let team = armor_protocol::parse_team(TEAM_ENV) + .ok_or_else(|| anyhow!("ARMOR_TEAM must be 0 or 1, got {TEAM_ENV:?}"))?; + + let peripherals = Peripherals::take()?; + let sys_loop = EspSystemEventLoop::take()?; + let nvs = EspDefaultNvsPartition::take()?; + + // 队别与颜色:ARMOR_TEAM 是权威,NVS 只记住结果。 + let color = settings::Settings::new(nvs.clone())?.resolve_color(team)?; + + let threshold: u16 = THRESHOLD_ENV + .parse() + .map_err(|_| anyhow!("ARMOR_THRESHOLD must be a number, got {THRESHOLD_ENV:?}"))?; + let rules = Rules { + threshold, + ..Rules::default() + }; + + let mut armor = Armor::new(rules, color); + + info!("pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7"); + info!("pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1"); + info!("pins: boot=GPIO9 debug uart0 rxd=GPIO20 txd=GPIO21 115200 8N1"); + info!( + "armor board: team={team} ({}), {} hp, -{} per hit, {} ms dark on the hit strip", + if team == TEAM_2 { "blue" } else { "red" }, + armor.hp(), + rules.damage_per_hit, + rules.hit_blink.as_millis() + ); + + let mut leds = leds::Leds::new()?; + let mut piezo = piezo::Piezo::new()?; + + // BOOT 键(GPIO9,低有效):内部上拉,按下读低。 + let button = PinDriver::input(peripherals.pins.gpio9, Pull::Up)?; + let mut button_fsm = Button::default(); + + let mut wifi = BlockingWifi::wrap( + EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?, + sys_loop, + )?; + // WiFi 是尽力而为:**连不上也绝不能挡住受击检测与灯效**。一块因为没网就不亮 + // 灯的装甲板,在场上比"裁判端看不到血量"糟糕得多。所以这里只报警,主循环照跑, + // 由看门狗每 5 s 重试。 + if let Err(err) = join(&mut wifi, Duration::from_secs(10)) { + warn!("no referee link yet ({err:#}); hits and LEDs still work, retrying every {WIFI_CHECK_MS} ms"); + } + + let reporter = net::Reporter::new()?; + let sender_id = net::local_id(); + + let mut hits: Vec<(Plate, u16)> = Vec::with_capacity(16); + let mut reports = ReportSchedule::new(Duration::from_millis(REPORT_HEARTBEAT_MS)); + let mut last_status_ms = now_ms(); + let mut last_wifi_check_ms = now_ms(); + // 启动即发一次:裁判端不用等到第一次受击才知道板子在线。 + let mut force_report = true; + // 上报统计,状态行里打出来:现场"裁判端没显示"时,第一件事是分清是链路问题 + // 还是对端问题。计数在涨就说明包已经出去了。 + let mut reports_sent: u64 = 0; + let mut report_errors: u64 = 0; + // 上一次状态行以来每路 ADC 的峰值(校准阈值用),打印后清零。 + let mut peak = [0u16; 4]; + + info!("entering the main loop: long-press BOOT to reset hp, short-press to print status"); + + loop { + let now = Duration::from_millis(now_ms()); + + // 采样:一次读一帧(约 10 ms),这一帧就是主循环的节拍。 + match piezo.read_frame(armor.rules().threshold, &mut hits) { + Ok(stats) => { + for (slot, value) in stats.peak.iter().enumerate() { + peak[slot] = peak[slot].max(*value); + } + + for (plate, raw) in hits.iter().copied() { + if let Some(event) = armor.on_sample(plate, raw, now) { + info!( + "hit plate={} raw={} hp={} (hits={})", + event.plate.name(), + event.raw, + event.hp, + armor.hits() + ); + if event.dead { + warn!("hp reached 0: all four strips stay dark until the next reset"); + } + } + } + } + Err(err) => { + warn!("adc frame failed: {err:#}"); + // 读失败时别把循环变成没有节拍的忙等。 + std::thread::sleep(Duration::from_millis(10)); + } + } + + // 灯带:只有内容变化的那一路才真的发 RMT(每秒另有一次强制全刷)。 + let strips = armor.strips(now); + if let Err(err) = leds.refresh(strips, now_ms()) { + leds::log_failure(&err); + } + + // 按键。BOOT 低有效,所以"按下" = 读到低电平。 + match button_fsm.tick(button.is_low(), now) { + ButtonEvent::LongPress => { + armor.reset(); + warn!("BOOT long press: hp reset to {} (new round)", armor.hp()); + force_report = true; + } + ButtonEvent::Click => { + info!( + "status: {}", + status_with_link(&armor, now, reports_sent, report_errors, peak) + ); + force_report = true; + } + ButtonEvent::None => {} + } + + // 上报:血量一变立刻发,其余按心跳重发。 + let current_ms = now_ms(); + if force_report || reports.due(armor.hp(), now) { + match broadcast_health(&reporter, &armor, team, sender_id) { + Ok(()) => { + reports.mark_sent(armor.hp(), now); + reports_sent = reports_sent.saturating_add(1); + } + Err(err) => { + report_errors = report_errors.saturating_add(1); + warn!("report failed: {err:#}"); + } + } + force_report = false; + } + + // 状态行:现场不看串口也能从日志确认板子活着、血量是多少、链路通不通。 + if current_ms.saturating_sub(last_status_ms) >= STATUS_INTERVAL_MS { + last_status_ms = current_ms; + info!( + "status: {}", + status_with_link(&armor, now, reports_sent, report_errors, peak) + ); + // 峰值是"上一秒"的量,打完就清零,这样现场能一眼看出没敲时的底噪。 + peak = [0u16; 4]; + } + + // WiFi 看门狗:**关联上 + 拿到 IP** 才算链路好。 + // + // 判断不用 `BlockingWifi::is_connected()/is_up()`:那两个读的是 esp-idf-svc + // 自己的事件缓存,实测在 IDF v6.0.1 上会停留在"已连接",而 IDF 的 API 明明 + // 说没关联(现场表现就是日志说一切正常、裁判端一个包都收不到)。所以这里 + // 直接问 IDF:`esp_wifi_sta_get_ap_info()` 与 `esp_netif_get_ip_info()`。 + // + // 重连时只给 3 s 等 IP(启动时给的 10 s):这里是主循环,等太久会漏掉受击。 + if current_ms.saturating_sub(last_wifi_check_ms) >= WIFI_CHECK_MS { + last_wifi_check_ms = current_ms; + + let link = link_state(); + let ip = sta_ip(); + let healthy = link.associated() && ip.is_some_and(|ip| !ip.is_unspecified()); + + if !healthy { + warn!("link is not usable ({}); reconnecting", describe_link(&link, ip)); + + // 断开再连:关联掉了或 DHCP 没下来时,只有重新来一遍才会好。 + if let Err(err) = wifi.disconnect() { + warn!("disconnect failed: {err}"); + } + if let Err(err) = join(&mut wifi, Duration::from_secs(3)) { + warn!("reconnect failed: {err:#}"); + } + } + } + } +} + +/// 广播四块装甲板的血量给裁判终端。 +/// +/// 四条数据报的 `hp` 相同(整车一条血量),`index` 分别是 0..3 —— 裁判端已有的 +/// 四格血量界面无需改动。`sender_id` 用 MAC 派生的板子 id,方便抓包时分辨是谁发的。 +fn broadcast_health( + reporter: &net::Reporter, + armor: &Armor, + team: u8, + sender_id: u32, +) -> Result<()> { + for datagram in armor.datagrams_from(team, sender_id) { + reporter.broadcast(&datagram)?; + } + Ok(()) +} + +/// 状态行 + 当场问出来的链路状态。 +/// +/// 每次都现算而不是缓存:现场排查时最怕看到的是过期的好消息。 +fn status_with_link( + armor: &Armor, + now: Duration, + reports_sent: u64, + report_errors: u64, + peak: [u16; 4], +) -> String { + status_line( + armor, + now, + &link_line(&link_state(), sta_ip(), reports_sent, report_errors), + peak, + ) +} + +/// 一行状态,给 UART 调试口用。 +/// +/// `peak` 是这一秒里每路 ADC 的峰值。它比"有没有受击"更有用:现场要判断阈值该调到 +/// 多少,靠的就是"没敲时的底噪"和"敲击时的幅度"这两个数字。 +fn status_line(armor: &Armor, now: Duration, link: &str, peak: [u16; 4]) -> String { + let counts = armor.plate_hits_all(); + let per_plate = Plate::ALL + .iter() + .map(|plate| format!("{}={}", plate.name(), counts[plate.index() as usize])) + .collect::>() + .join(" "); + let peaks = Plate::ALL + .iter() + .map(|plate| format!("{}={}", plate.name(), peak[plate.index() as usize])) + .collect::>() + .join(" "); + + format!( + "hp={} hits={} dead={} plates[{}] peak[{}] thr={} strips[{}] {link}", + armor.hp(), + armor.hits(), + armor.dead(), + per_plate, + peaks, + armor.rules().threshold, + leds::describe(armor.strips(now)), + ) +} + +/// 链路那半截状态:连的是哪个 AP、信号多强、有没有 IP、上报发了多少包。 +/// +/// 现场"裁判端没显示"时,第一件事是分清链路问题还是对端问题:`reports` 在涨就说明 +/// 包已经出去了,该去查裁判端;一直不动就是这块板子没连上。而"没连上"还要再分 +/// 关联与 DHCP —— 关联上但 IP 是 0.0.0.0 是最容易看错的一种。 +fn link_line(link: &Link, ip: Option, reports_sent: u64, report_errors: u64) -> String { + format!( + "{} reports={reports_sent} errs={report_errors}", + describe_link(link, ip) + ) +} + +/// 当前关联状态。**来源是 IDF 的 API**,不是 esp-idf-svc 的事件缓存(见看门狗注释)。 +#[derive(Debug, Clone)] +enum Link { + /// 关联上了:AP 名字、RSSI、信道。 + Associated { + ssid: String, + rssi: i8, + channel: u8, + }, + /// 没关联(或者 IDF 说 STA 没在跑)。 + Idle { rc: i32 }, +} + +impl Link { + #[must_use] + fn associated(&self) -> bool { + matches!(self, Link::Associated { .. }) + } +} + +/// 问一次 IDF:STA 关联到哪个 AP 了。 +fn link_state() -> Link { + let mut record = esp_idf_svc::sys::wifi_ap_record_t::default(); + + // SAFETY: record 是本函数的局部量,IDF 只往里写;未关联时返回非 OK 而不动它。 + let rc = unsafe { esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut record) }; + if rc != esp_idf_svc::sys::ESP_OK { + return Link::Idle { rc }; + } + + // ssid 是定长 33 字节、以 0 结尾的缓冲区。 + let ssid = unsafe { + core::ffi::CStr::from_ptr(record.ssid.as_ptr() as *const core::ffi::c_char) + .to_string_lossy() + .into_owned() + }; + + Link::Associated { + ssid, + rssi: record.rssi, + channel: record.primary, + } +} + +/// STA 网卡当前的 IPv4 地址(直接从 `esp_netif` 读,绕开 esp-idf-svc 的封装)。 +/// +/// `None` 表示还没拿到 DHCP 地址。0.0.0.0 也按 `None` 处理 —— 那等于没有地址。 +fn sta_ip() -> Option { + // 默认 STA 网卡的 interface key(`esp_netif_create_default_wifi_sta` 用的就是它)。 + let handle = unsafe { + esp_idf_svc::sys::esp_netif_get_handle_from_ifkey(c"WIFI_STA_DEF".as_ptr()) + }; + if handle.is_null() { + return None; + } + + let mut info = esp_idf_svc::sys::esp_netif_ip_info_t::default(); + // SAFETY: info 是局部量,handle 来自上面的查询。 + if unsafe { esp_idf_svc::sys::esp_netif_get_ip_info(handle, &mut info) } + != esp_idf_svc::sys::ESP_OK + { + return None; + } + + // lwIP 的 `ip4_addr_t.addr` 是网络字节序,所以最低字节是点分表示的第一段。 + let raw = info.ip.addr; + let ip = std::net::Ipv4Addr::new( + (raw & 0xFF) as u8, + ((raw >> 8) & 0xFF) as u8, + ((raw >> 16) & 0xFF) as u8, + ((raw >> 24) & 0xFF) as u8, + ); + + if ip.is_unspecified() { + None + } else { + Some(ip) + } +} + +/// 链路状态的一行描述,日志与状态行共用。 +fn describe_link(link: &Link, ip: Option) -> String { + let ip = match ip { + Some(ip) => ip.to_string(), + None => "none".to_string(), + }; + + match link { + Link::Associated { + ssid, + rssi, + channel, + } => format!("ap={ssid} rssi={rssi} ch={channel} ip={ip}"), + Link::Idle { rc } => format!("ap=none (rc=0x{rc:x}) ip={ip}"), + } +} + +/// 等到 STA 真的拿到 IP(权威来源),最多等 `timeout`。 +/// +/// 不用 `BlockingWifi::wait_netif_up()`:它读的是 esp-idf-svc 的事件缓存,在 +/// IDF v6.0.1 上会"立刻成功"而实际没有地址 —— 那样 join() 会假装连上了,看门狗 +/// 也就永远不会重试。 +fn wait_for_ip(timeout: Duration) -> Option { + let deadline = std::time::Instant::now() + timeout; + + loop { + if let Some(ip) = sta_ip() { + return Some(ip); + } + + if std::time::Instant::now() >= deadline { + return None; + } + + std::thread::sleep(Duration::from_millis(200)); + } +} + +/// 连上 WiFi 并等一个 IP(幂等:已启动就不再 start 一次)。 +/// +/// 连不上时把扫到的网络打出来:这块模块只有 2.4 GHz,SSID 若在 5 GHz 上,现场看到 +/// 一张空列表比看一个关联超时有用得多。**调用方不应该因为这个错误就退出** —— 没有 +/// 网也要能打灯。 +fn join(wifi: &mut BlockingWifi>, ip_timeout: Duration) -> Result<()> { + if !wifi.is_started()? { + wifi.start()?; + } + + if !link_state().associated() { + let config = Configuration::Client(ClientConfiguration { + ssid: SSID + .try_into() + .map_err(|_| anyhow!("WIFI_SSID too long (max 32 bytes)"))?, + bssid: None, + auth_method: default_auth_method(), + password: PASSWORD + .try_into() + .map_err(|_| anyhow!("WIFI_PASS too long (max 64 bytes)"))?, + channel: None, + // 通告 PMF:WPA2/WPA3 混合模式的路由器否则会丢掉握手;`required: false` + // 仍然允许纯 WPA2 的 AP。 + pmf_cfg: PmfConfiguration::Capable { required: false }, + ..Default::default() + }); + + wifi.set_configuration(&config)?; + + if let Err(err) = wifi.connect() { + error!("could not join \"{SSID}\": {err}"); + report_scan(wifi); + return Err(err.into()); + } + } + + // 省电模式换来的电流不值得:它会让上报晚一个信标间隔(上百毫秒),而"受击后 + // 50 ms 变暗"这件事本来就是要给人即时反馈的。 + let rc = unsafe { + esp_idf_svc::sys::esp_wifi_set_ps(esp_idf_svc::sys::wifi_ps_type_t_WIFI_PS_NONE) + }; + if rc != esp_idf_svc::sys::ESP_OK { + warn!("could not disable wifi power save ({})", esp::name(rc)); + } + + let link = link_state(); + + let Some(ip) = wait_for_ip(ip_timeout) else { + report_scan(wifi); + return Err(anyhow!( + "no DHCP address within {} ms ({})", + ip_timeout.as_millis(), + describe_link(&link, None) + )); + }; + + info!("joined \"{SSID}\" on {ip}"); + Ok(()) +} + +/// 没写 `WIFI_PASS` 就是开放网络。 +fn default_auth_method() -> AuthMethod { + if PASSWORD.is_empty() { + AuthMethod::None + } else { + AuthMethod::WPA2Personal + } +} + +/// 把扫到的网络打出来,并标出我们要连的那个。连不上时的第一诊断信息。 +fn report_scan(wifi: &mut BlockingWifi>) { + match wifi.scan() { + Ok(aps) if aps.is_empty() => { + error!("scan found no networks; check the antenna and that the AP is on 2.4 GHz"); + } + Ok(aps) => { + for ap in aps { + info!( + " {:<32} ch {:>2} {:>4} dBm {:?}{}", + ap.ssid, + ap.channel, + ap.signal_strength, + ap.auth_method, + if ap.ssid.as_str() == SSID { " <- target" } else { "" } + ); + } + } + Err(err) => warn!("scan failed: {err}"), + } +} diff --git a/armor/armor-firmware/src/net.rs b/armor/armor-firmware/src/net.rs new file mode 100644 index 0000000..1dbf6cb --- /dev/null +++ b/armor/armor-firmware/src/net.rs @@ -0,0 +1,142 @@ +//! 上报通道:把 [`ArmorHealth`](armor_protocol::ArmorHealth) 广播给本项目裁判终端。 +//! +//! # 为什么直接写 lwIP 的 socket +//! +//! 与同仓库的图传固件(`src/udp.rs`)一样:`std::net` 在 lwIP 上的行为要靠一层 +//! VFS 转接,而这里只需要"建一个 UDP socket + 广播"这一件事,直接用 IDF 原生的 +//! `lwip_*` 就能与图传板跑在同一条被验证过的代码路径上。 +//! +//! # 为什么不 bind +//! +//! 装甲板只发不收:血量是板子算出来的,裁判端只显示。所以 socket 不绑定端口, +//! 由 lwIP 选一个临时端口即可 —— 绑定 5601 反而会和裁判端、以及同网段的另一块 +//! 装甲板抢同一个端口。裁判端监听 5601,板子往 5601 发,方向是单向的。 +//! +//! # 广播而不是单播 +//! +//! 裁判终端的地址是 DHCP 变的(图传板的 Hello 广播就是为了绕开这件事)。装甲板 +//! 沿用同一个约定:不知道也不关心裁判端在哪,反正同一个局域网里它会收到。 + +use core::ffi::c_void; +use std::net::SocketAddrV4; + +use anyhow::{anyhow, Result}; +use esp_idf_svc::sys as ffi; +use log::{info, warn}; + +use armor_protocol::{ARMOR_PORT, BROADCAST_ADDR}; + +/// 把 Rust 的 socket 地址转成 lwIP 期望的 C 结构。 +/// +/// 逐字段构造而不是 `transmute`:`SocketAddrV4` 是紧凑的 6 字节(端口在前、主机 +/// 字节序),C 的 `sockaddr_in` 是 16 字节且地址是网络字节序 —— 指针强转会发到一个 +/// 乱地址,而且不会报错。 +fn to_sockaddr(addr: SocketAddrV4) -> ffi::sockaddr_in { + let octets = addr.ip().octets(); + + ffi::sockaddr_in { + sin_len: core::mem::size_of::() as u8, + sin_family: ffi::AF_INET as ffi::sa_family_t, + // 用 lwIP 的 htons,字节序不必在 Rust 侧推理。 + sin_port: unsafe { ffi::lwip_htons(addr.port()) }, + sin_addr: ffi::in_addr { + s_addr: u32::from_le_bytes(octets), + }, + sin_zero: [0; 8], + } +} + +/// 本机 id:由 WiFi MAC 派生(FNV-1a),重启不变,且避开 0 —— 0 留给裁判终端。 +pub fn local_id() -> u32 { + let mut mac = [0u8; 6]; + + // SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。 + let rc = unsafe { ffi::esp_read_mac(mac.as_mut_ptr(), ffi::esp_mac_type_t_ESP_MAC_WIFI_STA) }; + + if rc != ffi::ESP_OK { + warn!("could not read the WiFi MAC ({:#x}); using a fixed id", rc); + return 0xF1F0_0001; + } + + let id = armor_protocol::id_from_mac(&mac); + info!("this board's id is 0x{id:08X}, from MAC {mac:02X?}"); + id +} + +/// 广播上报器。 +pub struct Reporter { + socket: i32, +} + +impl Reporter { + /// 建一个开了广播许可的 UDP socket。 + pub fn new() -> Result { + // SAFETY: 建 socket 不涉及 Rust 内存。 + let socket = unsafe { ffi::lwip_socket(ffi::AF_INET as i32, ffi::SOCK_DGRAM as i32, 0) }; + if socket < 0 { + // 用 std 的 errno 读取:`__errno` 不是每个目标的绑定里都有,而 lwIP 的 + // socket 错误码就是同一个 errno。 + return Err(anyhow!( + "could not create UDP socket: {}", + std::io::Error::last_os_error() + )); + } + + // 目标地址是 255.255.255.255,不开这个选项 sendto 会返回 EACCES。默认就是 + // 关的,而且失败不会自己冒出来 —— 只会表现为"裁判端一直显示未接入"。 + let enable: i32 = 1; + // SAFETY: optval 指向栈上的 i32,optlen 与它一致。 + let rc = unsafe { + ffi::lwip_setsockopt( + socket, + ffi::SOL_SOCKET as i32, + ffi::SO_BROADCAST as i32, + &enable as *const i32 as *const c_void, + core::mem::size_of::() as u32, + ) + }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + // SAFETY: 关闭本函数刚建的 socket。 + unsafe { ffi::lwip_close(socket) }; + return Err(anyhow!("could not enable SO_BROADCAST: {err}")); + } + + info!("reporting to {BROADCAST_ADDR}:{ARMOR_PORT} over UDP"); + + Ok(Self { socket }) + } + + /// 把一个数据报广播到裁判终端。 + pub fn broadcast(&self, payload: &[u8]) -> Result<()> { + let addr = to_sockaddr(SocketAddrV4::new(BROADCAST_ADDR, ARMOR_PORT)); + + // SAFETY: 缓冲区与地址在调用期间都有效;sendto 复制数据而不是持有指针。 + let rc = unsafe { + ffi::lwip_sendto( + self.socket, + payload.as_ptr() as *const c_void, + payload.len(), + ffi::MSG_DONTWAIT as i32, + &addr as *const ffi::sockaddr_in as *const ffi::sockaddr, + core::mem::size_of::() as ffi::socklen_t, + ) + }; + + if rc < 0 { + return Err(anyhow!( + "broadcast failed: {}", + std::io::Error::last_os_error() + )); + } + + Ok(()) + } +} + +impl Drop for Reporter { + fn drop(&mut self) { + // SAFETY: 关闭本结构体自己建的 socket。 + unsafe { ffi::lwip_close(self.socket) }; + } +} diff --git a/armor/armor-firmware/src/piezo.rs b/armor/armor-firmware/src/piezo.rs new file mode 100644 index 0000000..38c1061 --- /dev/null +++ b/armor/armor-firmware/src/piezo.rs @@ -0,0 +1,213 @@ +//! 四路压电片的 ADC 连续采样(ESP32-C3)。 +//! +//! # 为什么要连续(DMA)采样 +//! +//! 压电片被敲一下的波形只有几十微秒宽。用 oneshot 轮询(每圈读一次)几乎必然从 +//! 尖峰旁边擦过去,表现就是"有时候敲得响才算";参考实现因此用 40 kHz 的连续采样, +//! 这里保持一致:四个通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个采样点。 +//! +//! # 为什么也走 sys 绑定 +//! +//! `esp-idf-hal` 的 `adc::continuous` 要用 `AdcChannels` 组合通道,而多通道组合的 +//! 入口 `EmptyAdcChannels::chain` 在 0.47 里的约束是 `A: Adc` 且 +//! `O: AdcChannels` —— `ADC1` 满足前者、`ADCU1` 满足后者,同一个类型 +//! 参数凑不齐,四路 ADC1 通道拼不出来。直接照着 IDF 的 C API 建驱动反而更短,也 +//! 与参考实现逐行对应。 +//! +//! 采样点的通道号由硬件结果自带(`type2.channel`),所以映射不依赖"结果按 pattern +//! 顺序轮转"这个假设。 + +use anyhow::Result; +use esp_idf_svc::sys as ffi; +use log::info; + +use armor_protocol::Plate; + +use crate::esp; + +/// 四个通道合计的采样率;每通道 10 kHz。 +const SAMPLE_FREQ_HZ: u32 = 40_000; + +/// 一帧的采样点数:10 ms × 40 kHz。 +pub const FRAME_MEASUREMENTS: usize = 400; + +/// 一帧的字节数(每个结果 4 字节)。 +const FRAME_BYTES: u32 = FRAME_MEASUREMENTS as u32 * ffi::SOC_ADC_DIGI_RESULT_BYTES; + +/// 连续读的超时:正常情况 10 ms 就凑满一帧,200 ms 只用来发现"驱动卡住了"。 +const READ_TIMEOUT_MS: u32 = 200; + +/// 每个板位对应的 ADC 通道号(ESP32-C3:GPIO0/4/3/1 = ADC1_CH0/CH4/CH3/CH1)。 +/// +/// 顺序与 [`Plate`] 的 index 一致;硬件结果里的通道号就是这些值。 +const PLATE_CHANNEL: [u8; 4] = [0, 4, 3, 1]; + +/// 一帧的统计:读到多少采样点,以及每个板位的峰值。 +/// +/// 峰值是给**现场校准阈值**用的:压电片的输出幅度取决于贴装、线长和有没有放大, +/// 只报"有没有过阈值"没法判断该把阈值调到哪里。status 行每秒打印一次。 +#[derive(Debug, Clone, Copy, Default)] +pub struct FrameStats { + /// 这一帧实际读到的采样点数。 + pub samples: usize, + /// 每个板位的最大原始 ADC 值,按 [`Plate`] 的 index 排列。 + pub peak: [u16; 4], +} + +/// 连续采样的压电片输入。 +pub struct Piezo { + handle: ffi::adc_continuous_handle_t, + /// 一帧的原始结果。`adc_digi_output_data_t` 里有 union,bindgen 不给 `Default`, + /// 所以用零值显式初始化(这个类型是纯 POD,零值是合法状态)。 + frame: Box<[ffi::adc_digi_output_data_t; FRAME_MEASUREMENTS]>, +} + +impl Piezo { + /// 建驱动并开始采样。 + pub fn new() -> Result { + let handle_config = ffi::adc_continuous_handle_cfg_t { + // 两帧的余量:这一帧还没读完时,下一帧有地方落。 + max_store_buf_size: FRAME_BYTES * 2, + conv_frame_size: FRAME_BYTES, + ..Default::default() + }; + + let mut handle: ffi::adc_continuous_handle_t = core::ptr::null_mut(); + esp::check( + "adc_continuous_new_handle", + // SAFETY: 两个指针都指向本函数的局部量,调用期间有效。 + unsafe { ffi::adc_continuous_new_handle(&handle_config, &mut handle) }, + )?; + + // 四路通道的采样参数:0 dB 衰减、12 bit、都在 ADC1。 + let mut patterns = [ffi::adc_digi_pattern_config_t { + atten: ffi::adc_atten_t_ADC_ATTEN_DB_0 as u8, + channel: 0, + unit: ffi::adc_unit_t_ADC_UNIT_1 as u8, + bit_width: 12, + }; 4]; + + for (pattern, channel) in patterns.iter_mut().zip(PLATE_CHANNEL) { + pattern.channel = channel; + } + + let config = ffi::adc_continuous_config_t { + pattern_num: patterns.len() as u32, + adc_pattern: patterns.as_mut_ptr(), + sample_freq_hz: SAMPLE_FREQ_HZ, + conv_mode: ffi::adc_digi_convert_mode_t_ADC_CONV_SINGLE_UNIT_1, + // C3 的连续采样只支持 12 bit 的 TYPE2 结果布局,也正好对应下面读的 + // `type2.data`(参考实现依赖了默认值 0,这里写明确)。 + format: ffi::adc_digi_output_format_t_ADC_DIGI_OUTPUT_FORMAT_TYPE2, + ..Default::default() + }; + + if let Err(err) = esp::check( + "adc_continuous_config", + // SAFETY: IDF 会把 patterns 复制进驱动,调用后不再持有这个指针。 + unsafe { ffi::adc_continuous_config(handle, &config) }, + ) { + // SAFETY: 句柄刚建好、还没 start,可以直接删。 + unsafe { ffi::adc_continuous_deinit(handle) }; + return Err(err); + } + + esp::check("adc_continuous_start", unsafe { + ffi::adc_continuous_start(handle) + })?; + + let zero = ffi::adc_digi_output_data_t { + __bindgen_anon_1: ffi::adc_digi_output_data_t__bindgen_ty_1 { val: 0 }, + }; + + info!( + "piezo: {SAMPLE_FREQ_HZ} Hz total over {} channels, {} measurements/frame (10 ms)", + PLATE_CHANNEL.len(), + FRAME_MEASUREMENTS + ); + + Ok(Self { + handle, + frame: Box::new([zero; FRAME_MEASUREMENTS]), + }) + } + + /// 读一帧(约 10 ms),把过阈值的采样点收集进 `hits`。 + /// + /// `hits` 会被清空后填充 `(板位, 原始值)`;返回值里带这一帧的采样点数与每路峰值。 + /// **去抖与扣血不在这里做** —— 那是 + /// [`armor_protocol::Armor::on_sample`] 的职责,一次击打的多个过阈值采样点由它 + /// 的冷却窗口收敛成一次受击。 + pub fn read_frame( + &mut self, + threshold: u16, + hits: &mut Vec<(Plate, u16)>, + ) -> Result { + hits.clear(); + let mut stats = FrameStats::default(); + + let mut out_length: u32 = 0; + let rc = unsafe { + ffi::adc_continuous_read( + self.handle, + self.frame.as_mut_ptr() as *mut u8, + FRAME_BYTES, + &mut out_length, + READ_TIMEOUT_MS, + ) + }; + + if rc == ffi::ESP_ERR_TIMEOUT { + // 没有数据不算错误:上一帧还没凑满,下一圈再来。 + return Ok(stats); + } + esp::check("adc_continuous_read", rc)?; + + let count = (out_length as usize / ffi::SOC_ADC_DIGI_RESULT_BYTES as usize) + .min(FRAME_MEASUREMENTS); + stats.samples = count; + + for index in 0..count { + // SAFETY: 配置的是 TYPE2(12 bit)格式,`type2` 是有效成员;每个结果 + // 4 字节且已 4 字节对齐。 + let result = unsafe { self.frame[index].__bindgen_anon_1.type2 }; + let raw = result.data() as u16; + + let Some(plate) = plate_of_channel(result.channel() as u8) else { + continue; + }; + + // 峰值取自**所有**采样点,不只是过阈值的:阈值调得对不对,要靠没敲时 + // 的底噪和敲击时的幅度两个数字一起看。 + let slot = plate.index() as usize; + if raw > stats.peak[slot] { + stats.peak[slot] = raw; + } + + if raw > threshold { + hits.push((plate, raw)); + } + } + + Ok(stats) + } +} + +impl Drop for Piezo { + fn drop(&mut self) { + // 固件里 main 永不返回;这段只在启动失败路径上跑到。 + unsafe { + if !self.handle.is_null() { + ffi::adc_continuous_stop(self.handle); + ffi::adc_continuous_deinit(self.handle); + } + } + } +} + +/// 硬件结果里的通道号 → 板位。 +#[must_use] +fn plate_of_channel(channel: u8) -> Option { + let slot = PLATE_CHANNEL.iter().position(|c| *c == channel)?; + Plate::ALL.get(slot).copied() +} diff --git a/armor/armor-firmware/src/settings.rs b/armor/armor-firmware/src/settings.rs new file mode 100644 index 0000000..08a43fd --- /dev/null +++ b/armor/armor-firmware/src/settings.rs @@ -0,0 +1,65 @@ +//! 板子自己的持久化设置(NVS):队别与颜色。 +//! +//! 命名空间与键名都沿用参考实现(`armor` / `color`),所以从参考固件刷过来时, +//! 之前存过的颜色还能被读到。新增一个 `team` 键用来记住这块板子是哪一队。 + +use anyhow::Result; +use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault}; +use log::info; + +use armor_protocol::Rgb; + +/// NVS 命名空间(与参考实现一致)。 +const NAMESPACE: &str = "armor"; +/// 队别键名。 +const KEY_TEAM: &str = "team"; +/// 颜色键名,值是 `0x00RRGGBB`(与参考实现一致)。 +const KEY_COLOR: &str = "color"; + +/// 打开着的 NVS 句柄。 +pub struct Settings { + nvs: EspNvs, +} + +impl Settings { + /// 打开(必要时创建)命名空间。 + pub fn new(partition: EspDefaultNvsPartition) -> Result { + Ok(Self { + nvs: EspNvs::new(partition, NAMESPACE, true)?, + }) + } + + /// 定出这次该用哪个颜色,并把它写回 NVS。 + /// + /// 规则:**编译期注入的 `ARMOR_TEAM` 是权威**。换队伍只要重刷固件,不用先擦 + /// NVS;NVS 里的值只用来记住当前状态(也给以后加"手动换色"留位置)。两者 + /// 不一致时以 `ARMOR_TEAM` 为准并覆盖,日志里会说一声,免得现场纳闷为什么 + /// 灯色变了。 + pub fn resolve_color(&self, team: u8) -> Result { + let expected = Rgb::for_team(team); + let stored_team = self.nvs.get_u8(KEY_TEAM)?; + let stored_color = self.nvs.get_u32(KEY_COLOR)?; + + let color = match (stored_team, stored_color) { + (Some(stored), Some(raw)) if stored == team => Rgb::from_u32(raw), + (Some(stored), Some(raw)) => { + info!( + "ARMOR_TEAM={team} overrides the stored team {stored} (colour #{raw:06X}); rewriting NVS" + ); + expected + } + _ => { + info!( + "first boot as team {team}: storing colour #{:06X}", + expected.to_u32() + ); + expected + } + }; + + self.nvs.set_u8(KEY_TEAM, team)?; + self.nvs.set_u32(KEY_COLOR, color.to_u32())?; + + Ok(color) + } +} diff --git a/armor/armor-protocol/.cargo/config.toml b/armor/armor-protocol/.cargo/config.toml new file mode 100644 index 0000000..387575d --- /dev/null +++ b/armor/armor-protocol/.cargo/config.toml @@ -0,0 +1,7 @@ +# 这个 crate 在主机上跑单元测试,不交叉编译。 +# +# 它坐在 armor/ 目录树里,cargo 会向上继承 armor/.cargo/config.toml 的 RISC-V +# 目标与 ldproxy linker —— 把主机测试编译成 ESP32-C3 固件会直接失败在链接上。 +# 把 target 覆盖回 host triple 就够了,别的键对非 ESP 目标没有影响。 +[build] +target = "aarch64-apple-darwin" diff --git a/armor/armor-protocol/Cargo.toml b/armor/armor-protocol/Cargo.toml new file mode 100644 index 0000000..dca6c59 --- /dev/null +++ b/armor/armor-protocol/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "armor-protocol" +version = "0.1.0" +edition = "2021" + +# 只依赖错误检查用的东西:规则层是纯计算(血量、去抖、按键状态机), +# 这样它能在 PC 上跑单元测试,不需要 ESP32-C3 硬件。 +[dependencies] +protocol = { path = "../../protocol" } diff --git a/armor/armor-protocol/src/lib.rs b/armor/armor-protocol/src/lib.rs new file mode 100644 index 0000000..a8716f1 --- /dev/null +++ b/armor/armor-protocol/src/lib.rs @@ -0,0 +1,872 @@ +//! 装甲板规则层:板位、整车血量、受击判定、按键状态机、上报帧。 +//! +//! # 为什么和固件分开 +//! +//! 这一层全是纯计算:一次采样算不算新受击、扣几滴血、灯带这一刻该显示什么颜色、 +//! 按一下键算短按还是长按。这些恰好是最容易写错、又最不适合"烧上去看现象"的部分 +//! —— 在 ESP32-C3 上,受击是几微秒的压电片尖峰,现场根本没法复现"冷却期内的第二次 +//! 采样被误判"这类 bug。放在这里就能用 `Duration` 喂假时间来测。 +//! +//! 固件侧(`armor-firmware`)只负责把外设接到这层:ADC 采样值、GPIO 电平、毫秒 +//! 时基、UDP 发送。 +//! +//! # 硬件形态(一主三从) +//! +//! **只有主板一颗 MCU**:三块"从"装甲板里没有单片机,只有一路 WS2812 和一路压电片, +//! 直接接到主板上。所以这一层同时管四路: +//! +//! | 板位 | WS2812 | 压电片 ADC | +//! | --- | --- | --- | +//! | 前 front | GPIO10 | ADC1_CH0 (GPIO0) | +//! | 后 rear | GPIO5 | ADC1_CH4 (GPIO4) | +//! | 左 left | GPIO6 | ADC1_CH3 (GPIO3) | +//! | 右 right | GPIO7 | ADC1_CH1 (GPIO1) | +//! +//! # 规则(已与本项目裁判端对齐) +//! +//! - 整车一条血量:初始 100,每次受击 −3,减到 0 为止**不变负**,血量 0 时四路灯带 +//! 全黑("死了全黑")。 +//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动;50 ms 同时是同一板位的 +//! 重复判定冷却。 +//! - 颜色由队伍决定:队伍一红、队伍二蓝(RoboMaster 惯例)。 +//! - 血量账在板子上算,按 [`protocol::ArmorHealth`] 上报给本项目裁判终端(UDP 5601)。 +//! 整车一条血量 → 四条上报的 `hp` 相同,`index` 分别是 0..3,裁判端已有的四格血量 +//! 界面无需改动。 + +use std::time::Duration; + +pub use protocol::{id_from_mac, ArmorHealth, ARMOR_PORT, BROADCAST_ADDR, MsgType}; + +/// 队伍一。 +pub const TEAM_1: u8 = 0; +/// 队伍二。 +pub const TEAM_2: u8 = 1; + +/// 一块装甲板。 +/// +/// 判别值就是上报里的 `index`(0..3),所以 `Plate::Front.index() == 0` 与裁判端 +/// 的四格顺序一一对应。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum Plate { + /// 前装甲板,WS2812 = GPIO10,压电片 = ADC1_CH0。 + Front = 0, + /// 后装甲板,WS2812 = GPIO5,压电片 = ADC1_CH4。 + Rear = 1, + /// 左装甲板,WS2812 = GPIO6,压电片 = ADC1_CH3。 + Left = 2, + /// 右装甲板,WS2812 = GPIO7,压电片 = ADC1_CH1。 + Right = 3, +} + +impl Plate { + /// 按 index 顺序的四块板,也是灯带刷新和 ADC 通道表里用的顺序。 + pub const ALL: [Plate; 4] = [Plate::Front, Plate::Rear, Plate::Left, Plate::Right]; + + /// 上报里的 `index`。 + #[must_use] + pub const fn index(self) -> u8 { + self as u8 + } + + /// 日志里用的短名(英文,和本仓库其余固件的日志一致)。 + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Plate::Front => "front", + Plate::Rear => "rear", + Plate::Left => "left", + Plate::Right => "right", + } + } + + /// 从 `index` 还原,越界返回 `None`。 + #[must_use] + pub const fn from_index(value: u8) -> Option { + match value { + 0 => Some(Plate::Front), + 1 => Some(Plate::Rear), + 2 => Some(Plate::Left), + 3 => Some(Plate::Right), + _ => None, + } + } + + /// 数组下标。 + #[must_use] + const fn slot(self) -> usize { + self as usize + } +} + +/// 颜色,`0x00RRGGBB`(与参考实现里 `Adafruit_NeoPixel::Color(r, g, b)` 一致)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl Rgb { + /// 队伍一的颜色。 + pub const RED: Rgb = Rgb(0xFF, 0x00, 0x00); + /// 队伍二的颜色。 + pub const BLUE: Rgb = Rgb(0x00, 0x00, 0xFF); + /// 全黑,也就是熄灭。 + pub const BLACK: Rgb = Rgb(0x00, 0x00, 0x00); + + /// 队伍对应的颜色:队伍二蓝,其余(含误配的值)红。 + #[must_use] + pub const fn for_team(team: u8) -> Rgb { + if team == TEAM_2 { + Rgb::BLUE + } else { + Rgb::RED + } + } + + /// 打包成 `0x00RRGGBB`,用于写 NVS 或日志。 + #[must_use] + pub const fn to_u32(self) -> u32 { + ((self.0 as u32) << 16) | ((self.1 as u32) << 8) | (self.2 as u32) + } + + /// 从 `0x00RRGGBB` 还原。 + #[must_use] + pub const fn from_u32(value: u32) -> Rgb { + Rgb( + ((value >> 16) & 0xFF) as u8, + ((value >> 8) & 0xFF) as u8, + (value & 0xFF) as u8, + ) + } + + /// WS2812 的线上字节序:GRB,MSB first。 + /// + /// 交错顺序错了不会报错,只会显示成另一种颜色(红蓝互换),所以单独有测试钉住。 + #[must_use] + pub const fn to_grb(self) -> [u8; 3] { + [self.1, self.0, self.2] + } +} + +/// 一条灯带这一刻要显示的内容。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Strip { + /// 正常颜色。 + Color(Rgb), + /// 全黑:受击的 50 ms 窗口内,或者整车血量已经归零。 + Black, +} + +impl Strip { + /// 是否熄灭。 + #[must_use] + pub const fn is_black(self) -> bool { + matches!(self, Strip::Black) + } + + /// 实际颜色,`Black` 就是 [`Rgb::BLACK`]。 + #[must_use] + pub const fn color(self) -> Rgb { + match self { + Strip::Color(rgb) => rgb, + Strip::Black => Rgb::BLACK, + } + } +} + +/// 规则参数。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rules { + /// 整车初始血量。 + pub max_hp: u16, + /// 每次受击扣多少血。 + pub damage_per_hit: u16, + /// 受击后该路灯带全黑的时长。 + pub hit_blink: Duration, + /// 同一块板重复受击的抑制窗口(压电片一次击打会在多个采样点上过阈值)。 + pub hit_cooldown: Duration, + /// 原始 12 bit ADC 采样值超过它才算受击。 + pub threshold: u16, +} + +impl Default for Rules { + /// 现场取值:100 血、每击 −3、50 ms 变暗/冷却、阈值 2000(与参考实现一致)。 + fn default() -> Self { + Self { + max_hp: 100, + damage_per_hit: 3, + hit_blink: Duration::from_millis(50), + hit_cooldown: Duration::from_millis(50), + threshold: 2000, + } + } +} + +/// 一次被计入的受击。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HitEvent { + /// 哪块装甲板。 + pub plate: Plate, + /// 触发这次判定的原始 ADC 值,用来现场校准阈值。 + pub raw: u16, + /// 扣血之后的整车血量。 + pub hp: u16, + /// 这一击是否把血量打到 0。 + pub dead: bool, + /// 判定时刻。 + pub at: Duration, +} + +/// 装甲板状态机:血量、受击冷却、灯带输出。 +#[derive(Debug, Clone)] +pub struct Armor { + rules: Rules, + color: Rgb, + hp: u16, + hits: u32, + plate_hits: [u32; 4], + last_hit: [Option; 4], +} + +impl Armor { + /// 满血、`color` 为正常色。 + #[must_use] + pub fn new(rules: Rules, color: Rgb) -> Self { + Self { + rules, + color, + hp: rules.max_hp, + hits: 0, + plate_hits: [0; 4], + last_hit: [None; 4], + } + } + + /// 规则参数。 + #[must_use] + pub fn rules(&self) -> Rules { + self.rules + } + + /// 当前整车血量。 + #[must_use] + pub fn hp(&self) -> u16 { + self.hp + } + + /// 血量是否归零。 + #[must_use] + pub fn dead(&self) -> bool { + self.hp == 0 + } + + /// 累计计入的受击次数。 + #[must_use] + pub fn hits(&self) -> u32 { + self.hits + } + + /// 某块装甲板各自被计中的次数。 + #[must_use] + pub fn plate_hits(&self, plate: Plate) -> u32 { + self.plate_hits[plate.slot()] + } + + /// 四块板各自被计中的次数,按 index 排列。 + #[must_use] + pub fn plate_hits_all(&self) -> [u32; 4] { + self.plate_hits + } + + /// 正常显示颜色。 + #[must_use] + pub fn color(&self) -> Rgb { + self.color + } + + /// 换正常显示颜色(队伍变了、或从 NVS 读回)。 + pub fn set_color(&mut self, color: Rgb) { + self.color = color; + } + + /// 喂一个 ADC 采样值。 + /// + /// 只有"过阈值 + 该板不在冷却期内"才计入受击并扣血;其余返回 `None`。 + /// 一次击打在 40 kHz 采样下会连续多个点过阈值,冷却窗口就是把这些点算成一次。 + pub fn on_sample(&mut self, plate: Plate, raw: u16, now: Duration) -> Option { + if raw <= self.rules.threshold { + return None; + } + + let slot = plate.slot(); + + if let Some(last) = self.last_hit[slot] { + if now.saturating_sub(last) < self.rules.hit_cooldown { + return None; + } + } + + self.last_hit[slot] = Some(now); + self.hits = self.hits.saturating_add(1); + self.plate_hits[slot] = self.plate_hits[slot].saturating_add(1); + + let before = self.hp; + self.hp = self.hp.saturating_sub(self.rules.damage_per_hit); + + Some(HitEvent { + plate, + raw, + hp: self.hp, + // 是"这一击打死了",不是"打完之后已经死了":已经 0 血以后继续挨打不该 + // 每次都报一次死亡,否则现场日志会被刷屏、上报也会抖。 + dead: self.hp == 0 && before > 0, + at: now, + }) + } + + /// 这块装甲板这一刻该显示什么。 + /// + /// 整车死亡优先于一切:死了四路全黑。否则只有被打的那一路在 50 ms 窗口内全黑。 + #[must_use] + pub fn strip(&self, plate: Plate, now: Duration) -> Strip { + if self.dead() { + return Strip::Black; + } + + if let Some(last) = self.last_hit[plate.slot()] { + if now.saturating_sub(last) < self.rules.hit_blink { + return Strip::Black; + } + } + + Strip::Color(self.color) + } + + /// 四路灯带这一刻的输出,按 index 排列 —— 固件照着它刷 RMT。 + #[must_use] + pub fn strips(&self, now: Duration) -> [Strip; 4] { + Plate::ALL.map(|plate| self.strip(plate, now)) + } + + /// 复位成"新一局":满血、清计数、清冷却(颜色不动)。 + pub fn reset(&mut self) { + self.hp = self.rules.max_hp; + self.hits = 0; + self.plate_hits = [0; 4]; + self.last_hit = [None; 4]; + } + + /// 要上报给裁判端的四条记录。 + /// + /// 整车一条血量,所以四条 `hp` 相同、`index` 分别是 0..3;裁判端按 + /// [`protocol::ArmorHealth::team`] 把这里的数据归到对应队伍。 + #[must_use] + pub fn reports(&self, team: u8) -> [ArmorHealth; 4] { + Plate::ALL.map(|plate| ArmorHealth { + team, + index: plate.index(), + hp: self.hp, + }) + } + + /// 四条记录编码成可以直接 `sendto` 的字节串。 + #[must_use] + pub fn datagrams(&self, team: u8) -> Vec> { + self.datagrams_from(team, 0) + } + + /// 同 [`datagrams`](Self::datagrams),但帧头带上板子自己的 id(由 MAC 派生)。 + /// + /// 用 `sender_id` 而不是默认的 0:0 是裁判终端的保留 id,抓包时一块装甲板发的 + /// 包不该长得像裁判端发的。 + #[must_use] + pub fn datagrams_from(&self, team: u8, sender_id: u32) -> Vec> { + self.reports(team) + .iter() + .map(|report| report.encode_from(sender_id)) + .collect() + } +} + +/// 上报节奏:血量一变立刻发,没变就按心跳周期重发。 +/// +/// 裁判终端只看得到最后一条,"每 200 ms 重发"是它判断板子还在线的唯一依据 —— +/// 没有心跳,一次丢包就会让界面显示成"装甲板未接入"。 +#[derive(Debug, Clone)] +pub struct ReportSchedule { + heartbeat: Duration, + last: Option<(u16, Duration)>, +} + +impl ReportSchedule { + /// `heartbeat` 是血量不变时的重发周期。 + #[must_use] + pub fn new(heartbeat: Duration) -> Self { + Self { + heartbeat, + last: None, + } + } + + /// 这一刻该不该发。 + #[must_use] + pub fn due(&self, hp: u16, now: Duration) -> bool { + match self.last { + None => true, + Some((last_hp, last_at)) => { + last_hp != hp || now.saturating_sub(last_at) >= self.heartbeat + } + } + } + + /// 记下"这一刻用这个血量发过了"。 + pub fn mark_sent(&mut self, hp: u16, now: Duration) { + self.last = Some((hp, now)); + } +} + +/// 按键事件。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonEvent { + /// 什么都没发生。 + None, + /// 短按(按下后很快松开):固件用它打印一次状态并立刻重发上报。 + Click, + /// 长按(按住到阈值):固件用它复位血量,也就是"新一局"。 + LongPress, +} + +/// 按键状态机:去抖 + 短按 / 长按,事件只发一次。 +/// +/// 参考实现移植了 OneButton 2.6.1 的整套状态机(它还带双击、多击、idle 回调)。 +/// 这里只有两种动作,所以按同样的语义重写了一个本地版本:电平去抖,长按在按住 +/// 到阈值时立刻产生一次且不重复,松开时若在短按窗口内则产生一次短按。 +#[derive(Debug, Clone)] +pub struct Button { + debounce: Duration, + click_max: Duration, + long_press: Duration, + stable_pressed: bool, + candidate: bool, + candidate_since: Duration, + pressed_since: Duration, + long_fired: bool, +} + +impl Default for Button { + /// BOOT 键(GPIO9,低有效)的现场取值:去抖 20 ms、短按 <500 ms、长按 ≥500 ms。 + fn default() -> Self { + Self::new( + Duration::from_millis(20), + Duration::from_millis(500), + Duration::from_millis(500), + ) + } +} + +impl Button { + /// 自定义时间参数,主要给测试用。 + #[must_use] + pub fn new(debounce: Duration, click_max: Duration, long_press: Duration) -> Self { + Self { + debounce, + click_max, + long_press, + stable_pressed: false, + candidate: false, + candidate_since: Duration::ZERO, + pressed_since: Duration::ZERO, + long_fired: false, + } + } + + /// 喂一次电平(已换算成"是否按下")与当前时刻,最多产生一个事件。 + pub fn tick(&mut self, pressed: bool, now: Duration) -> ButtonEvent { + let mut event = ButtonEvent::None; + + if pressed != self.stable_pressed { + if self.candidate != pressed { + // 抖动的新电平:重新开始计稳定时间。 + self.candidate = pressed; + self.candidate_since = now; + } else if now.saturating_sub(self.candidate_since) >= self.debounce { + self.stable_pressed = pressed; + + if pressed { + self.pressed_since = now; + self.long_fired = false; + } else if !self.long_fired + && now.saturating_sub(self.pressed_since) < self.click_max + { + event = ButtonEvent::Click; + } + } + } else { + // 电平回到稳定值,候选作废。 + self.candidate = pressed; + } + + if event == ButtonEvent::None + && self.stable_pressed + && !self.long_fired + && now.saturating_sub(self.pressed_since) >= self.long_press + { + self.long_fired = true; + event = ButtonEvent::LongPress; + } + + event + } +} + +/// 解析编译期注入的 `ARMOR_TEAM`。 +/// +/// 只接受 "0" 与 "1":把队伍认错会让裁判端把血记到对手头上,现场很难发现, +/// 所以宁可启动就报错。 +#[must_use] +pub fn parse_team(value: &str) -> Option { + match value.trim() { + "0" => Some(TEAM_1), + "1" => Some(TEAM_2), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(v: u64) -> Duration { + Duration::from_millis(v) + } + + fn armor() -> Armor { + Armor::new(Rules::default(), Rgb::for_team(TEAM_1)) + } + + // ---------------------------------------------------------------- 血量 -- + + #[test] + fn starts_at_full_hp_and_is_not_dead() { + let a = armor(); + assert_eq!(a.hp(), 100); + assert!(!a.dead()); + assert_eq!(a.hits(), 0); + } + + #[test] + fn each_hit_costs_three_and_floors_at_zero() { + let mut a = armor(); + let mut hits = 0; + + // 100 / 3 = 33.33,所以第 34 击才归零。 + for i in 0..34 { + let at = ms(i * 1000); + a.on_sample(Plate::Front, 2500, at).expect("hit should count"); + hits += 1; + } + + assert_eq!(hits, 34); + assert_eq!(a.hp(), 0); + assert!(a.dead()); + + // 已经归零之后继续打,不会变负,也不算"新的一击导致死亡"。 + let event = a + .on_sample(Plate::Rear, 2500, ms(40_000)) + .expect("still counts as a hit"); + assert_eq!(event.hp, 0); + assert!(!event.dead, "0 -> 0 不是新的死亡事件"); + assert_eq!(a.hp(), 0); + } + + #[test] + fn sample_below_threshold_is_not_a_hit() { + let mut a = armor(); + assert!(a.on_sample(Plate::Front, 2000, ms(0)).is_none(), "阈值是严格大于"); + assert!(a.on_sample(Plate::Front, 1999, ms(0)).is_none()); + assert!(a.on_sample(Plate::Front, 2001, ms(0)).is_some()); + assert_eq!(a.hp(), 97); + } + + #[test] + fn per_plate_hit_counts_are_separate() { + let mut a = armor(); + + a.on_sample(Plate::Front, 3000, ms(0)); + a.on_sample(Plate::Left, 3000, ms(1000)); + a.on_sample(Plate::Left, 3000, ms(2000)); + + assert_eq!(a.plate_hits(Plate::Front), 1); + assert_eq!(a.plate_hits(Plate::Left), 2); + assert_eq!(a.plate_hits(Plate::Rear), 0); + assert_eq!(a.plate_hits(Plate::Right), 0); + assert_eq!(a.hits(), 3); + // 整车一条血量:三击共扣 9。 + assert_eq!(a.hp(), 91); + } + + // ------------------------------------------------------------ 受击去抖 -- + + #[test] + fn repeat_samples_inside_the_cooldown_are_one_hit() { + let mut a = armor(); + + // 40 kHz 采样下,一次击打会连续几十个点过阈值。 + for i in 0..49 { + a.on_sample(Plate::Left, 2600, ms(i)); + } + + assert_eq!(a.plate_hits(Plate::Left), 1, "49 ms 内的连续采样算一次"); + assert_eq!(a.hp(), 97); + } + + #[test] + fn a_sample_after_the_cooldown_is_a_new_hit() { + let mut a = armor(); + + a.on_sample(Plate::Left, 2600, ms(0)); + assert!(a.on_sample(Plate::Left, 2600, ms(49)).is_none()); + assert!(a.on_sample(Plate::Left, 2600, ms(50)).is_some(), "50 ms 是边界,算新受击"); + assert_eq!(a.plate_hits(Plate::Left), 2); + assert_eq!(a.hp(), 94); + } + + #[test] + fn cooldown_is_per_plate_not_global() { + let mut a = armor(); + + // 参考实现用全局冷却:一块板挨打后,别块板在这 50 ms 内的击打会被吃掉。 + // 每板独立冷却才是"每面各自挨打"的正确语义。 + a.on_sample(Plate::Front, 3000, ms(0)); + assert!(a.on_sample(Plate::Left, 3000, ms(1)).is_some()); + assert!(a.on_sample(Plate::Rear, 3000, ms(2)).is_some()); + assert_eq!(a.hits(), 3); + assert_eq!(a.hp(), 91); + } + + // ------------------------------------------------------------ 灯带输出 -- + + #[test] + fn hit_plate_goes_black_for_fifty_ms_only() { + let mut a = armor(); + a.on_sample(Plate::Left, 3000, ms(1000)); + + assert!(a.strip(Plate::Left, ms(1000)).is_black(), "受击瞬间"); + assert!(a.strip(Plate::Left, ms(1049)).is_black(), "窗口内"); + assert_eq!( + a.strip(Plate::Left, ms(1050)), + Strip::Color(Rgb::RED), + "窗口结束恢复原色" + ); + + // 其余三路全程不受影响 —— 这正是每面独立灯带的意义。 + for plate in [Plate::Front, Plate::Rear, Plate::Right] { + assert_eq!(a.strip(plate, ms(1000)), Strip::Color(Rgb::RED)); + assert_eq!(a.strip(plate, ms(1049)), Strip::Color(Rgb::RED)); + } + } + + #[test] + fn dead_blacks_out_every_strip() { + let mut a = armor(); + let last = 34 * 1000; + + for i in 0..34 { + a.on_sample(Plate::Front, 3000, ms(i * 1000)); + } + + assert!(a.dead()); + for plate in Plate::ALL { + assert!(a.strip(plate, ms(last)).is_black(), "{plate:?} 死了要全黑"); + } + // 冷却窗口早就过了,仍然全黑:死优先于一切。 + assert!(a.strip(Plate::Left, ms(last + 10_000)).is_black()); + } + + #[test] + fn strips_returns_all_four_in_index_order() { + let mut a = armor(); + a.on_sample(Plate::Rear, 3000, ms(0)); + let strips = a.strips(ms(0)); + + assert_eq!(strips.len(), 4); + assert!(strips[Plate::Rear.index() as usize].is_black()); + assert_eq!(strips[Plate::Front.index() as usize].color(), Rgb::RED); + } + + // ---------------------------------------------------------------- 复位 -- + + #[test] + fn reset_restores_full_hp_and_keeps_the_colour() { + let mut a = armor(); + a.on_sample(Plate::Front, 3000, ms(0)); + a.on_sample(Plate::Right, 3000, ms(100)); + a.set_color(Rgb::BLUE); + + a.reset(); + + assert_eq!(a.hp(), 100); + assert_eq!(a.hits(), 0); + assert_eq!(a.plate_hits_all(), [0; 4]); + assert_eq!(a.color(), Rgb::BLUE); + assert_eq!(a.strip(Plate::Front, ms(100)), Strip::Color(Rgb::BLUE)); + } + + // ---------------------------------------------------------------- 上报 -- + + #[test] + fn reports_carry_the_shared_hp_on_all_four_indices() { + let mut a = armor(); + a.on_sample(Plate::Front, 3000, ms(0)); + a.on_sample(Plate::Front, 3000, ms(1000)); + + let reports = a.reports(TEAM_2); + assert_eq!(reports.len(), 4); + + for (i, report) in reports.iter().enumerate() { + assert_eq!(report.team, TEAM_2); + assert_eq!(report.index, i as u8, "四格按 index 排列"); + assert_eq!(report.hp, 94, "整车一条血量,四格同值"); + } + } + + #[test] + fn datagrams_round_trip_through_the_shared_protocol() { + let mut a = armor(); + a.on_sample(Plate::Left, 3000, ms(0)); + + let datagrams = a.datagrams(TEAM_1); + assert_eq!(datagrams.len(), 4); + + for (i, bytes) in datagrams.iter().enumerate() { + // 裁判端就是用这个函数解回来的,编码错了这里立刻挂。 + let decoded = ArmorHealth::decode(bytes).expect("裁判端要能解出来"); + assert_eq!(decoded.team, TEAM_1); + assert_eq!(decoded.index, i as u8); + assert_eq!(decoded.hp, 97); + assert_eq!(bytes.len(), protocol::HEADER_LEN + 4); + assert_eq!(bytes[4], MsgType::ArmorHealth as u8); + } + } + + #[test] + fn datagrams_can_carry_the_board_id() { + let a = armor(); + let datagrams = a.datagrams_from(TEAM_2, 0xABCD_1234); + + for bytes in &datagrams { + let header = protocol::decode_header(bytes).expect("帧头可解"); + assert_eq!(header.sender_id, 0xABCD_1234); + assert_eq!(ArmorHealth::decode(bytes).map(|r| r.team), Some(TEAM_2)); + } + + // 默认入口仍然是裁判终端的保留 id 0。 + let plain = a.datagrams(TEAM_2); + assert_eq!( + protocol::decode_header(&plain[0]).map(|h| h.sender_id), + Some(0) + ); + } + + #[test] + fn report_schedule_fires_immediately_then_heartbeats() { + let mut s = ReportSchedule::new(ms(200)); + + assert!(s.due(100, ms(0)), "第一次一定发"); + s.mark_sent(100, ms(0)); + + assert!(!s.due(100, ms(199))); + assert!(s.due(100, ms(200)), "心跳到期"); + + // 血量变化不等心跳。 + assert!(s.due(97, ms(201))); + s.mark_sent(97, ms(201)); + assert!(!s.due(97, ms(250))); + assert!(s.due(94, ms(251))); + } + + // ---------------------------------------------------------------- 按键 -- + + #[test] + fn button_needs_the_debounce_window() { + let mut b = Button::new(ms(20), ms(500), ms(500)); + + assert_eq!(b.tick(true, ms(0)), ButtonEvent::None); + assert_eq!(b.tick(false, ms(5)), ButtonEvent::None, "5 ms 的毛刺不算按下"); + assert_eq!(b.tick(true, ms(10)), ButtonEvent::None); + // 从 10 ms 起连续按下到 30 ms 才稳定。 + assert_eq!(b.tick(true, ms(29)), ButtonEvent::None); + assert_eq!(b.tick(true, ms(30)), ButtonEvent::None, "按下本身不产生事件"); + } + + #[test] + fn short_press_is_a_click() { + let mut b = Button::new(ms(20), ms(500), ms(500)); + + b.tick(true, ms(0)); + b.tick(true, ms(20)); // 稳定按下 + b.tick(false, ms(100)); + assert_eq!(b.tick(false, ms(120)), ButtonEvent::Click); + // 只发一次。 + assert_eq!(b.tick(false, ms(200)), ButtonEvent::None); + } + + #[test] + fn holding_produces_one_long_press_and_no_click_on_release() { + let mut b = Button::new(ms(20), ms(500), ms(500)); + + b.tick(true, ms(0)); + b.tick(true, ms(20)); // 稳定按下 + assert_eq!(b.tick(true, ms(400)), ButtonEvent::None); + assert_eq!(b.tick(true, ms(520)), ButtonEvent::LongPress, "按住到阈值"); + assert_eq!(b.tick(true, ms(900)), ButtonEvent::None, "不回弹重复长按"); + + b.tick(false, ms(1000)); + assert_eq!( + b.tick(false, ms(1020)), + ButtonEvent::None, + "长按过的这一下不该再算短按" + ); + } + + // ---------------------------------------------------------------- 其它 -- + + #[test] + fn colours_match_the_robo_master_convention() { + assert_eq!(Rgb::for_team(TEAM_1), Rgb::RED); + assert_eq!(Rgb::for_team(TEAM_2), Rgb::BLUE); + + // 存 NVS / 打印与 Adafruit_NeoPixel::Color 的打包一致。 + assert_eq!(Rgb::RED.to_u32(), 0x00FF_0000); + assert_eq!(Rgb::BLUE.to_u32(), 0x0000_00FF); + assert_eq!(Rgb::from_u32(0x0012_3456), Rgb(0x12, 0x34, 0x56)); + } + + #[test] + fn ws2812_byte_order_is_grb() { + assert_eq!(Rgb::RED.to_grb(), [0x00, 0xFF, 0x00]); + assert_eq!(Rgb::BLUE.to_grb(), [0x00, 0x00, 0xFF]); + assert_eq!(Rgb(0x11, 0x22, 0x33).to_grb(), [0x22, 0x11, 0x33]); + } + + #[test] + fn plate_indices_are_stable() { + assert_eq!(Plate::Front.index(), 0); + assert_eq!(Plate::Rear.index(), 1); + assert_eq!(Plate::Left.index(), 2); + assert_eq!(Plate::Right.index(), 3); + + for plate in Plate::ALL { + assert_eq!(Plate::from_index(plate.index()), Some(plate)); + } + assert_eq!(Plate::from_index(4), None); + } + + #[test] + fn team_env_parsing_rejects_anything_but_zero_and_one() { + assert_eq!(parse_team("0"), Some(TEAM_1)); + assert_eq!(parse_team("1"), Some(TEAM_2)); + assert_eq!(parse_team(" 1\n"), Some(TEAM_2)); + assert_eq!(parse_team("2"), None); + assert_eq!(parse_team("team1"), None); + assert_eq!(parse_team(""), None); + } +} diff --git a/scripts/armor_probe.py b/scripts/armor_probe.py new file mode 100755 index 0000000..b3034aa --- /dev/null +++ b/scripts/armor_probe.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""独立验证装甲板血量上报的诊断脚本。 + +不依赖 Rust,也不依赖 protocol / armor-protocol crate——常量是手写的。正因如此它 +是线路格式的**独立**检查:如果 Rust 侧的编码写错了,这个脚本仍按文档解析,会立刻 +报出不一致。 + +用法: + + python3 scripts/armor_probe.py [秒数] + +它监听 UDP 5601(装甲板主板的广播目标),把收到 `ArmorHealth` 数据报按 +`(team, index)` 汇总,打印血量变化与心跳间隔。板子每 200 ms 广播四条,所以 +正常情况下每秒每个 `(team, index)` 大约 5 条。 + +用它可以确认三件事,都不需要打开裁判端界面: + + 1. 板子在广播、IP 与 MAC 派生 id 是多少 + 2. 四条 index 的血量是否一致(协议约定:整车一条血,四格同值) + 3. 血量有没有随受击下降(每击 −3,100 起) + +常量与 protocol/src/lib.rs 保持一致。改了协议记得改这里——这正是它作为交叉检查 +的价值所在。 +""" +import socket +import sys +import time +from collections import defaultdict + +# ---- 与 protocol/src/lib.rs 一致 ---- +HEADER = 27 # magic4 + type1 + ver2 + sender4 + frame4 + idx2 + cnt2 + len4 + w2 + h2 +MAGIC = 0x4D564331 # "MVC1" +ARMOR_PORT = 5601 # ARMOR_PORT +MSG_ARMOR_HEALTH = 5 # MsgType::ArmorHealth + +TEAM_NAMES = {0: "队伍一", 1: "队伍二"} + + +def decode(data): + """解析一个数据报,返回 (team, index, hp, sender_id, version) 或 None。""" + if len(data) < HEADER + 4: + return None + + if int.from_bytes(data[0:4], "little") != MAGIC: + return None + + if data[4] != MSG_ARMOR_HEALTH: + return None + + version = int.from_bytes(data[5:7], "little") + sender_id = int.from_bytes(data[7:11], "little") + team = data[HEADER] + index = data[HEADER + 1] + hp = int.from_bytes(data[HEADER + 2 : HEADER + 4], "little") + + return team, index, hp, sender_id, version + + +def main(): + seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0 + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("", ARMOR_PORT)) + sock.settimeout(0.5) + + print(f"listening on UDP {ARMOR_PORT} for {seconds:g}s ...") + + # (team, index) -> 最后一次 (hp, 收到时刻) + latest = {} + # (team, index) -> 收到过的 hp 序列(去重后按时间) + history = defaultdict(list) + # (team, index) -> 包数 + counts = defaultdict(int) + senders = set() + versions = set() + + deadline = time.time() + seconds + while time.time() < deadline: + try: + data, addr = sock.recvfrom(2048) + except socket.timeout: + continue + + parsed = decode(data) + if parsed is None: + continue + + team, index, hp, sender_id, version = parsed + now = time.time() + key = (team, index) + counts[key] += 1 + senders.add((sender_id, f"{addr[0]}:{addr[1]}")) + versions.add(version) + + previous = latest.get(key) + if previous is None or previous[0] != hp: + history[key].append((round(now, 3), hp)) + print( + f" {TEAM_NAMES.get(team, f'team{team}')} plate {index}: " + f"hp {hp}" + + (f" (was {previous[0]})" if previous else " (first seen)") + ) + latest[key] = (hp, now) + + print() + + if not counts: + print("没有收到装甲板上报。检查:") + print(" - 板子上电了吗(UART0 上应有 'reporting to 255.255.255.255:5601')") + print(" - 板子和这台机器在同一个局域网、同一个子网吗") + print(" - 防火墙放行 UDP 5601 了吗") + return 1 + + print(f"发送方 ({len(senders)}):") + for sender_id, addr in sorted(senders): + print(f" 0x{sender_id:08X} {addr}") + + print(f"协议版本: {sorted(versions)}") + print() + + by_team = defaultdict(dict) + for (team, index), count in sorted(counts.items()): + by_team[team][index] = count + + elapsed = max(seconds, 0.001) + + print("每个 (队伍, index) 的包数 / 心率:") + for team, plates in sorted(by_team.items()): + cells = [] + for index in range(4): + n = plates.get(index, 0) + cells.append(f"index{index}={n} ({n / elapsed:.1f}/s)") + print(f" {TEAM_NAMES.get(team, f'team{team}')}: " + " ".join(cells)) + + # 协议约定:整车一条血 → 四格同值;四条都在新鲜期内才等于"这条队伍在线"。 + print() + print("四条 index 的血量:") + for team, plates in sorted(by_team.items()): + hps = [] + for index in range(4): + reading = latest.get((team, index)) + hps.append(str(reading[0]) if reading else "-") + same = len(set(hps)) == 1 and "-" not in hps + print( + f" {TEAM_NAMES.get(team, f'team{team}')}: [{', '.join(hps)}]" + + (" <- 一致(整车一条血量)" if same else " <- 不一致:检查 index 或丢包") + ) + + if history: + print() + print("血量变化:") + for key in sorted(history): + team, index = key + events = history[key] + if len(events) == 1: + continue + line = " ".join(f"t={t:+.1f}s hp={hp}" for t, hp in events[:12]) + if len(events) > 12: + line += f" ... (+{len(events) - 12} more)" + print(f" {TEAM_NAMES.get(team, f'team{team}')} plate {index}: {line}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_armor.sh b/scripts/build_armor.sh new file mode 100755 index 0000000..e61aa38 --- /dev/null +++ b/scripts/build_armor.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# 编译装甲板固件(ESP32-C3),凭据与队伍号来自 .env。 +# +# ARMOR_TEAM=1 scripts/build_armor.sh # 队伍二(蓝) +# scripts/build_armor.sh # 队伍一(红,默认) +# +# ESP-IDF 的位置来自 armor/.cargo/config.toml(IDF_PATH / ESP_IDF_TOOLS_INSTALL_DIR), +# 所以这里不需要先 activate ESP-IDF。 +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +: "${WIFI_SSID:?set WIFI_SSID in .env (or export it) before building}" +export WIFI_PASS="${WIFI_PASS:-}" +# 队伍一(红)是默认值;队伍号在固件里决定灯色,也在上报里决定裁判端算哪一边。 +export ARMOR_TEAM="${ARMOR_TEAM:-0}" +# 受击阈值(原始 12 bit ADC)。看 status 行里的 peak[...] 来定:底噪要明显低于它, +# 正常敲击要明显高于它。不写就是参考实现的 2000。 +export ARMOR_THRESHOLD="${ARMOR_THRESHOLD:-2000}" + +case "$ARMOR_TEAM" in + 0 | 1) ;; + *) + echo "ARMOR_TEAM must be 0 or 1, got '$ARMOR_TEAM'" >&2 + exit 1 + ;; +esac + +case "$ARMOR_THRESHOLD" in + '' | *[!0-9]*) + echo "ARMOR_THRESHOLD must be a non-negative integer, got '$ARMOR_THRESHOLD'" >&2 + exit 1 + ;; +esac + +echo "==> building armor firmware for esp32c3 (team $ARMOR_TEAM, threshold $ARMOR_THRESHOLD)" +cd armor +cargo build --release --bin armor "$@" + +echo +echo "elf: armor/target/riscv32imc-esp-espidf/release/armor" +echo "next: scripts/flash_armor.sh" diff --git a/scripts/flash_armor.sh b/scripts/flash_armor.sh new file mode 100755 index 0000000..cef8395 --- /dev/null +++ b/scripts/flash_armor.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# 编译 + 烧录装甲板固件,然后抓一段串口日志(UART0 = RXD GPIO20 / TXD GPIO21)。 +# +# scripts/flash_armor.sh # 自动找串口,烧完看 20 秒日志 +# ESP_PORT=/dev/cu.usbmodem1101 scripts/flash_armor.sh +# scripts/flash_armor.sh --monitor 60 # 看 60 秒 +# scripts/flash_armor.sh --no-monitor # 只烧不抓 +# +# 板子上的调试口就是 UART0,所以烧完之后日志会直接从这里出来,不需要另开串口工具。 +set -euo pipefail + +cd "$(dirname "$0")/.." + +MONITOR_SECS=20 +while [ $# -gt 0 ]; do + case "$1" in + --monitor) + MONITOR_SECS="${2:-20}" + shift 2 + ;; + --no-monitor) + MONITOR_SECS=0 + shift + ;; + *) break ;; + esac +done + +port="${ESP_PORT:-}" +if [ -z "$port" ]; then + port=$(ls /dev/cu.usbmodem* /dev/cu.wchusbserial* /dev/cu.SLAB* /dev/cu.usbserial* 2>/dev/null | head -1 || true) +fi +if [ -z "$port" ]; then + echo "no serial port found; set ESP_PORT=/dev/cu.xxx" >&2 + exit 1 +fi + +echo "==> build" +./scripts/build_armor.sh "$@" + +elf="armor/target/riscv32imc-esp-espidf/release/armor" +if [ ! -f "$elf" ]; then + echo "expected $elf; run scripts/build_armor.sh first" >&2 + exit 1 +fi + +echo "==> flash $port" +# espflash 直接吃 ELF:分区表与 bootloader 都从 ELF 里读,不用手工拼地址。 +espflash flash --chip esp32c3 --port "$port" --non-interactive "$elf" + +if [ "$MONITOR_SECS" -gt 0 ]; then + echo "==> serial log (${MONITOR_SECS}s, 115200 8N1)" + python3 - "$port" "$MONITOR_SECS" <<'PY' +import os, sys, termios, time + +fd = os.open(sys.argv[1], os.O_RDONLY | os.O_NOCTTY) +a = termios.tcgetattr(fd) +a[0] = a[1] = a[3] = 0 +a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL +a[4] = a[5] = termios.B115200 +a[6][termios.VMIN] = 0 +a[6][termios.VTIME] = 1 +termios.tcsetattr(fd, termios.TCSANOW, a) + +end = time.time() + float(sys.argv[2]) +while time.time() < end: + try: + data = os.read(fd, 4096) + except OSError: + break + if data: + sys.stdout.write(data.decode("utf-8", "replace")) + sys.stdout.flush() +PY +fi