Files
ESP32S3_vt/armor/README.md
Horldsence a93ae065cf desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/
(该目录不进仓库)。

1. 客户端显示对手的血量
   快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠
   数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己
   绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用
   `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。

2. 血条改到画面两个上角,平行四边形,一条分 5 格
   左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg),
   右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。
   条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、
   拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。
   注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条
   从外侧开始灭(代码里写了注释钉住这件事)。

3. 受击画面不抖动
   删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但
   **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的
   配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关,
   并有单测钉住"旧配置读得进来、默认不抖"。

4. 血量归零算击败,不显示"损毁"
   血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在
   Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己**
   (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血),
   两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也
   没有"这局结束了"的痕迹。

5. 裁判端可以改队伍名
   两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到
   「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个
   两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照,
   紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name`
   (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。
   名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置
   面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。

测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
2026-10-14 20:48:12 +08:00

20 KiB
Raw Permalink Blame History

装甲板(ESP32-C3 / Rust / ESP-IDF)—— 一主三从,与裁判终端联动

一辆车四块装甲板(前/后/左/右),只有主板一颗 MCU:另外三块"从"板里没有单片机, 各自只有一路 WS2812 和一路压电片,直接接线到主板上。主板负责四路击打检测、四路灯带、 整车血量,并把血量广播给本项目裁判终端。

固件是跑在 ESP-IDF 上的 Rust,目标芯片 ESP32-C3(RISC-V)。

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:

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: 队伍名旁边直接显示血量数字,低于 30 或归零时数字与血条转红。归零不写"已击毁"——那一刻 图传中央会给出「胜利 / 击败」的结论(见 desktop/README.md)。

构建

凭据与队伍号走 .env(与图传固件共用同一个文件):

# .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 上跑,不需要板子:

cd armor/armor-protocol && cargo test    # 22 个测试
cd protocol && cargo test                # 线路格式往返(含 ArmorHealth)

(armor-protocol/.cargo/config.toml 把 target 覆盖回 host:它坐在 armor/ 目录树 里,否则会继承 RISC-V 目标,链接必然失败。这与 protocol/、client/ 的做法一致。)

烧录与实测

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。

不打开裁判端界面,也可以单独确认板子在广播、血量对不对:

python3 scripts/armor_probe.py 15

它手写协议常量(不依赖 Rust 侧代码),所以是线路格式的独立检查:如果固件编码写错, 这个脚本会立刻报不一致。

串口日志长什么样(实机抓的)

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,界面四格还是绿的,看起来像"没掉血"。现在 队伍名旁边直接显示数字(低于 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() 就会失败:

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. 日志里"有没有连上"要说清楚

状态行长这样:

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 里发。