diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dab084f --- /dev/null +++ b/AGENTS.md @@ -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)。 diff --git a/CHANGELOG.json b/CHANGELOG.json deleted file mode 100644 index 16e45c9..0000000 --- a/CHANGELOG.json +++ /dev/null @@ -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 - } - ] - } -} diff --git a/FLASH-CLIENT-ARCH.md b/FLASH-CLIENT-ARCH.md deleted file mode 100644 index cddb6fe..0000000 --- a/FLASH-CLIENT-ARCH.md +++ /dev/null @@ -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-.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-.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--.bin`(约 1.2 MB)。 - 这既是回滚素材,也是一份“坏点快照”。 -- 写后:回读 → 差分 → 若有 mismatch,**先更新 badmap,再重新规划**,而不是简单重试。 -- 引导:抓串口,断言 `Loaded app from partition at offset 0x` 与 - `ESP32 camera is live on `;失败则按 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/远程三处复用。 diff --git a/README.md b/README.md index 015a78a..d517fc2 100644 --- a/README.md +++ b/README.md @@ -1,357 +1,292 @@ -# RMIC::2026 视野链路系统 +

+ Simba Robotics 队标 +

-黑龙江大学 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 固件维护。 + +[![Firmware: ESP32-S3](https://img.shields.io/badge/Firmware-ESP32--S3-00979D)](docs/setup/firmware.md) +[![Armor: ESP32-C3](https://img.shields.io/badge/Armor-ESP32--C3-00979D)](docs/hardware/armor.md) +[![Desktop: Tauri 2 + React 19](https://img.shields.io/badge/Desktop-Tauri_2_%2B_React_19-24C8D8)](docs/desktop/client.md) +[![Transport: UDP](https://img.shields.io/badge/Transport-UDP-5865F2)](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
硬件 JPEG"] --> Firmware["采集与分片
最新帧发送"] + end + + subgraph Armor["装甲板 · ESP32-C3"] + Piezo["四路压电片"] --> Rules["受击规则与整车血量"] + Rules --> LEDs["四路 WS2812"] + end + + subgraph Desktop["RMIC::2026 · 桌面端"] + Core["Rust 核心
发现、重组与血量状态"] + Backend["Tauri 后端
本地通道、截图与录制"] + UI["React / WebView
用户端或裁判端"] + 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。 + +
+Windows x64:开发运行与安装包 + +在 `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**。 + +
+ +
+macOS / Linux:目标选择与演示模式 + +`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#演示模式没有板子也能把整条链路跑起来)。 + +
+ +首次启动选择用户端或裁判端。用户端需要设置现场密码并绑定本车板卡;裁判端自动分配图传槽位,装甲板按上报队伍分配。对应关系不符时,在设置中手动指定。 + +### 3. 编译图传或装甲固件 + +| 硬件 | 构建目标 | 入门文档 | +| :--- | :--- | :--- | +| Freenove ESP32-S3-WROOM CAM + OV5640 | `xtensa-esp32s3-espidf` | [环境、参数与烧录](docs/setup/firmware.md) | +| ESP32-C3 装甲板主板 | `riscv32imc-esp-espidf` | [接线、规则与构建](docs/hardware/armor.md) | + +
+已装好图传工具链:构建命令 + +先按[固件入门](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 写入。 + +
+ +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)。 + +
+前端检查与 Windows 主机 Rust 检查 + +前端命令在 `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、板卡与网络行为。 + +
+ +提交使用 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) + +
+仓库地址 + +仓库已由 `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` 不能绕过该检查。下表的 `` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。 - -| 产物 | 位置 | -| --- | --- | -| 固件 ELF | `/xtensa-esp32s3-espidf/release/esp32`(没有 `.exe` 后缀) | -| Bootloader | `/xtensa-esp32s3-espidf/release/bootloader.bin` | -| 分区表 BIN | `/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):建立自动版本机制之前的人工改动记录。 +
diff --git a/armor/README.md b/armor/README.md index 4efc01a..ac343bb 100644 --- a/armor/README.md +++ b/armor/README.md @@ -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`:`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)。 diff --git a/desktop/README.md b/desktop/README.md index 2ee8591..fb83b40 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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 身份。默认看配置文件 - --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 摆一个 `` 再叠一层 `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 -i pipe:0` - 喂帧,x264 编码,`+faststart`。 -- **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来 - 就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放 - 时第一个要看的就是它。 -- 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是 - 链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。 -- 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的 - `-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。 - -界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的 -目录生效。默认录制目录在名单里;改到别处时 `