Files
ESP32S3_vt/client/README.md
T
Horldsence eafa112959 文档:写防作弊、缩放布局、槽位语义
client/README.md 新增三节:

  防作弊  说明为什么必须在**接收端**挡而不是界面里隐藏——Hello 广播已经带着
          MAC 派生 id 和 IP,对手 tcpdump 一下就有,而数据本来也已收下解码。同时
          明确写出这一版挡不住什么:对手知道 IP 后可以自己发 Subscribe,固件会向
          任何订阅者推流。要真正封住得回固件加配对 token。

  缩放    image-fit 必须跟着 zoom 在 contain/fill 之间切,否则放大不会改变画面;
          缩回 1.0 要归位,否则下次放大从偏移开始像跳了一下。

  槽位    记下"按 id 顺序对号入座"这个做法错在哪,以及为什么会出现画面左右互换。

根 README 排查表加两条:两格宽窄不一的检查点,以及放大后画面没变清晰的原因。
2026-10-05 21:45:24 +08:00

366 lines
14 KiB
Markdown
Raw 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.
# client
Slint 桌面客户端。**两种身份,界面对应两个不同的组件。**
| 身份 | 画面 | 血量 | 需要绑定 |
|---|---|---|---|
| `--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
cam-client --list # 先看看场上有几块板子
cam-client --role team --team 1 # 队伍一(首次运行会问绑定)
cam-client --role team --team 1 --camera-id 0x3D4D8A9A # 跳过选择直接绑
```
`--list` 会等 5 秒收广播周期再打印,所以它不是即时的。
## 配置文件
默认 `~/.config/cam-client/config.toml`,`--config` 可改。
```toml
role = "team"
team = 1
camera_id = 0x3D4D8A9A
```
**没有配置文件也能跑**——裁判端就是无配置启动的,直接显示两路。队伍端缺配置时进入
摄像头选择界面。
命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当另一
台用,不动文件。
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()`。这个编译错误会在每个第一次用它的人身上重现一次。
**`.slint` 的语法限制。**
- `Rectangle` 这类基础元素**不能**挂 `callback`,要用 `TouchArea`
- `in` 属性不能从内部赋值
- 没有 `RowLayout`,用 `HorizontalLayout`
- 窗口不能同时给 `width`/`height` 和 `min-*`
## 线程
三个,加上 Slint 自己的:
| 线程 | 做什么 | 为什么单独 |
|---|---|---|
| `udp-reader` | 收包、解析、重组 | 不能让解码挤进数据报之间 |
| `jpeg-decode` | JPEG → RGBA | 几毫秒的活 |
| 事件循环 | 取 RGBA、造 `Image`、刷新界面 | `Image` 只能在拥有它的线程构造 |
| `slint::Timer` 回调(33ms) | 推进上面那条流水线 | 显示不需要 22fps 重绘 |
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
### 槽位一旦占定就不换人
裁判端有两个槽位,但**不要按 id 顺序重新对号入座**。
原来那版是:
```rust
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
let wanted = ids.get(i).copied();
```
看起来稳定,其实不是:低 id 的板子一离线,`ids` 就缩短成 `[B]`,B 落到**左**槽 ——
画面左右互换。现场看非常像"两路串了",而实际上只是排序。
现在的规则:还**在线**的槽位保持不动;空槽或已掉线的槽位才从还没被占用的板子里按 id
顺序取第一个。
```rust
let still_here = view.sender_id.is_some_and(|id| cameras.iter().any(|c| c.id == id));
if still_here { continue; }
```
换人时优先挑一个和当前不同的板子,否则会出现"板子短暂掉线又回来,槽位原地打转",
看起来像卡住。
`view.sender_id` 变了才重建 `View`(清零帧率窗口),否则每 33ms 重置一次统计,帧率
永远算不出来。
## 帧率:按计数除以墙钟
```rust
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
```
不是对帧间隔取平均。
一次 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` 决定颜色:空数组会让整个
占位框消失,看起来像布局出错,而不是像"还没有数据"。
## 防作弊:release 队伍端只接收绑定的那一块
对抗赛里两队都用这套系统看自己小车的图传,所以要防止对手开第二个窗口连到另一队
的板子。
**关键是这件事必须做在接收端,而不是界面里。**
一开始我按"release 下不显示 id 和 IP"理解,那样等于没做:
- 广播的 Hello 里就有 MAC 派生的 `sender_id` 和 IP。对手 `tcpdump` 一下全都知道,
界面藏不藏毫无关系。
- 数据本来已经收下并解码了。`.slint` 只控制显示,隐藏一行文字不阻止任何事。
所以是 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、只统计
绑定的那一块。对手的板子即使把流推到了本机端口(IP 已泄露,`DEFAULT_PORT` 是公开
常量),数据也不进入任何管线。
```rust
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
config.camera_id.filter(|_| args.camera_id.is_none())
} else {
None
};
```
四种情况不限制,各有理由:
| 情况 | 为什么不限制 |
|---|---|
| 裁判端 | 中立第三方,本来就要同时看两块板子 |
| debug 构建 | 开发时需要看到全场 |
| 队伍端未绑定 | 选择界面需要列出候选,否则无从选起 |
| `--camera-id` 覆盖了配置 | 显式指定了要看哪块,是有意的 |
### 遥测按编译模式门控
```rust
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
```
fps / 丢帧率 / 数据报数 / 帧间隔这些在队伍端 release 下**不产出**(空字符串,界面
也不显示),stdout 的诊断日志同样门控。
由编译模式决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开一次
就泄露了。
裁判端不受影响——双方遥测对判罚有用。
### `--list` 在 release 队伍端直接拒绝
它会打印场上所有板子的 id 和 IP,正是要防的那件事。与其列出来再叮嘱"别看",不如
不给:
```
Error: "--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。"
```
### 选择界面保留
现场换机器时还要用它重新绑定。数据侧的闸门已经足够,界面列出候选不构成额外泄露
(对手需要的是实时画面,不是 id 字符串)。
### 这挡不住什么
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后,可以自己发一个
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。
真要挡住需要固件侧做配对(首次绑定发一个 token,固件只接受带 token 的订阅),
代价是固件重启就失配。这次没做,因为需求定的是"避免一个队伍通过开其他窗口连接到
其他队伍的图传",而这需要对手主动在自己机器上装这套客户端——纯 UI 或客户端侧的
措施挡不住它。**这一条如果要真正封住,得回固件加 token。**
## 缩放与自适应布局
### 滚轮缩放 + 拖动平移
Slint 的 `Image` 没有缩放因子属性,所以实现是:把 `Image` 的 `width`/`height` 按
`zoom` 放大后放进 `clip: true` 的容器,靠 `offset-x`/`offset-y` 平移。
- 滚轮改 `zoom`(每次 ×1.25,上限 8 倍)
- 拖动改 `offset`(只在 `zoom > 1` 时生效——此时才有可移动的空间)
- 双击复位
- 缩回 1.0 时自动归位
最后一条容易漏:缩回 1.0 后若保留偏移,下一次放大会从偏移位置开始,看起来像
"跳了一下"。
`image-fit` 必须跟着 `zoom` 切:
```
image-fit: root.zoom <= 1.0 ? contain : fill;
```
`contain` 在 `zoom = 1` 时保证完整可见且不变形,`fill` 才让画面跟着放大。**只用
`contain` 的话放大不会有任何视觉变化**——这个 bug 很容易写出来,因为界面上确实
变大了,只是画面没跟上。
裁判端两路的缩放**互相独立**:滚轮只作用于鼠标所在的那一路。
### 为什么两格必须等宽
裁判端曾经放大时左边占 55%、右边 45%;空的那一侧更极端时会被压成一条缝。
原因不是缺 `stretch`。`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按
stretch 分。左格写"已连接,等待画面…"(8 字),右格"未收到画面"(5 字),左格就
多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。
三处一起改:
1. **占位文字改成绝对定位**(给出 `x`/`y`),不参与父级尺寸计算。文字长短本不该
决定画面大小。
2. **两格统一 `preferred-width` + `horizontal-stretch: 1`**,需求变成相同,于是精确
平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
3. **缩放指示与复位按钮也绝对定位**。否则它们一出现就改变这一格的尺寸需求,把不
等宽重新引进来——而且是在用户放大之后才出现,最难排查。
`preferred-width` 只接受长度或字面量 `100%`,不接受 `50%`。
### 启动即最大化
```rust
ui.window().set_maximized(true);
ui.show()?;
```
**必须在 `show()` 之前**——之后调用会被后端当成"用户点了最大化按钮",而不是初始
状态。
取 `set_maximized` 而不是 `set_fullscreen`:全屏会盖掉菜单栏和 Dock,切出去要按快
捷键。裁判终端偶尔要切到别的窗口核对数据。
## 内存:解码只留最新一张
解码线程在写入前先 `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 | 为什么 |
|---|---|
| `slint` / `slint-build` | 界面。macOS 默认渲染器是 femtovg,不需要额外系统库 |
| `image` | 只为 `jpeg` feature,解码用 |
| `protocol`(path) | 共享线路格式。用 path 而不是复制,两边不可能悄悄分叉 |
`image` 关掉了 default features 只开 jpeg:这个客户端不读 PNG/TIFF/webp 以外的任何
东西,也不写任何文件。
这一侧**没有任何东西重新编码**。板子出的就是传感器里的 JPEG,客户端只解码。