文档:按 UDP 多端架构重写根 README 与 client README

三份文档此前还是 UDP 之前的内容(旧 HTTP 架构、单路、无身份概念),与实现不符。

根 README 是总入口:拓扑(两块板不区分队伍,"哪一边"由客户端决定)、实测性
能对比、发现协议、身份派生理由、构建烧录、硬件坑、排查表。

client README 覆盖:两种身份对应的两个组件、命令行、绑定流程与配置格式、三个
线程的分工、Slint 的硬约束(Image/Weak 不是 Send、ModelRc 没有 From<Vec<T>>、
基础元素不能挂 callback)。

写的时候逐条核对了代码,发现并修正了几处"我以为是这样":

  - WIFI_AUTH 一直能用但从未进文档,.env.example 里也没有
  - 两端各有超时淘汰(原以为没有),值不同:固件 6 秒丢订阅者、客户端 60 秒
    才从界面列表移除。固件还有 MAX_PEERS=4 的上限,也补上了
  - 两处注释写死 app 分区在 0x40000,实际由 flash.sh 自动挑选
  - 实测数字重测:22 fps / 0 丢帧 / 平均帧 22 kB / 350 包每秒

也把两条踩过的坑写进去了,因为它们都属于"看起来是 A,实际是 B":帧率必须按
计数除以墙钟(累计值除窗口值会报出单调上升的假数字,这个错误出现过两次),
以及探到链路异常先怀疑测量工具。
This commit is contained in:
Horldsence
2026-10-05 19:13:52 +08:00
parent c7f42a1979
commit 7b6593bb75
2 changed files with 461 additions and 460 deletions
+182 -126
View File
@@ -1,158 +1,214 @@
# cam-client
# client
[ESP32-S3 摄像头固件](../README.md) 的桌面查看器。接收 UDP 数据报形式的 JPEG 帧,
解码后显示实时画面,并给出帧率与丢帧统计。
Slint 桌面客户端。**两种身份,界面对应两个不同的组件。**
Rust + [Slint](https://slint.dev),不需要额外的系统库。
## 环境要求
- Rust(普通工具链——**不要**用固件那个 `esp` 工具链)
- 已在运行本固件的摄像头,且能在 2.4GHz WiFi 下访问到
## 目标架构
**编译前先设好。** 本目录位于固件源码树内部,Cargo 否则会继承
`../.cargo/config.toml` 去交叉编译 `xtensa-esp32s3-espidf`——报错会出现在依赖
深处,而不是一眼可见的地方。
`client/.cargo/config.toml` 因此把目标钉成宿主架构:
```toml
[build]
target = "aarch64-apple-darwin" # Intel Mac:x86_64-apple-darwin
# Linux:rustc -vV | grep host 的输出
```
查看你的:
```sh
rustc -vV | grep host
```
## 编译
```sh
cargo build --release
```
因为上面的 target 覆盖,二进制落在带架构名的目录里:
| 身份 | 画面 | 血量 | 需要绑定 |
|---|---|---|---|
| `--role referee`(默认) | 两块板左右并排 | 双方 | 否 |
| `--role team` | 一块画面 | 自己那一边 | **是,选定后不可更改** |
```sh
cd client && cargo build --release
./target/aarch64-apple-darwin/release/cam-client
```
## 运行
产物路径带三重名(`target/aarch64-apple-darwin/release/`)是因为下面那节解释了原因。
## 命令行
```text
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
--team <0|1> 自己属于哪一边,仅 team 端用
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
--config <路径> 配置文件位置
--list 列出发现的摄像头后退出
-h, --help 显示这段帮助
```
常用:
```sh
./target/aarch64-apple-darwin/release/cam-client # 用下面的默认值
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139 5600
cam-client --list # 先看看场上有几块板子
cam-client --role team --team 1 # 队伍一(首次运行会问绑定)
cam-client --role team --team 1 --camera-id 0x3D4D8A9A # 跳过选择直接绑
```
```
用法: cam-client [CAMERA_IP] [PORT]
CAMERA_IP ESP32 摄像头地址(默认 192.168.1.139)
PORT UDP 视频端口 (默认 5600)
`--list` 会等 5 秒收广播周期再打印,所以它不是即时的。
## 配置文件
默认 `~/.config/cam-client/config.toml`,`--config` 可改。
```toml
role = "team"
team = 1
camera_id = 0x3D4D8A9A
```
改 UI 时:
**没有配置文件也能跑**——裁判端就是无配置启动的,直接显示两路。队伍端缺配置时进入
摄像头选择界面。
```sh
cargo run --release -- 192.168.1.139
命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当另一
台用,不动文件。
TOML 的序列化是手写的(`config.rs`),没有引 `toml` crate。配置只有四个标量字段,
引一个解析器不值得;而手写解析换来的是零依赖,以及 `save` 与 `parse` 严格对称这一点
可以被一个测试覆盖。**忘记同步这两个方向的改动是这类代码最典型的 bug**,所以它们挨着
放,并且有 `parses_a_team_config` 盯着。
## 绑定流程
队伍端首次运行:
1. 接收线程开始收广播,板子每 2 秒发一次 Hello
2. 界面列出发现的所有板子(id + IP + 是否在线)
3. 点一个 → 写进配置文件
4. 此后配置里已有 `camera_id`,界面**不再出现选择页**
"选定不可更改"就落在这条路径上:没有 unbind 命令,`--camera-id` 是命令行覆盖而不是改
文件。
改绑的办法只有两种:手改配置文件,或 `rm ~/.config/cam-client/config.toml`。
### `Box::leak` 不是疏忽
绑定回调拿到的 `CallbackHandle` **必须活到窗口关闭**。它是 `Copy`,但 `Drop` 会注销回
调。
这里踩过一个坑:一开始用 `mem::forget`,编译器提示它对 `Copy` 类型无效——这个提示是
对的,`mem::forget` 传的是值,句柄被复制进闭包捕获后原值立刻 drop,回调在第一次点击
后就注销了,**第二次点击什么也不会发生**。`Box::leak` 让所有权真的离开作用域。
窗口里的 `Timer` 同理:被 drop 即停止,也泄漏掉。
## Slint 的三条硬约束
这三条都不是文档里写的,是撞出来的。
**`Weak<App>` 和 `slint::Image` 都不是 `Send`。**
`slint::Image` 尤其致命:JPEG 必须在别的线程解码(一张 VGA 的 JPEG 要几毫秒,放在收包
线程里会挤进数据报之间,造成链路上本不存在的丢帧),但解码出来的 RGBA 无法跨线程交给
UI。
解法是分两段:worker 线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在
事件循环里。`main.rs` 的 `decoded` 槽位就是这个用途,`take_decoded()` 是那段跨界。
**`ModelRc` 没有 `From<Vec<T>>`。**
只有 `From<Rc<M>>`、`From<&[T]>`、`From<[T; N]>`。所以要写:
```rust
std::rc::Rc::new(slint::VecModel::from(v)).into()
```
## 界面读数
而不是 `v.into()`。这个编译错误会在每个第一次用它的人身上重现一次。
```
streaming 20.8 fps 10.1 kB per frame 35 ms between frames
640x480 shown 146 dropped 0 (0.0%) 1270 datagrams
```
**`.slint` 的语法限制。**
丢帧率的存在是有理由的。这条链路**整帧丢弃而不重试**,所以单看帧率是有歧义的:
- `Rectangle` 这类基础元素**不能**挂 `callback`,要用 `TouchArea`
- `in` 属性不能从内部赋值
- 没有 `RowLayout`,用 `HorizontalLayout`
- 窗口不能同时给 `width`/`height` 和 `min-*`
| 帧率 | 丢帧 | 判断 |
## 线程
三个,加上 Slint 自己的:
| 线程 | 做什么 | 为什么单独 |
|---|---|---|
| 达到传感器上限 | 0 | 链路正常,瓶颈是传感器 |
| 远低于上限 | 有 | 瓶颈是网络 |
| 低 | 0,且显示 "no frames - camera?" | 板子没在推流 |
| `udp-reader` | 收包、解析、重组 | 不能让解码挤进数据报之间 |
| `jpeg-decode` | JPEG → RGBA | 几毫秒的活 |
| 事件循环 | 取 RGBA、造 `Image`、刷新界面 | `Image` 只能在拥有它的线程构造 |
| `slint::Timer` 回调(33ms) | 推进上面那条流水线 | 显示不需要 22fps 重绘 |
状态文字的颜色是同一信息的粗略版本:推流中绿色,否则琥珀色。
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
### 帧率是"计数"算出来的,不是"平均间隔"
### 槽位不会左右跳动
不是对帧间隔取平均。一次 UI 轮询之间可能已完成好几帧,所以平均间隔量的是
**显示**的刷新快慢,而不是**相机**的出帧快慢——那曾把上限 22fps 的传感器报成
36fps。一个笃定的错误数字比没有数字更糟。
裁判端按 sender_id 排序填两个槽位,**槽位保持稳定**——某块板子临时离线时不会重新
洗牌。如果每次都重新排版,一块板子掉线会让剩下的画面从左边跳到右边,现场看非常像
"两路串了"。
## 诊断
`view.sender_id != wanted` 才重建 `View`(清零帧率窗口),避免每 33ms 重置一次统计。
```sh
RUST_LOG=info ./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
## 帧率:按计数除以墙钟
```rust
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
```
```
frames shown 146 | dropped 0 (0.0%) | datagrams 1270 | undecodable 0 | short 0
不是对帧间隔取平均。
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更糟**,
现场你会以为还有性能余量。
## 血量:接口就位,数据源没有
```rust
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
```
| 计数器 | 含义 |
`ArmorSource` 是真的 trait,`protocol::ArmorHealth` 和 `ARMOR_PORT` 都已就位。装甲板接上
后只需换掉这一行,`render_health()` 会自动走 `Some(hps)` 分支,界面和其余代码都不用改。
现在没有数据源,所以**显示占位符而不是 0**。0 看起来像真值——现场看到两个 0 会以为
对方已经没血了,然后开始找裁判理论。
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
占位框消失,看起来像布局出错,而不是像"还没有数据"。
## 内存:解码只留最新一张
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
一张 VGA 的 RGBA 是 1.2 MB。两路各留两张就是 5 MB,而延迟换清晰度在这里没有意义——
观战的人要的是最新的画面,不是最完整的那张已经过时的。
## 为什么客户端收全部板子的视频
队伍端也订阅了所有板子,然后在**界面层**过滤到自己那一个。
反过来的做法(板子按订阅者的 role 决定推给谁)会引出一个不对称:板子得维护"谁是队伍
一谁是队伍二"的认知,但那块知识本来就在客户端。裁判端同时要两路的事实说明这个
认知不能放在板子上。
代价是队伍端浪费一份几乎相同的图像数据——实测 264 kB/s,占链路 6–9%,可以接受。
## 换机器要改 `client/.cargo/config.toml`
```toml
[build]
target = "aarch64-apple-darwin"
```
**这行必须改**,它是本文件唯一的存在理由。
client/ 嵌在固件的目录树里,cargo 会向上找到 `../.cargo/config.toml`,那里面
`target = "xtensa-esp32s3-espidf"` 加一个 ldproxy linker,只有在 ESP-IDF 交叉编译里才成立。
没有这个覆盖,桌面客户端会被交叉编译到 Xtensa,然后在依赖深处失败——`libc` 找不到
`MAP_FAILED` 之类的东西,因为它正在为一个没有 `mmap` 的嵌入式目标编译。
父配置的其它设置对非 ESP 目标都不适用,所以只需覆盖 target 一项。
## 线程安全清单
- 接收线程 → 其它:`Arc<Discovery>` / `Arc<LatestFrames>`,内部 `Mutex`
- 解码线程 → 事件循环:`Arc<Mutex<Vec<(u32, DecodedFrame)>>>`
- 事件循环 → 界面:直接调用(同一个线程)
- 绝不跨线程:`Weak<App>`、`slint::Image`、`ModelRc`
## 依赖
| crate | 为什么 |
|---|---|
| `frames shown` | 完整重组并交给界面的帧 |
| `dropped` | 因分片缺失而被放弃的帧 |
| `datagrams` | 能解析为分片的数据报 |
| `undecodable` | 收到了但解析不了的数据报 |
| `slint` / `slint-build` | 界面。macOS 默认渲染器是 femtovg,不需要额外系统库 |
| `image` | 只为 `jpeg` feature,解码用 |
| `protocol`(path) | 共享线路格式。用 path 而不是复制,两边不可能悄悄分叉 |
`undecodable` 非零意味着板子的数据报**到了但没看懂**——是协议或端口对不上,不是网络
问题。这两者很容易混淆,而这个计数器正是用来区分它们的。
`image` 关掉了 default features 只开 jpeg:这个客户端不读 PNG/TIFF/webp 以外的任何
东西,也不写任何文件。
## 排查
| 现象 | 原因 |
|---|---|
| 一直显示 `connecting to …` | 板子不在网上,或防火墙拦了入站 UDP 5600 |
| `no frames - camera?` | 板子停止推流:重启了,或掉线了 |
| 完全没有输出,也没有日志 | host triple 不对,见[目标架构](#目标架构) |
| 只有第一帧然后卡住 | 另一个客户端连在别的端口上;板子只推给最后宣告的那个 |
| 颜色错乱 | 通道顺序。当前构建用 `Rgba8Pixel`,另一个选项是 `Bgra8Pixel` |
| 编译在 `libc` / `memmap2` 报错 | 用的是 `esp` 工具链,或缺少 target triple 覆盖 |
## 目录结构
```
client/
├── Cargo.toml
├── build.rs 编译 ui/camera.slint
├── ui/camera.slint 窗口定义
└── src/
├── main.rs 命令行、线程编排、统计
└── receiver.rs socket、重组、JPEG 解码
```
线路格式在 [`../protocol`](../protocol),与固件共用,两端不会漂移。
## 线程模型
三个线程,且**没有任何 worker 线程碰 Slint 组件**:
| 线程 | 职责 |
|---|---|
| `udp-reader` | `recv_from`、解析、重组 |
| `jpeg-decode` | JPEG → RGBA |
| 主线程(事件循环) | RGBA → `slint::Image`、更新属性,每 30ms 一次 |
这是 Slint 的硬性约束而非偏好:组件只能在拥有它的线程上使用,`Weak<App>` 是
`!Send` 就是为了强制这一点。worker 线程去 upgrade 一个 weak handle 会拿到 `None`
然后什么都不做——现象和"界面卡死"完全一样。
解码单独一个线程,是因为一张 VGA 的 JPEG 要花几毫秒,放在收包线程里会挤进数据报
之间,造成**链路上本不存在**的丢帧。
## 可调项
| 项目 | 位置 | 说明 |
|---|---|---|
| 轮询间隔 | `src/main.rs` 的 `UI_POLL` | 30ms。更快只是白费 CPU,不会多出帧。 |
| 帧率统计窗口 | `FPS_WINDOW` | 2 秒。在 ~22fps 下更短的窗口会量化失真。 |
| 帧超时 | `receiver.rs` 的 `FRAME_TIMEOUT` | 500ms。一帧约 40ms,更慢的就是丢了。 |
| 宣告间隔 | `ANNOUNCE_INTERVAL` | 500ms。同时兼作保活。 |
| 主题 | `build.rs` | 默认 `fluent`;可试 `fluent-dark`、`material`、`native`。 |
这一侧**没有任何东西重新编码**。板子出的就是传感器里的 JPEG,客户端只解码。