Files
ESP32S3_vt/armor/README.md
T
Horldsence a30eb3200a armor: ESP32-C3 装甲板固件(一主三从)+ 烧录脚本,之前一直没提交
这一块代码本来就写好了、根 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。
2026-10-14 19:43:14 +08:00

352 lines
19 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)—— 一主三从,与裁判终端联动
一辆车四块装甲板(前/后/左/右),**只有主板一颗 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<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 省电关掉
省电模式下上报会晚一个信标间隔(上百毫秒),而"受击后 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` 里发。