Files
sloray 6a0eb14b28
构建客户端 / 检查(类型 + 单元测试) (push) Waiting to run
构建客户端 / Linux x64(deb + AppImage) (push) Blocked by required conditions
构建客户端 / Windows x64(NSIS 安装包) (push) Blocked by required conditions
构建客户端 / macOS 通用(.app + .dmg) (push) Blocked by required conditions
docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。

- fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示
- test(client): 按文件名核对媒体类型,避免依赖时间戳排序

验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
2026-10-19 11:26:26 +08:00

219 lines
11 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.
# 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` 负责接收。缺少上报时显示占位符,避免将数据缺失误读为血量归零。