Files
simba-rmic-2026/docs/firmware-provisioning-evaluation.md
T
sloray e3acb37d32
构建客户端 / 检查(类型 + 单元测试) (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
docs: 按 humanizer 重写项目文档并同步 RMIC::2026 现状
2026-10-19 10:47:00 +08:00

1308 lines
81 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 客户端自动烧录、坏区规避与 SSID 配置 Patch:工具评估与验证路径
> 评估对象:`C:\Users\HelixCopex\code\ESP32S3_vt` 当前工作区(ESP32-S3 图传固件 + Tauri 桌面端)。
> 评估日期:2026-10-19。评估员:工具评估师(DSH)。
> 置信度:中高。静态代码、构建产物、烧录/校验日志证据充分;坏区扫描、断电与 OTA 实验尚未在本机执行,相关结论需按第 6 节 MVE 复核。
> 下次评审:MVE E2–E5 完成后;或固件模板/分区表一旦变更立即复审。
>
> 状态补注(2026-10-19):本稿是提议稿,正文按撰写时的仓库状态记录。已实现的固件维护页走另一条路线:只做 USB 现场更新,客户端 patch 设备当前分区表的 factory 偏移/大小,不改固件源码;SSID 动态修改已放弃,OTA 不在本次范围。实现见 [`firmware-maintenance.md`](firmware-maintenance.md)。文中的 config 分区与 OTA-ready 布局仍是未实施建议;2.3 节“桌面端串口/打包”两行的现状已过时(现已引入 `serialport`、`espflash`,bundle targets 为 `all`)。
---
## 0. 管理层摘要
**推荐方案(条件性)**
> 一次性冻结模板固件(新增 `config` 分区读取 + OTA-ready 分区布局)+ 工具侧按分区/槽位做实际镜像写读校验 + 配置区多副本 CRC + P0 用 Python + esptool 做参考流水线,P1 再封装进 Tauri。
这不是“选一个万能工具”,而是三层组合:
1. **参考/校验层**:`esptool`(当前 4.12.0)负责原始读、擦、写、逐段 `verify_flash`、镜像信息检查;
2. **产品写入层**:`espflash` 4.4.0(Apache-2.0、单文件 Rust)负责发现端口与快速写入;其校验结果必须与 esptool 做 parity(一致性)测试后才能单独作为门禁;
3. **业务编排层**:Python CLI(P0)或 Tauri Rust 后端(P1)负责设备发现、清单匹配、坏区映射、槽位选择、配置 patch、报告与回归。
**关键判断**
- `esptool` 和 `espflash` 都没有坏块扫描、坏块跳过或坏块重映射命令;它们只有“按地址读/擦/写/校验”的原语。所谓“自动屏蔽”必须由上层工具实现。
- 典型 SPI NOR 没有 NAND 式坏块表/坏块标记;本板的故障是字节级位缺陷(`0xFADE7` 的 bit 5–7、`0x10863` 的 bit 6 可疑),不是可被文件系统跳过的“坏块”。
- **可启动 App 不能在镜像内部跳过坏扇区**:Bootloader 按分区偏移线性读取,App 的链接地址也假定连续 Flash。唯一正确的规避是“整分区/整槽位选择”,或用实际镜像写读校验证明该槽位对当前镜像可用。
- **合并镜像(merged image)在坏板上是高风险路径**:它从 `0x0` 连续写入,包含分区间填充,坏字节落在填充区也会导致校验失败或写入无意义。必须分段写入、分段校验。
- **不要直接 patch App 字节来改 SSID**:当前 App 镜像的 `Checksum` 与 `Validation Hash` 都是 valid(见 [`target/firmware/IMAGE-INFO.txt`](../target/firmware/IMAGE-INFO.txt)),直接改字节会破坏它们;若启用 Secure Boot,还会破坏签名。正确位置是独立 config 分区。
- 当前 [`scripts/flash.sh`](../scripts/flash.sh) 的“搜索 64 KiB 对齐偏移 + 改分区表 + 重编译”是应急规避,不是可回归的 provisioning 方案:它依赖 macOS 串口/termios、会改 `partitions.csv`、会重编译两次,且每次重编都可能移动镜像字节。
- **需要一次性模板变更**:新增 config 分区读取逻辑、可选 OTA 布局。这里的“不重新编译”应定义为“每台设备不再编译”,而不是“永远不改固件源码”;若连一次性模板变更都不允许,只能走脆弱的 App 字节 patch,本报告不推荐。
**投入与收益(量级)**
| 项目 | 估算 |
| --- | --- |
| P0 参考流水线 + MVE | 5–8 工程师日 |
| 模板固件(config 分区 + 解析 + 单测) | 3–5 工程师日 |
| 坏区扫描硬化 | 3–5 工程师日 |
| P1 桌面端集成(发现/UI/报告/侧车) | 8–12 工程师日 |
| 测试矩阵 + 硬件在环 | 5–8 工程师日 |
| 文档/培训 | 2–3 工程师日 |
| OTA(若纳入范围) | 5–10 工程师日 |
| 硬件(继电器/Hub/适配器/备件,不含板卡) | 约 ¥500–1,300($70–180);板卡另计 |
| 软件许可 | ¥0;esptool 为 GPL-2.0-or-later,桌面端打包需做 GPL 合规评估;espflash 为 Apache-2.0 |
收益主要不是省编译时间,而是降低坏板 brick/返修风险、让每台设备的配置可追溯、让烧录与坏区判定可自动回归。若年产量只有几十台,ROI 不是主要理由;若达到数百台/年,配置 patch 与自动校验的工时收益会明显。
**需要用户先确认的 4 件事**
1. 是否允许一次性修改模板固件(新增 config 分区读取;可选 OTA)?
2. OTA 是否在本次范围内?当前分区表没有任何 OTA 分区,但测试矩阵已包含 OTA,建议本次直接采用 OTA-ready 布局,避免二次迁移。
3. App 分区是否可从 `0x120000` 扩到 `0x140000`?当前 App 已占 **96.94%**(1,143,504 / 1,179,648),只剩 36,144 字节;加入 config 加载/OTA 代码后很可能不够。
4. 是否启用/计划启用 Secure Boot、Flash Encryption、NVS 加密?这决定 config 分区能否明文写入、App 能否重签。
---
## 1. 结论先行:七个必须接受的技术事实
1. **没有“坏块感知烧录器”**。esptool/espflash 不会读取坏块表,也不会跳过坏扇区;它们只会在写入后做 digest 校验,失败就报错。仓库里已有的 `espflash::verify_failed` 日志([`target/fw-budget40-flash.log`](../target/fw-budget40-flash.log)、[`target/soak/vga30-fb3-flash.log`](../target/soak/vga30-fb3-flash.log))说明“写入命令返回”不等于“内容正确”。
2. **SPI NOR 的故障粒度是位/字节,不是 NAND 的块**。把 `0xFADE7` 叫“坏块”只是工程口语;工具应按 4 KiB 扇区统计,但根因要记录到字节/位。
3. **Bootable App 只能整槽位规避**。如果坏字节落在 App 镜像的某个字节上,不能“跳过 4 KiB 继续写”;只能换分区偏移、换 OTA 槽位,或证明当前镜像恰好能写入该字节。
4. **实际镜像写读校验优先于通用图案扫描**。通用 `0x00/0x55/0xAA` 图案能发现“对任意数据都坏”的扇区,但会误报“仅对某些字节坏、当前镜像恰好可写”的槽位。App 槽位的最终判据应是“写入实际 App 镜像 → 读回 → 全字节比较通过”。
5. **合并镜像不适合坏板**。它把 Bootloader、分区表、App 和填充区从 `0x0` 连续写入;坏字节在填充区也会被写入/校验,导致整包失败。分段写入能把故障局部化,也便于独立校验。
6. **App 字节 patch 不是配置方案**。即使只改 SSID 字符串,也会改变 App 的 `Checksum` 和 `Validation Hash`;需要自定义工具重算,且 Secure Boot 下还要重签。config 分区 patch 不触碰 App,App SHA-256 保持不变,天然满足“模板固件 + patch”。
7. **当前 `flash.sh` 不能作为回归基线**。它会在构建后搜索偏移、改 `partitions.csv`、删 `esp-idf-sys` 构建目录、再编一次;这是“让镜像躲开一个已知字节”,不是“检测并屏蔽坏区”。它可以保留为故障板应急脚本,但不应进入产品 provisioning 流程。
---
## 2. 现状审计(基于仓库证据)
### 2.1 固件、Flash 与分区
| 项目 | 当前值 | 证据 | 影响 |
| --- | --- | --- | --- |
| SoC / 模组 | ESP32-S3-WROOM-1-N16R8,16 MB Flash + 8 MB Octal PSRAM | [`README.md`](../README.md) 硬件表、[`sdkconfig.defaults`](../sdkconfig.defaults) | 目标固定,工具可按 chip/flash 做兼容性门禁 |
| Flash 模式 | 16 MB、DIO、40 MHz | [`sdkconfig.defaults`](../sdkconfig.defaults) `CONFIG_ESPTOOLPY_*` | 烧录参数必须与构建一致;QIO/80 MHz 在本板会 boot loop |
| Bootloader | `0x0`,当前 0x5160 字节 | [`DEVELOPMENT.md` 镜像导出](../DEVELOPMENT.md)、[`target/latency/tx15-verify.log`](../target/latency/tx15-verify.log) | Bootloader 未变时不要重写;写入是单点故障 |
| 分区表 | `0x8000`,当前 0xC00 字节 | [`sdkconfig.defaults`](../sdkconfig.defaults) `CONFIG_PARTITION_TABLE_OFFSET=0x8000`、verify 日志 | 分区表与 Bootloader/App 必须成套 |
| NVS | `0x9000`,`0x6000` | [`partitions.csv`](../partitions.csv) | 目前主要由 Wi-Fi 驱动使用,不应塞入业务配置 |
| PHY init | `0xF000`,`0x1000` | 同上 | 保存射频校准,不要整片擦除 |
| Factory App | `0x70000`,`0x120000`(1,179,648 B) | 同上、[`BUILD-INFO.json`](../target/firmware/BUILD-INFO.json) | App 实际 1,143,504 B,占用 **96.94%**,余量仅 36,144 B |
| OTA | **无** `otadata` / `ota_0` / `ota_1` | [`partitions.csv`](../partitions.csv)、`grep OTA` 无结果 | OTA 测试当前无法执行;需一次性布局变更 |
| 坏字节 1 | `0xFADE7`,bit 5–7 写入异常 | [`HARDWARE.md`](../HARDWARE.md#损坏-flash-模组的兼容处理) | 当前 App 镜像在该地址的字节为 `0x00`,恰好可写 |
| 坏字节 2 | `0x10863`,bit 6 读回为 1(期望 `0x2e`,读回 `0x6e`) | 同上、[`BUILD-INFO.json`](../target/firmware/BUILD-INFO.json) | 尚未确认永久故障;当前靠 App 偏移 ≥ `0x20000` 避开 |
| App 镜像完整性 | `Checksum: 9e (valid)`、`Validation Hash: ... (valid)` | [`target/firmware/IMAGE-INFO.txt`](../target/firmware/IMAGE-INFO.txt) | 直接改 App 字节会破坏两者 |
**当前坏板规避逻辑**([`scripts/flash.sh`](../scripts/flash.sh)):
1. `espflash save-image` 导出 App;
2. 从 `0x20000` 开始按 64 KiB 步进,找最低的偏移,使 App 中落到 `0xFADE7` 的那个字节满足 `byte & 0xE0 == 0`;
3. 把偏移写回 `partitions.csv`;
4. 删除 `esp-idf-sys` 构建目录并重编译;
5. 再次导出 App,确认字节仍可写;
6. 用 `esptool write-flash` 写 `0x0` Bootloader、`0x8000` 分区表、`offset` App;
7. 串口观察。
这条链路的问题:
- 它只检查 `0xFADE7` 一个地址,不检测其他潜在坏字节;
- 它要求“镜像字节恰好是 0x00–0x1F”,换一次编译就可能失效;
- 它修改版本控制中的 `partitions.csv`,重编两次,无法做到“每台不编译”;
- 它依赖 macOS 串口名、Unix `termios`、本地 ESP-IDF Python 目录,Windows 不可用;
- 它没有独立记录“哪些扇区被判定坏、依据是什么”。
### 2.2 工具链与现有烧录入口
| 工具/入口 | 版本/位置 | 现状 | 结论 |
| --- | --- | --- | --- |
| `espflash` | 4.4.0,仓库 [`target/tools/espflash/espflash.exe`](../target/tools/espflash/espflash.exe) | 用于 `flash`、`save-image --merge`、`write-bin`、`monitor`;日志中出现 `verify_failed` | 可做快速写入/合并镜像,但坏板上不能把它的 verify 当唯一门禁 |
| `esptool` | 4.12.0(`IMAGE-INFO` 与 verify 日志) | 用于三段独立 `verify_flash`;`flash.sh` 用 `write-flash` | 作为原始读/擦/写/逐段校验的参考工具 |
| ESP-IDF | v5.5.5 | `esptool`/`parttool`/`otatool`/`nvs_partition_gen` 均随 IDF Python 环境提供 | 可用 `parttool.py` 做命名分区、`otatool.py` 做 OTA |
| Rust 工具链 | `esp-1.97.0.0` | [`README.md`](../README.md) | 模板固件构建固定;不随 provisioning 变化 |
| `export-firmware.ps1` | 硬编码 `C:\s3vt\...` | 导出 App/Bootloader/分区表/merged | 需改为可移植、带 manifest 的导出 |
| `verify-firmware.py` | 仓库 `target/` | 解析分区表、检查 merged、检查 `0xFADE7` | 可扩展为“布局不变量 + manifest 校验” |
| CI | [`.github/workflows/build-client.yml`](../.github/workflows/build-client.yml) | 只构建桌面端、跑 protocol/core 测试 | **没有固件构建、没有硬件在环** |
### 2.3 客户端、配置与测试
| 项目 | 现状 | 证据 | 差距 |
| --- | --- | --- | --- |
| SSID/密码 | 编译期 `env!("WIFI_SSID")` / `env!("WIFI_PASS")` | [`src/main.rs`](../src/main.rs) 36–37、582–599 | 每换 SSID 必须重编;无法按台 patch |
| 运行时配置 | 无;NVS 只传给 `EspWifi` | [`src/main.rs`](../src/main.rs) 168–180 | 需新增 config 分区读取 + 回退 |
| 桌面端串口 | 无 `serialport` 依赖 | [`desktop/src-tauri/Cargo.toml`](../desktop/src-tauri/Cargo.toml) | 需新增设备发现/烧录后端 |
| 桌面端打包 | Tauri v2,bundle targets `app/dmg`,CI 覆盖 | [`desktop/src-tauri/tauri.conf.json`](../desktop/src-tauri/tauri.conf.json) | 需加 sidecar/资源与 Windows NSIS |
| 串口测试 | `serial_soak.py`、`capture-serial.py` | [`scripts/serial_soak.py`](../scripts/serial_soak.py)、[`target/capture-serial.py`](../target/capture-serial.py) | 可复用为 provisioning 后验证 |
| UDP 验证 | `udp_probe.py`、`perf_probe.py` | [`scripts/udp_probe.py`](../scripts/udp_probe.py)、[`scripts/perf_probe.py`](../scripts/perf_probe.py) | 可复用为启动后验收 |
| 主机测试文化 | protocol/core/stream-control 有 host tests | [`DEVELOPMENT.md`](../DEVELOPMENT.md) | 新 config 格式应放共享 crate,做 host tests |
| 产物完整性 | `SHA256SUMS.txt`、`BUILD-INFO.json`、verify 日志 | [`target/firmware/`](../target/firmware) | 可扩展为 manifest + 设备 provisioning 报告 |
### 2.4 差距总表
| 需求 | 当前能力 | 差距 | 优先级 |
| --- | --- | --- | --- |
| 插入设备自动扫描识别 | 无;桌面端只做 UDP 网络发现 | USB 枚举 + VID/PID 过滤 + 主动探测 + 热插拔去抖 | P0 |
| 选择固件烧录 | 手工路径/命令 | 固件清单、兼容性校验、分段写入、逐段校验 | P0 |
| 坏区检测并自动屏蔽 | 单字节偏移搜索 + 重编译 | 分区感知扫描、实际镜像校验、槽位选择、配置副本 | P0 |
| 动态 patch SSID | 编译期常量 | config 分区 + CRC + 多副本 + 回退 | P0 |
| 不重新编译固件 | 每改 SSID 重编 | 模板固件一次构建;每台只写 config | P0 |
| 自动化验证/回归 | 手工脚本 + 零散日志 | pytest + 硬件在环 + JSON 报告 + CI 门禁 | P1 |
| OTA | 无分区、无回滚 | OTA-ready 布局 + 签名/回滚 + 测试 | P1/P2 |
| 安全 | 凭据编译进固件 | config 明文风险、Secure Boot/加密预检、日志脱敏 | P0/P1 |
---
## 3. 需求拆解与术语校准
| 术语 | 本报告中的定义 | 工程含义 |
| --- | --- | --- |
| 坏块 | 工程口语,指某个 4 KiB 扇区内存在无法可靠写入/读回的位或字节 | SPI NOR 通常没有坏块表;按扇区统计,按字节定位 |
| 坏字节 | 某个绝对地址的位固定/翻转,例如 `0xFADE7` | 通用图案扫描会命中;实际镜像未必受影响 |
| 坏扇区 | 4 KiB 擦除/编程/读回至少一种图案失败 | App 槽位可整体避开;config 可换副本 |
| 自动屏蔽 | 在分区/槽位/副本层面避开不可靠区域 | 不是把坏扇区从 App 镜像中间“跳过” |
| 模板固件 | 一次性构建、所有设备共用、App SHA-256 相同的固件 | 每台只写 config,不重编 |
| Config patch | 把 SSID/密码等写入独立 config 分区的某个副本 | 不改 App、不改分区表、不重编 |
| 实际镜像校验 | 写入真实 App/Config 数据后读回逐字节比较 | 比通用图案更贴近最终用途 |
| 通用图案扫描 | 写 `0x00/0x55/0xAA` 等图案并读回比较 | 用于发现硬故障/评估余量,不单独决定 App 槽位 |
| OTA-ready | 分区表预留 `otadata`、`ota_0`、`ota_1`,Bootloader 支持回滚 | 即使暂不启用 OTA,也避免二次迁移 |
> **需求校准**:如果“自动屏蔽坏块”被理解为“在 App 镜像中间跳过坏扇区、仍然正常启动”,在不改 Bootloader、不引入 FTL、不让 App 从文件系统加载的前提下**不可实现**。可交付的定义是:App 选一个实际镜像写读校验通过的槽位;Config 用多副本;Data 区可做扇区跳过。若业务坚持“单槽 App 内部跳过”,应改为更换模组或重新设计启动链,而不是继续调烧录参数。
**“不重新编译”的边界**
- 推荐定义:每台设备不再编译;模板固件由 CI 构建一次,产物带 SHA-256/manifest;
- 若业务要求“连模板源码都不能改”,则只能做 App 字节 patch:需要固定长度占位符、重算镜像 checksum/Validation Hash、Secure Boot 下重签;这是高风险的 Plan C,不应作为主方案;
- 若未来修改 App 逻辑,仍然需要重新构建模板;这不违反“每台不编译”,但需要重新跑布局/坏区/OTA 回归。
**“自动扫描识别”的边界**
- 建议定义为:USB 插入 → 枚举串口 → 按 VID/PID 过滤 → 主动探测 chip/MAC/Flash → 出现在设备列表;
- 不建议把“网络发现”混进 provisioning:网络发现只能证明设备已启动,不能替代 USB 烧录通道;
- 主动探测通常会复位设备进入 Bootloader;需要提供“被动枚举”和“主动探测”两种模式,避免在设备正在推流时误复位;
- 固件侧“自动扫描”只扫描受控目录(如 `firmware/*/manifest.json`),不是任意路径;manifest 的 SHA-256/兼容性校验通过后才进入可选列表。
---
## 4. 工具能力评估
### 4.1 评估维度与权重
权重按本项目“坏板 + 免重编 + 可回归”的目标设定:
| 维度 | 权重 | 说明 |
| --- | --- | --- |
| 坏区检测/规避能力 | 25% | 能否发现坏字节/坏扇区,能否支持槽位/副本规避 |
| 分区级写入/校验 | 20% | 按偏移/分区名写、擦、读、逐段校验 |
| 二进制/配置区 patch | 15% | 能否安全改配置区而不破坏镜像 |
| 自动化/CI 可集成 | 15% | CLI/API、退出码、可解析输出、跨平台 |
| 坏板可靠性/可恢复性 | 10% | 失败是否可诊断、可重试、可恢复 |
| 桌面集成/许可/分发 | 10% | 单文件、许可证、Python 依赖、签名 |
| 成本/学习曲线 | 5% | 团队上手与维护成本 |
分数为 0–10 的专家判断,MVE 后用实测修正。
### 4.2 能力矩阵
图例:✅ 原生支持;⚠️ 需外部脚本/有限支持;❌ 不支持。
| 能力 | esptool | espflash | ESP-IDF parttool/otatool | NVS 工具 | 自研 Python 编排 | Tauri + serialport |
| --- | --- | --- | --- | --- | --- | --- |
| 坏区扫描 | ❌(只有读/写/擦原语) | ❌ | ❌ | ❌ | ✅(组合原语) | ✅(调用编排层) |
| 坏区跳过/重映射 | ❌ | ❌ | ❌ | ❌ | ⚠️(槽位/副本层面) | ⚠️(同左) |
| 按偏移写/擦/读 | ✅ | ✅(`write-bin` 等,需确认 4.4.0 子命令) | ✅(按分区名) | ⚠️(整分区) | ✅ | ✅ |
| 逐段校验 | ✅ `verify_flash` | ⚠️(有 verify,但仓库有失败日志) | ✅(包装 esptool) | ⚠️ | ✅ | ✅ |
| 原始全片备份 | ✅ `read_flash` | ⚠️(需确认) | ⚠️ | ❌ | ✅ | ✅ |
| App 二进制 patch | ❌(不重算 checksum/hash) | ❌ | ❌ | ❌ | ⚠️(需自研) | ⚠️ |
| Config 分区 patch | ✅(按偏移写) | ✅(按偏移写) | ✅(按分区名) | ⚠️(NVS 格式) | ✅ | ✅ |
| OTA | ❌(只写镜像) | ❌ | ✅ `otatool.py` | ❌ | ⚠️(需 IDF OTA) | ⚠️ |
| 自动化/CI | ✅ CLI/退出码 | ✅ CLI | ✅ CLI | ✅ CLI | ✅ | ✅ |
| 单文件分发 | ❌(Python) | ✅ | ❌(Python/IDF) | ❌ | ❌ | ✅(Rust 后端) |
| 许可证 | GPL-2.0-or-later | Apache-2.0 | Apache-2.0(IDF 脚本;运行时通常调用 GPL 的 esptool) | Apache-2.0(IDF) | 自研 | Tauri MIT/Apache;serialport 等依赖各自许可证(需核对) |
> 注:`espflash` 4.4.0 的 `read-flash`/`erase-region` 等子命令需在 MVE E0 用 `--help` 确认;本仓库只使用过 `flash`、`save-image`、`write-bin`、`monitor`。未确认前,扫描/回读统一走 esptool。
### 4.3 esptool:能力与限制
**能做**
- `read_flash`:按绝对地址读任意长度,适合备份、读分区表、读 App/Config 回读;
- `erase_region`:按 4 KiB 对齐擦除;
- `write_flash`:按“偏移 + 文件”写入,可一次多段;只擦除覆盖到的扇区;
- `verify_flash`:把 Flash 内容与文件做 digest 比较;仓库日志中的 `verify OK (digest matched)` 就是它;
- `image_info`:解析 App 镜像的 segments、Checksum、Validation Hash;仓库用它生成 [`IMAGE-INFO.txt`](../target/firmware/IMAGE-INFO.txt);
- `flash_id`/`read_mac`/`chip_id`:识别芯片、Flash、MAC,适合做设备身份与兼容性门禁;
- `merge_bin`:把多段合并为一个从 `0x0` 写入的镜像(适合健康板/归档,不适合坏板)。
**不能做**
- 没有“扫描坏块”“跳过坏块”“坏块重映射”“坏块表”命令;
- 不会理解分区语义(除了镜像格式);按分区名操作要靠 `parttool.py`;
- 不会重算 App 的 `Checksum`/`Validation Hash`;不能安全地做 App 字节 patch;
- 不会在写入失败时自动换槽位/重试到好扇区;
- 默认校验只覆盖“本次写入的区间”,不覆盖全片;不要用“命令成功”推断整片健康。
**关键限制**
1. **合并镜像的填充区**:`merge_bin` 生成的镜像从 `0x0` 连续排布,分区间会填充;`write_flash 0x0 merged.bin` 会把这些填充也写进去。坏板上的坏字节若落在填充区,校验会失败;即使不失败,也浪费写入并扩大故障面。
2. **小文件写入会擦整个扇区**:写 256 字节 config 也会擦除其所在的 4 KiB 扇区;因此 config 必须“一条记录占一个扇区”,不能把多条记录塞在同一扇区。
3. **版本/命令名差异**:v4 使用 `write_flash` 等下划线形式;v5 改为 `write-flash` 等连字符形式,并可能改变输出。**锁定 4.12.0 或 5.6.0 其中之一**,不要在同一流水线混用。
4. **GPL**:esptool 是 GPL-2.0-or-later;如果把它打包进闭源桌面安装包,需要做 GPL 合规(源码提供、许可证声明等)。P0 内部工具无碍;P1 产品化优先 espflash 或独立进程+法律评估。
### 4.4 espflash:能力与限制
**优势**
- 单文件 Rust 二进制,Apache-2.0,桌面端打包/签名比 Python 容易;
- 本仓库已在用:`flash`、`save-image --merge --skip-padding`、`write-bin`、`monitor`;
- 写入速度快,支持 `--chip/--port/--flash-size/--flash-mode/--flash-freq/--partition-table/--bootloader`;
- 适合做“产品侧写入器”和“健康板快速烧录”。
**风险**
- 仓库中两次 `espflash::verify_failed`([`target/fw-budget40-flash.log`](../target/fw-budget40-flash.log)、[`target/soak/vga30-fb3-flash.log`](../target/soak/vga30-fb3-flash.log))没有记录根因;不能据此断言是坏字节,但足以说明**不能把 espflash 的 verify 当唯一验收**;
- 其校验实现/覆盖范围与 esptool 不完全相同;在坏板上可能出现“esptool 分段 verify 通过、espflash 整包 verify 失败”或反之;
- 没有坏区扫描/槽位选择;`write-bin` 只写指定地址;
- 合并镜像同样有填充区问题。
**建议**
- 用 espflash 做“写”,用 esptool 做“独立读回/逐段校验”;
- 在 MVE E4 做 parity 测试:同一镜像分别用 espflash 和 esptool 写/校验,记录差异;只有 parity 稳定后,才允许 espflash 单独作为健康板门禁;
- 坏板/迁移路径默认走 esptool 分段写入。
### 4.5 ESP-IDF parttool / otatool / NVS 工具
| 工具 | 用途 | 适用点 | 限制 |
| --- | --- | --- | --- |
| `parttool.py` | 按分区名读/写/擦、查分区信息 | config 分区 patch、按名字定位、避免硬编码偏移 | 依赖 IDF Python 环境和分区表;仍是 esptool 包装 |
| `otatool.py` | 读写 OTA 分区、切换启动分区、查询 OTA 信息 | OTA 测试、强制回滚实验 | 需要 OTA 分区布局;不负责签名/业务校验 |
| `nvs_partition_gen.py` | 从 CSV 生成 NVS 镜像 | 如果坚持把配置放 NVS | 生成整分区,容易覆盖 Wi-Fi 驱动使用的 NVS;不推荐与 Wi-Fi 共用 |
| `nvs_tool.py` | 解析/导出 NVS | 诊断 | 不适合作为高频 patch 通道 |
| `espsecure.py` | 签名/加密 | Secure Boot/Flash Encryption | 需要密钥与流程;不是 patch 工具 |
| `espefuse.py` | 查看/烧录 eFuse | 安全预检(Secure Boot/Flash Encryption 状态) | 写 eFuse 不可逆,必须只读预检 |
**结论**:`parttool.py` 很适合 config 分区,`otatool.py` 适合 OTA;但它们都不是“坏块工具”。NVS 方案不如独立 raw config 分区:NVS 与 Wi-Fi 驱动共用分区,整分区生成/写入会带来覆盖风险。
### 4.6 方案评分与选型
| 方案 | 坏区 25% | 分区 20% | Patch 15% | 自动化 15% | 坏板可靠 10% | 集成/许可 10% | 成本 5% | 加权 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| A. 纯 esptool CLI | 3 | 9 | 4 | 9 | 8 | 5 | 9 | **6.25** |
| B. 纯 espflash | 2 | 8 | 3 | 8 | 5 | 9 | 8 | **5.55** |
| C. ESP-IDF parttool/otatool + esptool | 2 | 9 | 8 | 7 | 8 | 4 | 6 | **6.05** |
| D. Python 编排 + esptool(P0) | 8 | 9 | 9 | 9 | 8 | 5 | 6 | **8.10** |
| E. D 的产品化(Tauri + serialport + espflash 侧车 + esptool 校验) | 8 | 9 | 9 | 8 | 8 | 9 | 5 | **8.30** |
**推荐组合**
- **P0(现在)**:方案 D。Python 3.11 + esptool 4.12.0 + pyserial + pytest;一个 `provision.py` CLI 完成发现/扫描/烧录/config patch/校验/报告。先不碰桌面端,最快拿到可回归证据。
- **P1(产品化)**:方案 E。Tauri Rust 后端用 `serialport` 做发现,`espflash` 侧车做写入,esptool 做独立校验;UI 只负责选择/展示/确认。
- **P2(OTA)**:在 E 的基础上加 `otatool.py` 或 ESP-IDF OTA;OTA 分区/回滚在模板阶段一次到位。
- **不推荐**:纯 B(坏板可靠性不足)、纯 A 打包进桌面(Python/GPL)、直接 App 字节 patch、坏板写 merged image。
**敏感性**
- 若“桌面端不能带 Python/GPL”,D 的集成分下降、E 上升;P0 仍可用 D 做内部工具,P1 再换。
- 若 OTA 是硬需求,C/E 的 OTA 能力加分;但仍需自定义“坏槽位安全失败”逻辑。
- 若把“坏板可靠”权重提到 20%,D/E 仍领先,因为坏区能力是自研编排层提供的,不是 esptool/espflash 原生能力。
---
## 5. 推荐架构
### 5.1 总体架构
```text
┌──────────────────────────────────────────────────────────────────┐
│ Tauri 桌面端(P1) / Python CLI(P0) │
│ - 设备列表/热插拔 - 固件清单/兼容性选择 │
│ - 扫描结果/坏区地图 - SSID/密码表单(不落日志) │
│ - 进度/日志/报告导出 - 失败恢复指引 │
└───────────────┬──────────────────────────────────────────────────┘
│ 命令/事件(Tauri IPC 或 CLI 参数)
┌───────────────▼──────────────────────────────────────────────────┐
│ Provisioning Orchestrator(Rust 或 Python) │
│ 1. 发现/识别:serialport → VID/PID → chip_id/read_mac/flash_id │
│ 2. 清单匹配:chip/flash/layout/app_sha256/config_schema │
│ 3. 备份/扫描:分段 read_flash + 实际镜像/图案校验 │
│ 4. 槽位选择:App slot / config slot / 失败策略 │
│ 5. 写入:esptool/espflash 分段 write;安全顺序;逐段 verify │
│ 6. 配置:生成 config record;写多副本;回读 CRC │
│ 7. 启动验收:串口日志 + UDP/perf probe │
│ 8. 报告:JSON + 日志 + SHA-256 + 坏区地图 │
└───────────────┬──────────────────────────────────────────────────┘
│
┌───────────────▼──────────────────────────────────────────────────┐
│ 固件(一次性模板) │
│ Bootloader 0x0 → 分区表 0x8000 → App(factory/ota) → config 分区 │
│ App 启动:读 config 多副本 → 校验 magic/version/CRC/seq → Wi-Fi │
│ 失败回退:编译期默认 SSID/PASS,并打印 config source │
└──────────────────────────────────────────────────────────────────┘
```
### 5.2 分区布局:从“单 App 躲字节”改为“统一布局 + 槽位”
**当前布局**
| 分区 | 偏移 | 大小 | 说明 |
| --- | ---: | ---: | --- |
| Bootloader | `0x0` | ~`0x5160` | 不随 App 移动 |
| Partition table | `0x8000` | `0xC00` | 自定义表 |
| NVS | `0x9000` | `0x6000` | Wi-Fi 驱动 |
| PHY | `0xF000` | `0x1000` | 射频校准 |
| Factory | `0x70000` | `0x120000` | 包含 `0xFADE7`,靠镜像字节恰好可写 |
**建议布局(OTA-ready;MVE 可先用 `0x120000` 槽位)**
| 分区 | 类型/子类型 | 偏移 | 大小 | 说明 |
| --- | --- | ---: | ---: | --- |
| Bootloader | — | `0x0` | ≤`0x8000` | 当前 0x5160,保持 |
| Partition table | — | `0x8000` | `0xC00` | 与 Bootloader/App 成套 |
| NVS | `data/nvs` | `0x9000` | `0x6000` | 保持现偏移,迁移时保留 |
| PHY | `data/phy` | `0xF000` | `0x1000` | 保持现偏移,保留校准 |
| *gap* | — | `0x10000` | `0x1000` | 含 `0x10863`,**不要分配** |
| `otadata` | `data/ota` | `0x11000` | `0x2000` | OTA 状态,两扇区 |
| `config` | `data/0x99`(或 `undefined`) | `0x13000` | `0x10000` | 4 个 4 KiB 副本槽 |
| *gap* | — | `0x23000` | `0xDD000` | 含 `0xFADE7`,**不要分配** |
| `factory` | `app/factory` | `0x100000` | `0x140000`(建议) | 恢复槽;当前 App 约 1.09 MiB(1,143,504 B) |
| `ota_0` | `app/ota_0` | `0x240000` | `0x140000` | OTA 槽 A |
| `ota_1` | `app/ota_1` | `0x380000` | `0x140000` | OTA 槽 B |
| 空闲/数据 | — | `0x4C0000` | 至 `0x1000000` | 约 11.25 MiB,可放日志/资源 |
> 若 MVE 先用 `0x120000` 槽位:`factory=0x100000`、`ota_0=0x220000`、`ota_1=0x340000`、空闲从 `0x460000` 开始;`otadata`/`config` 不变。模板定稿建议 `0x140000`,避免 App 增长后再次迁移。
**为什么这样排**
- `0x10863` 落在 `0x10000–0x11000` 的空隙;`0xFADE7` 落在 `0x23000–0x100000` 的空隙;两者都不在任何被写入的分区内。
- App 从 `0x100000` 开始后,不再需要“镜像字节恰好可写”的检查;模板可以稳定重建,不会因为一次编译移动字节而踩雷。
- `factory` + `ota_0` + `ota_1` 让 OTA 有回滚余地;`factory` 作为恢复镜像,OTA 失败不会失去可启动 App。
- `config` 独立于 NVS,不与 Wi-Fi 驱动抢分区;4 个副本可跨坏扇区。
- 统一布局对好板和坏板一致,避免“好板一套、坏板一套”的 provisioning 分叉。
**槽位大小决策**
- 当前 App 1,143,504 B / `0x120000` = **96.94%**,只剩 36,144 B;
- 新增 config 读取、日志、OTA rollback 代码后,36 KB 余量偏紧;
- 建议模板槽位用 `0x140000`(1.25 MiB),留约 163 KiB(167,216 B);三个槽共 3.75 MiB,16 MiB Flash 足够;
- 若坚持 `0x120000`,当前 96.94% 已经超过“App ≤ 分区 95%”门禁,实际上只剩 35.3 KiB;只能把门禁改为 98%(仍无余量)或扩到 `0x140000`。建议扩。
**迁移**
- 现网旧布局(`0x70000`)板卡需要一次全量布局迁移:写新分区表 + 新 factory App(+ 可选 Bootloader);
- NVS/PHY 偏移保持不变,迁移时不要擦除,保留 Wi-Fi 校准;
- 旧 App 区域 `0x70000` 可保留(不被新表引用),确认稳定后再擦除;
- 工具检测到旧布局时,应提示“需要迁移”,不要直接把新 App 写到旧偏移。
### 5.3 Config 记录格式与 patch 流程
**目标**:App 不重编、不改字节;每台只写 256 B 配置记录;至少一个副本可读;掉电不丢配置。
**记录格式(256 B,小端)**
| 偏移 | 长度 | 字段 | 规则 |
| ---: | ---: | --- | --- |
| 0 | 4 | magic | `S3CF`(ASCII 字节 `0x53 0x33 0x43 0x46`) |
| 4 | 2 | version | 当前 1;不兼容时固件回退默认 |
| 6 | 2 | flags | bit0=密码存在;其余保留 0 |
| 8 | 8 | seq | 单调递增;固件选最大 seq |
| 16 | 1 | ssid_len | 0–32,UTF-8 字节数 |
| 17 | 1 | pass_len | 0–64,UTF-8 字节数 |
| 18 | 2 | reserved | 0 |
| 20 | 32 | ssid | 右补 0;禁止内嵌 `\0` |
| 52 | 64 | password | 右补 0;**不写日志** |
| 116 | 4 | crc32 | 对 0–115 字节做 IEEE CRC32 |
| 120 | 136 | reserved | `0xFF`;未来可放 HMAC/标签 |
**副本布局**
- `config` 分区 64 KiB = 16 × 4 KiB;
- 使用前 4 个扇区:`+0x0000`、`+0x1000`、`+0x2000`、`+0x3000`;
- 每条记录独占一个扇区;写入会擦整个扇区,这是预期行为;
- 工具选择“当前不是最高 seq 的有效副本”写入,写入后回读校验;至少保留一个旧有效副本;
- 固件启动时扫描 4 个槽,选 `magic/version/length/CRC` 全部通过且 seq 最大的记录;
- 若全部无效:回退到编译期 `WIFI_SSID`/`WIFI_PASS`,打印 `config source=default`;
- 若使用分区成功:打印 `config source=partition seq=N ssid=...`(不打印密码)。
**Python 参考实现(可直接用于 P0 工具)**
```python
import struct, zlib
MAGIC = b"S3CF"
VERSION = 1
RECORD_SIZE = 256
CRC_OFFSET = 116
CRC_SPAN = 116
def pack_config(ssid: str, password: str, seq: int) -> bytes:
ssid_b = ssid.encode("utf-8")
pass_b = password.encode("utf-8")
assert 0 <= len(ssid_b) <= 32 and 0 <= len(pass_b) <= 64
assert b"\x00" not in ssid_b and b"\x00" not in pass_b
header = struct.pack(
"<4sHHQBBH",
MAGIC, VERSION, 1 if pass_b else 0, seq,
len(ssid_b), len(pass_b), 0,
)
body = header + ssid_b.ljust(32, b"\x00") + pass_b.ljust(64, b"\x00")
assert len(body) == CRC_SPAN
crc = zlib.crc32(body) & 0xFFFFFFFF
return body + struct.pack("<I", crc) + b"\xFF" * (RECORD_SIZE - CRC_OFFSET - 4)
def parse_config(buf: bytes):
if len(buf) != RECORD_SIZE or buf[:4] != MAGIC:
return None
version, flags, seq, ssid_len, pass_len, _ = struct.unpack_from("<HHQBBH", buf, 4)
if version != VERSION or not (0 <= ssid_len <= 32 and 0 <= pass_len <= 64):
return None
if zlib.crc32(buf[:CRC_SPAN]) & 0xFFFFFFFF != struct.unpack_from("<I", buf, CRC_OFFSET)[0]:
return None
ssid = buf[20:20 + ssid_len].decode("utf-8")
password = buf[52:52 + pass_len].decode("utf-8")
return {"version": version, "flags": flags, "seq": seq,
"ssid": ssid, "password": password}
```
**固件侧建议**
- 把解析逻辑放到可 host test 的共享 crate(类似 `protocol`),用 `cargo +stable test` 覆盖:magic/version/长度/CRC/seq 选择/回退;
- 读取 `config` 分区用 `esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "config")`,按 label 查找,避免 subtype 依赖;
- 在 `connect()` 之前加载;`ClientConfiguration` 的 SSID/password 用运行时字符串 `try_into()`;
- 现有 `SSID` 常量还在 `connect()`、`advertised_auth()`、`report_scan()` 与 `client_config()` 中使用;改为运行时配置后,把这些函数的比较/日志改为传入的 `&str`,否则扫描诊断仍指向编译期默认 SSID;
- 失败时回退到 `env!` 常量,保持现有行为;
- 日志只打印 `ssid`、`seq`、`source`,绝不打印密码。
**为什么不用 NVS**
- NVS 当前由 `EspWifi` 使用;`nvs_partition_gen.py` 生成整分区镜像,容易覆盖 Wi-Fi 驱动的键;
- NVS 的磨损均衡/CRC 对“整条记录”友好,但对“每台写一次、保留多副本”的 provisioning 不如 raw 分区直观;
- raw config 可以用 `parttool.py`/`esptool` 按偏移读写,回读/比对简单;
- 若未来配置项变多,可把 config 分区扩到 128 KiB,仍不与 NVS 冲突。
**安全说明**
- CRC32 只保证完整性,不保证真实性;能接触 Flash 的人可以改 SSID/密码;
- 若威胁模型包含物理篡改,增加 HMAC(设备唯一密钥放 eFuse/NVS)或启用 Flash Encryption;
- provisioning 文件、日志、报告中的密码必须脱敏;报告只记录 `ssid`、`config_sha256`、`seq`;
- 当前仓库把 Wi-Fi 凭据编译进 App 并随包分发;config 分区至少把凭据从 App 中分离出来,但仍需访问控制。
### 5.4 坏区扫描与“自动屏蔽”
**核心原则**
1. **App 槽位:实际镜像写读校验 > 通用图案扫描**。最终判据是“这个 App 镜像写进去、读回来、逐字节一致”;通用图案只用于评估余量和发现硬故障。
2. **Config 槽位:实际记录写读校验 + 多副本**。每个槽写真实 256 B 记录,回读/CRC 通过即可用;失败换下一个槽。
3. **不跳过 App 镜像内部的扇区**。没有文件系统/FTL 能改变 Bootloader 的线性读取;不要尝试“把坏扇区绕过去”。
4. **扫描目标按分区裁剪**。常规 provisioning 只对 Bootloader/分区表/otadata/config/App 槽位做写入校验;NVS/PHY 含 Wi-Fi/射频校准,默认只读或跳过,除非有已验证备份且确有必要。跳过 `0xFADE7`、`0x10863` 所在的空隙,避免无意义的写入和“恢复失败”。
5. **先备份,后破坏性测试**。任何写图案/擦除前,先 `read_flash` 备份目标区间;能恢复才做。
**扫描算法(分区感知)**
```text
# 图案扫描用于非 App 区;NVS/PHY 默认只读或跳过(除非有已验证备份)
# App 槽位用实际镜像写读校验(见下)
for partition in [bootloader, partition_table, otadata, config, data]:
if partition is known_bad_gap: skip
backup = read_flash(partition.offset, partition.size)
for chunk in 64 KiB chunks:
for pattern in (0x00, 0x55, 0xAA):
write_flash(chunk.offset, pattern * chunk.size) # 默认校验若失败,继续回读
readback = read_flash(chunk.offset, chunk.size)
for each byte: if readback != pattern: mark_bad(offset, expected, actual)
restore chunk from backup
verify_flash(chunk.offset, backup_chunk)
# App 槽位:写实际镜像 → 读回 → 全字节比较
for slot in app_slots:
write_flash(slot.offset, app_image)
readback = read_flash(slot.offset, len(app_image))
if readback != app_image: mark_slot_bad(slot, first_mismatch)
```
**为什么用 64 KiB 分块**
- 单次全片图案写入如果中途失败,定位困难;64 KiB 能局部化,又比 4 KiB 逐扇区快;
- `write_flash` 只擦覆盖扇区;分块不会碰其他分区;
- 对已知坏板,先扫“模板将写入的区间”;对疑似板,再做全片。
**图案选择**
| 图案 | 能发现 | 说明 |
| --- | --- | --- |
| `0x00` | 位 stuck-at-1(无法清 0) | 例如 `0x10863` 的 bit 6 若固定为 1,写 0 会读回 `0x40` 等 |
| `0x55` | 交替位、相邻位干扰;可发现“无法置 1”的位 | `0xFADE7` 的 bit 5–7 若无法置 1,写 `0x55` 会失败 |
| `0xAA` | 交替位(反相);可发现“无法置 1”的位 | `1010_1010` |
| `0xFF` | 位 stuck-at-0(无法置 1) | 擦除态;写 `0xFF` 是无操作,通常通过“擦除后读回”检查 |
| 可选 `0xA5` | 更复杂的位组合 | 增加覆盖,但时间上升 |
**判定与分级**
| 结果 | 定义 | 处理 |
| --- | --- | --- |
| GOOD | 所有图案写读一致 | 可用于 App/config/data |
| SUSPECT | 首次失败、重试通过;或仅个别位不稳定 | 隔离;重复扫描;优先不用 |
| BAD | 多次重试仍失败 | 不用于 App/config;记录绝对地址/位 |
| UNRECOVERABLE | 恢复备份后仍无法读回原数据 | 停止,标记返修;不要继续写 |
**“自动屏蔽”的真实含义**
| 区域 | 能否扇区级跳过 | 正确做法 |
| --- | --- | --- |
| Bootloader | 否 | 固定 `0x0`;坏则整板不可用/返修 |
| 分区表 | 否 | 固定 `0x8000`;坏则整板不可用/返修 |
| App(factory/ota) | 否 | 选一个实际镜像写读校验通过的槽位;否则拒绝/返修 |
| Config | 是(逻辑层) | 多副本;写实际记录;固件选有效副本 |
| 数据/日志 | 是(需自研/LittleFS) | 按扇区 CRC + 跳过表;P0 可不做 |
**对当前 `0xFADE7` 的正确理解**
- 当前 App 在 `0x70000`,镜像字节 `0x00` 恰好可写,所以 `verify OK`;
- 如果用 `0x55`/`0xAA` 做通用图案扫描,扫描到 `0xFADE7` 会失败;但**这不代表当前 App 不可用**;
- 因此:App 槽位以“实际镜像 verify”为准;通用扫描结果只作为“该槽位对未来镜像的余量”参考;
- 新布局把 `0xFADE7` 放进空隙,问题消失;仍保留扫描用于发现其他未知坏字节。
**扫描时间**
- 16 MB 全片、3 图案、460800 baud:约 10–30 分钟(取决于写入/读取/擦除与重试);
- 模板写入区间约 4.5 MB:约 3–8 分钟;
- 生产建议分层:
- Tier 0(每台,<10 s):`flash_id`/`read_mac` + 读已知坏地址所在扇区 + 实际 App 写读校验;
- Tier 1(每台或抽检,几分钟):扫描模板写入区间;
- Tier 2(疑似/RMA,十几分钟):全片扫描。
### 5.5 Provisioning 状态机
```text
IDLE
→ DETECTED USB 插入/枚举到候选串口
→ IDENTIFIED chip_id + read_mac + flash_id + 读分区表
→ MANIFEST_OK 固件清单匹配 chip/flash/layout/app_sha256
→ BACKED_UP 读回关键分区/全片备份(按策略)
→ SCANNED 坏区地图/实际镜像校验结果
→ SLOT_SELECTED App slot / config slot 选择
→ WRITTEN 分段写入(安全顺序)
→ VERIFIED 逐段 verify + 回读 SHA-256
→ CONFIGURED config 多副本写入 + 回读 CRC
→ BOOT_VALIDATED 串口日志 + UDP/perf probe
→ REPORTED JSON/日志/地图/退出码
```
**安全写入顺序(迁移/首次烧录)**
1. 备份(至少 NVS/PHY/旧分区表/旧 App 的关键区);
2. 写新 App 到新槽位(旧分区表尚未引用它,旧 App 仍可启动);
3. 写 config 副本(旧 App 不读它,无影响);
4. 写新分区表(切换引用;这是单点故障,尽量最后写);
5. 如需更新 Bootloader,最后写;
6. 复位,串口/UDP 验收。
> 注意:当前 `flash.sh` 的顺序是 Bootloader → 分区表 → App;迁移时若在分区表写入后掉电,设备可能无法启动。建议按“先 App、再表、最后 Bootloader”的顺序降低风险。首次空白板可不受此限,但统一顺序更安全。
**幂等与失败处理**
- 重复执行同一 manifest:允许;先读回/比对,已正确的段可跳过写入;
- 写入失败:最多重试 3 次;仍失败则标记该槽位 BAD,换槽位;
- 校验失败:不要重试到“碰巧通过”;先读回定位差异字节,再决定重写/换槽;
- 断电/拔出:记录最后完成阶段;重新插入后从该阶段恢复;不要报告成功;
- 退出码建议:0 成功;2 未发现设备;3 清单不兼容;4 扫描失败;5 写入失败;6 校验失败;7 config 失败;8 启动验收失败;9 中断;10 安全预检失败。
### 5.6 桌面端/CLI 集成
**P0:Python CLI(推荐先做)**
```text
python tools/provision.py discover
python tools/provision.py identify --port COM5
python tools/provision.py scan --port COM5 --profile tier1 --out reports/COM5-scan.json
python tools/provision.py flash --port COM5 --manifest firmware/0.2.0/manifest.json
python tools/provision.py patch-config --port COM5 --ssid "..." --password-stdin --slot auto
python tools/provision.py verify --port COM5 --manifest ... --seconds 15
python tools/provision.py report --out reports/...
```
- 纯 CLI 便于 CI、便于复现;Tauri 后续调用同一套逻辑;
- 所有命令输出 JSON(`--json`),人类可读日志走 stderr;
- 密码通过 stdin/环境变量/受限权限临时文件传入,避免命令行历史泄漏;示例中的 `--password-stdin` 是占位,实际参数以工具实现为准;
- 报告目录按 `<timestamp>-<mac>/` 组织,包含 `device.json`、`scan.json`、`commands.log`、`serial.log`、`udp.json`、`result.json`。
**P1:Tauri 集成**
- Rust 后端新增 `serialport`(发现/打开/读写)与 provisioning 模块;
- 侧车:优先 `espflash.exe`(Apache-2.0、单文件);esptool 作为独立校验器(若允许 Python/GPL),或把扫描/校验逻辑移植到 Rust + espflash;
- Tauri `bundle.externalBin`/`resources` 打包侧车;Windows NSIS 安装包需要代码签名;
- UI:
- 设备列表:COM、VID/PID、chip、MAC、Flash、当前布局;
- 固件选择:manifest 列表、版本/日期/SHA-256/兼容性;
- 扫描结果:坏区地图(地址、扇区、位、图案、状态);
- 配置表单:SSID/密码、校验、显示“只写 config、不改 App”;
- 进度/日志/报告导出/失败恢复指引;
- 权限:Tauri v2 capabilities 只开必要命令;侧车路径固定;不执行任意用户命令;
- 单实例/端口锁:同一串口只允许一个 provisioning 进程;避免与 `espflash monitor`/串口助手冲突。
**P1 的两种实现路径**
| 路径 | 写入 | 校验/扫描 | 优点 | 风险 |
| --- | --- | --- | --- | --- |
| P1-A | espflash 侧车 | esptool 侧车(Python) | 复用现有工具、快 | 双运行时、GPL/Python 打包 |
| P1-B | espflash crate/侧车 | Rust 自研(espflash 读写) | 单运行时、易签名 | 需补 read/erase/verify parity;开发量大 |
建议:先用 P1-A 做内部试点;若产品要求单文件/无 Python,再投入 P1-B。
### 5.7 与现有脚本/CI 的衔接
| 现有资产 | 复用方式 | 需要改动 |
| --- | --- | --- |
| `scripts/build.ps1` | 模板构建仍用它;`.env` 只提供默认 SSID | 不再要求每台改 `.env`;构建产物加 manifest |
| `target/export-firmware.ps1` | 导出 App/Bootloader/分区表 | 去掉硬编码 `C:\s3vt`;生成 `manifest.json` + `SHA256SUMS`;merged 仅归档 |
| `scripts/flash.sh` | 保留为故障板应急 | 不作为产品入口;标注 macOS-only/会重编 |
| `target/verify-firmware.py` | 扩展为布局不变量检查 | 检查坏地址不在写入分区;检查 App 大小/分区余量 |
| `target/refresh-deliverables.py` | 扩展为 manifest/报告打包 | 加入 config schema、布局 hash、工具版本 |
| `scripts/serial_soak.py` | provisioning 后串口验收 | 增加超时/失败退出码;可被 pytest 调用 |
| `scripts/udp_probe.py` / `perf_probe.py` | 启动后网络验收 | pytest 解析 JSON;断言帧率/丢帧阈值 |
| `.github/workflows/build-client.yml` | 复用 CI 结构 | 新增 host tests job;硬件 job 用独立 runner |
**建议新增的 CI 分层**
1. **PR 门禁(无硬件)**:Python lint/单元测试、config pack/parse、partition table 解析、manifest schema、坏区选择逻辑、命令构造 golden tests;
2. **Nightly(硬件在环)**:1 好板 + 1 坏板;跑 N/B/H/P 子集;产出 JSON/JUnit;失败保留日志;
3. **Release 门禁**:模板构建 + 三段 verify + config patch + 启动验收;人工签字。
> 现有 CI 的 Windows runner 处于低完整性沙箱(见 workflow 注释),不适合 USB 硬件在环;硬件 job 应使用独立、正常完整性、可访问 USB 的 runner,不要复用 `windows-runner-01`。
---
## 6. 最小可行性验证实验(MVE)
> 目的:用最小成本证明/证伪关键假设,再决定是否投入产品化。
> 所有命令中的 `COM5`、路径、IP 均需替换。执行前确认板子已备份、供电稳定、没有其他串口程序占用。
>
> **最小闭环(Stop/Go)**:E0 → E1 → E2/E3 → E4 → E5。若这五步不能全部通过,不要投入 P1;其中 E3(实际镜像 vs 图案)、E4(分段 vs merged)、E5(config patch 不改 App)是三个关键证伪点。
### E0. 工具与设备基线(0.5 天)
**目标**:锁定版本、确认 chip/Flash/分区表/镜像信息。
```powershell
python -m esptool version
.\target\tools\espflash\espflash.exe --version
python --version
python -m esptool --chip esp32s3 --port COM5 flash_id
python -m esptool --chip esp32s3 --port COM5 read_mac
python -m esptool --chip esp32s3 --port COM5 read_flash 0x8000 0xC00 partition-table-readback.bin
python -m esptool --chip esp32s3 image_info target\firmware\esp32s3-app.bin
```
**通过标准**
- `esptool` 4.12.0、`espflash` 4.4.0(或明确锁定的替代版本);
- `flash_id` 显示 16 MB、厂商/型号与 manifest 一致;
- `read_mac` 得到稳定 MAC;
- `image_info` 显示 `Checksum valid`、`Validation Hash valid`;
- 读回分区表可解析,`factory` 偏移与 manifest 一致。
**产物**:`reports/E0/versions.txt`、`device.json`、`image-info.txt`。
### E1. 全片/关键区备份与读稳定性(0.5 天)
**目标**:证明读通道稳定;为后续破坏性实验留后路。
```powershell
python -m esptool --chip esp32s3 --port COM5 --baud 460800 read_flash 0 0x1000000 backup-1.bin
python -m esptool --chip esp32s3 --port COM5 --baud 460800 read_flash 0 0x1000000 backup-2.bin
Get-FileHash backup-1.bin, backup-2.bin -Algorithm SHA256
```
**通过标准**
- 两次 SHA-256 完全一致;若不一致,先排查 USB/供电/波特率,不进入后续实验;
- 备份文件 ≥16 MB,SHA-256 记录在报告;
- 关键区(`0x0–0x23000`、`0x100000–0x4C0000`)单独备份一份,减小恢复粒度。
**风险**:460800 下 16 MB 读取约 6–10 分钟;不要用 921600 作为首次备份速率,除非 E0/E1 已证明稳定。
### E2. 已知坏字节表征(0.5–1 天)
**目标**:复现 `0xFADE7`、`0x10863`,确认是永久还是偶发;得到“期望/实际”位图。
**只在一台可恢复的故障板/牺牲板上做;先完成 E1 备份。**
```powershell
# 读周边扇区,先看原始值
python -m esptool --chip esp32s3 --port COM5 read_flash 0xFA000 0x1000 fade7-before.bin
python -m esptool --chip esp32s3 --port COM5 read_flash 0x10000 0x1000 s10863-before.bin
# 生成 4 KiB 图案文件(0x00/0x55/0xAA 同理)
python -c "open('pattern-00-4k.bin','wb').write(b'\x00'*4096)"
# 擦除 + 写 0x00 图案,读回
python -m esptool --chip esp32s3 --port COM5 erase_region 0xFA000 0x1000
python -m esptool --chip esp32s3 --port COM5 write_flash 0xFA000 pattern-00-4k.bin
# 若 write_flash 因坏字节返回校验失败,不要中断:数据通常已写入,
# 继续用 read_flash 回读并记录差异;不要依赖 --no-verify(版本支持情况不一)。
python -m esptool --chip esp32s3 --port COM5 read_flash 0xFA000 0x1000 fade7-00.bin
# 再写 0x55 / 0xAA / 0xFF,重复读回
```
> 扫描实验的目标是“拿到读回差异”,不是“让 write_flash 返回 0”。最终烧录必须保留默认校验;不要关闭校验。若所用版本确实提供 `--no-verify`,也只可用于图案扫描,并在报告中注明。
**通过标准**
- `0xFADE7` 在 `0x00` 或 `0x55/0xAA` 图案下复现固定偏差(例如 bit 5–7 保持 1 或 0);
- `0x10863` 若在多次擦写后仍读回 `0x6E`(期望 `0x2E`),标记为永久;若某次恢复 `0x2E`,标记为偶发/待观察;
- 恢复原始扇区后 `verify_flash` 通过;若无法恢复,停止并标记该板;
- 记录:地址、期望字节、实际字节、异或位图、图案、重试次数、温度/供电。
**风险**:此实验会擦除目标扇区;必须确认备份可恢复。不要在全片备份未验证前做。
### E3. “坏扇区” vs “当前镜像可写”(1 天)
**目标**:证明通用图案扫描会误报、实际镜像校验才是 App 槽位的正确判据。
**步骤**
1. 对当前 `factory` 分区(`0x70000`,`0x120000`)做通用图案扫描,记录 `0xFADE7` 是否被标记 BAD;
2. 用 `esptool write_flash 0x70000 target\firmware\esp32s3-app.bin` 写实际镜像,`verify_flash`;
3. 对比:
- 通用扫描:`0xFADE7` 失败(预期);
- 实际镜像:`verify OK`(当前镜像字节 `0x00`,预期);
4. 若可能,构造一个“镜像字节在 `0xFADE7` 为非 0 值”的测试镜像(可用 Python 改一个字节并重算 checksum/hash,仅用于实验),写入同一槽位,预期 `verify` 失败。**不要把这个测试镜像用于启动。**
**通过标准**
- 通用扫描标记 BAD、实际镜像 verify OK 同时成立;
- 测试镜像 verify 失败,且失败地址定位到 `0xFADE7`;
- 结论写入报告:App 槽位必须以实际镜像为准;通用扫描只做余量评估。
### E4. 分段写入 vs 合并镜像(0.5 天)
**目标**:证明坏板上分段写入可用、合并镜像高风险/失败。
```powershell
# 分段写入(推荐)
python -m esptool --chip esp32s3 --port COM5 --baud 460800 write_flash `
--flash_mode dio --flash_freq 40m --flash_size 16mb `
0x0 target\firmware\bootloader.bin `
0x8000 target\firmware\partition-table.bin `
0x70000 target\firmware\esp32s3-app.bin
# 逐段校验
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x0 target\firmware\bootloader.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x8000 target\firmware\partition-table.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x70000 target\firmware\esp32s3-app.bin
```
```powershell
# 对照:合并镜像(仅实验,确认失败/风险)
python -m esptool --chip esp32s3 --port COM5 write_flash 0x0 target\firmware\esp32s3-merged.bin
```
**通过标准**
- 分段写入 3 段均 `verify OK`;串口启动;
- 合并镜像在坏板上若失败,记录失败地址/校验错误;若通过,也不能据此认为安全(填充区可能未被覆盖/未被校验);
- 报告明确:坏板/迁移路径只用分段。
**注意**:E4 用当前 `0x70000` 布局做对照;模板切到 `0x100000` 后,把三条偏移替换为新布局。`write_flash` 的 `--flash_size 16mb` 语法按所用版本确认;与构建的 DIO/40 MHz 保持一致。
### E5. Config 分区 patch(1 天)
**目标**:证明不改 App、不重编即可切换 SSID;App SHA-256 不变。
**前置**:模板固件已加入 config 读取;`partitions.csv` 已有 `config` 分区;工具已能生成 256 B 记录。`provision.py` 完成前,可直接用 5.3 的 `pack_config` 片段生成 `cfg-a.bin`/`cfg-b.bin`。
```powershell
# 生成两个记录(seq 1/2),写入不同槽
python tools\provision.py make-config --ssid "LAB-A" --password-stdin --seq 1 --out cfg-a.bin
python tools\provision.py make-config --ssid "LAB-B" --password-stdin --seq 2 --out cfg-b.bin
# 写槽 0 / 槽 1(偏移按实际分区表)
python -m esptool --chip esp32s3 --port COM5 write_flash 0x13000 cfg-a.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x13000 cfg-a.bin
python -m esptool --chip esp32s3 --port COM5 write_flash 0x14000 cfg-b.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x14000 cfg-b.bin
# 读回 App,确认未变;$appSize 以 manifest 的 app.size 为准(当前 1,143,504)
$appSize = 1143504
python -m esptool --chip esp32s3 --port COM5 read_flash 0x100000 $appSize app-readback.bin
Get-FileHash app-readback.bin -Algorithm SHA256
```
**通过标准**
- 串口日志出现 `config source=partition seq=2 ssid=LAB-B`;
- `app-readback.bin` 的 SHA-256 与 manifest 中的 App SHA-256 一致;
- 设备联网,`perf_probe.py <IP> --seconds 15 --json` 有完整帧;
- 把槽 1 的 CRC 改坏/擦除,重启后回退到槽 0,日志 `seq=1`;
- 全部槽无效时回退到编译期默认,日志 `config source=default`。
### E6. 配置副本与坏扇区规避(0.5 天)
**目标**:证明 config 多副本能跨坏扇区。
**步骤**
1. 在故障板上选一个已知坏扇区(或人为把某个 config 槽写成无效 CRC);
2. 工具按策略跳过该槽,写下一个好槽;
3. 设备启动,读取有效副本;
4. 报告记录 `config_slot=N, skipped=[...]`。
**通过标准**
- 至少一个副本有效;设备联网;
- 工具不因单个槽失败而整体失败;
- 不把无效副本当成有效(CRC/seq 检查);
- 重启 3 次结果一致。
### E7. 热插拔与自动发现(0.5 天)
**目标**:验证枚举/探测/去抖/端口释放。
**步骤**
1. 插拔 20 次,记录从插入到设备出现在列表的时间;
2. 同时插两块板,按 MAC 选择,确认不会交叉写入;
3. 打开串口监视器再执行 provisioning,确认报“端口占用”而不是挂死;
4. 写入过程中拔掉 USB,确认报错、设备可重新进入下载模式、没有“成功”报告。
**通过标准**
- 20/20 次发现;P95 发现时间 ≤ 2 s(Windows 驱动枚举可能更慢,按实测设阈值);
- 0 次错板写入;
- 端口占用/拔出有明确错误码;
- 重新插入后可恢复。
### E8. OTA(条件:仅当 OTA 纳入范围,1–2 天)
**目标**:验证 OTA 成功、失败回滚、断电不砖。
**前置**:OTA-ready 分区表;Bootloader 支持 rollback;App 在启动成功后调用 `esp_ota_mark_app_valid_cancel_rollback()`。
**步骤**
1. 用 `otatool.py` 或 ESP-IDF OTA 把新 App 写入 `ota_0`,切换启动分区,重启;
2. 写入损坏镜像/错误 SHA,确认 Bootloader 拒绝,仍启动旧槽;
3. 在 OTA 写入 25%/50%/75% 时断电,恢复后确认旧/新槽至少一个可启动;
4. 把 `ota_0` 设为已知坏槽,确认 OTA 安全失败,旧槽不受影响;
5. 若启用 anti-rollback,测试降级被拒绝。
**通过标准**
- 成功 OTA 后版本/分区日志正确;
- 失败 OTA 不改变当前可启动槽;
- 断电后设备可启动,无需整片擦除;
- 坏槽 OTA 不导致 brick;
- 回滚在 1 次复位内生效。
### E9. 断电/异常拔插(0.5–1 天)
**目标**:验证写入/配置/分区表阶段的恢复能力。
**步骤**
1. App 写入 50% 时断电;重新上电,确认可进下载模式,重刷后启动;
2. config 写入时断电;确认旧副本仍有效;
3. 分区表写入时断电;确认可进下载模式,重刷分区表/App;
4. Bootloader 写入时断电;确认 BOOT strap 可恢复(最高风险,最后做);
5. 扫描/恢复时拔掉 USB;确认备份可恢复,不把半写状态当成功。
**通过标准**
- 每种场景都能通过 BOOT + 重刷恢复;
- 不出现“必须换板”的 brick;
- 报告记录最后完成阶段/失败阶段;
- 恢复后 App/Config 校验通过。
### E10. 回归基线(0.5 天)
**目标**:把上述实验固化为可重复的 pytest/脚本。
- host tests:config pack/parse、CRC、seq 选择、分区表解析、manifest 校验、坏区选择;
- hardware tests:E0/E1/E3/E4/E5/E7 的最小集;
- 报告 schema 校验;
- 同一输入重复 3 次,结果一致(允许时间差异)。
**通过标准**
- host tests 100% 通过;
- hardware 最小集 3/3 通过;
- 报告 JSON 可被 schema 校验;
- 命令/版本/哈希可追溯。
---
## 7. 测试矩阵与判定标准
> 完整机器可读版本见 [`firmware-provisioning-test-matrix.csv`](firmware-provisioning-test-matrix.csv)。下表是评审用的摘要。
### 7.1 测试矩阵(摘要)
| ID | 类别 | 前置 | 操作 | 期望/判定 | 证据 | 优先级 |
| --- | --- | --- | --- | --- | --- | --- |
| N-01 | 正常 | 健康板,新模板 | 分段写 Bootloader/表/App,逐段 verify,启动 | 3 段 verify OK;串口 live;UDP 15 s 有完整帧 | verify 日志、serial、perf JSON | P0 |
| N-02 | 正常 | 健康板,已 provision | 只更新 App(同布局) | App hash 更新;启动;配置保留 | serial、manifest、perf | P0 |
| N-03 | 正常 | 健康板,已 provision | 只 patch config(换 SSID) | App SHA-256 不变;新 SSID 生效;不重编 | app hash、serial、perf | P0 |
| N-04 | 正常 | 健康板 | 重复 provisioning 3 次 | 幂等;无 brick;报告一致 | 3 份报告 | P1 |
| N-05 | 正常 | 健康板 | 写 merged image(对照) | 记录是否通过;不用于坏板 | verify 日志 | P1 |
| B-01 | 坏板 | 故障板,已备份 | 牺牲板 E2 写读表征;生产板定向读已知地址 + 扫描模板写入区 | E2 复现缺陷;生产板地图正确;恢复 verify | scan JSON、verify | P0 |
| B-02 | 坏板 | 故障板,新布局 | 分段写新 App/表,verify,启动 | 写入区不含坏地址;verify OK;启动 | verify、serial | P0 |
| B-03 | 坏板 | 强制 App 槽含坏字节 | 写实际镜像 | 若镜像字节不可写,verify 失败;工具换槽/拒绝;不 brick | verify、report | P0 |
| B-04 | 坏板 | config 槽含坏扇区 | 写 config 多副本 | 跳过坏槽;至少 1 个有效;启动 | scan、serial | P0 |
| B-05 | 坏板 | 故障板 | 写 merged image(对照) | 预期失败/高风险;工具默认禁止 | verify 日志 | P1 |
| B-06 | 坏板 | 间歇性坏字节 | 同一扇区扫描 3 次 | 标记 SUSPECT;隔离;不用于 App | scan JSON | P1 |
| B-07 | 坏板 | 故障板 | 全片扫描 + 恢复 | 时间/地图/恢复验证;不丢关键区 | scan JSON、backup | P2 |
| O-01 | OTA | OTA-ready | 写 `ota_0`,切换,重启 | 新版本启动;分区日志正确 | serial、otatool | P1 |
| O-02 | OTA | OTA-ready | 写损坏镜像 | Bootloader 拒绝;旧槽仍启动 | serial | P1 |
| O-03 | OTA | OTA-ready | 25/50/75% 断电 | 至少一个槽可启动;无需整片擦 | serial、relay log | P1 |
| O-04 | OTA | 坏槽 | OTA 到坏槽 | 安全失败;旧槽不受影响 | serial、scan | P1 |
| O-05 | OTA | anti-rollback | 降级 | 按策略拒绝/接受;不 brick | serial | P2 |
| H-01 | 热插拔 | 1 板 | 插拔 20 次 | 20/20 发现;P95 ≤2 s | discovery JSON | P0 |
| H-02 | 热插拔 | 2 板 | 同时插入,按 MAC 选择 | 0 次错板;端口/MAC 正确 | report | P0 |
| H-03 | 热插拔 | 写入中 | 拔 USB | 报错;可恢复;不报成功 | log、report | P0 |
| H-04 | 热插拔 | 扫描中 | 拔 USB | 报错;备份可恢复;不半写成功 | log、report | P1 |
| H-05 | 热插拔 | 串口被占用 | 执行 provisioning | 明确“端口占用”;不挂死 | log | P1 |
| H-06 | 热插拔 | 错固件/错芯片 | 选择不兼容 manifest | 写入前拒绝;设备不变 | report | P0 |
| P-01 | 断电 | App 写入 50% | 断电/上电 | 可进下载模式;重刷启动;不 brick | serial、verify | P0 |
| P-02 | 断电 | config 写入 | 断电/上电 | 旧副本有效;启动 | serial | P0 |
| P-03 | 断电 | 分区表写入 | 断电/上电 | BOOT 恢复;重刷表/App;不 brick | serial | P1 |
| P-04 | 断电 | Bootloader 写入 | 断电/上电 | BOOT 恢复;重刷;不 brick | serial | P2 |
| P-05 | 断电 | 扫描/恢复 | 拔 USB/断电 | 备份恢复;不丢关键区 | backup hash | P2 |
| S-01 | 安全 | 正常 provision | 检查日志/报告 | 无密码;只有 SSID/hash/seq | grep | P0 |
| S-02 | 安全 | Secure Boot/加密板 | 运行预检 | 检测 eFuse;拒绝未签名 patch | espefuse 输出 | P1 |
| S-03 | 安全 | 篡改 manifest | 校验 SHA-256 | 拒绝烧录 | report | P1 |
| S-04 | 安全 | 伪造 VID/PID | 主动探测 chip/MAC | 识别不匹配;拒绝 | report | P2 |
| R-01 | 回归 | host | 单元测试 | 100% 通过 | pytest/JUnit | P0 |
| R-02 | 回归 | 硬件 | 20 次循环 | 首次通过率 ≥95%;0 brick;0 错板 | 报告汇总 | P1 |
| R-03 | 回归 | 工具版本 | 固定版本/哈希 | 同输入同命令/同结果 | versions.txt | P0 |
| M-01 | 迁移 | 旧布局 `0x70000` | 全量迁移到新布局 | 保留 NVS/PHY;启动;配置可用 | serial、report | P1 |
| M-02 | 迁移 | 旧布局 | 只 patch config | 工具拒绝并提示迁移 | report | P0 |
### 7.2 全局判定标准
**一次 provisioning 判定为 PASS,必须同时满足:**
1. 设备身份匹配:chip = ESP32-S3、Flash 容量/型号匹配、MAC 与报告一致;
2. manifest 匹配:布局 hash、App SHA-256、config schema 匹配;
3. 写入的每一段 `verify_flash` 通过,且独立回读 SHA-256 与源文件一致;
4. config 至少一个副本有效,回读 CRC 通过;
5. 启动日志出现预期标记(`ESP32 camera is live`、`config source=...`),无 panic/brownout/watchdog 循环;
6. `perf_probe.py` 在 15 s 内收到完整帧,帧率 ≥ 15 fps、丢帧 ≤ 5%(按实验室网络设定;现场可放宽);
7. 报告包含工具版本、命令、时间、设备信息、坏区地图、结果;
8. 不出现密码明文。
**失败判定(任一即 FAIL)**
- 命令返回 0 但未做独立校验;
- 写入/校验失败后仍报告成功;
- 设备无法再次进入下载模式;
- 错板写入;
- 日志/报告出现密码;
- 坏区地图缺失或与实际不符。
**统计验收(P1 试点)**
- 同一批次 ≥20 台/20 次循环;
- 首次通过率 ≥95%;
- 0 brick、0 错板;
- 失败可复现且报告可定位;
- 连续 3 个 nightly 无 flaky。
### 7.3 测试环境与物料
| 物料 | 用途 | 备注 |
| --- | --- | --- |
| ESP32-S3 好板 ×2 | 正常路径/对照 | 与目标模组同型号 |
| ESP32-S3 故障板 ×2 | 坏区/迁移 | 至少 1 块有 `0xFADE7`,1 块 `0x10863` 可疑 |
| USB 带供电 Hub | 多板/掉电 | 每口可独立开关更好 |
| USB 继电器/可控电源 | 断电实验 | 2 通道即可;注意 USB-JTAG 会断开 |
| 串口适配器(可选) | 断电后仍能看日志 | 与原生 USB 分离 |
| 2.4 GHz AP | 联网验收 | 关闭 AP 隔离;准备备用热点 |
| Windows 11 测试机 | 主平台 | 关闭其他串口助手;装 CP210x/CH340 驱动 |
| Python 3.11 + pytest | P0 自动化 | 锁版本;venv/容器 |
| esptool 4.12 / espflash 4.4 | 写入/校验 | 版本写入报告 |
| 逻辑分析仪(可选) | 电源/复位时序 | 排查 brownout/复位 |
---
## 8. 风险登记与待确认项
### 8.1 风险登记
| ID | 风险 | 概率 | 影响 | 缓解 |
| --- | --- | --- | --- | --- |
| R-01 | 不允许一次性模板变更 | 中 | 高 | 先确认;若禁止,只做 Plan C App patch,并明确风险/限制 |
| R-02 | 坏区扫描漏检/误检 | 中 | 高 | 实际镜像校验 + 多图案 + 重试 + 重复扫描 + 温度/供电应力;SUSPECT 隔离 |
| R-03 | 扫描/恢复破坏数据 | 中 | 高 | 先备份并验证;分区感知;跳过已知空隙;牺牲板先行 |
| R-04 | 坏板写 merged image | 高 | 高 | 工具默认禁止;只允许分段;manifest 标记 `bad_board` |
| R-05 | App 字节 patch 破坏 checksum/hash | 高 | 高 | 不做;走 config 分区;Plan C 必须重算并 `image_info` 验证 |
| R-06 | espflash verify 假失败/真失败 | 中 | 高 | esptool 独立 verify;parity 测试;不关闭校验 |
| R-07 | OTA 未实现/回滚不可用 | 高 | 中 | 本次采用 OTA-ready 布局;Bootloader rollback + App 标记有效;或明确 N/A |
| R-08 | Secure Boot/Flash Encryption 未知 | 中 | 高 | `espefuse.py summary` 只读预检;启用则签名/加密;无密钥拒绝 |
| R-09 | 错板/多板混淆 | 中 | 高 | VID/PID + chip/MAC + 用户确认;同一串口加锁 |
| R-10 | 写入中拔出/断电 | 中 | 高 | 状态机 + 安全顺序 + 重试 + BOOT 恢复;未校验不报成功 |
| R-11 | 分区表/Bootloader 写坏 | 低 | 高 | 最后写;备份;BOOT strap 恢复;golden image |
| R-12 | 凭据泄漏 | 中 | 高 | 日志脱敏;文件 ACL;可选 HMAC/加密;报告不含密码 |
| R-13 | 工具供应链 | 低 | 高 | 锁版本/哈希;签名 manifest;SBOM;不从网络临时下载 |
| R-14 | USB 驱动/线材兼容 | 中 | 中 | CP210x/CH340/原生 USB 分别测;带供电 Hub;波特率回退 |
| R-15 | CI 硬件 flaky | 高 | 中 | 独立 runner;重试/隔离;nightly;报告留档 |
| R-16 | App 分区余量不足 | 高 | 中 | 扩到 `0x140000`;构建门禁 ≤95%;监控 app size |
| R-17 | Config schema/固件版本错配 | 中 | 中 | version + 回退;OTA 回滚后测试旧固件读新 config |
| R-18 | GPL/Python 打包 | 中 | 中 | P1 优先 espflash;esptool 仅独立进程/合规评估 |
| R-19 | 旧布局迁移失败 | 中 | 中 | 先 App 后表;保留 NVS/PHY;备份;试点 5 台 |
| R-20 | 扫描时间过长 | 中 | 低 | 分层扫描;只扫写入区;生产抽检 |
### 8.2 待确认项(按重要性)
| ID | 问题 | 为什么重要 | 默认假设(若未确认) |
| --- | --- | --- | --- |
| Q-01 | 是否允许一次性模板固件变更(config 读取;可选 OTA)? | 决定方案是否成立 | 允许,一次构建,所有设备共用 |
| Q-02 | OTA 是否在本次范围? | 决定分区布局与测试矩阵 | 在范围内;采用 OTA-ready 布局 |
| Q-03 | App 槽位是否可从 `0x120000` 扩到 `0x140000`? | 当前只剩 36 KB,新增代码可能溢出 | 扩到 `0x140000` |
| Q-04 | 是否启用/计划 Secure Boot、Flash Encryption、NVS 加密? | 决定 config 能否明文、App 能否重签 | 当前未启用;工具做只读预检 |
| Q-05 | 生产环境是否 Windows 单平台?桌面端是否必须内置烧录器? | 决定 Tauri/sidecar/Python 选型 | Windows 主平台;桌面端内置 P1 |
| Q-06 | Python 运行时/GPL 是否可接受? | 决定 P1 用 esptool 还是 espflash | P0 可接受;P1 优先 espflash |
| Q-07 | 每板可接受的 provisioning 时间? | 决定扫描层级/全片扫描 | Tier1 ≤10 min;全片仅疑似板 |
| Q-08 | 配置项只有 SSID/密码,还是还有 IP/摄像头/队伍绑定? | 决定 config 大小/格式 | MVP 只 SSID/密码;schema 可扩展 |
| Q-09 | 配置文件保密等级?是否允许明文 Wi-Fi 密码落盘? | 决定加密/HMAC/访问控制 | MVP 明文 + 脱敏 + ACL;后续加密 |
| Q-10 | 现网旧布局(`0x70000`)板卡如何迁移? | 决定迁移流程/停机 | 允许一次性全量迁移;保留 NVS/PHY |
| Q-11 | 目标产量/坏板比例? | 决定 ROI 与抽检策略 | 50–200 台/年;坏板少量 |
| Q-12 | 是否有坏板样品、USB 继电器、独立测试机? | 决定 MVE 能否执行 | 有 2 好 2 坏;继电器可采购 |
| Q-13 | “自动扫描识别”是否包括网络发现? | 决定是否要 UDP 发现 UI | 仅 USB 插入 + 主动探测 |
| Q-14 | 失败策略:坏板报废、返修还是降级? | 决定 SUSPECT/坏槽策略 | 坏槽拒绝;SUSPECT 隔离;不降级启动 |
| Q-15 | ESP32-C3 装甲板是否也要纳入同一工具? | 决定 chip/profile 抽象 | 暂不;后续按 board profile 扩展 |
| Q-16 | Flash 型号/批次是否固定? | 坏区行为/容量/ID 可能变化 | 固定 N16R8;`flash_id` 门禁 |
| Q-17 | OTA 是否要求签名/anti-rollback? | 决定安全设计 | 若 OTA,启用签名与 rollback |
| Q-18 | 报告/日志保留多久?是否含敏感信息? | 决定存储/脱敏 | 90 天;不含密码 |
### 8.3 决策记录(建议)
| ID | 决策 | 状态 |
| --- | --- | --- |
| D-01 | 用独立 config 分区,不 patch App 字节 | 建议,待 Q-01 |
| D-02 | 坏板只用分段写入,默认禁止 merged | 建议 |
| D-03 | esptool 做独立校验的权威;espflash 做写入,parity 后放行 | 建议 |
| D-04 | 本次直接采用 OTA-ready 布局 | 建议,待 Q-02 |
| D-05 | P0 用 Python CLI,P1 再进 Tauri | 建议 |
| D-06 | App 槽位扩到 `0x140000` | 建议,待 Q-03 |
| D-07 | 统一好板/坏板布局,废弃 per-board 偏移搜索 | 建议 |
---
## 9. 成本、ROI 与安全
### 9.1 成本估算
| 阶段 | 项目 | 人日 | 说明 |
| --- | --- | ---: | --- |
| P0 | CLI + config 格式 + 单测 | 5–8 | 发现/扫描/写入/patch/报告 |
| P0 | 模板固件(config + 日志 + 回退) | 3–5 | 含 host tests;不含 OTA |
| P0 | 坏区扫描硬化 | 3–5 | 分区感知、重试、恢复、报告 |
| **P0 小计** | | **11–18** | 约 2.5–4 周 |
| P1 | Tauri(发现/UI/侧车/报告) | 8–12 | 含权限/打包/签名 |
| P1 | 测试矩阵 + 硬件在环 | 5–8 | pytest + runner + 报告 |
| P1 | 文档/培训 | 2–3 | 操作手册/故障处理 |
| **P1 小计** | | **15–23** | 约 3–5 周 |
| 可选 | OTA | 5–10 | 布局/rollback/签名/测试 |
| **合计** | **P0+P1** | **26–41** | 约 5–8 周 |
| **合计** | **P0+P1+OTA** | **31–51** | 约 6–10 周 |
**硬件**:好板/坏板(若已有则 0)、USB 继电器 ¥200–500、带供电 Hub ¥150–350、串口适配器 ¥50–150、备用 Flash 模组 ¥100–300;合计约 ¥500–1,300(不含板卡)。
**软件**:esptool/espflash/ESP-IDF 均为开源;GPL 合规评估 1–2 人日;Windows 代码签名证书另计。
### 9.2 ROI 敏感性(说明性,不是承诺)
假设:
- 当前每台改 SSID 需要“改 `.env` → 重编 → 烧录 → 校验 → 串口检查”;
- 新流程每台只写 config + 校验;
- 节省时间 `t` 分钟/台;年产量 `N`;工程师成本 `c` 元/小时;坏板返修/报废成本 `b` 元/台,避免率 `r`。
年收益 ≈ `N × (t/60) × c + N × r × b`。
| 场景 | N | t | c | 工时收益 | 避免返修(r=1%,b=¥200) | 合计/年 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 小批量 | 50 | 10 min | ¥300/h | ¥2,500 | ¥100 | ¥2,600 |
| 中批量 | 200 | 10 min | ¥300/h | ¥10,000 | ¥400 | ¥10,400 |
| 较大批量 | 500 | 15 min | ¥300/h | ¥37,500 | ¥1,000 | ¥38,500 |
**解读**:小批量下,ROI 不是主要理由;主要价值是降低 brick 风险、让配置可追溯、让回归自动化。中大批量下,工时收益才明显。若只做 P0(约 2.5–4 周),回收期更短;P1 的 UI 投入应按“操作员不是工程师”这一场景来论证。
### 9.3 安全评估
| 威胁 | 影响 | 当前状态 | 建议 |
| --- | --- | --- | --- |
| Wi-Fi 凭据在固件/配置中 | 高 | 编译进 App;随包分发 | 移到 config 分区;日志/报告脱敏;可选加密 |
| 物理读取 Flash | 高 | 仓库配置未启用;以 eFuse 读数为准 | 若威胁模型需要,启用 Flash Encryption;否则接受物理访问风险 |
| 篡改 App/OTA | 高 | 仓库配置未启用;以 eFuse 读数为准 | 若 OTA/现场更新,启用签名与 rollback/anti-rollback |
| 伪造设备 | 中 | 仅 VID/PID 可能被伪造 | 主动读 chip/MAC/Flash ID;与 manifest/工单匹配 |
| 工具供应链 | 高 | 依赖本地二进制/Python 包 | 锁版本/哈希;签名 manifest;SBOM;不从网络临时下载 |
| 日志泄漏 | 中 | 串口可能打印失败 SSID | 统一日志脱敏;报告只存 SSID/hash/seq |
| GPL 合规 | 中 | 若打包 esptool | P1 优先 espflash;若用 esptool,提供许可证/源码获取方式 |
**Secure Boot/Flash Encryption 预检命令(只读)**
```powershell
python -m espefuse --chip esp32s3 --port COM5 summary
python -m espsecure --help # 确认签名/加密工具可用
```
- 若 `Secure Boot` 已启用:工具必须拒绝未签名 App;config 分区若被加密,写入需走加密路径;
- 若 `Flash Encryption` 已启用:不能直接写明文 config;需要 `espsecure.py encrypt_flash_data` 或通过设备端 provisioning;
- 当前仓库没有相关配置;不要假设“肯定没开”,以 eFuse 读数为准。
---
## 10. 结论与下一步
### 10.1 推荐实施顺序
**第 1 周:确认与基线**
- 确认 Q-01/Q-02/Q-03/Q-04;
- 跑 E0/E1,锁定工具版本、备份、设备身份;
- 固化“分段写入 + 逐段 verify”的命令模板。
**第 2 周:证明坏区策略**
- 跑 E2/E3/E4;
- 产出“坏字节地图 + 实际镜像校验”报告;
- 决定 App 槽位大小/布局。
**第 3 周:模板固件 + config patch**
- 加 config 分区/解析/回退/日志;
- 跑 E5/E6;
- 证明 App SHA-256 不变、SSID 可切换、坏副本可回退。
**第 4 周:自动化与试点**
- E7/E9/E10;
- 5–10 台试点;
- 评审是否进入 P1/Tauri 与 OTA。
### 10.2 需要立即做的三件事
1. **不要在坏板上继续使用 merged image / 单次整包写入**;先改为分段写入 + 逐段 verify。
2. **不要直接改 App 字节做 SSID patch**;先设计 config 分区(本报告 5.3 的格式可直接用)。
3. **把 `flash.sh` 从产品流程移除**,保留为故障板应急;用 manifest + 分段写入 + 报告替代。
### 10.3 结论
- **工具选型**:P0 用 Python + esptool;P1 用 Tauri + serialport + espflash 侧车 + esptool 独立校验;OTA 用 `otatool.py`/ESP-IDF OTA。
- **坏区策略**:没有“坏块感知烧录器”;正确做法是“分区感知扫描 + 实际镜像写读校验 + 槽位/副本规避”。App 不能扇区级跳过;config 可以多副本屏蔽。
- **SSID patch**:独立 config 分区 + 256 B 记录 + CRC + 4 副本 + 回退;App 不重编、不改字节。
- **验证路径**:E0–E10 的 MVE 先证明“坏字节可复现、实际镜像校验正确、分段写入可用、config patch 不改 App、断电可恢复”,再投入产品化。
- **最大风险**:不是工具能力,而是“是否允许一次性模板变更”和“OTA/安全范围未定”。这两项不确认,后续设计会反复。
---
## 附录 A:命令速查
```powershell
# 版本/设备
python -m esptool version
python -m esptool --chip esp32s3 --port COM5 flash_id
python -m esptool --chip esp32s3 --port COM5 read_mac
python -m esptool --chip esp32s3 --port COM5 chip_id
# 备份/读
python -m esptool --chip esp32s3 --port COM5 --baud 460800 read_flash 0 0x1000000 backup.bin
python -m esptool --chip esp32s3 --port COM5 read_flash 0x8000 0xC00 partition-table.bin
# 擦除(谨慎)
python -m esptool --chip esp32s3 --port COM5 erase_region 0x13000 0x1000
# 分段写 + 逐段校验
python -m esptool --chip esp32s3 --port COM5 --baud 460800 write_flash `
--flash_mode dio --flash_freq 40m --flash_size 16mb `
0x0 bootloader.bin 0x8000 partition-table.bin 0x100000 app.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x0 bootloader.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x8000 partition-table.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x100000 app.bin
# Config patch
python -m esptool --chip esp32s3 --port COM5 write_flash 0x13000 cfg.bin
python -m esptool --chip esp32s3 --port COM5 verify_flash 0x13000 cfg.bin
# 镜像信息
python -m esptool --chip esp32s3 image_info app.bin
# 安全预检
python -m espefuse --chip esp32s3 --port COM5 summary
# 启动后验收
python scripts\perf_probe.py 192.168.1.139 --seconds 15 --json perf.json
python scripts\serial_soak.py --port COM5 --seconds 20 --out reports\serial
```
> 命令名/参数按锁定的 esptool 版本为准;v4 与 v5 的下划线/连字符不同。不要把 `--no-verify` 用在最终烧录上,只可用于“写图案然后自己回读”的扫描实验。
## 附录 B:Manifest 示例
```json
{
"schema": 1,
"version": "0.2.0",
"chip": "esp32s3",
"flash_size": "16MB",
"flash_mode": "dio",
"flash_freq": "40m",
"partition_table": {
"offset": "0x8000",
"file": "partition-table.bin",
"sha256": "<sha256>"
},
"bootloader": {
"offset": "0x0",
"file": "bootloader.bin",
"sha256": "<sha256>"
},
"app": {
"offset": "0x100000",
"file": "esp32s3-app.bin",
"sha256": "<sha256>",
"size": 1143504
},
"config": {
"partition": "config",
"offset": "0x13000",
"size": "0x10000",
"schema": 1,
"record_size": 256,
"slots": 4
},
"ota": {
"enabled": true,
"factory": "0x100000",
"ota_0": "0x240000",
"ota_1": "0x380000",
"slot_size": "0x140000"
},
"bad_addresses": ["0xFADE7", "0x10863"],
"build": {
"source_revision": "<git-sha>",
"built_at": "2026-10-19T00:00:00+08:00",
"toolchain": "esp-1.97.0.0",
"esp_idf": "v5.5.5",
"espflash": "4.4.0",
"esptool": "4.12.0"
}
}
```
## 附录 C:证据索引
| 证据 | 位置 | 用途 |
| --- | --- | --- |
| 坏字节/偏移策略 | [`HARDWARE.md`](../HARDWARE.md#损坏-flash-模组的兼容处理)、[`scripts/flash.sh`](../scripts/flash.sh) | 现状与风险 |
| 当前分区表 | [`partitions.csv`](../partitions.csv) | 偏移/大小/无 OTA |
| App 大小/偏移/坏字节 | [`target/firmware/BUILD-INFO.json`](../target/firmware/BUILD-INFO.json) | 96.94% 占用、`0x70000`、坏字节 |
| 镜像 checksum/hash | [`target/firmware/IMAGE-INFO.txt`](../target/firmware/IMAGE-INFO.txt) | App 字节 patch 风险 |
| 三段 verify | [`target/latency/tx15-verify.log`](../target/latency/tx15-verify.log) | esptool 逐段校验证据 |
| espflash 失败 | [`target/fw-budget40-flash.log`](../target/fw-budget40-flash.log)、[`target/soak/vga30-fb3-flash.log`](../target/soak/vga30-fb3-flash.log) | 不能只信写入成功 |
| SSID 编译期 | [`src/main.rs`](../src/main.rs) 36–37、582–599 | 需要 config 分区 |
| 构建脚本 | [`scripts/build.ps1`](../scripts/build.ps1) | `.env`/重编 |
| 桌面端无串口 | [`desktop/src-tauri/Cargo.toml`](../desktop/src-tauri/Cargo.toml) | 需新增发现/烧录 |
| CI 仅桌面 | [`.github/workflows/build-client.yml`](../.github/workflows/build-client.yml) | 需新增 host/hardware job |
| 串口/UDP 验证 | [`scripts/serial_soak.py`](../scripts/serial_soak.py)、[`scripts/perf_probe.py`](../scripts/perf_probe.py) | 复用为验收 |
---
**评估员**:工具评估师(DSH)
**评估日期**:2026-10-19
**置信度**:中高(静态证据充分;硬件实验待执行)
**下次评审**:MVE E2–E5 完成后;或模板/分区/安全配置变更时