Files
simba-rmic-2026/docs/protocol/udp.md
T
sloray 054379dc64
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
- fix(installer): 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- fix(firmware): 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- fix(installer): 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- style(client): 应用图标增加圆角,统一“裁判系统客户端”命名
- fix(config): 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- docs: 润色项目说明并统一裁判系统术语
- test(packaging): 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成

验证:Windows NSIS 构建及原生页面预览;通用与本地安装配置编译;固件 release 构建、重复打包和客户端离线校验;6 项固件打包回归与 Windows 权限回归;git diff --check。未执行实机烧录。
2026-10-21 09:25:17 +08:00

11 KiB
Raw Blame History

protocol

摄像头 UDP 图传的线路格式,由固件与两种客户端共用。

protocol/   线路格式 + 帧重组    (23 个单元测试,无 ESP-IDF 依赖)
.           固件  -> ESP32-S3    (交叉编译,Xtensa)
desktop/    桌面客户端           (RMIC::2026,Tauri v2 + React + Rust,两种身份)

编码与帧重组出错时,板上通常只表现为没有画面,难以定位。因此这里提供不依赖裁判系统的主机测试。

在宿主架构上运行测试:

cd protocol && cargo test

非 macOS / Apple Silicon 主机上,protocol/.cargo/config.toml 固定的 target 不适用,需按 开发与验证 用 --target <宿主三元组> --config 'unstable.build-std=[]' 覆盖。本机实测:

23 passed

拓扑

                    ┌──────────────────────────────┐
                    │  RMIC::2026 桌面端(仅显示) │
                    │  队伍一 + 队伍二 + 双方血量  │
                    └───────────────┬──────────────┘
                                    │  同一个局域网,广播可用
               ┌────────────────────┼────────────────────┐
               │                    │                    │
       ┌───────┴───────┐    ┌───────┴───────┐    ┌───────┴───────┐
       │  图传板 A     │    │  图传板 B     │    │  装甲板 ×4    │
       │  (队伍一)     │    │  (队伍二)     │    │  (已实现)     │
       └───────────────┘    └───────────────┘    └───────────────┘

两块板子不区分队伍,只通过 MAC 派生的 id 标识自身,所属队伍由客户端配置决定。因此,两块板子可以烧录同一份固件。

线路格式

每个数据报 = 27 字节头 + 至多 1400 字节载荷:

 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 字节:

  相对 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%。这些估计缺少随文保存的测量条件,不能单凭此表确认瓶颈或因果,验证边界见开发与验证。

本协议不重传或确认视频分片。缺少分片时丢弃整帧,转而等待下一张完整帧,避免旧帧重传继续占用实时画面的发送时间。

接收端的取舍

FrameAssembler 仅交付分片完整的帧,并优先处理最新帧。

UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,不将缺片画面交给解码器,以免显示不完整图像。

按发送方分开

每个发送方必须使用独立的重组器。两块板子的 frame_id 分别计数,若共用重组器,两路数据会互相覆盖,导致两路都无法交付画面。

重复分片必须去重

FrameAssembler 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个 分片确实到过。

仅用计数 seen >= expected 判断是否收齐并不可靠:重复分片可能补足计数,但丢失位置仍为空,最终交付的 JPEG 尾部会留下 0。两个测试覆盖这一情况(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 不变。两块板子因此可以烧录同一份固件,无需另配设备身份。

返回值避开裁判终端保留值 0。测试检查结果稳定性与样例间是否碰撞,但不能证明哈希永不碰撞。

独立核对

scripts/udp_probe.py 手写了这里的每一个常量,不依赖本 crate。

如果本 crate 编码有误,脚本仍按文档独立解析,可立即报出不一致。探测脚本本身也曾出错:role 字节曾多填 12 个零字节,固件按严格偏移读取后拒收,表现为“板子在线但 0 clients subscribed”。修改协议时需同步核对脚本。

装甲板

ArmorHealth 占 MsgType 的 5 号。装甲板主板向 ARMOR_PORT 广播,desktop/core/src/armor.rs 里的 ArmorHealthStore 负责接收。缺少上报时显示占位符,避免将数据缺失误读为血量归零。