docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。 - fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示 - test(client): 按文件名核对媒体类型,避免依赖时间戳排序 验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# protocol
|
||||
|
||||
摄像头 UDP 图传的线路格式,由固件与两种客户端共用。
|
||||
|
||||
```
|
||||
protocol/ 线路格式 + 帧重组 (23 个单元测试,无 ESP-IDF 依赖)
|
||||
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
|
||||
desktop/ 桌面客户端 (RMIC::2026,Tauri v2 + React + Rust,两种身份)
|
||||
```
|
||||
|
||||
编码与帧重组出错时,板上通常只表现为没有画面,难以定位。因此这里提供不依赖板卡的主机测试。
|
||||
|
||||
在宿主架构上运行测试:
|
||||
|
||||
```sh
|
||||
cd protocol && cargo test
|
||||
```
|
||||
|
||||
非 macOS / Apple Silicon 主机上,`protocol/.cargo/config.toml` 固定的 target 不适用,需按 [开发与验证](../development/firmware.md) 用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。本机实测:
|
||||
|
||||
```
|
||||
23 passed
|
||||
```
|
||||
|
||||
## 拓扑
|
||||
|
||||
```text
|
||||
┌──────────────────────────────┐
|
||||
│ RMIC::2026 桌面端(仅显示) │
|
||||
│ 队伍一 + 队伍二 + 双方血量 │
|
||||
└───────────────┬──────────────┘
|
||||
│ 同一个局域网,广播可用
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||||
│ 图传板 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) |
|
||||
+---------------------------------------------------------------+
|
||||
```
|
||||
|
||||
全部整数使用小端编码,通过 `to_le_bytes` / `from_le_bytes` 读写。
|
||||
|
||||
`msg_type` 决定数据报的解释方式。视频与发现使用 5600 端口,装甲板上报使用 5601 端口,两者共用头部格式:
|
||||
|
||||
| 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` 的数据报,以避免按错误布局解析视频。若通告也被丢弃,客户端便无法发现协议不同的板卡,也无法解释没有画面的原因。
|
||||
|
||||
`Hello::decode` 使用 `decode_header_lenient`,保留魔数、长度和字段校验,允许版本不匹配。读出 `proto` 后,客户端可以显示双方协议号,并提示重新烧录。
|
||||
|
||||
这不削弱边界:视频、订阅、装甲板上报仍然走严格解析。
|
||||
|
||||
## 为什么视频走 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 |
|
||||
|
||||
原说明以 20–30 Mbit/s 为良好链路下的 TCP 上限估计,将低帧率归因为队头阻塞和重传等待,并估算 UDP 占链路能力的 6–9%。这些估计缺少随文保存的测量条件,不能单凭此表确认瓶颈或因果,验证边界见[开发与验证](../development/firmware.md#性能测量)。
|
||||
|
||||
本协议不重传或确认视频分片。缺少分片时丢弃整帧,转而等待下一张完整帧,避免旧帧重传继续占用实时画面的发送时间。
|
||||
|
||||
## 接收端的取舍
|
||||
|
||||
`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 多个分片;固定端口与临时端口两种方式均未观察到重复投递。去重用于保证重组正确性。重复片若被误计数,可能在帧率正常、没有报错时偶发地产生无法解码的 JPEG。
|
||||
|
||||
### 按 frame_id 隔离,换帧即放弃
|
||||
|
||||
每个 `FrameAssembler` 按 `frame_id` 隔离状态:帧号变化时重置,并丢弃上一帧的未完成数据。
|
||||
|
||||
若不隔离,丢片后的计数器可能继续接收下一帧分片,错误地交付混合两帧内容的 JPEG。
|
||||
|
||||
`scripts/udp_probe.py` 最初只按 `sender_id` 保存重组状态,曾在 10 秒内报告 34 张“JPEG 不完整”。作者据此提交了重复分片修复并将问题归因于协议,后来确认归因有误:探测器混合了不同帧。修正帧隔离后,14 秒窗口内未再出现坏帧。发现坏帧时也应检查测量工具。
|
||||
|
||||
### 有界超时
|
||||
|
||||
发送方在帧中途断开时,`expire()` 清理未完成帧,避免缓冲一直保留。
|
||||
|
||||
### 发送端的复用
|
||||
|
||||
`Fragment::encode_into()` 追加写入而不分配:发送端对每帧的每个分片复用同一个缓冲。
|
||||
按早期 22 fps 场景估算,复用可省去每秒约 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` 占 `MsgType` 的 5 号。装甲板主板向 `ARMOR_PORT` 广播,`desktop/core/src/armor.rs`
|
||||
里的 `ArmorHealthStore` 负责接收。缺少上报时显示占位符,避免将数据缺失误读为血量归零。
|
||||
Reference in New Issue
Block a user