Files
ESP32S3_vt/FLASH-CLIENT-ARCH.md
T
sloray 896b21fe44
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
2026-10-19 02:40:08 +08:00

465 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 烧录 / 维护客户端架构设计(ESP32-S3 图传板)
> 状态:**提议中(Proposed)**,等待第 9 节的问题清单答复后冻结。
> 输入约束:现场存在 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/远程三处复用。