界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。 用户端: - 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0; - 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟, 像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效; - 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中: 第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血"); - 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。 裁判端: - 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放; - 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位; - 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。 密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的 解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。 其它: - 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名, 原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做); - 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来; - 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证); - CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明 JS 加载、IPC、WebSocket、React effect 四件事。 删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了 (槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名 同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。 测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
393 lines
19 KiB
Markdown
393 lines
19 KiB
Markdown
# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)
|
||
|
||
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
|
||
UDP 图传。
|
||
|
||
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 **Tauri v2 +
|
||
React**,用户端与裁判端两套页面(见 [`desktop/README.md`](desktop/README.md))。同一套
|
||
系统里还有一队一块的**装甲板**(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量,
|
||
并把血量广播给裁判终端。
|
||
|
||
```text
|
||
protocol/ 线路格式 + 帧重组 三端共用,无 ESP-IDF 依赖,14 个单元测试
|
||
. 图传固件 -> ESP32-S3 交叉编译,Xtensa
|
||
armor/ 装甲板固件 -> ESP32-C3 独立 workspace,RISC-V;规则层 22 个单元测试
|
||
desktop/ 桌面端 Tauri v2 + React;用户端(血条 + 瞄准器)/ 裁判端
|
||
core/ 不含界面与 Tauri 的网络层、配置、密码(可单测)
|
||
src-tauri/ Tauri 后端:推流、命令、录制
|
||
src/ React 前端:两套页面 + HUD
|
||
```
|
||
|
||
五份产物各自独立构建,各有自己的 `.cargo/config.toml`。
|
||
|
||
## 拓扑
|
||
|
||
```text
|
||
┌──────────────────────────────┐
|
||
│ 裁判终端(PC,仅显示) │
|
||
│ 队伍一 + 队伍二 + 双方血量 │
|
||
└───────────────┬──────────────┘
|
||
│ 同一个局域网,广播可用
|
||
┌────────────────────┼────────────────────┐
|
||
│ │ │
|
||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||
│ 图传板 A │ │ 图传板 B │ │ 装甲板主板 │
|
||
│ (队伍一) │ │ (队伍二) │ │ ×2(每队一块)│
|
||
└───────────────┘ └───────────────┘ └───────────────┘
|
||
```
|
||
|
||
**图传板不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
|
||
客户端决定。烧同一份固件即可,配置全在客户端一侧。
|
||
|
||
**装甲板反过来:队伍是编译期定的**(`ARMOR_TEAM=0/1`,同时决定灯色红/蓝),因为血量
|
||
上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。
|
||
|
||
裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收
|
||
全部板子的视频,然后在**界面层**过滤到绑定的那一块。
|
||
|
||
## 实测性能
|
||
|
||
VGA 640x480,传感器产出上限 22 fps(OV5640 在此配置下的实测值)。
|
||
|
||
| | TCP(HTTP MJPEG) | UDP(现在) |
|
||
|---|---|---|
|
||
| 传感器实际产出 | 22 fps | 22 fps |
|
||
| 客户端实际收到 | **1.9 fps** | **22 fps** |
|
||
| 实际吞吐 | 0.28 Mbit/s | 1.6 Mbit/s |
|
||
| 丢帧 | — | 0% |
|
||
|
||
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限约
|
||
20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的重传计时
|
||
器。
|
||
|
||
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。丢帧率实测 0%,因为带宽从来
|
||
不是瓶颈——即便如此也只用掉 6–9%。
|
||
|
||
其余数字:平均帧 22 kB,约 350 包/秒,每帧约 17 个分片,客户端观测的帧间隔 7 ms。
|
||
|
||
## 为什么视频走 UDP 而不是 TCP
|
||
|
||
**延迟优先。** 丢失 5% 的画面远好于等待 500 ms 的重传。
|
||
|
||
**没有队头阻塞。** 一帧的任一分片卡住,后面所有帧都跟着卡——对实时画面这正好是错
|
||
的方向。
|
||
|
||
**带宽不是约束。** 见上表,TCP 那栏证明了链路是空的,所以 TCP 的优势在这里换不来
|
||
任何东西。
|
||
|
||
代价是丢包丢整帧。这被接受了:**撕裂的画面比过期的画面更糟**,所以接收端不做补洞,
|
||
只等下一张完整帧。
|
||
|
||
## 帧率必须按计数除以墙钟
|
||
|
||
"22 fps" 这个数字不是对帧间隔取平均得来的,是每个窗口的**帧数增量除以窗口时长**。
|
||
|
||
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
|
||
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更
|
||
糟**,现场你会以为还有性能余量,然后花时间去找一个不存在的问题。
|
||
|
||
这个错误在本项目里出现过两次(客户端的 `View::update_fps`、探测脚本的分窗打印),两
|
||
次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
|
||
|
||
## 快速开始
|
||
|
||
### 1. 固件
|
||
|
||
```sh
|
||
cp .env.example .env # 填 WIFI_SSID / WIFI_PASS
|
||
./scripts/flash.sh --monitor 20
|
||
```
|
||
|
||
`flash.sh` 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 /
|
||
app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不用先 `activate`。
|
||
|
||
`.env`:
|
||
|
||
```sh
|
||
WIFI_SSID=你的2.4G网络
|
||
WIFI_PASS=密码
|
||
|
||
# 装甲板(armor/)用这两个里的第一个;队伍号决定灯色与上报里的 team 字段:
|
||
#ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
|
||
|
||
# 以下都有默认值:
|
||
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
|
||
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
|
||
#CAM_VSYNC_INVERT=1 # 若板子在硬件上反相了 VSYNC,填 0
|
||
#WIFI_AUTH=wpa2 # 固定认证方式,仅当扫描看不到 AP 时需要(如隐藏 SSID)
|
||
```
|
||
|
||
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
|
||
在板子上带着别人的密码。`ARMOR_TEAM` 同理:换队伍重刷装甲板固件即可,不用擦 NVS。
|
||
|
||
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
|
||
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
|
||
挨个试等于在猜一个本该已知的值。
|
||
|
||
### 2. 装甲板固件(ESP32-C3)
|
||
|
||
```sh
|
||
scripts/flash_armor.sh # 编译 + 烧录 + 20 秒串口日志
|
||
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量
|
||
```
|
||
|
||
接线表、血量规则、C3 的 RMT/ADC 踩坑都在 [`armor/README.md`](armor/README.md)。
|
||
|
||
### 3. 桌面端
|
||
|
||
```sh
|
||
cd desktop
|
||
pnpm install
|
||
pnpm app # = tauri dev
|
||
```
|
||
|
||
没有板子也能把整条链路跑起来:`CAM_DEMO=1 pnpm app` 会合成**协议数据报**喂进真实的接收
|
||
路径(分片、重组、发现、掉血派生、两道闸门全在跑真代码),界面上标着"演示模式"。
|
||
|
||
首次启动会问这台机器是哪一端:**用户端**要绑一块图传板与一块装甲板(改动需密码),
|
||
**裁判端**不需要任何配置就能看两路。
|
||
|
||
**`desktop/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
|
||
desktop/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配置,
|
||
然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
|
||
|
||
界面、视频通道、密码模型、录制与排查都在 [`desktop/README.md`](desktop/README.md)。
|
||
|
||
## 发现是双向的
|
||
|
||
单靠一个方向建立不了"谁要看我":
|
||
|
||
- 板子每 **2 秒**广播一次 Hello —— "我在线,我是谁"
|
||
- 客户端每 **800 ms**广播一次 Subscribe —— "我是这个角色,请给我画面"
|
||
|
||
板子从订阅的**源地址**学到推流目标(所以客户端的 socket 必须固定绑 5600 端口,不能
|
||
用临时端口)。
|
||
|
||
对端靠**源地址**记忆,不发探测包。续订本来就每 800 ms 一次,所以"还活着吗"这个问题
|
||
由续订本身回答了——超时即代表它离开了,多一份心跳状态就多一个能出错的地方。
|
||
|
||
两端各有自己的超时淘汰,阈值不同:
|
||
|
||
| | 超时 | 为什么是这个量级 |
|
||
|---|---|---|
|
||
| 固件丢弃订阅者 | 6 s | 续订间隔 800 ms,7 倍余量,偶发丢包不会把人踢掉 |
|
||
| 客户端移除板子 | 60 s | 只影响界面列表,不影响推流 |
|
||
|
||
客户端的 60 s 刻意很长:界面列表里留着一块刚重启还没广播的板子,比它短暂消失更可接
|
||
受。而它每 5 秒才扫一次,不值得为这个上一份状态。
|
||
|
||
固件同时最多向 **4 个**订阅者推流(`MAX_PEERS`)。每个对端每帧要一次 `sendto`,这是帧
|
||
CPU 成本的倍数;三台客户端(两队 + 裁判)够用,且不让一个陈旧的地址条目拖慢每一帧。
|
||
|
||
## 身份:为什么用 MAC 派生
|
||
|
||
`protocol::id_from_mac()` 用 FNV-1a 从 MAC 派生 `sender_id`,避开 0(裁判终端的保留
|
||
值)。
|
||
|
||
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换地
|
||
址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
|
||
|
||
## 线路格式
|
||
|
||
27 字节头 + 至多 1400 字节载荷,全部小端。细节在 `protocol/README.md`,那里有完整
|
||
的字节图和每个拒收条件。
|
||
|
||
三处关键决定:
|
||
|
||
- **27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在两端各写一段
|
||
手写偏移常量,是引入静默错位的更划算的代价。
|
||
- **每个数据报 1427 字节,低于 1500 MTU。** 超 MTU 的数据报要么被 AP 丢、要么被 IP
|
||
分片,两种情况的丢包都记在应用账上而不是网络账上——后者本来更容易排查。
|
||
- **`version` 不匹配就拒收**,而不是按错误布局继续解析。后者表现为随机崩溃或画面错
|
||
乱,比拒绝连接难查得多。
|
||
|
||
## 硬件
|
||
|
||
Freenove ESP32-S3-WROOM CAM,ESP32-S3-WROOM-1-N16R8(16 MB flash + 8 MB octal
|
||
PSRAM),OV5640。
|
||
|
||
### 引脚
|
||
|
||
`src/camera.rs` 的 `pins` 模块:
|
||
|
||
| | GPIO |
|
||
|---|---|
|
||
| RESET | 0 |
|
||
| XCLK | 15 |
|
||
| PCLK | 13 |
|
||
| VSYNC | 6 |
|
||
| HREF | 7 |
|
||
| SIOD (SDA) | 4 |
|
||
| SIOC (SCL) | 5 |
|
||
| DATA[0..8] | 11, 9, 8, 10, 12, 18, 17, 16 |
|
||
|
||
### 千万不要套用别的板子的引脚表
|
||
|
||
这块板子的 GPIO 分配是死的:
|
||
|
||
| 范围 | 占用 |
|
||
|---|---|
|
||
| 26–32 | SPI flash |
|
||
| 33–37 | octal PSRAM |
|
||
| 19 / 20 | USB |
|
||
| 43 / 44 | console |
|
||
| 45 / 46 | 模组内部 |
|
||
|
||
直接套用 AI-Thinker ESP32-CAM 的引脚表会有 **7 根冲突**。冲突的表现不是"某根线不
|
||
通",而是 `PIN_FUNC_SELECT` 把某个外设接到了 flash 或 PSRAM 上,**CPU 在传输中途挂
|
||
死**——没有报错、没有回溯、串口直接静默。
|
||
|
||
改引脚前先对照上面这张表。
|
||
|
||
### 那块坏掉的 flash 字节
|
||
|
||
测试用的这块模组在 `0xFADE7` 有个字节的 bit 5–7 **写不进去**。镜像放在哪里会决定哪
|
||
个镜像字节落到这个地址,落上去就校验失败、bootloader 拒绝启动。
|
||
|
||
镜像本身不因位置而变,所以移动 app 分区就够了。`scripts/flash.sh` 会挑最低的 64 KB
|
||
对齐、且该地址处字节 flash 真的能存的偏移,写回 `partitions.csv`,然后重新编译
|
||
(重编译会让镜像位移,所以编译后再验证一次)。
|
||
|
||
**换到健康硬件**:把 `factory` 分区放回 `0x10000`,删掉 `flash.sh` 里的
|
||
`BAD_FLASH_ADDR`,别的什么都不用改。
|
||
|
||
Flash 跑 DIO @ 40 MHz 而不是这颗片子的标称 QIO/80 MHz:测试模组的坏字节在 quad 模式
|
||
下会被推到 boot loop。QIO 对**代码执行**速度的提升是明显的(程序从 flash 跑,cache
|
||
miss 直接打在这条总线上),健康硬件上值得换回去试。
|
||
|
||
### `PSRAM DMA` 是故意关掉的
|
||
|
||
开着的话,esp32-camera 让 GDMA 直接把帧缓冲写进 PSRAM,然后**只在最后一个 DMA 节点
|
||
(1 KB)里**找 JPEG 结束标记。标记落在窗口外就丢帧并报 `NO-EOI`——在这块板子上是大部
|
||
分的帧,几帧之后驱动干脆不再交出任何帧,而且不恢复。
|
||
|
||
关掉之后驱动 DMA 到内部 RAM 的中转缓冲,再 memcpy 进 PSRAM。每帧多一次约 23 KB 的
|
||
memcpy,但搜索整个缓冲找标记。对一条能用的图传链路,这是对的取舍。
|
||
|
||
### CPU 频率
|
||
|
||
**`CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y`** 是 `sdkconfig.defaults` 里最值钱的一行,
|
||
而且免费。
|
||
|
||
ESP-IDF v5.5 的 Kconfig 把 ESP32-S3 默认成 **160** MHz(见
|
||
`components/esp_system/port/soc/esp32s3/Kconfig.cpu`),所以默认编译悄悄浪费了一半
|
||
CPU。相机采集、esp32-camera 里的 JPEG 搜索、lwIP、发送路径全是 CPU 密集的,这一行一
|
||
次性把它们全部搬上去。
|
||
|
||
### WiFi 缓冲留在内部 RAM
|
||
|
||
`CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n`。
|
||
|
||
这个值曾经按"PSRAM 很空闲"的直觉写成 `y`,方向恰好相反:WiFi 描述符和 TCP 段**每个
|
||
包都要碰**,内部 SRAM 比隔着内存总线的 PSRAM 快一个数量级。把它们放进 PSRAM 的表现是
|
||
高延迟和差吞吐,无论 PSRAM 还剩多少。
|
||
|
||
帧缓冲相反——它大、相对少碰,留在 PSRAM。
|
||
|
||
### 相机任务钉在 CPU1
|
||
|
||
`CONFIG_CAMERA_CORE0=n`(即跑在 CPU1)。WiFi 任务钉在 CPU0。esp32-camera 的采集任务
|
||
时间是短促且延迟敏感的(DMA 完成、JPEG 标记搜索、拷进帧缓冲),在共享核上会被抢占,
|
||
每一帧都被拉长。双核芯片上没有理由共享。
|
||
|
||
## vendored esp32-camera
|
||
|
||
`components/esp32-camera/` 是上游 2.1.7 的副本,因为组件注册表的版本求解器找不到它。
|
||
|
||
它打了一处补丁:`target/esp32s3/ll_cam.c` 里的 `CAM_IO_MUX_REG()`。这个宏负责把引脚
|
||
接到 I2C(OV5640 的 SCCB 走 I2C),不接的话传感器根本不会被发现。
|
||
|
||
**注意这个目录叫 `target/`,但它不是构建目录。** 它是上游组件的按芯片分目录
|
||
(`esp32/`、`esp32s2/`、`esp32s3/`),里面是 `ll_cam.c` 等源码,包含上述补丁。所以
|
||
`.gitignore` 里三条 `target/` 规则全部锚定在根目录——这是有意的。
|
||
|
||
## 改过 C 文件之后
|
||
|
||
```sh
|
||
rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
|
||
```
|
||
|
||
不删不会重新编译,你的补丁看起来像完全没生效。
|
||
|
||
## 摄像头端不编码,客户端不编码
|
||
|
||
OV5640 的 JPEG 编码器**在传感器内部**。DVP 接口出来的一帧要么是 JPEG,要么是 YUV 裸
|
||
数据。
|
||
|
||
不编码的话,单帧从约 21 KB 涨到 **450 KB**(21 倍),而 ESP32-S3 **没有硬件 JPEG
|
||
编码器**(整个 ESP32 系列只有 P4 有,`esp_driver_jpeg` 里
|
||
`CONFIG_IDF_TARGET_ESP32P4` 是写死的)。
|
||
|
||
同理,客户端也不编码:板子出的就是传感器里的 JPEG,客户端只解码。ESP32-S3 也没有硬
|
||
件 JPEG 解码器。
|
||
|
||
## 为什么收包和解码要分开线程
|
||
|
||
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
|
||
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
|
||
|
||
现在桌面端把 JPEG **原样**交给 WebView,由浏览器的原生解码器解——后端连解都不解,
|
||
接收线程只做一次 16 字节头的拼接与一次广播。这一条比上面那条更彻底:省掉的不是几毫秒,
|
||
而是一整个线程与一次内存拷贝。细节在 [`desktop/README.md`](desktop/README.md)。
|
||
|
||
## 排查
|
||
|
||
| 现象 | 原因 |
|
||
|---|---|
|
||
| 板子无输出、串口静默 | GPIO 冲突。CPU 在传输中途挂了,见上面那张占用表 |
|
||
| 几帧后停止出图,报 `NO-EOI` | `PSRAM DMA` 被打开了 |
|
||
| 板子在线但 `0 clients subscribed` | 订阅包格式不对。`Subscribe` 的 `role` 字节紧跟 27 字节头,在偏移 27 |
|
||
| 一直接到但画面偶尔解不出来 | 帧重组的问题。**先怀疑你的测量工具**:本项目的探测脚本就因为重组状态没按 `frame_id` 隔离,误报过 34 张坏帧 |
|
||
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
|
||
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
|
||
| 裁判端两块画面宽窄不一 | 两格必须是 `flex: 1 1 0` 且 `min-width: 0`:按内容分宽度时,占位文字长的那一格会多占十几个像素 |
|
||
| 裁判端两格显示同一块板子 | 旧版槽位分配的真 bug。现在候选里要排除另一个槽位占着的 id |
|
||
| 有画面但状态栏说"离线" | 视频帧也必须算存活证据(`Discovery::touch`),不能只看 Hello |
|
||
| 改了绑定却"没生效" | 改动需要先解锁;后端每个改动命令都会校验,界面上的入口只是入口 |
|
||
|
||
独立核对线路格式:
|
||
|
||
```sh
|
||
python3 scripts/udp_probe.py 10
|
||
```
|
||
|
||
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
|
||
有错,脚本会立刻报出不一致。
|
||
|
||
## 四份文档
|
||
|
||
| | |
|
||
|---|---|
|
||
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
|
||
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
|
||
| `armor/README.md` | 装甲板接线表、血量规则、上报、C3 的 RMT/ADC 踩坑 |
|
||
| `desktop/README.md` | 两套页面、视频通道路径、两道闸门、密码模型、录制与排查 |
|
||
|
||
## 装甲板(已实现)
|
||
|
||
`armor/` 是一套独立的 Rust workspace(目标 **ESP32-C3**,RISC-V):
|
||
|
||
- `armor-protocol` —— 规则层:血量(100 起、每击 −3)、每板独立的 50 ms 受击冷却、
|
||
受击后**只有那一路**灯带全黑 50 ms、按键 FSM、上报编码。纯计算,主机上 22 个测试。
|
||
- `armor-firmware` —— 固件:四路压电片 40 kHz ADC 连续采样、四路 WS2812(C3 只有一个
|
||
RMT 通道可用,靠 `rmt_tx_switch_gpio` 轮流驱动)、NVS、BOOT 键、WiFi + UDP。
|
||
|
||
硬件是"一主三从":三块从板里没有 MCU,各自只有一路灯带和一路压电片,直接接线到主板。
|
||
接线表与实现细节见 [`armor/README.md`](armor/README.md)。
|
||
|
||
```sh
|
||
scripts/build_armor.sh # 编译(WIFI_SSID/WIFI_PASS/ARMOR_TEAM 取自 .env)
|
||
scripts/flash_armor.sh # 编译 + 烧录 + 串口日志
|
||
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量
|
||
```
|
||
|
||
桌面端侧:`desktop/core/src/armor.rs` 的 `ArmorHealthStore` 监听 5601,按 `(team, index)`
|
||
存最后一次读数,四条都在 1.5 s 内收到才显示——**没有数据时显示占位符而不是 0**,
|
||
0 看起来像真值,现场看到会以为对方已经没血了。界面上直接显示血量数字(整车一条血,
|
||
归零显示 `0 · 已击毁` 并转红);数字变化也会打进客户端日志。
|
||
|
||
同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板"
|
||
并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用
|
||
`encode_from(sender_id)` 把自己的 id 打进帧头。
|