Files
simba-rmic-2026/docs/hardware/armor.md
T
sloray 054379dc64
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
- fix(installer): 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- fix(firmware): 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- fix(installer): 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- style(client): 应用图标增加圆角,统一“裁判系统客户端”命名
- fix(config): 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- docs: 润色项目说明并统一裁判系统术语
- test(packaging): 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成

验证:Windows NSIS 构建及原生页面预览;通用与本地安装配置编译;固件 release 构建、重复打包和客户端离线校验;6 项固件打包回归与 Windows 权限回归;git diff --check。未执行实机烧录。
2026-10-21 09:25:17 +08:00

297 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 装甲板(ESP32-C3 / Rust / ESP-IDF)—— 一主三从,与 RMIC::2026 桌面端联动
每车有前、后、左、右四块装甲板,只有主板含 MCU。其余三块从板各有一路 WS2812 和一路压电片,直接接线到主板。主板负责四路受击检测、四路灯带和整车血量,并向 RMIC::2026 桌面端广播。
固件使用 Rust + ESP-IDF,目标芯片为 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
```
`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 作为主控制台,日志从丝印 RXD/TXD 对应的调试口输出。
`sdkconfig.defaults` 还将 USB Serial/JTAG 设为次控制台(`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`)。未连接 USB-TTL 时,也能从板上原生 USB 口读取应用日志;仅配置 UART0 则只能在原生 USB 上看到 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;界面按 `(team, index)` 收齐四条后显示同一个数值。
参考实现使用全局 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)` 收齐四条才显示。
- 上报使用广播而非单播,因为桌面电脑通过 DHCP 获取的 IP 可能变化。图传板使用 Hello 广播处理这一问题,装甲板沿用相同约定。
### 桌面端怎么收
`desktop/core/src/armor.rs` 里的 `ArmorHealthStore`(两种身份都启动):
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
- 四条上报都在 1.5 s 内收到时才交给界面显示,否则显示占位符。心跳间隔为 200 ms,1.5 s 对应连丢 7 个包;偶发丢包不会让读数反复切换。
- 用户端也要显示双方血条,所以两端都会占 5601。
- 血量变化会写入日志(`armor: board 0x… team 0 hp 100 -> 97`),便于不查看窗口时也能核对扣血。
界面在队伍名旁显示血量数字。`TeamHealthDto` 提供 `hp` 与 `critical`,血量低于 30 或归零时,数字与血条一起转红。归零时不显示“已击毁”,图传中央会显示“胜利 / 落败”(见 [桌面客户端](../desktop/client.md))。
## 构建
凭据与队伍号走 `.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` 为准覆盖 NVS,并记录日志。
本机已有的 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/`、`desktop/` 的做法一致。更换宿主平台时,固定的 `aarch64-apple-darwin` 不再适用,需按[开发与验证](../development/firmware.md)用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。
## 烧录与实测
```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 有明显余量 |
| 未接线 | 后/左/右三路压电片与灯带本次未接(用户说明),代码按四路齐全写 |
### 旧客户端的血量显示问题
旧客户端已收到正确的 `hps`,但界面只显示绿/灰方块,没有绘制血量数值,归零后仍为绿色。修复后,队伍名旁显示血量数字,日志记录 `armor: board 0x… 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
```
C3 只有 2 个 TX 通道,四路灯带无法各占一个。因此只创建一个通道,使用全部 96 个 symbol,再通过 `rmt_tx_switch_gpio()` 依次切换四个 GPIO。每帧为 3 珠 × 24 bit + reset,共 73 个 symbol,每路发送约 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<AdcUnit = A>`。`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 省电关掉
省电模式可能让上报多等一个信标间隔,达到上百毫秒。装甲板使用车载供电,因此关闭省电以减少上报等待。主循环每 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()`
`join()` 失败只记录警告,不退出,断网时仍保持本地检测与灯带功能。看门狗每 5 s 检查关联与有效 IP,不满足时断开重连;重连等待 IP 最多 3 s,避免长时间阻塞受击处理。
### 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 功能没有移植。
- **下发血量**:当前板子本地算账(裁判端只显示)。若以后要裁判端权威(下发血量/复位),
需要新增一条下行消息并在 `desktop` 里发。