docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。 - fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示 - test(client): 按文件名核对媒体类型,避免依赖时间戳排序 验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
# 仓库协作约定
|
||||
|
||||
本文件适用于整个仓库,供开发者和代码助手使用。先阅读 [README.md](README.md) 与 [文档首页](docs/index.md),再按任务进入对应子项目。子目录若新增更具体的 `AGENTS.md`,按其适用范围执行。
|
||||
|
||||
## 项目边界
|
||||
|
||||
RMIC::2026 包含 ESP32-S3 图传固件、ESP32-C3 装甲板固件和 Tauri 桌面端。它们共享部分协议代码,但不共享同一个构建目标。
|
||||
|
||||
| 路径 | 修改内容 | 需要注意 |
|
||||
| --- | --- | --- |
|
||||
| `src/`、根 `build.rs` | S3 采集、发送、重连及构建参数 | Rust `std` + ESP-IDF,默认目标为 Xtensa |
|
||||
| `components/cam_wrapper/` | 相机 C/Rust FFI | 同步核对 C 结构体、绑定与缓冲所有权 |
|
||||
| `components/esp32-camera/` | 本地驱动及补丁 | 保留上游结构和本地补丁说明 |
|
||||
| `protocol/` | 线路编解码与帧重组 | 固件和桌面共用,保持协议兼容性 |
|
||||
| `desktop/core/` | 网络、配置、密码、装甲板与槽位 | 保持独立于 UI 与 Tauri,可单独测试 |
|
||||
| `desktop/src-tauri/` | 本地通道、命令、录制与维护 | 涉及文件、子进程、串口及 WebView 边界 |
|
||||
| `desktop/src/` | React、TypeScript、canvas 与样式 | 复用已有组件、绘制函数和样式令牌 |
|
||||
| `armor/` | C3 固件与受击规则 | 独立 workspace,目标为 RISC-V |
|
||||
| `scripts/` | 构建、打包、版本和诊断 | 修改入口或输出路径时同步文档与调用方 |
|
||||
| `reference/armor/` | 早期 C 版参考工程 | 默认用于核对,不作为当前实现入口 |
|
||||
|
||||
## 工具链与执行目录
|
||||
|
||||
- 根 `rust-toolchain.toml` 固定为 `esp-1.97.0.0`,根 `.cargo/config.toml` 默认目标是 `xtensa-esp32s3-espidf`,并启用 `build-std`。
|
||||
- `desktop/` 使用 Rust stable,但默认 Cargo 目标仍为 `aarch64-apple-darwin`;`protocol/` 和 `armor/armor-protocol/` 也有宿主目标覆盖。换平台时显式传入实际目标。
|
||||
- Windows x64 的宿主目标是 `x86_64-pc-windows-msvc`。主机检查显式使用 `+stable`、`--target` 和 `--config 'unstable.build-std=[]'`,避免继承固件配置。
|
||||
- S3 默认 ESP-IDF 为 v5.5.5;装甲板原环境使用 v6.0.1。`armor/.cargo/config.toml` 含原开发机的 `IDF_PATH`,构建前核对,不假设该路径在其他机器存在。
|
||||
- Cargo 根据执行目录查找 `.cargo/config.toml`;仅传 `--manifest-path` 不会自动切换到对应子项目的配置。需要依赖子项目配置时,先进入该目录。
|
||||
- 固件通过 `scripts/build.ps1` 或 `scripts/build.sh` 构建,以加载 `.env` 并设置组件、分区表路径。直接执行 `cargo build` 不会自动加载 `.env`。
|
||||
- 桌面前端使用 pnpm 和 `desktop/pnpm-lock.yaml`,安装时优先 `pnpm install --frozen-lockfile`。不要同时维护 npm / Yarn 锁文件。
|
||||
|
||||
环境准备和平台细节见[固件入门](docs/setup/firmware.md)、[客户端说明](docs/desktop/client.md)和[装甲板说明](docs/hardware/armor.md)。
|
||||
|
||||
## 修改方式
|
||||
|
||||
1. 开始前检查工作区差异,保留用户已有的修改;先定位行为所在模块,再修改对应层。
|
||||
2. 沿用现有命名、格式和模块划分。代码助手只处理任务相关变更,避免顺带重排全仓库、升级依赖或改动其他平台配置。
|
||||
3. 修改行为时补充能覆盖该行为的验证;纯文档或文案调整检查链接、命令、类型和显示效果即可。
|
||||
4. 完成后检查差异与空白问题,说明改了什么、做了哪些验证,以及仍未验证的范围。
|
||||
|
||||
### 协议与实时图传
|
||||
|
||||
- 线路定义以 `protocol/` 为准。修改字段、头长、消息类型或版本时,同时核对固件、桌面、`scripts/udp_probe.py` 等独立探测器和[协议文档](docs/protocol/udp.md)。
|
||||
- 视频帧必须按发送方和帧号隔离,重复分片不重复计数,不完整 JPEG 不进入解码和绘制。
|
||||
- 保留最新帧优先、有界等待和过期清理。不要让接收线程等待视频解码、写盘或 UI 消费,也不要以无限队列掩盖拥塞。
|
||||
- 修改 C 相机缓冲交接时,检查获取、归还、超时和提前返回路径,避免重复归还或泄漏。
|
||||
- 用户端绑定过滤、发现列表和裁判槽位有不同职责,不要为了显示候选设备而放开用户端视频接收限制。
|
||||
|
||||
### 桌面端与文案
|
||||
|
||||
- 业务规则放在 Rust 核心,前端使用既有快照与 IPC 接口;新增设置需核对配置序列化、默认值和旧配置兼容性。
|
||||
- 画面、准星、网格和命中反馈复用现有变换与绘制函数。改文案或布局后,按影响范围检查用户端、裁判端、设置和首次启动页面。
|
||||
- 中文表达清楚、具体,写明状态、条件和下一步。保留术语、错误原因、数字与验证边界,避免比喻式按钮和含糊的成功提示。
|
||||
- 密码限制用于防止现场误改;包内哈希校验用于检查完整性。文案不能将它们描述为不可绕过的访问控制或固件来源认证。
|
||||
- 预览和演示数据始终标明为合成数据,不用演示结果替代实际网络或硬件验证。
|
||||
|
||||
### 硬件、配置与源码保护
|
||||
|
||||
- 不提交 Wi-Fi 凭据、密码哈希、个人 SDK 绝对路径或本机缓存;不要在日志和交付说明中输出 `.env` 内容。保留已有 `.env`,示例使用占位值。
|
||||
- 仅做软件、文档或文案任务时,不执行烧录、擦除、写分区表或修改设备安全配置。涉及硬件任务时,按用户授权核对芯片、端口、分区与备份。
|
||||
- `components/esp32-camera/target/` 是驱动源码目录,不能按构建缓存删除或忽略。清理缓存时使用明确的构建目录,相关方法见[开发与验证](docs/development/firmware.md#清理构建缓存)。
|
||||
- 第三方组件的 README、许可证和本地补丁记录保留原有位置。不要把参考工程的旧实现误写成当前功能。
|
||||
|
||||
## 按变更选择验证
|
||||
|
||||
以下命令默认从仓库根目录执行,标明其他目录的除外。示例宿主为 Windows x64;其他平台用 `rustc +stable -vV` 核对 host 后替换 `--target`。
|
||||
|
||||
| 变更 | 至少检查 |
|
||||
| --- | --- |
|
||||
| 文档、链接、导航 | 文件与锚点有效,代码块闭合,命令路径正确;Mermaid 语法可解析 |
|
||||
| React / TypeScript / UI 文案 | `desktop/` 下 `pnpm build`,并预览受影响页面 |
|
||||
| 前端解码队列 | `desktop/` 下 `pnpm test:stream` |
|
||||
| 延迟校准标记 | `desktop/` 下 `pnpm test:latency` |
|
||||
| 线路编解码、帧重组 | `protocol/` 主机测试,必要时独立协议探测 |
|
||||
| 桌面业务规则 | `desktop/core/` 主机测试 |
|
||||
| Tauri 命令、录制、维护 | 后端编译检查和相关单元测试;录制测试需 ffmpeg / ffprobe |
|
||||
| 装甲板受击规则 | `armor/armor-protocol/` 主机测试 |
|
||||
| 固件发送、重连、缓冲交接 | 对应主机策略测试或 `scripts/test-latest-frame.py`;硬件表现另行实测 |
|
||||
| 版本生成脚本或生成路径 | 隔离样例检查输出位置与重复生成,再运行版本一致性检查 |
|
||||
|
||||
### 常用主机命令
|
||||
|
||||
```powershell
|
||||
cargo +stable test --locked --manifest-path protocol/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable test --locked --manifest-path desktop/core/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable test --locked --manifest-path armor/armor-protocol/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable check --locked --manifest-path desktop/src-tauri/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
前端命令在 `desktop/` 执行:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
pnpm test:stream
|
||||
pnpm test:latency
|
||||
```
|
||||
|
||||
浏览器预览:`pnpm dev`,打开 `http://127.0.0.1:1420/`;可用 `?preview=referee`、`?preview=first-run`、`?preview=password` 检查不同页面。预览不能验证 Tauri 原生能力。
|
||||
|
||||
测试通过后无需重复运行无关的大范围检查。依赖、工具链或设备条件不足时,明确记录实际执行结果和未验证项。**编译、主机测试、镜像生成不等于烧录、板上运行或硬件稳定性已验证。**
|
||||
|
||||
## 文档组织
|
||||
|
||||
- 根目录 `README.md` 介绍项目、展示架构并链接到详细文档;本文件保存仓库协作约定。子项目 README 保留简短入口。
|
||||
- 除 README 和 AGENTS 外,项目说明集中到 `docs/`,按 `setup/`、`development/`、`hardware/`、`desktop/`、`protocol/`、`maintenance/`、`testing/`、`releases/`、`design/` 归类。
|
||||
- 新增或迁移文档时,同步更新 `docs/index.md`、相对链接、标题锚点、脚本和代码中的引用。可复用既有主题时,优先修改原文。
|
||||
- 已被实现替代或明确放弃的方案可以删除,历史从 Git 查阅;保留仍有价值的实验条件、原始数字和限定。未实现需求写入 `docs/design/` 并标明状态。
|
||||
- 改写中文时保留事实、数字、命令、否定和确定程度,不将计划写成已完成,不将接收间隔写成端到端延迟。
|
||||
- README 使用 GitHub 风味 Markdown:表格用于比较,折叠块放可选步骤,提示块强调实际限制,Mermaid 解释结构和流程。使用稳定基础语法,不在图表中加入脚本或外部服务依赖。
|
||||
|
||||
## 版本与提交
|
||||
|
||||
提交标题采用 Conventional Commits:`type(scope): 说明`。破坏性改动升 major,`feat` 升 minor,`fix` / `perf` 升 patch;其他类型保持语义版本,构建号由 Git 提交数计算。
|
||||
|
||||
版本和日志由脚本生成,在根目录执行:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs --write
|
||||
node scripts/version.mjs --check
|
||||
```
|
||||
|
||||
不要手动修改 `version.json`、`desktop/src/lib/version.ts` 或 `docs/releases/changelog.md` / `.json`。`desktop/package.json` 和 `desktop/src-tauri/tauri.conf.json` 的版本字段也由同一脚本同步;其他字段按任务正常维护。提交或标签改变后,按发布流程重新生成;文档路径变更不得使日志重新出现在根目录。
|
||||
|
||||
完整规则见[版本维护](docs/development/versioning.md)。
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"major": 0,
|
||||
"minor": 2,
|
||||
"patch": 1,
|
||||
"build": 37,
|
||||
"protocol": 1,
|
||||
"date": "2026-10-19",
|
||||
"baseline": "v0.2.1",
|
||||
"commits": 2,
|
||||
"groups": {
|
||||
"docs": [
|
||||
{
|
||||
"scope": null,
|
||||
"text": "README 不写死构建号,改由 version.json 提供",
|
||||
"breaking": false
|
||||
},
|
||||
{
|
||||
"scope": null,
|
||||
"text": "按 humanizer 重写项目文档并同步 RMIC::2026 现状",
|
||||
"breaking": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
# 烧录 / 维护客户端架构设计(ESP32-S3 图传板)
|
||||
|
||||
> 状态:**提议中(Proposed)**,等待第 9 节的问题清单答复后冻结。
|
||||
> 落地情况:实际实现走的是 desktop 维护页 + `.zip` bundle,见 [docs/firmware-maintenance.md](docs/firmware-maintenance.md)。
|
||||
> 输入约束:现场存在 SPI Flash 坏块;需要兼顾 OTA 与现场排障;**不改固件源码、不重新编译**;
|
||||
> 优先“模板固件 + 动态 patch”。
|
||||
> 本文所有结论都在仓库里逐条对过代码/脚本/分区表/文档,引用位置见各节链接。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论摘要(先看这五条)
|
||||
|
||||
1. **“不改固件源码”可以做到;但“真 OTA”做不到。** 当前固件没有 `ota_0/ota_1/otadata` 分区,
|
||||
没有 `esp_ota` 调用,UDP 协议里也没有任何命令通道(只有 Hello/Subscribe/视频/血量)。
|
||||
设备自己没有能力接收固件。所谓“兼顾 OTA”,在现约束下只能是**现场 USB 串口更新**,
|
||||
或者先落一个 OTA-就绪的分区表(只改二进制,不改源码)作为未来选项。这是必须先决的问题。
|
||||
2. **坏块规避其实不需要重编译。** `scripts/flash.sh` 之所以要重编,是因为它把新偏移写回
|
||||
`partitions.csv`(构建输入)再触发重建;而 **App 镜像不包含自己的分区偏移**,偏移只存在于
|
||||
`0x8000` 的 `partition-table.bin`(0xC00 字节 + 末尾 16 字节 MD5)。
|
||||
→ 对同一份已构建镜像,**搜索偏移 + 重写分区表 + 原样写 App**,全程零编译。
|
||||
这是本方案最大的收益点,也是“模板 + patch”的第一块拼图。
|
||||
3. **SSID/密码不是 NVS,是 `.rodata` 里的字符串字面量**(`env!` 编译期注入)。
|
||||
二进制里能直接 grep 到明文。所以“动态改 SSID”= 字节级 patch App 镜像,
|
||||
且必须连带处理字符串长度常量与镜像 checksum/SHA256。
|
||||
长度常量能否可靠定位是这个方案真正的技术风险(见 7.2)。
|
||||
4. **坏块信息不能“读”出来,只能“写进去再读回来”差分出来。** `flash_id` 只给芯片型号与容量,
|
||||
SFDP 只给参数,启动日志只给症状。仓库里 `0x10863` 的 bit6 挂在 1,就是整段回读比对发现的
|
||||
(HARDWARE.md)。因此客户端必须自带「写入图案 → 回读 → 差分」与「写后整段校验差分」两条链路。
|
||||
5. **推荐形态:纯逻辑核心库 `flash-core` + 优先 CLI + 桌面端加一页**。
|
||||
把最危险的逻辑(镜像格式、分区表、偏移搜索、补丁)做成不碰 I/O 的纯函数,
|
||||
在主机上用真实 1.1 MB 镜像做往返测试——这与仓库已有的 `protocol/`、`stream_control`、
|
||||
`link_control`「能在主机跑测试的策略代码」风格一致。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状梳理(对着代码说)
|
||||
|
||||
### 1.1 工程与构建/烧录链
|
||||
|
||||
| 路径 | 角色 | 与本次需求的关系 |
|
||||
| --- | --- | --- |
|
||||
| [src/](src/) | S3 图传固件(根 Cargo 包,xtensa) | 烧录对象 |
|
||||
| [partitions.csv](partitions.csv) | 自定义分区表源文件 | 坏字节规避的**构建输入**(现状),本方案改为产物 |
|
||||
| [scripts/build.ps1](scripts/build.ps1) / [scripts/build.sh](scripts/build.sh) | 读 `.env` → `cargo build` | 现场不想跑 |
|
||||
| [scripts/flash.sh](scripts/flash.sh) | build → 搜偏移 → 改 csv → **重建** → 校验 → esptool 写 | 只支持 macOS,必须重编 |
|
||||
| [desktop/](desktop/) | Tauri v2 + React 客户端(视频/血量/录制) | **完全没有串口/烧录能力**(全仓库 `ssid|serial|espflash` 只命中 serde) |
|
||||
| [target/tools/espflash/espflash.exe](target/tools/espflash/espflash.exe) | 已落地的 espflash 4.4.0 | 现成的协议实现,可直接当库或 sidecar |
|
||||
| [target/firmware/](target/firmware/) | 已导出的 bootloader / partition-table / app / merged | 真实样本,用于离线验收测试 |
|
||||
|
||||
产物三元组(README「编译固件」表)是 **bootloader + partition-table + app**,
|
||||
今天现场要么用 ELF(`espflash flash ... --partition-table partitions.csv`),
|
||||
要么用 merged(从 `0x0` 全片写)。这两条路都**没有**坏字节规避能力。
|
||||
|
||||
### 1.2 固件侧:配置是编译期的,运行期没有任何入口
|
||||
|
||||
```rust
|
||||
// src/main.rs:36-37
|
||||
const SSID: &str = env!("WIFI_SSID");
|
||||
const PASSWORD: &str = env!("WIFI_PASS");
|
||||
```
|
||||
|
||||
- 每次启动都会用编译期值覆盖驱动配置:`wifi.set_configuration(&client_config(auth)?)`
|
||||
([src/main.rs:502](src/main.rs#L502))。所以 NVS 里即使存着旧配置也不会被采用。
|
||||
- `EspDefaultNvsPartition` 只是被交给 `EspWifi::new`([src/main.rs:178](src/main.rs#L178)),
|
||||
用于驱动自身的存储,不参与凭据解析。
|
||||
- 对比:[armor/armor-firmware/src/settings.rs](armor/armor-firmware/src/settings.rs) 的 C3 装甲板
|
||||
**有** NVS 设置层(队伍/颜色)——说明“运行期配置”在本仓库是被认可的,只是 S3 固件没做。
|
||||
- 构建脚本强制要求凭据([scripts/build.ps1:40-43](scripts/build.ps1#L40-L43)),
|
||||
`.env` 里是 `WIFI_SSID='SR-602-2G4'`(10 字节)、`WIFI_PASS=12345678`(8 字节)。
|
||||
- 已确认:该字面量就在构建产物里(对 [target/firmware/esp32s3-app.bin](target/firmware/esp32s3-app.bin)
|
||||
检索 `SR-602-2G4` 有命中)。
|
||||
|
||||
### 1.3 OTA:三条路全断
|
||||
|
||||
| OTA 必要条件 | 现状 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| `ota_0/ota_1/otadata` 分区 | 只有 `factory`([partitions.csv:16-18](partitions.csv#L16-L18)) | 可用**重写分区表**补上(不改源码) |
|
||||
| 固件内有 `esp_ota_*` 接收端 | 无(全仓库无 `esp_ota`) | **必须改源码**,与本约束冲突 |
|
||||
| 设备可达的控制/数据传输通道 | UDP 只有 Hello/Subscribe/视频/血量([protocol/README.md](protocol/README.md) 无线格式里无命令字) | **必须改源码** |
|
||||
|
||||
→ 结论:**“禁止重新编译” ⇒ 真 OTA 不可行**。这不是实现难度问题,是固件里没有任何代码能把
|
||||
新镜像写进 flash。
|
||||
|
||||
### 1.4 坏 Flash 的真实现状(两个模组,两种缺陷)
|
||||
|
||||
| 模组 | 证据 | 缺陷 |
|
||||
| --- | --- | --- |
|
||||
| 健康板(MAC `90:70:69:f8:f2:14`) | [budget40-verify-all.log](target/camera-diagnostics/budget40-verify-all.log):App @ `0x10000`,`verify OK` | 无 |
|
||||
| 故障板(开发板) | [HARDWARE.md:59-67](HARDWARE.md#L59-L67) | `0xFADE7` bit5–7 不可写(经验判据:`data[addr] & 0xE0 == 0`);`0x10863` bit6 读回为 1(期望 `0x2e` 实读 `0x6e`) |
|
||||
|
||||
关键推论(对架构有决定性影响):
|
||||
|
||||
- `0x10863` 位于**未分配空洞** `0x10000–0x70000`(`phy_init` 结束到 `factory` 开始之间,
|
||||
380 KB 全是空闲 flash)。→ 这块空洞就是天然的坏块探测试验场,写坏也不要紧。
|
||||
- `0xFADE7` 落在 App 镜像的**代码段**内(镜像布局见 1.5),所以“把那个字节改成别的安全值”
|
||||
会改坏指令;唯一安全的动作是挪分区。
|
||||
- 现行偏移已经漂过至少三次:`0x10000` → `0x50000`([LATENCY.md:88](LATENCY.md#L88))→ `0x70000`
|
||||
(当前 [partitions.csv](partitions.csv))。每次重编都要重新核对,这正是要消灭的脆弱点。
|
||||
- 缺陷有两种方向相反的形态:卡在 0(写不进 1)与卡在 1(写不进 0)。后者在“擦除后读回 0xFF”
|
||||
的检查里是隐形的,必须写 0 才暴露。探测逻辑必须两种都打图案。
|
||||
|
||||
### 1.5 已实测出来的镜像布局(`fps-final-raw-serial.log` 逐段对齐算出)
|
||||
|
||||
```
|
||||
0x000000 esp_image_header_t (24B) magic E9, segment_count, entry_addr, hash_appended
|
||||
0x000018 segment 0 header (8B) → load 0x3c0d0020 ← DROM / .rodata(SSID 字面量在这里)
|
||||
0x000020 segment 0 data (0x2a2a8) 开头 256B 是 esp_app_desc_t(version/project_name/idf_ver…)
|
||||
0x02a2d0 segment 1 (0x05d48) → 0x3fc9e500 DRAM
|
||||
0x030020 segment 2 (0xc60c0) → 0x42000020 IROM 代码(0xFADE7 落在这里)
|
||||
0x0f60e8 segment 3 (0x021c0)
|
||||
0x0f82b0 segment 4 (0x0163f8)
|
||||
0x10e6b0 segment 5 (0x00020)
|
||||
→ 段尾 16B 对齐填充 + 1B checksum(XOR, 种子 0xEF) + 16B 对齐 + 32B SHA256
|
||||
0x10e700 = 1,107,712 B(与 budget40-verify-all.log 的 0x10e700 一致)
|
||||
```
|
||||
|
||||
几个直接可用的副产品:
|
||||
|
||||
- `esp_app_desc_t` 在 **App 分区 + 0x20**,内含 `version` / `project_name` / `idf_ver` / 编译时间。
|
||||
不需要让板子跑起来,读 256 字节就能回答“这块板上到底是哪版固件”。
|
||||
- 分区表就是 0x8000 起的 0xC00 字节(`verify 0xc00 @ 0x8000` 佐证),末尾 16 字节 MD5。
|
||||
改写偏移只要重算这 16 字节。
|
||||
|
||||
### 1.6 需求 vs 现状 gap
|
||||
|
||||
| 需求 | 现状 | Gap |
|
||||
| --- | --- | --- |
|
||||
| 插入即自动识别 | 无 | 串口枚举 + ROM 探测 + 读镜像描述符 |
|
||||
| 选文件烧录 | 只能用脚本 + 手敲端口 | 文件分类(bundle / merged / app / ELF)+ 计划预览 |
|
||||
| 自动跳过坏块 | 半自动:改 csv + 重编 + 手工核对 | 偏移搜索 + 分区表重写 + 写后差分 |
|
||||
| 动态改 SSID | 改 `.env` + 重编 + 重烧 | 镜像内字面量 patch(含长度常量与哈希) |
|
||||
| OTA | 无 | **不可行**(见 1.3) |
|
||||
| 现场排障 | 串口日志靠人看 | 结构化阈值断言 + 回读差分 + 设备档案/回滚 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 三个方案对比
|
||||
|
||||
### 方案 A:桌面端内嵌烧录模块(Tauri 单进程)
|
||||
|
||||
在 [desktop/](desktop/) 里加 `flash` 页面,Rust 侧用 `espflash` 库直接开串口。
|
||||
|
||||
- ✅ 现场只有一个程序;已有 NSIS 打包、CI、配置与密码闸门可复用。
|
||||
- ✅ 视频端本来就要连板子,烧完顺手进监控很自然。
|
||||
- ❌ WebView 里跑长任务 + 进度 + 日志尾随,需要额外的事件/取消/背压设计。
|
||||
- ❌ 烧录是系统级、不可中断、有变砖风险的操作,和“看画面”共享一个进程的失败域。
|
||||
- ❌ 无法在 CI / 无人值守 / 远程 SSH 场景复用。
|
||||
|
||||
### 方案 B(推荐):纯逻辑核心库 + 双前端(CLI 优先,GUI 复用)
|
||||
|
||||
`flash/core`(无 I/O,纯函数,主机可测)+ `flashctl` CLI + 桌面端后续加一页调用 Tauri 命令。
|
||||
|
||||
- ✅ 最容易错的部分(镜像格式、checksum/SHA256、分区表 MD5、偏移搜索、补丁)全部可在主机测,
|
||||
且可以用仓库里真实的 1.1 MB 镜像做往返验收。
|
||||
- ✅ CLI 先天可脚本化:CI 能跑、现场能远程跑、能出确定退出码与机器可读报告。
|
||||
- ✅ GUI 后置,做完 CLI 后界面只是薄壳;失败域分开(GUI 崩了不影响烧录工具)。
|
||||
- ❌ 多一个二进制要分发(可由桌面端作为 sidecar 一起打包解决)。
|
||||
- ❌ 现场非技术用户需要 GUI(用 P2 的 Tauri 页面补上)。
|
||||
|
||||
### 方案 C:外部进程编排(客户端只做壳,调 `espflash.exe` / `esptool.py`)
|
||||
|
||||
- ✅ 落地最快,协议风险最低;`target/tools/espflash/espflash.exe` 已经在了。
|
||||
- ✅ 与现有 `scripts/*.sh` 心智一致。
|
||||
- ❌ 拿不到回读差分(espflash 内部校验只给 pass/fail,不给字节列表)——而差分是本方案
|
||||
坏块检测的核心,只能再调一次 `read-flash` 自己比。
|
||||
- ❌ 解析人类可读输出、处理 `esptool.py` 的 Python 依赖,现场极易碎(现有 flash.sh 就是被
|
||||
“macOS 串口名 + termios + 本地 IDF venv”绑死的)。
|
||||
- ❌ 补丁/分区表重写仍要自己写,等于 B 的核心工作一样不少,只省了协议层。
|
||||
|
||||
### 决策矩阵(权重按当前项目处境给)
|
||||
|
||||
| 维度 | 权重 | A 内嵌 | **B 核心库+双前端** | C 外部编排 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 可测试性(本方案最需要) | 30% | 6 | **9** | 4 |
|
||||
| 坏块检测能力(要回读差分) | 25% | 8 | **9** | 5 |
|
||||
| 现场可用性 | 20% | 9 | 7 | 6 |
|
||||
| 线上/CI 可自动化 | 15% | 3 | **9** | 6 |
|
||||
| 交付成本 | 10% | 6 | 6 | **9** |
|
||||
| 加权 | | 6.45 | **8.10** | 5.65 |
|
||||
|
||||
**推荐 B**。GUI 不是不做,而是放到 P2:先用 CLI 把危险逻辑做对、做实测。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐架构
|
||||
|
||||
### 3.1 分层(依赖方向单向,内层不认识外层的存在)
|
||||
|
||||
```
|
||||
┌─────────────────────────────── 前端(可替换) ───────────────────────────────┐
|
||||
│ flashctl CLI(P0/P1) desktop Tauri 命令 + 「维护」页(P2) │
|
||||
└───────────────┬────────────────────────────────────┬────────────────────────┘
|
||||
│ │
|
||||
┌───────────────▼────────────────────────────────────▼────────────────────────┐
|
||||
│ flash-device —— I/O 适配层(唯一碰硬件的地方) │
|
||||
│ · 串口枚举/VID-PID 过滤 · espflash Connection/Flasher 包装 · 回读 │
|
||||
│ · 监视器(日志尾随 + 断言)· 自动复位/进下载模式策略 │
|
||||
└───────────────────────────────┬──────────────────────────────────────────────┘
|
||||
│ 只传「字节 + 计划 + 观察值」
|
||||
┌───────────────────────────────▼──────────────────────────────────────────────┐
|
||||
│ flash-core —— 纯逻辑(无 I/O、无 serde-io、可在主机完整测试) │
|
||||
│ image │ parttable │ placement │ badmap │ manifest │ patch │ verify │ session │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
建议目录(沿用本仓库“每个目标一套 workspace”的既有做法):
|
||||
|
||||
```
|
||||
flash/
|
||||
core/ crate flash-core lib,纯函数 + 单测(含对真实镜像的往返测试)
|
||||
cli/ crate flashctl bin,含 flash-device 适配(串口/espflash)
|
||||
templates/ 模板生产脚本:从 ELF 生成 manifest(开发期一次性,不进现场)
|
||||
```
|
||||
|
||||
`desktop/src-tauri` 之后以 path 依赖引 `flash-core` + `flash/cli` 的适配层。
|
||||
|
||||
### 3.2 模块职责
|
||||
|
||||
| 模块 | 职责 | 关键不变量 / 失败模式 |
|
||||
| --- | --- | --- |
|
||||
| `image` | 解析 ESP-IDF App 镜像:header、段表、`esp_app_desc_t`、checksum 位置、SHA256 位置;重算哈希;打字节补丁 | 往返测试:对未修改镜像重算出的 checksum/SHA256 必须与文件里已有的**逐字节相同** |
|
||||
| `parttable` | 解析/序列化 0xC00 分区表;改某条的 offset;重算 MD5 | 输出必须能被 `esp-idf` 的 `esp_partition` 规则接受(4 KiB 对齐、不重叠、不越界) |
|
||||
| `placement` | 给定(镜像字节, 坏点表, 约束)枚举合法 App 偏移,**并给出每个候选被拒的原因** | 纯函数;同一输入必须给同一结果;找不到时必须说清是哪条约束卡住 |
|
||||
| `badmap` | 缺陷模型:`{addr, stuck0_mask, stuck1_mask, sector, confidence}`;提供 `byte_storable(addr, value)` | 两向谓词:`(v & stuck0)==0 && (v & stuck1)==stuck1` |
|
||||
| `manifest` | 模板清单:镜像哈希绑定、补丁点(偏移/容量/长度字位置)、能力标志 | 清单与镜像哈希不匹配 → **拒绝**,不做“尽力而为” |
|
||||
| `patch` | 应用凭据补丁(字符串 + 长度字)+ 哈希重算,输出变更日志 | 每次补丁都产出「改了什么、改在哪、前后值」,写进报告 |
|
||||
| `verify` | 期望字节 vs 回读字节 → 缺陷列表(地址、期望、实读、推导出的 stuck mask) | 差分必须能表达“读出比期望多 1 位/少 1 位” |
|
||||
| `session` | 状态机:`Enumerate → Identify → LoadBundle → Plan → Patch → Write → Verify → BootCheck → Report` | 每步可 dry-run;任一步失败可回滚到写前备份 |
|
||||
| `report` | `FlashReport`(本次)与 `DeviceProfile`(按 MAC 累积的坏点表/上次好镜像/历史) | 档案只作**提示**,最终决策永远以本次回读为准 |
|
||||
|
||||
### 3.3 数据模型(三份 JSON,人可读、可 diff、可回滚)
|
||||
|
||||
```jsonc
|
||||
// manifest.json —— 与模板镜像一起发布,模板生产线生成
|
||||
{
|
||||
"schema": 1,
|
||||
"app_sha256": "…", // 本清单只对这一份 app.bin 有效
|
||||
"chip": "esp32s3",
|
||||
"image": { "size": 1107712, "segments": 6, "hash_appended": true },
|
||||
"patch_points": [
|
||||
{ "id": "ssid", "kind": "utf8", "str_offset": 123456, "capacity": 10,
|
||||
"len_words": [{"offset": 210000, "width": 4}], "max_len": 32 },
|
||||
{ "id": "password", "kind": "utf8", "str_offset": 123466, "capacity": 8,
|
||||
"len_words": [], "max_len": 64 }
|
||||
],
|
||||
"capabilities": { "equal_length_only": ["password"] }
|
||||
}
|
||||
|
||||
// device-<mac>.json —— 主机侧设备档案(累积)
|
||||
{ "mac": "…", "chip": "esp32s3", "flash_jedec": "0x…", "flash_size": 16777216,
|
||||
"defects": [ {"addr": 1027047, "stuck0": 224, "stuck1": 0, "confidence": "verified"} ],
|
||||
"last_good": { "bundle": "…", "app_offset": "0x50000", "at": "…" },
|
||||
"history": [ { "at": "…", "app_offset": "0x70000", "result": "ok" } ] }
|
||||
|
||||
// report-<timestamp>.json —— 每次烧录一份,可交给现场/复核
|
||||
{ "device": "…", "bundle": "…", "plan": { "app_offset": "0x70000", "patches": ["ssid"], "erase": [...] },
|
||||
"write": { "ok": true }, "verify": { "mismatches": [] },
|
||||
"boot": { "signature": "Loaded app from partition at offset 0x70000", "ip": "…" } }
|
||||
```
|
||||
|
||||
### 3.4 状态机(每步都能停、都能讲清为什么)
|
||||
|
||||
```
|
||||
Enumerate ─▶ Identify ─▶ LoadBundle ─▶ Plan ──(人工确认)──▶ Patch ─▶ Write ─▶ Verify ─▶ BootCheck ─▶ Report
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ └─ 打印全部计划:偏移/补丁/擦除范围/预计耗时
|
||||
│ │ └─ 分类:bundle | merged | app | ELF;类型必须**显示并由人确认**
|
||||
│ └─ 芯片型号、MAC、Flash JEDEC/容量、0x8000 分区表、App 描述符(离线读)
|
||||
└─ 端口枚举 + VID/PID 过滤;只有一个候选才自动选中
|
||||
```
|
||||
|
||||
`Plan` 必须可 `--dry-run` 输出到 stdout(JSON),这是 CI 与现场复核的接缝。
|
||||
|
||||
---
|
||||
|
||||
## 4. 坏块屏蔽的落点(四层,各层归属明确)
|
||||
|
||||
### 4.1 检测(`flash-device::probe` + `flash-core::verify`)
|
||||
|
||||
| 手段 | 能发现什么 | 成本 | 用在哪 |
|
||||
| --- | --- | --- | --- |
|
||||
| `flash_id` / JEDEC + SFDP | 厂商、型号、容量、时序 —— **不含缺陷** | 毫秒 | 识别芯片、判断是否可疑批次 |
|
||||
| **图案探测**:向空洞 `0x10000–0x70000` 写 `00 / 55 / AA / E0 / 1F` 等图案并回读差 | 卡 0、卡 1、整扇区失效 | 380 KB × N 图案,约十几秒 | 首次接管一块板 / 用户点「深度体检」 |
|
||||
| **写后整段回读差** | 本次实际没写进去的字节(**权威**) | 1.1 MB 回读,秒级 | **每次烧录都做**,不可选 |
|
||||
| 串口日志诊断 | 症状:`invalid header` / checksum 失败 / rst 原因 | 秒级 | 板子已经在现场、只能看现象时 |
|
||||
|
||||
要点:
|
||||
- 图案探测必须双向:写 0 找卡 1,写 1 找卡 0。擦除态 `0xFF` 会让“卡 1”完全隐形,
|
||||
`0x10863` 就是这样在整段回读里才发现的。
|
||||
- 探测区选空洞而不是 NVS:**不要为了体检去毁 NVS**。若怀疑 NVS 区有坏点,单独提供
|
||||
“备份 NVS → 探测 → 还原”的危险模式,默认关闭。
|
||||
- probe 结束后必须 `erase` 干净,并**复核空洞回到 0xFF**,避免留下疑似数据。
|
||||
|
||||
### 4.2 决策(`flash-core::placement`,纯函数)
|
||||
|
||||
```
|
||||
输入:image(bytes), badmap, {flash_size, align(min 4KiB, 首选 64KiB), min_offset=0x20000,
|
||||
app_size=0x120000, 保留区表}
|
||||
对每个候选 off(升序,先 64 KiB 网格,再 4 KiB 网格兜底):
|
||||
① off 4 KiB 对齐,off ≥ min_offset,off + image.len() ≤ flash_size
|
||||
② off ≥ 0x20000 且分区不与 0x10863 所在扇区重叠(等价于“不覆盖已坏空洞”)
|
||||
③ 对每个“已证坏点 b”:若 off ≤ b < off+image.len(),要求 byte_storable(b, image[b-off])
|
||||
④ 分区 [off, off+0x120000) 不与任何 bad sector 相交(注意:**镜像尾部之外的 slack 不算**)
|
||||
输出:第一个满足全部条件的 off;若都没有,逐个候选打印“被 ③ 卡住,b=0xFADE7 需要 image 字节
|
||||
的 bit5..7 为 0,实际 0x6E”——这正是本仓库一贯的“失败要能解释”的风格。
|
||||
```
|
||||
|
||||
三点必须写进设计:
|
||||
|
||||
- **对齐放宽到 4 KiB**:App 分区只要求 4 KiB 对齐,`flash.sh` 用 64 KiB 是自我限制。
|
||||
候选从 ~230 个变成 ~3700 个,命中概率高一个数量级(单坏点 1/8,双坏点也能稳过)。
|
||||
- **分区大小 ≠ 镜像大小**:`0x120000` 的分区里镜像只占 `0x10E700`,尾部 71 KiB 是空的。
|
||||
坏扇区落在尾部 slack 里无害,别把它算成阻塞。
|
||||
- **bootloader 与分区表固定偏移**:`0x0`(0x5160) 与 `0x8000`(0xC00) 挪不了。
|
||||
这里只能做**字节级吸收**:例如坏点落在分区表某条 `label` 字段里,就换一个 label 让那一位合法
|
||||
(label 是自由文本);落在 `magic/offset/size/MD5` 上则该板报废。这条路径要显式实现并明确其边界。
|
||||
|
||||
### 4.3 执行(`flash-core::parttable` + `image`)
|
||||
|
||||
- 改写 `partition-table.bin` 中 `factory` 的 offset(和必要时 size)→ 重算末尾 16 字节 MD5。
|
||||
- App 镜像原样写入新偏移——镜像里没有任何“我在哪个偏移”的信息,改位置不改变它。
|
||||
- 只有在“分区表/bootloader 必须字节吸收”这一层才动镜像,并且要重算 checksum/SHA256。
|
||||
|
||||
### 4.4 验证与回滚(`session` + `report`)
|
||||
|
||||
- 写前:把 `0x0–0x9000` + 分区表 + App 分区整段回读到 `rescue-<mac>-<ts>.bin`(约 1.2 MB)。
|
||||
这既是回滚素材,也是一份“坏点快照”。
|
||||
- 写后:回读 → 差分 → 若有 mismatch,**先更新 badmap,再重新规划**,而不是简单重试。
|
||||
- 引导:抓串口,断言 `Loaded app from partition at offset 0x<off>` 与
|
||||
`ESP32 camera is live on <ip>`;失败则按 rst 原因给出分类结论(镜像校验失败 / 找不到分区 /
|
||||
Wi-Fi 没连上 / 相机没起来)。**写入成功 ≠ 能跑**,这是仓库文档反复强调的点。
|
||||
|
||||
**为什么这套不会变砖**:ROM 下载模式永远在,只要不是 eFuse 层面的破坏(secure boot / flash
|
||||
encryption / 烧写保护),任何 App/分区表错误都只是“不启动”,可以用同一根 USB 线救回来。
|
||||
前提是**确认这些 eFuse 特性没开**(见问题 Q6)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 动态 patch 的落点
|
||||
|
||||
### 5.1 必须重算的两样东西(否则一定不开机)
|
||||
|
||||
App 镜像的完整性由 bootloader 校验:
|
||||
|
||||
```
|
||||
checksum = 0xEF XOR (所有段的所有数据字节) // 1 字节,位于段尾 16B 对齐之后
|
||||
sha256 = SHA256(镜像中它之前的全部字节) // 32 字节,仅当 header.hash_appended=1
|
||||
```
|
||||
|
||||
任何字节补丁都会同时破坏这两者。`flash-core::image` 必须实现并在真实镜像上做往返测试
|
||||
(重算结果与文件里已有的字节必须完全相同)——这是整个方案最便宜、最有效的验收手段,
|
||||
在碰硬件之前就能证明实现是对的。
|
||||
|
||||
另外:`esp_app_desc_t` 里的 `app_elf_sha256` 是 ELF 的哈希,补丁后必然与内容不符。
|
||||
它只用于 core dump 关联,**没有校验作用**,可以接受;但要在报告里写一句,避免以后有人拿它
|
||||
当“镜像指纹”。真正的指纹用整镜像 SHA256。
|
||||
|
||||
### 5.2 SSID / 密码补丁的两种模式(这是本方案的真实技术风险)
|
||||
|
||||
事实基础:
|
||||
|
||||
- `const SSID: &str = env!("WIFI_SSID")` 是编译期常量,长度也是编译期常量。
|
||||
- 字面量本身在 `.rodata`(segment 0,`0x3c0d0020` 附近),二进制里可 grep。
|
||||
- 但**长度**在机器码里可能以三种形式存在:`.rodata` 里的 `(ptr,len)` 静态对
|
||||
(`format_args!` 走 trait object 时几乎必然产生一份)、`movi` 立即数、或两者混用。
|
||||
|
||||
| 模式 | 做法 | 前提 | 风险 |
|
||||
| --- | --- | --- | --- |
|
||||
| **M1 等长补丁** | 只改写字符串字节,长度不动 | 新值**字节长度 == 模板槽位长度** | 低。仅当现场 SSID 长度固定时可用 |
|
||||
| **M2 长度字补丁** | 改字节 + 改所有 `(ptr,len)` 长度字 | 模板生产期能**定位全部**长度字并验证 | 中高。混用立即数时会出现“日志显示新 SSID 但连接用的旧长度” |
|
||||
|
||||
工程对策(三条,缺一不可):
|
||||
|
||||
1. **模板生产线生成 manifest**(`flash/templates/`):构建出 ELF 后,用符号表 + 字面量地址
|
||||
反查所有引用,产出 `str_offset / capacity / len_words[]`。这是一次性、离线、可审计的活动,
|
||||
**不发生在现场**。
|
||||
2. **清单里写清能力**:`capabilities.equal_length_only: ["password"]` 之类。M2 只在
|
||||
“长度字全部定位成功”时启用;否则清单退化为 M1,客户端在长度不匹配时**拒绝并给出可执行的
|
||||
建议**(“本模板 SSID 槽位 10 字节,请把路由器 SSID 设为 10 字节,或使用 N 字节模板”)。
|
||||
3. **硬件验收一次,之后才允许现场用**:模板发布前跑一遍「patch 成另一个等长 SSID → 烧 → 看日志
|
||||
是否连上」。连不上就把该模板降级为 M1。这一步可以脚本化,属于发布门禁。
|
||||
|
||||
> 注意 `format_args!` 会**把补丁后的 SSID 原样打进日志**(`"{SSID}" was not in the scan results`),
|
||||
> 所以现场日志本身就能佐证补丁是否生效——这是免费的验收信号,也是排障的第一手材料。
|
||||
|
||||
### 5.3 模板固件的构建约定(一次性,发布期)
|
||||
|
||||
```text
|
||||
WIFI_SSID = 32 字节占位(留满槽位,便于 M2);WIFI_PASS = 64 字节占位
|
||||
其余 .env 参数按交付基线冻结
|
||||
产物:bootloader.bin + partition-table.template + app.bin + manifest.json → 一个 bundle
|
||||
```
|
||||
|
||||
这样**现场只选 SSID/密码,不再触碰 `.env`、不再跑 cargo**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键风险
|
||||
|
||||
| # | 风险 | 影响 | 对策 |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | 长度字定位不全,M2 补丁“半生效” | 板子连不上 Wi-Fi,看起来像“密码错了” | 清单声明能力 + 发布门禁实测 + 不匹配就拒绝(不猜) |
|
||||
| R2 | 补丁后未重算 checksum/SHA256 | 不启动(可救,但现场中断) | 真实镜像往返测试;写入前本地自校验 |
|
||||
| R3 | 偏移漂移:新镜像可能**没有**合法偏移 | 该板无法升级 | 4 KiB 对齐搜索 + 尾部 slack 检查 + 找不到时明确报“这块板的 flash 太坏” |
|
||||
| R4 | 坏点表串板/过期(缺陷随温度、擦写次数变化) | 写进去才发现 | 档案只作提示;**每次写后必做回读差分** |
|
||||
| R5 | 坏点在 bootloader/分区表固定区 | 无法规避 | 仅 label 等自由字段可吸收;其余明确报废,别赌 |
|
||||
| R6 | 自动复位/进下载模式不可靠 | “插入即烧”的第一步就失败 | 先实测;失败时降级为“按 BOOT 提示 + 重试”,并把步骤画在界面上 |
|
||||
| R7 | 多程序抢串口 / 抢 UDP 5600 | 现场打不开或烧不了 | 串口独占提示;烧录模式与视频模式互斥提示(沿用现有 `startup-error.txt` 风格) |
|
||||
| R8 | secure boot / flash encryption / eFuse 保护开启 | **整套方案失效**(镜像是加密/签名的) | 上线前确认关闭;客户端探测到加密镜像直接拒绝并说明 |
|
||||
| R9 | 模板与清单版本错配 | 打到错误偏移 | 清单绑定 `app_sha256`,不匹配即拒绝 |
|
||||
| R10 | OTA 需求最终被判定为硬需求 | 架构返工 | **第 9 节 Q2 必须先答**;先落 OTA-ready 分区表作为廉价期权 |
|
||||
| R11 | `esp_app_desc_t.app_elf_sha256` 补丁后不符 | 误导后人 | 报告里注明;指纹改用整镜像 SHA256 |
|
||||
| R12 | 破坏 NVS(改分区布局或误擦) | 丢绑定/校准,重连变慢 | 默认不擦 NVS;提供备份/还原;改布局前显式确认 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 必须向用户确认的问题(决定架构是否成立)
|
||||
|
||||
| # | 问题 | 为什么现在必须答 | 我的默认建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Q1** | 客户端宿主平台与技术栈?Windows-only 还是三平台?扩展 [desktop/](desktop/) 还是独立工具? | 决定打包、串口栈、UI 形态 | Rust 核心库 + CLI(P1)→ Tauri 页面(P2),Windows 优先 |
|
||||
| **Q2** | **“OTA”到底指什么**:(a) USB 现场更新;(b) 设备自己从网上拉;(c) 局域网推 | (b)(c) 与“禁止改固件”**直接互斥**,架构会完全不同 | 先按 (a) 做;同时只改分区表落一个 OTA-ready 布局作期权 |
|
||||
| **Q3** | SSID 修改是否接受**等长约束**?现场 SSID 长度是否固定?是否出现过 ≥16 字节或含中文/空格? | 决定 M1 够不够,还是必须做 M2 并承担风险 | 双模:M1 保底,M2 需模板验收通过才启用 |
|
||||
| **Q4** | 是否允许**写入图案做坏块探测**(十几秒、会擦除未分配空洞)?坏板一共几块?坏点是否稳定复现? | 决定检测深度与是否值得做 probe | 首次接管一块板自动探测一次;之后只做写后差分 |
|
||||
| **Q5** | 现有板子的**自动复位/进下载模式**可靠吗(是否必须手按 BOOT/EN)? | 决定“插入即烧录”能不能成立 | 实测;不可靠则做“半自动 + 引导” |
|
||||
| **Q6** | 是否启用过 secure boot / flash encryption / eFuse 写保护? | 开了则本方案**整体失效** | 假设关闭,需你确认 |
|
||||
| **Q7** | 现场更新时可否重写**分区表与 NVS**?丢了客户端绑定/设置能接受吗? | 有些修复必须动布局 | 默认不碰 NVS;改布局前备份并确认 |
|
||||
| **Q8** | 是否需要同一个客户端也支持 **ESP32-C3 装甲板**([armor/](armor/))? | 影响芯片抽象与模板管理 | 预留 chip 抽象,P2 再接 C3 |
|
||||
| **Q9** | 现场是否允许**任意固件文件**(用户挑一个 `.bin/.elf`),还是只允许“官方 bundle”? | 决定 manifest 从哪来:bundle 自带 vs 客户端现场解析 ELF | 两者都支持:任意文件走“无 patch 模式”(坏块规避仍然有效),bundle 才有 SSID patch |
|
||||
| **Q10** | 坏块规避失败(该板无合法偏移)时的产品行为:拒绝升级 / 允许“尽力而为” / 提示换模组? | 决定失败语义 | **拒绝并给证据**,不做尽力而为 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 分期建议
|
||||
|
||||
| 阶段 | 内容 | 验收标准 |
|
||||
| --- | --- | --- |
|
||||
| **P0**(1 周) | `flash-core`:`image` + `parttable` + 真实镜像往返测试;CLI 能“读懂一份 bundle + 打印计划(dry-run)” | 未修改镜像的 checksum/SHA256 重算与原文件逐字节一致 |
|
||||
| **P1**(2 周) | `placement` + `verify` + 串口写入 + 回读差分 + 引导断言;**在健康板与故障板上各跑通一次** | 故障板零重编译完成升级;`0xFADE7` 落点字节被证明可写;日志断言通过 |
|
||||
| **P2**(2 周) | manifest 生产线 + SSID/patch(M1 先上,M2 门禁通过再开)+ 设备档案 + 回滚 | 同一 bundle 换 SSID 后板子连上新 AP,无需任何编译 |
|
||||
| **P3** | desktop Tauri「维护」页(复用 P1/P2 的一切) | 现场非技术用户可完成“插入 → 识别 → 选包 → 烧 → 看到连上” |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:两条 ADR
|
||||
|
||||
### ADR-001:坏字节规避从“重编译”改为“分区表重写”
|
||||
|
||||
- **状态**:提议中
|
||||
- **背景**:`0xFADE7` 的 bit5–7 不可写;`scripts/flash.sh` 通过改 `partitions.csv` 并重建来避开它,
|
||||
但重建会移动镜像内容,于是需要“第二次验证”,且只支持 macOS、必须带完整 IDF 工具链。
|
||||
- **决策**:把 App 偏移从**构建输入**改成**烧录期的计划值**:搜索偏移 → 改写
|
||||
`partition-table.bin`(重算 MD5)→ App 镜像原样写入。
|
||||
- **备选**:① 继续重编译(现状);② 为每块板生成专用 merged 镜像;③ 用 `otadata` 之类间接重定向。
|
||||
- **影响**:现场彻底摆脱工具链;同一份镜像可服务健康板与故障板;代价是客户端必须正确实现镜像与
|
||||
分区表格式(用真实镜像的往返测试兜底)。
|
||||
|
||||
### ADR-002:核心逻辑与 I/O 分离,CLI 先于 GUI
|
||||
|
||||
- **状态**:提议中
|
||||
- **背景**:本方案里最容易错、错了最难在现场发现的,是镜像格式、哈希重算、偏移搜索与差分;
|
||||
而这些恰好都能在主机上测。仓库已有 `protocol/`、`stream_control`、`link_control` 的先例。
|
||||
- **决策**:`flash-core` 纯函数、无 I/O;`flash-device` 单独承担串口与协议;GUI 最后接。
|
||||
- **备选**:直接写进 Tauri 后端(失败域大、难测);用 Python 脚本 (现有风格) 但无法复用 Rust 类型。
|
||||
- **影响**:多一个 crate 与一条编译链;换来可在 CI 上守护的不变量,以及将来 GUI/CLI/远程三处复用。
|
||||
@@ -1,357 +1,292 @@
|
||||
# RMIC::2026 视野链路系统
|
||||
<p align="center">
|
||||
<img src="desktop/src-tauri/icons/128x128.png" width="88" height="88" alt="Simba Robotics 队标">
|
||||
</p>
|
||||
|
||||
黑龙江大学 602 实验室 · Simba Robotics 的机器人对抗赛视野链路。ESP32-S3 采集 OV5640 输出的硬件 JPEG,经 UDP 推给局域网内的桌面端;桌面端 RMIC::2026 提供队伍端与裁判端两套界面,接收 ESP32-C3 装甲板的血量广播,并负责固件维护。
|
||||
# RMIC::2026 · 视野链路系统
|
||||
|
||||
当前版本 **v0.2.1**(线路协议 1,构建号见 `version.json`)。仓库已从 `ESP32S3_vt` 改名为 `simba-rmic-2026`,旧地址会重定向到新地址:
|
||||
**黑龙江大学 602 实验室 · Simba Robotics**
|
||||
|
||||
面向机器人对抗赛的局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量,桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。
|
||||
|
||||
[](docs/setup/firmware.md)
|
||||
[](docs/hardware/armor.md)
|
||||
[](docs/desktop/client.md)
|
||||
[](docs/protocol/udp.md)
|
||||
|
||||
[文档首页](docs/index.md) · [硬件接线](docs/hardware/camera.md) · [故障排查](docs/testing/troubleshooting.md) · [改动记录](docs/releases/changelog.md) · [开发约定](AGENTS.md)
|
||||
|
||||
> [!NOTE]
|
||||
> 版本、构建号与线路协议号以 [`version.json`](version.json) 为准。改动记录从 Git 历史生成,维护方式见[版本说明](docs/development/versioning.md)。
|
||||
|
||||
## 导航
|
||||
|
||||
- [系统架构](#系统架构)
|
||||
- [功能与验证范围](#功能与验证范围)
|
||||
- [快速开始](#快速开始)
|
||||
- [发现与收流时序](#发现与收流时序)
|
||||
- [仓库结构](#仓库结构)
|
||||
- [开发与验证](#开发与验证)
|
||||
- [文档与后续工作](#文档与后续工作)
|
||||
|
||||
## 系统架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Camera["图传板 · ESP32-S3"]
|
||||
Sensor["OV5640<br/>硬件 JPEG"] --> Firmware["采集与分片<br/>最新帧发送"]
|
||||
end
|
||||
|
||||
subgraph Armor["装甲板 · ESP32-C3"]
|
||||
Piezo["四路压电片"] --> Rules["受击规则与整车血量"]
|
||||
Rules --> LEDs["四路 WS2812"]
|
||||
end
|
||||
|
||||
subgraph Desktop["RMIC::2026 · 桌面端"]
|
||||
Core["Rust 核心<br/>发现、重组与血量状态"]
|
||||
Backend["Tauri 后端<br/>本地通道、截图与录制"]
|
||||
UI["React / WebView<br/>用户端或裁判端"]
|
||||
Core --> Backend
|
||||
Backend -->|"本地 WebSocket:JPEG 与状态"| UI
|
||||
UI -->|"IPC:配置与操作"| Backend
|
||||
end
|
||||
|
||||
Firmware -->|"UDP 5600:Hello 与视频分片"| Core
|
||||
Core -->|"Subscribe:广播与定向续订"| Firmware
|
||||
Rules -->|"UDP 5601:血量广播"| Core
|
||||
```
|
||||
|
||||
两块图传板各自发送,裁判端同时接收两路;用户端只接收绑定图传板的画面。装甲板主板连接四路传感器和灯带,其余从板不含 MCU。
|
||||
|
||||
| 网络项 | 配置 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| 图传板 Wi-Fi | 2.4 GHz | 板卡与客户端需处于可互通的局域网 |
|
||||
| 视频与发现 | UDP `5600` | Hello、Subscribe 与 JPEG 分片 |
|
||||
| 装甲板上报 | UDP `5601` | 每 200 ms 广播四路血量 |
|
||||
| 本地界面通道 | 回环 WebSocket + Tauri IPC | JPEG 由 WebView 解码,网络接收线程不做视频解码 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 网络需要允许广播和设备间互访。请关闭 AP 客户端隔离,并允许客户端通过防火墙。VPN / 虚拟网卡可能影响广播路由,排查方法见[客户端说明](docs/desktop/client.md#windows-x64-构建)。
|
||||
|
||||
## 功能与验证范围
|
||||
|
||||
| 模块 | 已有功能 | 使用条件与边界 |
|
||||
| :--- | :--- | :--- |
|
||||
| 图传固件 | VGA 彩色 JPEG、UDP 分片、多端订阅、Wi-Fi 重连、过期帧放弃 | 当前记录约 30 fps;不同网络、板卡与画质下需复测[^performance] |
|
||||
| 用户端 | 本队画面、双方装甲条、瞄准器、偏移校准、命中反馈 | 先绑定图传板与装甲板;改绑、切换模式和退出受密码限制 |
|
||||
| 裁判端 | 双路画面、队名编辑、装甲板发现、槽位分配、截图、录制与回放 | 录制需要 ffmpeg;图传槽位可手动指定 |
|
||||
| 装甲板 | 四路受击检测与灯带、整车血量、UDP 上报 | 初始 100 血,每次受击扣 3 点;队伍号在编译期选择 |
|
||||
| 固件维护 | USB 读取设备、检查固件包、选择候选槽位、备份和写后回读 | App 写入与回读已有实机记录;完整分区切换尚未验证 |
|
||||
|
||||
发送端在有限预算内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限约束的是本机积压,**不代表相机到屏幕的端到端延迟**。
|
||||
|
||||
> [!WARNING]
|
||||
> 固件维护会写入设备 Flash。使用前核对芯片、分区和备份,并阅读[已验证与未验证范围](docs/maintenance/firmware.md#已验证--未验证)。ESP32-S3 与 ESP32-C3 的固件不能混用。
|
||||
|
||||
[^performance]: [最新帧处理记录](docs/testing/realtime.md)、[功率与拥塞记录](docs/testing/power.md)和[延迟测量](docs/testing/latency.md)保留了配置、观察窗口与测量限制。历史数字不是所有部署环境的性能保证。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 无板卡:先看界面
|
||||
|
||||
安装 Node.js 和 pnpm 后,在仓库根目录执行:
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
打开 [用户端预览](http://127.0.0.1:1420/) 或 [裁判端预览](http://127.0.0.1:1420/?preview=referee)。预览由浏览器合成状态和画面,无需 Rust 后端或板卡。
|
||||
|
||||
> [!TIP]
|
||||
> 检查首次启动流程时,可使用 `?preview=first-run` 和 `?preview=password`。需要测试实际接收、重组和录制路径时,使用原生客户端的 `--demo` 模式;两种方式显示的都是合成数据。
|
||||
|
||||
### 2. 连接板卡:运行桌面端
|
||||
|
||||
先按[客户端说明](docs/desktop/client.md#快速开始)安装宿主平台依赖。Windows x64 需要 Rust stable MSVC、Visual Studio C++ Build Tools、Windows SDK 和 WebView2;录制另需 ffmpeg。
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows x64:开发运行与安装包</strong></summary>
|
||||
|
||||
在 `desktop/` 执行。显式指定目标,并清空从根固件项目继承的 `build-std`:
|
||||
|
||||
```powershell
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm app --target x86_64-pc-windows-msvc -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
生成 NSIS 安装包:
|
||||
|
||||
```powershell
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
安装包位于 `desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/`。显示名为 **RMIC::2026**,Windows 可执行文件和安装包使用 **RMIC-2026**。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS / Linux:目标选择与演示模式</strong></summary>
|
||||
|
||||
`desktop/.cargo/config.toml` 默认目标为 `aarch64-apple-darwin`。Intel Mac 使用 `x86_64-apple-darwin`,常见 Linux x64 使用 `x86_64-unknown-linux-gnu`;其他架构先用 `rustc +stable -vV` 核对 host。
|
||||
|
||||
以 Linux x64 为例,在 `desktop/` 执行:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm app --target x86_64-unknown-linux-gnu -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
同一命令末尾追加 `-- --demo` 可启动原生演示模式。它将合成数据报送入正常接收与重组路径,适合检查截图、录制和命中反馈;预览与演示的区别见[客户端说明](docs/desktop/client.md#演示模式没有板子也能把整条链路跑起来)。
|
||||
|
||||
</details>
|
||||
|
||||
首次启动选择用户端或裁判端。用户端需要设置现场密码并绑定本车板卡;裁判端自动分配图传槽位,装甲板按上报队伍分配。对应关系不符时,在设置中手动指定。
|
||||
|
||||
### 3. 编译图传或装甲固件
|
||||
|
||||
| 硬件 | 构建目标 | 入门文档 |
|
||||
| :--- | :--- | :--- |
|
||||
| Freenove ESP32-S3-WROOM CAM + OV5640 | `xtensa-esp32s3-espidf` | [环境、参数与烧录](docs/setup/firmware.md) |
|
||||
| ESP32-C3 装甲板主板 | `riscv32imc-esp-espidf` | [接线、规则与构建](docs/hardware/armor.md) |
|
||||
|
||||
<details>
|
||||
<summary><strong>已装好图传工具链:构建命令</strong></summary>
|
||||
|
||||
先按[固件入门](docs/setup/firmware.md)安装固定的 `esp-1.97.0.0` 工具链与 ESP-IDF 依赖。在仓库根目录准备 `.env`;已有文件时不要覆盖。
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
if (-not (Test-Path -LiteralPath .env)) {
|
||||
Copy-Item -LiteralPath .env.example -Destination .env
|
||||
}
|
||||
# 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。
|
||||
.\scripts\build.ps1 -Package
|
||||
```
|
||||
|
||||
Linux / macOS(Bash):
|
||||
|
||||
```bash
|
||||
test -f .env || cp .env.example .env
|
||||
# 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。
|
||||
. "$HOME/export-esp.sh"
|
||||
scripts/build.sh --package
|
||||
```
|
||||
|
||||
上述命令只构建和打包。烧录步骤、引脚与分区偏移请按[固件入门](docs/setup/firmware.md)核对;不要将 ELF 当作 BIN 写入。
|
||||
|
||||
</details>
|
||||
|
||||
Wi-Fi 凭据会编入固件。`.env` 已被 Git 忽略,分享固件前也需检查其中的凭据。
|
||||
|
||||
## 发现与收流时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Board as ESP32-S3 图传板
|
||||
participant Client as 桌面 Rust 核心
|
||||
participant View as WebView 画面
|
||||
|
||||
Board-->>Client: Hello 广播(每 2 秒,含版本与协议号)
|
||||
Client->>Board: Subscribe(每 800 ms 续订)
|
||||
Note over Board,Client: 按订阅包源地址推流;6 秒未续订则移除
|
||||
|
||||
loop 采集与发送
|
||||
Board-->>Client: JPEG 分片(27 字节头 + 最多 1400 字节载荷)
|
||||
alt 分片完整且通过接收检查
|
||||
Client->>View: 本地通道投递完整 JPEG
|
||||
View->>View: 解码并绘制最新有效帧
|
||||
else 缺片或超时
|
||||
Client->>Client: 丢弃未完成帧,等待后续帧
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
分片按 `sender_id` 和 `frame_id` 隔离,不重传缺失分片。线路格式、去重和拒收条件见 [UDP 协议](docs/protocol/udp.md)。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| [`src/`](src/) | ESP32-S3 固件 | 采集、分片、发现订阅、发送与重连 |
|
||||
| [`components/`](components/) | C 组件与 FFI | 本地相机驱动、封装及补丁 |
|
||||
| [`protocol/`](protocol/) | 共享线路协议 | 编解码与帧重组,可在主机测试 |
|
||||
| [`desktop/core/`](desktop/core/) | 桌面核心 | 网络、装甲板、配置、密码与槽位 |
|
||||
| [`desktop/src-tauri/`](desktop/src-tauri/) | Tauri 后端 | 本地通道、命令、录制与固件维护 |
|
||||
| [`desktop/src/`](desktop/src/) | React 前端 | 用户端、裁判端、维护与延迟校准 |
|
||||
| [`armor/`](armor/) | ESP32-C3 独立 workspace | 受击规则、灯带与血量广播 |
|
||||
| [`scripts/`](scripts/) | 构建与诊断工具 | 烧录、打包、版本、协议与性能探测 |
|
||||
| [`reference/armor/`](reference/armor/) | 早期 C 版参考工程 | 用于核对硬件与实现差异 |
|
||||
| [`docs/`](docs/index.md) | 项目文档 | 入门、开发、硬件、测试、发布与提案 |
|
||||
|
||||
> [!CAUTION]
|
||||
> `components/esp32-camera/target/` 是按芯片分类的**驱动源码**,包含本地补丁。它不是构建缓存,不能随其他 `target/` 目录一起清理。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
按修改范围运行对应检查。主机 Rust 测试需显式选择宿主目标,避免继承 ESP32 的构建配置;各平台说明见[开发与验证](docs/development/firmware.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>前端检查与 Windows 主机 Rust 检查</strong></summary>
|
||||
|
||||
前端命令在 `desktop/` 执行:
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
pnpm test:stream
|
||||
pnpm test:latency
|
||||
```
|
||||
|
||||
以下 Rust 命令在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
cargo +stable test --locked --manifest-path protocol/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable test --locked --manifest-path desktop/core/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable check --locked --manifest-path desktop/src-tauri/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
录制相关测试会调用 ffmpeg / ffprobe。主机测试、前端构建通过后,仍需按变更范围检查真实 WebView、板卡与网络行为。
|
||||
|
||||
</details>
|
||||
|
||||
提交使用 Conventional Commits,如 `docs(readme): 完善项目入口`。版本由 Git 历史计算,在根目录执行:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs --write
|
||||
node scripts/version.mjs --check
|
||||
```
|
||||
|
||||
生成的改动记录位于 `docs/releases/`;派生文件清单见[版本维护](docs/development/versioning.md)。
|
||||
|
||||
## 文档与后续工作
|
||||
|
||||
文档沿用 Firefox 仓库的分类与索引思路:根目录保留项目入口和协作约定,详细说明集中到 [`docs/index.md`](docs/index.md)。
|
||||
|
||||
| 要做什么 | 从哪里开始 |
|
||||
| :--- | :--- |
|
||||
| 首次准备图传板 | [固件入门](docs/setup/firmware.md) → [硬件接线](docs/hardware/camera.md) |
|
||||
| 使用或调试桌面端 | [客户端说明](docs/desktop/client.md) → [故障排查](docs/testing/troubleshooting.md) |
|
||||
| 更新设备固件 | [固件维护](docs/maintenance/firmware.md) |
|
||||
| 修改受击规则或灯带 | [装甲板说明](docs/hardware/armor.md) |
|
||||
| 复核帧率和延迟 | [最新帧记录](docs/testing/realtime.md) → [延迟测量](docs/testing/latency.md) |
|
||||
| 参与开发 | [AGENTS.md](AGENTS.md) → [开发与验证](docs/development/firmware.md) |
|
||||
|
||||
实现状态与待验证事项分开记录:
|
||||
|
||||
- [x] UDP 图传、发现订阅与桌面双模式
|
||||
- [x] 装甲板血量上报、HUD、命中反馈与录制
|
||||
- [x] USB 固件包校验、App 写入与回读
|
||||
- [ ] 完成固件维护的完整实机分区切换验证
|
||||
- [ ] 确认并实现[比赛控制与直播提案](docs/design/match-and-broadcast.md)
|
||||
|
||||
<details>
|
||||
<summary><strong>仓库地址</strong></summary>
|
||||
|
||||
仓库已由 `ESP32S3_vt` 改名为 `simba-rmic-2026`,旧地址会重定向。SSH 地址:
|
||||
|
||||
```text
|
||||
git@git.simba-robotics.com:PRAM/simba-rmic-2026.git
|
||||
```
|
||||
|
||||
## 组成与数据流
|
||||
|
||||
```text
|
||||
ESP32-S3 图传板 A ─┐ ┌─ 队伍端(绑定本队板卡)
|
||||
├─ 同一局域网 / UDP ─┼─ 裁判端(双路画面 + 装甲板表)
|
||||
ESP32-S3 图传板 B ─┤ └─ 队伍端
|
||||
ESP32-C3 装甲板 ───┘
|
||||
```
|
||||
|
||||
视频使用 UDP **5600**,装甲板血量使用 UDP **5601**。板卡与客户端要在可互相通信、允许广播的网络里;关闭 AP 客户端隔离,并允许客户端程序通过防火墙。
|
||||
|
||||
| 部分 | 作用 | 技术栈 |
|
||||
| --- | --- | --- |
|
||||
| 根目录固件 | 图传板:相机采集、JPEG 分片、UDP 发送、发现与订阅、Wi-Fi 重连 | Rust `std` + ESP-IDF |
|
||||
| `protocol/` | 视频与血量的线路格式、帧重组 | 纯 Rust,主机可测 |
|
||||
| `desktop/` | RMIC::2026:队伍端 / 裁判端、录制、固件维护 | Tauri v2 + React + Rust |
|
||||
| `armor/` | 装甲板:四路受击检测、WS2812 灯带、血量上报 | Rust + ESP-IDF(ESP32-C3) |
|
||||
| `components/` | 本地相机驱动与 C/Rust 接口封装 | C + Rust FFI |
|
||||
|
||||
## 当前状态
|
||||
|
||||
图传板跑 VGA 640×480 彩色,完整接收约 **30 fps**,发射功率 **15 dBm**,三块 PSRAM 采集缓冲。发送端只保留当前一张完整 JPEG,拥塞时按有限预算重试,过期就转向新帧;原生视频通道和 WebView 都会拒绝滞留超过 100 ms 的旧帧。具体策略和五分钟实测见 [REALTIME.md](REALTIME.md)。
|
||||
|
||||
桌面端已经换成 Tauri v2 + React,不再是 Slint 客户端。队伍端有装甲条、瞄准器、命中反馈和密码闸门,裁判端有双路画面、可改队名、装甲板发现表、槽位分配和录制回放;两侧共用同一份快照。固件维护页支持选择编译后打出的 `.zip` 固件包,在坏块板上搜索可写槽位并回读校验,不需要重新编译。
|
||||
|
||||
装甲板是独立的 ESP32-C3 工程:主板负责四路压电片和四路灯带,整车 100 血,每次受击扣 3 点,每 200 ms 广播四条血量。
|
||||
|
||||
版本号和改动日志都从 git 历史算出。板子每 2 秒广播的 `Hello` 里带着语义化版本、构建号和编译进去的线路协议号;客户端会把它显示在绑定列表和裁判端每一格下面,协议不匹配时直接说明原因,而不是只表现为没有画面。
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `src/` | ESP32-S3 图传固件,根目录 Cargo 项目 |
|
||||
| `components/` | 本地相机驱动及 C/Rust 接口封装 |
|
||||
| `protocol/` | 共享 UDP 协议与帧重组,支持主机测试 |
|
||||
| `armor/` | 独立 ESP32-C3 装甲板 workspace |
|
||||
| `desktop/` | Tauri + React 客户端及独立网络核心 `core/` |
|
||||
| `scripts/` | 构建、烧录、打包与探测脚本 |
|
||||
| `reference/armor/` | 早期 C 版装甲板工程 |
|
||||
| `docs/` | 固件维护、自动烧录评估与界面重构设计稿 |
|
||||
|
||||
各子项目的构建目标不同。下面的编译、烧录流程针对根目录的 ESP32-S3 图传固件。
|
||||
|
||||
## 硬件要求
|
||||
|
||||
| 项目 | 当前配置 |
|
||||
| --- | --- |
|
||||
| 开发板 | Freenove ESP32-S3-WROOM CAM |
|
||||
| 模组 | ESP32-S3-WROOM-1-N16R8,16 MB Flash + 8 MB Octal PSRAM |
|
||||
| 摄像头 | OV5640;驱动包含 OV2640 / OV3660 支持,接线需核对 |
|
||||
| 网络 | 2.4 GHz Wi-Fi |
|
||||
| 连接 | 支持数据传输的 USB 线、可识别的串口及稳定供电 |
|
||||
|
||||
默认采集 VGA(640 × 480)、彩色 JPEG quality 12,按传感器节拍发送。相机引脚、Flash 分区和 PSRAM 配置见 [HARDWARE.md](HARDWARE.md)。换板型前必须核对接线,避免占用 Flash / PSRAM 引脚。
|
||||
|
||||
## 固件开发环境
|
||||
|
||||
本项目使用 Rust `std` 和 ESP-IDF。ESP32-S3 属于 Xtensa 架构,需要通过 `espup` 安装 Espressif Rust 工具链;普通 `rustup target add` 不能替代这一步。根目录固定使用名为 `esp-1.97.0.0` 的工具链,避免浮动版本影响构建。
|
||||
|
||||
### 1. 安装基础依赖与 Rust 工具
|
||||
|
||||
先安装 [Rust / rustup](https://rustup.rs/)、[Git](https://git-scm.com/downloads)、[Python 3](https://www.python.org/downloads/)(建议 3.11)、[CMake](https://cmake.org/download/) 与 [Ninja](https://ninja-build.org/),确保命令在 `PATH` 中。
|
||||
|
||||
- Windows:使用 PowerShell 7.5 或更新版本,安装 Visual Studio Build Tools 的“使用 C++ 的桌面开发”和 Windows SDK,使用 MSVC Rust 主机工具链。仓库和工具链尽量使用较短、无空格和中文的路径。
|
||||
- Linux(Debian / Ubuntu):安装 `build-essential pkg-config libudev-dev libssl-dev python3 python3-venv cmake ninja-build git curl`。
|
||||
- macOS:安装 Xcode Command Line Tools(`xcode-select --install`),以及 Python、CMake 和 Ninja。
|
||||
|
||||
以下命令显式使用 `+stable`,即使在仓库内执行,也不会要求事先已有 `esp`:
|
||||
|
||||
```text
|
||||
rustup toolchain install stable
|
||||
cargo +stable install espup --locked
|
||||
cargo +stable install ldproxy --locked
|
||||
cargo +stable install espflash --version 4.4.0 --locked
|
||||
espup install --name esp-1.97.0.0 --toolchain-version 1.97.0.0 --targets esp32s3 --std
|
||||
```
|
||||
|
||||
也可以从各工具的官方 releases 下载预编译程序。`espup --std` 安装本项目所需的 Rust / LLVM 工具,ESP-IDF 的 C 工具链由后续构建准备。
|
||||
|
||||
Linux / macOS 每次新开终端后执行:
|
||||
|
||||
```bash
|
||||
. "$HOME/export-esp.sh"
|
||||
```
|
||||
|
||||
Windows 安装后重新打开 PowerShell,让环境变量生效;若当前终端仍找不到 LLVM,可执行 `. "$HOME/export-esp.ps1"`。检查结果:
|
||||
|
||||
```text
|
||||
rustup toolchain list
|
||||
rustc +esp-1.97.0.0 --version
|
||||
cargo +esp-1.97.0.0 --version
|
||||
espflash --version
|
||||
```
|
||||
|
||||
官方入门资料:[The Rust on ESP Book](https://docs.espressif.com/projects/rust/book/getting-started/toolchain.html)、[espup](https://github.com/esp-rs/espup)、[espflash](https://github.com/esp-rs/espflash/tree/v4.4.0/espflash)。
|
||||
|
||||
### 2. 准备 ESP-IDF
|
||||
|
||||
默认使用 ESP-IDF v5.5.5。首次构建时,`esp-idf-sys` 下载 SDK、对应 C 工具链及 Python 依赖,默认缓存到用户目录的 `.espressif/`;需要网络、磁盘空间及缓存目录写入权限。
|
||||
|
||||
已有匹配版本 SDK 时可在终端设置 `IDF_PATH`,不要提交个人绝对路径:
|
||||
|
||||
```powershell
|
||||
# Windows:替换为实际 SDK 源码目录。
|
||||
$env:IDF_PATH = 'C:\Espressif\frameworks\esp-idf-v5.5.5'
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS:替换为实际 SDK 源码目录。
|
||||
export IDF_PATH="$HOME/esp/esp-idf-v5.5.5"
|
||||
```
|
||||
|
||||
使用自动下载时,清除指向其他 SDK 的旧 `IDF_PATH`。缓存位置等高级设置见 [esp-idf-sys 构建选项](https://github.com/esp-rs/esp-idf-sys/blob/master/BUILD-OPTIONS.md)。
|
||||
|
||||
## 配置 Wi-Fi 与摄像头
|
||||
|
||||
在根目录复制 `.env.example` 为 `.env`,再用编辑器填写;已有 `.env` 时不要覆盖。
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```dotenv
|
||||
WIFI_SSID='你的2.4G网络名称'
|
||||
WIFI_PASS='你的Wi-Fi密码'
|
||||
|
||||
# 可选,省略时使用固件默认值。
|
||||
#CAM_RES=vga
|
||||
#CAM_QUALITY=12
|
||||
#CAM_QUALITY_MAX=0
|
||||
#CAM_MAX_FPS=0
|
||||
#CAM_CLOCK_PROFILE=default
|
||||
#CAM_FB_COUNT=3
|
||||
#CAM_GRAYSCALE=0
|
||||
#CAM_EXPOSURE_EV=0
|
||||
#CAM_AE_LEVEL=-1
|
||||
#CAM_SHUTTER_LINES=0
|
||||
#CAM_EXPOSURE_MAX_LINES=0
|
||||
#CAM_GAIN_CEILING=0
|
||||
#CAM_HTS=0
|
||||
#UDP_PACKET_GAP_US=0
|
||||
#UDP_RETRY_BUDGET_US=8000
|
||||
#CAM_THERMAL_LIMIT_C=75
|
||||
#CAM_VSYNC_INVERT=1
|
||||
#WIFI_AUTH=wpa2
|
||||
#WIFI_TX_POWER_DBM=15
|
||||
#WIFI_TEST_DROP_AFTER_S=0
|
||||
```
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `WIFI_SSID` | 无,必填 | 2.4 GHz 网络名称,最多 32 字节 |
|
||||
| `WIFI_PASS` | 空字符串 | Wi-Fi 密码;开放网络可留空 |
|
||||
| `CAM_RES` | `vga` | `qqvga` / `qvga` / `cif` / `hvga` / `vga` / `svga` / `xga` / `sxga` / `uxga` |
|
||||
| `CAM_QUALITY` | `12` | 0–63,越小通常画质越好、JPEG 越大;当前交付使用 18 |
|
||||
| `CAM_QUALITY_MAX` | `0` | `0` 固定画质;设为 `CAM_QUALITY`–63 时按发送回压在两者之间调节压缩,保留彩色和分辨率。拥塞时每 5 秒增大最多 3 档,连续稳定 15 秒才恢复 1 档 |
|
||||
| `CAM_MAX_FPS` | `0` | 取帧与发送的最高节拍,1–60;`0` 不限帧。仅限制消费节拍,不修改传感器时钟 |
|
||||
| `CAM_CLOCK_PROFILE` | `default` | `default` 保留驱动时钟;`vga30` 使用 OV5640 VGA 约 30.09 fps PLL 配置,保持 HTS/VTS 和读出窗口,SYSCLK 61 MHz / PCLK 15.25 MHz。切换后需重新核对曝光行数 |
|
||||
| `CAM_FB_COUNT` | `3` | PSRAM 帧缓冲数,允许 2 或 3。当前交付选三缓冲,保留发送突发时的采集余量;双缓冲曾在短测降低部分回显延迟,但用户长程运行反馈峰值丢帧明显,保留为试验选项 |
|
||||
| `CAM_GRAYSCALE` | `0` | `1` 使用传感器灰度效果,保留硬件 JPEG;`0` 彩色 |
|
||||
| `CAM_EXPOSURE_EV` | `0` | OV5640 自动曝光目标偏置:`-1` / `-2` 约对应 −1.1 / −1.9 EV;不是标定后的绝对 EV,也不保证快门缩短 |
|
||||
| `CAM_AE_LEVEL` | 未设置 | 可选 OV5640 驱动 AE 档位,−5–5;仅与 `CAM_EXPOSURE_EV=0` 同用。`-1` 将目标从 55 小幅降至 45,目标比值约 −0.29 EV,保留自动曝光/增益;未设置时按 EV 参数处理。实际快门和亮度变化取决于场景 |
|
||||
| `CAM_SHUTTER_LINES` | `0` | OV5640 固定快门,单位为传感器曝光行;`0` 自动曝光,非零保持自动增益。默认 VGA / HTS 2060 下 220 行估算约 10 ms;数值不能超过当前 VTS |
|
||||
| `CAM_EXPOSURE_MAX_LINES` | `0` | OV5640 自动曝光上限,`0` 保留驱动默认。非零保持 AEC/AGC 联动并禁用夜景延帧,不能与固定快门同时使用。默认 VGA 下 440 行约 20 ms;`vga30` 下 592 行约 20 ms。实际曝光由场景和抗频闪步长决定 |
|
||||
| `CAM_GAIN_CEILING` | `0` | OV5640 自动增益上限,`0` 保留驱动默认;1–63,单位为倍,实际上限略低于整数档。短曝光遇到室内弱光时可提高,但暗部噪点和 JPEG 负载可能增加 |
|
||||
| `CAM_HTS` | `0` | OV5640 水平总长度试验参数,`0` 使用驱动默认。缩短消隐会影响彩色稳定性,本机 1500 / 1800 出现绿紫闪烁,已撤回;几何检查通过不代表可用 |
|
||||
| `UDP_PACKET_GAP_US` | `0` | 成功提交视频数据报之间的最小间隔;0–5000 μs,须为调度 tick 的整数倍(当前为 1000 μs)。配合有界帧等待,避免集中突发写入队列 |
|
||||
| `UDP_RETRY_BUDGET_US` | `8000` | 每帧、所有分片及订阅者共用的让出调度重试预算,0–25000 μs,须为 tick 整数倍;`0` 为非阻塞失败即弃。总发送检查期限为 25 ms,采集帧龄期限为 100 ms;分片间隔也计入期限,不会额外延长 |
|
||||
| `CAM_THERMAL_LIMIT_C` | `75` | 芯片内部温度达到该值时减半帧率(向上取整),下降 5°C 后恢复;40–95,`0` 禁用。原先不限帧时降至 8 fps |
|
||||
| `CAM_VSYNC_INVERT` | `1` | 驱动反相 VSYNC;硬件已反相时填 `0` |
|
||||
| `WIFI_AUTH` | 自动选择 | `none` / `wep` / `wpa` / `wpa2` / `wpawpa2` / `wpa3` / `wpa2wpa3` / `wapi`;只在扫描看不到 AP(如隐藏 SSID)时才需要 |
|
||||
| `WIFI_TX_POWER_DBM` | SDK 默认上限 | 可选整数 2–20 dBm;当前交付请求 15 dBm,本板驱动回读 15.00 dBm;启动及重连时应用,拥塞和温控逻辑不降低此上限 |
|
||||
| `WIFI_TEST_DROP_AFTER_S` | `0` | 仅诊断构建:运行指定秒数后主动断网一次并保持 5 秒,用来验证运行时恢复。正式构建保持 `0` |
|
||||
|
||||
`.env` 使用简单的 `KEY=value`;值含空格、`#` 或 `$` 时用单引号包围,不使用变量展开或命令替换。脚本会加载 `.env`,直接 `cargo build` 不会自动读取它。上述参数在编译时写入固件,修改后需要重新编译和烧录。`.env` 已被 Git 忽略;固件也包含凭据,分享前应重新配置。
|
||||
|
||||
运行时断线恢复不重置摄像头、曝光或增益。首次尝试立即发起,失败后按 1、2、4、8、16、30 秒退避;关联与 DHCP 分别有 15 秒期限。恢复 IP 后丢弃旧采集帧、重建 socket 并重新广播,由客户端自动续订。启动时首次联网仍使用扫描与认证模式探测流程。实现依据 [ESP-IDF 断线事件处理说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。
|
||||
|
||||
## 编译固件
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
$env:CARGO_TARGET_DIR = 'C:\s3vt'
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
脚本执行 `cargo build --release --locked`,目标为 `xtensa-esp32s3-espidf`。首次编译包含 Rust 标准库、ESP-IDF 和相机组件,耗时较长。构建完成后可用 `-Package` / `--package` 再打一个 `.zip` 固件包,供桌面端维护页使用。
|
||||
|
||||
Windows 的 ESP-IDF 构建输出路径需不超过 88 字符,因此示例使用短目录 `C:\s3vt`(需可写,且不与其他项目共用)。`subst` 不能绕过该检查。下表的 `<TARGET_DIR>` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。
|
||||
|
||||
| 产物 | 位置 |
|
||||
| --- | --- |
|
||||
| 固件 ELF | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/esp32`(没有 `.exe` 后缀) |
|
||||
| Bootloader | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/bootloader.bin` |
|
||||
| 分区表 BIN | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/partition-table.bin` |
|
||||
| 分区表源文件 | 根目录 `partitions.csv` |
|
||||
|
||||
ELF 供 `espflash` 烧录与符号解析,不能当作 BIN 直接写入 Flash。Bootloader、分区表与 App 应来自同一次构建。
|
||||
|
||||
## 烧录与串口验证
|
||||
|
||||
### 1. 查找串口
|
||||
|
||||
```text
|
||||
espflash list-ports
|
||||
```
|
||||
|
||||
Windows 常见 `COM5`;Linux 为 `/dev/ttyACM0` 或 `/dev/ttyUSB0`;macOS 为 `/dev/cu.usbmodem...`。关闭其他串口监视器,Linux 需具备串口访问权限。
|
||||
|
||||
### 2. 烧录 ELF
|
||||
|
||||
本项目使用自定义分区表,显式传入 `partitions.csv` 和本次构建的 Bootloader。将示例端口替换为实际端口:
|
||||
|
||||
```powershell
|
||||
$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' }
|
||||
espflash flash --chip esp32s3 --port COM5 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$targetDir/xtensa-esp32s3-espidf/release/esp32"
|
||||
```
|
||||
|
||||
```bash
|
||||
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||
espflash flash --chip esp32s3 --port /dev/ttyACM0 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$target_dir/xtensa-esp32s3-espidf/release/esp32"
|
||||
```
|
||||
|
||||
连接失败时按住 BOOT,点按 RESET / EN,松开 BOOT 后重试。正常启动应看到相机识别、Wi-Fi 获取 IP 和 `ESP32 camera is live` 日志。没有客户端时,`0 clients subscribed` 属于正常状态。
|
||||
|
||||
仅查看日志:
|
||||
|
||||
```text
|
||||
espflash monitor --port COM5
|
||||
```
|
||||
|
||||
### 3. 导出 BIN
|
||||
|
||||
`espflash save-image --merge` 可导出从 `0x0` 写入的合并镜像。完整命令、独立镜像偏移与测试方法见 [DEVELOPMENT.md](DEVELOPMENT.md)。
|
||||
|
||||
保留的 `scripts/flash.sh` 针对特定坏 Flash 模组,依赖 macOS 环境,会修改分区并重编译。普通板卡使用上述标准流程;坏板限制见 [HARDWARE.md](HARDWARE.md#损坏-flash-模组的兼容处理)。
|
||||
|
||||
## 桌面端 RMIC::2026
|
||||
|
||||
安装依赖并启动开发模式:
|
||||
|
||||
```text
|
||||
cd desktop
|
||||
pnpm install
|
||||
pnpm app
|
||||
```
|
||||
|
||||
`pnpm app` 等价于 `tauri dev`:起 Vite、编 Rust、开窗口。只构建前端用 `pnpm build`,打包用 `pnpm tauri build`。Windows x64 需要 Node.js 22.12+、pnpm、Rust stable MSVC、Visual Studio Build Tools 的 C++ 组件和 WebView2 Runtime;安装包用 NSIS,产物在 `src-tauri/target/x86_64-pc-windows-msvc/release/`。
|
||||
|
||||
没有板子也能跑演示模式:
|
||||
|
||||
```text
|
||||
cd desktop
|
||||
CAM_DEMO=1 pnpm app
|
||||
```
|
||||
|
||||
演示模式合成协议数据报,从真实的接收与重组路径进入界面,不是给界面塞假数据。界面配置、密码、绑定、录制、裁判端槽位、防作弊和固件维护的细节见 [desktop/README.md](desktop/README.md)。固件维护页的实现、`.zip` 包格式与实机验证范围见 [docs/firmware-maintenance.md](docs/firmware-maintenance.md)。
|
||||
|
||||
## 装甲板
|
||||
|
||||
装甲板是独立的 ESP32-C3 / RISC-V 工程,不能使用 S3 的 ELF。`ARMOR_TEAM=0/1` 在编译期决定红 / 蓝队伍。接线、规则与构建烧录见 [armor/README.md](armor/README.md)。
|
||||
|
||||
```bash
|
||||
scripts/build_armor.sh # 编译
|
||||
scripts/flash_armor.sh # 烧录并看 20 秒日志
|
||||
python3 scripts/armor_probe.py 15 # 不打开界面时核对血量广播
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 检查方法 |
|
||||
| --- | --- |
|
||||
| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 |
|
||||
| `espup` 查询 GitHub 版本返回 403 | 对上述固定版本安装命令追加 `--skip-version-parse`,或稍后重试 |
|
||||
| 找不到 `ldproxy` / `libclang` | 安装链接工具;Unix 加载 `export-esp.sh`,Windows 重开终端 |
|
||||
| ESP-IDF 路径或版本错误 | 清除旧 `IDF_PATH` 或指向 v5.5.5 SDK |
|
||||
| Windows 路径过长 | 缩短仓库和工具链路径,见开发说明 |
|
||||
| Wi-Fi 连不上 | 核对 2.4 GHz、凭据和认证方式,再重新编译烧录 |
|
||||
| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM |
|
||||
| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` |
|
||||
| 在线但没有画面 | 检查客户端订阅、UDP 5600、防火墙及 AP 隔离 |
|
||||
| 界面提示协议不匹配 | 板子与客户端的线路协议号不同;按提示重新烧录同版本的固件 |
|
||||
| 开启 VPN / 虚拟网卡后设备在线但无画面 | 使用支持按已发现设备 IP 定向续订的客户端;仅发送 `255.255.255.255` 广播可能走错网卡 |
|
||||
| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 |
|
||||
| 主机项目被编为 Xtensa | 显式指定宿主 target,见各子项目说明 |
|
||||
|
||||
用 `python scripts/udp_probe.py 10` 可独立检查视频协议;装甲板用 `python scripts/armor_probe.py 15`。这些脚本需要板卡在线,构建通过不代表已完成硬件验证。
|
||||
|
||||
测量帧率和丢帧可运行以下命令,将 IP 换成板卡实际地址。对照测试时先退出桌面客户端,避免额外订阅者增加无线负载:
|
||||
|
||||
```text
|
||||
python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json
|
||||
```
|
||||
|
||||
脚本定向续订,支持保留 VPN / TUN 的环境。输出包含完整帧率、帧损失率、接收间隔和组装耗时;这些时间不等于摄像头到屏幕的端到端延迟。
|
||||
|
||||
运动图传保持 `CAM_GRAYSCALE=0`,优先使用自动曝光上限和自动增益协同控制拖影。室内强制 −1 EV / 固定短快门在本机过暗,已撤回;提高增益上限也会增加噪点。需要限流时设 `CAM_MAX_FPS=15`,追求帧率时用 `0`;可启用有界 JPEG 压缩调节应对发送回压,分片间隔按网络实测选择。灰度效果仍保留硬件 JPEG,不是改为 RGB565 或降低 JPEG 采样位数。75°C 是主动减载阈值,温度来自 ESP 芯片内部,不能代表摄像头或环境温度,也不是芯片损坏温度规格。五分钟彩色对照与此前中止的灰度长测见 [STABILITY.md](STABILITY.md)。
|
||||
|
||||
## 版本号与改动日志
|
||||
|
||||
版本号和 `CHANGELOG.md` 不手写,从 git 历史算:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs --write # 计算并写回 version.json / CHANGELOG / 客户端版本
|
||||
node scripts/version.mjs --check # 只校验(CI 与发布前用)
|
||||
```
|
||||
|
||||
- 版本号由提交类型决定:`BREAKING CHANGE:` → major,`feat` → minor,`fix` / `perf` → patch。
|
||||
- 构建号 = `git rev-list --count HEAD`,单调、不撞车。
|
||||
- 同一次计算还产出 `version.json`(固件 `build.rs` 读它)、`CHANGELOG.json`、`desktop/src/lib/version.ts`。
|
||||
|
||||
固件与客户端的版本是联动的:板子每 2 秒广播的 `Hello` 里带着语义化版本、构建号与它编译进去的线路协议号;客户端读出来显示在设置与裁判端每一格下面,协议对不上时直接说明原因(“板子是协议 2,客户端是协议 1,画面会被丢弃,请重新烧录”),而不是只表现为没有画面。
|
||||
|
||||
细节见 [desktop/README.md](desktop/README.md#版本与改动日志算出来的不是写出来的) 与 [protocol/README.md](protocol/README.md#通告里的版本信息)。
|
||||
|
||||
## 文档索引
|
||||
|
||||
- [CHANGELOG.md](CHANGELOG.md):改动记录,由脚本从 git 历史生成。
|
||||
- [HARDWARE.md](HARDWARE.md):接线、内存、Flash 与驱动限制。
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md):镜像导出、测试、协议与性能测量。
|
||||
- [PERFORMANCE.md](PERFORMANCE.md):保留 TUN 的图传排查、参数取舍与实际测试结果。
|
||||
- [STABILITY.md](STABILITY.md):限帧、灰度与温度减载,长时间运行测试。
|
||||
- [REALTIME.md](REALTIME.md):阻塞时取最新帧、超期丢弃和客户端积压处理。
|
||||
- [POWER.md](POWER.md):15 dBm 回退、功率与距离的关系,以及发送拥塞证据。
|
||||
- [LATENCY.md](LATENCY.md):约 30 fps 配置、Wi-Fi 重连及实际画面链路延迟对照。
|
||||
- [FLASH-CLIENT-ARCH.md](FLASH-CLIENT-ARCH.md):烧录 / 维护客户端的架构提议稿。
|
||||
- [protocol/README.md](protocol/README.md):UDP 线路格式及帧重组。
|
||||
- [armor/README.md](armor/README.md):装甲板固件与规则。
|
||||
- [desktop/README.md](desktop/README.md):RMIC::2026 的配置、录制与排查。
|
||||
- [docs/firmware-maintenance.md](docs/firmware-maintenance.md):已实现的固件维护页、官方 bundle 格式、坏块规避流程与实机验证记录。
|
||||
- [docs/firmware-provisioning-evaluation.md](docs/firmware-provisioning-evaluation.md):自动烧录、坏区规避与 SSID 配置 patch 的工具评估、MVE 与测试矩阵。
|
||||
- [docs/desktop-ui-redesign.md](docs/desktop-ui-redesign.md):RMIC::2026 界面、比赛控制与直播包装的重构设计稿。
|
||||
- [docs/CHANGELOG-legacy.md](docs/CHANGELOG-legacy.md):建立自动版本机制之前的人工改动记录。
|
||||
</details>
|
||||
|
||||
+6
-344
@@ -1,351 +1,13 @@
|
||||
# 装甲板(ESP32-C3 / Rust / ESP-IDF)—— 一主三从,与 RMIC::2026 桌面端联动
|
||||
# 装甲板(ESP32-C3)
|
||||
|
||||
一辆车四块装甲板(前/后/左/右),**只有主板一颗 MCU**:另外三块"从"板里没有单片机,
|
||||
各自只有一路 WS2812 和一路压电片,直接接线到主板上。主板负责四路击打检测、四路灯带、
|
||||
整车血量,并把血量广播给 RMIC::2026 桌面端。
|
||||
独立的 Rust + ESP-IDF workspace。主板处理四路压电片、四路 WS2812 灯带和整车血量,并经 UDP 向 RMIC::2026 广播。三块从板直接接线到主板,不含 MCU。
|
||||
|
||||
固件是跑在 ESP-IDF 上的 Rust,目标芯片 **ESP32-C3(RISC-V)**。
|
||||
|
||||
```text
|
||||
armor/
|
||||
├── Cargo.toml workspace:两个 crate,各自独立构建
|
||||
├── armor-protocol/ 规则层 + 线路格式(纯计算,主机上 22 个单元测试)
|
||||
├── armor-firmware/ 固件:ADC / RMT / NVS / 按键 / WiFi+UDP
|
||||
│ ├── src/leds.rs 四路 WS2812(单 RMT 通道轮流切 GPIO)
|
||||
│ ├── src/piezo.rs 四路压电片(40 kHz ADC 连续采样)
|
||||
│ ├── src/net.rs UDP 广播 ArmorHealth
|
||||
│ ├── src/settings.rs NVS:队伍与颜色
|
||||
│ └── sdkconfig.defaults / partitions.csv
|
||||
└── .cargo/config.toml riscv32imc-esp-espidf + MCU=esp32c3
|
||||
```
|
||||
|
||||
**为什么拆成两个 crate。** 规则层(算不算一次受击、扣几滴血、这一刻灯该显示什么、
|
||||
按一下键算短按还是长按)是最容易写错、又最不适合"烧上去看现象"的部分 —— 压电片的
|
||||
尖峰在硬件上根本没法按需复现。放在 `armor-protocol` 里就能喂 `Duration` 假时间来测。
|
||||
固件侧只做外设接线。
|
||||
|
||||
## 接线
|
||||
|
||||
| 功能 | GPIO | 说明 |
|
||||
| --- | --- | --- |
|
||||
| WS2812 前 | IO10 | 每条灯带 3 颗灯珠,GRB、MSB first |
|
||||
| WS2812 后 | IO5 | |
|
||||
| WS2812 左 | IO6 | |
|
||||
| WS2812 右 | IO7 | |
|
||||
| 压电片 前 | GPIO0(ADC1_CH0) | 0 dB 衰减、12 bit |
|
||||
| 压电片 后 | GPIO4(ADC1_CH4) | |
|
||||
| 压电片 左 | GPIO3(ADC1_CH3) | |
|
||||
| 压电片 右 | GPIO1(ADC1_CH1) | |
|
||||
| BOOT 键 | GPIO9 | 低有效,内部上拉 |
|
||||
| 调试串口 UART0 TXD | GPIO21 | 115200 8N1,日志从这里出来(主控制台) |
|
||||
| 调试串口 UART0 RXD | GPIO20 | 目前空闲,留给以后的命令通道 |
|
||||
|
||||
引脚与参考实现完全一致(灯:前 10 / 后 5 / 左 6 / 右 7;压电:前 0 / 后 4 / 左 3 /
|
||||
右 1)。调试口用 UART0 而不是 USB Serial/JTAG,是为了让"RXD/TXD"这对丝印就是日志口。
|
||||
|
||||
**日志同时从板上原生 USB 口出来。** `sdkconfig.defaults` 里把 USB Serial/JTAG 配成了
|
||||
**次**控制台(`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`):主控制台仍是 UART0,
|
||||
所以"RXD/TXD 就是调试口"这条成立;而只用原生 USB 线烧录、没接 USB-TTL 的人也能看到日志,
|
||||
否则那种接法下应用日志一个字符都看不到(只有 ROM 的启动信息)。
|
||||
|
||||
## 规则
|
||||
|
||||
| | 值 | 在哪 |
|
||||
| --- | --- | --- |
|
||||
| 整车血量 | 100 | `Rules::max_hp` |
|
||||
| 每次受击扣血 | 3 | `Rules::damage_per_hit` |
|
||||
| 受击后变暗 | **只有被打的那一路**全黑 50 ms,其余三路不动 | `Rules::hit_blink` + `Armor::strip` |
|
||||
| 同一板位重复判定抑制 | 50 ms | `Rules::hit_cooldown` |
|
||||
| 受击阈值 | 原始 12 bit ADC > 2000(`ARMOR_THRESHOLD` 可改) | `Rules::threshold` |
|
||||
| 血量归零 | 四路全黑,直到复位 | `Armor::strip` |
|
||||
| 正常颜色 | 队伍一红(`0xFF0000`)、队伍二蓝(`0x0000FF`) | `Rgb::for_team` |
|
||||
| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` |
|
||||
| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` |
|
||||
|
||||
**整车一条血量。** 板子算账、裁判端只显示:四条上报的 `hp` 相同,`index` 分别是 0..3,
|
||||
界面按 `(team, index)` 收齐四条后显示同一个数值。
|
||||
|
||||
**为什么要"每板独立冷却"。** 参考实现用全局 50 ms 冷却:一块板挨打后,别块板在这
|
||||
50 ms 内的击打会被吃掉。四路各自独立才是"每面各自挨打"的正确语义,这里有单元测试
|
||||
钉住(`cooldown_is_per_plate_not_global`)。
|
||||
|
||||
## 上报(UDP)
|
||||
|
||||
主板每 200 ms 广播四条 `protocol::ArmorHealth` 到 `255.255.255.255:5601`:
|
||||
|
||||
```text
|
||||
magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index(1) | hp(2)
|
||||
```
|
||||
|
||||
- **血量一变立刻发**,没变就按 200 ms 心跳重发。裁判端靠心跳判断板子还在线,一次
|
||||
丢包不该让界面退回"未接入"。
|
||||
- `sender_id` 用 MAC 派生的板子 id(`protocol::id_from_mac`),不是 0 —— 0 是裁判
|
||||
终端的保留 id,抓包时一块装甲板发的包不该长得像裁判端发的。
|
||||
- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。
|
||||
- 广播而不是单播:桌面端电脑的 IP 是 DHCP 给的,会变。图传板用 Hello 广播绕开这件事,
|
||||
装甲板沿用同一个约定。
|
||||
|
||||
### 桌面端怎么收
|
||||
|
||||
`desktop/core/src/armor.rs` 里的 `ArmorHealthStore`(两种身份都启动):
|
||||
|
||||
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
|
||||
- **四条都在 1.5 s 内收到**才交给界面显示;否则回退占位符。心跳 200 ms,1.5 s 是连丢
|
||||
7 个包,偶发丢包不会让读数来回跳。
|
||||
- 用户端也要显示双方血条,所以两端都会占 5601。
|
||||
- 血量变化会打日志(`armor: board 0x… team 0 hp 100 -> 97`):窗口是给别人看的,
|
||||
抓日志的人也得能看见掉血。
|
||||
|
||||
**界面显示血量数字。** `TeamHealthDto` 带 `hp` 与 `critical`:数值就在队伍名旁边,低于
|
||||
30 或归零时它与血条一起转红。归零**不写"已击毁"**——那一刻图传中央会给出
|
||||
「胜利 / 击败」的结论(见 [`desktop/README.md`](../desktop/README.md))。
|
||||
|
||||
## 构建
|
||||
|
||||
凭据与队伍号走 `.env`(与图传固件共用同一个文件):
|
||||
|
||||
```bash
|
||||
# .env
|
||||
WIFI_SSID=your-ssid
|
||||
WIFI_PASS=your-password
|
||||
ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
|
||||
ARMOR_THRESHOLD=2000 # 受击阈值(原始 12 bit ADC),看日志里的 peak 来定
|
||||
|
||||
scripts/build_armor.sh # 编译
|
||||
ARMOR_TEAM=1 scripts/build_armor.sh # 临时当队伍二编译
|
||||
ARMOR_THRESHOLD=1200 scripts/build_armor.sh # 换受击阈值(看日志里的 peak 来定)
|
||||
```
|
||||
|
||||
产物:`armor/target/riscv32imc-esp-espidf/release/armor`。
|
||||
|
||||
`ARMOR_TEAM` 是**编译期**注入的:换队伍重刷固件即可,不用先擦 NVS。NVS 里另外记着
|
||||
`team` 与 `color`(命名空间 `armor`,与参考实现同名),两者不一致时以 `ARMOR_TEAM`
|
||||
为准并覆盖,日志里会说一声。
|
||||
|
||||
本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` /
|
||||
`ESP_IDF_TOOLS_INSTALL_DIR`),不需要先 `activate` ESP-IDF。删掉那两行就交给
|
||||
esp-idf-sys 自己下载它钉住的版本。
|
||||
|
||||
### 用的是 ESP-IDF v6.0.1,不是图传板那套 v5.5.5
|
||||
|
||||
这台机器上有两套 ESP-IDF:
|
||||
|
||||
| | 路径 | 工具链 |
|
||||
| --- | --- | --- |
|
||||
| v6.0.1(eim 装的) | `/Users/peng/.espressif/v6.0.1/esp-idf` | Xtensa + **RISC-V(`esp-15.2.0_20251204`)** |
|
||||
| v5.5.5(图传板在用) | `/Users/peng/.espressif/esp-idf/v5.5.5` | 只有 Xtensa |
|
||||
|
||||
C3 是 **RISC-V**,而 v5.5.5 那套缺 `riscv32-esp-elf`(它要的是 `esp-14.2.0_20260121`,
|
||||
本机没有,而 GitHub release 又下不动)。所以装甲板固件指向 v6.0.1 —— 它的工具链齐全,
|
||||
且 `esp-idf-sys` 0.38.1 支持的版本范围是 4.4.4+、5.0–5.5、**6.0–6.2**。
|
||||
|
||||
两套 IDF 共存没有冲突:各自 `.cargo/config.toml` 里的 `IDF_PATH` 是每套 crate 自己钉的。
|
||||
|
||||
`ESP_IDF_TOOLS_INSTALL_DIR` 默认 `global`(即 `~/.espressif`,工具所在处),但**没有**加
|
||||
`force`:沙箱或 CI 里可以用 `ESP_IDF_TOOLS_INSTALL_DIR=custom:<可写目录>` 覆盖(工具目录
|
||||
不可写时把 `tools/` 软链到已装好的工具即可,Python venv 会建在那个可写目录里)。
|
||||
|
||||
### 主机侧测试
|
||||
|
||||
规则层在 PC 上跑,不需要板子:
|
||||
|
||||
```bash
|
||||
cd armor/armor-protocol && cargo test # 22 个测试
|
||||
cd protocol && cargo test # 线路格式往返(含 ArmorHealth)
|
||||
```
|
||||
|
||||
(`armor-protocol/.cargo/config.toml` 把 target 覆盖回 host:它坐在 `armor/` 目录树
|
||||
里,否则会继承 RISC-V 目标,链接必然失败。这与 `protocol/`、`desktop/` 的做法一致。
|
||||
换到其他宿主平台时,配置里钉住的 `aarch64-apple-darwin` 不适用,按
|
||||
[DEVELOPMENT.md](../DEVELOPMENT.md) 用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。)
|
||||
|
||||
## 烧录与实测
|
||||
|
||||
```bash
|
||||
scripts/flash_armor.sh # 自动找串口,烧完看 20 秒日志
|
||||
ESP_PORT=/dev/cu.usbmodem1101 scripts/flash_armor.sh
|
||||
scripts/flash_armor.sh --monitor 60 # 看 60 秒
|
||||
scripts/flash_armor.sh --no-monitor # 只烧不抓
|
||||
```
|
||||
|
||||
烧的是 ELF(`espflash` 自己从 ELF 里读分区表与 bootloader),芯片 `esp32c3`。
|
||||
|
||||
不打开裁判端界面,也可以单独确认板子在广播、血量对不对:
|
||||
从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
scripts/build_armor.sh
|
||||
scripts/flash_armor.sh
|
||||
python3 scripts/armor_probe.py 15
|
||||
```
|
||||
|
||||
它手写协议常量(不依赖 Rust 侧代码),所以是线路格式的**独立**检查:如果固件编码写错,
|
||||
这个脚本会立刻报不一致。
|
||||
|
||||
### 串口日志长什么样(实机抓的)
|
||||
|
||||
```text
|
||||
I (321) armor: pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7
|
||||
I (321) armor: pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1
|
||||
I (4864) esp_netif_handlers: sta ip: 192.168.1.117, mask: 255.255.255.0, gw: 192.168.1.1
|
||||
I (5034) armor: joined "SR-602-2G4" on 192.168.1.117
|
||||
I (5034) armor::net: reporting to 255.255.255.255:5601 over UDP
|
||||
I (5034) armor::net: this board's id is 0xA820D321, from MAC [98, A3, 16, 4C, 67, C8]
|
||||
I (6044) armor: status: hp=100 hits=0 dead=false plates[front=0 rear=0 left=0 right=0]
|
||||
peak[front=490 rear=395 left=456 right=475] thr=2000
|
||||
strips[front=#FF0000 rear=#FF0000 left=#FF0000 right=#FF0000]
|
||||
ap=SR-602-2G4 rssi=-36 ch=6 ip=192.168.1.117 reports=5 errs=0
|
||||
I (63104) armor: hit plate=front raw=4095 hp=1 (hits=33)
|
||||
I (63314) armor: hit plate=front raw=4095 hp=0 (hits=34)
|
||||
W (63314) armor: hp reached 0: all four strips stay dark until the next reset
|
||||
W (306504) armor: BOOT long press: hp reset to 100 (new round)
|
||||
```
|
||||
|
||||
(日志里的长行是这里折行显示的,实机是一行。)
|
||||
|
||||
## 实测(2026-10-14,ESP32-C3 devkit,MAC `98:a3:16:4c:67:c8`)
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 编译 | `cargo build --release` 通过;text 1,081,093 B,app 镜像 **1,082,848 B**,占 4 MB flash 的 26% |
|
||||
| 启动 | 无 panic/重启;状态行每秒一条 |
|
||||
| WiFi | 关联 `SR-602-2G4`(rssi −36,ch 6),DHCP 拿到 `192.168.1.117` |
|
||||
| 上报 | `scripts/armor_probe.py` 收到队伍一四条、约 5 包/秒、`sender_id 0xA820D321`、四格同值 |
|
||||
| 裁判端 | 旧客户端 `cam-client --role referee` 日志 `team 0 plate 0..3 is online (hp 100) via 192.168.1.117`,界面显示血量数字 |
|
||||
| 受击 | 敲前装甲板:`hit plate=front raw=2663..4095`,每次 −3(100→97→94…),只有 front 计数 |
|
||||
| 变暗 | 敲击瞬间**只有前那一路**全黑 50 ms,其余不变(现场确认) |
|
||||
| 死亡 | 34 击到 0;状态行 `dead=true strips[front=off rear=off left=off right=off]`,四路全黑 |
|
||||
| 复位 | BOOT 长按 → 板子 `reset to 100`,裁判端日志 `armor: team 0 hp 0 -> 100` |
|
||||
| 底噪 | 未敲击时四路 peak 约 380–510,阈值 2000 有明显余量 |
|
||||
| 未接线 | 后/左/右三路压电片与灯带本次未接(用户说明),代码按四路齐全写 |
|
||||
|
||||
### 裁判端这边踩到的坑:界面根本不显示血量
|
||||
|
||||
协议、`ArmorSource`、`ArmorHealthStore` 都对,但改之前旧界面上那几格只是绿/灰方块,
|
||||
`hps` 的**数值从不渲染**——板子被打到 0,格子还是绿的,看起来像"没掉血"。修好之后
|
||||
队伍名旁边直接显示数字,客户端日志也会打 `armor: board 0x… team 0 hp 100 -> 97`。
|
||||
|
||||
## 实现要点(都是踩过的坑)
|
||||
|
||||
### 1. ESP32-C3 的 RMT 只有 96 个 symbol 的通道内存
|
||||
|
||||
C3 的 RMT 只有一个 block,总计 96 个 symbol,按 48 为单位分给两个 TX 通道。`mem_block_symbols = 64`
|
||||
会被向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 就会失败:
|
||||
|
||||
```text
|
||||
E rmt: rmt_tx_register_to_group(168): no free tx channels
|
||||
```
|
||||
|
||||
四条灯带不可能各占一个通道(只有 2 个 TX 通道)。所以这里只建**一个**通道、让它独占
|
||||
96 个 symbol(一帧 = 3 珠 × 24 bit + reset = 73 个 symbol,装得下),再用
|
||||
`rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送;每路约 90 µs。
|
||||
|
||||
**为什么不用 `esp-idf-hal` 的 `TxChannelDriver`。** 它把 `rmt_channel_handle_t` 藏在
|
||||
结构体里(`as_ptr()` 是 `pub(crate)`),拿不到句柄就调不了 `rmt_tx_switch_gpio()`。
|
||||
所以 `leds.rs` 直接用 sys 绑定:通道、官方 copy encoder、transmit 都是 IDF 原生的那
|
||||
几个函数,用法与参考实现的 `led_strip_encoder.c` 一一对应。
|
||||
|
||||
**每路内容没变就不重发。** 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍;
|
||||
这里四路颜色独立(被打的那一路要单独变暗),于是只在内容变化时才走
|
||||
`disable → switch → enable → transmit`,另外每秒强制全刷一次(掉电、静电都可能让
|
||||
某条灯带丢帧,缓存不能当真相)。
|
||||
|
||||
### 2. 压电片必须连续(DMA)采样
|
||||
|
||||
一次击打的波形只有几十微秒宽,oneshot 轮询几乎必然从尖峰旁边擦过去,表现就是"敲得响
|
||||
才算"。这里与参考实现一致:四通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个
|
||||
采样点,一帧就是主循环的节拍。
|
||||
|
||||
`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 的约束是
|
||||
`A: Adc` 且 `O: AdcChannels<AdcUnit = A>`:`ADC1` 满足前者、`ADCU1` 满足后者,同一个
|
||||
类型参数凑不齐,四路 ADC1 通道拼不出来。所以 `piezo.rs` 同样直接用 sys 绑定,与参考
|
||||
实现逐行对应。
|
||||
|
||||
采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序
|
||||
轮转"这个假设。
|
||||
|
||||
### 3. 一次击打会被算成几十次
|
||||
|
||||
40 kHz 下,压电片一次击打会连续几十个采样点过阈值。**去抖不在采样层做**,而是
|
||||
`Armor::on_sample` 的冷却窗口把它收敛成一次受击 —— 这样"算不算受击"这条规则可以在
|
||||
主机上测(`repeat_samples_inside_the_cooldown_are_one_hit`)。
|
||||
|
||||
### 4. 控制台放在 UART0
|
||||
|
||||
`sdkconfig.defaults` 里显式写了 `CONFIG_ESP_CONSOLE_UART_DEFAULT=y`:C3 的 ROM 默认
|
||||
控制台就是 UART0,引脚正好是 RXD=GPIO20 / TXD=GPIO21,与要求一致。换成 USB
|
||||
Serial/JTAG 输出只要把这一行换成 `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG`。
|
||||
|
||||
### 5. 应用分区给 2 MB
|
||||
|
||||
默认的 1 MB 单 app 槽对带 WiFi 的 Rust 固件太紧,`partitions.csv` 按参考实现给
|
||||
factory 划了 2 MB。
|
||||
|
||||
### 6. WiFi 省电关掉
|
||||
|
||||
省电模式下上报会晚一个信标间隔(上百毫秒),而"受击后 50 ms 变暗"本来就是要给人即时
|
||||
反馈的。板子是车载供电,这点电流不值得换不确定性。另外主循环里每 5 s 检查一次连接,
|
||||
断了就重连 —— 一块静默失联的装甲板在场上是最难查的故障。
|
||||
|
||||
### 7. 判断"连上没有"要问 IDF,不要信 esp-idf-svc 的事件缓存
|
||||
|
||||
`BlockingWifi::is_connected()/is_up()/wait_netif_up()` 读的是 esp-idf-svc 自己维护的
|
||||
WiFi 事件状态。实测在 **IDF v6.0.1 + esp-idf-svc 0.53** 上它会停在"已连接":日志说
|
||||
一切正常、`get_ip_info()` 却是 `0.0.0.0`,而 IDF 的 `esp_wifi_sta_get_ap_info()` 明明
|
||||
说没关联。后果是 `join()` 假装连上了、看门狗永远不会重试,板子静默失联。
|
||||
|
||||
所以固件里一律用 IDF 的 API 判断:
|
||||
|
||||
- 关联:`esp_wifi_sta_get_ap_info()`
|
||||
- 地址:`esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")` + `esp_netif_get_ip_info()`
|
||||
|
||||
而且 **WiFi 是尽力而为**:`join()` 失败只警告、不退出。一块因为没网就不亮灯的装甲板,
|
||||
比"裁判端看不到血量"糟糕得多。看门狗每 5 s 检查一次"关联 + 有 IP",不满足就断开重连
|
||||
(重连时只等 3 s IP,别让主循环错过受击)。
|
||||
|
||||
### 8. 日志里"有没有连上"要说清楚
|
||||
|
||||
状态行长这样:
|
||||
|
||||
```text
|
||||
status: hp=0 hits=37 dead=true plates[front=37 rear=0 left=0 right=0]
|
||||
peak[front=504 rear=379 left=459 right=486] thr=2000
|
||||
strips[front=off rear=off left=off right=off]
|
||||
ap=SR-602-2G4 rssi=-29 ch=6 ip=192.168.1.117 reports=1745 errs=0
|
||||
```
|
||||
|
||||
- `peak[...]` 是**这一秒内每路 ADC 的峰值**,用来校准阈值:没敲时的底噪要明显低于
|
||||
阈值,正常敲击要明显高于它(实测底噪 ~400–500,敲击 2000–4095)。只报"有没有过阈值"
|
||||
没法判断阈值该调到哪。
|
||||
- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。
|
||||
- `ap=` / `ip=` / `reports=` 一眼分清"板子没连上"和"包发出去了但对端没显示"。
|
||||
|
||||
### 9. USB 控制台看不到复位后的头两秒
|
||||
|
||||
主控制台是 UART0,另外把 USB Serial/JTAG 配成了次控制台,所以插原生 USB 也能看日志。
|
||||
但复位时 USB 会重新枚举,**复位后最初约 1–2 秒的日志会丢**(我们实测错过过启动横幅)。
|
||||
要看完整启动过程就用 UART0 那对脚接 USB-TTL——那才是设计中的调试口。
|
||||
|
||||
## 与参考实现(`reference/armor`)的差异
|
||||
|
||||
| | 参考实现 | 这里 | 为什么 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主从链路 | ESP-NOW 配对,两块板对拷 | 无:三块从板只是直接接线的灯与传感器 | 按实际硬件(只有主板一颗 MCU) |
|
||||
| DBUS 透传 | UART0 收发 10 字节 DBUS,USB CDC 打 CSV | 不做 | 这套硬件没有 DBUS 需求;UART0 让给日志 |
|
||||
| 受击变暗 | 四条灯带一起全黑 50 ms | 只暗被打的那一路 | 每面独立灯带的意义就在这里 |
|
||||
| 冷却窗口 | 全局 50 ms | 每板独立 50 ms | 别块板的击打不该被吃掉 |
|
||||
| 灯带刷新 | 每 10 ms 无条件刷四遍 | 只在内容变化时刷 + 每秒强制全刷 | 四路独立控制后,省掉四轮 RMT 开销 |
|
||||
| 血量 | 只有击打计数,无血量 | 整车 100 血、每击 −3、0 血全黑,按 `ArmorHealth` 上报 | 与裁判端界面的血量显示对齐 |
|
||||
| 颜色切换 | BOOT 短按切红/蓝,存 NVS | 颜色由 `ARMOR_TEAM` 决定(红/蓝),短按打状态 | 队别不该靠现场按键切错 |
|
||||
| 按键 | 移植 OneButton 状态机(短按配对,长按切色) | 本地 FSM:长按复位血量、短按打状态 | 只有两种动作,且要能在主机上测 |
|
||||
| 上报 sender_id | —(不走 UDP) | MAC 派生 id | 0 是裁判终端保留 id |
|
||||
|
||||
## 还没做的
|
||||
|
||||
- **另外三路(后/左/右)的压电片与灯带还没接**(2026-10-14 实机只有前一路)。
|
||||
代码按四路齐全写,接线后无需改代码;但"四路同时受击"、"每路独立变暗"这两件事
|
||||
还没在实板上验证过。
|
||||
- **UART0 RX(GPIO20)上的命令通道**:日志方向已经能用;反向的 `status` / `reset`
|
||||
命令还没接(当前复位走 BOOT 长按)。
|
||||
- **从板加 MCU 的方案**:如果以后从板也装单片机(走 UART 或无线回传),
|
||||
`armor-protocol` 里已经预留了板位枚举与上报格式,加一个 `slave` bin 即可。
|
||||
- **双击 / 多击**:`Button` 只实现短按与长按,参考实现里未使用的 OneButton 功能没有移植。
|
||||
- **下发血量**:当前板子本地算账(裁判端只显示)。若以后要裁判端权威(下发血量/复位),
|
||||
需要新增一条下行消息并在 `desktop` 里发。
|
||||
接线、规则、工具链版本、测试记录与实现限制见[装甲板说明](../docs/hardware/armor.md)。它使用 ESP32-C3 / RISC-V 目标,不能烧录 S3 图传固件。其他主题见[文档首页](../docs/index.md)。
|
||||
|
||||
+9
-673
@@ -1,681 +1,17 @@
|
||||
# RMIC::2026
|
||||
# RMIC::2026 桌面端
|
||||
|
||||
黑龙江大学 602 实验室 · Simba Robotics 的**视野链路终端**,桌面端。
|
||||
两种身份、两套页面,都在同一个进程里。
|
||||
Tauri v2 + React + Rust 客户端。用户端显示本队图传、双方装甲条、瞄准器与命中反馈;裁判端显示双路画面、装甲板列表,并支持队名编辑、槽位分配、截图与录制回放。
|
||||
|
||||
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
||||
|---|---|---|---|---|
|
||||
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
||||
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
||||
|
||||
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。它不是把旧客户端套进
|
||||
一个窗口:网络、重组、发现、配置、密码、录制全部重写成可单测的模块。
|
||||
|
||||
> **名字与数据目录**:显示名是 `RMIC::2026`;安装包与可执行文件叫 `RMIC-2026`(Windows
|
||||
> 文件名不允许 `:`)。配置、录制、日志仍落在旧的 `cam-desktop` 目录下——这是**故意的**,
|
||||
> 换名字不该顺手把现场的密码哈希与绑定丢掉。见「配置」一节。
|
||||
|
||||
```text
|
||||
desktop/
|
||||
core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试)
|
||||
src-tauri/ Tauri 后端:推流、命令、录制(40 个测试)
|
||||
src/ React 前端:两套页面 + HUD
|
||||
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化、准星绘制、界面小节拍
|
||||
src/components/ 取景器、装甲条、命中、结算、装甲板表、队名、录制、设置抽屉、启动序列、门、提示
|
||||
src/pages/ 用户端 / 裁判端 / 固件维护 / 延迟校准
|
||||
src/styles/ tokens(令牌)、base(原子)、chrome(外壳)、stage(舞台)、console、settings、maintenance
|
||||
public/fonts/ 自托管的 HarmonyOS Sans SC 与 JetBrains Mono
|
||||
src-tauri/icons/ 队标(由 simba-robotics.com/logo.svg 生成,含光学尺寸分级)
|
||||
```
|
||||
|
||||
## 界面:一套终端语言
|
||||
|
||||
### 配色:墨黑蓝打底,只有一支红
|
||||
|
||||
| 角色 | 值 | 用在哪 |
|
||||
|---|---|---|
|
||||
| 底色 | `--void #05070d` → `--panel-3 #172032` | 全部面积。五级低饱和冷蓝黑,靠 1px 线分层,不靠阴影 |
|
||||
| 文字 | `--ink #e7ecf5` → `--ink-4 #3a4457` | 四档,第四档暗到不像真信息 |
|
||||
| **强调** | `--primary #e8232b` | **只有**:焦点、选中、录制中、错误、临界血量、结算 |
|
||||
| 状态 | `--error` / `--warn #f2b33d` / `--ok #3fc98a` | 各有语义,不与强调色混用 |
|
||||
| 队伍 | `--team-0 #ff6b5e` / `--team-1 #4a9bff` | **硬件约束**:装甲板 LED 就是红与蓝 |
|
||||
|
||||
一条硬规则:**红只给必须被看见的东西**。开关合上是白的、滑杆填充是钢灰、分段控件的选中
|
||||
记号是底沿一条 2px 线、分区编号是灰的、顶栏没有任何强调线。红色出现的地方,后面都有一
|
||||
件真事。
|
||||
|
||||
队红刻意做得比强调红更暖更浅,否则两条血并列时会被误读成"系统在报警"。
|
||||
|
||||
### 形状:矩形,唯一的斜边给装甲条
|
||||
|
||||
全局是矩形:没有圆角、切角、药丸,也没有阴影卡片。**唯一的斜边是装甲条那排平行四边形**
|
||||
——它必须在一屏矩形里被一眼认成"这是血,不是别的东西"。别的元素一旦也开始斜切,那个形状
|
||||
就不再携带信息了。
|
||||
|
||||
### 动效:严格照 Material 3 的曲线与时长
|
||||
|
||||
`styles/tokens.css` 里直接写着 M3 的七条缓动与 short/medium/long 四组时长:
|
||||
|
||||
| 令牌 | 曲线 | 语义 |
|
||||
|---|---|---|
|
||||
| `--m3-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | 需要存在感的转场 |
|
||||
| `--m3-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | **进入**(快起慢收) |
|
||||
| `--m3-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | **退出**(慢起快走) |
|
||||
| `--m3-standard` / `-decelerate` / `-accelerate` | `(0.2,0,0,1)` / `(0,0,0,1)` / `(0.3,0,1,1)` | 常规位移 |
|
||||
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
|
||||
|
||||
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为**指向 M3 的别名**,所以没有一条规则落在自造
|
||||
的贝塞尔上。两个例外是刻意的:`steps()` 用于录制红点与终端光标(离散闪烁),`linear` 用于
|
||||
"没信号"那圈恒速转动的虚线环——M3 的曲线描述的是有始有终的转场,不是无限循环。
|
||||
|
||||
### 动效:每一页只有一个签名动作
|
||||
|
||||
重复使用同一种效果会让界面显得机械,所以只共享底层曲线与时长,**每页各有一个属于自己的
|
||||
动作**:
|
||||
|
||||
| 界面 | 签名动作 | 它解决什么 |
|
||||
|---|---|---|
|
||||
| 启动序列 | 一行行亮起的自检 + 真实进度 | 让"还在等"与"卡住了"分得开 |
|
||||
| 首启门 | 两张模块卡,悬停浮起并露出角括号 | 说明这件选择是要"装"进这台机器的 |
|
||||
| 用户端 | 逐格熄灭的装甲条、四周泛红、六秒休眠 | 静止时屏幕上的东西越少越好 |
|
||||
| 裁判端 | 中缝可拖、鼠标停在哪一路下方就看哪一路 | 不用再开第三个页面去"看某一边" |
|
||||
| 设置 | 常驻实时预览 + 竖向分节导航 | 改准星/网格/装甲条时立刻看得见结果 |
|
||||
| 固件维护 | 步骤轨 + 环形进度 + 终端日志 | 烧录的哪一步、到哪儿了,一眼可读 |
|
||||
| 解锁 | 一次性的校验扫描 + 错误时的一次抖动 | 明确"现在这一步是在验证身份" |
|
||||
|
||||
`prefers-reduced-motion` 会把这些时长压到 0,不是关掉功能。
|
||||
|
||||
### 字体:中文一套、拉丁一套,逐字回退
|
||||
|
||||
| | 字体 | 承担什么 |
|
||||
|---|---|---|
|
||||
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
|
||||
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
|
||||
|
||||
两者都以 woff2 自托管在 `public/fonts/`,随 `frontendDist` 进安装包,**运行时不碰网络**
|
||||
(CSP 里 `default-src 'self'` 也不允许)。中文由 `cn-font-split` 切成 82 个 unicode-range
|
||||
分片 × 3 个字重,浏览器只下载页面真正用到的那几十个,而不是一上来吞 14 MB。
|
||||
|
||||
合到一句话里靠**逐字回退**,不做语言判断:`--font-ui` 把 JetBrains Mono 写在前面,它里面
|
||||
没有汉字,浏览器就逐字往下找,落到 HarmonyOS。于是"血量 87"里数字是等宽的、汉字是
|
||||
HarmonyOS 的,一条 `font-family` 同时满足两个要求。
|
||||
|
||||
启动序列会等 `document.fonts.ready`:先画成系统黑体、两百毫秒后再跳成 HarmonyOS,会让整块
|
||||
布局重排一次——那一个抖动比多等一瞬难看得多。
|
||||
|
||||
### 队标
|
||||
|
||||
应用图标与启动页的标记是队标(`https://simba-robotics.com/logo.svg`),由它生成整套
|
||||
`src-tauri/icons/`。原图是 1024×1024 的画布、标志只占中间一条,所以脚本把 viewBox 收到本体
|
||||
上再居中;**小尺寸用更大的缩放比**(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%),
|
||||
否则 16/24/32 上那些 1px 笔画会糊成一团。`icon.ico` 是手工拼的多尺寸容器——Pillow 的写入器
|
||||
只能从一张主图缩出所有档位,而 Windows 任务栏恰恰只取最小的那几档。
|
||||
|
||||
笔画在 17px 以下就只剩 1px,所以顶栏只放字标 `RMIC::2026`(`::` 是界面里唯一被染红的两个
|
||||
字符),完整队标留给启动页的 30px。
|
||||
|
||||
### 每页最顺手的那个操作
|
||||
|
||||
- **用户端不用点。** 操作手的手在遥控器上,所以这一页的交互全部收在画面上一个手势里:
|
||||
滚轮缩放、拖动平移、双击复位。没有任何"瞄准再点"的按钮。六秒没有输入也没有挨打时,角上
|
||||
的标签与代号自己淡下去;一有动静整块醒来。
|
||||
- **裁判端让控制台跟着鼠标走。** 按下左格,下方列表就只显示那一边的装甲板(再按一次回到
|
||||
全部,键盘 `1` / `2` / `0` 同样)。中间那条竖线可以拖——哪一路更重要就把它拉宽,双击回到
|
||||
五五开。画面与下方控制台之间的横线也能拖:看回放的时候把播放器拉大。
|
||||
- **设置是一块带预览的控制台。** 左侧竖向导航,右侧常驻一块实时预览;预览用的是画面里
|
||||
**同一个**绘制函数(`lib/reticle.ts`),所以"预览里对、画面上偏"这种最坏情况不可能发生。
|
||||
预览旁边那根血量滑杆是刻意加的:低血阈值与脉动只有真的把血拖下去才看得见。
|
||||
|
||||
## 版本与改动日志:算出来的,不是写出来的
|
||||
|
||||
版本号和改动日志都从 **git 历史**算,不靠人记着去改。
|
||||
在本目录执行:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs --write # 计算并写回所有派生文件
|
||||
node scripts/version.mjs --check # 只校验:有未归档的提交就退 1(CI 里跑这个)
|
||||
node scripts/version.mjs --print # 把算出来的版本打到 stdout
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
提交标题用 Conventional Commits(`type(scope): 说明`):
|
||||
|
||||
| 这批提交里有 | 升哪一位 |
|
||||
|---|---|
|
||||
| `BREAKING CHANGE:` 或 `type!:` | major |
|
||||
| `feat` | minor |
|
||||
| `fix` / `perf` | patch |
|
||||
| 其它 | 不动版本,只加构建号 |
|
||||
|
||||
构建号是 `git rev-list --count HEAD`:单调递增、不会撞车、不用手工维护。正文里以 `- `
|
||||
开头且本身写成 `type(scope): 说明` 的行算**额外条目**——这样一次 squash 提交也能把几件事
|
||||
分别归类,不必为了日志好看去拆提交。
|
||||
|
||||
### 派生出来的东西
|
||||
|
||||
| 文件 | 谁用 |
|
||||
|---|---|
|
||||
| `version.json`(仓库根) | 固件 `build.rs` 读它,编译期注入 `RMIC_VERSION*` |
|
||||
| `CHANGELOG.md` / `CHANGELOG.json` | 人读的那份与机器读的那份,同一次计算产出 |
|
||||
| `desktop/src/lib/version.ts` | 界面上的版本标签与协议号 |
|
||||
| `desktop/package.json`、`tauri.conf.json` 的 `version` | 装包文件名与 Windows 的「应用和功能」 |
|
||||
|
||||
建这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](../docs/CHANGELOG-legacy.md)。
|
||||
|
||||
### 固件与客户端怎么对上
|
||||
|
||||
同一条链:`git → version.json → 固件二进制 → Hello 报文 → 客户端界面`。
|
||||
|
||||
板子每 2 秒广播的 `Hello` 里带着**语义化版本、构建号、以及它编译进去的线路协议号**
|
||||
(布局见 `protocol/README.md`)。客户端读出来之后:
|
||||
|
||||
- 设置面板的绑定下拉里显示 `固件 v0.2.0 · 34`;
|
||||
- 裁判端每一格下方显示这块板子的固件版本;
|
||||
- 协议对不上时,用户端画面正中给出**具体原因**("它是协议 2,本客户端是协议 1"),
|
||||
而不是只表现为没有画面——它的视频帧会被严格解析丢弃,但那句解释正是靠通告读出来的。
|
||||
|
||||
**为什么值得占这 10 个字节**:一块烧错固件的板子,原来只表现为"没画面",现场会去查网线、
|
||||
AP、供电。现在同一个现象会直接指向"重新烧固件"。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
pnpm install
|
||||
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
|
||||
pnpm build # 只构建前端(tsc --noEmit && vite build)
|
||||
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
|
||||
pnpm app
|
||||
```
|
||||
|
||||
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
||||
`pnpm build` 构建前端,`pnpm tauri build` 打包应用。默认 Rust target 是 macOS ARM;Windows 和 Linux 需显式指定宿主 target。
|
||||
|
||||
```sh
|
||||
cd desktop/core && cargo test # 83
|
||||
cd desktop/src-tauri && cargo test # 40,其中几条真的会起 ffmpeg
|
||||
```
|
||||
|
||||
### Windows x64 构建
|
||||
|
||||
需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。
|
||||
|
||||
在 `desktop/` 中执行:
|
||||
|
||||
```powershell
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
`--target` 覆盖本目录原来的 macOS target,`--bundles nsis` 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 `build-std`。
|
||||
|
||||
- 程序:`src-tauri/target/x86_64-pc-windows-msvc/release/RMIC-2026.exe`(名字由 `tauri.conf.json` 的 `productName` 决定;直接 `cargo build` 出来的原始产物仍叫 `cam-desktop.exe`,那是内部构建物,不是交付物)。
|
||||
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
|
||||
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
|
||||
|
||||
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
|
||||
|
||||
设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免"能发现并绑定,但始终等待画面"的情况。队伍端的绑定和画面过滤仍然生效。
|
||||
|
||||
### 演示模式:没有板子也能把整条链路跑起来
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
CAM_DEMO=1 pnpm app
|
||||
```
|
||||
|
||||
它不伪造数据给界面,而是合成协议数据报,从 `Receiver::datagram` 与
|
||||
`ArmorHealthStore::datagram` 进去。演示模式跑的是同一条真实路径:
|
||||
|
||||
```text
|
||||
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||||
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||||
```
|
||||
|
||||
分片、重组、发现、统计、掉血派生、两道闸门全在跑真代码,截图与录制也不需要特殊分支。
|
||||
界面上会有一条 **演示模式** 标记,板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出是人造
|
||||
的形状——**别把它当成现场数据**。
|
||||
|
||||
### 命令行与环境变量
|
||||
|
||||
```text
|
||||
--role <team|referee> 身份。默认看配置文件
|
||||
--config <路径> 配置文件位置
|
||||
--demo 演示模式
|
||||
--list 打印发现的摄像头与装甲板后退出
|
||||
-h, --help 帮助
|
||||
|
||||
CAM_DEMO=1 等价于 --demo
|
||||
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||||
```
|
||||
|
||||
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||||
(`tauri dev -- -- --demo`),写在一行环境变量里不会有人记错。
|
||||
|
||||
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
|
||||
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
|
||||
|
||||
## 为什么换成 Tauri,代价是什么
|
||||
|
||||
旧客户端是 Rust + Slint 单二进制,能用。换掉它的理由是**界面这一侧的生产力**:血条、
|
||||
瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 CSS/HTML 写是
|
||||
几十分钟的事,用即时模式 UI 工具写要自己排版、自己画控件、自己处理焦点。这个项目的重心
|
||||
在链路(UDP、重组、发现、防作弊),界面应当越快改完越好。
|
||||
|
||||
代价说清楚:
|
||||
|
||||
- **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
|
||||
- **要联网装依赖**(前端 npm、Rust crates)。
|
||||
- **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器,
|
||||
路径更短。
|
||||
|
||||
换来的另一样东西是**可测性**:`core/` 不认识界面,所以重组、发现、帧率窗口、槽位分配、
|
||||
配置与密码全都能用单元测试盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
|
||||
|
||||
## 视频怎么进 WebView
|
||||
|
||||
视频走一个只监听回环地址的 WebSocket,推二进制 JPEG。界面从 `stream_info` 命令拿到
|
||||
`ws://127.0.0.1:<随机端口>/?token=...`。
|
||||
|
||||
### 为什么不是 Tauri 的 IPC
|
||||
|
||||
两路 22fps、每帧约 22 kB,每秒约 1 MB 的裸 JPEG。走 IPC 要序列化成 JSON(base64 再涨
|
||||
1/3),前端再解回来——CPU 花在没人需要的编解码上,延迟白涨一截。二进制 WebSocket 帧把
|
||||
原始字节直接交给前端,浏览器用**原生解码器**解,**后端连 JPEG 都不用解**。
|
||||
|
||||
### 二进制帧的布局
|
||||
|
||||
```text
|
||||
0 4 8 10 12 16
|
||||
+--------------+--------------+------+------+--------------+---------+
|
||||
| sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
|
||||
+--------------+--------------+------+------+--------------+---------+
|
||||
```
|
||||
|
||||
小端,16 字节头。前后端对这段布局的约定由一个测试盯着(`server.rs` 与 `stream.ts`
|
||||
两侧都有),长度不一致的帧宁可丢掉,也不要让前端拿到一段不完整的 JPEG。
|
||||
|
||||
### 前端侧的两个陷阱
|
||||
|
||||
**解码是异步的,所以帧号要看着用。** `createImageBitmap` 返回 Promise,两个相邻帧的解码
|
||||
可能乱序完成。所以每路记住最后画过的帧号,只有更新的那帧才采用——没有这一步,画面会偶尔
|
||||
往回跳一帧,看起来像"卡了一下",极难归因。
|
||||
|
||||
**`ImageBitmap` 必须显式 `close()`。** 它持有解码后的像素,不吃 GC 的账。每收到新帧就
|
||||
替换旧的那张并不够,必须把旧的那张关掉,否则两路 22fps 跑几分钟就是几百 MB。
|
||||
|
||||
## 防作弊:两道闸门,不是一个
|
||||
|
||||
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
|
||||
|
||||
关键是这件事做在接收端,不是界面里。广播的 Hello 里就有 MAC 派生的 `sender_id` 与
|
||||
IP,藏起来毫无意义;数据也可能已经收下并解码了,CSS 只控制显示。
|
||||
|
||||
| 闸门 | 管什么 | 什么时候开 |
|
||||
|---|---|---|
|
||||
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
|
||||
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
||||
|
||||
旧客户端把两件事绑在同一道闸门上,于是只能在"能列出候选"和"挡住对手"之间二选一,改绑
|
||||
只能靠手改配置文件。现在分开了:设置界面能列出候选摄像头(否则无从选起),但**画面进不来**:
|
||||
对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧也进不了重组器。
|
||||
|
||||
`only_sender` 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。
|
||||
|
||||
### 遥测按编译模式门控
|
||||
|
||||
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
|
||||
数字——不是"界面藏起来",是 DTO 里就不存在(`CameraStatsDto::build` 直接把它们置零)。
|
||||
由编译模式决定而不是命令行开关:开关意味着二进制里带着那套东西,忘开一次就泄露了。
|
||||
|
||||
### 这挡不住什么
|
||||
|
||||
**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后可以自己发一个
|
||||
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。真要挡住
|
||||
需要固件侧做配对 token(首次绑定发一个令牌,固件只接受带令牌的订阅),代价是固件重启就
|
||||
失配。
|
||||
|
||||
回环通道上那个 token 也不是防对手的:它只让"本机另一个程序顺手连上来看画面"变成一件需要
|
||||
先读到那个 token 的事。
|
||||
|
||||
## 密码:它挡的是谁
|
||||
|
||||
- **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;围观
|
||||
的人翻不到明文(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——改了之后界面会
|
||||
要求先解锁。**退出/关闭程序**同样要密码,因为"随手关掉"也是一种要挡的误操作。
|
||||
- **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置与二进制都在他手上,删掉哈希
|
||||
或者直接改源码都行。真要封住得回固件加配对 token。
|
||||
|
||||
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
|
||||
|
||||
实现上:
|
||||
|
||||
- Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希
|
||||
一起躺在配置里,以后调参不需要迁移。用快哈希(SHA-256 之类)对 4 位数字密码等于没有
|
||||
保护。
|
||||
- 首次启动**强制**设置密码(`Config::needs_password_setup`),最短 4 位。长度下限只要
|
||||
4 位是刻意的:一个 8 位强密码在这里换不来任何实际安全增益,却会让现场每次改绑都要掏
|
||||
手机查一遍。
|
||||
- 解锁一次管到进程结束(会话内)。用户端可以手动"上锁"。
|
||||
- **裁判端不要密码**:它没有"绑定到自己那块板子"的动作可被滥用,而现场裁判要能最快地改
|
||||
槽位、开录。
|
||||
- **离开用户端要密码**:否则切到裁判端就能看两路画面、还能随便改配置,闸门形同虚设。
|
||||
|
||||
执行点在 Rust 侧:每个改变配置的命令都过 `AppState::require_unlocked`。界面上的解锁
|
||||
对话框只是入口——即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。
|
||||
|
||||
**故意没有 `--camera-id` 这类命令行覆盖。** 旧客户端有一个,用来"临时把一台机器当另一台
|
||||
用";在这里它等于一条不需要密码就能改绑的路,所以去掉。要临时换一块板子,就在设置里改
|
||||
(要密码),或者用 `--config` 指一份另外的配置文件。
|
||||
|
||||
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
|
||||
导入并备份成 `.bak`。
|
||||
|
||||
## 用户端 HUD
|
||||
|
||||
### 血条:两个上角,平行四边形,一条分 5 格
|
||||
|
||||
**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数,
|
||||
放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格
|
||||
20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。
|
||||
少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。
|
||||
|
||||
(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、
|
||||
指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。)
|
||||
|
||||
宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区
|
||||
在哪"是一直看得见的,而不是等真的低血了才第一次出现。
|
||||
|
||||
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
|
||||
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||
|
||||
血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留
|
||||
一个 0 与灭掉的格子。
|
||||
|
||||
### 胜负横幅
|
||||
|
||||
一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是
|
||||
「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接
|
||||
看出是哪一边赢了。
|
||||
|
||||
- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是
|
||||
输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的
|
||||
自家主板会让人"凭空胜利"。
|
||||
- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有
|
||||
"这局结束了"的痕迹,截图、回放与现场的争议都无从对照。
|
||||
- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**:
|
||||
它是一局的结束,此刻不再需要瞄准。
|
||||
|
||||
### 瞄准器
|
||||
|
||||
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
|
||||
用 DOM 摆一个 `<img>` 再叠一层 `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而
|
||||
两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换
|
||||
只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。
|
||||
|
||||
尺寸与缩放的关系也是刻意分开的:
|
||||
|
||||
| | 跟着缩放走吗 | 为什么 |
|
||||
|---|---|---|
|
||||
| 位置 | **是** | 它指的是画面里的一个点 |
|
||||
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
|
||||
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
|
||||
|
||||
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
|
||||
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
|
||||
|
||||
### 命中反馈
|
||||
|
||||
主板**不报**"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的
|
||||
(`HealthTracker`):
|
||||
|
||||
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
|
||||
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
|
||||
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
|
||||
|
||||
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
|
||||
基线会让一次掉血被报成四次命中。
|
||||
|
||||
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
|
||||
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
|
||||
|
||||
反馈有**两样**:画面四周泛红(颜色只从四条边渗进来,中间保持原样)、准星旁的伤害数字。
|
||||
**大号数字不放在画面正中**——第一版放了,结果它正好压在瞄准点上,而被打中的那一下恰恰是最
|
||||
需要看着准星的时候。
|
||||
|
||||
**它不给方向。** 命中事件里的 `index` 是哪一条上报先发现了掉血,不是"从哪一侧打来的"(主板
|
||||
一次广播四条、血值相同,谁先到达取决于 UDP)。把它画成方向箭头会是一条看起来很专业、实际上
|
||||
完全错误的线索,比不画糟得多。
|
||||
|
||||
**没有"画面抖动"了。** 原来有第三样:被打中时整幅图(连瞄准器一起)晃一下。它去掉了,
|
||||
理由有两条:
|
||||
|
||||
- 抖动恰好发生在最需要看清画面的那一下;
|
||||
- 它把**画面**动了,而操作手看到画面动时的第一反应是"车在晃",不是"我被打中了"——一个
|
||||
只有几百毫秒的信号,代价是让人怀疑一个不存在的问题。
|
||||
|
||||
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
|
||||
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
|
||||
面板里也没有这个开关了。
|
||||
|
||||
## 裁判端
|
||||
|
||||
### 队名可以直接改
|
||||
|
||||
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
|
||||
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
|
||||
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
|
||||
|
||||
规范化的三件事统一在 Rust 侧做(`config::sanitize_team_name`,有单测):合并空白、按**字符**
|
||||
截断到 12 个字(按字节截断会切出半个字)、留空回落到「队伍一 / 队伍二」。超过长度在输入框里
|
||||
就限制了,比提交后被悄悄截掉诚实。
|
||||
|
||||
命令是 `set_team_name(team, name)`——**一次只改一边**。界面本来可以传一整个两条的数组,但那样
|
||||
每次改名都是"读两条、改一条、写两条",而读来的那两条来自最多 200 ms 前的快照,紧接着改另一边
|
||||
就会把上一次的改动覆盖回去。**同样过密码闸门**:裁判端本来就没有密码(`Role::needs_password`
|
||||
为假),所以对现场没有影响;而给一个不设闸门的"写配置"入口,等于在闸门上开一条旁路——今天
|
||||
改的是名字,明天有人顺手把绑定也挪进来。
|
||||
|
||||
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
|
||||
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
|
||||
而且不会报错。
|
||||
|
||||
### 两格等宽
|
||||
|
||||
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
|
||||
|
||||
旧客户端在这里翻过车:`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按 stretch
|
||||
分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"(5 字),于是左边多要
|
||||
十几个像素——实测 55% / 45%。现在占位文字绝对定位、两格需求相同。
|
||||
|
||||
### 槽位分配:修掉了一个真 bug
|
||||
|
||||
规则:
|
||||
|
||||
1. 还在线的槽位不换人;
|
||||
2. **候选里排除已经被另一个槽位占着的 id**;
|
||||
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
|
||||
4. 没有候选就不动——只有一块板在场时另一个槽位空着,空着比显示一份复制品诚实。
|
||||
|
||||
规则 2 是修 bug。旧实现只排除"这个槽位自己原来那个 id",于是**队伍一的板子一离线,它的
|
||||
槽位就会去拿队伍二那块,两格显示同一块板子**——看起来像图传串了,现场的排查方向会是网线、
|
||||
AP、板子,而不是二十行分配代码。现在它有一个直接对着这个场景写的测试。
|
||||
|
||||
有烧错固件的板子(`team` 字段是反的)时可以在设置里改成手动指定槽位。
|
||||
|
||||
### 装甲板发现列表
|
||||
|
||||
场上连上了几块板子、各自自报哪一队、现在多少血、多久没广播、四条上报齐不齐、分到了哪个
|
||||
槽位——"裁判端没显示血量"时第一眼看的就是这张表。它能区分"板子根本没上线"、"上线了但队
|
||||
伍号烧反了"和"只有一条上报在发"。
|
||||
|
||||
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
|
||||
只是旧的解码路径把它丢了。现在 `ArmorHealth::decode_from` 把它交出来。
|
||||
|
||||
### 截图与录制
|
||||
|
||||
- 截图存 PNG(`shots/` 下),文件名带板子 id 与毫秒时间戳。
|
||||
- 录制存 mp4,走 ffmpeg 子进程:`-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0`
|
||||
喂帧,x264 编码,`+faststart`。
|
||||
- **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来
|
||||
就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放
|
||||
时第一个要看的就是它。
|
||||
- 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是
|
||||
链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。
|
||||
- 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的
|
||||
`-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。
|
||||
|
||||
界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的
|
||||
目录生效。默认录制目录在名单里;改到别处时 `<video>` 会加载失败,界面上给出"用系统播放器
|
||||
打开"的退路——而不是让人以为录下来的文件坏了。
|
||||
|
||||
## 配置
|
||||
|
||||
`~/.config/cam-desktop/config.toml`,`--config` 可改。**目录名沿用旧的 `cam-desktop`**(原因见开头)。要干净重来就删掉这个目录。
|
||||
|
||||
```toml
|
||||
role = "team"
|
||||
roleChosen = true
|
||||
team = 0
|
||||
teamNames = ["队伍一", "队伍二"]
|
||||
cameraId = "0x0DE00001"
|
||||
armorId = "0x0A200001"
|
||||
mirror = false
|
||||
password = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
|
||||
[recording]
|
||||
dir = "/Users/you/Movies/cam-desktop"
|
||||
ffmpeg = "ffmpeg"
|
||||
crf = 20
|
||||
preset = "veryfast"
|
||||
|
||||
[hud.crosshair]
|
||||
style = "cross-dot"
|
||||
size = 0.05
|
||||
offsetX = 0.0
|
||||
```
|
||||
|
||||
几个决定:
|
||||
|
||||
- **手写解析换成 serde + toml。** 旧客户端的配置只有四个标量字段,手写换来零依赖是划算
|
||||
的;现在有 HUD 的嵌套结构、密码哈希、录制参数,对称性成本超过了收益。
|
||||
- **未知键名仍然报错**(`deny_unknown_fields`)。打错一个键名却"看起来生效了",在现场是
|
||||
要浪费时间的。
|
||||
- **字段名统一 camelCase**,磁盘、IPC、前端 TS 一个名字。TOML 里写 camelCase 不算最漂亮,
|
||||
但换来的是不需要任何一层做名字转换——而那种转换漏掉一个字段时不会报错,只会让某一项设置
|
||||
看起来"改了没生效"。
|
||||
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
|
||||
文件会让"解锁"永久失败。
|
||||
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
|
||||
- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队
|
||||
叫什么"是比赛本身的属性,不是裁判端这个身份的属性。
|
||||
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
|
||||
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
|
||||
|
||||
旧客户端(Slint 版)的配置在首次启动时导入能对应的字段,然后改名成 `.bak`(不删:现场
|
||||
出问题时"旧的那份到底写了什么"是第一个要看的东西)。只有用**默认**路径时才做这件事——
|
||||
`--config` 指定了别的路径说明这次运行是有意分开的,那时去动常规配置是越界的。
|
||||
|
||||
## 浏览器预览(仅开发)
|
||||
|
||||
Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建。所以 dev 模式下、且页面
|
||||
里没有 Tauri 注入时,同一套 React 组件可以在浏览器里跑起来:状态与画面由
|
||||
`src/lib/dev-preview.ts` 合成。
|
||||
|
||||
```sh
|
||||
pnpm dev # 用户端
|
||||
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
|
||||
open 'http://127.0.0.1:1420/?preview=first-run' # 首启的身份选择
|
||||
open 'http://127.0.0.1:1420/?preview=password' # 首启的设密码
|
||||
```
|
||||
|
||||
后两个参数只是让对应的门禁强制显示出来,不用为了看一眼就去删配置文件。
|
||||
|
||||
两重门保证它进不了正式构建:`import.meta.env.DEV` 为真**且**没有 `__TAURI_INTERNALS__`,
|
||||
并且调用点是 `await import(...)`——条件为假时整个模块不会被打进产物。
|
||||
|
||||
## 测试
|
||||
|
||||
```sh
|
||||
cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
||||
cd desktop/src-tauri && cargo test # 40:帧头布局、录制、截图、演示画面、命令行
|
||||
cd protocol && cargo test # 23
|
||||
```
|
||||
|
||||
前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,5 项测试覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复与设备重启。它不替代实际 WebView 的画面验证。
|
||||
|
||||
三条值得单独说的:
|
||||
|
||||
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
|
||||
存在且非空"是不够的:ffmpeg 参数写错时也会留下一个空的容器,而那正是最难在现场发现的
|
||||
一种失败。
|
||||
- **截图**那条真写一个 PNG 再读回来对尺寸,另外验证坏输入会报错而不是留下一个空文件。
|
||||
- **帧率窗口**那条把"对帧间隔取平均会给出什么"也钉住了(约 30fps),防止有人"顺手修一下"
|
||||
改回错误算法。
|
||||
|
||||
CSP 的验证不靠"看一眼没报错",而是靠**前端能不能连上后端**:前端的第一步是先 `invoke`
|
||||
拿通道地址,再连 WebSocket。所以快照里的 `clients` 从 0 变成 1 同时证明了四件事——JS 加载
|
||||
成功、IPC 通、WebSocket 通、React 的 effect 跑到了。CSP 一旦挡住其中任何一环,它就会停在
|
||||
0。手动核对的办法:
|
||||
|
||||
```sh
|
||||
RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
||||
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1
|
||||
```
|
||||
|
||||
## 线程与数据流
|
||||
|
||||
| 线程 | 做什么 | 为什么单独 |
|
||||
|---|---|---|
|
||||
| `udp-reader` | 收包、解析、按发送方重组 | 不能让解码/写盘挤进数据报之间 |
|
||||
| `armor-reader` | 收 5601 上的血量广播 | 与视频是两个端口、两种节奏 |
|
||||
| `rec-writer-<id>` | 把队列里的帧写进 ffmpeg 的 stdin | 接收线程绝不能被写盘拖慢 |
|
||||
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
|
||||
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
|
||||
|
||||
帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。
|
||||
视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。
|
||||
前端每路最多保留一次正在进行的解码和一张最新待解码帧。
|
||||
|
||||
每张本地 JPEG 前增加 `frameTiming` 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。
|
||||
|
||||
## 已知限制与没做的
|
||||
|
||||
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
|
||||
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
|
||||
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
|
||||
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
|
||||
DTO,所以至少能解释清楚。
|
||||
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
|
||||
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
|
||||
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端",
|
||||
见"测试"一节最后一条。
|
||||
- **证书与公证**没做,`.dmg` 只在本机可用。
|
||||
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
|
||||
|
||||
## 排查
|
||||
|
||||
需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:
|
||||
|
||||
```powershell
|
||||
.\RMIC-2026.exe --config .\referee.toml --latency-report .\latency-run.ndjson
|
||||
```
|
||||
|
||||
报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 `VideoSurface` 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。
|
||||
|
||||
`pnpm test:latency` 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。
|
||||
|
||||
| 现象 | 原因 |
|
||||
|---|---|
|
||||
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
|
||||
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
|
||||
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
|
||||
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
|
||||
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
|
||||
| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug |
|
||||
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
|
||||
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
|
||||
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
|
||||
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
|
||||
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
|
||||
| 两块画面串了 | 检查是不是手动指定了槽位,或者两块板子烧了同一个 `ARMOR_TEAM` |
|
||||
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
|
||||
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||||
- [客户端说明](../docs/desktop/client.md):构建、配置、界面行为、网络通道、权限与排查。
|
||||
- [固件维护](../docs/maintenance/firmware.md):`.zip` 固件包、坏块规避、备份与回读校验。
|
||||
- [版本维护](../docs/development/versioning.md):版本计算与派生文件。
|
||||
- [文档首页](../docs/index.md):仓库的其他说明。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rmic-2026",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"description": "RMIC::2026 —— ESP32 图传与装甲板系统的桌面端:用户端(装甲条 + 瞄准器)与裁判端(双路 + 装甲板 + 录制)",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- RMIC::2026 应用图标(矢量源)。
|
||||
由 https://simba-robotics.com/logo.svg 生成:把原图 1024 画布的 viewBox 收窄、标志居中,
|
||||
底色用界面的 --deep。位图(PNG / ICO / ICNS)由这一份光栅化得到;
|
||||
小尺寸另有一版更大的缩放比(见 desktop/README.md 的「队标」一节)。 -->
|
||||
小尺寸另有一版更大的缩放比(见 docs/desktop/client.md 的「队标」一节)。 -->
|
||||
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93333" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="270.93333" height="270.93333" fill="#080b12"/>
|
||||
<g transform="translate(135.4667 135.4667) scale(0.82) translate(-135.28 -136.22)">
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -76,7 +76,7 @@ pub fn config_path(state: Shared<'_>) -> String {
|
||||
pub fn unlock(password: String, state: Shared<'_>) -> Result<bool, String> {
|
||||
state
|
||||
.unlock(&password)
|
||||
.map_err(|err| format!("存下来的密码哈希读不出来:{err}"))
|
||||
.map_err(|err| format!("无法读取已保存的密码哈希:{err}"))
|
||||
}
|
||||
|
||||
/// 手动重新上锁(并顺手把发现列表关掉)。
|
||||
@@ -305,7 +305,7 @@ pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
|
||||
let frame = state
|
||||
.latest_frames()
|
||||
.latest(camera)
|
||||
.ok_or_else(|| format!("还没有收到 0x{camera:08X} 的画面,暂时截不了"))?;
|
||||
.ok_or_else(|| format!("尚未收到 0x{camera:08X} 的画面,暂时无法截图"))?;
|
||||
|
||||
let config = state.config();
|
||||
let dir = config.recordings_dir().join("shots");
|
||||
@@ -329,7 +329,7 @@ pub fn start_recording(
|
||||
state: Shared<'_>,
|
||||
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
|
||||
if cameras.is_empty() {
|
||||
return Err("没有指定要录哪一路".into());
|
||||
return Err("请先选择要录制的画面".into());
|
||||
}
|
||||
|
||||
let config = state.config();
|
||||
@@ -347,8 +347,8 @@ pub fn start_recording(
|
||||
Some(measured) if measured > 1.0 => measured,
|
||||
_ => {
|
||||
log::warn!(
|
||||
"0x{camera:08X} 还没有测到帧率,录制先按 {FALLBACK_INPUT_FPS} fps 走;\
|
||||
回放速度可能不准,可以改用 [recording] fps 明确指定"
|
||||
"尚未测得 0x{camera:08X} 的帧率,录制暂用 {FALLBACK_INPUT_FPS} fps;\
|
||||
回放速度可能不准确,可在 [recording] 中设置 fps"
|
||||
);
|
||||
FALLBACK_INPUT_FPS
|
||||
}
|
||||
@@ -365,7 +365,7 @@ pub fn start_recording(
|
||||
config.recording.crf,
|
||||
&config.recording.preset,
|
||||
)
|
||||
.map_err(|err| format!("起不了录制:{err:#}"))?;
|
||||
.map_err(|err| format!("无法开始录制:{err:#}"))?;
|
||||
|
||||
started.push(dto);
|
||||
}
|
||||
@@ -405,7 +405,7 @@ pub fn reveal(path: String, state: Shared<'_>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err(format!("{} 不存在了", target.display()));
|
||||
return Err(format!("文件不存在:{}", target.display()));
|
||||
}
|
||||
|
||||
tauri_plugin_opener::reveal_item_in_dir(&target)
|
||||
@@ -427,7 +427,7 @@ pub fn open_file(path: String, state: Shared<'_>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err(format!("{} 不存在了", target.display()));
|
||||
return Err(format!("文件不存在:{}", target.display()));
|
||||
}
|
||||
|
||||
tauri_plugin_opener::open_path(&target, None::<&str>)
|
||||
@@ -470,7 +470,7 @@ pub fn clear_config(password: Option<String>, state: Shared<'_>) -> Result<Strin
|
||||
if path.exists() {
|
||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
||||
std::fs::rename(&path, &backup)
|
||||
.map_err(|err| format!("挪走 {} 失败:{err}", path.display()))?;
|
||||
.map_err(|err| format!("移动文件 {} 失败:{err}", path.display()))?;
|
||||
|
||||
log::warn!("配置已重置,旧文件留在 {}", backup.display());
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||
log::debug!("stream: {}", state.stream_info().url);
|
||||
|
||||
if args.demo {
|
||||
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
|
||||
state.note("演示模式已开启:画面与血量由本机合成。");
|
||||
demo::spawn(Arc::clone(&state));
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ impl Recorder {
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| {
|
||||
format!("could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径")
|
||||
format!("无法启动 ffmpeg({ffmpeg}),请在 [recording] 中设置 ffmpeg 路径")
|
||||
})?;
|
||||
|
||||
let stdin = child
|
||||
@@ -268,11 +268,11 @@ pub fn save_screenshot(jpeg: &[u8], dir: &Path, camera: u32) -> Result<PathBuf>
|
||||
);
|
||||
|
||||
let image = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg)
|
||||
.map_err(|err| anyhow!("这一帧不是能解码的 JPEG:{err}"))?;
|
||||
.map_err(|err| anyhow!("无法解码当前 JPEG 帧:{err}"))?;
|
||||
|
||||
image
|
||||
.save_with_format(&path, image::ImageFormat::Png)
|
||||
.map_err(|err| anyhow!("写不了 {}:{err}", path.display()))?;
|
||||
.map_err(|err| anyhow!("无法写入文件 {}:{err}", path.display()))?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
@@ -290,9 +290,9 @@ fn pump(
|
||||
// 读出来拼进错误里:只说"Broken pipe"对现场毫无帮助。
|
||||
let reason = child_stderr(child);
|
||||
return Err(anyhow!(
|
||||
"writing to ffmpeg failed: {err}{}",
|
||||
"向 ffmpeg 写入画面失败:{err}{}",
|
||||
reason
|
||||
.map(|r| format!(";ffmpeg 说:{r}"))
|
||||
.map(|r| format!(";ffmpeg 输出:{r}"))
|
||||
.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
@@ -776,7 +776,10 @@ mod tests {
|
||||
names.contains(&"b.PNG"),
|
||||
"后缀大小写不该决定它是不是一张截图"
|
||||
);
|
||||
assert_eq!(files[0].kind, "video");
|
||||
let video = files.iter().find(|file| file.name == "a.mp4").unwrap();
|
||||
let image = files.iter().find(|file| file.name == "b.PNG").unwrap();
|
||||
assert_eq!(video.kind, "video");
|
||||
assert_eq!(image.kind, "image");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -375,13 +375,13 @@ impl AppState {
|
||||
match self.unlock(presented) {
|
||||
Ok(true) => return Ok(()),
|
||||
Ok(false) => {
|
||||
return Err("密码不对。改摄像头/装甲板绑定、切换队伍都需要密码。".into())
|
||||
return Err("密码错误。修改图传板、装甲板绑定或队伍号需要密码。".into())
|
||||
}
|
||||
Err(err) => return Err(format!("存下来的密码哈希读不出来:{err}")),
|
||||
Err(err) => return Err(format!("无法读取已保存的密码哈希:{err}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err("这个改动需要密码。先解锁(密码只是防止现场随手改绑,见 README 的说明)。".into())
|
||||
Err("修改设置需要密码,请先解锁。".into())
|
||||
}
|
||||
|
||||
/// 打开/关闭设置面板带来的发现列表。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "RMIC-2026",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"identifier": "com.simbarobotics.rmic2026",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+9
-9
@@ -120,9 +120,9 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const milestones = [
|
||||
{ key: "fonts", label: "载入字体集", code: "HARMONYOS · JETBRAINS", done: fontsReady },
|
||||
{ key: "channel", label: "建立后端通道", code: "LOCAL IPC", done: url !== null || !!fatal },
|
||||
{ key: "snapshot", label: "接收首份状态", code: "SNAPSHOT 200ms", done: snapshot !== null || !!fatal },
|
||||
{ key: "fonts", label: "加载字体", code: "HARMONYOS · JETBRAINS", done: fontsReady },
|
||||
{ key: "channel", label: "连接本机服务", code: "LOCAL IPC", done: url !== null || !!fatal },
|
||||
{ key: "snapshot", label: "读取运行状态", code: "SNAPSHOT 200ms", done: snapshot !== null || !!fatal },
|
||||
];
|
||||
|
||||
const sequenceDone = milestones.every((item) => item.done);
|
||||
@@ -213,7 +213,7 @@ export default function App() {
|
||||
if (!snapshot) return;
|
||||
|
||||
if (snapshot.role !== "referee" && !snapshot.unlocked) {
|
||||
needsUnlock("改摄像头/装甲板绑定、切换队伍号都要密码。", { kind: "settings" });
|
||||
needsUnlock("修改图传板、装甲板绑定或队伍号需要密码。", { kind: "settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -332,9 +332,9 @@ export default function App() {
|
||||
return (
|
||||
<div className="splash">
|
||||
<span className="splash__mark label">RMIC · 2026</span>
|
||||
<h1 className="splash__title">还没有拿到后端状态</h1>
|
||||
<h1 className="splash__title">正在读取运行状态</h1>
|
||||
<p className="splash__text">
|
||||
{connected ? "正在等待后端的第一份快照…" : "正在连接后端…"}
|
||||
{connected ? "已连接,等待状态数据…" : "正在连接本机服务…"}
|
||||
</p>
|
||||
<div className="scan" style={{ width: 220 }} />
|
||||
</div>
|
||||
@@ -430,7 +430,7 @@ export default function App() {
|
||||
<div className="calibrate-bar">
|
||||
<span className="calibrate-bar__mark label">CALIBRATE</span>
|
||||
<span className="calibrate-bar__text">
|
||||
拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
|
||||
拖动画面调整瞄准点,完成后点击“结束校准”。
|
||||
</span>
|
||||
<button type="button" className="btn btn--ghost" onClick={() => setCalibrating(false)}>
|
||||
结束校准
|
||||
@@ -454,7 +454,7 @@ function ChannelFault({ snapshot }: { snapshot: Snapshot }) {
|
||||
|
||||
if (connected) return null;
|
||||
|
||||
let detail = "正在重连后端的视频通道…";
|
||||
let detail = "视频通道已断开,正在重连…";
|
||||
|
||||
try {
|
||||
detail += `(已收到 ${streamClient().counters.frames} 帧,重连 ${streamClient().counters.reconnects} 次)`;
|
||||
@@ -463,7 +463,7 @@ function ChannelFault({ snapshot }: { snapshot: Snapshot }) {
|
||||
}
|
||||
|
||||
if (snapshot.clients === 0 && snapshot.uptimeMs > 5000) {
|
||||
detail += " 后端报告没有任何界面连着,这一条是前端侧的问题。";
|
||||
detail += " 本机服务尚未检测到界面连接。";
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,7 +40,7 @@ function silenceTone(online: boolean, silenceMs: number): "good" | "warn" | "bad
|
||||
/** 四格位图:这一块板子的四条上报各自到没到。 */
|
||||
function Plates({ seen }: { seen: number }) {
|
||||
return (
|
||||
<span className="armor__plates" title={`见过 ${seen.toString(2).padStart(4, "0")} 四条上报`}>
|
||||
<span className="armor__plates" title={`四路上报位图:${seen.toString(2).padStart(4, "0")}`}>
|
||||
{[0, 1, 2, 3].map((bit) => (
|
||||
<span key={bit} className={`armor__plate${(seen >> bit) & 1 ? " is-on" : ""}`} />
|
||||
))}
|
||||
@@ -55,7 +55,7 @@ export function ArmorTable({ boards, slots, names, focusTeam = null }: Props) {
|
||||
return (
|
||||
<div className="armor">
|
||||
<div className="armor__head">
|
||||
<span className="armor__col armor__col--plates">四条</span>
|
||||
<span className="armor__col armor__col--plates">四路</span>
|
||||
<span className="armor__col">装甲板</span>
|
||||
<span className="armor__col">地址</span>
|
||||
<span className="armor__col">自报队伍</span>
|
||||
@@ -69,12 +69,12 @@ export function ArmorTable({ boards, slots, names, focusTeam = null }: Props) {
|
||||
<div className="armor__empty">
|
||||
{boards.length === 0 ? (
|
||||
<>
|
||||
还没有收到任何装甲板上报。主板每 200 ms 广播一次,确认它已上电、和这台机器在
|
||||
同一个网络里。
|
||||
尚未收到装甲板上报。主板每 200 ms 广播一次;请检查供电,并确认主板与本机
|
||||
处于同一网络。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
这一队还没有装甲板上报(另有 {hidden} 块属于另一队)。按 <kbd>0</kbd> 看全部。
|
||||
本队暂无装甲板上报,另一队有 {hidden} 块。按 <kbd>0</kbd> 显示全部。
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -86,14 +86,14 @@ export function ArmorTable({ boards, slots, names, focusTeam = null }: Props) {
|
||||
? `左 · ${teamName(0, names)}`
|
||||
: slots.armorB === board.id
|
||||
? `右 · ${teamName(1, names)}`
|
||||
: "未入槽";
|
||||
: "未分配";
|
||||
const tone = silenceTone(board.online, board.silenceMs);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={board.id}
|
||||
className={`armor__row${board.online ? "" : " is-offline"}${
|
||||
assigned === "未入槽" ? " is-unassigned" : ""
|
||||
assigned === "未分配" ? " is-unassigned" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="armor__col armor__col--plates">
|
||||
@@ -165,7 +165,7 @@ export function StatsLine({
|
||||
return (
|
||||
<div className="statsline statsline--empty">
|
||||
<span className="dot dot--dead" />
|
||||
还没有这一路的数据
|
||||
尚未收到这一路的数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function Boot({ milestones, progress, fatal, leaving = false }: Props) {
|
||||
<span className="boot__fatal-title">启动失败</span>
|
||||
<p className="boot__fatal-text">{fatal}</p>
|
||||
<p className="boot__fatal-hint">
|
||||
端口可能被另一个客户端占着(旧的版本或另一份本程序)。关掉那一份,再重开。
|
||||
端口可能被其他客户端占用。请关闭正在运行的客户端,再重新启动。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -87,7 +87,7 @@ export function Boot({ milestones, progress, fatal, leaving = false }: Props) {
|
||||
</div>
|
||||
<div className="boot__meta">
|
||||
<span className="label">
|
||||
{progress >= 1 ? "序列完成 · 正在进入" : "自检中"}
|
||||
{progress >= 1 ? "加载完成 · 正在进入" : "正在加载"}
|
||||
</span>
|
||||
<span className="num boot__pct">
|
||||
{Math.round(Math.max(0, Math.min(1, progress)) * 100)}
|
||||
|
||||
@@ -34,7 +34,7 @@ const MODULES: {
|
||||
index: "01",
|
||||
cn: "用户端",
|
||||
en: "OPERATOR",
|
||||
summary: "一路画面 + 双方装甲条 + 瞄准器 + 命中反馈。装在这台车上,给操作手看。",
|
||||
summary: "供操作手使用:本队画面、双方装甲条、瞄准器和命中反馈。",
|
||||
needs: ["绑定一块图传板", "绑定一块装甲板", "设置一个现场密码"],
|
||||
},
|
||||
{
|
||||
@@ -42,8 +42,8 @@ const MODULES: {
|
||||
index: "02",
|
||||
cn: "裁判端",
|
||||
en: "REFEREE",
|
||||
summary: "两路画面并排 + 双方血量 + 装甲板发现列表 + 截图与录制。给场边的人看。",
|
||||
needs: ["不需要绑定", "不需要密码", "自动按队伍分左右"],
|
||||
summary: "供场边裁判使用:双路画面、双方血量、装甲板列表、截图和录制。",
|
||||
needs: ["不需要绑定", "不需要密码", "自动分配左右槽位"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -77,9 +77,9 @@ export function RolePicker({ onOpenMaintenance }: { onOpenMaintenance?: () => vo
|
||||
</header>
|
||||
|
||||
<div className="gate__lede">
|
||||
<h1 className="gate__title">这台机器是哪一端?</h1>
|
||||
<h1 className="gate__title">选择运行模式</h1>
|
||||
<p className="gate__hint">
|
||||
选定之后记进配置,下次启动直接进这一端。之后可以在设置里改——离开用户端需要密码。
|
||||
选择会保存到配置,下次启动直接进入。可在设置中切换;离开用户端需要密码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function RolePicker({ onOpenMaintenance }: { onOpenMaintenance?: () => vo
|
||||
</span>
|
||||
|
||||
<span className="module__cta">
|
||||
{busy === module.role ? "正在切换…" : "选用这一端"}
|
||||
{busy === module.role ? "正在切换…" : "使用此模式"}
|
||||
<span className="module__arrow" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
@@ -123,7 +123,7 @@ export function RolePicker({ onOpenMaintenance }: { onOpenMaintenance?: () => vo
|
||||
|
||||
{onOpenMaintenance && (
|
||||
<button type="button" className="gate__maintenance" onClick={onOpenMaintenance} disabled={busy !== null}>
|
||||
固件维护 / 烧录(不进入身份选择)
|
||||
打开固件维护
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
if (busy) return;
|
||||
|
||||
if (first !== again) {
|
||||
setError("两次输入不一致");
|
||||
setError("两次输入的密码不一致");
|
||||
setAgain("");
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
try {
|
||||
await api.setPassword(first);
|
||||
await api.refresh();
|
||||
pushNote("密码已设置。改图传板/装甲板绑定、切换队伍号、退出都需要它。");
|
||||
pushNote("密码已设置。修改图传板或装甲板绑定、切换队伍号、退出程序时需要输入密码。");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -178,10 +178,10 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
</header>
|
||||
|
||||
<div className="gate__lede">
|
||||
<h1 className="gate__title">先配一把钥匙</h1>
|
||||
<h1 className="gate__title">设置现场密码</h1>
|
||||
<p className="gate__hint">
|
||||
用户端改图传板与装甲板绑定、切换队伍号、退出都要用它。至少 4 位;本机存的是
|
||||
Argon2id 哈希,不是明文。
|
||||
修改用户端的图传板或装甲板绑定、切换队伍号、退出程序时需要密码。至少 4 位;
|
||||
本机保存 Argon2id 哈希,不保存明文。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
<input
|
||||
className="input input--lg"
|
||||
type="password"
|
||||
placeholder="再输一次"
|
||||
placeholder="再次输入密码"
|
||||
autoComplete="new-password"
|
||||
value={again}
|
||||
onChange={(event) => setAgain(event.target.value)}
|
||||
@@ -220,10 +220,10 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
))}
|
||||
<span className="keypad__meter-note">
|
||||
{first.length === 0
|
||||
? "还没有输入"
|
||||
? "请输入密码"
|
||||
: first.length < 4
|
||||
? `还需要 ${4 - first.length} 位`
|
||||
: "长度够了"}
|
||||
: "长度符合要求"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -241,13 +241,13 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
</div>
|
||||
|
||||
<p className="gate__note">
|
||||
忘了密码的处理:删掉配置文件里的 password 一行,或者删掉整个配置文件。旧客户端
|
||||
(Slint 版)的配置会在首次启动时自动导入并备份成 .bak。
|
||||
忘记密码时,可删除配置文件中的 password 一行,或删除整个配置文件。旧客户端
|
||||
(Slint 版)的配置会在首次启动时自动导入,并备份为 .bak。
|
||||
</p>
|
||||
|
||||
{onOpenMaintenance && (
|
||||
<button type="button" className="gate__maintenance" onClick={onOpenMaintenance} disabled={busy}>
|
||||
固件维护 / 烧录(不进入身份选择)
|
||||
打开固件维护
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 图传中央的胜负结算:一边归零之后,画面正中给出「胜利 / 击败」。
|
||||
* 图传中央的胜负结算:一边归零之后,画面正中给出「胜利 / 落败」。
|
||||
*
|
||||
* # 为什么这次放在正中
|
||||
*
|
||||
@@ -34,7 +34,7 @@ interface Props {
|
||||
|
||||
const WORD: Record<MatchOutcome, string> = {
|
||||
victory: "胜利",
|
||||
defeat: "击败",
|
||||
defeat: "落败",
|
||||
draw: "平局",
|
||||
};
|
||||
|
||||
|
||||
@@ -47,10 +47,14 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
const active = snapshot.recordings.filter((recording) => recording.active);
|
||||
const cameras = snapshot.cameras.filter((camera) => camera.online).map((camera) => camera.id);
|
||||
|
||||
const guard = (action: Promise<unknown>) => {
|
||||
const guard = (action: Promise<unknown>, onSuccess?: () => void) => {
|
||||
setBusy(true);
|
||||
void action
|
||||
.then(() => api.refresh())
|
||||
.then(() => {
|
||||
onSuccess?.();
|
||||
refresh();
|
||||
return api.refresh();
|
||||
})
|
||||
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
@@ -66,10 +70,11 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={busy || cameras.length === 0}
|
||||
title={cameras.length === 0 ? "还没有在线的摄像头" : undefined}
|
||||
title={cameras.length === 0 ? "暂无在线图传板" : undefined}
|
||||
onClick={() => {
|
||||
guard(api.startRecording(cameras));
|
||||
onInfo("开始录制。停止之前请留在这台机器上——退出会先收尾再走。");
|
||||
guard(api.startRecording(cameras), () =>
|
||||
onInfo("已开始录制。退出程序时会先结束录制并保存文件。"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="media__rec-glyph" aria-hidden="true" />
|
||||
@@ -82,7 +87,6 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
disabled={busy || active.length === 0}
|
||||
onClick={() => {
|
||||
guard(api.stopRecording([]));
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
停止全部录制
|
||||
@@ -97,11 +101,11 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
className="btn btn--ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
guard(api.screenshot(camera));
|
||||
guard(api.screenshot(camera), () =>
|
||||
onInfo(
|
||||
`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(存在录制目录的 shots/ 下)。`,
|
||||
`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(已保存到录制目录的 shots/)。`,
|
||||
),
|
||||
);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
截图
|
||||
@@ -141,7 +145,7 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
{recording.framesDropped > 0 && (
|
||||
<span
|
||||
className="strip__warn"
|
||||
title="写盘跟不上时丢的帧。丢帧不报出来,回放里少半秒会被当成相机的问题。"
|
||||
title="写入速度不足时丢弃的帧数,可能影响回放速度。"
|
||||
>
|
||||
丢 {recording.framesDropped} 帧
|
||||
</span>
|
||||
@@ -152,7 +156,6 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
className="btn btn--ghost"
|
||||
onClick={() => {
|
||||
guard(api.stopRecording([recording.camera]));
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
停止
|
||||
@@ -232,8 +235,8 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
{playbackFailed ? (
|
||||
<div className="playback__fallback">
|
||||
<p className="playback__warn">
|
||||
界面内回放失败——多半是这个目录不在资源协议的白名单里(改成默认的录制目录,
|
||||
或者直接看下面这一条)。
|
||||
无法在界面内回放,可能是录制目录不在允许访问的范围内。可改用默认录制目录,
|
||||
或用系统播放器打开。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -245,7 +245,7 @@ export function SettingsPanel({
|
||||
onClose();
|
||||
});
|
||||
}}
|
||||
title="立刻收回改动权限"
|
||||
title="锁定设置,修改时需重新输入密码"
|
||||
>
|
||||
上锁
|
||||
</Button>
|
||||
@@ -259,13 +259,13 @@ export function SettingsPanel({
|
||||
{locked && (
|
||||
<div className="drawer__locked">
|
||||
<span className="dot dot--warn" />
|
||||
面板的改动需要密码。
|
||||
修改设置前请先输入密码解锁。
|
||||
<button
|
||||
type="button"
|
||||
className="drawer__unlock"
|
||||
onClick={() => onNeedsUnlock("改动设置需要密码")}
|
||||
onClick={() => onNeedsUnlock("修改设置需要密码")}
|
||||
>
|
||||
立即解锁
|
||||
解锁设置
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -291,8 +291,8 @@ export function SettingsPanel({
|
||||
<div className="drawer__scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
{isTeam && <Preview hud={hud} team={snapshot.team} />}
|
||||
|
||||
<Section id="sect-role" index={1} title="身份" hint="身份决定这一端能做什么。离开用户端要密码:否则切到裁判端就能看两路画面。">
|
||||
<Field label="这台机器是哪一端">
|
||||
<Section id="sect-role" index={1} title="身份" hint="用户端显示绑定的图传,裁判端显示双路画面。离开用户端需要密码。">
|
||||
<Field label="运行模式">
|
||||
<Select
|
||||
value={config.role}
|
||||
options={[
|
||||
@@ -310,7 +310,7 @@ export function SettingsPanel({
|
||||
id="sect-bind"
|
||||
index={2}
|
||||
title="绑定"
|
||||
hint="图传板与装甲板都用 MAC 派生的 id 标识,重启不变,所以绑定是稳的。改动需要密码。"
|
||||
hint="图传板和装甲板使用由 MAC 派生的 ID,重启后绑定仍有效。修改绑定需要密码。"
|
||||
>
|
||||
<Field label="图传板">
|
||||
<Select
|
||||
@@ -320,7 +320,7 @@ export function SettingsPanel({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="装甲板主板" hint="决定装甲条读谁的血量。下拉里是场上正在广播的板子。">
|
||||
<Field label="装甲板主板" hint="选择装甲条使用的血量来源。列表显示已发现的装甲板。">
|
||||
<Select
|
||||
value={config.armorId}
|
||||
options={armorOptions}
|
||||
@@ -329,8 +329,8 @@ export function SettingsPanel({
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="自己属于哪一边"
|
||||
hint="改错队号会把对手的血条当成自己的:画面左上角那一条就会是对手的血。"
|
||||
label="所属队伍"
|
||||
hint="左上角显示本队血量,右上角显示对手血量。请核对队伍号。"
|
||||
>
|
||||
<Select
|
||||
value={config.team}
|
||||
@@ -344,14 +344,14 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={config.mirror}
|
||||
label="左右镜像画面(摄像头装反了)"
|
||||
label="左右镜像画面"
|
||||
onChange={(mirror) => void run(() => api.setMirror(mirror))}
|
||||
/>
|
||||
|
||||
{cameraOptions.length === 0 && (
|
||||
<p className="sect__warn">
|
||||
现在没有发现任何图传板。板子每 2 秒广播一次,确认它已上电、和这台机器在
|
||||
同一个网络里,然后稍等。
|
||||
尚未发现图传板。板卡每 2 秒广播一次;请检查供电,确认与本机处于
|
||||
同一网络后稍候。
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
@@ -360,7 +360,7 @@ export function SettingsPanel({
|
||||
id="sect-reticle"
|
||||
index={3}
|
||||
title="瞄准器"
|
||||
hint="位置跟着画面走,大小不跟着缩放走——放大后准星更精确。"
|
||||
hint="瞄准点随画面移动,准星大小保持不变;放大后覆盖的画面范围更小。"
|
||||
>
|
||||
<Switch
|
||||
checked={hud.crosshair.visible}
|
||||
@@ -379,7 +379,7 @@ export function SettingsPanel({
|
||||
{ value: "cross", label: "四臂十字" },
|
||||
{ value: "cross-dot", label: "十字 + 中心点" },
|
||||
{ value: "circle-dot", label: "圆环 + 中心点" },
|
||||
{ value: "dot", label: "只有点" },
|
||||
{ value: "dot", label: "中心点" },
|
||||
]}
|
||||
onChange={(style) =>
|
||||
update((draft) => {
|
||||
@@ -506,7 +506,7 @@ export function SettingsPanel({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="网格明显程度">
|
||||
<Field label="网格不透明度">
|
||||
<Slider
|
||||
value={hud.grid.opacity}
|
||||
min={0.05}
|
||||
@@ -535,7 +535,7 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={hud.showStats}
|
||||
label="状态行显示详细读数(分辨率、丢包、帧间隔)"
|
||||
label="状态栏显示详细读数(分辨率、丢包、帧间隔)"
|
||||
onChange={(showStats) =>
|
||||
update((draft) => {
|
||||
draft.showStats = showStats;
|
||||
@@ -545,7 +545,7 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={hud.hitFeedback.flash}
|
||||
label="被打中时四周泛红"
|
||||
label="受击时画面四周泛红"
|
||||
onChange={(flash) =>
|
||||
update((draft) => {
|
||||
draft.hitFeedback.flash = flash;
|
||||
@@ -555,7 +555,7 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={hud.hitFeedback.marker}
|
||||
label="在瞄点旁显示伤害数字"
|
||||
label="在瞄准点旁显示伤害数字"
|
||||
onChange={(marker) =>
|
||||
update((draft) => {
|
||||
draft.hitFeedback.marker = marker;
|
||||
@@ -583,7 +583,7 @@ export function SettingsPanel({
|
||||
id="sect-armor"
|
||||
index={5}
|
||||
title="装甲条"
|
||||
hint="条挂在画面两个上角:左上角是自己、右上角是对手。一条分 5 格,一格 20 点血。"
|
||||
hint="左上角显示本队血量,右上角显示对手血量。每条分 5 格,每格 20 点血。"
|
||||
>
|
||||
<Switch
|
||||
checked={hud.health.visible}
|
||||
@@ -621,7 +621,7 @@ export function SettingsPanel({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Field label="低血阈值" hint="低于这个值时条会转红并脉动">
|
||||
<Field label="低血阈值" hint="达到此值或更低时显示低血状态;启用脉动后会同时闪动">
|
||||
<Slider
|
||||
value={hud.health.lowThreshold}
|
||||
min={5}
|
||||
@@ -658,7 +658,7 @@ export function SettingsPanel({
|
||||
id="sect-slots"
|
||||
index={2}
|
||||
title="裁判端槽位"
|
||||
hint="默认按装甲板自报的队伍自动分左右。有烧错固件的板子时打开手动指定。"
|
||||
hint="图传板自动分配左右画面,装甲板按上报队伍分配。对应关系不符时,可手动指定。"
|
||||
>
|
||||
<Switch
|
||||
checked={snapshot.slots.manual}
|
||||
@@ -731,7 +731,7 @@ export function SettingsPanel({
|
||||
id="sect-media"
|
||||
index={isTeam ? 6 : 3}
|
||||
title="录制与截图"
|
||||
hint="截图存 PNG,录制存 mp4(需要 ffmpeg)。"
|
||||
hint="截图保存为 PNG;录制保存为 MP4,需要 ffmpeg。"
|
||||
>
|
||||
<Field label="输出目录">
|
||||
<input
|
||||
@@ -748,7 +748,7 @@ export function SettingsPanel({
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="ffmpeg 路径" hint="默认从 PATH 找。找不到就录不了,但界面照常用。">
|
||||
<Field label="ffmpeg 路径" hint="默认从 PATH 查找。未找到时无法录制,其他功能仍可使用。">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
@@ -764,8 +764,8 @@ export function SettingsPanel({
|
||||
/>
|
||||
</Field>
|
||||
<p className="sect__note">
|
||||
界面内回放要目录在资源协议的白名单里(默认的 ~/Movies/cam-desktop 就在)。
|
||||
改到别处时回放会失败,界面上会给出"用系统播放器打开"的退路。
|
||||
界面内回放仅支持允许访问的目录,默认的 ~/Movies/cam-desktop 已在其中。
|
||||
其他目录中的视频可用系统播放器打开。
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
@@ -773,14 +773,14 @@ export function SettingsPanel({
|
||||
id="sect-safety"
|
||||
index={isTeam ? 7 : 4}
|
||||
title="安全"
|
||||
hint="密码只挡现场随手改绑;配置文件与程序都在本机,愿意改的人绕得过去。"
|
||||
hint="密码用于防止现场误改设置;能修改本机配置或程序的人仍可绕过。"
|
||||
>
|
||||
<ChangePassword onDone={onInfo} onError={onError} />
|
||||
<div className="sect__row">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (!window.confirm("清空配置?旧文件会被改名成 .bak,然后需要重启程序。")) return;
|
||||
if (!window.confirm("清空配置并重启设置流程?旧配置将备份为 .bak,操作后需重启程序。")) return;
|
||||
void api.clearConfig().then(
|
||||
(message) => onInfo(message),
|
||||
(err) => {
|
||||
@@ -797,7 +797,7 @@ export function SettingsPanel({
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (!window.confirm("退出程序?正在进行的录制会先收尾。")) return;
|
||||
if (!window.confirm("退出程序?正在进行的录制将先结束并保存。")) return;
|
||||
void api.quit().catch((err) => {
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
if (text.includes("密码")) onNeedsUnlock(text);
|
||||
@@ -813,7 +813,7 @@ export function SettingsPanel({
|
||||
<div className="drawer__tail">
|
||||
<span className="label">RMIC · 2026</span>
|
||||
<span className="drawer__tail-note">
|
||||
改完即生效并写盘;滑杆停手 350 ms 后落盘。
|
||||
修改立即生效并保存;滑杆停止调整 350 ms 后保存。
|
||||
</span>
|
||||
<span className="drawer__tail-version num">
|
||||
{VERSION_LABEL} · 线路协议 {BUILD.protocol}
|
||||
@@ -922,7 +922,7 @@ function Preview({ hud, team }: { hud: HudView; team: number }) {
|
||||
<div className="preview">
|
||||
<div className="preview__head">
|
||||
<span className="label">LIVE PREVIEW</span>
|
||||
<span className="preview__note">以下几项改完立刻在这里生效</span>
|
||||
<span className="preview__note">实时预览准星、网格与装甲条</span>
|
||||
</div>
|
||||
|
||||
<div className="preview__frame corners">
|
||||
@@ -975,8 +975,8 @@ function Calibration({
|
||||
<Switch checked={calibrating} label="拖动画面校准瞄准点" onChange={onCalibrating} />
|
||||
|
||||
<p className="sect__note">
|
||||
摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是
|
||||
<span className="em">画面比例</span>,所以换分辨率、换窗口大小都不影响它。
|
||||
摄像头与枪管不同轴时,可将瞄准点调整到弹着点。偏移按
|
||||
<span className="em">画面比例</span>保存,更换分辨率或调整窗口大小后仍有效。
|
||||
</p>
|
||||
|
||||
<Field label="水平偏移">
|
||||
|
||||
@@ -83,7 +83,7 @@ export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
|
||||
maxLength={MAX_CHARS}
|
||||
disabled={disabled}
|
||||
placeholder={DEFAULT_TEAM_NAMES[team] ?? "队伍"}
|
||||
title="改完按回车或点到别处生效;Esc 撤销;留空回落到默认名"
|
||||
title="回车或移开焦点保存;Esc 取消;留空使用默认队名"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onFocus={() => {
|
||||
focused.current = true;
|
||||
@@ -106,7 +106,7 @@ export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
|
||||
|
||||
<span className="tname__hint" aria-hidden="true">
|
||||
<span className="tname__keys">
|
||||
<kbd>⏎</kbd>存 <kbd>⎋</kbd>撤
|
||||
<kbd>⏎</kbd>保存 <kbd>⎋</kbd>取消
|
||||
</span>
|
||||
<span className="tname__count num">
|
||||
{draft.length}/{MAX_CHARS}
|
||||
|
||||
@@ -63,14 +63,14 @@ export function TopBar({
|
||||
<button
|
||||
type="button"
|
||||
className="tag tag--warn tag--button"
|
||||
title="所有画面与血量都是本机合成的假数据,不代表场上的任何一块板子。"
|
||||
title="画面与血量由本机合成,不代表现场板卡状态。"
|
||||
onClick={() =>
|
||||
pushNote(
|
||||
"演示模式:画面与血量由本机合成,不是真实链路。截图或验收时别把它当成现场数据。",
|
||||
"演示模式:画面与血量由本机合成。截图和验证时请注明为演示数据。",
|
||||
)
|
||||
}
|
||||
>
|
||||
演练 · 合成数据
|
||||
演示 · 合成数据
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function TopBar({
|
||||
<div className="topbar__cluster topbar__cluster--center">{children}</div>
|
||||
|
||||
<div className="topbar__cluster topbar__cluster--end">
|
||||
<span className="topbar__clients num" title="连着这个后端的界面数">
|
||||
<span className="topbar__clients num" title="连接本机服务的界面数">
|
||||
{snapshot.clients}
|
||||
<span className="topbar__clients-unit">UI</span>
|
||||
</span>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
if (ok) {
|
||||
onUnlocked();
|
||||
} else {
|
||||
setError("密码不对");
|
||||
setError("密码错误");
|
||||
setPassword("");
|
||||
input.current?.focus();
|
||||
}
|
||||
@@ -85,8 +85,8 @@ export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
{error && <p className="lock__error">{error}</p>}
|
||||
|
||||
<p className="lock__note">
|
||||
密码只挡现场随手改绑,不是安全边界:配置文件与程序都在本机,愿意改的人绕得过去。
|
||||
忘了密码就删掉配置里的 password 一行。
|
||||
密码用于防止现场误改设置;能修改本机配置或程序的人仍可绕过。
|
||||
忘记密码时,可删除配置文件中的 password 一行。
|
||||
</p>
|
||||
|
||||
<div className="lock__actions">
|
||||
@@ -99,7 +99,7 @@ export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || password.length === 0}
|
||||
>
|
||||
{busy ? "校验中…" : "校验并解锁"}
|
||||
{busy ? "校验中…" : "解锁"}
|
||||
<span className="btn__key">⏎</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -326,7 +326,7 @@ export function VideoSurface({
|
||||
className="vsurface__reset"
|
||||
onClick={() => onView(IDENTITY_VIEW)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
title="复位缩放与平移(也可以双击画面)"
|
||||
title="重置缩放与平移(也可双击画面)"
|
||||
>
|
||||
<span className="vsurface__reset-key">⤢</span>
|
||||
复位
|
||||
@@ -336,7 +336,7 @@ export function VideoSurface({
|
||||
{calibrating && (
|
||||
<div className="vsurface__calibrating">
|
||||
<span className="dot dot--live" />
|
||||
校准中 · 拖动画面挪动瞄准点
|
||||
校准中 · 拖动画面调整瞄准点
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ function handle(command: string, args: Record<string, unknown>): unknown {
|
||||
/** 预览里模拟后端的密码闸门,好让解锁流程也能被看到。 */
|
||||
function guard() {
|
||||
if (state.role === "team" && state.password !== null && !state.unlocked) {
|
||||
throw new Error("这个改动需要密码。先解锁(预览模式里密码是 1234)。");
|
||||
throw new Error("修改设置需要密码。请先解锁,预览模式密码为 1234。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ function snapshot(): Snapshot {
|
||||
// 两边都给:用户端也要显示对手的血量(画面右上角那一条)。
|
||||
health: [healthFor(0), healthFor(1)],
|
||||
recordings: recordings(),
|
||||
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
|
||||
notes: ["浏览器预览:状态与画面均为合成数据,不代表真实链路。"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
*/
|
||||
|
||||
export const BUILD = {
|
||||
version: "0.2.1",
|
||||
version: "0.2.2",
|
||||
major: 0,
|
||||
minor: 2,
|
||||
patch: 1,
|
||||
patch: 2,
|
||||
/** git 提交数,单调递增。 */
|
||||
build: 37,
|
||||
build: 38,
|
||||
/** 线路协议版本。与固件自报的那个数直接比对。 */
|
||||
protocol: 1,
|
||||
/** 生成时的提交日期(`YYYY-MM-DD`)。 */
|
||||
|
||||
@@ -299,7 +299,7 @@ function FlashGauge({
|
||||
<span className={`mnt-gauge__hint ${plan.canFlash ? "dim" : "danger"}`}>
|
||||
{plan.canFlash
|
||||
? `计划就绪 · ${plan.candidates.length} 个候选槽位,按顺序尝试`
|
||||
: "计划不可烧录,请先处理左侧警告。"}
|
||||
: "当前计划无法烧录,请先处理计划中的警告。"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mnt-gauge__hint dim">生成计划后,这里显示目标槽位与实时进度。</span>
|
||||
@@ -791,7 +791,7 @@ export function MaintenancePage({
|
||||
</div>
|
||||
{device && (
|
||||
<Tag tone={securityEnabled ? "danger" : "ok"}>
|
||||
{securityEnabled ? "安全启动/加密已启用" : "可安全烧录"}
|
||||
{securityEnabled ? "安全启动/加密已启用" : "安全启动/加密未启用"}
|
||||
</Tag>
|
||||
)}
|
||||
</header>
|
||||
@@ -894,7 +894,7 @@ export function MaintenancePage({
|
||||
<h2 className="mnt-sec__title">官方固件包</h2>
|
||||
<span className="mnt-sec__caption label">BUNDLE / PACKAGE</span>
|
||||
</div>
|
||||
{bundle && <Tag tone="ok">SHA 已校验</Tag>}
|
||||
{bundle && <Tag tone="ok">完整性已校验</Tag>}
|
||||
</header>
|
||||
|
||||
<div className="mnt-sec__body">
|
||||
@@ -909,7 +909,7 @@ export function MaintenancePage({
|
||||
|
||||
{!bundle && (
|
||||
<p className="mnt-hint dim">
|
||||
选择编译后打包的 <code>.zip</code> 固件包。客户端只接受带 SHA256 清单的官方包;旧目录包仍可读取。
|
||||
选择编译后生成的 <code>.zip</code> 官方固件包,包内需包含清单和 SHA256 校验值。也可读取旧目录包。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1053,7 +1053,7 @@ export function MaintenancePage({
|
||||
/>
|
||||
<span className="mnt-check__box" aria-hidden="true" />
|
||||
<span className="mnt-check__text">
|
||||
备份旧 App(多读约{" "}
|
||||
备份旧 App(额外读取约{" "}
|
||||
{device
|
||||
? formatBytes(
|
||||
device.partitionTable.find((entry) => entry.name === "factory")?.size ??
|
||||
@@ -1145,7 +1145,7 @@ export function MaintenancePage({
|
||||
{logs.length === 0 ? (
|
||||
<div className="mnt-terminal__line">
|
||||
<span className="mnt-terminal__no num">00</span>
|
||||
<span className="mnt-terminal__text mnt-terminal__empty dim">(还没有操作)</span>
|
||||
<span className="mnt-terminal__text mnt-terminal__empty dim">尚未执行操作</span>
|
||||
</div>
|
||||
) : (
|
||||
logs.map((line, index) => {
|
||||
|
||||
@@ -220,7 +220,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
||||
className="ref__splitter"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="拖动调整两格的宽度,双击回到五五开"
|
||||
title="拖动调整两路画面宽度,双击恢复等宽"
|
||||
onPointerDown={beginDrag("split")}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
@@ -439,7 +439,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
||||
left={
|
||||
<span className="statusbar__state">
|
||||
<span className={`dot ${snapshot.clients > 0 ? "dot--live" : "dot--warn"}`} />
|
||||
两路并看 · 鼠标停在哪一路,滚轮就缩哪一路
|
||||
同时查看两路画面 · 滚轮缩放鼠标所在的画面
|
||||
</span>
|
||||
}
|
||||
center={
|
||||
@@ -472,7 +472,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
||||
}
|
||||
right={
|
||||
<span className="statusbar__hint">
|
||||
{scope === "all" ? "按 1 / 2 盯住某一路" : `正在看 ${scope === "a" ? "左路" : "右路"} · 按 0 收回全部`}
|
||||
{scope === "all" ? "按 1 / 2 查看单路" : `正在看 ${scope === "a" ? "左路" : "右路"} · 按 0 显示全部`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 看到"的两个数,放在画面之外意味着视线要离开取景;
|
||||
* - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走;
|
||||
* - 被打中时四周泛红、瞄点旁跳一个伤害数字——**画面不抖**(见 `HitFeedbackConfig`);
|
||||
* - 一方归零之后,画面正中给出「胜利 / 击败」。
|
||||
* - 一方归零之后,画面正中给出「胜利 / 落败」。
|
||||
*
|
||||
* # 这一页最顺手的操作是什么
|
||||
*
|
||||
@@ -94,8 +94,8 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
<span className="standby__code label">NO CAMERA BOUND</span>
|
||||
<h2 className="standby__title">还没有绑定图传板</h2>
|
||||
<p className="standby__text">
|
||||
用户端要先选一块图传板(就是装在这台车上的那一块)。绑定会写进配置文件,
|
||||
重启后仍然生效;以后要改需要密码。
|
||||
请选择安装在本车上的图传板。绑定会保存到配置文件,
|
||||
重启后仍有效;修改绑定需要密码。
|
||||
</p>
|
||||
<button type="button" className="btn btn--primary btn--lg" onClick={onOpenSettings}>
|
||||
选择图传板
|
||||
@@ -119,7 +119,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
camera={camera}
|
||||
label={`CAM · ${cameraText}`}
|
||||
emptyText={
|
||||
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:这块板子还没广播"
|
||||
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:尚未收到板卡广播"
|
||||
}
|
||||
mirrored={snapshot.mirrored}
|
||||
crosshair={hud.crosshair}
|
||||
@@ -179,9 +179,9 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
<div className="fwfault" role="alert">
|
||||
<span className="fwfault__tag label">固件不匹配</span>
|
||||
<span className="fwfault__text">
|
||||
这块板子自报固件 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
|
||||
<b>{boundCamera.protocol}</b>;本客户端要协议 <b>{BUILD.protocol}</b>。
|
||||
它的画面会被丢弃——烧录一版与之匹配的固件,或者换一份对应版本的客户端。
|
||||
板卡固件版本为 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
|
||||
<b>{boundCamera.protocol}</b>;本客户端使用协议 <b>{BUILD.protocol}</b>。
|
||||
画面无法接收。请烧录协议匹配的固件,或使用对应版本的客户端。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -192,7 +192,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
left={
|
||||
<span className="statusbar__state">
|
||||
<span className={`dot ${stats?.online ? "dot--live" : "dot--dead"}`} />
|
||||
{stats?.status ?? "还没有这一路的数据"}
|
||||
{stats?.status ?? "尚未收到这一路的数据"}
|
||||
</span>
|
||||
}
|
||||
center={
|
||||
@@ -223,7 +223,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
right={
|
||||
<span className="statusbar__hint">
|
||||
{calibrating
|
||||
? "校准中 · 拖动画面挪动瞄准点"
|
||||
? "校准中 · 拖动画面调整瞄准点"
|
||||
: "滚轮 缩放 · 拖动 平移 · 双击 复位"}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# 比赛控制与直播提案
|
||||
|
||||
> 状态:待确认,尚未实现。保留自 2026-10-19 的界面重构草案;以下布局、状态、命令、快捷键与验收数字均为提议,不是当前能力或测试结果。
|
||||
> 比赛判负、技术暂停、计时比分、直播页和双路同步回放仍需确认。当前客户端说明见[桌面端](../desktop/client.md),已实现的 USB 维护与固件包格式见[固件维护](../maintenance/firmware.md)。
|
||||
> 原草案中的旧色板、字体方案、单文件 CSS 基线、烧录实现计划和排期已被当前实现取代,不再作为开发依据。
|
||||
> 规则依据:[2026 校内赛参赛手册](https://docs.simba-robotics.com/zh-cn/competition/)、[对抗赛与奖项](https://docs.simba-robotics.com/zh-cn/competition/matches/)、[裁判系统](https://docs.simba-robotics.com/zh-cn/competition/referee-system/)。规则摘录沿用原稿,实施前需核对手册。
|
||||
|
||||
## 页面方案
|
||||
|
||||
### 应用外壳(新增 `AppShell`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ TopBar 44px cam-desktop · 裁判端 · 链路● │ 比赛条(裁判/直播) │ 工具 │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Stage:视频 + 画面内 HUD + 覆盖层(暂停/判定/校准) │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ StatusBar 28px 链路 / 帧率 / 丢帧 / 提示 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
三种外壳状态(`data-shell`):
|
||||
|
||||
| 状态 | 触发 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `normal` | 默认 | 全部 chrome 可见 |
|
||||
| `immersive` | F11 | chrome 隐藏;指针移到上沿 8px 时 TopBar 150ms 淡入,3 s 后淡出;指针静止 2 s 隐藏光标 |
|
||||
| `broadcast` | 视图切换 `Ctrl+Shift+B` | 只渲染 `BroadcastStage`;禁用设置/维护/发现列表;不渲染任何 IP/token |
|
||||
|
||||
结构落点:`App.tsx` 里现有的门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码)不动,只在最后一段把 `TeamPage | RefereePage` 包进 `AppShell`,并新增 `BroadcastPage` 分支。
|
||||
|
||||
### 用户端 `TeamPage`(操作手视角)
|
||||
|
||||
使用场景:操作手位于布帘后,单手操作、不看键盘,单局 3 分钟。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 02:41 (计时) │ ← 仅比赛进行中出现
|
||||
│ ┌────────────┐ ┌────────────┐│
|
||||
│ │我方 ████ 76│ │100 ████ 对手││ ← 血条贴角,宽 36%
|
||||
│ └────────────┘ └────────────┘│
|
||||
│ │
|
||||
│ + (准星)│
|
||||
│ │
|
||||
│ (命中时:上中"命中 -3" + 全屏淡红 620ms) │
|
||||
└──────────────────────────────────────────────┘
|
||||
│ 链路 · 28.6 fps · 丢 0.4% │ ← release 下只留"链路"
|
||||
```
|
||||
|
||||
改动点:
|
||||
|
||||
1. 顶部加计时条(仅 `match.phase ∈ {running, paused}` 且本队已绑定);不显示比分细节,局间才显示"第 2 局 · 1:1"。
|
||||
2. 底部状态行从"滚轮缩放 · 拖动平移…"改成首次提示:进入页面 5 s 后自动淡出,鼠标移动再出现(作为新交互提议,时长仍需确认)。
|
||||
3. 新增 `PausedOverlay`:技术暂停时整屏 86% 遮罩 + "技术暂停 03:12 · 由裁判发起",血条/计时保留可见。
|
||||
4. 新增 `JudgedOverlay`:判负/结束时显示"本局判负(设备故障)"或"胜利",与 `MatchBanner` 合并成一个组件(避免两套结论 UI 同时出现)。
|
||||
5. 设置入口、录制入口不进主界面(现在 TopBar 里有"设置/固件维护",用户端误触风险高):改为 `Ctrl+K` 命令面板或长按品牌名 2 s。
|
||||
6. 校准模式(现有 `calibrating`)保留,但提示从底部横幅改到画面内角标,避免遮挡。
|
||||
|
||||
### 裁判端 `RefereePage`
|
||||
|
||||
三区 + 一条比赛条:
|
||||
|
||||
```
|
||||
┌ TopBar ────────────────────────────────────────────────────────────────┐
|
||||
│ cam-desktop · 裁判端 · 链路● · 录制×2 [直播] [设置] │
|
||||
├ MatchBar 52px ─────────────────────────────────────────────────────────┤
|
||||
│ BO3 第2局 │ 02:41 │ 红 76 ╍╍╍╍ 100 蓝 │ ⏸ 技术暂停 │ ⚑ 判负 │ ⋯ 更多 │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ 红方(左)──────────────┐ ┌─ 蓝方(右)──────────────┐ │
|
||||
│ │ video + HUD │ │ video + HUD │ │
|
||||
│ │ [队名 可编辑] [fps] │ │ [队名 可编辑] [fps] │ │
|
||||
│ └─────────────────────────┘ └──────────────────────────┘ │
|
||||
├ Dock ─────────────────────────────────────────────────────────────────┤
|
||||
│ [装甲板 8] [录制与回放 3] [事件 27] [连接诊断] [刷新] [⟳] │
|
||||
│ …内容(表格/列表/日志)… │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
改动点:
|
||||
|
||||
1. `MatchBar`(新,常驻,高 52px):局次、计时、比分、暂停、判负、更多菜单。这是裁判端最重要的新增组件;判定后整条切成"结果态"(红/蓝底 + 理由 + 撤销入口)。
|
||||
2. `referee-stage` 保持 `flex: 1`,新增的 `MatchBar` 占固定高度;下方控制台采用可拖拽高度(提议默认 240px,最小 120,最大 45vh);加一行比赛条后,900px 高的窗口约剩 560px 用于画面。
|
||||
3. `ArmorTable` 改为两队并列(左红右蓝),每行左侧加 5 格迷你血条(与 HUD 同源,便于核对);主装甲板加 `主` 徽标;离线行 `opacity .55`(保留);`reportsAllPlates=false` 时显示"仅报 2 块"——规则允许 2 或 4 块装甲板。
|
||||
4. 新增 `EventPanel`(Dock 第 3 个页签):命中/暂停/判负/录制开始/结束的时间轴列表,可导出。
|
||||
5. `MediaPanel` 保留全部功能,新增两个东西:按局分组(`matchId` 目录)与 "回放本局"(双路同步 + 事件定位)。
|
||||
6. 队名编辑保留(现在是内联输入),但改成"点击铅笔 → 行内编辑 → Enter 保存 / Esc 取消",避免一直占一行高。
|
||||
|
||||
### 直播 `BroadcastPage`(新)
|
||||
|
||||
见下文直播方案。要点:一个 1920×1080 虚拟画布 + 4 个模板 + 安全区参考线 + 只读数据。
|
||||
|
||||
## 状态与交互
|
||||
|
||||
### 状态模型(前端)
|
||||
|
||||
```ts
|
||||
interface AppStore {
|
||||
snapshot: Snapshot | null;
|
||||
connected: boolean;
|
||||
lastHit: HitPulseRecord | null;
|
||||
confirmQuit: number;
|
||||
notes: string[];
|
||||
channelError: string | null;
|
||||
// 新增
|
||||
ui: { shell: "normal" | "immersive" | "broadcast"; guides: boolean; idle: boolean };
|
||||
events: MatchEventView[]; // 由 WireMsg::MatchEvent 累积(本地最多 500 条)
|
||||
clockSkewMs: number; // serverNow - localNow,用于计时插值
|
||||
}
|
||||
```
|
||||
|
||||
事件传输:比赛事件走独立推送,与现有的 `hit` 同一条路(`server::WireMsg` 新增 `MatchEvent`),前端累积;`Snapshot.match` 只带最近 12 条用于重连后补齐。理由:快照 5 Hz × 200 条事件会让每份快照增加到几十 KB,事件单独推送可避免重复传输。
|
||||
|
||||
计时以后端为准:`Snapshot` 增加 `nowMs: u64`(后端 Unix 毫秒),前端用 `useServerClock` 每帧算 `serverNow = localNow + clockSkewMs`,再用 `match.remainingMs - (serverNow - snapshotAt)` 插值。`setInterval` 在窗口失焦时会被节流,不能作为比赛计时依据。
|
||||
|
||||
### 一键判负
|
||||
|
||||
入口:`MatchBar` 右侧 `⚑ 判负`,或 `Ctrl/Cmd+Shift+F`。只在裁判端、`phase ∈ {running, paused}` 时可用。
|
||||
|
||||
三步,不可合并
|
||||
|
||||
```
|
||||
① 点按 → ForfeitDialog
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 判定本局结果 │
|
||||
│ 当前:红 76 · 蓝 100 | 剩余 02:41 | 第2局│
|
||||
│ 败方: (●) 红方 ( ) 蓝方 │
|
||||
│ 理由: [设备故障 ▾] │
|
||||
│ 设备故障 / 违规 / 弃权 / 未到场 / 其他 │
|
||||
│ 备注: [________________________] │
|
||||
│ ⚠ 本操作只改变比赛判定与记录,不改变硬件血量 │
|
||||
│ [取消] [长按 1 秒确认判负] │
|
||||
└─────────────────────────────────────────┘
|
||||
② 长按 1000ms(HoldButton,松手即取消,Esc 取消)
|
||||
③ 5s 内 toast 提供「撤销」
|
||||
```
|
||||
|
||||
执行语义
|
||||
|
||||
```rust
|
||||
match_end { reason: Forfeit { loser: 0|1, kind: ForfeitKind, note: Option<String> } }
|
||||
```
|
||||
|
||||
- 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。
|
||||
- 用户端若已同步(见 R2):显示 `JudgedOverlay`「本局判负」;未同步则只影响裁判端与直播。
|
||||
- 不改硬件血量:`ArmorHealthStore` 只接收上报,没有任何写入口(见 R1)。界面必须显式写明这一点,否则现场会以为"判负之后对方血量归零了"。
|
||||
- 审计:事件 `{ at, actor, action:"forfeit", loser, reason, note, hp:[76,100], remainingMs:161000 }`,追加到 `matches/<yyyy-mm-dd>/<matchId>/events.ndjson`,同时进 `snapshot.match.events`。
|
||||
- 撤销:`match_undo { eventId }`,5 s 内可直接撤销;之后只能 `match_reopen_round { reason }`,在历史里留下"更正"两条记录(不删除原记录)。
|
||||
|
||||
与自动判定共存:`resolve_outcomes` 已经能在血量归零时给出 `Victory/Defeat/Draw`。两者必须用同一字段建模:
|
||||
|
||||
```ts
|
||||
end: { by: "auto" | "referee"; reason: "knockout" | "timeout" | "forfeit" | "equipment" | "tiebreak"; loser?: 0|1 }
|
||||
```
|
||||
|
||||
回放和统计据此区分结束原因。
|
||||
|
||||
### 技术暂停
|
||||
|
||||
规则对齐:手册写"任意一方…可向场边裁判申请发起时长为 5 分钟 的技术暂停并说明理由,待主裁判批准后技术暂停开始。技术暂停期间,双方均可在该段时间内检修机器人。"
|
||||
|
||||
四步流程
|
||||
|
||||
```
|
||||
申请方(红/蓝) → 理由 → ✅ 主裁判已批准 → 开始计时(05:00) → 恢复
|
||||
```
|
||||
|
||||
- 入口:`MatchBar` 的 `⏸ 技术暂停`。对话框必填:申请方、理由(枚举+备注)、"主裁判已批准"复选框(不勾不能开始——把规则写进交互里)。
|
||||
- 开始后:`phase = Paused { kind: Tech, endsAt, applicant }`,比赛计时冻结,技术暂停独立倒计时 5:00。
|
||||
- 用户端/直播:`PausedOverlay` 显示"技术暂停 · 剩余 04:12",不遮挡血条与计时。
|
||||
- 结束:
|
||||
- 手动「恢复比赛」→ 3-2-1 倒数 3 s(`Resuming` 过渡态)→ `Running`。
|
||||
- 05:00 到点 → 状态 `PausedTimeout`,提示"技术暂停已超时",按钮变成「恢复」/「判负」二选一,不自动判负(规则没写超时即判负)。
|
||||
- 录制不停(证据链需要),但写入 `bookmark` 事件;视频文件里保留暂停期间的画面。
|
||||
- 暂停期间的命中:硬件照常上报,客户端如实显示并把事件标红(不隐藏、不改血量)。理由见 R1/Q3——客户端没有写硬件的能力,做"冻结基线"只会制造两份互相矛盾的血量。
|
||||
|
||||
### 计时 / 比分
|
||||
|
||||
两个不同的 3 分钟,界面必须分开写
|
||||
|
||||
| 阶段 | 时长 | 文案 |
|
||||
| --- | --- | --- |
|
||||
| 赛前准备 | 3:00 | `准备 02:41`(浅色,不计入比赛) |
|
||||
| 单局比赛 | 3:00 | `比赛 02:41`(主色,进入最后 30 s 变红) |
|
||||
| 技术暂停 | 5:00 | `技术暂停 04:12`(青色) |
|
||||
|
||||
状态机
|
||||
|
||||
```
|
||||
Idle ──start_round──▶ Running ──pause──▶ Paused ──resume──▶ Running
|
||||
▲ │ │
|
||||
│ ├──hp=0 / timeout──▶ Ended(by=auto)
|
||||
│ └──forfeit─────────▶ Ended(by=referee)
|
||||
└──reset/next_round─────┴──────────────────┘
|
||||
|
||||
Ended(timeout 且血量相等) ──▶ AwaitingTiebreak ──set_tiebreak──▶ Ended(by=referee, reason=tiebreak)
|
||||
```
|
||||
|
||||
到点判定(严格照手册四条):
|
||||
|
||||
1. 血量先减为 0 的一方落败 → 已有 `resolve_outcomes`,直接复用。
|
||||
2. 3 分钟耗尽且无人归零 → 血量多者胜。
|
||||
3. 双方血量均等 → 不以"平局"结束,进入 `AwaitingTiebreak`,由裁判选择"本局最快开始移动的一队"。
|
||||
- 客户端没有运动遥测(协议里没有位置/速度),所以只能人工录入:`set_tiebreak { winner: 0|1 }`。
|
||||
- 这条必须显眼提示裁判"需要人工判定",不能默认算平局,否则会静默改变比赛结果。
|
||||
4. 两边同时归零 → `Draw`(现有 `MatchOutcome::Draw` 逻辑保留,仅用于显示)。
|
||||
|
||||
比分:`bestOf ∈ {1,3,5}`(对应手册 BO1/BO3/BO5),`score: [u8;2]`,`history: Vec<RoundResult>`。先到 `ceil(bestOf/2)` 局的一方赢下系列赛,界面显示 `2:0 · 系列赛结束`。
|
||||
|
||||
计时实现(后端为准):
|
||||
|
||||
```rust
|
||||
pub struct MatchClock {
|
||||
started: Option<Instant>,
|
||||
frozen_elapsed: Duration, // 暂停累计
|
||||
limit: Duration,
|
||||
}
|
||||
impl MatchClock {
|
||||
fn remaining(&self) -> Duration;
|
||||
fn pause(&mut self); fn resume(&mut self);
|
||||
}
|
||||
```
|
||||
|
||||
在 `AppState::tick()`(5 Hz)里推进;`Snapshot.match.remaining_ms` 为权威值;`Snapshot.now_ms` 用于前端插值。不允许前端 `setInterval` 自己减。
|
||||
|
||||
### 回放
|
||||
|
||||
入口:`Ended` 后结果卡 →「回放本局」;或 Dock 的「录制与回放 → 按局分组」。
|
||||
|
||||
布局
|
||||
|
||||
```
|
||||
┌ 回放 · 2026-11-01 第2局 红 vs 蓝 ─────────────── [导出] [返回] ┐
|
||||
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ 红方录屏 │ │ 蓝方录屏 │ │
|
||||
│ └──────────────────────┘ └──────────────────────┘ │
|
||||
│ ▶ ━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 01:12/03:00 │
|
||||
│ ⚑ 00:00 开始 · ● 00:41 命中 · ⏸ 01:12 技术暂停 · ⚑ 02:33 判负 │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 两个 `<video>` 共用主时钟(以 A 为主,B 每秒校正一次,偏差 > 120 ms 才 `seek`,避免抖动)。
|
||||
- 事件时间轴可点击跳转:`currentTime = (event.atMs - roundStartAtMs) / 1000`。
|
||||
- 快捷键:`空格` 播放/暂停、`←/→` ±1 s、`Shift+←/→` ±5 s、`M` 打标记。
|
||||
- 截图:现有 `api.screenshot(camera)` 只能截最新一帧,不能截回放帧。需要新增 `save_png { data: Vec<u8>, name: String }`(限制写入 `recordings_dir/shots`),前端用 `canvas.drawImage(video)` + `toBlob` 取帧。
|
||||
- 录制文件命名建议:`<date>_<round>_<红队>_vs_<蓝队>_<cameraText>.mp4`;若比赛开始时自动开启录制,需要给 `Recorder::start` 增加一个 `label: Option<&str>`。
|
||||
- 录制起停由 `match_start/end` 发出事件,是否自动则看用户配置(默认自动开、自动停;退出程序仍走现有 `stop_all` 收尾)。
|
||||
|
||||
---
|
||||
|
||||
## 直播 UI:安全区与包装层
|
||||
|
||||
### 基准画布与安全区
|
||||
|
||||
基准 1920×1080(16:9)。相机是 640×480(4:3),所以直播层必须自己决定是加信箱还是裁切,不能指望画面填满。
|
||||
|
||||
| 区域 | 距边距 | 1080p 像素 | 内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 画布 | — | 0 | 1920×1080 |
|
||||
| Action safe(93%) | 3.5% | 左右 67px / 上下 38px | 视频、装饰边框 |
|
||||
| Title safe(90%) | 5% | 左右 96px / 上下 54px | 比分、计时、队名、判定卡 |
|
||||
| 可牺牲区(平台遮挡) | — | 底部 120px(y ≥ 960)、右侧 60px | 只放赞助条/次要字幕;比分、计时、判定卡一律不得进入 |
|
||||
| 视频安全区(画面内 HUD) | 3.5% | 640×480 上约 22px 横 / 17px 纵 | 血条与命中提示条 |
|
||||
|
||||
> 若输出 720p,以上像素值 ×2/3;若必须 4:3 输出(1440×1080),安全区按宽高比各算一次,不要简单套 16:9 的数字。
|
||||
|
||||
### 分区与坐标(模板 A:双路对称,默认)
|
||||
|
||||
```
|
||||
y=0 ┌──────────────────────────────────────────────────────────┐
|
||||
│ [赛事名/校徽] ● LIVE BO3 第2局 │ 0–108 标题区
|
||||
y=108 ├──────────────────────────────────────────────────────────┤
|
||||
│ ┌────────────────┐ ┌────────────────┐ │
|
||||
y=150 │ 红方 │ 视频 800×600 │ │ 视频 800×600 │ 蓝方 │ 150–750 媒体区
|
||||
│ 队名 │ (640×480 放大)│ │ │ 队名 │ x:140–940 / 980–1780
|
||||
y=750 ├──────────────────────────────────────────────────────────┤
|
||||
│ 红 76 ━━━━━━ 02:41 ━━━━━━ 100 蓝 │ 770–930 比分/计时
|
||||
y=930 ├──────────────────────────────────────────────────────────┤
|
||||
│ [事件字幕] [赞助条] │ 940–1080 可牺牲区
|
||||
y=960 │ - - - - - - - - - 平台遮挡区上沿(不放关键信息) - - - - - │
|
||||
y=1080 └──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 媒体区左右各留 140px:其中 100px 给队名,剩余给队伍色条(`--team-0/1` 4px 竖条)。
|
||||
- 比分/计时带固定 y 770–930,底边 930 < 960,永远不进平台遮挡区;判定卡必须在 title safe 内(x 96–1824,y 54–1026)。
|
||||
- 计时居中,字号 `--fs-clock`;比分 `--fs-score`。
|
||||
- 4:3 → 800×600 是 1.25× 放大,VGA 源放大后仍可读;不要裁切(裁切会切掉装甲板或场地边线,判罚举证时没有依据)。
|
||||
|
||||
模板 B:主视角 + PIP(一方图传丢失时自动建议)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 标题条 │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ 主画面 1600×900(居中偏上) │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ PIP 480×360│ ← 右下,避开平台遮挡区
|
||||
│ 比分板 └──────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
模板 C:待机/准备(`Idle/Ready`,或信号中断)
|
||||
|
||||
- 中央:赛事名 + 双方队名 + `准备 03:00`(大号)+ "信号恢复中"(画面缺失时显示)。
|
||||
- 背景:`--bg-void` + 2% 噪点 + 细网格(`background-image: repeating-linear-gradient`,静态,不滚)。
|
||||
- 永不显示纯黑屏(黑屏会被平台判为离线)。
|
||||
|
||||
模板 D:判定(`Ended`)
|
||||
|
||||
- 中央判定卡:`红方胜` / `本局判负`(`--fs-display`)+ 理由(设备故障/超出时间/血量归零)+ 比分。
|
||||
- 卡背景 `rgba(8,8,12,.72)` + 1px 亮边 + 切角,不做全屏红闪。
|
||||
|
||||
### 包装层结构(z 序)
|
||||
|
||||
```
|
||||
z0 BackgroundLayer 底色 + 静态网格 + 噪点
|
||||
z1 MediaLayer 双路 VideoSurface(复用现有组件,crosshair=null)
|
||||
z2 HudLayer 画面内血条(可选显示,直播默认显示)
|
||||
z3 LowerLayer 比分板 / 计时 / 队名 / 事件字幕
|
||||
z4 AlertLayer 暂停 / 判负 / 判定卡
|
||||
z5 BrandLayer 赛事名、校徽、赞助、LIVE 徽标
|
||||
z6 GuideLayer 安全区参考线(仅预览,输出时 display:none)
|
||||
```
|
||||
|
||||
实现要点:
|
||||
|
||||
- `BroadcastStage` 用 1920×1080 的绝对坐标系 + `transform: scale(min(vw/1920, vh/1080))`,`transform-origin: top left`。这样所有设计稿坐标直接写进 CSS,无需换算为百分比。
|
||||
- 参考线由 `GuideLayer` 画(`outline: 1px dashed`),受 `props.guides` 控制;全屏/输出时自动关闭。
|
||||
- 视频源仍用 `VideoSurface`(`ViewTransform` 固定 `IDENTITY_VIEW`),只是容器换成 800×600 的固定框——不要为直播再写一套 canvas 绘制。
|
||||
|
||||
### 输出方式
|
||||
|
||||
| 方案 | 做法 | 评价 |
|
||||
| --- | --- | --- |
|
||||
| A(推荐) | 同一窗口切 `broadcast` 视图 + F11 全屏;OBS「窗口采集」+ 裁到 16:9 | 无需新增权限或进程,作为首选方案 |
|
||||
| B | 第二个 `WebviewWindow`(`label: broadcast`,无边框、置顶) | 需要 `core:webview:allow-create-webview-window`;双窗口抢 GPU,留待后续实现 |
|
||||
| C | NDI / 虚拟摄像头 | 明确不做(依赖重、跨平台成本高) |
|
||||
|
||||
实施时需检查:
|
||||
|
||||
1. WebView 背景:`transparent: true` 在 Windows WebView2 与 macOS WKWebView 行为不一致,直播场景下透明边缘会出黑边。结论:用不透明底色 `--bg-void` 铺满,不做透明。
|
||||
2. 低帧画面 + 高帧包装:视频是 JPEG 序列,帧率取决于固件配置(`commands.rs` 的注释里记着"The sensor 实测 22 fps",`target/firmware/BUILD-INFO.json` 里 vga30 档实测 29.7 fps);无论哪个值都远低于包装层的 60 fps,动效会"飘"在画面上。所以包装层只做状态迁移动画,不做连续动画。
|
||||
3. 纯黑编码:`#000` 在 H.264 里容易 banding,用 `#07080a` + 2% 噪点。
|
||||
4. OBS 窗口采集会采到鼠标:全屏时 `cursor: none`(`immersive` 状态负责)。
|
||||
5. 窗口标题:直播窗口标题里不要出现队名/端口等内部信息(`setTitle` 现在会写身份;broadcast 视图下改成 `cam-desktop — 直播`)。
|
||||
6. 分辨率自适应:`transform: scale()` 后文字仍清晰(矢量),但如果 OBS 缩放窗口,字号会变成非整数像素 → 建议 OBS 采集时窗口保持 1920×1080 或 1280×720。
|
||||
|
||||
### 直播视图的安全边界(必须做)
|
||||
|
||||
- `broadcast` 视图不渲染:设置按钮、维护入口、`cameras[].addr`(IP)、`stream_info.url`(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
|
||||
- 所有管理命令在广播态禁用(不是隐藏,是 `disabled` + `aria-hidden`,防止误触与 Tab 聚焦)。
|
||||
- 演示模式(`demo`)在直播里显示红色角标"演示画面",与现有 `tag--demo` 同一语义(现在只在 TopBar,直播态必须另有一处)。
|
||||
- 断流时 `StandbyCard` 明确写"信号中断 · 正在重连",不显示上一帧冻结画面冒充直播(冻结画面会被观众当成"画面卡住")。
|
||||
|
||||
---
|
||||
|
||||
## 校内赛规则要点与判罚流程
|
||||
|
||||
### 规则事实(来自官方手册,不改写)
|
||||
|
||||
| 条目 | 规定 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| 队伍 | 每队严格 3 人,每人限一队;16 队取得比赛资格 | [赛事概览](https://docs.simba-robotics.com/zh-cn/competition/) |
|
||||
| 赛制 | BO1 / BO3 / BO5;16→8 双败 BO3,8→4 双败 BO3,总决赛 BO5 | [对抗赛与奖项](https://docs.simba-robotics.com/zh-cn/competition/matches/) |
|
||||
| 红蓝方 | 每场编号小者为红方,编号大者为蓝方 | 同上 |
|
||||
| 检录 | 尺寸 / 遥控运动 / 发射 / 裁判系统安装 / 裁判系统(含图传)运作;通过后不得再烧录或改硬件直至准备阶段 | 同上 |
|
||||
| 赛前准备 | 3 分钟;双方把机器人放到与装甲板颜色相同的部署点;确认裁判系统与操作位图传客户端连接、遥控连接正常 | 同上 |
|
||||
| 技术暂停 | 5 分钟;任意一方遇突发问题向场边裁判申请并说明理由,主裁判批准后开始;期间双方均可检修 | 同上 |
|
||||
| 血量 | 初始 100;每发有效攻击扣 3 点 | 同上 |
|
||||
| 单局时长 | 3 分钟 | 同上 |
|
||||
| 胜负 | ①血量先归零者负 ②时间耗尽血量多者胜 ③双方血量均等则以本局最快开始移动的一队获胜 | 同上 |
|
||||
| 装甲板 | 每车 2 或 4 块,其中一块为主装甲板;检测周期 100 ms;任一触发后全车 100 ms 静默期;供电 5 V ± 1 V;UART 可向主控发键鼠指令 | [裁判系统](https://docs.simba-robotics.com/zh-cn/competition/referee-system/) |
|
||||
| 图传 | 1 个模块,回传第一人称画面到裁判系统客户端屏幕;供电 3.3 V(与装甲板 5 V 不同) | 同上 |
|
||||
| 日程 | 10-25 适应性训练;11-01 决赛(9:00–11:00 / 14:00–18:00) | [赛事概览](https://docs.simba-robotics.com/zh-cn/competition/) |
|
||||
|
||||
### 对客户端的影响
|
||||
|
||||
1. 血量必须同时有数字和格子。100 点 / 5 格 = 20 点/格,而单发只扣 3 点——一格的粒度相当于 6.7 发。只给格子,操作手无法判断"再中两发就死"。→ 保留 `showNumbers` 默认开启,且数字 ≥ 18px。
|
||||
2. 100 点 = 34 发(33 发后剩 1 点)。裁判端可以显示"已中 33 发"这类辅助读数(`100 - hp) / 3`),但不要把它当成权威值——静默期内的连击会被合并。
|
||||
3. 100 ms 静默期决定命中动效下限:同一次攻击可能在 4 块板上各上报一次,`HealthTracker` 按血量差派生,已经能合并;UI 侧不要为每个 board 事件各弹一次伤害数字(否则一次命中会闪 4 下)。
|
||||
4. 装甲板 2 块也要能显示:`ArmorBoardDto.reportsAllPlates` / `platesSeen` 已存在,界面在 `platesSeen < 4` 时显示"仅报 N 块",不要显示 4 个空槽让人以为板子坏了。
|
||||
5. "最快开始移动"没有数据源。图传只给画面,协议里没有位移/速度;因此这条规则只能人工判定(见“计时 / 比分”)。不要试图从画面里推算。
|
||||
6. 赛前 3 分钟是"确认客户端连上了"的窗口,所以用户端需要一个准备检查卡:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 `Ready` 阶段自动出现,`Running` 后消失。
|
||||
7. 红蓝配色固定:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 `teamColor` 已是这个语义,只改色值)。
|
||||
|
||||
### 判罚流程(客户端可执行的部分)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ 单局结束条件 │
|
||||
└──────────────┬───────────────┘
|
||||
┌───────────────────────────┼───────────────────────────┐
|
||||
│ │ │
|
||||
血量归零 时间耗尽 人工裁定
|
||||
(自动,双方一致) (自动比血量) (裁判操作)
|
||||
│ │ │
|
||||
│ ┌──────┴──────┐ ┌─────────┼─────────┐
|
||||
│ 血量不等 血量相等 设备故障 违规/弃权 平血仲裁
|
||||
│ │ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼ ▼
|
||||
Ended(by=auto, Ended(by=auto, AwaitingTiebreak ForfeitDialog(长按 1s + 理由)
|
||||
reason=knockout) reason=timeout) → set_tiebreak → Ended(by=referee)
|
||||
│
|
||||
▼
|
||||
Ended(by=referee, reason=tiebreak)
|
||||
```
|
||||
|
||||
操作记录
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "evt-0007",
|
||||
"atMs": 1761998400000,
|
||||
"actor": "referee",
|
||||
"action": "forfeit",
|
||||
"loser": 0,
|
||||
"reason": "equipment",
|
||||
"note": "图传模块断电",
|
||||
"hp": [76, 100],
|
||||
"remainingMs": 161000,
|
||||
"round": 2,
|
||||
"score": [1, 0]
|
||||
}
|
||||
```
|
||||
|
||||
- 落盘:`<app_data>/matches/<yyyy-mm-dd>/<matchId>/events.ndjson`(追加写,不覆盖),另有 `match.json` 存最终结果。
|
||||
- 导出:`match_export` 把 `result.csv` 与 `events.ndjson` 放到录制目录,供组委会归档。
|
||||
- 更正:`match_undo`(5 s 内)或 `match_reopen_round`(带理由);任何更正都新增记录,不删除原记录。
|
||||
|
||||
规则缺口(手册未定义,客户端不臆造)
|
||||
|
||||
| 缺口 | 客户端默认行为 | 需要谁定 |
|
||||
| --- | --- | --- |
|
||||
| 警告/红黄牌累计 | 不实现累计逻辑,只有"理由 + 备注" | 组委会 |
|
||||
| 判负后是否重赛 | 只记录 `reason`,不自动重开 | 主裁判 |
|
||||
| 平血"最快开始移动"的判定依据 | 人工选择,必填备注 | 主裁判 |
|
||||
| 设备故障(图传中断)导致无法比赛 | 人工裁定:技术暂停 / 判负 / 重赛 | 主裁判 |
|
||||
| 技术暂停超时(>5 分钟) | 提示超时,不自动判负 | 主裁判 |
|
||||
|
||||
> 手册 1.0.0 的更新日志提到"添加了详细的扣血、判罚规则与单局时间",但当前公开页面只给出了 上方规则表列出的内容(扣血 3 点、技术暂停 5 分钟、四条胜负条件)。如果组委会另有一份判罚细则(红黄牌、犯规等级),需要提供原文,否则界面不写死任何罚则。
|
||||
|
||||
---
|
||||
|
||||
## 风险与待确认事项
|
||||
|
||||
### 风险
|
||||
|
||||
| # | 风险 | 概率/影响 | 缓解 |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | 客户端判负 ≠ 硬件血量:协议只有上行(`MsgType` 1–5),没有下行控制,判负后对方装甲板仍报 100 | 高 / 高 | 界面明确写"只改变判定与记录,不改变硬件血量";不伪造 HP;若要硬件生效必须另做固件+协议(Q2) |
|
||||
| R2 | 跨端不同步:裁判端判负/暂停,操作位用户端看不到 | 高 / 高 | v1 只作用于裁判端与直播;v2 用 `5601` 广播新 `MsgType`(见下)并加共享码签名;现场先用对讲机兜底 |
|
||||
| R6 | 动效拖慢低配裁判机(双路 canvas + CSS 动画) | 中 / 中 | 只用 `transform/opacity`;禁连续动画;低动效开关;实测 30 min 不掉帧 |
|
||||
| R7 | 录制与比赛状态机耦合(自动起停误伤手动录制) | 中 / 中 | 状态机只发事件;"随比赛自动录制"做成开关,默认开 |
|
||||
| R8 | 直播泄密(IP/token/密码入口出现在画面里) | 中 / 高 | broadcast 视图白名单渲染;不渲染 `addr`/`url`;只读态;截图核对清单 |
|
||||
| R9 | 规则变更/规则缺口(平血、超时、罚则) | 中 / 中 | 规则参数可配置(`MatchConfig`);缺省行为全部"人工裁定 + 记录" |
|
||||
| R10 | 中文排版回归(字距、字重、字号) | 中 / 低 | 沿用"中文不加字距";只换 token 不动排版规则;逐页截图对比 |
|
||||
| R11 | 光敏/无障碍(红闪、脉动) | 低 / 中 | ≤ 3 Hz、面积 < 25%、`prefers-reduced-motion` 降级、低动效开关 |
|
||||
|
||||
R2 的可选技术方案(若用户要跨端同步)
|
||||
|
||||
```rust
|
||||
// protocol/src/lib.rs 新增(不改动已有枚举值)
|
||||
pub enum MsgType { Video = 1, Subscribe = 2, Hello = 3, Heartbeat = 4, ArmorHealth = 5, MatchControl = 6 }
|
||||
|
||||
// 载荷:version(1) op(1) team(1) seq(4) issued_unix_ms(8) mac(16)
|
||||
// op: 1=pause 2=resume 3=forfeit 4=reset 5=state-sync
|
||||
```
|
||||
|
||||
- 裁判端以 `REFEREE_ID` 广播到 `5601`;用户端在装甲板监听里增加一个 `MsgType::MatchControl` 分支(不改相机订阅逻辑)。
|
||||
- 安全:局域网里任何人都能发 UDP,所以必须带共享码派生 MAC(检录时裁判端生成 6 位码,双方在客户端输入),否则队伍可以伪造"对方判负"。
|
||||
- 固件兼容性待验证:装甲板固件只认 `MsgType::ArmorHealth`,未知类型按现有 `decode_header` 逻辑返回 `None` 被忽略;但上线前必须在实机上验证"未知 MsgType 不会让板子异常"。
|
||||
|
||||
### 需要用户确认的问题
|
||||
|
||||
比赛语义
|
||||
|
||||
1. Q1 客户端"一键判负"要不要让用户端(操作位)也看到?如果需要,是否接受"局域网广播 + 共享码"的方案(R2)?
|
||||
2. Q2 判负/技术暂停是否需要改变装甲板硬件血量(例如判负后立即把对方 HP 打到 0)?这需要改固件 + 协议,属于另一个工程。
|
||||
3. Q3 技术暂停期间的命中:如实显示并标红(当前建议),还是冻结/忽略?谁来确定"暂停开始"的时间点——客户端按钮,还是场边裁判口头通知后由裁判端按下?
|
||||
4. Q4 平血时"本局最快开始移动的一队"由谁录入?是否接受"裁判端手动点选一方 + 必填备注"?
|
||||
5. Q5 是否需要 BO3/BO5 的系列赛比分与"第 N 局"编排?队伍名/编号从哪来(手工输入 vs 导入名单)?
|
||||
|
||||
直播
|
||||
|
||||
6. Q6 输出分辨率与帧率(1080p30 / 720p60?)、推流平台(B 站 / 抖音 / 校内大屏 / 录播)?安全区按哪个标准?
|
||||
7. Q7 是否用 OBS 窗口采集(推荐,方案 A)?还是需要独立输出窗口 / 透明背景(方案 B)?
|
||||
8. Q8 包装层需要哪些固定素材:赛事名、校徽、赞助商 LOGO、选手照片/队徽?由谁提供、什么格式?
|
||||
|
||||
录制与部署
|
||||
|
||||
- 录制是否随比赛状态自动起停?文件名是否包含双方队名与局次?
|
||||
- 现场优先支持哪个平台?当前默认 Rust target 为 `aarch64-apple-darwin`;Windows 构建需要显式指定宿主 target。
|
||||
|
||||
## 附录 A:热键总表
|
||||
|
||||
| 键 | 范围 | 动作 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| `F11` / `⌃⌘F` / `Alt+Enter` | 全局 | 全屏切换 | 400 ms 去抖 |
|
||||
| `Esc` | 全局 | 关最上层(对话框 → 抽屉 → 全屏) | 逐层关闭 |
|
||||
| `Ctrl/Cmd+K` | 全局 | 命令面板(设置/维护/直播/回放) | 替代主界面按钮 |
|
||||
| `Ctrl/Cmd+Shift+B` | 全局 | 切换直播视图 | 仅裁判端 |
|
||||
| `Ctrl/Cmd+Enter` | 裁判端 | 开始本局 | `phase ∈ {Idle, Ready}` |
|
||||
| `Ctrl/Cmd+Shift+P` | 裁判端 | 技术暂停 | 弹对话框 |
|
||||
| `Ctrl/Cmd+Shift+F` | 裁判端 | 判负 | 仍需长按确认 |
|
||||
| `Ctrl/Cmd+1` / `+2` | 裁判端 | 选中红/蓝(对话框内) | — |
|
||||
| `Space` | 仅回放 | 播放/暂停 | 比赛中禁止 |
|
||||
| `←/→`、`Shift+←/→` | 仅回放 | ±1 s / ±5 s | — |
|
||||
| `M` | 仅回放 | 打标记 | 写入事件 |
|
||||
| `Tab`(按住) | 全局 | 显示当前视图快捷键 | 半透明面板 |
|
||||
| `Ctrl/Cmd+R` | 全局 | 刷新快照(现有 `refresh`) | — |
|
||||
|
||||
## 附录 B:验收标准(提议)
|
||||
|
||||
| 项 | 标准 | 验证方法 |
|
||||
| --- | --- | --- |
|
||||
| 全屏 | F11 切换 < 350 ms;切 10 次不卡死、不丢键 | 手工 |
|
||||
| 计时 | 3 分钟误差 < 200 ms;切窗口/最小化不失速 | 与手机秒表对比 3 次 |
|
||||
| 判负 | 必须长按 ≥ 1000 ms;< 1000 ms 不生效;撤销窗口 5 s | 自动化 + 手工 |
|
||||
| 技术暂停 | 申请表单不勾"已批准"无法开始;5:00 倒计时准确 | 手工 |
|
||||
| 直播 | 安全区内无越界元素;不出现 IP/token/设置入口 | 截图核对清单 |
|
||||
| 动效 | 30 min 双路运行,主线程不掉帧(≥ 25 fps) | 性能面板 |
|
||||
| 无障碍 | 关键文字对比 ≥ 4.5:1;`prefers-reduced-motion` 下无位移动画 | 对比度工具 + 开关 |
|
||||
| 回归 | 视频/血量/装甲板/录制/维护五项原功能全部可用 | 回归清单 |
|
||||
|
||||
## 附录 C:范围
|
||||
|
||||
1. 不做网络 OTA / 设备自升级(与[固件维护](../maintenance/firmware.md) 的边界一致)。
|
||||
2. 不做修改固件源码;打包只组装已有产物。
|
||||
3. 不做从画面推算"移动先后"、命中位置、弹道等规则数据。
|
||||
4. 不做直播的透明背景/虚拟摄像头/NDI。
|
||||
5. 不做多语言(中文界面为唯一语言,符合约束)。
|
||||
6. 不做用户账号/云端同步。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,530 @@
|
||||
# RMIC::2026
|
||||
|
||||
黑龙江大学 602 实验室 · Simba Robotics 的视野链路桌面端。同一进程提供用户端与裁判端两种界面。
|
||||
|
||||
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
||||
|---|---|---|---|---|
|
||||
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
||||
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
||||
|
||||
前端使用 WebView 中的 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为可单测的模块。
|
||||
|
||||
> 显示名是 `RMIC::2026`;安装包和可执行文件使用 `RMIC-2026`,因为 Windows 文件名不允许 `:`。配置、录制与日志沿用 `cam-desktop` 目录,以保留已有密码哈希与绑定,见“配置”。
|
||||
|
||||
本文的路径以仓库根目录为起点,构建命令会注明执行目录。目录树及命令注释中的测试数量是原记录的快照,当前测试结果以实际运行输出为准。
|
||||
|
||||
```text
|
||||
desktop/
|
||||
core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试)
|
||||
src-tauri/ Tauri 后端:推流、命令、录制(40 个测试)
|
||||
src/ React 前端:两套页面 + HUD
|
||||
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化、准星绘制、界面小节拍
|
||||
src/components/ 取景器、装甲条、命中、结算、装甲板表、队名、录制、设置抽屉、启动序列、门、提示
|
||||
src/pages/ 用户端 / 裁判端 / 固件维护 / 延迟校准
|
||||
src/styles/ tokens(令牌)、base(原子)、chrome(外壳)、stage(舞台)、console、settings、maintenance
|
||||
public/fonts/ 自托管的 HarmonyOS Sans SC 与 JetBrains Mono
|
||||
src-tauri/icons/ 队标(由 simba-robotics.com/logo.svg 生成,含光学尺寸分级)
|
||||
```
|
||||
|
||||
## 界面:一套终端语言
|
||||
|
||||
### 配色:墨黑蓝打底,只有一支红
|
||||
|
||||
| 角色 | 值 | 用在哪 |
|
||||
|---|---|---|
|
||||
| 底色 | `--void #05070d` → `--panel-3 #172032` | 全部面积。五级低饱和冷蓝黑,靠 1px 线分层,不靠阴影 |
|
||||
| 文字 | `--ink #e7ecf5` → `--ink-4 #3a4457` | 四档亮度,第四档用于弱化显示 |
|
||||
| **强调** | `--primary #e8232b` | **只有**:焦点、选中、录制中、错误、临界血量、结算 |
|
||||
| 状态 | `--error` / `--warn #f2b33d` / `--ok #3fc98a` | 各有语义,不与强调色混用 |
|
||||
| 队伍 | `--team-0 #ff6b5e` / `--team-1 #4a9bff` | **硬件约束**:装甲板 LED 就是红与蓝 |
|
||||
|
||||
红色用于需要注意的状态。开启的开关为白色,滑杆填充为钢灰色,分段控件以底部 2px 线标记选中,分区编号为灰色,顶栏不加装饰强调线。
|
||||
|
||||
队红刻意做得比强调红更暖更浅,否则两条血并列时会被误读成"系统在报警"。
|
||||
|
||||
### 形状:矩形,唯一的斜边给装甲条
|
||||
|
||||
界面采用矩形,不使用圆角、切角、药丸或阴影卡片。斜边只用于装甲条的平行四边形,使血量与其他控件在形状上保持区别。
|
||||
|
||||
### 动效:严格照 Material 3 的曲线与时长
|
||||
|
||||
`styles/tokens.css` 里直接写着 M3 的七条缓动与 short/medium/long 四组时长:
|
||||
|
||||
| 令牌 | 曲线 | 语义 |
|
||||
|---|---|---|
|
||||
| `--m3-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | 需要存在感的转场 |
|
||||
| `--m3-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | **进入**(快起慢收) |
|
||||
| `--m3-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | **退出**(慢起快走) |
|
||||
| `--m3-standard` / `-decelerate` / `-accelerate` | `(0.2,0,0,1)` / `(0,0,0,1)` / `(0.3,0,1,1)` | 常规位移 |
|
||||
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
|
||||
|
||||
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为**指向 M3 的别名**,所以没有一条规则落在自造
|
||||
的贝塞尔上。两个例外是刻意的:`steps()` 用于录制红点与终端光标(离散闪烁),`linear` 用于
|
||||
"没信号"那圈恒速转动的虚线环——M3 的曲线描述的是有始有终的转场,不是无限循环。
|
||||
|
||||
### 动效:每一页只有一个签名动作
|
||||
|
||||
各页共用缓动曲线和时长,动效随操作场景区分:
|
||||
|
||||
| 界面 | 签名动作 | 它解决什么 |
|
||||
|---|---|---|
|
||||
| 启动序列 | 一行行亮起的自检 + 真实进度 | 区分加载中与启动失败 |
|
||||
| 首启门 | 两张模块卡,悬停浮起并露出角括号 | 提示选择将保存到本机 |
|
||||
| 用户端 | 逐格熄灭的装甲条、四周泛红、六秒休眠 | 静止时屏幕上的东西越少越好 |
|
||||
| 裁判端 | 中缝可拖、鼠标停在哪一路下方就看哪一路 | 不用再开第三个页面去"看某一边" |
|
||||
| 设置 | 常驻实时预览 + 竖向分节导航 | 改准星/网格/装甲条时立刻看得见结果 |
|
||||
| 固件维护 | 步骤轨 + 环形进度 + 终端日志 | 烧录的哪一步、到哪儿了,一眼可读 |
|
||||
| 解锁 | 一次性的校验扫描 + 错误时的一次抖动 | 明确"现在这一步是在验证身份" |
|
||||
|
||||
`prefers-reduced-motion` 将这些动效时长设为 0,功能保持可用。
|
||||
|
||||
### 字体:中文一套、拉丁一套,逐字回退
|
||||
|
||||
| | 字体 | 承担什么 |
|
||||
|---|---|---|
|
||||
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
|
||||
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
|
||||
|
||||
两者都以 woff2 自托管在 `public/fonts/`,随 `frontendDist` 进安装包,**运行时不碰网络**
|
||||
(CSP 里 `default-src 'self'` 也不允许)。中文由 `cn-font-split` 切成 82 个 unicode-range
|
||||
分片 × 3 个字重,浏览器按需加载,避免启动时加载完整的 14 MB 字体。
|
||||
|
||||
合到一句话里靠**逐字回退**,不做语言判断:`--font-ui` 把 JetBrains Mono 写在前面,它里面
|
||||
没有汉字,浏览器就逐字往下找,落到 HarmonyOS。于是"血量 87"里数字是等宽的、汉字是
|
||||
HarmonyOS 的,一条 `font-family` 同时满足两个要求。
|
||||
|
||||
启动页等待 `document.fonts.ready`,避免先显示系统字体、两百毫秒后切换到 HarmonyOS 引起布局重排。
|
||||
|
||||
### 队标
|
||||
|
||||
应用图标与启动页的标记是队标(`https://simba-robotics.com/logo.svg`),由它生成整套
|
||||
`src-tauri/icons/`。原图是 1024×1024 的画布、标志只占中间一条,所以脚本把 viewBox 收到本体
|
||||
上再居中;**小尺寸用更大的缩放比**(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%),
|
||||
否则 16/24/32 上那些 1px 笔画会糊成一团。`icon.ico` 是手工拼的多尺寸容器——Pillow 的写入器
|
||||
只能从一张主图缩出所有档位,而 Windows 任务栏恰恰只取最小的那几档。
|
||||
|
||||
笔画在 17px 以下就只剩 1px,所以顶栏只放字标 `RMIC::2026`(`::` 是界面里唯一被染红的两个
|
||||
字符),完整队标留给启动页的 30px。
|
||||
|
||||
### 每页最顺手的那个操作
|
||||
|
||||
- 用户端通过滚轮缩放、拖动平移、双击复位,无需额外点击按钮。六秒没有输入或受击时,角落标签与代号淡出;出现输入或受击后恢复。
|
||||
- **裁判端让控制台跟着鼠标走。** 按下左格,下方列表就只显示那一边的装甲板(再按一次回到
|
||||
全部,键盘 `1` / `2` / `0` 同样)。中间那条竖线可以拖——哪一路更重要就把它拉宽,双击回到
|
||||
等宽。画面与下方控制台之间的横线也可拖动,用来调整回放区域的高度。
|
||||
- **设置是一块带预览的控制台。** 左侧竖向导航,右侧常驻一块实时预览;预览用的是画面里
|
||||
同一个绘制函数(`lib/reticle.ts`),使预览与实际画面的绘制逻辑一致。预览血量滑杆用于查看低血阈值与脉动效果。
|
||||
|
||||
## 版本与改动日志:算出来的,不是写出来的
|
||||
|
||||
版本号、构建号和改动记录均从 Git 历史计算。提交规则、派生文件和固件版本通告见[版本维护](../development/versioning.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
pnpm install
|
||||
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
|
||||
pnpm build # 只构建前端(tsc --noEmit && vite build)
|
||||
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
|
||||
```
|
||||
|
||||
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
||||
|
||||
```sh
|
||||
cd desktop/core && cargo test # 83
|
||||
cd desktop/src-tauri && cargo test # 40,其中几条真的会起 ffmpeg
|
||||
```
|
||||
|
||||
### Windows x64 构建
|
||||
|
||||
需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。
|
||||
|
||||
在 `desktop/` 中执行:
|
||||
|
||||
```powershell
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
`--target` 覆盖本目录原来的 macOS target,`--bundles nsis` 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 `build-std`。
|
||||
|
||||
- 程序:`src-tauri/target/x86_64-pc-windows-msvc/release/RMIC-2026.exe`(名字由 `tauri.conf.json` 的 `productName` 决定;直接 `cargo build` 出来的原始产物仍叫 `cam-desktop.exe`,那是内部构建物,不是交付物)。
|
||||
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
|
||||
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
|
||||
|
||||
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
|
||||
|
||||
设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免"能发现并绑定,但始终等待画面"的情况。队伍端的绑定和画面过滤仍然生效。
|
||||
|
||||
### 演示模式:没有板子也能把整条链路跑起来
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
CAM_DEMO=1 pnpm app
|
||||
```
|
||||
|
||||
演示模式合成协议数据报,交给 `Receiver::datagram` 与 `ArmorHealthStore::datagram`,沿正常接收路径进入界面:
|
||||
|
||||
```text
|
||||
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||||
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||||
```
|
||||
|
||||
分片、重组、发现、统计、掉血派生与两项接收限制使用正式代码路径,截图和录制也无需专门分支。界面标注“演示模式”,设备 ID 使用 `0x0DE00001` 等人工值。演示数据不能作为现场状态。
|
||||
|
||||
### 命令行与环境变量
|
||||
|
||||
```text
|
||||
--role <team|referee> 身份。默认看配置文件
|
||||
--config <路径> 配置文件位置
|
||||
--demo 演示模式
|
||||
--list 打印发现的摄像头与装甲板后退出
|
||||
-h, --help 帮助
|
||||
|
||||
CAM_DEMO=1 等价于 --demo
|
||||
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||||
```
|
||||
|
||||
使用 `tauri dev` 时,应用参数需要通过两个 `--` 分隔符传递,如 `tauri dev -- -- --demo`。也可通过上面的环境变量设置。
|
||||
|
||||
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
|
||||
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
|
||||
|
||||
## 为什么换成 Tauri,代价是什么
|
||||
|
||||
旧客户端使用 Rust + Slint 单二进制。改用 Tauri 是为了加快血条、瞄准器、拖拽校准、绑定控件、设置和回放等界面的开发,把更多工作时间留给 UDP、重组、发现与接收限制。
|
||||
|
||||
相应代价:
|
||||
|
||||
- **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
|
||||
- **要联网装依赖**(前端 npm、Rust crates)。
|
||||
- **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器,
|
||||
路径更短。
|
||||
|
||||
`core/` 不依赖界面,重组、发现、帧率窗口、槽位分配、配置与密码均可单独测试。
|
||||
|
||||
## 视频怎么进 WebView
|
||||
|
||||
视频走一个只监听回环地址的 WebSocket,推二进制 JPEG。界面从 `stream_info` 命令拿到
|
||||
`ws://127.0.0.1:<随机端口>/?token=...`。
|
||||
|
||||
### 为什么不是 Tauri 的 IPC
|
||||
|
||||
按早期两路各 22 fps、每帧约 22 kB 的场景估算,JPEG 流量约 1 MB/s。JSON IPC 需要序列化和反序列化,base64 还会增加约 1/3 体积。二进制 WebSocket 直接传递原始 JPEG,由浏览器原生解码,后端无需解码。
|
||||
|
||||
### 二进制帧的布局
|
||||
|
||||
```text
|
||||
0 4 8 10 12 16
|
||||
+--------------+--------------+------+------+--------------+---------+
|
||||
| sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
|
||||
+--------------+--------------+------+------+--------------+---------+
|
||||
```
|
||||
|
||||
帧头为 16 字节,采用小端编码。`server.rs` 与 `stream.ts` 两侧均有布局测试;长度不一致的帧直接丢弃,防止投递不完整 JPEG。
|
||||
|
||||
### 前端侧的两个陷阱
|
||||
|
||||
`createImageBitmap` 异步解码,相邻帧可能乱序完成。每路记录最后绘制的帧号,只采用更新帧,避免画面回退。
|
||||
|
||||
旧的 `ImageBitmap` 必须显式调用 `close()` 释放像素资源,仅替换引用不够。早期两路 22 fps 场景下,未释放的资源在几分钟内可积累到几百 MB。
|
||||
|
||||
## 防作弊:两道闸门,不是一个
|
||||
|
||||
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
|
||||
|
||||
限制在接收端执行。Hello 广播已包含由 MAC 派生的 `sender_id` 与 IP;CSS 只能隐藏显示,无法阻止接收与解码。
|
||||
|
||||
| 闸门 | 管什么 | 什么时候开 |
|
||||
|---|---|---|
|
||||
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
|
||||
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
||||
|
||||
旧客户端共用一个开关控制发现和接收,改绑需要手动修改配置文件。现在设置界面可以列出候选摄像头,同时保持接收限制:其他板卡即使向本机端口推流或进入发现列表,其视频帧也不会进入重组器。
|
||||
|
||||
`only_sender` 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。
|
||||
|
||||
### 遥测按编译模式门控
|
||||
|
||||
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
|
||||
数字,`CameraStatsDto::build` 将相应字段置零或置空。门控由编译模式决定,避免依赖现场命令行开关。
|
||||
|
||||
### 这挡不住什么
|
||||
|
||||
Hello 广播仍包含 IP。固件会向任何发送 `Subscribe` 的订阅者推流,本机客户端的接收限制无法阻止其他客户端订阅。若要限制无线订阅者,需要在固件侧实现配对 token,例如首次绑定时发放令牌,之后只接受带令牌的订阅;若令牌只保存在内存中,固件重启后需要重新配对。
|
||||
|
||||
回环通道令牌只限制本机程序连接:连接方需要先取得令牌。它不验证无线视频订阅者的身份。
|
||||
|
||||
## 密码:它挡的是谁
|
||||
|
||||
- 密码用于限制现场改绑和退出程序。配置只保存 Argon2id 哈希;修改配置中的绑定后,界面仍要求解锁。
|
||||
- 拥有本机配置和源码的人可以删除哈希或修改客户端,因此密码无法约束这种操作。限制无线订阅仍需固件侧配对机制。
|
||||
|
||||
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
|
||||
|
||||
实现上:
|
||||
|
||||
- Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希
|
||||
一起保存在配置中,之后调整参数无需迁移。短密码不应仅使用 SHA-256 等快速哈希保护。
|
||||
- 用户端首次启动时必须设置密码(`Config::needs_password_setup`),最短 4 位,便于现场记忆与输入。
|
||||
- 解锁在本次进程内有效,用户端可以手动“上锁”。
|
||||
- 裁判端无需密码,便于现场调整槽位和录制。
|
||||
- 离开用户端需要密码,避免通过切换到裁判端绕过绑定和配置限制。
|
||||
|
||||
Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从控制台调用 `invoke("bind_camera")`,也需要通过这项检查。
|
||||
|
||||
客户端移除了旧版的 `--camera-id` 命令行覆盖,避免绕过密码改绑。临时更换板卡时,在设置中解锁后修改,或用 `--config` 指定另一份配置文件。
|
||||
|
||||
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
|
||||
导入并备份成 `.bak`。
|
||||
|
||||
## 用户端 HUD
|
||||
|
||||
### 血条:两个上角,平行四边形,一条分 5 格
|
||||
|
||||
自己和对手的血条分别位于画面左上角与右上角,便于操作手在取景时对照。血条为平行四边形,分成 5 格,每格 20 点血;较大的分格便于远处辨认。血量减少时,从靠近画面中心的一侧开始熄灭。
|
||||
|
||||
血条使用绝对定位,设置 `pointer-events: none`,让鼠标仍能缩放和拖动画面。倍率与复位徽标放在下沿,为上方血条留出空间。
|
||||
|
||||
宽度、数字、队名、低血阈值与脉动均可配置。阈值以内的格子始终使用警示色,提前标出低血区域。
|
||||
|
||||
未接入装甲板时显示占位说明,避免将缺失数据误读为血量归零。
|
||||
|
||||
血量归零后,血条保留数字 0 和熄灭的格子,比赛结果由中央横幅显示。
|
||||
|
||||
### 胜负横幅
|
||||
|
||||
一方归零后,图传中央显示结果:获胜方为“胜利”,落败方为“落败”,双方同时归零为“平局”。用词带上对手的名字,裁判端两格因此也能直接
|
||||
看出是哪一边赢了。
|
||||
|
||||
- 结果由 Rust 侧计算(`snapshot::resolve_outcomes`,有单测)。自己归零即可判负,即使对手掉线;判胜则要求对手归零且自己仍有血,避免在本队从未上报时误判获胜。
|
||||
- 横幅持续显示到血量重置(长按 BOOT / 下一局开始),供截图、回放和现场核对。
|
||||
- 比赛结果放在画面中央;比赛中的命中反馈避开中央瞄准区域。
|
||||
|
||||
### 瞄准器
|
||||
|
||||
瞄准器标记相机画面中的一个点,直接绘制在 canvas 上。图像、网格、瞄准器和伤害数字共用同一组变换,避免 DOM 与 canvas 分别计算位置而产生偏移。
|
||||
|
||||
尺寸与缩放的关系也是刻意分开的:
|
||||
|
||||
| | 跟着缩放走吗 | 为什么 |
|
||||
|---|---|---|
|
||||
| 位置 | **是** | 它指的是画面里的一个点 |
|
||||
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
|
||||
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
|
||||
|
||||
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
|
||||
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
|
||||
|
||||
### 命中反馈
|
||||
|
||||
主板只上报血量,不单独发送受击事件。`HealthTracker` 根据相邻读数还原命中:
|
||||
|
||||
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
|
||||
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
|
||||
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
|
||||
|
||||
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
|
||||
基线会让一次掉血被报成四次命中。
|
||||
|
||||
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
|
||||
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
|
||||
|
||||
反馈包括画面四周泛红和准星旁的伤害数字,中央画面保持原样。早期版本的数字曾遮住瞄点,因此移到了准星旁。
|
||||
|
||||
命中反馈不表示方向。主板每次广播四路相同血量,事件中的 `index` 只表示哪一路上报先被收到;到达顺序受 UDP 影响,不能据此判断受击方向。
|
||||
|
||||
早期版本会在命中时晃动画面和瞄准器。该效果会干扰观察,也容易被误认为车体晃动,因此已移除。
|
||||
|
||||
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
|
||||
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
|
||||
面板里也没有这个开关了。
|
||||
|
||||
## 裁判端
|
||||
|
||||
### 队名可以直接改
|
||||
|
||||
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
|
||||
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
|
||||
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
|
||||
|
||||
Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合并空白,按字符截断到 12 个字,留空时使用「队伍一 / 队伍二」。输入框也限制长度,避免提交后才截断;按字符处理可避免截断中文的字节序列。
|
||||
|
||||
`set_team_name(team, name)` 每次只修改一队。若提交整个队名数组,最多 200 ms 前的旧快照可能覆盖刚修改的另一队名称。命令仍经过配置解锁检查,避免新增不受检查的写配置入口;裁判端的 `Role::needs_password` 为假,现场编辑无需密码。
|
||||
|
||||
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
|
||||
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
|
||||
而且不会报错。
|
||||
|
||||
### 两格等宽
|
||||
|
||||
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
|
||||
|
||||
旧客户端的 `HorizontalLayout` 先按内容需求分配宽度,再分配剩余空间。两侧占位文字长度不同时,曾测得 55% / 45% 的宽度。现在占位文字绝对定位,两格使用相同的宽度规则。
|
||||
|
||||
### 槽位分配:修掉了一个真 bug
|
||||
|
||||
规则:
|
||||
|
||||
1. 还在线的槽位不换人;
|
||||
2. **候选里排除已经被另一个槽位占着的 id**;
|
||||
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
|
||||
4. 没有候选时保持槽位为空,避免两格重复显示同一块板。
|
||||
|
||||
旧实现只排除当前槽位原有的 id。一队掉线后,其槽位可能选中另一队仍在线的板卡,导致两格重复显示。规则 2 修复了这一问题,并有对应测试。
|
||||
|
||||
图传板按在线候选自动分配,装甲板按上报的 `team` 分配。若画面对应关系不符,或装甲板固件的队伍号烧反,可在设置中手动指定槽位。
|
||||
|
||||
### 装甲板发现列表
|
||||
|
||||
列表显示板卡数量、自报队伍、血量、距上次广播的时间、四路上报情况和槽位。裁判端未显示血量时,可据此区分板卡未上线、队伍号错误和上报不完整。
|
||||
|
||||
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
|
||||
只是旧的解码路径把它丢了。现在 `ArmorHealth::decode_from` 把它交出来。
|
||||
|
||||
### 截图与录制
|
||||
|
||||
- 截图存 PNG(`shots/` 下),文件名带板子 id 与毫秒时间戳。
|
||||
- 录制存 mp4,走 ffmpeg 子进程:`-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0`
|
||||
喂帧,x264 编码,`+faststart`。
|
||||
- `image2pipe` 的 JPEG 序列没有时间戳,需要显式向 ffmpeg 提供帧率。客户端优先使用配置值,其次使用实测值;都不可用时使用默认值并记录警告。该值写入日志与 DTO,回放速度异常时应先核对它。
|
||||
- 帧经长度为 64 的队列交给独立写盘线程,避免阻塞接收。队列写入失败时丢帧并计数,界面显示该计数。
|
||||
- 开始新录制前先结束上一段,并检查文件名是否重复;重名时追加 `-2`、`-3`,避免 ffmpeg 的 `-y` 覆盖已有录制。
|
||||
|
||||
界面内回放使用 Tauri 资源协议,只能访问 `tauri.conf.json` 中 `assetProtocol.scope` 允许的目录。默认录制目录在范围内;其他目录中的 `<video>` 加载失败时,可选择“用系统播放器打开”。
|
||||
|
||||
## 配置
|
||||
|
||||
`~/.config/cam-desktop/config.toml`,`--config` 可改。**目录名沿用旧的 `cam-desktop`**(原因见开头)。要干净重来就删掉这个目录。
|
||||
|
||||
```toml
|
||||
role = "team"
|
||||
roleChosen = true
|
||||
team = 0
|
||||
teamNames = ["队伍一", "队伍二"]
|
||||
cameraId = "0x0DE00001"
|
||||
armorId = "0x0A200001"
|
||||
mirror = false
|
||||
password = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
|
||||
[recording]
|
||||
dir = "/Users/you/Movies/cam-desktop"
|
||||
ffmpeg = "ffmpeg"
|
||||
crf = 20
|
||||
preset = "veryfast"
|
||||
|
||||
[hud.crosshair]
|
||||
style = "cross-dot"
|
||||
size = 0.05
|
||||
offsetX = 0.0
|
||||
```
|
||||
|
||||
配置约定:
|
||||
|
||||
- 使用 serde + toml 处理 HUD 嵌套结构、密码哈希和录制参数,替代旧客户端针对四个标量字段的手写解析。
|
||||
- 未知键名报错(`deny_unknown_fields`),避免拼写错误被静默忽略。
|
||||
- 磁盘、IPC 与前端 TS 的字段名统一为 camelCase,减少字段转换遗漏。
|
||||
- 先写入同目录临时文件再 `rename`,避免中断写入导致配置或密码哈希不完整。
|
||||
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
|
||||
- `teamNames` 放在配置根层级,供用户端和裁判端共用。
|
||||
- `roleChosen` 单独记录是否选过模式。`role` 默认是裁判端,仅靠该字段无法判断是否需要显示首次选择页面。
|
||||
|
||||
首次启动时,客户端会导入旧版(Slint)配置中可对应的字段,并将旧文件改名为 `.bak`,供排查时核对。该迁移仅适用于默认路径;用 `--config` 指定其他路径时不修改常规配置。
|
||||
|
||||
## 浏览器预览(仅开发)
|
||||
|
||||
开发时可在浏览器中预览同一套 React 组件,无需启动 Tauri 后端。页面没有 Tauri 注入时,由 `src/lib/dev-preview.ts` 合成状态与画面。
|
||||
|
||||
```sh
|
||||
pnpm dev # 用户端
|
||||
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
|
||||
open 'http://127.0.0.1:1420/?preview=first-run' # 首启的身份选择
|
||||
open 'http://127.0.0.1:1420/?preview=password' # 首启的设密码
|
||||
```
|
||||
|
||||
后两个参数强制显示对应的首次启动页面,便于检查界面,无需删除配置文件。
|
||||
|
||||
预览要求 `import.meta.env.DEV` 为真且没有 `__TAURI_INTERNALS__`。模块通过 `await import(...)` 动态加载,正式构建中不包含它。
|
||||
|
||||
## 测试
|
||||
|
||||
```sh
|
||||
cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
||||
cd desktop/src-tauri && cargo test # 40:帧头布局、录制、截图、演示画面、命令行
|
||||
cd protocol && cargo test # 23
|
||||
```
|
||||
|
||||
前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复、设备重启和旧帧释放。它不替代实际 WebView 的画面验证。
|
||||
|
||||
测试覆盖的关键行为:
|
||||
|
||||
- 录制测试启动 ffmpeg,写入 20 帧并停止,再用 `ffprobe` 检查帧数和分辨率,避免只检查文件存在而漏掉空容器。
|
||||
- 截图测试写入 PNG 后读回尺寸,并检查无效输入会报错且不留下空文件。
|
||||
- 帧率窗口测试验证帧间隔平均值的计算结果(约 30 fps),防止算法回退。
|
||||
|
||||
CSP 验证需要检查前端能否连接后端。前端先通过 `invoke` 获取通道地址,再连接 WebSocket;由前端连接触发的快照 `clients` 从 0 变为 1,可用于确认 JS 加载、IPC、WebSocket 和 React effect 均已执行。手动连接检查如下:
|
||||
|
||||
```sh
|
||||
RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
||||
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1
|
||||
```
|
||||
|
||||
## 线程与数据流
|
||||
|
||||
| 线程 | 做什么 | 为什么单独 |
|
||||
|---|---|---|
|
||||
| `udp-reader` | 收包、解析、按发送方重组 | 不能让解码/写盘挤进数据报之间 |
|
||||
| `armor-reader` | 收 5601 上的血量广播 | 与视频是两个端口、两种节奏 |
|
||||
| `rec-writer-<id>` | 把队列里的帧写进 ffmpeg 的 stdin | 接收线程绝不能被写盘拖慢 |
|
||||
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
|
||||
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
|
||||
|
||||
帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。
|
||||
视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。
|
||||
前端每路最多保留一次正在进行的解码和一张最新待解码帧。
|
||||
|
||||
每张本地 JPEG 前增加 `frameTiming` 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。
|
||||
|
||||
## 已知限制与没做的
|
||||
|
||||
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
|
||||
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
|
||||
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
|
||||
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
|
||||
DTO,所以至少能解释清楚。
|
||||
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
|
||||
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
|
||||
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端",
|
||||
见"测试"一节最后一条。
|
||||
- **证书与公证**没做,`.dmg` 只在本机可用。
|
||||
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
|
||||
|
||||
## 排查
|
||||
|
||||
需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:
|
||||
|
||||
```powershell
|
||||
.\RMIC-2026.exe --config .\referee.toml --latency-report .\latency-run.ndjson
|
||||
```
|
||||
|
||||
报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 `VideoSurface` 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。
|
||||
|
||||
`pnpm test:latency` 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。
|
||||
|
||||
| 现象 | 原因 |
|
||||
|---|---|
|
||||
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
|
||||
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
|
||||
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
|
||||
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
|
||||
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
|
||||
| 改了队名,某一处还是旧名字 | 队名应统一来自快照 `teamNames`,血条使用 `name`。检查是否有组件自行拼接默认队名 |
|
||||
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
|
||||
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
|
||||
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
|
||||
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
|
||||
| 无法开始录制 | 检查 ffmpeg 是否在 PATH 中,或在设置中指定路径。启动失败时界面会显示错误 |
|
||||
| 两路画面与队伍对应不符 | 在设置中核对图传板槽位,必要时手动指定;装甲板队伍号由编译时的 `ARMOR_TEAM` 决定 |
|
||||
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
|
||||
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# 开发与验证
|
||||
|
||||
编译入口见 [README.md](README.md),硬件限制见 [HARDWARE.md](HARDWARE.md),历史变化见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
编译入口见 [固件入门](../setup/firmware.md),硬件限制见 [图传板硬件](../hardware/camera.md),历史变化见 [改动记录](../releases/changelog.md)。
|
||||
|
||||
## 镜像导出
|
||||
|
||||
@@ -48,7 +48,7 @@ rustc +stable --edition 2021 --test src/stream_control.rs -o target/stream-contr
|
||||
.\target\stream-control-tests.exe
|
||||
```
|
||||
|
||||
实际持续运行的串口与客户端通道记录方法见 [STABILITY.md](STABILITY.md)。主机策略测试不等于已经触发板上过温减载。
|
||||
实际持续运行的串口与客户端通道记录方法见 [持续运行记录](../testing/stability.md)。主机策略测试不等于已经触发板上过温减载。
|
||||
|
||||
## 清理构建缓存
|
||||
|
||||
@@ -70,13 +70,13 @@ cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys
|
||||
- 新客户端为每个接收器生成随机会话 ID;同一源 IP 下,同一会话换端口会更新推流目标。旧版公共固定 ID `0x0C110001` 保持按地址区分,避免合并同机旧客户端。
|
||||
- MAC 派生的 `sender_id` 不随 DHCP 地址变化,适合客户端持久绑定。
|
||||
- Hello 和视频都是设备在线证据;客户端列表清理与固件订阅使用不同超时。
|
||||
- 数据报为 27 字节头 + 最多 1400 字节载荷,采用小端编码;字段及拒收条件见 [protocol/README.md](protocol/README.md)。
|
||||
- 数据报为 27 字节头 + 最多 1400 字节载荷,采用小端编码;字段及拒收条件见 [UDP 协议](../protocol/udp.md)。
|
||||
|
||||
缺失分片不重传,不完整帧整体丢弃。JPEG 由传感器编码,桌面后端转交原始 JPEG,由 WebView 解码,避免接收线程进行耗时编解码。
|
||||
|
||||
## 性能测量
|
||||
|
||||
本次对照测试在保留 TUN 的环境中进行,最终参数与验证边界见 [PERFORMANCE.md](PERFORMANCE.md)。
|
||||
本次对照测试在保留 TUN 的环境中进行,最终参数与验证边界见 [性能排查](../testing/performance.md)。
|
||||
|
||||
下表为原 README 保存的**历史单次测试数据**,不是本次构建实测或性能保证。配置为 OV5640、VGA;原始日志、时长和完整网络条件未随这些数字保存。
|
||||
|
||||
@@ -97,7 +97,7 @@ FPS 应按**同一时间窗口的帧数增量 / 窗口秒数**计算。累计帧
|
||||
|
||||
固件日志将采集尝试与完整提交分开计数。`submitted` 只表示全部分片交给了网络,不代表无线确认或客户端显示;`send failures` 可能包含随后重试成功的调用,应结合 `peer frames dropped` 和接收端指标判断。
|
||||
|
||||
画面链路测量使用裁判端 `--latency-report <新文件>`,让相机拍摄校准页左侧标记,固定相机和窗口位置。标记经正常采集、网络与绘制路径回到客户端,源与回显共用主机单调时钟;不需要 ESP 时钟同步。测试期间只保留一个原生客户端,额外的本地 WebSocket 观察器不增加 UDP 订阅。方法、刷新估计边界及最新结果见 [LATENCY.md](LATENCY.md)。
|
||||
画面链路测量使用裁判端 `--latency-report <新文件>`,让相机拍摄校准页左侧标记,固定相机和窗口位置。标记经正常采集、网络与绘制路径回到客户端,源与回显共用主机单调时钟;不需要 ESP 时钟同步。测试期间只保留一个原生客户端,额外的本地 WebSocket 观察器不增加 UDP 订阅。方法、刷新估计边界及最新结果见 [延迟测量](../testing/latency.md)。
|
||||
|
||||
按记录文件的半开行号区间复算延迟:
|
||||
|
||||
@@ -118,7 +118,7 @@ Windows 为输出文件加 `.exe`。实机故障注入仅在诊断构建设置 `
|
||||
|
||||
## 板上验证
|
||||
|
||||
阻塞与缓冲交接测试:`python scripts/test-latest-frame.py` 使用主机 clang/gcc 编译真实 `cam_take_latest` 函数及队列替身,验证持续生产下有限取帧、旧缓冲仅归还一次、无新帧保留有效旧帧,以及首次超时不泄漏。`rustc +stable --test src/stream_control.rs` 验证发送期限、共享重试预算和零重试模式;`desktop/` 的 `pnpm test:stream` 验证 WebView 积压、解码阻塞及过期帧清理。实机验证范围另见 [REALTIME.md](REALTIME.md)。
|
||||
阻塞与缓冲交接测试:`python scripts/test-latest-frame.py` 使用主机 clang/gcc 编译真实 `cam_take_latest` 函数及队列替身,验证持续生产下有限取帧、旧缓冲仅归还一次、无新帧保留有效旧帧,以及首次超时不泄漏。`rustc +stable --test src/stream_control.rs` 验证发送期限、共享重试预算和零重试模式;`desktop/` 的 `pnpm test:stream` 验证 WebView 积压、解码阻塞及过期帧清理。实机验证范围另见 [最新帧处理](../testing/realtime.md)。
|
||||
|
||||
1. 相机识别、PSRAM 初始化及稳定采集。
|
||||
2. Wi-Fi 完成关联和 DHCP,得到有效 IP。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 版本维护
|
||||
|
||||
版本号与改动记录由 `scripts/version.mjs` 从 Git 历史计算。以下命令从仓库根目录执行:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs --write
|
||||
node scripts/version.mjs --check
|
||||
node scripts/version.mjs --print
|
||||
```
|
||||
|
||||
`--write` 写回派生文件,`--check` 检查当前版本是否与历史一致,`--print` 输出计算结果。
|
||||
|
||||
## 提交与版本规则
|
||||
|
||||
提交标题采用 Conventional Commits:`type(scope): 说明`。
|
||||
|
||||
| 当前区间内的提交 | 版本增量 |
|
||||
| --- | --- |
|
||||
| `BREAKING CHANGE:` 或 `type!:` | major |
|
||||
| `feat` | minor |
|
||||
| `fix` / `perf` | patch |
|
||||
| 其他类型 | 版本号不变,更新构建号 |
|
||||
|
||||
构建号取 `git rev-list --count HEAD`。提交正文中以 `- ` 开头、且内容符合 `type(scope): 说明` 的行,也会作为独立条目归类,适合记录 squash 提交中的多项改动。历史发布按标签区间重新计算,不从上一次生成的文件续写。
|
||||
|
||||
## 派生文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `version.json` | 根固件的 `build.rs` 读取,编译期注入 `RMIC_VERSION*` |
|
||||
| `docs/releases/changelog.md` | 供人阅读的改动记录 |
|
||||
| `docs/releases/changelog.json` | 供程序读取的当前区间记录 |
|
||||
| `desktop/src/lib/version.ts` | UI 版本标签与线路协议号 |
|
||||
| `desktop/package.json`、`desktop/src-tauri/tauri.conf.json` 的 `version` | 安装包命名与系统中的应用版本 |
|
||||
|
||||
这些文件由同一次计算生成;改动记录应修改提交信息或生成脚本,再运行 `--write`。自动版本机制建立前的说明保存在[早期人工记录](../releases/changelog-legacy.md)。
|
||||
|
||||
## 固件与客户端的版本对应
|
||||
|
||||
版本沿 `git → version.json → 固件二进制 → Hello 报文 → 客户端界面` 传递。图传板每 2 秒广播语义化版本、构建号和编译时的线路协议号,布局见[UDP 协议](../protocol/udp.md#通告里的版本信息)。
|
||||
|
||||
设置中的绑定列表显示固件版本,裁判端在各路画面下方显示对应版本。线路协议不匹配时,客户端拒收视频并提示双方协议号,便于判断是否需要重烧固件或更换客户端。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
||||
id,category,priority,precondition,steps,expected,pass_criteria,evidence,tools,est_minutes
|
||||
"N-01","正常","P0","健康板;新模板;工具版本锁定","分段写 Bootloader/分区表/App;逐段 verify;复位;串口与 UDP 验收","3 段 verify OK;启动日志正常;15 s 内收到完整帧","verify 全通过;无 panic/brownout;perf 帧率>=15 fps 且丢帧<=5%","verify 日志;serial.log;perf.json","esptool;perf_probe;serial_soak","20"
|
||||
"N-02","正常","P0","健康板;已 provision;同布局","只写新 App;verify;复位;检查 config 保留","App 版本更新;原 SSID 配置仍生效","App hash 与 manifest 一致;config seq 不变;联网正常","verify;serial;perf","esptool;perf_probe","15"
|
||||
"N-03","正常","P0","健康板;已 provision;模板支持 config","只 patch config(换 SSID);不写 App;复位","新 SSID 生效;App SHA-256 不变","app-readback SHA-256 与 manifest 一致;serial 显示 config source=partition;联网正常","app hash;serial;perf","esptool;provision.py;perf_probe","10"
|
||||
"N-04","正常","P1","健康板","连续 provisioning 3 次(相同输入)","幂等;无 brick;报告一致","3/3 通过;设备身份一致;无重复写入错误","3 份 result.json","provision.py","20"
|
||||
"N-05","正常","P1","健康板;归档镜像","写 merged image 并 verify(对照)","记录是否通过;不用于坏板","结果记录;不改变坏板策略","verify 日志","esptool","10"
|
||||
"B-01","坏板","P0","故障板;E1 备份已验证","牺牲板 E2 写读表征;生产板定向读 0xFADE7/0x10863 + 扫描模板写入区","E2 复现缺陷;生产板地图正确;恢复 verify","至少识别两个已知地址;恢复后关键区 verify OK","scan.json;verify;backup hash","esptool;provision.py","30"
|
||||
"B-02","坏板","P0","故障板;新布局;模板镜像","分段写新 App/表;verify;复位","写入区不含坏地址;App 实际镜像 verify OK;启动","3 段 verify OK;serial live;UDP 有帧","verify;serial;perf","esptool;perf_probe","25"
|
||||
"B-03","坏板","P0","故障板;强制 App 槽包含坏字节","写实际镜像;读回比较","若镜像字节不可写则 verify 失败;工具换槽或拒绝;不 brick","失败可定位到地址;设备仍可进下载模式;报告不报成功","verify;scan;report","esptool;provision.py","25"
|
||||
"B-04","坏板","P0","故障板;config 一个槽含坏扇区","写 config 多副本;复位","跳过坏槽;至少一个副本有效;联网","serial 显示有效 seq;报告记录 skipped 槽;App hash 不变","scan;serial;report","esptool;provision.py","20"
|
||||
"B-05","坏板","P1","故障板;merged 镜像","写 merged image(对照)","预期失败或高风险;工具默认禁止","记录失败地址/错误;后续流程仍走分段","verify 日志;report","esptool","15"
|
||||
"B-06","坏板","P1","故障板;疑似间歇坏字节","同一扇区扫描 3 次;不同温度/供电","标记 SUSPECT;隔离;不用于 App","3 次结果一致或明确记录间歇;SUSPECT 不进 App 槽","scan.json","esptool;provision.py","30"
|
||||
"B-07","坏板","P2","故障板;全片备份","全片扫描 + 恢复","得到全片地图;恢复关键区;不丢数据","恢复后关键区 verify OK;时间记录","scan.json;backup hash","esptool","60"
|
||||
"O-01","OTA","P1","OTA-ready;Bootloader rollback","写 ota_0;切换;复位","新版本启动;分区日志正确","serial 显示 ota_0;版本正确;联网","serial;otatool","otatool.py;perf_probe","30"
|
||||
"O-02","OTA","P1","OTA-ready;当前槽有效","写损坏镜像/错误 SHA;复位","Bootloader 拒绝;旧槽仍启动","旧版本启动;无 brick;serial 有拒绝原因","serial","otatool.py;esptool","20"
|
||||
"O-03","OTA","P1","OTA-ready;USB 继电器","OTA 写入 25/50/75% 时断电;恢复","至少一个槽可启动;无需整片擦","3 个断点均能启动;版本/分区符合预期","serial;relay log","otatool.py;relay","45"
|
||||
"O-04","OTA","P1","OTA-ready;目标槽含坏区","OTA 到坏槽;复位","安全失败;旧槽不受影响","旧版本启动;无 brick;报告记录坏槽","serial;scan","otatool.py;esptool","25"
|
||||
"O-05","OTA","P2","启用 anti-rollback","OTA 降级;复位","按策略拒绝或接受;不 brick","策略与日志一致;设备可启动","serial","otatool.py","20"
|
||||
"H-01","热插拔","P0","1 块板;驱动已装","插拔 20 次;记录发现时间","20/20 发现;P95<=2 s","无漏检;无端口残留;无错板","discovery.json","serialport;provision.py","20"
|
||||
"H-02","热插拔","P0","2 块板同时插入","按 MAC 选择;执行 patch/verify","0 次错板;端口/MAC 正确","两块设备报告 MAC 与操作一致","report","serialport;provision.py","20"
|
||||
"H-03","热插拔","P1","写入进行中","拔掉 USB","报错;设备可恢复;不报成功","错误码明确;重插后可进下载模式","log;report","provision.py","15"
|
||||
"H-04","热插拔","P1","扫描进行中","拔掉 USB","报错;备份可恢复;不半写成功","重插后恢复;关键区 verify OK","log;backup","provision.py","20"
|
||||
"H-05","热插拔","P1","串口监视器占用端口","执行 provisioning","明确端口占用;不挂死","错误码/提示;进程退出","log","provision.py;espflash monitor","10"
|
||||
"H-06","热插拔","P0","错固件/错芯片","选择不兼容 manifest","写入前拒绝;设备不变","App hash 前后一致;错误码 3","report","provision.py","10"
|
||||
"P-01","断电","P0","健康板/坏板;可恢复","App 写入 50% 断电;上电;重刷","可进下载模式;重刷后启动;不 brick","BOOT 恢复;verify OK;serial live","serial;verify","esptool;relay","20"
|
||||
"P-02","断电","P0","config 已有一个有效副本","config 写入时断电;上电","旧副本有效;启动","serial 显示旧 seq;联网正常","serial","esptool;relay","15"
|
||||
"P-03","断电","P1","分区表迁移中","分区表写入时断电;上电;重刷","BOOT 恢复;重刷表/App;不 brick","可进下载模式;重刷后启动","serial;verify","esptool;relay","25"
|
||||
"P-04","断电","P2","Bootloader 更新中(最高风险)","Bootloader 写入时断电;上电;BOOT 恢复","可恢复;不 permanent brick","BOOT 可进下载;重刷后启动","serial;verify","esptool;relay","30"
|
||||
"P-05","断电","P2","扫描/恢复中","拔 USB/断电;恢复","备份可用;关键区不丢","关键区 verify OK;无半写成功","backup hash","esptool","30"
|
||||
"S-01","安全","P0","正常 provision","检查日志、报告、命令行历史","无密码明文;只有 SSID/hash/seq","grep 不到密码;报告字段合规","grep;report","provision.py","10"
|
||||
"S-02","安全","P1","Secure Boot/加密板(如有)","espefuse summary;尝试未签名 patch","检测 eFuse;拒绝未签名/未加密 patch","工具退出码 10;设备不变","espefuse 输出;report","espefuse;espsecure","20"
|
||||
"S-03","安全","P1","篡改 manifest/App","校验 SHA-256 后烧录","拒绝烧录","错误码 3;设备不变","report","provision.py","10"
|
||||
"S-04","安全","P2","伪造 VID/PID 设备","主动探测 chip/MAC/Flash","识别不匹配;拒绝","不写入;报告记录","report","serialport;esptool","20"
|
||||
"R-01","回归","P0","host 环境","pytest 全部 host tests","100% 通过","0 failed;覆盖 config/分区/manifest/选择逻辑","JUnit/HTML","pytest","10"
|
||||
"R-02","回归","P1","硬件在环","20 次循环(N/B/H 子集)","首次通过率>=95%;0 brick;0 错板","统计达标;失败可定位","summary.json","pytest;provision.py","120"
|
||||
"R-03","回归","P0","锁定工具版本","重复执行同一命令 3 次","同输入同命令/同结果","版本/哈希/输出一致","versions.txt;report","all","15"
|
||||
"R-04","回归","P1","报告 schema","用 jsonschema 校验 result/scan/manifest","全部通过","0 schema error","schema 校验日志","pytest;jsonschema","10"
|
||||
"M-01","迁移","P1","旧布局 0x70000 板","备份;写新 App→新表→可选 Bootloader;保留 NVS/PHY;复位","新布局启动;NVS/PHY 保留;config 可用","serial live;MAC 不变;Wi-Fi 校准未丢","serial;report","esptool;provision.py","30"
|
||||
"M-02","迁移","P0","旧布局板","尝试只 patch config","工具拒绝并提示迁移","错误码 3/提示明确;设备不变","report","provision.py","10"
|
||||
|
@@ -0,0 +1,323 @@
|
||||
# 装甲板(ESP32-C3 / Rust / ESP-IDF)—— 一主三从,与 RMIC::2026 桌面端联动
|
||||
|
||||
每车有前、后、左、右四块装甲板,只有主板含 MCU。其余三块从板各有一路 WS2812 和一路压电片,直接接线到主板。主板负责四路受击检测、四路灯带和整车血量,并向 RMIC::2026 桌面端广播。
|
||||
|
||||
固件使用 Rust + ESP-IDF,目标芯片为 ESP32-C3(RISC-V)。以下构建、烧录与探测命令从仓库根目录执行;主机测试按示例切换目录。
|
||||
|
||||
```text
|
||||
armor/
|
||||
├── Cargo.toml workspace:两个 crate,各自独立构建
|
||||
├── armor-protocol/ 规则层 + 线路格式(纯计算,主机上 22 个单元测试)
|
||||
├── armor-firmware/ 固件:ADC / RMT / NVS / 按键 / WiFi+UDP
|
||||
│ ├── src/leds.rs 四路 WS2812(单 RMT 通道轮流切 GPIO)
|
||||
│ ├── src/piezo.rs 四路压电片(40 kHz ADC 连续采样)
|
||||
│ ├── src/net.rs UDP 广播 ArmorHealth
|
||||
│ ├── src/settings.rs NVS:队伍与颜色
|
||||
│ └── sdkconfig.defaults / partitions.csv
|
||||
└── .cargo/config.toml riscv32imc-esp-espidf + MCU=esp32c3
|
||||
```
|
||||
|
||||
`armor-protocol` 负责受击判定、扣血、灯色和按键规则,可通过 `Duration` 模拟时间来测试。压电片尖峰难以按需复现,因此把规则与外设操作分开,固件侧负责连接外设。
|
||||
|
||||
## 接线
|
||||
|
||||
| 功能 | GPIO | 说明 |
|
||||
| --- | --- | --- |
|
||||
| WS2812 前 | IO10 | 每条灯带 3 颗灯珠,GRB、MSB first |
|
||||
| WS2812 后 | IO5 | |
|
||||
| WS2812 左 | IO6 | |
|
||||
| WS2812 右 | IO7 | |
|
||||
| 压电片 前 | GPIO0(ADC1_CH0) | 0 dB 衰减、12 bit |
|
||||
| 压电片 后 | GPIO4(ADC1_CH4) | |
|
||||
| 压电片 左 | GPIO3(ADC1_CH3) | |
|
||||
| 压电片 右 | GPIO1(ADC1_CH1) | |
|
||||
| BOOT 键 | GPIO9 | 低有效,内部上拉 |
|
||||
| 调试串口 UART0 TXD | GPIO21 | 115200 8N1,日志从这里出来(主控制台) |
|
||||
| 调试串口 UART0 RXD | GPIO20 | 目前空闲,留给以后的命令通道 |
|
||||
|
||||
引脚与参考实现一致(灯:前 10 / 后 5 / 左 6 / 右 7;压电:前 0 / 后 4 / 左 3 / 右 1)。UART0 作为主控制台,日志从丝印 RXD/TXD 对应的调试口输出。
|
||||
|
||||
`sdkconfig.defaults` 还将 USB Serial/JTAG 设为次控制台(`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`)。未连接 USB-TTL 时,也能从板上原生 USB 口读取应用日志;仅配置 UART0 则只能在原生 USB 上看到 ROM 启动信息。
|
||||
|
||||
## 规则
|
||||
|
||||
| | 值 | 在哪 |
|
||||
| --- | --- | --- |
|
||||
| 整车血量 | 100 | `Rules::max_hp` |
|
||||
| 每次受击扣血 | 3 | `Rules::damage_per_hit` |
|
||||
| 受击后变暗 | **只有被打的那一路**全黑 50 ms,其余三路不动 | `Rules::hit_blink` + `Armor::strip` |
|
||||
| 同一板位重复判定抑制 | 50 ms | `Rules::hit_cooldown` |
|
||||
| 受击阈值 | 原始 12 bit ADC > 2000(`ARMOR_THRESHOLD` 可改) | `Rules::threshold` |
|
||||
| 血量归零 | 四路全黑,直到复位 | `Armor::strip` |
|
||||
| 正常颜色 | 队伍一红(`0xFF0000`)、队伍二蓝(`0x0000FF`) | `Rgb::for_team` |
|
||||
| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` |
|
||||
| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` |
|
||||
|
||||
**整车一条血量。** 板子算账、裁判端只显示:四条上报的 `hp` 相同,`index` 分别是 0..3,
|
||||
界面按 `(team, index)` 收齐四条后显示同一个数值。
|
||||
|
||||
参考实现使用全局 50 ms 冷却,一路受击会抑制其余路在该窗口内的击打。当前四路分别冷却,由 `cooldown_is_per_plate_not_global` 测试验证。
|
||||
|
||||
## 上报(UDP)
|
||||
|
||||
主板每 200 ms 广播四条 `protocol::ArmorHealth` 到 `255.255.255.255:5601`:
|
||||
|
||||
```text
|
||||
magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index(1) | hp(2)
|
||||
```
|
||||
|
||||
- **血量一变立刻发**,没变就按 200 ms 心跳重发。裁判端靠心跳判断板子还在线,一次
|
||||
丢包不该让界面退回"未接入"。
|
||||
- `sender_id` 用 MAC 派生的板子 id(`protocol::id_from_mac`),不是 0 —— 0 是裁判
|
||||
终端的保留 id,抓包时一块装甲板发的包不该长得像裁判端发的。
|
||||
- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。
|
||||
- 广播而不是单播:桌面端电脑的 IP 是 DHCP 给的,会变。图传板用 Hello 广播绕开这件事,
|
||||
装甲板沿用同一个约定。
|
||||
|
||||
### 桌面端怎么收
|
||||
|
||||
`desktop/core/src/armor.rs` 里的 `ArmorHealthStore`(两种身份都启动):
|
||||
|
||||
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
|
||||
- **四条都在 1.5 s 内收到**才交给界面显示;否则回退占位符。心跳 200 ms,1.5 s 是连丢
|
||||
7 个包,偶发丢包不会让读数来回跳。
|
||||
- 用户端也要显示双方血条,所以两端都会占 5601。
|
||||
- 血量变化会打日志(`armor: board 0x… team 0 hp 100 -> 97`):窗口是给别人看的,
|
||||
抓日志的人也得能看见掉血。
|
||||
|
||||
**界面显示血量数字。** `TeamHealthDto` 带 `hp` 与 `critical`:数值就在队伍名旁边,低于
|
||||
30 或归零时它与血条一起转红。归零**不写"已击毁"**——那一刻图传中央会给出
|
||||
“胜利 / 落败”的结论(见 [桌面客户端](../desktop/client.md))。
|
||||
|
||||
## 构建
|
||||
|
||||
凭据与队伍号走 `.env`(与图传固件共用同一个文件):
|
||||
|
||||
```bash
|
||||
# .env
|
||||
WIFI_SSID=your-ssid
|
||||
WIFI_PASS=your-password
|
||||
ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
|
||||
ARMOR_THRESHOLD=2000 # 受击阈值(原始 12 bit ADC),看日志里的 peak 来定
|
||||
|
||||
scripts/build_armor.sh # 编译
|
||||
ARMOR_TEAM=1 scripts/build_armor.sh # 临时当队伍二编译
|
||||
ARMOR_THRESHOLD=1200 scripts/build_armor.sh # 换受击阈值(看日志里的 peak 来定)
|
||||
```
|
||||
|
||||
产物:`armor/target/riscv32imc-esp-espidf/release/armor`。
|
||||
|
||||
`ARMOR_TEAM` 是**编译期**注入的:换队伍重刷固件即可,不用先擦 NVS。NVS 里另外记着
|
||||
`team` 与 `color`(命名空间 `armor`,与参考实现同名),两者不一致时以 `ARMOR_TEAM`
|
||||
为准并覆盖,日志里会说一声。
|
||||
|
||||
本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` /
|
||||
`ESP_IDF_TOOLS_INSTALL_DIR`),不需要先 `activate` ESP-IDF。删掉那两行就交给
|
||||
esp-idf-sys 自己下载它钉住的版本。
|
||||
|
||||
### 用的是 ESP-IDF v6.0.1,不是图传板那套 v5.5.5
|
||||
|
||||
这台机器上有两套 ESP-IDF:
|
||||
|
||||
| | 路径 | 工具链 |
|
||||
| --- | --- | --- |
|
||||
| v6.0.1(eim 装的) | `/Users/peng/.espressif/v6.0.1/esp-idf` | Xtensa + **RISC-V(`esp-15.2.0_20251204`)** |
|
||||
| v5.5.5(图传板在用) | `/Users/peng/.espressif/esp-idf/v5.5.5` | 只有 Xtensa |
|
||||
|
||||
C3 是 **RISC-V**,而 v5.5.5 那套缺 `riscv32-esp-elf`(它要的是 `esp-14.2.0_20260121`,
|
||||
本机没有,而 GitHub release 又下不动)。所以装甲板固件指向 v6.0.1 —— 它的工具链齐全,
|
||||
且 `esp-idf-sys` 0.38.1 支持的版本范围是 4.4.4+、5.0–5.5、**6.0–6.2**。
|
||||
|
||||
两套 IDF 共存没有冲突:各自 `.cargo/config.toml` 里的 `IDF_PATH` 是每套 crate 自己钉的。
|
||||
|
||||
`ESP_IDF_TOOLS_INSTALL_DIR` 默认 `global`(即 `~/.espressif`,工具所在处),但**没有**加
|
||||
`force`:沙箱或 CI 里可以用 `ESP_IDF_TOOLS_INSTALL_DIR=custom:<可写目录>` 覆盖(工具目录
|
||||
不可写时把 `tools/` 软链到已装好的工具即可,Python venv 会建在那个可写目录里)。
|
||||
|
||||
### 主机侧测试
|
||||
|
||||
规则层在 PC 上跑,不需要板子:
|
||||
|
||||
```bash
|
||||
cd armor/armor-protocol && cargo test # 22 个测试
|
||||
cd protocol && cargo test # 线路格式往返(含 ArmorHealth)
|
||||
```
|
||||
|
||||
(`armor-protocol/.cargo/config.toml` 把 target 覆盖回 host:它坐在 `armor/` 目录树
|
||||
里,否则会继承 RISC-V 目标,链接必然失败。这与 `protocol/`、`desktop/` 的做法一致。
|
||||
换到其他宿主平台时,配置里钉住的 `aarch64-apple-darwin` 不适用,按
|
||||
[开发与验证](../development/firmware.md) 用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。)
|
||||
|
||||
## 烧录与实测
|
||||
|
||||
```bash
|
||||
scripts/flash_armor.sh # 自动找串口,烧完看 20 秒日志
|
||||
ESP_PORT=/dev/cu.usbmodem1101 scripts/flash_armor.sh
|
||||
scripts/flash_armor.sh --monitor 60 # 看 60 秒
|
||||
scripts/flash_armor.sh --no-monitor # 只烧不抓
|
||||
```
|
||||
|
||||
烧的是 ELF(`espflash` 自己从 ELF 里读分区表与 bootloader),芯片 `esp32c3`。
|
||||
|
||||
不打开裁判端界面,也可以单独确认板子在广播、血量对不对:
|
||||
|
||||
```bash
|
||||
python3 scripts/armor_probe.py 15
|
||||
```
|
||||
|
||||
它手写协议常量(不依赖 Rust 侧代码),可用于独立检查线路格式,发现固件编码与脚本解析不一致的情况。
|
||||
|
||||
### 串口日志长什么样(实机抓的)
|
||||
|
||||
```text
|
||||
I (321) armor: pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7
|
||||
I (321) armor: pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1
|
||||
I (4864) esp_netif_handlers: sta ip: 192.168.1.117, mask: 255.255.255.0, gw: 192.168.1.1
|
||||
I (5034) armor: joined "SR-602-2G4" on 192.168.1.117
|
||||
I (5034) armor::net: reporting to 255.255.255.255:5601 over UDP
|
||||
I (5034) armor::net: this board's id is 0xA820D321, from MAC [98, A3, 16, 4C, 67, C8]
|
||||
I (6044) armor: status: hp=100 hits=0 dead=false plates[front=0 rear=0 left=0 right=0]
|
||||
peak[front=490 rear=395 left=456 right=475] thr=2000
|
||||
strips[front=#FF0000 rear=#FF0000 left=#FF0000 right=#FF0000]
|
||||
ap=SR-602-2G4 rssi=-36 ch=6 ip=192.168.1.117 reports=5 errs=0
|
||||
I (63104) armor: hit plate=front raw=4095 hp=1 (hits=33)
|
||||
I (63314) armor: hit plate=front raw=4095 hp=0 (hits=34)
|
||||
W (63314) armor: hp reached 0: all four strips stay dark until the next reset
|
||||
W (306504) armor: BOOT long press: hp reset to 100 (new round)
|
||||
```
|
||||
|
||||
(日志里的长行是这里折行显示的,实机是一行。)
|
||||
|
||||
## 实测(2026-10-14,ESP32-C3 devkit,MAC `98:a3:16:4c:67:c8`)
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 编译 | `cargo build --release` 通过;text 1,081,093 B,app 镜像 **1,082,848 B**,占 4 MB flash 的 26% |
|
||||
| 启动 | 无 panic/重启;状态行每秒一条 |
|
||||
| WiFi | 关联 `SR-602-2G4`(rssi −36,ch 6),DHCP 拿到 `192.168.1.117` |
|
||||
| 上报 | `scripts/armor_probe.py` 收到队伍一四条、约 5 包/秒、`sender_id 0xA820D321`、四格同值 |
|
||||
| 裁判端 | 旧客户端 `cam-client --role referee` 日志 `team 0 plate 0..3 is online (hp 100) via 192.168.1.117`,界面显示血量数字 |
|
||||
| 受击 | 敲前装甲板:`hit plate=front raw=2663..4095`,每次 −3(100→97→94…),只有 front 计数 |
|
||||
| 变暗 | 敲击瞬间**只有前那一路**全黑 50 ms,其余不变(现场确认) |
|
||||
| 死亡 | 34 击到 0;状态行 `dead=true strips[front=off rear=off left=off right=off]`,四路全黑 |
|
||||
| 复位 | BOOT 长按 → 板子 `reset to 100`,裁判端日志 `armor: team 0 hp 0 -> 100` |
|
||||
| 底噪 | 未敲击时四路 peak 约 380–510,阈值 2000 有明显余量 |
|
||||
| 未接线 | 后/左/右三路压电片与灯带本次未接(用户说明),代码按四路齐全写 |
|
||||
|
||||
### 旧客户端的血量显示问题
|
||||
|
||||
旧客户端已收到正确的 `hps`,但界面只显示绿/灰方块,没有绘制血量数值,归零后仍为绿色。修复后,队伍名旁显示血量数字,日志记录 `armor: board 0x… team 0 hp 100 -> 97`。
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 1. ESP32-C3 的 RMT 只有 96 个 symbol 的通道内存
|
||||
|
||||
C3 的 RMT 只有一个 block,总计 96 个 symbol,按 48 为单位分给两个 TX 通道。`mem_block_symbols = 64`
|
||||
会被向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 就会失败:
|
||||
|
||||
```text
|
||||
E rmt: rmt_tx_register_to_group(168): no free tx channels
|
||||
```
|
||||
|
||||
C3 只有 2 个 TX 通道,四路灯带无法各占一个。因此只创建一个通道,使用全部 96 个 symbol,再通过 `rmt_tx_switch_gpio()` 依次切换四个 GPIO。每帧为 3 珠 × 24 bit + reset,共 73 个 symbol,每路发送约 90 µs。
|
||||
|
||||
**为什么不用 `esp-idf-hal` 的 `TxChannelDriver`。** 它把 `rmt_channel_handle_t` 藏在
|
||||
结构体里(`as_ptr()` 是 `pub(crate)`),拿不到句柄就调不了 `rmt_tx_switch_gpio()`。
|
||||
所以 `leds.rs` 直接用 sys 绑定:通道、官方 copy encoder、transmit 都是 IDF 原生的那
|
||||
几个函数,用法与参考实现的 `led_strip_encoder.c` 一一对应。
|
||||
|
||||
参考实现每 10 ms 刷新四路同色灯带。这里四路颜色独立,只有内容变化时才执行 `disable → switch → enable → transmit`;每秒另做一次全量刷新,避免灯带因掉电、静电等丢帧后一直保持错误状态。
|
||||
|
||||
### 2. 压电片必须连续(DMA)采样
|
||||
|
||||
一次击打的波形只有几十微秒宽,oneshot 轮询容易错过尖峰,造成轻敲漏判。采样方式与参考实现一致:四通道合计 40 kHz,每通道 10 kHz;每帧为 10 ms、共 400 个采样点,主循环按帧处理。
|
||||
|
||||
`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 的约束是
|
||||
`A: Adc` 且 `O: AdcChannels<AdcUnit = A>`:`ADC1` 满足前者、`ADCU1` 满足后者,同一个
|
||||
类型参数凑不齐,四路 ADC1 通道拼不出来。所以 `piezo.rs` 同样直接用 sys 绑定,与参考
|
||||
实现逐行对应。
|
||||
|
||||
采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序
|
||||
轮转"这个假设。
|
||||
|
||||
### 3. 一次击打会被算成几十次
|
||||
|
||||
40 kHz 采样时,一次击打可使连续几十个采样点超过阈值。`Armor::on_sample` 在规则层用冷却窗口合并为一次受击,主机测试 `repeat_samples_inside_the_cooldown_are_one_hit` 覆盖这一行为。
|
||||
|
||||
### 4. 控制台放在 UART0
|
||||
|
||||
`sdkconfig.defaults` 里显式写了 `CONFIG_ESP_CONSOLE_UART_DEFAULT=y`:C3 的 ROM 默认
|
||||
控制台就是 UART0,引脚正好是 RXD=GPIO20 / TXD=GPIO21,与要求一致。换成 USB
|
||||
Serial/JTAG 输出只要把这一行换成 `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG`。
|
||||
|
||||
### 5. 应用分区给 2 MB
|
||||
|
||||
默认的 1 MB 单 app 槽对带 WiFi 的 Rust 固件太紧,`partitions.csv` 按参考实现给
|
||||
factory 划了 2 MB。
|
||||
|
||||
### 6. WiFi 省电关掉
|
||||
|
||||
省电模式可能让上报多等一个信标间隔,达到上百毫秒。装甲板使用车载供电,因此关闭省电以减少上报等待。主循环每 5 s 检查连接,断开后重连。
|
||||
|
||||
### 7. 判断"连上没有"要问 IDF,不要信 esp-idf-svc 的事件缓存
|
||||
|
||||
`BlockingWifi::is_connected()/is_up()/wait_netif_up()` 读的是 esp-idf-svc 自己维护的
|
||||
WiFi 事件状态。实测在 **IDF v6.0.1 + esp-idf-svc 0.53** 上它会停在"已连接":日志说
|
||||
一切正常、`get_ip_info()` 却是 `0.0.0.0`,而 IDF 的 `esp_wifi_sta_get_ap_info()` 明明
|
||||
说没关联。后果是 `join()` 假装连上了、看门狗永远不会重试,板子静默失联。
|
||||
|
||||
所以固件里一律用 IDF 的 API 判断:
|
||||
|
||||
- 关联:`esp_wifi_sta_get_ap_info()`
|
||||
- 地址:`esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")` + `esp_netif_get_ip_info()`
|
||||
|
||||
`join()` 失败只记录警告,不退出,断网时仍保持本地检测与灯带功能。看门狗每 5 s 检查关联与有效 IP,不满足时断开重连;重连等待 IP 最多 3 s,避免长时间阻塞受击处理。
|
||||
|
||||
### 8. 日志里"有没有连上"要说清楚
|
||||
|
||||
状态行长这样:
|
||||
|
||||
```text
|
||||
status: hp=0 hits=37 dead=true plates[front=37 rear=0 left=0 right=0]
|
||||
peak[front=504 rear=379 left=459 right=486] thr=2000
|
||||
strips[front=off rear=off left=off right=off]
|
||||
ap=SR-602-2G4 rssi=-29 ch=6 ip=192.168.1.117 reports=1745 errs=0
|
||||
```
|
||||
|
||||
- `peak[...]` 是**这一秒内每路 ADC 的峰值**,用来校准阈值:没敲时的底噪要明显低于
|
||||
阈值,正常敲击要明显高于它(实测底噪 ~400–500,敲击 2000–4095)。只报"有没有过阈值"
|
||||
没法判断阈值该调到哪。
|
||||
- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。
|
||||
- `ap=` / `ip=` / `reports=` 用于区分未联网和已发送但对端未显示的情况。
|
||||
|
||||
### 9. USB 控制台看不到复位后的头两秒
|
||||
|
||||
主控制台是 UART0,另外把 USB Serial/JTAG 配成了次控制台,所以插原生 USB 也能看日志。
|
||||
但复位时 USB 会重新枚举,**复位后最初约 1–2 秒的日志会丢**(我们实测错过过启动横幅)。
|
||||
需要完整启动日志时,通过 UART0 连接 USB-TTL。
|
||||
|
||||
## 与参考实现(`reference/armor`)的差异
|
||||
|
||||
| | 参考实现 | 这里 | 为什么 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主从链路 | ESP-NOW 配对,两块板对拷 | 无:三块从板只是直接接线的灯与传感器 | 按实际硬件(只有主板一颗 MCU) |
|
||||
| DBUS 透传 | UART0 收发 10 字节 DBUS,USB CDC 打 CSV | 不做 | 这套硬件没有 DBUS 需求;UART0 让给日志 |
|
||||
| 受击变暗 | 四条灯带一起全黑 50 ms | 只暗被打的那一路 | 每面独立灯带的意义就在这里 |
|
||||
| 冷却窗口 | 全局 50 ms | 每板独立 50 ms | 别块板的击打不该被吃掉 |
|
||||
| 灯带刷新 | 每 10 ms 无条件刷四遍 | 只在内容变化时刷 + 每秒强制全刷 | 四路独立控制后,省掉四轮 RMT 开销 |
|
||||
| 血量 | 只有击打计数,无血量 | 整车 100 血、每击 −3、0 血全黑,按 `ArmorHealth` 上报 | 与裁判端界面的血量显示对齐 |
|
||||
| 颜色切换 | BOOT 短按切红/蓝,存 NVS | 颜色由 `ARMOR_TEAM` 决定(红/蓝),短按打状态 | 队别不该靠现场按键切错 |
|
||||
| 按键 | 移植 OneButton 状态机(短按配对,长按切色) | 本地 FSM:长按复位血量、短按打状态 | 只有两种动作,且要能在主机上测 |
|
||||
| 上报 sender_id | —(不走 UDP) | MAC 派生 id | 0 是裁判终端保留 id |
|
||||
|
||||
## 还没做的
|
||||
|
||||
- **另外三路(后/左/右)的压电片与灯带还没接**(2026-10-14 实机只有前一路)。
|
||||
代码支持四路,接线后无需修改;四路同时受击与各路独立变暗仍未完成实板验证。
|
||||
- **UART0 RX(GPIO20)上的命令通道**:日志方向已经能用;反向的 `status` / `reset`
|
||||
命令还没接(当前复位走 BOOT 长按)。
|
||||
- **从板加 MCU 的方案**:如果以后从板也装单片机(走 UART 或无线回传),
|
||||
`armor-protocol` 里已经预留了板位枚举与上报格式,加一个 `slave` bin 即可。
|
||||
- **双击 / 多击**:`Button` 只实现短按与长按,参考实现里未使用的 OneButton 功能没有移植。
|
||||
- **下发血量**:当前板子本地算账(裁判端只显示)。若以后要裁判端权威(下发血量/复位),
|
||||
需要新增一条下行消息并在 `desktop` 里发。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 图传板硬件说明
|
||||
|
||||
对应 Freenove ESP32-S3-WROOM CAM、ESP32-S3-WROOM-1-N16R8 与 OV5640。构建和烧录入口见 [README.md](README.md)。
|
||||
对应 Freenove ESP32-S3-WROOM CAM、ESP32-S3-WROOM-1-N16R8 与 OV5640。构建和烧录入口见 [固件入门](../setup/firmware.md)。
|
||||
|
||||
## 摄像头引脚
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# RMIC::2026 文档
|
||||
|
||||
从使用步骤进入,再按问题查阅硬件、实现和测试记录。代码命令默认从仓库根目录执行;需要切换目录的地方会注明。
|
||||
|
||||
项目概览与架构图见 [README](../README.md),工具链、验证命令和仓库协作约定见 [AGENTS](../AGENTS.md)。
|
||||
|
||||
## 入门与使用
|
||||
|
||||
- [图传固件入门](setup/firmware.md):硬件要求、Rust / ESP-IDF 环境、参数、编译与烧录。
|
||||
- [桌面客户端](desktop/client.md):用户端与裁判端、配置、密码、录制、演示与浏览器预览。
|
||||
- [固件维护](maintenance/firmware.md):USB 更新、固件包、候选槽位、备份与校验。
|
||||
- [故障排查](testing/troubleshooting.md):工具链、网络、相机与图传问题。
|
||||
|
||||
## 开发与源码说明
|
||||
|
||||
- [开发与验证](development/firmware.md):镜像导出、主机测试、构建缓存与探测脚本。
|
||||
- [版本维护](development/versioning.md):Conventional Commits、版本计算与生成文件。
|
||||
- [图传板硬件](hardware/camera.md):引脚、PSRAM、分区、故障 Flash 与驱动补丁。
|
||||
- [装甲板](hardware/armor.md):接线、受击规则、上报、构建与实现要点。
|
||||
- [UDP 协议](protocol/udp.md):线路格式、版本通告、帧重组与拒收条件。
|
||||
|
||||
## 测试与排查记录
|
||||
|
||||
以下是特定配置、板卡和时间窗口的记录;不同轮次的数字不能直接视为同一配置的结果。
|
||||
|
||||
- [最新帧与阻塞恢复](testing/realtime.md):发送期限、本地积压清理及五分钟观察。
|
||||
- [功率、距离与拥塞](testing/power.md):15 dBm 回退及先前功率对照。
|
||||
- [帧率与延迟](testing/latency.md):约 30 fps 配置、Wi-Fi 重连和标记回显测量。
|
||||
- [持续运行](testing/stability.md):彩色、曝光、调度及历史长测。
|
||||
- [早期性能排查](testing/performance.md):保留 TUN 的短时对照与参数选择。
|
||||
|
||||
## 发布记录与提案
|
||||
|
||||
- [改动记录](releases/changelog.md):由 Git 历史生成;[JSON](releases/changelog.json)供程序读取。
|
||||
- [早期人工记录](releases/changelog-legacy.md):自动版本机制建立前的记录。
|
||||
- [比赛控制与直播提案](design/match-and-broadcast.md):尚未实现、仍需确认的需求。
|
||||
|
||||
## 文档维护
|
||||
|
||||
本仓库参考 [Firefox 文档首页](https://github.com/mozilla-firefox/firefox/blob/main/docs/index.md)按入门、开发、源码、测试和发布分组,并用索引串联主题。这里统一使用 Markdown,不引入 Firefox 的整套文档构建工具。
|
||||
|
||||
除 `README.md` 和 `AGENTS.md`(或 `Agents.md`)外,项目说明集中放在 `docs/`。根目录 README 介绍项目并链接到文档;子项目 README 保留简短入口。第三方组件的 README、许可证、构建文件、运行时配置和测试数据保留各自位置。
|
||||
|
||||
新增文档时先选择主题目录,再补充本页链接。迁移时同步修改相对链接、标题锚点与生成脚本。已被实现取代或已放弃的方案可以删除,历史仍可从 Git 查阅;仍需使用的实验记录保留条件、日期与验证范围,提案须明确标注状态。
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
## 范围
|
||||
|
||||
本次只做 **USB 现场更新**(用户确认的 OTA 语义 = A),不做设备联网自升级。
|
||||
维护页通过 USB 串口更新设备,不支持设备联网自升级。以下命令均从仓库根目录执行,另有 `cd` 的示例除外。
|
||||
|
||||
- **不做** SSID 动态修改(用户已放弃该功能),App 镜像逐字节原样写入;
|
||||
- **不做** 网络 OTA;当前固件没有 OTA 分区、没有 `esp_ota` 接收端、没有控制通道;
|
||||
- **不改固件源码、不重编译**:使用当前已构建的官方 bundle,客户端只 patch 分区表的
|
||||
factory 偏移/大小;
|
||||
- App 镜像逐字节原样写入,不支持动态修改 SSID;
|
||||
- 当前固件没有 OTA 分区、`esp_ota` 接收端或控制通道;
|
||||
- 使用已构建的官方 bundle,无需修改固件源码或重新编译;客户端只修改设备当前分区表的
|
||||
factory 偏移和大小;
|
||||
- 目标芯片以 ESP32-S3 为当前实现,`chip` 字段与 `espflash` 的芯片探测已经为 C3 预留,
|
||||
但 C3 的 bundle/布局尚未验收。
|
||||
|
||||
@@ -17,7 +17,7 @@ App 镜像不包含自己的分区偏移;偏移只在 `0x8000` 的分区表里
|
||||
写到旧 App 之后的空闲槽位,只需要改分区表里的 factory 偏移,不需要重新构建。
|
||||
|
||||
旧 `scripts/flash.sh` 之所以重编,是因为它把新偏移写回 `partitions.csv` 再重建。
|
||||
客户端改为直接 patch 已构建的 `partition-table.bin`:
|
||||
客户端直接修改从设备读取的当前分区表;包内的 `partition-table.bin` 只作参考模板与校验:
|
||||
|
||||
1. 从设备读当前分区表(0xC00 字节);
|
||||
2. 解析条目,找到 factory;
|
||||
@@ -48,14 +48,14 @@ App 镜像不包含自己的分区偏移;偏移只在 `0x8000` 的分区表里
|
||||
### 已知坏点
|
||||
|
||||
- `0xFADE7`:bit 5–7 无法正确编程(已确认);
|
||||
- `0x10863`:bit 6 读回为 1(`HARDWARE.md` 中尚未确认是否永久)。
|
||||
- `0x10863`:bit 6 读回为 1,尚未确认是否为永久故障,见[硬件说明](../hardware/camera.md#损坏-flash-模组的兼容处理)。
|
||||
|
||||
两个地址都在 `0x100000` 以下;`0x200000` 槽位天然避开它们。客户端仍会对写入的
|
||||
App 整段回读,不依赖“已知坏点表”。
|
||||
|
||||
## 官方 .zip 固件包
|
||||
|
||||
编译完成后由 [`scripts/package_firmware.py`](../scripts/package_firmware.py) 打成单文件
|
||||
编译完成后由 [`scripts/package_firmware.py`](../../scripts/package_firmware.py) 打成单文件
|
||||
`.zip`,客户端直接读取,不需要先解压。旧目录包仍兼容。
|
||||
|
||||
zip 根目录:
|
||||
@@ -76,7 +76,7 @@ BUILD-INFO.json # 可选;展示版本/构建信息
|
||||
- 只解压到 app cache 下的临时目录,读完立即删除;不把 zip 内容写进用户目录;
|
||||
- `BundleInfo` 会显示 `sourceKind=zip`、整包 SHA256 与条目数。
|
||||
|
||||
`bundle.json` 示例(由 [`scripts/make_bundle.py`](../scripts/make_bundle.py) 生成):
|
||||
`bundle.json` 示例(由 [`scripts/make_bundle.py`](../../scripts/make_bundle.py) 生成):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -99,8 +99,7 @@ BUILD-INFO.json # 可选;展示版本/构建信息
|
||||
```
|
||||
|
||||
客户端会校验 `format`、`chip`、`flashSize`、`appSha256`、`partitionTableSha256`;
|
||||
不匹配就拒绝,不接受任意 `.bin`。当前示例在
|
||||
[`target/firmware/bundle.json`](../target/firmware/bundle.json)。
|
||||
不匹配就拒绝,不接受任意 `.bin`。本地构建生成的示例位于 `target/firmware/bundle.json`,不随源码提交。
|
||||
|
||||
编译后打包:
|
||||
|
||||
@@ -121,7 +120,7 @@ target/firmware/camfw-...zip.sha256
|
||||
```
|
||||
|
||||
zip 使用固定条目顺序、固定 mtime 与 deflate level 9,同一个输入应得到同一个整包
|
||||
SHA256,便于核对“两个 U 盘里的包是不是同一版”。`package_firmware.py` 会复用
|
||||
SHA256,便于核对分发的固件包是否一致。`package_firmware.py` 会复用
|
||||
`make_bundle.py`,不维护第二套 manifest 逻辑。
|
||||
|
||||
> 注意:bundle 里的 `partition-table.bin` 是参考模板;客户端实际写的是设备当前
|
||||
@@ -131,9 +130,9 @@ SHA256,便于核对“两个 U 盘里的包是不是同一版”。`package_fi
|
||||
|
||||
入口有三处,不要求先选身份或绑定摄像头:
|
||||
|
||||
- 首次启动的身份选择页:`固件维护 / 烧录(不进入身份选择)`;
|
||||
- 首次启动的身份选择页:`打开固件维护`;
|
||||
- 用户端设置密码页:同上;
|
||||
- 主界面右上角:`固件维护`。
|
||||
- 主界面右上角:`固件`。
|
||||
|
||||
页面流程:
|
||||
|
||||
@@ -150,13 +149,13 @@ SHA256,便于核对“两个 U 盘里的包是不是同一版”。`package_fi
|
||||
|
||||
| 层 | 文件 |
|
||||
| --- | --- |
|
||||
| 后端:探测/分区表/候选槽位/写入/回读/备份/恢复 | [`desktop/src-tauri/src/maintenance.rs`](../desktop/src-tauri/src/maintenance.rs) |
|
||||
| Tauri 命令注册、`--maintenance-probe`/`--maintenance-bundle`/`--maintenance-plan` | [`desktop/src-tauri/src/main.rs`](../desktop/src-tauri/src/main.rs) |
|
||||
| 前端 IPC 与 DTO | [`desktop/src/lib/maintenance.ts`](../desktop/src/lib/maintenance.ts) |
|
||||
| 维护页 | [`desktop/src/pages/MaintenancePage.tsx`](../desktop/src/pages/MaintenancePage.tsx) |
|
||||
| 样式 | [`desktop/src/styles/maintenance.css`](../desktop/src/styles/maintenance.css) |
|
||||
| manifest 生成 | [`scripts/make_bundle.py`](../scripts/make_bundle.py) |
|
||||
| zip 打包 | [`scripts/package_firmware.py`](../scripts/package_firmware.py) |
|
||||
| 后端:探测/分区表/候选槽位/写入/回读/备份/恢复 | [`desktop/src-tauri/src/maintenance.rs`](../../desktop/src-tauri/src/maintenance.rs) |
|
||||
| Tauri 命令注册、`--maintenance-probe`/`--maintenance-bundle`/`--maintenance-plan` | [`desktop/src-tauri/src/main.rs`](../../desktop/src-tauri/src/main.rs) |
|
||||
| 前端 IPC 与 DTO | [`desktop/src/lib/maintenance.ts`](../../desktop/src/lib/maintenance.ts) |
|
||||
| 维护页 | [`desktop/src/pages/MaintenancePage.tsx`](../../desktop/src/pages/MaintenancePage.tsx) |
|
||||
| 样式 | [`desktop/src/styles/maintenance.css`](../../desktop/src/styles/maintenance.css) |
|
||||
| manifest 生成 | [`scripts/make_bundle.py`](../../scripts/make_bundle.py) |
|
||||
| zip 打包 | [`scripts/package_firmware.py`](../../scripts/package_firmware.py) |
|
||||
|
||||
后端使用 `espflash` 4.6 库(`default-features = false, features = ["serialport"]`)、
|
||||
`serialport` 4.10 和 `zip` 2.6(只开 `deflate`);Tauri 用 `tauri-plugin-dialog` 选文件/目录。
|
||||
@@ -207,7 +206,7 @@ cd desktop/src-tauri
|
||||
|
||||
## 已验证 / 未验证
|
||||
|
||||
已在当前连接板(`COM8`,ESP32-S3 v0.2,MAC `90:70:69:f8:f2:14`,16 MB,
|
||||
原记录已在测试板(`COM8`,ESP32-S3 v0.2,MAC `90:70:69:f8:f2:14`,16 MB,
|
||||
Secure Boot / Flash Encryption 均未启用)上验证:
|
||||
|
||||
- `--maintenance-probe`:读取芯片、MAC、分区表、App 描述;
|
||||
@@ -222,7 +221,7 @@ Secure Boot / Flash Encryption 均未启用)上验证:
|
||||
|
||||
**尚未执行**完整实机切换(写 patch 后分区表 → 复位 → 启动日志)。App 写入/回读
|
||||
已经在同一块板上验证;切换步骤的风险集中在 0x8000 的 3 KiB 分区表写入,写入后会
|
||||
回读,失败会尝试恢复旧表。建议在维护页点一次;如果新 App 启动失败,页面会给出
|
||||
回读,失败会尝试恢复旧表。如果新 App 启动失败,页面会给出
|
||||
备份目录和 `恢复旧分区表` 按钮。
|
||||
|
||||
## 边界
|
||||
@@ -0,0 +1,218 @@
|
||||
# protocol
|
||||
|
||||
摄像头 UDP 图传的线路格式,由固件与两种客户端共用。
|
||||
|
||||
```
|
||||
protocol/ 线路格式 + 帧重组 (23 个单元测试,无 ESP-IDF 依赖)
|
||||
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
|
||||
desktop/ 桌面客户端 (RMIC::2026,Tauri v2 + React + Rust,两种身份)
|
||||
```
|
||||
|
||||
编码与帧重组出错时,板上通常只表现为没有画面,难以定位。因此这里提供不依赖板卡的主机测试。
|
||||
|
||||
在宿主架构上运行测试:
|
||||
|
||||
```sh
|
||||
cd protocol && cargo test
|
||||
```
|
||||
|
||||
非 macOS / Apple Silicon 主机上,`protocol/.cargo/config.toml` 固定的 target 不适用,需按 [开发与验证](../development/firmware.md) 用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。本机实测:
|
||||
|
||||
```
|
||||
23 passed
|
||||
```
|
||||
|
||||
## 拓扑
|
||||
|
||||
```text
|
||||
┌──────────────────────────────┐
|
||||
│ RMIC::2026 桌面端(仅显示) │
|
||||
│ 队伍一 + 队伍二 + 双方血量 │
|
||||
└───────────────┬──────────────┘
|
||||
│ 同一个局域网,广播可用
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||||
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
|
||||
│ (队伍一) │ │ (队伍二) │ │ (已实现) │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
两块板子不区分队伍——它们只知道自己是谁(MAC 派生的 id),"哪一边"由客户端决定。
|
||||
烧同一份固件即可,配置在客户端一侧。
|
||||
|
||||
## 线路格式
|
||||
|
||||
每个数据报 = 27 字节头 + 至多 1400 字节载荷:
|
||||
|
||||
```text
|
||||
0 1 2 3
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| magic (u32) | type | version (u16) |
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| sender_id (u32) | frame_id (u32) |
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| frag_index (u16) | frag_count (u16) |
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| total_len (u32) | width (u16) | height (u16) |
|
||||
+-------------------------------+---------------+---------------+
|
||||
| payload (0..=1400) |
|
||||
+---------------------------------------------------------------+
|
||||
```
|
||||
|
||||
全部整数使用小端编码,通过 `to_le_bytes` / `from_le_bytes` 读写。
|
||||
|
||||
`msg_type` 决定数据报的解释方式。视频与发现使用 5600 端口,装甲板上报使用 5601 端口,两者共用头部格式:
|
||||
|
||||
| type | 方向 | 用途 |
|
||||
|---|---|---|
|
||||
| 1 `Video` | 板子 → 客户端 | 一帧 JPEG 的第 N 个分片 |
|
||||
| 2 `Subscribe` | 客户端 → 板子 | "我是这个角色,请给我画面" |
|
||||
| 3 `Hello` | 板子 → 客户端 | "我在线,我是谁" |
|
||||
| 4 `Heartbeat` | 板子 → 客户端 | 同上,心跳形式 |
|
||||
| 5 `ArmorHealth` | 装甲板 → 客户端 | 血量(已实现) |
|
||||
|
||||
### 四个设计决定
|
||||
|
||||
**27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在固件和客户端两侧
|
||||
各写一段手写偏移常量,更容易引入静默错位。
|
||||
|
||||
**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
|
||||
|
||||
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
|
||||
解析,中途加入的接收端也能立刻知道分辨率。
|
||||
|
||||
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
|
||||
错乱,比拒绝连接难查得多。
|
||||
|
||||
## 通告里的版本信息
|
||||
|
||||
`Hello` / `Heartbeat` 的负载是**向后兼容的一截**,现在 10 字节:
|
||||
|
||||
```text
|
||||
相对 HEADER_LEN 的偏移
|
||||
0 2 6 8 10
|
||||
+-----+-----+-----+-----+
|
||||
| fw | ver | bld |proto| fw: u16 旧字段(老客户端还读它)
|
||||
+-----+-----+-----+-----+ ver: u32 major<<16|minor<<8|patch
|
||||
bld: u16 构建号(git 提交数)
|
||||
proto: u16 板子编译进去的线路协议
|
||||
```
|
||||
|
||||
三个决定:
|
||||
|
||||
- **旧的 2 字节 `fw` 保留不动**。老客户端只读它,多余的字节不看,所以一次固件更新
|
||||
不会把还在用旧客户端的机器踢下线。
|
||||
- **新客户端接受只有 2 字节的老固件**:版本与构建号回到 0("未知"),协议号退回帧头里
|
||||
的那个。界面显示"版本未知",而不是一个看起来像真值的 `0.0.0`。
|
||||
- **通告用宽松的帧头解析,视频帧不用**。见下一条。
|
||||
|
||||
### 为什么通告允许协议不匹配
|
||||
|
||||
严格解析会丢弃 `version != PROTOCOL_VERSION` 的数据报,以避免按错误布局解析视频。若通告也被丢弃,客户端便无法发现协议不同的板卡,也无法解释没有画面的原因。
|
||||
|
||||
`Hello::decode` 使用 `decode_header_lenient`,保留魔数、长度和字段校验,允许版本不匹配。读出 `proto` 后,客户端可以显示双方协议号,并提示重新烧录。
|
||||
|
||||
这不削弱边界:视频、订阅、装甲板上报仍然走严格解析。
|
||||
|
||||
## 为什么视频走 UDP 而不是 TCP
|
||||
|
||||
早期单次记录(VGA 640x480,传感器产出 22 fps;原始日志、时长及完整网络条件未保存):
|
||||
|
||||
| | TCP(HTTP MJPEG) | UDP |
|
||||
|---|---|---|
|
||||
| 传感器实际产出 | 22 fps | 22 fps |
|
||||
| 客户端实际收到 | **1.9 fps** | **20.8 fps** |
|
||||
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
|
||||
|
||||
原说明以 20–30 Mbit/s 为良好链路下的 TCP 上限估计,将低帧率归因为队头阻塞和重传等待,并估算 UDP 占链路能力的 6–9%。这些估计缺少随文保存的测量条件,不能单凭此表确认瓶颈或因果,验证边界见[开发与验证](../development/firmware.md#性能测量)。
|
||||
|
||||
本协议不重传或确认视频分片。缺少分片时丢弃整帧,转而等待下一张完整帧,避免旧帧重传继续占用实时画面的发送时间。
|
||||
|
||||
## 接收端的取舍
|
||||
|
||||
`FrameAssembler` 仅交付分片完整的帧,并优先处理最新帧。
|
||||
|
||||
UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,不将缺片画面交给解码器,以免显示不完整图像。
|
||||
|
||||
### 按发送方分开
|
||||
|
||||
**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个
|
||||
重组器会让它们互相顶掉,两路都出不来。
|
||||
|
||||
### 重复分片必须去重
|
||||
|
||||
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
|
||||
分片确实到过。
|
||||
|
||||
用单一计数 `seen >= expected` 判断收齐不够健壮:一个丢失的分片若能被某个分片的副本
|
||||
凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。两个测试专门覆盖
|
||||
它(`a_duplicate_cannot_complete_a_frame_with_a_hole`、
|
||||
`a_gap_that_is_never_filled_never_yields_a_frame`)。
|
||||
|
||||
一次历史测试使用一到两个订阅者,20 秒收到 2000 多个分片;固定端口与临时端口两种方式均未观察到重复投递。去重用于保证重组正确性。重复片若被误计数,可能在帧率正常、没有报错时偶发地产生无法解码的 JPEG。
|
||||
|
||||
### 按 frame_id 隔离,换帧即放弃
|
||||
|
||||
每个 `FrameAssembler` 按 `frame_id` 隔离状态:帧号变化时重置,并丢弃上一帧的未完成数据。
|
||||
|
||||
若不隔离,丢片后的计数器可能继续接收下一帧分片,错误地交付混合两帧内容的 JPEG。
|
||||
|
||||
`scripts/udp_probe.py` 最初只按 `sender_id` 保存重组状态,曾在 10 秒内报告 34 张“JPEG 不完整”。作者据此提交了重复分片修复并将问题归因于协议,后来确认归因有误:探测器混合了不同帧。修正帧隔离后,14 秒窗口内未再出现坏帧。发现坏帧时也应检查测量工具。
|
||||
|
||||
### 有界超时
|
||||
|
||||
发送方在帧中途断开时,`expire()` 清理未完成帧,避免缓冲一直保留。
|
||||
|
||||
### 发送端的复用
|
||||
|
||||
`Fragment::encode_into()` 追加写入而不分配:发送端对每帧的每个分片复用同一个缓冲。
|
||||
按早期 22 fps 场景估算,复用可省去每秒约 250 次分配,减少内部 RAM 的分配开销。
|
||||
|
||||
`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的集合。
|
||||
|
||||
## 拒收条件
|
||||
|
||||
`Fragment::decode` 在数据进入 `FrameAssembler` 前拒绝以下情况:
|
||||
|
||||
| 条件 | 为什么 |
|
||||
|---|---|
|
||||
| `magic` 不符 | 同端口上可能有别的协议 |
|
||||
| `version` 不符 | 按错误布局解析比拒收难查 |
|
||||
| 长度不足 27 字节 | 可能是存活探测包(1 字节) |
|
||||
| `frag_count == 0` | 自相矛盾 |
|
||||
| `frag_index >= frag_count` | 自相矛盾 |
|
||||
| 载荷比 `total_len` 还长 | 损坏,且在造成大额分配之前就该挡住 |
|
||||
|
||||
## 不变量
|
||||
|
||||
- 一个数据报不超过 `HEADER_LEN + MAX_FRAGMENT_PAYLOAD` = **1427 字节**,低于典型
|
||||
以太网的 1500 MTU。
|
||||
这一点比看起来重要:超 MTU 的数据报要么被 AP 丢弃,要么被 IP 分片,两种情况的
|
||||
丢包都**记在应用账上**而不是网络账上——后者本来更容易排查。
|
||||
- VGA 下每帧约 17 个分片。
|
||||
- `DEFAULT_PORT` = 5600,两端共用同一个常量。
|
||||
- `ARMOR_PORT` = 5601,装甲板血量上报。
|
||||
|
||||
## 身份
|
||||
|
||||
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
|
||||
|
||||
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换
|
||||
地址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
|
||||
|
||||
返回值避开裁判终端保留值 0。测试检查结果稳定性与样例间是否碰撞,但不能证明哈希永不碰撞。
|
||||
|
||||
## 独立核对
|
||||
|
||||
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
|
||||
|
||||
这正是它的价值:如果本 crate 的编码有错,脚本仍按文档解析,会立刻报出不一致。反面
|
||||
例子也有——它的 `role` 字节曾多填 12 个零字节,固件按偏移严格读取因而拒收,表现为
|
||||
"板子在线但 0 clients subscribed"。改协议时记得同步它。
|
||||
|
||||
## 装甲板
|
||||
|
||||
`ArmorHealth` 占 `MsgType` 的 5 号。装甲板主板向 `ARMOR_PORT` 广播,`desktop/core/src/armor.rs`
|
||||
里的 `ArmorHealthStore` 负责接收。缺少上报时显示占位符,避免将数据缺失误读为血量归零。
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": "0.2.2",
|
||||
"major": 0,
|
||||
"minor": 2,
|
||||
"patch": 2,
|
||||
"build": 38,
|
||||
"protocol": 1,
|
||||
"date": "2026-10-19",
|
||||
"baseline": "v0.2.1",
|
||||
"commits": 3,
|
||||
"groups": {
|
||||
"fix": [
|
||||
{
|
||||
"scope": "client",
|
||||
"text": "统一状态与操作文案,录制和截图成功后再显示完成提示",
|
||||
"breaking": false
|
||||
}
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"scope": null,
|
||||
"text": "整理项目文档并统一界面表述",
|
||||
"breaking": false
|
||||
},
|
||||
{
|
||||
"scope": null,
|
||||
"text": "README 不写死构建号,改由 version.json 提供",
|
||||
"breaking": false
|
||||
},
|
||||
{
|
||||
"scope": null,
|
||||
"text": "按 humanizer 重写项目文档并同步 RMIC::2026 现状",
|
||||
"breaking": false
|
||||
}
|
||||
],
|
||||
"test": [
|
||||
{
|
||||
"scope": "client",
|
||||
"text": "按文件名核对媒体类型,避免依赖时间戳排序",
|
||||
"breaking": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
# 改动记录
|
||||
|
||||
> 本文件由 `scripts/version.mjs` 依据 git 历史生成,**请勿手改**。
|
||||
> 提交信息遵循 Conventional Commits,一条一个条目;要求见该脚本的文件头。
|
||||
> 建立这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](docs/CHANGELOG-legacy.md)。
|
||||
> 由 [`scripts/version.mjs`](../../scripts/version.mjs) 从 Git 历史生成,请勿手改。
|
||||
> 提交采用 Conventional Commits,规则见[版本维护](../development/versioning.md)。
|
||||
> 自动版本机制建立前的说明见[早期人工记录](changelog-legacy.md)。
|
||||
|
||||
## v0.2.1 — 2026-10-19
|
||||
## v0.2.2 — 2026-10-19
|
||||
|
||||
构建号 **37** · 线路协议 **1** · 2 个提交(v0.2.1 → v0.2.1)
|
||||
构建号 **38** · 线路协议 **1** · 3 个提交(v0.2.1 → v0.2.2)
|
||||
|
||||
### 修复
|
||||
|
||||
- **client** · 统一状态与操作文案,录制和截图成功后再显示完成提示
|
||||
|
||||
### 文档
|
||||
|
||||
- 整理项目文档并统一界面表述
|
||||
- README 不写死构建号,改由 version.json 提供
|
||||
- 按 humanizer 重写项目文档并同步 RMIC::2026 现状
|
||||
|
||||
### 测试
|
||||
|
||||
- **client** · 按文件名核对媒体类型,避免依赖时间戳排序
|
||||
|
||||
## v0.2.1 — 2026-10-19
|
||||
|
||||
1 个提交
|
||||
@@ -0,0 +1,210 @@
|
||||
# 图传固件入门
|
||||
|
||||
以下命令均在仓库根目录执行。桌面端与装甲板使用各自的构建目标,见[文档首页](../index.md)。
|
||||
|
||||
## 硬件要求
|
||||
|
||||
| 项目 | 当前配置 |
|
||||
| --- | --- |
|
||||
| 开发板 | Freenove ESP32-S3-WROOM CAM |
|
||||
| 模组 | ESP32-S3-WROOM-1-N16R8,16 MB Flash + 8 MB Octal PSRAM |
|
||||
| 摄像头 | OV5640;驱动包含 OV2640 / OV3660 支持,接线需核对 |
|
||||
| 网络 | 2.4 GHz Wi-Fi |
|
||||
| 连接 | 支持数据传输的 USB 线、可识别的串口及稳定供电 |
|
||||
|
||||
默认采集 VGA(640 × 480)、彩色 JPEG quality 12,按传感器节拍发送。相机引脚、Flash 分区和 PSRAM 配置见 [图传板硬件](../hardware/camera.md)。换板型前必须核对接线,避免占用 Flash / PSRAM 引脚。
|
||||
|
||||
## 固件开发环境
|
||||
|
||||
本项目使用 Rust `std` 和 ESP-IDF。ESP32-S3 属于 Xtensa 架构,需要通过 `espup` 安装 Espressif Rust 工具链;普通 `rustup target add` 不能替代这一步。根目录固定使用名为 `esp-1.97.0.0` 的工具链,避免浮动版本影响构建。
|
||||
|
||||
### 1. 安装基础依赖与 Rust 工具
|
||||
|
||||
先安装 [Rust / rustup](https://rustup.rs/)、[Git](https://git-scm.com/downloads)、[Python 3](https://www.python.org/downloads/)(建议 3.11)、[CMake](https://cmake.org/download/) 与 [Ninja](https://ninja-build.org/),确保命令在 `PATH` 中。
|
||||
|
||||
- Windows:使用 PowerShell 7.5 或更新版本,安装 Visual Studio Build Tools 的“使用 C++ 的桌面开发”和 Windows SDK,使用 MSVC Rust 主机工具链。仓库和工具链尽量使用较短、无空格和中文的路径。
|
||||
- Linux(Debian / Ubuntu):安装 `build-essential pkg-config libudev-dev libssl-dev python3 python3-venv cmake ninja-build git curl`。
|
||||
- macOS:安装 Xcode Command Line Tools(`xcode-select --install`),以及 Python、CMake 和 Ninja。
|
||||
|
||||
以下命令显式使用 `+stable`,即使在仓库内执行,也不会要求事先已有 `esp`:
|
||||
|
||||
```text
|
||||
rustup toolchain install stable
|
||||
cargo +stable install espup --locked
|
||||
cargo +stable install ldproxy --locked
|
||||
cargo +stable install espflash --version 4.4.0 --locked
|
||||
espup install --name esp-1.97.0.0 --toolchain-version 1.97.0.0 --targets esp32s3 --std
|
||||
```
|
||||
|
||||
也可以从各工具的官方 releases 下载预编译程序。`espup --std` 安装本项目所需的 Rust / LLVM 工具,ESP-IDF 的 C 工具链由后续构建准备。
|
||||
|
||||
Linux / macOS 每次新开终端后执行:
|
||||
|
||||
```bash
|
||||
. "$HOME/export-esp.sh"
|
||||
```
|
||||
|
||||
Windows 安装后重新打开 PowerShell,让环境变量生效;若当前终端仍找不到 LLVM,可执行 `. "$HOME/export-esp.ps1"`。检查结果:
|
||||
|
||||
```text
|
||||
rustup toolchain list
|
||||
rustc +esp-1.97.0.0 --version
|
||||
cargo +esp-1.97.0.0 --version
|
||||
espflash --version
|
||||
```
|
||||
|
||||
官方入门资料:[The Rust on ESP Book](https://docs.espressif.com/projects/rust/book/getting-started/toolchain.html)、[espup](https://github.com/esp-rs/espup)、[espflash](https://github.com/esp-rs/espflash/tree/v4.4.0/espflash)。
|
||||
|
||||
### 2. 准备 ESP-IDF
|
||||
|
||||
默认使用 ESP-IDF v5.5.5。首次构建时,`esp-idf-sys` 下载 SDK、对应 C 工具链及 Python 依赖,默认缓存到用户目录的 `.espressif/`;需要网络、磁盘空间及缓存目录写入权限。
|
||||
|
||||
已有匹配版本 SDK 时可在终端设置 `IDF_PATH`,不要提交个人绝对路径:
|
||||
|
||||
```powershell
|
||||
# Windows:替换为实际 SDK 源码目录。
|
||||
$env:IDF_PATH = 'C:\Espressif\frameworks\esp-idf-v5.5.5'
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS:替换为实际 SDK 源码目录。
|
||||
export IDF_PATH="$HOME/esp/esp-idf-v5.5.5"
|
||||
```
|
||||
|
||||
使用自动下载时,清除指向其他 SDK 的旧 `IDF_PATH`。缓存位置等高级设置见 [esp-idf-sys 构建选项](https://github.com/esp-rs/esp-idf-sys/blob/master/BUILD-OPTIONS.md)。
|
||||
|
||||
## 配置 Wi-Fi 与摄像头
|
||||
|
||||
在根目录复制 `.env.example` 为 `.env`,再用编辑器填写;已有 `.env` 时不要覆盖。
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```dotenv
|
||||
WIFI_SSID='你的2.4G网络名称'
|
||||
WIFI_PASS='你的Wi-Fi密码'
|
||||
|
||||
# 可选,省略时使用固件默认值。
|
||||
#CAM_RES=vga
|
||||
#CAM_QUALITY=12
|
||||
#CAM_QUALITY_MAX=0
|
||||
#CAM_MAX_FPS=0
|
||||
#CAM_CLOCK_PROFILE=default
|
||||
#CAM_FB_COUNT=3
|
||||
#CAM_GRAYSCALE=0
|
||||
#CAM_EXPOSURE_EV=0
|
||||
#CAM_AE_LEVEL=-1
|
||||
#CAM_SHUTTER_LINES=0
|
||||
#CAM_EXPOSURE_MAX_LINES=0
|
||||
#CAM_GAIN_CEILING=0
|
||||
#CAM_HTS=0
|
||||
#UDP_PACKET_GAP_US=0
|
||||
#UDP_RETRY_BUDGET_US=8000
|
||||
#CAM_THERMAL_LIMIT_C=75
|
||||
#CAM_VSYNC_INVERT=1
|
||||
#WIFI_AUTH=wpa2
|
||||
#WIFI_TX_POWER_DBM=15
|
||||
#WIFI_TEST_DROP_AFTER_S=0
|
||||
```
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `WIFI_SSID` | 无,必填 | 2.4 GHz 网络名称,最多 32 字节 |
|
||||
| `WIFI_PASS` | 空字符串 | Wi-Fi 密码;开放网络可留空 |
|
||||
| `CAM_RES` | `vga` | `qqvga` / `qvga` / `cif` / `hvga` / `vga` / `svga` / `xga` / `sxga` / `uxga` |
|
||||
| `CAM_QUALITY` | `12` | 0–63,越小通常画质越好、JPEG 越大;当前交付使用 18 |
|
||||
| `CAM_QUALITY_MAX` | `0` | `0` 固定画质;设为 `CAM_QUALITY`–63 时按发送回压在两者之间调节压缩,保留彩色和分辨率。拥塞时每 5 秒增大最多 3 档,连续稳定 15 秒才恢复 1 档 |
|
||||
| `CAM_MAX_FPS` | `0` | 取帧与发送的最高节拍,1–60;`0` 不限帧。仅限制消费节拍,不修改传感器时钟 |
|
||||
| `CAM_CLOCK_PROFILE` | `default` | `default` 保留驱动时钟;`vga30` 使用 OV5640 VGA 约 30.09 fps PLL 配置,保持 HTS/VTS 和读出窗口,SYSCLK 61 MHz / PCLK 15.25 MHz。切换后需重新核对曝光行数 |
|
||||
| `CAM_FB_COUNT` | `3` | PSRAM 帧缓冲数,允许 2 或 3。当前交付选三缓冲,保留发送突发时的采集余量;双缓冲曾在短测降低部分回显延迟,但用户长程运行反馈峰值丢帧明显,保留为试验选项 |
|
||||
| `CAM_GRAYSCALE` | `0` | `1` 使用传感器灰度效果,保留硬件 JPEG;`0` 彩色 |
|
||||
| `CAM_EXPOSURE_EV` | `0` | OV5640 自动曝光目标偏置:`-1` / `-2` 约对应 −1.1 / −1.9 EV;不是标定后的绝对 EV,也不保证快门缩短 |
|
||||
| `CAM_AE_LEVEL` | 未设置 | 可选 OV5640 驱动 AE 档位,−5–5;仅与 `CAM_EXPOSURE_EV=0` 同用。`-1` 将目标从 55 小幅降至 45,目标比值约 −0.29 EV,保留自动曝光/增益;未设置时按 EV 参数处理。实际快门和亮度变化取决于场景 |
|
||||
| `CAM_SHUTTER_LINES` | `0` | OV5640 固定快门,单位为传感器曝光行;`0` 自动曝光,非零保持自动增益。默认 VGA / HTS 2060 下 220 行估算约 10 ms;数值不能超过当前 VTS |
|
||||
| `CAM_EXPOSURE_MAX_LINES` | `0` | OV5640 自动曝光上限,`0` 保留驱动默认。非零保持 AEC/AGC 联动并禁用夜景延帧,不能与固定快门同时使用。默认 VGA 下 440 行约 20 ms;`vga30` 下 592 行约 20 ms。实际曝光由场景和抗频闪步长决定 |
|
||||
| `CAM_GAIN_CEILING` | `0` | OV5640 自动增益上限,`0` 保留驱动默认;1–63,单位为倍,实际上限略低于整数档。短曝光遇到室内弱光时可提高,但暗部噪点和 JPEG 负载可能增加 |
|
||||
| `CAM_HTS` | `0` | OV5640 水平总长度试验参数,`0` 使用驱动默认。缩短消隐会影响彩色稳定性,本机 1500 / 1800 出现绿紫闪烁,已撤回;几何检查通过不代表可用 |
|
||||
| `UDP_PACKET_GAP_US` | `0` | 成功提交视频数据报之间的最小间隔;0–5000 μs,须为调度 tick 的整数倍(当前为 1000 μs)。配合有界帧等待,避免集中突发写入队列 |
|
||||
| `UDP_RETRY_BUDGET_US` | `8000` | 每帧、所有分片及订阅者共用的让出调度重试预算,0–25000 μs,须为 tick 整数倍;`0` 为非阻塞失败即弃。总发送检查期限为 25 ms,采集帧龄期限为 100 ms;分片间隔也计入期限,不会额外延长 |
|
||||
| `CAM_THERMAL_LIMIT_C` | `75` | 芯片内部温度达到该值时减半帧率(向上取整),下降 5°C 后恢复;40–95,`0` 禁用。原先不限帧时降至 8 fps |
|
||||
| `CAM_VSYNC_INVERT` | `1` | 驱动反相 VSYNC;硬件已反相时填 `0` |
|
||||
| `WIFI_AUTH` | 自动选择 | `none` / `wep` / `wpa` / `wpa2` / `wpawpa2` / `wpa3` / `wpa2wpa3` / `wapi`;只在扫描看不到 AP(如隐藏 SSID)时才需要 |
|
||||
| `WIFI_TX_POWER_DBM` | SDK 默认上限 | 可选整数 2–20 dBm;当前交付请求 15 dBm,本板驱动回读 15.00 dBm;启动及重连时应用,拥塞和温控逻辑不降低此上限 |
|
||||
| `WIFI_TEST_DROP_AFTER_S` | `0` | 仅诊断构建:运行指定秒数后主动断网一次并保持 5 秒,用来验证运行时恢复。正式构建保持 `0` |
|
||||
|
||||
`.env` 使用简单的 `KEY=value`;值含空格、`#` 或 `$` 时用单引号包围,不使用变量展开或命令替换。脚本会加载 `.env`,直接 `cargo build` 不会自动读取它。上述参数在编译时写入固件,修改后需要重新编译和烧录。`.env` 已被 Git 忽略;固件也包含凭据,分享前应重新配置。
|
||||
|
||||
运行时断线恢复不重置摄像头、曝光或增益。首次尝试立即发起,失败后按 1、2、4、8、16、30 秒退避;关联与 DHCP 分别有 15 秒期限。恢复 IP 后丢弃旧采集帧、重建 socket 并重新广播,由客户端自动续订。启动时首次联网仍使用扫描与认证模式探测流程。实现依据 [ESP-IDF 断线事件处理说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。
|
||||
|
||||
## 编译固件
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
$env:CARGO_TARGET_DIR = 'C:\s3vt'
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
脚本执行 `cargo build --release --locked`,目标为 `xtensa-esp32s3-espidf`。首次编译包含 Rust 标准库、ESP-IDF 和相机组件,耗时较长。构建完成后可用 `-Package` / `--package` 再打一个 `.zip` 固件包,供桌面端维护页使用。
|
||||
|
||||
Windows 的 ESP-IDF 构建输出路径需不超过 88 字符,因此示例使用短目录 `C:\s3vt`(需可写,且不与其他项目共用)。`subst` 不能绕过该检查。下表的 `<TARGET_DIR>` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。
|
||||
|
||||
| 产物 | 位置 |
|
||||
| --- | --- |
|
||||
| 固件 ELF | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/esp32`(没有 `.exe` 后缀) |
|
||||
| Bootloader | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/bootloader.bin` |
|
||||
| 分区表 BIN | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/partition-table.bin` |
|
||||
| 分区表源文件 | 根目录 `partitions.csv` |
|
||||
|
||||
ELF 供 `espflash` 烧录与符号解析,不能当作 BIN 直接写入 Flash。Bootloader、分区表与 App 应来自同一次构建。
|
||||
|
||||
## 烧录与串口验证
|
||||
|
||||
### 1. 查找串口
|
||||
|
||||
```text
|
||||
espflash list-ports
|
||||
```
|
||||
|
||||
Windows 常见 `COM5`;Linux 为 `/dev/ttyACM0` 或 `/dev/ttyUSB0`;macOS 为 `/dev/cu.usbmodem...`。关闭其他串口监视器,Linux 需具备串口访问权限。
|
||||
|
||||
### 2. 烧录 ELF
|
||||
|
||||
本项目使用自定义分区表,显式传入 `partitions.csv` 和本次构建的 Bootloader。将示例端口替换为实际端口:
|
||||
|
||||
```powershell
|
||||
$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' }
|
||||
espflash flash --chip esp32s3 --port COM5 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$targetDir/xtensa-esp32s3-espidf/release/esp32"
|
||||
```
|
||||
|
||||
```bash
|
||||
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||
espflash flash --chip esp32s3 --port /dev/ttyACM0 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$target_dir/xtensa-esp32s3-espidf/release/esp32"
|
||||
```
|
||||
|
||||
连接失败时按住 BOOT,点按 RESET / EN,松开 BOOT 后重试。正常启动应看到相机识别、Wi-Fi 获取 IP 和 `ESP32 camera is live` 日志。没有客户端时,`0 clients subscribed` 属于正常状态。
|
||||
|
||||
仅查看日志:
|
||||
|
||||
```text
|
||||
espflash monitor --port COM5
|
||||
```
|
||||
|
||||
### 3. 导出 BIN
|
||||
|
||||
`espflash save-image --merge` 可导出从 `0x0` 写入的合并镜像。完整命令、独立镜像偏移与测试方法见 [开发与验证](../development/firmware.md)。
|
||||
|
||||
保留的 `scripts/flash.sh` 针对特定坏 Flash 模组,依赖 macOS 环境,会修改分区并重编译。普通板卡使用上述标准流程;坏板限制见 [图传板硬件](../hardware/camera.md#损坏-flash-模组的兼容处理)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 约 30 fps、Wi-Fi 重连与画面链路延迟验证(2026-10-18)
|
||||
|
||||
当前交付已增加阻塞恢复与新鲜度处理,见 [REALTIME.md](REALTIME.md)。本页保留此前减光确认和画面延迟对照;所列 SHA 与测量结果均属于当时构建,不是当前固件的重测结果。
|
||||
当前交付已增加阻塞恢复与新鲜度处理,见 [最新帧处理](realtime.md)。本页保留此前减光确认和画面延迟对照;所列 SHA 与测量结果均属于当时构建,不是当前固件的重测结果。
|
||||
|
||||
## 先前三缓冲与小幅减光记录
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
| 标记 → 首次可解码画布回显 P95 | 230.7 ms | 193.4 ms |
|
||||
| 回显估计 P99 / 最大值 | 285.3 / 443.2 ms | 248.9 / 461.5 ms |
|
||||
|
||||
这次窗口的 P50 降低 6.8 ms、P95 降低 37.3 ms。双缓冲减少潜在采集排队,同时保住接近 30 fps 的实际交付。两个窗口的网络回压、JPEG 自适应档位和时长不同,属于连续实机对照,不能把全部改善归因于缓冲数,也不是所有路由器或场景的保证。此前约 22 fps 的测试保留在 [STABILITY.md](STABILITY.md)。
|
||||
这次窗口的 P50 降低 6.8 ms、P95 降低 37.3 ms。双缓冲减少潜在采集排队,同时保住接近 30 fps 的实际交付。两个窗口的网络回压、JPEG 自适应档位和时长不同,属于连续实机对照,不能把全部改善归因于缓冲数,也不是所有路由器或场景的保证。此前约 22 fps 的测试保留在 [持续运行记录](stability.md)。
|
||||
|
||||
双缓冲试验五分钟全程在线,复位、采集错误、通道断开和计数重置均为 0 次。抽取 61 张 JPEG 均完整解码为 640×480 RGB;没有用灰度或降低色深换帧率。最小内部空闲堆 118771 B;实际 JPEG 档位 18–28,温度 58–60°C,未触发减载。
|
||||
|
||||
@@ -61,7 +61,7 @@ XCLK 保持 20 MHz,使用完整 PLL 分频组合:VCO 610 MHz、SYSCLK 61 MHz
|
||||
|
||||
下列实际曝光、增益范围属于此前双缓冲试验。当前 AE 目标改为 45/55,自动增益保留,曝光上限 592 行约 19.99 ms;实际曝光 590–590 行,估算 19.92–19.92 ms,增益 8.00–13.50×。最大 50 Hz band 数保留为 2,避免提升 SYSCLK 后向下取整只剩一个 band 导致画面过暗。未强制负 EV 或固定低快门。行数与毫秒换算依赖当前时钟,不能直接套用其他配置。
|
||||
|
||||
参考:[OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)、[Espressif OV5640 驱动](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。新组合的 VCO 和 PCLK 在该手册列出的范围内;这不代替板上信号完整性和画面验证。
|
||||
参考:[OV5640 数据手册资源页](<https://www.waveshare.com/wiki/OV5640_Camera_Board_(A)>)、[Espressif OV5640 驱动](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。Waveshare 资源页的 Datasheets 部分提供手册入口。新组合的 VCO 和 PCLK 在该手册列出的范围内;这不代替板上信号完整性和画面验证。
|
||||
|
||||
## Wi-Fi 运行时恢复
|
||||
|
||||
@@ -85,4 +85,4 @@ Windows 客户端使用裁判配置和 `--latency-report <新的NDJSON路径>`
|
||||
|
||||
该阶段 App SHA-256:`05aff1f53519fbb52b89ca526c3be11cc78f37a3356f588986336cf7870924eb`。Bootloader、分区表和 App 已经 COM8 烧录并独立校验,ZIP 内含校验清单。
|
||||
|
||||
本模组除原有 `0xFADE7` 坏点,还发现 `0x10863` 的 bit 6 异常读回为 1,整段回读只差这一字节,是否永久故障尚未单独擦写复核;已把应用分区移至 `0x50000` 并验证 `0xFADE7` 对应字节可写。该兼容布局针对故障模组,每次重编仍需重新核对,详见 [HARDWARE.md](HARDWARE.md)。
|
||||
本模组除原有 `0xFADE7` 坏点,还发现 `0x10863` 的 bit 6 异常读回为 1,整段回读只差这一字节,是否永久故障尚未单独擦写复核;已把应用分区移至 `0x50000` 并验证 `0xFADE7` 对应字节可写。该兼容布局针对故障模组,每次重编仍需重新核对,详见 [图传板硬件](../hardware/camera.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 图传性能排查与验证(2026-10-18)
|
||||
|
||||
本文保留早期短时对照;当前交付已回退到 15 dBm,功率、距离与发送拥塞分析见 [POWER.md](POWER.md),阻塞处理见 [REALTIME.md](REALTIME.md),此前约 30 fps 与回显延迟数据见 [LATENCY.md](LATENCY.md),更早的验证见 [STABILITY.md](STABILITY.md)。下列数字对应当时的传感器时序和网络状态。
|
||||
本文保留早期短时对照;当前交付已回退到 15 dBm,功率、距离与发送拥塞分析见 [功率与拥塞记录](power.md),阻塞处理见 [最新帧处理](realtime.md),此前约 30 fps 与回显延迟数据见 [延迟测量](latency.md),更早的验证见 [持续运行记录](stability.md)。下列数字对应当时的传感器时序和网络状态。
|
||||
|
||||
## 环境与结论
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## 最终参数与改动
|
||||
|
||||
- 该测试阶段本机 `.env` 选择 `CAM_QUALITY=18`、`WIFI_TX_POWER_DBM=14`。分辨率保持 VGA;quality 数字增大表示压缩更强,细节会有所损失。仓库默认 quality 仍为 12,未设置发射功率时保留 SDK 默认上限;当前功率选择见 [POWER.md](POWER.md)。
|
||||
- 该测试阶段本机 `.env` 选择 `CAM_QUALITY=18`、`WIFI_TX_POWER_DBM=14`。分辨率保持 VGA;quality 数字增大表示压缩更强,细节会有所损失。仓库默认 quality 仍为 12,未设置发射功率时保留 SDK 默认上限;当前功率选择见 [功率与拥塞记录](power.md)。
|
||||
- 视频 socket 设置 `IP_TOS=128`,使用 AC_VI,并保留 AMPDU;64 个动态 TX 缓冲、TX BA window 32、FreeRTOS 1000 Hz。JPEG 在 PSRAM 中原地读取,仅复用一个数据报缓冲;临时发送失败让出调度重试,等待预算为每帧 25 ms。
|
||||
- 客户端按发现的设备 IP 定向续订,接收缓冲请求 1 MiB。随机会话 ID 让同一会话的端口变化更新目标地址;旧固定 ID 保留按地址区分。实测迁移后旧端口无新视频,新端口继续接收;两个旧版订阅仍各自收到数据。
|
||||
- 接收器拒绝迟到旧帧、重复完成帧和矛盾分片,处理帧号回绕、设备重启及超时。视频 WebSocket 只保留各路最新帧;前端每路至多一次解码与一张最新待解码帧,避免持续积压。
|
||||
@@ -41,7 +41,7 @@ ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲
|
||||
|
||||
测试期间用户补充观察:用手握持开发板时丢包明显增多。测试没有逐秒标记握持、位置和朝向,五分钟记录应视作现场连续性观察,不能当作固定摆位的功率 A/B 测试。人体靠近 PCB 天线造成的吸收、失谐或遮挡,是更值得优先排查的线索。
|
||||
|
||||
本轮只把功率上限改到 15 dBm,其他图传参数与单 UDP 订阅保持不变,做五分钟收流观察;结果由交付包 `POWER-RESULT.json` 记录。之前的 17 dBm 记录保留为 `POWER-HISTORY-tx17.json`,14 dBm 的阻塞处理记录保留在 [REALTIME.md](REALTIME.md)。本轮不重新测量最远断连距离或物理屏幕端到端延迟。
|
||||
本轮只把功率上限改到 15 dBm,其他图传参数与单 UDP 订阅保持不变,做五分钟收流观察;结果由交付包 `POWER-RESULT.json` 记录。之前的 17 dBm 记录保留为 `POWER-HISTORY-tx17.json`,14 dBm 的阻塞处理记录保留在 [最新帧处理](realtime.md)。本轮不重新测量最远断连距离或物理屏幕端到端延迟。
|
||||
|
||||
下一项值得单独对照的是**分片突发整形**:固定 15 dBm、曝光和 JPEG 策略,按发送回压逐步比较小批次提交与短暂让出调度,并同时看原生完整帧率、网络丢帧、主动跳帧和帧龄。不能仅通过增大缓冲或延长重试,把丢帧转化为持续排队延迟。若要确认距离瓶颈,需要同步记录远点断连原因、AP 侧板卡 RSSI、MAC 重传和信道忙占比。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 当前交付
|
||||
|
||||
当前 15 dBm 交付 App SHA-256:`a6092e4b0cf42819c04424e066651f285b8961312a73822d667532f800e46767`,应用分区 `0x70000`。COM8 烧录与三段独立校验通过;355.1 秒串口的 71 次功率回读均为 15.00 dBm。五分钟观察:原生完整接收 **29.68 fps**,丢帧 **1.045%**,本地 JPEG 通道 **28.23 fps**,31 张抽帧全部完整解码为 640×480 RGB,无无效 JPEG、设备离线、通道断开、复位或采集错误;仍有最长 1.646 秒的到帧间隔,不能称为无卡顿。距离与拥塞分析、测试边界见 [POWER.md](POWER.md) 和制品 `POWER-RESULT.json`。
|
||||
当前 15 dBm 交付 App SHA-256:`a6092e4b0cf42819c04424e066651f285b8961312a73822d667532f800e46767`,应用分区 `0x70000`。COM8 烧录与三段独立校验通过;355.1 秒串口的 71 次功率回读均为 15.00 dBm。五分钟观察:原生完整接收 **29.68 fps**,丢帧 **1.045%**,本地 JPEG 通道 **28.23 fps**,31 张抽帧全部完整解码为 640×480 RGB,无无效 JPEG、设备离线、通道断开、复位或采集错误;仍有最长 1.646 秒的到帧间隔,不能称为无卡顿。距离与拥塞分析、测试边界见 [功率与拥塞记录](power.md) 和制品 `POWER-RESULT.json`。
|
||||
|
||||
沿用已确认可用的三块 PSRAM 采集缓冲、OV5640 VGA 彩色、约 30 fps PLL 时钟、AE 目标 45/55、约 20 ms 曝光上限及自动增益;Wi-Fi 自动重连和 TUN 兼容订阅保持不变。没有增加待发送 JPEG FIFO,也没有为了消除队列改成单块 DMA 缓冲。
|
||||
|
||||
@@ -31,7 +31,7 @@ UDP_RETRY_BUDGET_US=8000
|
||||
WIFI_TX_POWER_DBM=15
|
||||
```
|
||||
|
||||
当前交付根据用户反馈回退到 15 dBm。启动和重连应用同一功率上限,拥塞和温控逻辑不自动降功率;此前 17 dBm 的数据作为历史记录保留,见 [POWER.md](POWER.md)。
|
||||
当前交付根据用户反馈回退到 15 dBm。启动和重连应用同一功率上限,拥塞和温控逻辑不自动降功率;此前 17 dBm 的数据作为历史记录保留,见 [功率与拥塞记录](power.md)。
|
||||
|
||||
若要在发送回压时立即放弃本帧,可将 `UDP_RETRY_BUDGET_US=0` 后重编烧录。该模式仍保留三块采集内存和完整 JPEG 校验;这里的“零”指不让出调度重试,不是删除驱动和路由器中的所有缓冲。零重试策略已经主机测试,本次五分钟实机使用默认 8000 μs。
|
||||
|
||||
@@ -59,7 +59,7 @@ WIFI_TX_POWER_DBM=15
|
||||
|
||||
观察途中界面切到队伍端,按设计隐藏诊断计数,所以原生完整帧率和损失率只报告隐藏前的有效区间,不提供完整五分钟的原生丢帧率;本地 JPEG 通道则完整观察五分钟。保留用户当前界面选择,没有强制切回裁判端。两个最大值是板端自启动起的观测值,包含预热;帧龄从驱动的首个 DMA 缓冲时间戳开始,不是光子进入传感器到屏幕的时间。主动跳过的采集帧尚未分配网络帧号,不计入接收端丢帧率;两种计数不能混为一谈。另抽取 61 张 JPEG,均完整解码为 640×480 RGB。
|
||||
|
||||
部署前的 30 秒基线仅用于检查连通性,画面内容和无线状态有变化,不能据此证明本策略使丢帧率或光学延迟降低。本次未重新测量物理端到端延迟,也未证明所有长程拥塞峰值均已解决。较早的标记回显测量保留在 [LATENCY.md](LATENCY.md)。
|
||||
部署前的 30 秒基线仅用于检查连通性,画面内容和无线状态有变化,不能据此证明本策略使丢帧率或光学延迟降低。本次未重新测量物理端到端延迟,也未证明所有长程拥塞峰值均已解决。较早的标记回显测量保留在 [延迟测量](latency.md)。
|
||||
|
||||
## 阻塞恢复验证
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 彩色图传调度与持续运行验证(2026-10-18)
|
||||
|
||||
本文保留此前约 22 fps 配置及更早长测的历史记录。后续约 30 fps PLL 配置、缓冲选择、运行中断线恢复与画布回显延迟测量见 [LATENCY.md](LATENCY.md);以下参数与 SHA 属于当时的测试阶段,当前交付以新报告和制品 `BUILD-INFO.json` 为准。
|
||||
本文保留此前约 22 fps 配置及更早长测的历史记录。后续约 30 fps PLL 配置、缓冲选择、运行中断线恢复与画布回显延迟测量见 [延迟测量](latency.md);以下参数与 SHA 属于当时的测试阶段,当前交付以新报告和制品 `BUILD-INFO.json` 为准。
|
||||
|
||||
## 测试目标与配置
|
||||
|
||||
@@ -58,7 +58,7 @@ OV5640 驱动的 AE 档位不是 EV 数值:名义零档目标为 55,档位
|
||||
|
||||
这次完整接收帧率接近当前可靠彩色时序的约 22.2 fps 上限。亮度、画面内容、JPEG 大小和网络负载在各轮之间变化,不能把全部差异归因于单一控制参数,也不能将五分钟结果作为长期或所有路由器的保证。帧间隔不是摄像头到屏幕的光学延迟。
|
||||
|
||||
本轮固件 SHA-256 为 `e297069e190dcccefbae6d95591703c6848f4e523f910e2c0cb44af91f698936`,当时与交付及板上镜像一致;当前交付以 [README.md](README.md) 和交付包的 `BUILD-INFO.json` 为准。配套 Bootloader、分区表及 App 均已独立读回校验。曲线和完整数据位于制品包内的 `STABILITY.png` / `SCHEDULING-RESULT.json`,不包含相机抽帧或令牌日志。
|
||||
本轮固件 SHA-256 为 `e297069e190dcccefbae6d95591703c6848f4e523f910e2c0cb44af91f698936`,当时与交付及板上镜像一致;当前交付以 [项目说明](../../README.md) 和交付包的 `BUILD-INFO.json` 为准。配套 Bootloader、分区表及 App 均已独立读回校验。曲线和完整数据位于制品包内的 `STABILITY.png` / `SCHEDULING-RESULT.json`,不包含相机抽帧或令牌日志。
|
||||
|
||||
## 较早的五分钟彩色基线
|
||||
|
||||
@@ -89,7 +89,7 @@ OV5640 驱动的 AE 档位不是 EV 数值:名义零档目标为 55,档位
|
||||
|
||||
USB 恢复后,HTS 1500 的板上采集探针达到 30.49 fps,但白光环境出现明显绿紫闪烁,用户确认不可用。HTS 1800 的探针为 25.41 fps,仍出现明显偏紫,也已中止;两者均不作为有效五分钟彩色结果或交付配置。恢复默认 HTS 2060 后,用户确认颜色稳定。
|
||||
|
||||
1500 轮次开始时队伍 release 模式隐藏接收计数,因此该轮汇总中的接收 fps / 丢帧为无效值,不能当作零丢帧;观察脚本现已要求裁判诊断计数并记录诊断可用性。`0x380C/0x380D` 的水平总长定义见 [OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)。几何检查和 JPEG 成功解码都不足以证明彩色稳定。
|
||||
1500 轮次开始时队伍 release 模式隐藏接收计数,因此该轮汇总中的接收 fps / 丢帧为无效值,不能当作零丢帧;观察脚本现已要求裁判诊断计数并记录诊断可用性。`0x380C/0x380D` 的水平总长定义见 [OV5640 数据手册资源页](<https://www.waveshare.com/wiki/OV5640_Camera_Board_(A)>)中 Datasheets 部分的手册。几何检查和 JPEG 成功解码都不足以证明彩色稳定。
|
||||
|
||||
## 室内亮度回归
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 图传故障排查
|
||||
|
||||
以下命令均在仓库根目录执行。
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 检查方法 |
|
||||
| --- | --- |
|
||||
| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 |
|
||||
| `espup` 查询 GitHub 版本返回 403 | 对上述固定版本安装命令追加 `--skip-version-parse`,或稍后重试 |
|
||||
| 找不到 `ldproxy` / `libclang` | 安装链接工具;Unix 加载 `export-esp.sh`,Windows 重开终端 |
|
||||
| ESP-IDF 路径或版本错误 | 清除旧 `IDF_PATH` 或指向 v5.5.5 SDK |
|
||||
| Windows 路径过长 | 缩短仓库和工具链路径,见开发说明 |
|
||||
| Wi-Fi 连不上 | 核对 2.4 GHz、凭据和认证方式,再重新编译烧录 |
|
||||
| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM |
|
||||
| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` |
|
||||
| 在线但没有画面 | 检查客户端订阅、UDP 5600、防火墙及 AP 隔离 |
|
||||
| 界面提示协议不匹配 | 板子与客户端的线路协议号不同;按提示重新烧录同版本的固件 |
|
||||
| 开启 VPN / 虚拟网卡后设备在线但无画面 | 使用支持按已发现设备 IP 定向续订的客户端;仅发送 `255.255.255.255` 广播可能走错网卡 |
|
||||
| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 |
|
||||
| 主机项目被编为 Xtensa | 显式指定宿主 target,见各子项目说明 |
|
||||
|
||||
用 `python scripts/udp_probe.py 10` 可独立检查视频协议;装甲板用 `python scripts/armor_probe.py 15`。这些脚本需要板卡在线,构建通过不代表已完成硬件验证。
|
||||
|
||||
测量帧率和丢帧可运行以下命令,将 IP 换成板卡实际地址。对照测试时先退出桌面客户端,避免额外订阅者增加无线负载:
|
||||
|
||||
```text
|
||||
python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json
|
||||
```
|
||||
|
||||
脚本定向续订,支持保留 VPN / TUN 的环境。输出包含完整帧率、帧损失率、接收间隔和组装耗时;这些时间不等于摄像头到屏幕的端到端延迟。
|
||||
|
||||
运动图传保持 `CAM_GRAYSCALE=0`,优先使用自动曝光上限和自动增益协同控制拖影。室内强制 −1 EV / 固定短快门在本机过暗,已撤回;提高增益上限也会增加噪点。需要限流时设 `CAM_MAX_FPS=15`,追求帧率时用 `0`;可启用有界 JPEG 压缩调节应对发送回压,分片间隔按网络实测选择。灰度效果仍保留硬件 JPEG,不是改为 RGB565 或降低 JPEG 采样位数。75°C 是主动减载阈值,温度来自 ESP 芯片内部,不能代表摄像头或环境温度,也不是芯片损坏温度规格。五分钟彩色对照与此前中止的灰度长测见 [持续运行记录](stability.md)。
|
||||
+6
-238
@@ -1,243 +1,11 @@
|
||||
# protocol
|
||||
|
||||
摄像头 UDP 图传的线路格式,**固件与两端客户端共用**。
|
||||
图传与装甲板共用的 UDP 线路格式及帧重组库,不依赖 ESP-IDF,可在主机上测试。
|
||||
|
||||
```
|
||||
protocol/ 线路格式 + 帧重组 (23 个单元测试,无 ESP-IDF 依赖)
|
||||
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
|
||||
desktop/ 桌面客户端 (RMIC::2026,Tauri v2 + React + Rust,两种身份)
|
||||
在本目录执行,Windows MSVC 示例:
|
||||
|
||||
```powershell
|
||||
cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
这是本仓库里最该被测试的部分:帧重组和编码都极易出微妙错误,而这两件事都无法
|
||||
在固件上验证——你只会看到"不出画面"。
|
||||
|
||||
因此它为宿主架构编译,与 ESP-IDF 完全无关:
|
||||
|
||||
```sh
|
||||
cd protocol && cargo test
|
||||
```
|
||||
|
||||
非 macOS / Apple Silicon 主机上,`protocol/.cargo/config.toml` 里钉住的 target 不适用,需按 [DEVELOPMENT.md](../DEVELOPMENT.md) 用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。本机实测:
|
||||
|
||||
```
|
||||
23 passed
|
||||
```
|
||||
|
||||
## 拓扑
|
||||
|
||||
```text
|
||||
┌──────────────────────────────┐
|
||||
│ RMIC::2026 桌面端(仅显示) │
|
||||
│ 队伍一 + 队伍二 + 双方血量 │
|
||||
└───────────────┬──────────────┘
|
||||
│ 同一个局域网,广播可用
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||||
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
|
||||
│ (队伍一) │ │ (队伍二) │ │ (已实现) │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
两块板子不区分队伍——它们只知道自己是谁(MAC 派生的 id),"哪一边"由客户端决定。
|
||||
烧同一份固件即可,配置在客户端一侧。
|
||||
|
||||
## 线路格式
|
||||
|
||||
每个数据报 = 27 字节头 + 至多 1400 字节载荷:
|
||||
|
||||
```text
|
||||
0 1 2 3
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| magic (u32) | type | version (u16) |
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| sender_id (u32) | frame_id (u32) |
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| frag_index (u16) | frag_count (u16) |
|
||||
+-------------------------------+-------+-----------------------+
|
||||
| total_len (u32) | width (u16) | height (u16) |
|
||||
+-------------------------------+---------------+---------------+
|
||||
| payload (0..=1400) |
|
||||
+---------------------------------------------------------------+
|
||||
```
|
||||
|
||||
全部整数**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是
|
||||
`to_le_bytes` / `from_le_bytes`,没有字节序转换步骤。
|
||||
|
||||
`msg_type` 决定同一端口上的字节如何解释,这是视频、发现、装甲板能共用一个端口的
|
||||
前提:
|
||||
|
||||
| type | 方向 | 用途 |
|
||||
|---|---|---|
|
||||
| 1 `Video` | 板子 → 客户端 | 一帧 JPEG 的第 N 个分片 |
|
||||
| 2 `Subscribe` | 客户端 → 板子 | "我是这个角色,请给我画面" |
|
||||
| 3 `Hello` | 板子 → 客户端 | "我在线,我是谁" |
|
||||
| 4 `Heartbeat` | 板子 → 客户端 | 同上,心跳形式 |
|
||||
| 5 `ArmorHealth` | 装甲板 → 客户端 | 血量(已实现) |
|
||||
|
||||
### 四个设计决定
|
||||
|
||||
**27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在固件和客户端两侧
|
||||
各写一段手写偏移常量,更容易引入静默错位。
|
||||
|
||||
**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
|
||||
|
||||
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
|
||||
解析,中途加入的接收端也能立刻知道分辨率。
|
||||
|
||||
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
|
||||
错乱,比拒绝连接难查得多。
|
||||
|
||||
## 通告里的版本信息
|
||||
|
||||
`Hello` / `Heartbeat` 的负载是**向后兼容的一截**,现在 10 字节:
|
||||
|
||||
```text
|
||||
相对 HEADER_LEN 的偏移
|
||||
0 2 6 8 10
|
||||
+-----+-----+-----+-----+
|
||||
| fw | ver | bld |proto| fw: u16 旧字段(老客户端还读它)
|
||||
+-----+-----+-----+-----+ ver: u32 major<<16|minor<<8|patch
|
||||
bld: u16 构建号(git 提交数)
|
||||
proto: u16 板子编译进去的线路协议
|
||||
```
|
||||
|
||||
三个决定:
|
||||
|
||||
- **旧的 2 字节 `fw` 保留不动**。老客户端只读它,多余的字节不看,所以一次固件更新
|
||||
不会把还在用旧客户端的机器踢下线。
|
||||
- **新客户端接受只有 2 字节的老固件**:版本与构建号回到 0("未知"),协议号退回帧头里
|
||||
的那个。界面显示"版本未知",而不是一个看起来像真值的 `0.0.0`。
|
||||
- **通告用宽松的帧头解析,视频帧不用**。见下一条。
|
||||
|
||||
### 为什么通告允许协议不匹配
|
||||
|
||||
严格解析会把 `version != PROTOCOL_VERSION` 的数据报全部丢掉。对视频帧这是对的——按错的
|
||||
布局去解读一段 JPEG 只会得到垃圾。但对**通告**,那意味着一块烧了别的协议版本的板子在
|
||||
客户端看来根本不存在,现场看到的现象只有"没有画面"。
|
||||
|
||||
所以 `Hello::decode` 走 `decode_header_lenient`:魔数、长度、字段照常校验,只是不看版本。
|
||||
读出 `proto` 之后,客户端就能直接说"板子是协议 2,客户端是协议 1,画面会被丢弃,请重新
|
||||
烧录",而不是让人去猜。
|
||||
|
||||
这不削弱边界:视频、订阅、装甲板上报仍然走严格解析。
|
||||
|
||||
## 为什么视频走 UDP 而不是 TCP
|
||||
|
||||
实测(VGA 640x480,传感器产出 22 fps):
|
||||
|
||||
| | TCP(HTTP MJPEG) | UDP |
|
||||
|---|---|---|
|
||||
| 传感器实际产出 | 22 fps | 22 fps |
|
||||
| 客户端实际收到 | **1.9 fps** | **20.8 fps** |
|
||||
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
|
||||
|
||||
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限
|
||||
约 20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的
|
||||
重传计时器。
|
||||
|
||||
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。这换来低延迟,也把本来就
|
||||
有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在带宽上。
|
||||
|
||||
## 接收端的取舍
|
||||
|
||||
`FrameAssembler` 的策略就是全部要点:**一张帧只有所有分片都到齐才交付,且最新的
|
||||
帧永远优先。**
|
||||
|
||||
UDP 不保证跨数据报的顺序与到达,所以试图"补洞"的接收端做的功比"等下一张完整帧"
|
||||
更多——而且补的过程中会显示撕裂的画面。
|
||||
|
||||
### 按发送方分开
|
||||
|
||||
**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个
|
||||
重组器会让它们互相顶掉,两路都出不来。
|
||||
|
||||
### 重复分片必须去重
|
||||
|
||||
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
|
||||
分片确实到过。
|
||||
|
||||
用单一计数 `seen >= expected` 判断收齐不够健壮:一个丢失的分片若能被某个分片的副本
|
||||
凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。两个测试专门覆盖
|
||||
它(`a_duplicate_cannot_complete_a_frame_with_a_hole`、
|
||||
`a_gap_that_is_never_filled_never_yields_a_frame`)。
|
||||
|
||||
**在实测链路上一次重复投递都没出现过。** 一到两个订阅者、20 秒 2000+
|
||||
个分片,重复数为 0(固定端口与临时端口两种订阅方式都试过)。所以这层防护是为了正确
|
||||
性,不是为了修一个正在发生的故障。
|
||||
|
||||
它防的是"哪天真的重复了"——那时症状是**帧率完全正常、没有任何报错线索**,只有偶尔
|
||||
几张图解不出来,比一次崩溃难查得多。
|
||||
|
||||
### 按 frame_id 隔离,换帧即放弃
|
||||
|
||||
每个 `FrameAssembler` **按 `frame_id` 隔离**状态:`frame_id` 一变就重置,之前那帧的半
|
||||
成品直接丢掉。
|
||||
|
||||
不隔离的后果很具体——某帧丢了分片之后,下一帧的分片会接着往同一个计数器里凑,凑够
|
||||
了就交出去一张**混着两帧**的 JPEG。
|
||||
|
||||
这个坑我踩过:`scripts/udp_probe.py` 最初只按 `sender_id` 存重组状态,于是 10 秒里报出
|
||||
34 张"JPEG 不完整"。那个数字看起来像丢包或重复分片的证据,我据此给 `protocol` 写了一
|
||||
个"修复重复分片"的提交和一段因果说明——**结论是错的**,那段归因比它揭示的 bug 更值得
|
||||
记下来。修掉之后同样 14 秒内是 0 张。
|
||||
|
||||
探到坏帧时,先怀疑测量工具。
|
||||
|
||||
### 有界超时
|
||||
|
||||
发送方在帧中途死掉时,半截缓冲会永远留在重组器里。`expire()` 堵住这个。
|
||||
|
||||
### 发送端的复用
|
||||
|
||||
`Fragment::encode_into()` 追加写入而不分配:发送端对每帧的每个分片复用同一个缓冲。
|
||||
22 fps 下这是每秒约 250 次分配,省掉它们在这块同时要抢内部 RAM 的芯片上很实在。
|
||||
|
||||
`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的集合。
|
||||
|
||||
## 拒收条件
|
||||
|
||||
`Fragment::decode` 会挡掉这些,`FrameAssembler` 不必再操心:
|
||||
|
||||
| 条件 | 为什么 |
|
||||
|---|---|
|
||||
| `magic` 不符 | 同端口上可能有别的协议 |
|
||||
| `version` 不符 | 按错误布局解析比拒收难查 |
|
||||
| 长度不足 27 字节 | 可能是存活探测包(1 字节) |
|
||||
| `frag_count == 0` | 自相矛盾 |
|
||||
| `frag_index >= frag_count` | 自相矛盾 |
|
||||
| 载荷比 `total_len` 还长 | 损坏,且在造成大额分配之前就该挡住 |
|
||||
|
||||
## 不变量
|
||||
|
||||
- 一个数据报不超过 `HEADER_LEN + MAX_FRAGMENT_PAYLOAD` = **1427 字节**,低于典型
|
||||
以太网的 1500 MTU。
|
||||
这一点比看起来重要:超 MTU 的数据报要么被 AP 丢弃,要么被 IP 分片,两种情况的
|
||||
丢包都**记在应用账上**而不是网络账上——后者本来更容易排查。
|
||||
- VGA 下每帧约 17 个分片。
|
||||
- `DEFAULT_PORT` = 5600,两端共用同一个常量。
|
||||
- `ARMOR_PORT` = 5601,装甲板血量上报。
|
||||
|
||||
## 身份
|
||||
|
||||
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
|
||||
|
||||
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换
|
||||
地址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
|
||||
|
||||
返回值避开 0,那是裁判终端的保留值。哈希有测试保证稳定性与不撞车。
|
||||
|
||||
## 独立核对
|
||||
|
||||
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
|
||||
|
||||
这正是它的价值:如果本 crate 的编码有错,脚本仍按文档解析,会立刻报出不一致。反面
|
||||
例子也有——它的 `role` 字节曾多填 12 个零字节,固件按偏移严格读取因而拒收,表现为
|
||||
"板子在线但 0 clients subscribed"。改协议时记得同步它。
|
||||
|
||||
## 装甲板
|
||||
|
||||
`ArmorHealth` 占 `MsgType` 的 5 号。装甲板主板向 `ARMOR_PORT` 广播,`desktop/core/src/armor.rs`
|
||||
里的 `ArmorHealthStore` 负责接收。收不到上报时界面显示占位符而不是 0——**0 看起来像真值,
|
||||
现场看到会以为对方已经没血了**。
|
||||
其他平台换成对应的宿主 target。字段布局、版本兼容、重组策略与拒收条件见[UDP 协议](../docs/protocol/udp.md),测试方法见[开发与验证](../docs/development/firmware.md#主机测试)。
|
||||
|
||||
+7
-6
@@ -37,7 +37,7 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -316,10 +316,10 @@ function renderChangelog(meta, result, releases) {
|
||||
const lines = [];
|
||||
lines.push("# 改动记录");
|
||||
lines.push("");
|
||||
lines.push("> 本文件由 `scripts/version.mjs` 依据 git 历史生成,**请勿手改**。");
|
||||
lines.push("> 提交信息遵循 Conventional Commits,一条一个条目;要求见该脚本的文件头。");
|
||||
lines.push("> 由 [`scripts/version.mjs`](../../scripts/version.mjs) 从 Git 历史生成,请勿手改。");
|
||||
lines.push("> 提交采用 Conventional Commits,规则见[版本维护](../development/versioning.md)。");
|
||||
lines.push(
|
||||
"> 建立这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](docs/CHANGELOG-legacy.md)。",
|
||||
"> 自动版本机制建立前的说明见[早期人工记录](changelog-legacy.md)。",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
@@ -399,6 +399,7 @@ function writeIfChanged(path, content) {
|
||||
if (existsSync(full) && readFileSync(full, "utf8") === content) {
|
||||
return false;
|
||||
}
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, content, "utf8");
|
||||
return true;
|
||||
}
|
||||
@@ -432,8 +433,8 @@ function write(meta, result) {
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
put('CHANGELOG.md', renderChangelog(meta, result, result.releases));
|
||||
put("CHANGELOG.json", renderJson(meta, result));
|
||||
put("docs/releases/changelog.md", renderChangelog(meta, result, result.releases));
|
||||
put("docs/releases/changelog.json", renderJson(meta, result));
|
||||
put("desktop/src/lib/version.ts", renderTs(meta));
|
||||
|
||||
if (syncJsonVersion("desktop/package.json", meta.version)) changed.push("desktop/package.json");
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"major": 0,
|
||||
"minor": 2,
|
||||
"patch": 1,
|
||||
"build": 37,
|
||||
"patch": 2,
|
||||
"build": 38,
|
||||
"protocol": 1,
|
||||
"date": "2026-10-19",
|
||||
"baseline": "v0.2.1",
|
||||
|
||||
Reference in New Issue
Block a user