Files
Horldsence a89d7ae5fb desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层:

- desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按
  发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。
- desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、
  每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。
- protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来——
  装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选"
  不需要改固件。

几个改对了的旧问题:

- 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位
  会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。
- 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出
  几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。
- 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了"
  会被读成"密码错了"。现在要求盐与摘要都在。
- 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。
  现在先收尾上一段再开新的,并且重名往后排号。
- 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面**
  不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。

测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用
ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
2026-10-14 19:14:16 +08:00
..

protocol

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

protocol/   线路格式 + 帧重组    (13 个单元测试,无 ESP-IDF 依赖)
.           固件  -> ESP32-S3    (交叉编译,Xtensa)
client/     桌面客户端           (Slint,两种身份)

这是本仓库里最该被测试的部分:帧重组和编码都极易出微妙错误,而这两件事都无法 在固件上验证——你只会看到"不出画面"。

因此它为宿主架构编译,与 ESP-IDF 完全无关:

cd protocol && cargo test
13 passed

拓扑

                    ┌──────────────────────────────┐
                    │  裁判终端(PC,仅显示)      │
                    │  队伍一 + 队伍二 + 双方血量   │
                    └───────────────┬──────────────┘
                                    │  同一个局域网,广播可用
               ┌────────────────────┼────────────────────┐
               │                    │                    │
       ┌───────┴───────┐    ┌───────┴───────┐    ┌───────┴───────┐
       │  图传板 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)                        |
+---------------------------------------------------------------+

全部整数小端——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 不匹配就拒收,而不是按错误布局继续解析——后者会表现为随机崩溃或画面 错乱,比拒绝连接难查得多。

为什么视频走 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 发这个结构,客户端监听即可,界面与其它代码都不用改。