# 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 身份。默认 referee(无需配置即可看两路) --team <0|1> 自己属于哪一边,仅 team 端用 --camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变) --camera 向哪块板子发订阅(默认 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` 和 `slint::Image` 都不是 `Send`。** `slint::Image` 尤其致命:JPEG 必须在别的线程解码(一张 VGA 的 JPEG 要几毫秒,放在收包 线程里会挤进数据报之间,造成链路上本不存在的丢帧),但解码出来的 RGBA 无法跨线程交给 UI。 解法是分两段:worker 线程只处理纯数据(`Vec` + 尺寸),`slint::Image` 的构造留在 事件循环里。`main.rs` 的 `decoded` 槽位就是这个用途,`take_decoded()` 是那段跨界。 **`ModelRc` 没有 `From>`。** 只有 `From>`、`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。 ### 槽位不会左右跳动 裁判端按 sender_id 排序填两个槽位,**槽位保持稳定**——某块板子临时离线时不会重新 洗牌。如果每次都重新排版,一块板子掉线会让剩下的画面从左边跳到右边,现场看非常像 "两路串了"。 `view.sender_id != wanted` 才重建 `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 = 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` / `Arc`,内部 `Mutex` - 解码线程 → 事件循环:`Arc>>` - 事件循环 → 界面:直接调用(同一个线程) - 绝不跨线程:`Weak`、`slint::Image`、`ModelRc` ## 依赖 | crate | 为什么 | |---|---| | `slint` / `slint-build` | 界面。macOS 默认渲染器是 femtovg,不需要额外系统库 | | `image` | 只为 `jpeg` feature,解码用 | | `protocol`(path) | 共享线路格式。用 path 而不是复制,两边不可能悄悄分叉 | `image` 关掉了 default features 只开 jpeg:这个客户端不读 PNG/TIFF/webp 以外的任何 东西,也不写任何文件。 这一侧**没有任何东西重新编码**。板子出的就是传感器里的 JPEG,客户端只解码。