client/README.md 新增三节:
防作弊 说明为什么必须在**接收端**挡而不是界面里隐藏——Hello 广播已经带着
MAC 派生 id 和 IP,对手 tcpdump 一下就有,而数据本来也已收下解码。同时
明确写出这一版挡不住什么:对手知道 IP 后可以自己发 Subscribe,固件会向
任何订阅者推流。要真正封住得回固件加配对 token。
缩放 image-fit 必须跟着 zoom 在 contain/fill 之间切,否则放大不会改变画面;
缩回 1.0 要归位,否则下次放大从偏移开始像跳了一下。
槽位 记下"按 id 顺序对号入座"这个做法错在哪,以及为什么会出现画面左右互换。
根 README 排查表加两条:两格宽窄不一的检查点,以及放大后画面没变清晰的原因。
366 lines
14 KiB
Markdown
366 lines
14 KiB
Markdown
# 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,客户端只解码。
|