- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形 - feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退 - feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页 - feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路 - feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码 - feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔 - feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写 - feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容 - feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因 - feat(firmware): 固件包版本与客户端同源,都从 version.json 读 - fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载 - fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页 - fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系 - fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失 - fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径 - fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录 - build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO - build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-* - docs: 补充界面语言、版本机制与协议通告布局的说明
244 lines
11 KiB
Markdown
244 lines
11 KiB
Markdown
# protocol
|
||
|
||
摄像头 UDP 图传的线路格式,**固件与两端客户端共用**。
|
||
|
||
```
|
||
protocol/ 线路格式 + 帧重组 (13 个单元测试,无 ESP-IDF 依赖)
|
||
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
|
||
client/ 桌面客户端 (Slint,两种身份)
|
||
```
|
||
|
||
这是本仓库里最该被测试的部分:帧重组和编码都极易出微妙错误,而这两件事都无法
|
||
在固件上验证——你只会看到"不出画面"。
|
||
|
||
因此它为宿主架构编译,与 ESP-IDF 完全无关:
|
||
|
||
```sh
|
||
cd protocol && cargo test
|
||
```
|
||
|
||
```
|
||
13 passed
|
||
```
|
||
|
||
## 拓扑
|
||
|
||
```text
|
||
┌──────────────────────────────┐
|
||
│ 裁判终端(PC,仅显示) │
|
||
│ 队伍一 + 队伍二 + 双方血量 │
|
||
└───────────────┬──────────────┘
|
||
│ 同一个局域网,广播可用
|
||
┌────────────────────┼────────────────────┐
|
||
│ │ │
|
||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
|
||
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
|
||
└───────────────┘ └───────────────┘ └───────────────┘
|
||
```
|
||
|
||
两块板子不区分队伍——它们只知道自己是谁(MAC 派生的 id),"哪一边"由客户端决定。
|
||
烧同一份固件即可,配置在客户端一侧。
|
||
|
||
## 线路格式
|
||
|
||
每个数据报 = **27 字节头** + 至多 **1400 字节**载荷:
|
||
|
||
```text
|
||
0 1 2 3
|
||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||
+-------------------------------+-------+-----------------------+
|
||
| magic (u32) | type | version (u16) |
|
||
+-------------------------------+-------+-----------------------+
|
||
| sender_id (u32) | frame_id (u32) |
|
||
+-------------------------------+-------+-----------------------+
|
||
| frag_index (u16) | frag_count (u16) |
|
||
+-------------------------------+-------+-----------------------+
|
||
| total_len (u32) | width (u16) | height (u16) |
|
||
+-------------------------------+---------------+---------------+
|
||
| payload (0..=1400) |
|
||
+---------------------------------------------------------------+
|
||
```
|
||
|
||
全部整数**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是
|
||
`to_le_bytes` / `from_le_bytes`,没有字节序转换步骤。
|
||
|
||
`msg_type` 决定同一端口上的字节如何解释,这是视频、发现、装甲板能共用一个端口的
|
||
前提:
|
||
|
||
| type | 方向 | 用途 |
|
||
|---|---|---|
|
||
| 1 `Video` | 板子 → 客户端 | 一帧 JPEG 的第 N 个分片 |
|
||
| 2 `Subscribe` | 客户端 → 板子 | "我是这个角色,请给我画面" |
|
||
| 3 `Hello` | 板子 → 客户端 | "我在线,我是谁" |
|
||
| 4 `Heartbeat` | 板子 → 客户端 | 同上,心跳形式 |
|
||
| 5 `ArmorHealth` | 装甲板 → 客户端 | 血量(**预留,当前无实现**) |
|
||
|
||
### 三个设计决定
|
||
|
||
**27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在固件和客户端两侧
|
||
各写一段手写偏移常量,是引入静默错位的更划算的代价。
|
||
|
||
**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
|
||
|
||
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
|
||
解析,中途加入的接收端也能立刻知道分辨率。
|
||
|
||
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
|
||
错乱,比拒绝连接难查得多。
|
||
|
||
## 通告里的版本信息
|
||
|
||
`Hello` / `Heartbeat` 的负载是**向后兼容的一截**,现在 10 字节:
|
||
|
||
```text
|
||
相对 HEADER_LEN 的偏移
|
||
0 2 6 8 10
|
||
+-----+-----+-----+-----+
|
||
| fw | ver | bld |proto| fw: u16 旧字段(老客户端还读它)
|
||
+-----+-----+-----+-----+ ver: u32 major<<16|minor<<8|patch
|
||
bld: u16 构建号(git 提交数)
|
||
proto: u16 板子编译进去的线路协议
|
||
```
|
||
|
||
三个决定:
|
||
|
||
- **旧的 2 字节 `fw` 保留不动**。老客户端只读它,多余的字节不看,所以一次固件更新
|
||
不会把还在用旧客户端的机器踢下线。
|
||
- **新客户端接受只有 2 字节的老固件**:版本与构建号回到 0("未知"),协议号退回帧头里
|
||
的那个。界面显示"版本未知",而不是一个看起来像真值的 `0.0.0`。
|
||
- **通告用宽松的帧头解析,视频帧不用**。见下一条。
|
||
|
||
### 为什么通告允许协议不匹配
|
||
|
||
严格解析会把 `version != PROTOCOL_VERSION` 的数据报全部丢掉。对视频帧这是对的——按错的
|
||
布局去解读一段 JPEG 只会得到垃圾。但对**通告**,那意味着一块烧了别的协议版本的板子在
|
||
客户端看来根本不存在,现场看到的现象只有"没有画面"。
|
||
|
||
所以 `Hello::decode` 走 `decode_header_lenient`:魔数、长度、字段照常校验,只是不看版本。
|
||
读出 `proto` 之后,客户端就能直接说"板子是协议 2,客户端是协议 1,画面会被丢弃,请重新
|
||
烧录",而不是让人去猜。
|
||
|
||
这不削弱边界:视频、订阅、装甲板上报仍然走严格解析。
|
||
## 为什么视频走 UDP 而不是 TCP
|
||
|
||
实测(VGA 640x480,传感器产出 22 fps):
|
||
|
||
| | TCP(HTTP MJPEG) | UDP |
|
||
|---|---|---|
|
||
| 传感器实际产出 | 22 fps | 22 fps |
|
||
| 客户端实际收到 | **1.9 fps** | **20.8 fps** |
|
||
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
|
||
|
||
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限
|
||
约 20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的
|
||
重传计时器。
|
||
|
||
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。这换来低延迟,也把本来就
|
||
有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在带宽上。
|
||
|
||
## 接收端的取舍
|
||
|
||
`FrameAssembler` 的策略就是全部要点:**一张帧只有所有分片都到齐才交付,且最新的
|
||
帧永远优先。**
|
||
|
||
UDP 不保证跨数据报的顺序与到达,所以试图"补洞"的接收端做的功比"等下一张完整帧"
|
||
更多——而且补的过程中会显示撕裂的画面。
|
||
|
||
### 按发送方分开
|
||
|
||
**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个
|
||
重组器会让它们互相顶掉,两路都出不来。
|
||
|
||
### 重复分片必须去重
|
||
|
||
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
|
||
分片确实到过。
|
||
|
||
用单一计数 `seen >= expected` 判断收齐不够健壮:一个丢失的分片若能被某个分片的副本
|
||
凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。两个测试专门覆盖
|
||
它(`a_duplicate_cannot_complete_a_frame_with_a_hole`、
|
||
`a_gap_that_is_never_filled_never_yields_a_frame`)。
|
||
|
||
**不过要说清楚:在实测链路上一次重复投递都没出现过。** 一到两个订阅者、20 秒 2000+
|
||
个分片,重复数为 0(固定端口与临时端口两种订阅方式都试过)。所以这层防护是为了正确
|
||
性,不是为了修一个正在发生的故障。
|
||
|
||
它防的是"哪天真的重复了"——那时症状是**帧率完全正常、没有任何报错线索**,只有偶尔
|
||
几张图解不出来,比一次崩溃难查得多。
|
||
|
||
### 按 frame_id 隔离,换帧即放弃
|
||
|
||
每个 `FrameAssembler` **按 `frame_id` 隔离**状态:`frame_id` 一变就重置,之前那帧的半
|
||
成品直接丢掉。
|
||
|
||
不隔离的后果很具体——某帧丢了分片之后,下一帧的分片会接着往同一个计数器里凑,凑够
|
||
了就交出去一张**混着两帧**的 JPEG。
|
||
|
||
这个坑我踩过:`scripts/udp_probe.py` 最初只按 `sender_id` 存重组状态,于是 10 秒里报出
|
||
34 张"JPEG 不完整"。那个数字看起来像丢包或重复分片的证据,我据此给 `protocol` 写了一
|
||
个"修复重复分片"的提交和一段因果说明——**结论是错的**,那段归因比它揭示的 bug 更值得
|
||
记下来。修掉之后同样 14 秒内是 0 张。
|
||
|
||
探到坏帧时,先怀疑测量工具。
|
||
|
||
### 有界超时
|
||
|
||
发送方在帧中途死掉时,半截缓冲会永远留在重组器里。`expire()` 堵住这个。
|
||
|
||
### 发送端的复用
|
||
|
||
`Fragment::encode_into()` 追加写入而不分配:发送端对每帧的每个分片复用同一个缓冲,
|
||
在 22fps 下这是每秒约 250 次分配与不节省别的差别——在这块同时要抢内部 RAM 的芯片
|
||
上很实在。
|
||
|
||
`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的集合。
|
||
|
||
## 拒收条件
|
||
|
||
`Fragment::decode` 会挡掉这些,`FrameAssembler` 不必再操心:
|
||
|
||
| 条件 | 为什么 |
|
||
|---|---|
|
||
| `magic` 不符 | 同端口上可能有别的协议 |
|
||
| `version` 不符 | 按错误布局解析比拒收难查 |
|
||
| 长度不足 27 字节 | 可能是存活探测包(1 字节) |
|
||
| `frag_count == 0` | 自相矛盾 |
|
||
| `frag_index >= frag_count` | 自相矛盾 |
|
||
| 载荷比 `total_len` 还长 | 损坏,且在造成大额分配之前就该挡住 |
|
||
|
||
## 不变量
|
||
|
||
- 一个数据报不超过 `HEADER_LEN + MAX_FRAGMENT_PAYLOAD` = **1427 字节**,低于典型
|
||
以太网的 1500 MTU。
|
||
这一点比看起来重要:超 MTU 的数据报要么被 AP 丢弃,要么被 IP 分片,两种情况的
|
||
丢包都**记在应用账上**而不是网络账上——后者本来更容易排查。
|
||
- VGA 下每帧约 17 个分片。
|
||
- `DEFAULT_PORT` = 5600,两端共用同一个常量。
|
||
- `ARMOR_PORT` = 5601,装甲板预留,当前无实现。
|
||
|
||
## 身份
|
||
|
||
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
|
||
|
||
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换
|
||
地址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
|
||
|
||
返回值避开 0,那是裁判终端的保留值。哈希有测试保证稳定性与不撞车。
|
||
|
||
## 独立核对
|
||
|
||
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
|
||
|
||
这正是它的价值:如果本 crate 的编码有错,脚本仍按文档解析,会立刻报出不一致。反面
|
||
例子也有——它的 `role` 字节曾多填 12 个零字节,固件按偏移严格读取因而拒收,表现为
|
||
"板子在线但 0 clients subscribed"。改协议时记得同步它。
|
||
|
||
## 装甲板(预留)
|
||
|
||
`ArmorHealth` 与 `ARMOR_PORT` 已就位,`MsgType` 也留了 5 号。现在没有实现也没有
|
||
数据源,裁判界面显示占位符而不是 0——**0 看起来像真值,现场看到会以为对方已经没
|
||
血了**。
|
||
|
||
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端监听即可,界面与其它代码都不用改。
|