desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端

界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。

用户端:
- 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0;
- 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟,
  像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效;
- 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中:
  第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血");
- 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。

裁判端:
- 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放;
- 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位;
- 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。

密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的
解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。

其它:
- 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名,
  原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做);
- 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来;
- 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证);
- CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明
  JS 加载、IPC、WebSocket、React effect 四件事。

删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了
(槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名
同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。

测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
This commit is contained in:
2026-10-14 19:28:49 +08:00
parent a89d7ae5fb
commit 4f7a142a39
36 changed files with 4234 additions and 8739 deletions
+78 -35
View File
@@ -3,16 +3,22 @@
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
UDP 图传。
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;客户端是 Rust + Slint
桌面程序。
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 **Tauri v2 +
React**,用户端与裁判端两套页面(见 [`desktop/README.md`](desktop/README.md))。同一套
系统里还有一队一块的**装甲板**(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量,
并把血量广播给裁判终端。
```text
protocol/ 线路格式 + 帧重组 两端共用,无 ESP-IDF 依赖,13 个单元测试
. 固件 -> ESP32-S3 交叉编译,Xtensa
client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
protocol/ 线路格式 + 帧重组 三端共用,无 ESP-IDF 依赖,14 个单元测试
. 图传固件 -> ESP32-S3 交叉编译,Xtensa
armor/ 装甲板固件 -> ESP32-C3 独立 workspace,RISC-V;规则层 22 个单元测试
desktop/ 桌面端 Tauri v2 + React;用户端(血条 + 瞄准器)/ 裁判端
core/ 不含界面与 Tauri 的网络层、配置、密码(可单测)
src-tauri/ Tauri 后端:推流、命令、录制
src/ React 前端:两套页面 + HUD
```
三个 crate 各自独立构建,各有自己的 `.cargo/config.toml`。
五份产物各自独立构建,各有自己的 `.cargo/config.toml`。
## 拓扑
@@ -25,16 +31,19 @@ client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
┌────────────────────┼────────────────────┐
│ │ │
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
│ 图传板 A │ │ 图传板 B │ │ 装甲板主板 │
│ (队伍一) │ │ (队伍二) │ │ ×2(每队一块)│
└───────────────┘ └───────────────┘ └───────────────┘
```
**两块板子不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
**图传板不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
客户端决定。烧同一份固件即可,配置全在客户端一侧。
裁判终端同时收两块板子。队伍客户端也收全部板子的视频,然后在**界面层**过滤到绑定的
那一块。
**装甲板反过来:队伍是编译期定的**(`ARMOR_TEAM=0/1`,同时决定灯色红/蓝),因为血量
上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。
裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收
全部板子的视频,然后在**界面层**过滤到绑定的那一块。
## 实测性能
@@ -98,6 +107,9 @@ app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不
WIFI_SSID=你的2.4G网络
WIFI_PASS=密码
# 装甲板(armor/)用这两个里的第一个;队伍号决定灯色与上报里的 team 字段:
#ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
# 以下都有默认值:
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
@@ -106,29 +118,40 @@ WIFI_PASS=密码
```
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
在板子上带着别人的密码。
在板子上带着别人的密码。`ARMOR_TEAM` 同理:换队伍重刷装甲板固件即可,不用擦 NVS。
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
挨个试等于在猜一个本该已知的值。
### 2. 客户端
### 2. 装甲板固件(ESP32-C3)
```sh
cd client && cargo build --release
./target/aarch64-apple-darwin/release/cam-client --list
scripts/flash_armor.sh # 编译 + 烧录 + 20 秒串口日志
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量
```
`--list` 列出发现的板子。然后:
接线表、血量规则、C3 的 RMT/ADC 踩坑都在 [`armor/README.md`](armor/README.md)。
### 3. 桌面端
```sh
cam-client # 裁判端,无需配置
cam-client --role team --team 1 # 队伍端,首次运行问绑定
cd desktop
pnpm install
pnpm app # = tauri dev
```
**`client/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
client/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配
置,然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
没有板子也能把整条链路跑起来:`CAM_DEMO=1 pnpm app` 会合成**协议数据报**喂进真实的接收
路径(分片、重组、发现、掉血派生、两道闸门全在跑真代码),界面上标着"演示模式"。
首次启动会问这台机器是哪一端:**用户端**要绑一块图传板与一块装甲板(改动需密码),
**裁判端**不需要任何配置就能看两路。
**`desktop/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
desktop/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配置,
然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
界面、视频通道、密码模型、录制与排查都在 [`desktop/README.md`](desktop/README.md)。
## 发现是双向的
@@ -303,9 +326,9 @@ OV5640 的 JPEG 编码器**在传感器内部**。DVP 接口出来的一帧要
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
`slint::Image` 不是 `Send`,所以 worker 线程只能处理纯数据(`Vec<u8>` + 尺寸),`Ima
ge` 的构造必须留在事件循环。这条约束决定了整个客户端的线程结构,细节在
`client/README.md`。
现在桌面端把 JPEG **原样**交给 WebView,由浏览器的原生解码器解——后端连解都不解,
接收线程只做一次 16 字节头的拼接与一次广播。这一条比上面那条更彻底:省掉的不是几毫秒,
而是一整个线程与一次内存拷贝。细节在 [`desktop/README.md`](desktop/README.md)。
## 排查
@@ -315,11 +338,13 @@ ge` 的构造必须留在事件循环。这条约束决定了整个客户端的
| 几帧后停止出图,报 `NO-EOI` | `PSRAM DMA` 被打开了 |
| 板子在线但 `0 clients subscribed` | 订阅包格式不对。`Subscribe` 的 `role` 字节紧跟 27 字节头,在偏移 27 |
| 一直接到但画面偶尔解不出来 | 帧重组的问题。**先怀疑你的测量工具**:本项目的探测脚本就因为重组状态没按 `frame_id` 隔离,误报过 34 张坏帧 |
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
| 裁判端两块画面宽窄不一 | 检查那两格是否都写了相同的 `preferred-width`,且占位文字是绝对定位(`x`/`y`)而不是普通布局 |
| 放大后画面没变清晰 | `image-fit` 必须跟着 zoom 在 `contain`/`fill` 之间切。只用 `contain` 的话画面不会跟着放大 |
| 裁判端两块画面宽窄不一 | 两格必须是 `flex: 1 1 0` 且 `min-width: 0`:按内容分宽度时,占位文字长的那一格会多占十几个像素 |
| 裁判端两格显示同一块板子 | 旧版槽位分配的真 bug。现在候选里要排除另一个槽位占着的 id |
| 有画面但状态栏说"离线" | 视频帧也必须算存活证据(`Discovery::touch`),不能只看 Hello |
| 改了绑定却"没生效" | 改动需要先解锁;后端每个改动命令都会校验,界面上的入口只是入口 |
独立核对线路格式:
@@ -330,20 +355,38 @@ python3 scripts/udp_probe.py 10
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
有错,脚本会立刻报出不一致。
## 三份文档
## 四份文档
| | |
|---|---|
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
| `client/README.md` | 命令行、绑定流程、Slint 的硬约束、线程模型 |
| `armor/README.md` | 装甲板接线表、血量规则、上报、C3 的 RMT/ADC 踩坑 |
| `desktop/README.md` | 两套页面、视频通道路径、两道闸门、密码模型、录制与排查 |
## 装甲板(预留)
## 装甲板(已实现)
`protocol::ArmorHealth`、`ARMOR_PORT`(5601)、`MsgType` 5 号都已就位。客户端有真实的
`ArmorSource` trait,当前实现是 `NoArmorYet`。
`armor/` 是一套独立的 Rust workspace(目标 **ESP32-C3**,RISC-V):
**界面显示占位符而不是 0。** 0 看起来像真值——现场看到两个 0 会以为对方已经没血了。
- `armor-protocol` —— 规则层:血量(100 起、每击 −3)、每板独立的 50 ms 受击冷却、
受击后**只有那一路**灯带全黑 50 ms、按键 FSM、上报编码。纯计算,主机上 22 个测试。
- `armor-firmware` —— 固件:四路压电片 40 kHz ADC 连续采样、四路 WS2812(C3 只有一个
RMT 通道可用,靠 `rmt_tx_switch_gpio` 轮流驱动)、NVS、BOOT 键、WiFi + UDP。
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端实现 `ArmorSource` 即可,界面和其余代
码都不用改。
硬件是"一主三从":三块从板里没有 MCU,各自只有一路灯带和一路压电片,直接接线到主板。
接线表与实现细节见 [`armor/README.md`](armor/README.md)。
```sh
scripts/build_armor.sh # 编译(WIFI_SSID/WIFI_PASS/ARMOR_TEAM 取自 .env)
scripts/flash_armor.sh # 编译 + 烧录 + 串口日志
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量
```
桌面端侧:`desktop/core/src/armor.rs` 的 `ArmorHealthStore` 监听 5601,按 `(team, index)`
存最后一次读数,四条都在 1.5 s 内收到才显示——**没有数据时显示占位符而不是 0**,
0 看起来像真值,现场看到会以为对方已经没血了。界面上直接显示血量数字(整车一条血,
归零显示 `0 · 已击毁` 并转红);数字变化也会打进客户端日志。
同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板"
并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用
`encode_from(sender_id)` 把自己的 id 打进帧头。