Files
ESP32S3_vt/client
Horldsence cd8344c698 client: 修裁判端两格不等宽,加滚轮缩放与拖动平移,启动即最大化
**塌缩问题**(用户报的现象):裁判端窗口放大时,左边那格吃掉约 55% 宽度,右边
只有 45%;空的那一侧更极端时会被压成一条缝。

原因不是缺 stretch,而是 `HorizontalLayout` 先按内容的**最大**需求分配、剩下
的才按 stretch 分。左格写着"已连接,等待画面…"(8 个字),右格"未收到画面"
(5 个字),左格就多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。

三处一起改:

  1. 占位文字改成绝对定位(给出 x/y),不再参与父级尺寸计算。文字长短本不该
     决定画面大小。
  2. 两格统一 `preferred-width: 480px` + `horizontal-stretch: 1`,需求变成相同,
     于是精确平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
  3. 缩放指示与复位按钮也绝对定位。否则它们一出现就改变这一格的尺寸需求,把
     不等宽重新引进来——而且是在用户放大之后才出现,最难排查。

**缩放**:Slint 的 `Image` 没有缩放因子属性,所以把它的 `width`/`height` 按 zoom
放大后放进 `clip: true` 的容器,靠 offset 平移。滚轮改 zoom、拖动改 offset、双击
复位、缩回 1.0 时自动归位(否则下一次放大会从偏移位置开始,看起来像"跳了一下")。

`image-fit` 要跟着 zoom 切:`contain` 在 zoom=1 时保证完整可见且不变形,`fill`
才让画面跟着放大——只用 contain 的话放大不会有任何视觉变化。

**启动即最大化**:Slint 没有"最大化"这个状态,但有 `set_maximized`,它铺满工作区
且保留菜单栏和 Dock(真全屏 `set_fullscreen` 会盖掉它们,裁判终端要切去别的窗口
核对数据)。必须在 `show()` 之前调用,之后调用会被后端当成"用户点了最大化按钮"。

抽出 `VideoPane` 与 `StatusBar` 两个组件给两种身份复用。两者都不加 `export`——
它们只在 .slint 内部使用,加了会触发"doesn't inherit Window. No code will be
generated for it"的警告。

Slint 的两处写法记在这里,都是编译器试出来的:
  - `TouchArea` 的 `pressed` 是**属性**不是回调,写 `pressed => {}` 编译不过。
    拖动要用 `pointer-event(e)` 配 `PointerEventKind.down` / `.move`。
  - `EventResult.accept` 要写全限定;`accept` 单独写无法解析。
2026-10-05 21:28:32 +08:00
..

client

Slint 桌面客户端。两种身份,界面对应两个不同的组件。

身份 画面 血量 需要绑定
--role referee(默认) 两块板左右并排 双方 否
--role team 一块画面 自己那一边 是,选定后不可更改
cd client && cargo build --release
./target/aarch64-apple-darwin/release/cam-client

产物路径带三重名(target/aarch64-apple-darwin/release/)是因为下面那节解释了原因。

命令行

  --role <team|referee>   身份。默认 referee(无需配置即可看两路)
  --team <0|1>             自己属于哪一边,仅 team 端用
  --camera-id <0xID>      绑定到某块板子(MAC 派生,重启不变)
  --camera <IP>            向哪块板子发订阅(默认 255.255.255.255 广播)
  --config <路径>          配置文件位置
  --list                   列出发现的摄像头后退出
  -h, --help               显示这段帮助

常用:

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 可改。

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]>。所以要写:

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 重置一次统计。

帧率:按计数除以墙钟

self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();

不是对帧间隔取平均。

一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是显示的刷新快慢而不是相机 的出帧快慢——那会把上限 22fps 的传感器报成 36fps。一个笃定的错误数字比没有数字更糟, 现场你会以为还有性能余量。

血量:接口就位,数据源没有

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

[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,客户端只解码。