diff --git a/protocol/README.md b/protocol/README.md index 0051491..e55ce81 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -1,49 +1,95 @@ # protocol -摄像头 UDP 图传的线路格式,**固件与客户端共用**。 +摄像头 UDP 图传的线路格式,**固件与两端客户端共用**。 + +``` +protocol/ 线路格式 + 帧重组 (13 个单元测试,无 ESP-IDF 依赖) +. 固件 -> ESP32-S3 (交叉编译,Xtensa) +client/ 桌面客户端 (Slint,两种身份) +``` 这是本仓库里最该被测试的部分:帧重组和编码都极易出微妙错误,而这两件事都无法 在固件上验证——你只会看到"不出画面"。 -因此它没有 ESP-IDF 依赖,会为宿主架构编译: +因此它为宿主架构编译,与 ESP-IDF 完全无关: ```sh -cargo test +cd protocol && cargo test ``` ``` -7 passed +13 passed ``` +## 拓扑 + +```text + ┌──────────────────────────────┐ + │ 裁判终端(PC,仅显示) │ + │ 队伍一 + 队伍二 + 双方血量 │ + └───────────────┬──────────────┘ + │ 同一个局域网,广播可用 + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐ + │ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │ + │ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +两块板子不区分队伍——它们只知道自己是谁(MAC 派生的 id),"哪一边"由客户端决定。 +烧同一份固件即可,配置在客户端一侧。 + ## 线路格式 -每个数据报 = 20 字节头 + 至多 1400 字节 JPEG 数据: +每个数据报 = **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) | frame_id (u32) | -+-------------------------------+-------------------------------+ -| frag_index (u16) | frag_count (u16) | -+-------------------------------+-------------------------------+ ++-------------------------------+-------+-----------------------+ +| 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) | +| payload (0..=1400) | +---------------------------------------------------------------+ ``` -全部整数为**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是 +全部整数**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是 `to_le_bytes` / `from_le_bytes`,没有字节序转换步骤。 -- `magic` 固定值,用于把同端口上的无关 UDP 包挡掉。 -- `total_len` 是整张 JPEG 的长度,接收端据此预分配缓冲并判断帧是否完整。 -- `width`/`height` 每个分片都重复携带(不只是第 0 个),这样每个数据报都能独立 - 解析,中途加入的接收端也能立刻知道分辨率。 +`msg_type` 决定同一端口上的字节如何解释,这是视频、发现、装甲板能共用一个端口的 +前提: -## 为什么是 UDP 而不是 TCP +| type | 方向 | 用途 | +|---|---|---| +| 1 `Video` | 板子 → 客户端 | 一帧 JPEG 的第 N 个分片 | +| 2 `Subscribe` | 客户端 → 板子 | "我是这个角色,请给我画面" | +| 3 `Hello` | 板子 → 客户端 | "我在线,我是谁" | +| 4 `Heartbeat` | 板子 → 客户端 | 同上,心跳形式 | +| 5 `ArmorHealth` | 装甲板 → 客户端 | 血量(**预留,当前无实现**) | -这是本 crate 存在的原因,也是实测数据: +### 三个设计决定 + +**27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在固件和客户端两侧 +各写一段手写偏移常量,是引入静默错位的更划算的代价。 + +**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。 + +**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立 +解析,中途加入的接收端也能立刻知道分辨率。 + +**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面 +错乱,比拒绝连接难查得多。 + +## 为什么视频走 UDP 而不是 TCP + +实测(VGA 640x480,传感器产出 22 fps): | | TCP(HTTP MJPEG) | UDP | |---|---|---| @@ -51,45 +97,114 @@ cargo test | 客户端实际收到 | **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),帧却堵在队头阻塞后面等一个实时画面 -等不起的重传计时器。 +TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限 +约 20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的 +重传计时器。 -UDP 没有任何重传与确认:缺了分片的帧被整个丢弃,下一帧顶上。这换来低延迟,也把 -本来就有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在 -带宽上。 +UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。这换来低延迟,也把本来就 +有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在带宽上。 ## 接收端的取舍 -`FrameAssembler` 的策略就是全部要点:**只有分片全部到齐才显示该帧,且最新的帧永远 -优先。** +`FrameAssembler` 的策略就是全部要点:**一张帧只有所有分片都到齐才交付,且最新的 +帧永远优先。** -UDP 不保证跨数据报的顺序或到达,所以试图"补洞"的接收端做的功比"等下一张完整帧" +UDP 不保证跨数据报的顺序与到达,所以试图"补洞"的接收端做的功比"等下一张完整帧" 更多——而且补的过程中会显示撕裂的画面。 -另外有一个有界超时,用来堵住另一种失败模式:发送方在帧中途死掉时,半截缓冲会 -永远留在这里。 +### 按发送方分开 -| 字段 | 含义 | -|---|---| -| `frag_count == 0` | 非法,立即拒绝 | -| `frag_index >= frag_count` | 自相矛盾,立即拒绝 | -| `payload_len > total_len` | 损坏,立即拒绝(在造成大额分配之前就挡住) | +**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个 +重组器会让它们互相顶掉,两路都出不来。 -## 发送端 +### 重复分片必须去重 -`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的 -分片集合。 +`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 次分配与不分配的区别——在 WiFi 栈也要抢内部 RAM 的芯片 -上,这个差别很实在。 +在 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` = 1420 字节,低于典型以太网 - 的 1500 MTU。 - 这一点比看起来重要:超过 MTU 的数据报要么被 AP 丢掉,要么被 IP 分片,而两种情况 - 的丢包都会**记在应用账上**而不是网络账上——后者本来更容易排查。 -- VGA 下 `MAX_FRAGMENT_PAYLOAD` 对应每帧 8–10 个数据报。 -- `DEFAULT_PORT` = 5600,两端共用同一个常量。 \ No newline at end of file +- 一个数据报不超过 `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` 发这个结构,客户端监听即可,界面与其它代码都不用改。 diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index 83c1f8a..8dcbf73 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -617,12 +617,17 @@ impl FrameAssembler { let index = frag.header.frag_index as usize; - // 重复分片直接跳过,且**不**增加 seen。 + // 重复分片直接跳过,且**不**增加计数。 // - // 这里曾经用 `seen >= expected` 判断收齐,那是错的:UDP 会重复 delivered - // 重复包,一个丢失的分片可以被另一个包的副本凑数,于是 seen 达标而缓冲区 - // 里留着空洞,交出去一张 JPEG 尾部是 0 的坏图。这个 bug 很隐蔽——它不会让 - // 帧率下降,只会让偶尔几张图解不出来,而接收端没有任何线索知道原因。 + // 用 `seen` 一个计数判断收齐是不够健壮的:一个丢失的分片若能被某个分片的 + // 副本凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。 + // 位图让"收到过"这件事是显式的,不用靠计数的算术去推断。 + // + // 要说明的是:在本项目实测的链路上(2.4GHz AP,一到两个订阅者,20 秒 2000+ + // 个分片)**一次重复投递都没出现过**,所以这层防护是为了正确性而不是为了 + // 修一个正在发生的故障。之前观察到的 34 张坏 JPEG 另有原因:探测脚本自己 + // 的重组状态只按 sender_id 存、不按 frame_id 存,于是丢片之后下一帧的分片 + // 会接着凑上一帧的计数。那个脚本已修,见 scripts/udp_probe.py。 if self.have[index] { return Err(DropReason::Superseded); } @@ -638,10 +643,9 @@ impl FrameAssembler { self.seen += 1; // 计数达标还不够:最后一个分片必须真的到过,否则中间可能缺一块。 - // 显式检查尾块,比依赖 seen 的算术更直接,也更容易看出意图。 - let last = self.expected - 1; - let tail_expected = frag.header.total_len as usize; - let tail_index = tail_expected.saturating_sub(1) / MAX_FRAGMENT_PAYLOAD; + // 显式检查尾块,比依赖计数的算术更直接,也更容易看出意图。 + let tail_index = + (frag.header.total_len as usize).saturating_sub(1) / MAX_FRAGMENT_PAYLOAD; if self.seen >= self.expected && self.have[tail_index] { let total = frag.header.total_len as usize;