# 固件维护页:实现说明 ## 范围 本次只做 **USB 现场更新**(用户确认的 OTA 语义 = A),不做设备联网自升级。 - **不做** SSID 动态修改(用户已放弃该功能),App 镜像逐字节原样写入; - **不做** 网络 OTA;当前固件没有 OTA 分区、没有 `esp_ota` 接收端、没有控制通道; - **不改固件源码、不重编译**:使用当前已构建的官方 bundle,客户端只 patch 分区表的 factory 偏移/大小; - 目标芯片以 ESP32-S3 为当前实现,`chip` 字段与 `espflash` 的芯片探测已经为 C3 预留, 但 C3 的 bundle/布局尚未验收。 ## 为什么坏块规避不需要重编译 App 镜像不包含自己的分区偏移;偏移只在 `0x8000` 的分区表里。所以同一份 App 可以 写到旧 App 之后的空闲槽位,只需要改分区表里的 factory 偏移,不需要重新构建。 旧 `scripts/flash.sh` 之所以重编,是因为它把新偏移写回 `partitions.csv` 再重建。 客户端改为直接 patch 已构建的 `partition-table.bin`: 1. 从设备读当前分区表(0xC00 字节); 2. 解析条目,找到 factory; 3. 把 factory 的 offset/size 改成目标槽位; 4. 重算分区表 MD5(本仓库实测在表内 `0x70`,不是 `0xC00` 末尾); 5. 写回并整段回读。 ## 烧录顺序与坏块检测 ``` 探测设备 → 读当前分区表/App 描述 → 选候选槽位(旧 App 之后) → 备份旧分区表 + 旧 App → 对每个候选槽位: 写 App → 整段回读逐字节比较 通过 → 选中;失败 → 记录坏字节/坏扇区,换下一个 → 只有选中槽位通过,才写 patch 后的分区表 → 回读分区表 → 复位 → 抓启动日志 ``` - 候选槽位按 64 KiB 对齐,优先 `>= 0x200000`,并跳过与旧 App 重叠的区间; - 当前布局(factory `0x70000`,size `0x120000`)下,第一个候选是 `0x200000`, 新分区 size `0x140000`;旧 App 保留在 `0x70000`,可回滚; - 实际镜像写读校验是最终判据:图案扫描会误报“当前镜像恰好可写”的槽位;主流程 写真实镜像再回读; - 不使用 merged image:填充区的坏字节也会让整包失败;只写 App 和分区表; - 分区表只在 App 回读通过后才切换;切换失败/启动失败可用备份恢复旧表,旧 App 未覆盖。 ### 已知坏点 - `0xFADE7`:bit 5–7 无法正确编程(已确认); - `0x10863`:bit 6 读回为 1(`HARDWARE.md` 中尚未确认是否永久)。 两个地址都在 `0x100000` 以下;`0x200000` 槽位天然避开它们。客户端仍会对写入的 App 整段回读,不依赖“已知坏点表”。 ## 官方 .zip 固件包 编译完成后由 [`scripts/package_firmware.py`](../scripts/package_firmware.py) 打成单文件 `.zip`,客户端直接读取,不需要先解压。旧目录包仍兼容。 zip 根目录: ```text bundle.json esp32s3-app.bin partition-table.bin # 0xC00 字节 bootloader.bin # 可选;客户端默认不写 BUILD-INFO.json # 可选;展示版本/构建信息 ``` 客户端读取 zip 时的硬限制: - 条目数 ≤ 32;只接受根目录单层文件名,拒绝 `..`、绝对路径、盘符、`/`、`\`; - `bundle.json` ≤ 64 KiB;分区表必须正好 `0xC00`;App 不得超过 `appPartitionSize`; - zip CRC + `appSha256` / `partitionTableSha256` 双重校验; - 只解压到 app cache 下的临时目录,读完立即删除;不把 zip 内容写进用户目录; - `BundleInfo` 会显示 `sourceKind=zip`、整包 SHA256 与条目数。 `bundle.json` 示例(由 [`scripts/make_bundle.py`](../scripts/make_bundle.py) 生成): ```json { "format": 1, "name": "ESP32-S3 图传固件", "version": "vga30-fb3-realtime-tx15+4794bed", "chip": "esp32s3", "app": "esp32s3-app.bin", "appSha256": "…", "partitionTable": "partition-table.bin", "partitionTableSha256": "…", "bootloader": "bootloader.bin", "bootloaderSha256": "…", "appPartitionSize": "0x140000", "flashSize": "16mb", "flashMode": "dio", "flashFrequency": "40mhz", "notes": "…" } ``` 客户端会校验 `format`、`chip`、`flashSize`、`appSha256`、`partitionTableSha256`; 不匹配就拒绝,不接受任意 `.bin`。当前示例在 [`target/firmware/bundle.json`](../target/firmware/bundle.json)。 编译后打包: ```powershell # 只打包(会先调用 make_bundle.py 刷新 bundle.json) python scripts/package_firmware.py --firmware target/firmware # 构建完成后自动打包 scripts/build.ps1 -Package scripts/build.sh --package ``` 产物: ```text target/firmware/camfw----.zip target/firmware/camfw-...zip.sha256 ``` zip 使用固定条目顺序、固定 mtime 与 deflate level 9,同一个输入应得到同一个整包 SHA256,便于核对“两个 U 盘里的包是不是同一版”。`package_firmware.py` 会复用 `make_bundle.py`,不维护第二套 manifest 逻辑。 > 注意:bundle 里的 `partition-table.bin` 是参考模板;客户端实际写的是设备当前 > 分区表的 patch 版本,以保留设备的 NVS/PHY 布局。bundle 的分区表 SHA 仍会校验。 ## 界面 入口有三处,不要求先选身份或绑定摄像头: - 首次启动的身份选择页:`固件维护 / 烧录(不进入身份选择)`; - 用户端设置密码页:同上; - 主界面右上角:`固件维护`。 页面流程: 1. 自动列出串口并选中 Espressif 设备; 2. `读取设备` 显示芯片、MAC、Flash、安全启动/加密状态、当前 App、分区表、空闲区间; 3. `选择 .zip 固件包` 选择编译后打包的单文件;`选目录(兼容旧包)` 仍可用; 4. `生成计划` 显示目标槽位、候选槽位、切换后的 factory、警告; 5. 勾选是否备份旧 App;`开始烧录` 二次确认后执行; 6. 进度条 + 运行日志 + 结果;失败时显示坏字节/坏扇区、备份目录,并可 `恢复旧分区表`。 读取设备和烧录都会复位一次;开始前应停止录制,视频流会短暂中断。 ## 代码位置 | 层 | 文件 | | --- | --- | | 后端:探测/分区表/候选槽位/写入/回读/备份/恢复 | [`desktop/src-tauri/src/maintenance.rs`](../desktop/src-tauri/src/maintenance.rs) | | Tauri 命令注册、`--maintenance-probe`/`--maintenance-bundle`/`--maintenance-plan` | [`desktop/src-tauri/src/main.rs`](../desktop/src-tauri/src/main.rs) | | 前端 IPC 与 DTO | [`desktop/src/lib/maintenance.ts`](../desktop/src/lib/maintenance.ts) | | 维护页 | [`desktop/src/pages/MaintenancePage.tsx`](../desktop/src/pages/MaintenancePage.tsx) | | 样式 | [`desktop/src/styles/maintenance.css`](../desktop/src/styles/maintenance.css) | | manifest 生成 | [`scripts/make_bundle.py`](../scripts/make_bundle.py) | | zip 打包 | [`scripts/package_firmware.py`](../scripts/package_firmware.py) | 后端使用 `espflash` 4.6 库(`default-features = false, features = ["serialport"]`)、 `serialport` 4.10 和 `zip` 2.6(只开 `deflate`);Tauri 用 `tauri-plugin-dialog` 选文件/目录。 ## 构建与验证 Windows x64: ```powershell cd desktop pnpm install pnpm check pnpm build cd src-tauri cargo check --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]' cargo test --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]' maintenance::tests ``` 固件侧打包: ```powershell python scripts/package_firmware.py --firmware target/firmware # 或:scripts/build.ps1 -Package / scripts/build.sh --package ``` 运行 GUI: ```powershell cd desktop pnpm tauri dev --target x86_64-pc-windows-msvc -- --config 'unstable.build-std=[]' ``` > `desktop/.cargo/config.toml` 里的 `build.target` 是 macOS ARM 的默认值;Windows > 需要上面的 `--target` 覆盖。`tauri.conf.json` 的 bundle targets 是 `"all"`, > 各平台打包时使用自己的默认 bundle 类型。 无窗口诊断(仍会复位设备): ```powershell cd desktop/src-tauri .\target\x86_64-pc-windows-msvc\debug\cam-desktop.exe --maintenance-probe COM8 # 只校验 zip/目录包,不连设备 .\target\x86_64-pc-windows-msvc\debug\cam-desktop.exe --maintenance-bundle C:\path\to\camfw-*.zip .\target\x86_64-pc-windows-msvc\debug\cam-desktop.exe --maintenance-plan COM8 C:\path\to\camfw-*.zip # 只写 App + 回读,不切分区表;设备仍从旧 App 启动 .\target\x86_64-pc-windows-msvc\debug\cam-desktop.exe --maintenance-write-test COM8 C:\path\to\camfw-*.zip 0x200000 ``` ## 已验证 / 未验证 已在当前连接板(`COM8`,ESP32-S3 v0.2,MAC `90:70:69:f8:f2:14`,16 MB, Secure Boot / Flash Encryption 均未启用)上验证: - `--maintenance-probe`:读取芯片、MAC、分区表、App 描述; - `--maintenance-plan`:选中 `0x200000` / `0x140000`,patch 后 factory 为 `0x200000`,旧 App 保留; - `--maintenance-write-test COM8 … 0x200000`:实机写入 App 并整段回读, `writeOk=true`、`verifyOk=true`、0 处不一致;分区表未动,设备仍从旧 App 启动; - `.zip` 打包与读取:`camfw-…zip` 5 项、758,149 字节,`--maintenance-bundle` 校验通过, `sourceKind=zip`、整包 SHA256 与条目数正确; - 离线单元测试:分区表解析、MD5 重算、候选槽位、尺寸解析、zip 路径白名单,共 7 个; - 前端 `tsc --noEmit`、`vite build`,后端 `cargo check` 通过。 **尚未执行**完整实机切换(写 patch 后分区表 → 复位 → 启动日志)。App 写入/回读 已经在同一块板上验证;切换步骤的风险集中在 0x8000 的 3 KiB 分区表写入,写入后会 回读,失败会尝试恢复旧表。建议在维护页点一次;如果新 App 启动失败,页面会给出 备份目录和 `恢复旧分区表` 按钮。 ## 边界 - 不做运行时坏块重映射/FTL;ESP32 ROM bootloader 按分区线性读取,不能在 App 镜像 中间跳过坏扇区。这里的“屏蔽”= 选一个实际镜像写读校验通过的整槽位。 - 不写 bootloader、不碰 NVS/PHY;bundle 里的 bootloader 只做展示/校验。 - 分区表写入是单点风险:写入后会回读;失败会尝试恢复旧表;极端断电情况下可用 ROM 下载模式重新写入,备份在 `%APPDATA%/com.simbarobotics.rmic2026/backups/`(Windows)或 对应平台的 app data 目录。 - 网络 OTA 仍需要一次性改固件(OTA 分区 + `esp_ota` 接收端 + 控制通道),不在本次范围。