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

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

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

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

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

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

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

396 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ESP32-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 看起来像真值,现场看到会以为对方已经没血了。界面上的血量画在画面角上(一条分 5 格的
平行四边形,20 点血一格),归零不写"已击毁/损毁",而是由图传中央的「胜利 / 击败」横幅表达
(见 [`desktop/README.md`](desktop/README.md));数字变化也会打进客户端日志。
同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板"
并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用
`encode_from(sender_id)` 把自己的 id 打进帧头。