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。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -25,3 +25,6 @@
|
||||
|
||||
# pnpm 在本机把 store 落在了工作区里(沙箱下 HOME 里的 store 不可写)。
|
||||
/.pnpm-store/
|
||||
|
||||
# macOS 的目录元数据。不锚定,子目录里的也一起忽略。
|
||||
.DS_Store
|
||||
|
||||
@@ -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 }
|
||||
Generated
+1718
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
+351
@@ -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<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` 里发。
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
embuild::espidf::sysenv::output();
|
||||
}
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Strip>; 4],
|
||||
last_full_refresh_ms: u64,
|
||||
}
|
||||
|
||||
impl Leds {
|
||||
/// 建通道与 encoder,把四路灯带全刷成黑色(上电先灭)。
|
||||
pub fn new() -> Result<Self> {
|
||||
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::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// 记一次刷新失败:诊断信息比重启有用,现场灯带接错线也不该把固件打挂。
|
||||
pub fn log_failure(err: &anyhow::Error) {
|
||||
warn!("ws2812 refresh failed: {err:#}");
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
.join(" ");
|
||||
let peaks = Plate::ALL
|
||||
.iter()
|
||||
.map(|plate| format!("{}={}", plate.name(), peak[plate.index() as usize]))
|
||||
.collect::<Vec<_>>()
|
||||
.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<std::net::Ipv4Addr>, 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<std::net::Ipv4Addr> {
|
||||
// 默认 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<std::net::Ipv4Addr>) -> 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<std::net::Ipv4Addr> {
|
||||
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<EspWifi<'static>>, 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<EspWifi<'static>>) {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
@@ -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::<ffi::sockaddr_in>() 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<Self> {
|
||||
// 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::<i32>() 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::<ffi::sockaddr_in>() 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) };
|
||||
}
|
||||
}
|
||||
@@ -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<AdcUnit = A>` —— `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<Self> {
|
||||
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<FrameStats> {
|
||||
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<Plate> {
|
||||
let slot = PLATE_CHANNEL.iter().position(|c| *c == channel)?;
|
||||
Plate::ALL.get(slot).copied()
|
||||
}
|
||||
@@ -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<NvsDefault>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// 打开(必要时创建)命名空间。
|
||||
pub fn new(partition: EspDefaultNvsPartition) -> Result<Self> {
|
||||
Ok(Self {
|
||||
nvs: EspNvs::new(partition, NAMESPACE, true)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 定出这次该用哪个颜色,并把它写回 NVS。
|
||||
///
|
||||
/// 规则:**编译期注入的 `ARMOR_TEAM` 是权威**。换队伍只要重刷固件,不用先擦
|
||||
/// NVS;NVS 里的值只用来记住当前状态(也给以后加"手动换色"留位置)。两者
|
||||
/// 不一致时以 `ARMOR_TEAM` 为准并覆盖,日志里会说一声,免得现场纳闷为什么
|
||||
/// 灯色变了。
|
||||
pub fn resolve_color(&self, team: u8) -> Result<Rgb> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "armor-protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# 只依赖错误检查用的东西:规则层是纯计算(血量、去抖、按键状态机),
|
||||
# 这样它能在 PC 上跑单元测试,不需要 ESP32-C3 硬件。
|
||||
[dependencies]
|
||||
protocol = { path = "../../protocol" }
|
||||
@@ -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<Plate> {
|
||||
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<Duration>; 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<HitEvent> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Executable
+167
@@ -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())
|
||||
Executable
+49
@@ -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"
|
||||
Executable
+75
@@ -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
|
||||
Reference in New Issue
Block a user