# 固件更新（OTA）与功能差距

> **doc-id** `analysis/ota-and-gaps` · **层** 结论（权威）· **状态** 权威；命令层台账不在这里（见下行）· **建档** 2026-09-20 · **修订** 2026-09-26
> 文档系统 [`docs/README.md`](../README.md) · 逐条命令的全集只有两处：`docs/evidence/operations-matrix` §3 与 `docs/impl/command-surface`

> 数据来源：三个官方 APK 的静态逆向，结论标 `path:line`；CONFIRMED = 读到代码，INFERRED = 从命名/上下文推断，未找到 = 搜过且不存在。
> 分析日期 2026-09-20。协议/型号基础事实见 [品牌型号与协议矩阵](protocol-matrix.md)。
> 目的：(1) 把三家「相机固件更新」的实现机制与线上契约挖清楚，作为我们 clean-room 重实现的依据；(2) 盘出我们 App 相比官方的功能缺口并排优先级。

---

## 0. 结论先行

> 本节是地图。§1–§3 三家机制，§4 横向对比，**§5 本次下载真实固件后的镜像实测**，**§6 变砖风险审计与我们必须自建的护栏**，§7 我们自己的差距与落地顺序。
> ⚠️ 本文早先版本有 4 条结论被更深的审计推翻（下方标「更正」），§5.5 另列了镜像实测对前文的 5 条修正。

1. **固件更新不是一个功能，是「云检版 + 下载 + 送包 + 相机自己烧」四段。** 前两段三家形状接近，后两段完全不通用：XTU 一个 APK 里并存 4 条送包通道 + 3 代云接口，idGoLive 把送包整条塞进 native 的 FTP，TUWIN 三条通道各一套。所以插件层必须把 OTA 做成**按 profile 分派**的能力。
2. ⚠️ **更正**：「只有 TUWIN 闭环了装机后回读版本」是**错的**。TUWIN 的版本回读只存在于 `core.update.DeviceOtaCoordinator`，而该类**只在 DI 里被构造、从未被调用**；现役 `UpgradeHelper` + `UpgradeActivity` 路径在上传 POST 返回后弹一个 **30 秒安全窗**（`UpgradeActivity.java:96 COUNTDOWN_SECONDS = 30`，`:543-558`），然后**宣告成功并离开**，`UpgradeActivity` 自己不查版本。⇒ **三家 App 的 OTA 流程里都没有"装机后把版本读回来和期望值比一次"；变砖在官方阵营里不可检测。** ⚠️ 但要把话说准：**读数其实是有的**——重连后各家都会重新拿到固件版本（TUWIN 走会话 identity 回填 `ConnectedDevice.version`，XTU 走 `getdeviceattr.cgi` 重写 `CameraParameters.VersionNo`，见 §1.4/§2.5），**缺的只是"比对"这一步** ⇒ R3 的成本比看上去低。
3. ⚠️ **更正**：XTU 的四条送包通道**只有 72 字节帧那一条形状是活的（两个入口）**。本轮逐个查清单与 `startActivity` 后：`UpgradeSendFirmActivity` **在清单里但无人拉起**（⇒ 挂在它上面的 Ambarella Gen-A、iCatch/Mini1、以及 `FwUpload2` 全死）、`com/hisi/hisiFW/FwUpload` **连构造点都没有**、`FirmwareClientThread` 只被无人调用的 `CopyFileUtils` 用到 ⇒ **200 字节帧三种实现全死**；海思 CGI 的宿主 `UpgradeFirmwareActivity` **确实无清单条目**，但它有真实点击入口（`AboutCameraActivity.java:45`）⇒ 用户一点就是 `ActivityNotFoundException`。⇒ 本文旧版说「`SendSoftActivity` 三个静态字段从未赋值」**是错的**：它在 `com/my/AppSettingActivity.java:74-76` 被赋值，并由 `UpgradeVersionReadyActivity.java:72` 拉起，是一条**活路径**。
4. ⚠️ **更正（部分）**：XTU 的息屏保护**不是全线为零**——活的 `SendSoftActivity` 有 `addFlags(FLAG_KEEP_SCREEN_ON)`（`:59`），**另一条活路径 `DeviceUpgradeActivity`（设备管理 UI）完全没有**，且两条都没有 WakeLock、没有前台服务（`WakeLock` 只在死掉的 `UpgradePresenter` 里且只覆盖下载段）。另外我先前写的「MD5 丢前导零」也只对死代码成立，现役 `FileUtils.getFileMD5` 是**正确补零**的（`FileUtils.java:234-270`，逐字节 `Integer.toHexString` + 补 `0`）。
5. **idGoLive 的固件镜像走 FTP，不是 PTP。** `libcontrol.so` 内嵌完整 `ftplib` + `convert2FtpPath`，PTP 只用于控制面。这解释了它的安全模型：FTP 无传输完整性，唯一闸门是相机收完后的校验。
6. **好消息：镜像本身是自描述的，而且相机会校验。** §5 的真实固件证实：XTU `.bin` 有 256 字节头，内含**型号串**和**版本串**；iCatch `.devfw` 容器内嵌的升级代理会打印 `Start firmware CRC check...`、`Verifying image CRC ... 0x%x != 0x%x failed!`、`Cannot read fw image header!`。⇒ **坏镜像在设备侧会被拒，不必然变砖；而"这镜像是不是给这台的"这个问题 App 本地就能回答——三家都没读那个头。**
7. **版本比较可以统一，但更根本的问题是没人比较。** 三家版本真身都含 `yyyyMMdd`。然而 **TUWIN 的 OTA 路径里完全没有版本比较**（比较权在云端），App 会把**相等甚至更旧**的版本照样装进去。
8. **一旦字节开始流动，三家都无法取消烧录**，都没有相机侧残留清理，都没有恢复模式入口。XTU 甚至在"成功"后删掉本地固件，用户无法重试。
9. **官方写了但没接线的东西**：XTU 九条 `gku_firmware_update_failed_*`（含 battery<10%、无卡、离线、超时、校验失败）**只存在于两个 `R.java`，零功能引用**——厂商知道全部失败模式，一个都没实现。这是本次审计最有力的证据。
10. **App 自更新已移出本文范围**（本项目不通过 App 分发更新）。三家的做法仍作为事实记录保留在 §1.7 / §2.7 / §3.7，但**不再是我们的待办**。



---

## 1. XTU GO 8.4.3 的固件更新

> 语料：`_work/xtu_src/sources/`（`SRC`）、`_work/xtu_res/resources/res/values/strings.xml`（`RES`）。

### 1.1 固件从哪来

**云端：`server4.gkuvision.com`，三代接口并存**

| 代 | 端点 | 参数 | 调用者 |
|---|---|---|---|
| 当前（设备管理 UI） | `GET /v1/push/api/getNewestVersion` | `firm_ware_model`、`region`、`hardware_version`，**三个都是逗号拼接的列表**（多设备一次查） | `SRC/com/gku/loginmodule/manage/DeviceVersionManager.java:286-287`；声明 `loginmodule/api/ApiService.java:45-46` |
| 二代 | `GET /push/api/getNewestVersion?firmWareModel=<型号去空格>` | `firmWareModel` | `sigmastar/upgrade/firm/Presenter/UpgradePresenter.java:165-167`、`com/my/AppSettingActivity.java:90` |
| App 自更新（**不是相机固件**） | 同上，`firmWareModel=XTUGO_Android` 硬编码 | – | `sigmastar/upgrade/app/UpgradeViewManager.java:648` |
| 老海思 | `http://121.40.107.215:8041/firmware/info.json` | – | `hisilicon/dv/updateapp/UpgradeManager.java:35-36,842-849`（常量在，调用可达性未验证） |

**响应形态三种，字段名互不兼容**

- 当前代（`loginmodule/model/GetDeviceNewestVersionResponse.java:8-143`）：`{code,msg,data:{list:[{firm_ware_model,version,region,hardware_version,file_url,force,release_note,release_notes:[{lang,content(html)}]}]}}`
- 二代：`{retCode(0=ok), data:{updates:String, url:String}}`（`UpgradePresenter.java:196-208`）
- 老海思：**裸 JSON 数组** `[{"name":"<file.sw>"}]`（`UpgradeManager.java:851-867`）

⚠️ `force` 字段被解析但**全仓无人读取**（`GetDeviceNewestVersionResponse.java:51,95-97`）——「强制升级」在 XTU 里是个假功能。

**2026-09-23 实测回写（我们侧已按这一代接口落地）**

| 事实 | 实测结果 |
|---|---|
| 鉴权 | **零鉴权、零必需请求头**。带官方那套头（`os`/`appVersionCode`/`appVersionName`/`datetime`/空 `Bearer`）与裸 `curl` 返回**逐字节相同**的 2,475 字节体 ⇒ 我们不带任何伪装头。原表「无（登录前发空 `Bearer`）」成立，但「要发那些头」是误解 |
| 取值 | `firm_ware_model=XTUS7PRO&region=G&hardware_version=V1.0` → `code:0`，一条 `XTUS7PRO_20.8.6.1.20260910.G`，`size:54490165`，`force:false`，`release_notes` 有 `zh`/`en`/`ja` 三份 `<p>` 片段 |
| 型号键 | `getdeviceattr.cgi` 的 `name` 去空格即命中：S7PRO 真机回 `var name='XTU S7PRO'` → `XTUS7PRO`。**相机不回 `region`/`pcbrevision`**（2026-09-21 日志里 `getdeviceattr` 的键全集是 `name,serialnum,type,networkstatus,softversion,bluetoothrxversion,hardversion,startdate,runtimes,model,timeout`），所以两者只能取官方自己的默认 `G` / `V1.0` |
| 下载口 | `file_url` 是 **http://**；同一对象 `https://` 实测 200，`Content-Length: 54490165` 与 `size` 相等，且带 `Accept-Ranges: bytes`（可续传）与 `ETag`（单段上传即 MD5） ⇒ 我们强制改 https 并比 `size`，这两件事官方都没做（§5.2） |
| 送包口 | 现役路径把 **zip 原样**推给相机（`DeviceManagerActivity.java:187` → `ARGUMENT_FILE_PATH` = `getPathSave(...)` = `FileUtils.getName(url)` 即 `….zip`；唯一的 `ZipUtils.unzip` 在死掉的 `UpgradeSendFirmActivity.java:162`）⇒ 我们不 unzip，直接把云端那个 zip 送 8080 |

**本地固件：三种来源**

| 机制 | 位置 | 细节 |
|---|---|---|
| `<appdata>/upgrade/*.sw` | `UpgradeManager.java:59-93` | 按 `.sw` 过滤；`getLocalFirmwareByChip(chip)` `:68-75` —— `softVersion` 以 `HiCam` 开头时取 `substring(5,9)` 当芯片号 |
| APK assets `fw/` | `sigmastar/upgrade/firm/Utils/UpdateVersionUtils.java:63-101` | 扫 `assets/fw/`，文件名须 `<model>_<version>`；置 `CameraParameters.UpdateFirmSource="fw/upgrade_<name>"` |
| APK assets `fw_amba/` | `amba/ui/AmbaFWUtil.java:27,60-80` | 比较的文件名字面量是中文占位符 `"需要更新的固件名称"` → **该函数是桩，永远匹配不上** |

实测 `_work/xtu_assets/assets/` 里**只有 `litepal.xml` 和 `menu/`，没有 `fw/` 也没有 `fw_amba/`** → 出厂 APK 不捆绑固件，这两条 assets 路径是休眠代码。

### 1.2 送包：四条并存通道（⚠️ 只有 72 字节帧那两条是活的）

> **可达性优先**（本轮按 `_work/re/xtugo/manifest.md` + `components.tsv` + 全仓 `startActivity` 复查，⚠️ **本文此前把「现役/死」标错过两处**）：
> - **活**：`DeviceUpgradeActivity`（设备管理 UI，由 `loginmodule/ui/activity/DeviceManagerActivity.java:187` 拉起）与 `SendSoftActivity`（由 `sigmastar/upgrade/firm/Ui/UpgradeVersionReadyActivity.java:72` 拉起，上游是 `com/my/AppSettingActivity.java:74-78`）——**两条都是 72 字节 `TCP_MSG_S` 帧**。⇒ 原文「`SendSoftActivity` 三个静态字段从未赋值」**是错的**：`filePath`/`isAmba`/`modelName` 就在 `AppSettingActivity.java:74-76` 被赋值。
> - **死**：海思 CGI 通道（宿主 `hisilicon/dv/updateapp/UpgradeFirmwareActivity` **确实无清单条目**（`manifest.md`/`components.tsv` 两处都查不到），但它有真实点击入口 `AboutCameraActivity.java:45` 的 `startActivity` ⇒ **用户从「关于」页点进去就是 `ActivityNotFoundException`**，不是"没人能到"）；200 字节帧全家（唯一构造点 `UpgradeSendFirmActivity.java:127`，而该 Activity 在清单里**却零 `startActivity`**）；`com/hisi/hisiFW/FwUpload`（全仓无构造点）；`FirmwareClientThread`（只被 `CopyFileUtils.java:234`→`FirmwareConnectUtils` 调到，而 `CopyFileUtils` 无外部调用者）；两条 assets 恢复路径。
> - 连带结论：**Ambarella Gen-A 与 iCatch/Mini1 分支也一起是死的**，因为它们都挂在 `UpgradeSendFirmActivity` 这个分派器上（§1.2(a)(e)）。
> 详见 §5.5 与 §6.1-R6。

分派器（CONFIRMED，但在死路径上）：`sigmastar/upgrade/firm/Ui/UpgradeSendFirmActivity.java:104-149` —— `IsAmbaDevice` 走 Ambarella；`UpdateFirmSource` 含 `MINI1` 且 active case 是 `IcatchCase` 走 iCatch；**其余全部走 `FwUpload2`**。`IsAmbaDevice` 在 `HomeActivity.java:757`(true)/`:1744`(false) 被设，来源就是连接时「先试 hi3510 CGI、失败再试 Amba socket」那次探测。
⚠️ 但**现役**的 `DeviceUpgradeActivity.java:258` 用的是 `deviceVersionInfo.isAmba()` —— 一个**持久化在 Room 里的布尔列**，不是上面的活探测结果。这正是 §6.1-R6 的根因。
⚠️ 同一位置的另一处不一致：`SendSoftActivity` 用的是**静态字段 `isAmba`**（`SendSoftActivity.java:27`，**默认值是 `true`**），值由 `AppSettingActivity` 从 `SharedPreferences("modelNameAndSoftVersion")` 里读、缺省 `false`（`AppSettingActivity.java:88`）——同样是"记住的布尔值"，不是活探测。

| 通道 | 帧 | 分块 | cmd | MD5 在头里？ | 是否现役 |
|---|---|---|---|---|---|
| Ambarella Gen-A | JSON `msg_id 1286 PUT_FILE` on **7878** + 裸流 on **8787** | 1024 | – | 是，JSON `md5sum` | ❌（宿主 `UpgradeSendFirmActivity` 无人拉起） |
| Ambarella Gen-B | 同上 | 65536 | – | 是，JSON `md5sum` | ✅ |
| 海思 CGI | HTTP multipart on **80** | 1024 | – | **完全没有** | ❌（宿主 `UpgradeFirmwareActivity` 无清单条目；入口在，一点即抛） |
| 通用 8080（200 字节） | 定长 200B 小端头 | 1024（`FwUpload2`）/ 5120（`FwUpload`）/ 10240（`FirmwareClientThread`） | `2` | 是，偏移 72 | ❌（**三个实现全死**，本文旧版标 `FwUpload2` 为现役是错的） |
| 通用 8080（72 字节，设备管理 UI） | `TCP_MSG_S` 72B + 之后单独写 ASCII MD5 | 65536 | `RECV_FILE`=2 | **否** | ✅ |
| 通用 8080（72 字节，`SendSoftActivity`） | 同上（逐行近乎重复），但**目标 IP 取运行时网关**、且有 `addFlags(FLAG_KEEP_SCREEN_ON)` | 65536 | `RECV_FILE`=2 | **否** | ✅ |

**(a) Ambarella —— `PUT_FILE(1286)` + 8787 裸流，没有专门的烧录命令**

两代实现都在包里。Gen-A（`UpgradeSendFirmActivity.java:107-118`）：
`AmbaFWUtil.putFileCmd("/tmp/SD0/"+UpdateFirmVersionName, md5sum(file), file.length(), cb)` → `AmbaCmdModel.java:336-338`
```java
sendCmdQueueTimesIfTimeOut(new PutFileRequestBean(1286)
      .setParam(to).setSize(size).setMd5sum(md5).setOffset(0), cb, listener, 3);
```
**`offset` 恒为 0（无续传）**，`size = File.length()`，`md5sum` = 整文件小写十六进制 MD5（自定义 hex 表 `AmbaFWUtil.java:90-123`）。JSON 键 `param/size/md5sum/offset`（`module_camera/amba/bean/PutFileRequestBean.java:4-9`）。超时重发 **3 次**。
回包 `msg_id==1286` → `onChannelEvent(19 /*PUT_FILE*/)`，App 收到 19 才往 8787 推字节（`UpgradeSendFirmActivity.java:247-272`）；`DataChannel.txStream()` 是 **1024 字节裸读写循环，零分帧、无逐块 ack**（`amba/socket/DataChannel.java:99-133`），进度事件 515/516/517。
**完成由相机宣告**：`onChannelEvent(4 /*SHOW_ALERT*/, "put_file_complete")`（`:238-244`）。
`AMBA_BURN_FW = 8` 在 `AmbaCmdModel.java:47` 声明但**从未发送** → INFERRED：相机自己的 PUT_FILE 处理器对 `/tmp/SD0/*` 在 size/md5 对上时自行决定烧录。

Gen-B（`module_camera/amba/connector/AmbaUploader.java:36-83`，设备管理 UI 用）：先连 8787 并 `lockUpload.wait()` 阻塞 → `AmbaConnector.putFile`（`AmbaConnector.java:353-369`，字段值与 Gen-A 相同）→ 成功后 `upload()` 以 **65536** 字节裸推 → **完成只看本地字节数 `uploaded == length`**，不等 `put_file_complete`。错误码 `-104` 建连、`-113` 无输入流、`-102` 写异常、`-112` 短传（`SocketAmbaUpload.java:71,145,168,180`）；Activity ON_DESTROY 会 `cancelUpload()`。

**(b) 海思 CGI —— `fileupload.cgi` 上传，再 `upgrade.cgi` 触发**

`UpgradeManager.java:154-270`（`sigmastar/upgrade/app/UpgradeTaskManager.java:154-215` 是逐字节复制的孪生体）：
`new Socket(deviceIP, 80)` + `setSoTimeout(6000)`，手写 HTTP 头（`:191-203`）：
```
POST /cgi-bin/hi3510/fileupload.cgi HTTP/1.1
Charset: utf-8 / connection: keep-alive
Content-Type: multipart/form-data;boundary=<UUID>
User-Agent: <Build.MODEL> / Host: <ip> / Accept-Encoding: gzip
Content-Length: <size + 两段尾巴长度>
```
表单体是 `name="sd"; filename="<file>"` —— ⚠️ **没有 `Content-Disposition: form-data; ` 前缀，也没有前置 `--boundary`**。照抄，别"修正"，海思 CGI 显然是宽松解析的。body 分块 1024 字节。
上传字节数 == 文件长度时调 `DV.upgrade()` → `RemoteFileManager.java:61-62`：
```java
return HttpProxy.doForSuccess(String.format("http://%s%s/upgrade.cgi", ip, Common.CGI_PATH));
```
**`GET /cgi-bin/hi3510/upgrade.cgi`，无任何参数**（`Common.CGI_PATH="/cgi-bin/hi3510"`）。INFERRED：`upgrade.cgi` 靠 `fileupload.cgi` 的固定落点找文件，表单体叫 `sd` 说明落在 SD 卡。

**(c) 通用 8080，200 字节定长头（`com/gku/rxt/FwUpload2.java`，⚠️ 死代码）**

> 三条 200B 实现（`FwUpload2`/`FwUpload`/`FirmwareClientThread`）**全死**，见本节开头。它们仍值得抄清，因为这是海思系相机**唯一**能接受的另一种分帧；但要知道官方 shipped 的包里没人调它。

`HOST="192.168.0.1"`、`PORT=8080`、`TIME_OUT=5000`（`:18-20`，已验证）。流程 `run()` `:58-221`：
1. `FwInfo` 流式算 `size` 与 `md5`（`com/hisi/hisiFW/FwInfo.java:24-57`，1024 字节缓冲累加，`size` = 累计读到的字节数 ⇒ **`length` 字段就是文件长度**）；
2. `getInfo()` → `new RegisterDeviceToServer(2, size, name, md5).getBuf()` → **200 字节**（`FwInfo.java:25`）；
3. connect → 写头 + flush（`:75-77`）；
4. **单次 `read(byte[256])`** 取 ack（`:78-79`）；`<=0` → `"固件>>>读准备状态失败"`，空串 → `"固件>>>解析准备信息失败"`；
5. `ack.trim().startsWith(fwInfo.getName())` 才继续传文件，**1024 字节/块**，每块回调 `progress(sent/available)`（`:122-141`）；否则记 `"没有能够上传固件"` 并关闭；
6. `shutdownInput()+shutdownOutput()` 是**唯一的传输结束信号**，没有 trailer、没有最终 ack 读取。

同端口的孪生老实现 `com/hisi/hisiFW/FwUpload.java` 步骤逐条相同，只有两处不同：分块是 **`byte[5120]`**（`:102-141`）、进度回调 `已发/available` 浮点。**它比 `FwUpload2` 更早、且全仓连一个构造点都没有**。

**200 字节帧逐字段（已验证 `com/hisi/hisiFW/RegisterDeviceToServer.java:25-38`）**

| 偏移 | 长度 | 字段 | 编码 |
|---|---|---|---|
| 0 | 4 | `cmd_type` | int32 **小端** |
| 4 | 4 | `length` = 文件总字节 | int32 小端 |
| 8 | **64** | `data` = 固件**basename**（不是路径） | `String.getBytes()`，无终止符无填充，剩余是 `new byte[200]` 的零 |
| 72 | **128** | `md5` = 十六进制 MD5 | 同上 |
| – | – | 总长恒 **200** | 定长，非长度前缀 |

互操作坑（都要么原样复刻、要么明确决定不复刻）：文件名 >64 字节会**静默覆盖 MD5 槽**，>192 抛 `ArrayIndexOutOfBoundsException`；`String.getBytes()` 未指定字符集；`BigInteger(1,md5).toString(16)` **丢前导零 nibble**（`FwInfo.java:57`、`FirmwareClientThread.java:197`——注意后者是**这个类自带的** `getFileMD5`，与现役 72B 路径用的 `com/gku/base/utils/FileUtils.getFileMD5` 不是一把）；`length` 是 int32 → 硬上限 2GiB。

⚠️ **同名不同包的 `RegisterDeviceToServer` 有两份，字段布局相同但用途不同**：`com.hisi.hisiFW` 那份**只服务 200B 帧上传**（`FwInfo`/`FwUpload`），而 SigmaStar/海思的**预览与文件下载链路**用的是 `com.gku.actioncam.hisilicon.dv.ui.data.model.RegisterDeviceToServer`（import 见 `sigmastar/data/connect/SSConnectDevice.java:13`、`DownLoadFileUtils.java:9`、`FirmwareClientThread.java:9`）。⇒ 读代码时按包名区分，别当成同一个类。
同结构第三/四份拷贝：`hisilicon/dv/ui/data/model/RegisterDeviceToServer.java:25-38`、`…/FirmRegisterDeviceToServer.java:8-27`。另有个 **72 字节** 的无 MD5 兄弟构造器（同文件 `:12-23`），别和下面 `TCP_MSG_S` 的 72 字节帧混。

`FirmwareClientThread`（海思与 SigmaStar 各一份、内容逐字节相同）：connect **超时 10000**、无 soTimeout、IP **硬编码 `192.168.0.1`**；ack 判定同样是「回复以文件名开头」，**不解析 cmd**；先起 `getMessage()` 线程轮读 256B 直到前缀匹配才 `sendFile()`；分块 10240；进度只 `Log.d` 不上 UI；`sendToCamera(true)` **被调用两次**（`:156-159` 与 `:167-170`，异常路径也发）。调用链：`CopyFileUtils.java:234` → `FirmwareConnectUtils.connect(...)` → 本类，而**全仓找不到 `CopyFileUtils` 的外部调用者** → 判为遗留/死路径。

**(d) 通用 8080，72 字节 `TCP_MSG_S` + ASCII MD5 旁路（现役，两条）**

`loginmodule/ui/activity/DeviceUpgradeActivity.java:253-431`（`com/gku/SendSoftActivity.java:95-227` 近乎逐行重复，两处都活，入口见本节开头）：
```
createSocket(ip, 8080); setSoTimeout(5000)
TCP_MSG_S{ s32Cmd = RECV_FILE(=2), s32DataLen = file.length(), szData = 文件名(≤63B) }
write 72 字节 → 循环 read 恰好 72 字节 → resp.s32Cmd==2 才算 ack
→ write md5.getBytes(US_ASCII)（无长度前缀）→ flush
→ write 文件裸字节，65536/块
```
帧结构 `TCP_MSG_S.java:9-34`（`toBytes()` = `ByteBuffer.allocate(72).order(ByteOrder.nativeOrder())` ⇒ Android/ARM 上小端；`szData` 恒 `new byte[64]`）；`MSG_TYPE.java:5-9` 序数 `GET_FILE=0, GET_FAILED=1, RECV_FILE=2, RECV_FAILED=3, `**`RECV_RTMP=4`**（本文旧版写 5 是错的）。
两条活路径的差异（照抄时要分清）：`SendSoftActivity` 用 `NetworkDeviceUtils.getIpDevice()`（**运行时网关 IP**）且整页 `addFlags(FLAG_KEEP_SCREEN_ON=128)`（`SendSoftActivity.java:59`，`onDestroy` `:53` 清）、成功后 `FileUtils.deleteFile(filePath)` **删掉本地固件**（`:247`）、文案 `setting_updatefw_closeAppInfo`「固件更新已完成，请重新启动应用程序」（`:242`）；`DeviceUpgradeActivity` 用连接时的 IP、**全程无息屏保护**、成功后不删包、文案 `devicer_manager_transfer_success`。
**与 200 字节版的本质差别：MD5 不在头里，是 ack 之后在同一字节流上单独写的一段 ASCII。** 同端口、同 cmd 值、两种不兼容分帧 —— 必须按固件版本二选一。
错误码 `-100001`…`-100009` 映射见 `DeviceUpgradeActivity.java:63-71`（`SendSoftActivity` 用同一套：`-100001` 建连、`-100004` cmd 不符、`-100006` 读异常、`-100007` 写异常、`-100008` 流异常、`-100009` MD5 写失败、`-100005` 主动打断）。**这个变体不发任何"开始烧录"命令**，成功文案 `devicer_manager_transfer_success` = *"Transfer Complete, Please update on device"*（`:464`）⇒ INFERRED：相机自检并弹自己的升级界面，用户在相机屏幕上完成最后一步。

**(e) iCatch / Mini1**（`UpgradeSendFirmActivity.java:122` + `:153-226`）：下载物是 **ZIP**，`ZipUtils.unzip` 后取第一个成员 → `CaseManager.getActiveCase().fwUpdate(file, cb, cont)`（挂起函数，`m/mifan/acase/icatch/IcatchCase.java:2569-2600`）。结果码以 SDK 事件回传，每个码之后立刻 `delFwUpdateListener()+unBindEvent()`（连接被拆，这就是"吸收重启"的方式）：13/14=成功（提示重启 App）、18=透传但 App 不处理、**19=校验和失败**、20=通用失败。

### 1.3 版本与兼容门限

| 层 | 规则 | 位置 |
|---|---|---|
| 当前代 | `getLocalVersionInfo(cur).compareTo(getLocalVersionInfo(new)) < 0`，而 `getLocalVersionInfo` = 取串里**第一个 `\d{8}`**（无则整串）⇒ 实际是 `yyyyMMdd` 日期戳按字典序比 | `DeviceVersionManager.java:349,686-698` |
| | 且 `file_url` 非空 `:352`；`model-region-hardware` 三元组必须**完全相等**（本地键 `:230-258`，缺省 `region="G"`、`hardware="V1.0"`；服务端键 `:260-279`） | |
| | 送包前再校验一次 `cameraInfors.getName().equals(deviceVersionInfo.getModel())`，不符则按钮置灰 | `DeviceUpgradeActivity.java:113-128` |
| 二代 | 三个 int，都取 `\d{8}` 前 8 位：相机 `softVersion.split("\\.")[last].substring(0,8)`、服务端 URL 文件名末段、本地最新文件末段；`c>=s` 拒 `CurrentIsLatest`；`l>=s` 时若 `ip=="192.168.0.1"` 直接进安装否则拒 `PleaseConnectCameraWifi` | `UpgradePresenter.java:146-155,203-236,355-404` |
| 三代 | URL 与 `softVersion` 各抽 `(\d{8})`，`SimpleDateFormat("yyyyMMdd")` 后 `serverDate.after(cameraDate)` | `AppSettingActivity.java:98-104` |
| assets（休眠） | `Integer.parseInt(ver.replace(".",""))` 比较 | `UpdateVersionUtils.java:72-90` |
| 预检 | SigmaStar 就绪页会先后台取 `getSdCardInfo()` + `getBatteryInfo()` | `UpgradeVersionReadyActivity.java:58-65` |

⚠️ **未找到**：任何在代码里生效的电量/剩余空间阈值。`gku_firmware_update_failed_battery`（"电量低于 10%"）与 `_failed_nocard`（"请插入 SD 卡"）两条文案存在，但全仓**没有任何 Java 引用**（`RES/strings.xml:897,900`）。

### 1.4 失败处理

| 关注点 | 行为 | 位置 |
|---|---|---|
| 重试 | 只有 Ambarella 的 `PUT_FILE` 重发 3 次；**其余四条通道全无重试** | `AmbaCmdModel.java:337` |
| 超时 | connect 5000（多处）；`FirmwareClientThread` connect 10000 且无 soTimeout；海思 CGI soTimeout 6000；版本检查 HTTP 5s + 自定义 DNS 3000ms | `UpgradePresenter.java:167,253-297` |
| 传输中断 | **200 字节路径有真 bug**：异常 → `finishCall(e)` → `onFwUploadFinish(异常)` → 仍 `sendEmptyMessage(0)` 报**成功**（`UpgradeSendFirmActivity.java:129-131` 忽略了 Exception 实参）。72 字节路径报 `-100007` 进错误页 | |
| 防误操作 | ⚠️ **两条活的 72B 路径待遇不同**：`SendSoftActivity` 整页 `getWindow().addFlags(128)`（= `FLAG_KEEP_SCREEN_ON`，`:59`，`onDestroy` `:53` 清）⇒ **有息屏保护**；`DeviceUpgradeActivity`（设备管理 UI 那条）**全程不加**，全类只有一处 `addFlags(67108864)`，那是 Intent 的 `FLAG_ACTIVITY_NEW_TASK`（`:218`）。全仓 `grep keepScreenOn` 零命中；唯一的 `PowerManager.WakeLock`（`UpgradePresenter.java:301`）**只覆盖下载阶段**、成功/失败后 500ms 就释放，且该 Presenter 所在的那一代入口本身是死路径。返回键：`SendSoftActivity` 未做拦截；`DeviceUpgradeActivity.java:201-207` 只在 `processStatus==2`（传输中）时 Toast `devicer_manager_transfering_hint` 不退出 —— 是**软拦**，不是「onKeyDown 全键返回 false」（那个只在死的 `UpgradeSendFirmActivity` 上）。⇒ **doze 挂起这条风险对「设备管理 UI 送包」完全成立，对 `SendSoftActivity` 只解决了息屏、没解决休眠/进程死亡**（§6.1-R4） | `SendSoftActivity.java:53,59`、`UpgradePresenter.java:301-336`、`DeviceUpgradeActivity.java:201-207,218` |
| 校验回读 | **四条 socket 通道都不从相机回读摘要**。只有 iCatch 事件 19 会报 checksum 失败；海思 CGI 干脆没有校验；Ambarella 靠相机比，App 只表现为「没等到 put_file_complete」。⚠️ 但「回读版本」并非完全没做：相机重启后网络 `onLost`→`onAvailable` 会重跑整条连接流程，`getdeviceattr.cgi` 重新解析 `softversion` 写进 `CameraParameters.VersionNo`（`HomeActivity.java:1743-1746`）与 SP（`:1766-1768`），「关于」页展示它（`AboutCameraActivity.java:118,140`）——**版本变化是拿得到的，只是没有任何一处拿它去和「期望版本」比一次** ⇒ 我们的 R3 不是从零造回读，而是把已有的读数拿来比对 | |
| 本地清理 | 下载失败码 `DOWNLOAD_FILE_FAILE_CODE` 会**删掉 `<externalFilesDir>/<model>/` 整个目录**（连以前的好文件一起，`UpgradePresenter.java:89-93`）；当前代用 `.tmp` 后缀 + 出错/取消时删（`DeviceVersionManager.java:415,479,497,509`）；`SendSoftActivity.dealSuccess()` 成功后**删掉本地固件**（`:247`，`FileUtils.deleteFile(filePath)`）；而 `DeviceUpgradeActivity` 成功路径**不删** ⇒ 同一家两条活路径连「要不要留包以便重试」都不一致 | |
| 相机侧残留 | **未找到**任何清理/中止命令 | |
| 相机重启 | **不轮询、不重连、不复查版本**。只有文案 `firm_sending`/`firm_sent`（"传输成功，请重启相机以完成固件更新"）与 iCatch 的 `setting_updatefw_closeAppInfo` | `UpgradeSendFirmActivity.java:60-63,175` |
| 取消 | 下载可取消（`ProgressDialog` cancel → `cancelDownload()`）；送包靠 `bCancelUpload` 每块检查 / `isRunning` 标志 / `cancelPutFile()` | `UpgradePresenter.java:411-422`、`UpgradeManager.java:55-57,205` |

### 1.5 进度信号

| 通道 | 粒度 |
|---|---|
| Ambarella | 1% 台阶 + 字节 + `onRefreshSpeed(bytesPerSecond)`（≥1Hz） |
| 200 字节 | **每块**回调 `progress(float 0..1)`（1024B ⇒ 一次升级上千次 UI 调用，无节流） |
| 72 字节 | 手工 1% 阈值节流，`CustomRoundProgress` + 按钮文字显示 `pct%` |
| `FirmwareClientThread` | 只写日志，不上 UI |
| 下载 | `onDownloadProgress(model,current,total)` / `onDownloading(int)` |

界面顺序：设置页或首页角标 → 下载进度框 → `UpgradeVersionReadyActivity`（Send/Cancel）→ `SendSoftActivity` 或 `UpgradeSendFirmActivity`（发送中动画 → 已发送）→ `DeviceUpgradeActivity`（新旧版本对比 + 按 `lang` 选的 HTML release notes + 圆环进度 + 一个按钮复用 %/确认/完成）。

### 1.6 一份"没接线的完整 OTA 状态机"（设计参考，不是缺口）

`RES/strings.xml:886-913` 定义了 28 条 `gku_firmware_update_*` 文案，覆盖 `checking / check_failed / cur_ver / new_ver / no_new / compare_title / compare_tip / compare_ok / confirm / downloading / prepare / start / doing / doing_tip / wait / succeed_title / succeed / failed / try_again` + **7 种失败原因**（download / battery<10% / nocard / timeout / waitonline / offline / verify）。代码里只引用了 3 条（`DeviceManagerAdapter.java:83` 的 `_downloading`、`AppSettingActivity.java:111,120` 的 `_no_new`）。**这套文案正好可以直接当我们 OTA UI 的状态清单。**

### 1.7 App 自更新 vs 相机 OTA —— 别混

| | 相机固件 OTA | App(APK) 自更新 |
|---|---|---|
| 型号键 | 相机自报 `cameraInfors.getName().split("-")[0]`（`HomeActivity.java:1765-1767`） | 字面量 **`XTUGO_Android`** |
| 版本比较 | `\d{8}` 日期戳，字典序或 int | **int versionCode**：从 APK URL 文件名 `_<n>.apk` 里截数字，比 `getPackageInfo().versionCode`（`UpgradeViewManager.java:340-346,392-396`）；另有 `ApkInfo.buildDate` 按 `yyyyMMdd` 比 `versionName` 末 8 位（`:99-115`） |
| 落地 | **`getCacheDir()/version[/<region>][/<hardware>]/<URL 末段文件名>`**（`DeviceVersionManager.getPathDir():655-669` + `getPathSave():644-653`）。⚠️ 三处要留意：① 形参有 `model`/`version` 两个，但**函数体压根没把它们拼进路径** ⇒ 不同型号/不同版本的包会落进同一目录、按同名互相覆盖；② `getCacheDir()`（`:672-681`）在**外置存储已挂载**时取 `getExternalFilesDir("")`（为 null 再退 `getFilesDir()`），**未挂载**时直接 `getFilesDir()` ⇒ 同一个「固件包」根据插没插 SD 卡落在两处，重试可能找不到；③ 判「已下载」是 `hasDownloadZip()` = `File(pathSave).exists()`（`:624-642`），**不比对大小/哈希**。老路径另有 `<localAppData>/upgrade/*.sw`（§1.1） | `xtugo_newui_<versionCode>.apk` |
| 最后一步 | 1.2 的四条通道 | `UpgradeManager.installAPK()` → `ACTION_VIEW` + `application/vnd.android.package-archive` + `FLAG_ACTIVITY_NEW_TASK`（`:922-933`） |
| 另一条老通道 | – | `rxt/net/AppService.java:22` `http://www.gkuvision.com:8882/upload/update.xml` |
| 「有新版」怎么告诉用户 | 首页红点：`HomeActivity.java:597` 连上后 `DeviceVersionManager.checkDeviceVersion(true)` → 回调 `onNeedUpdateMsg(needUpdate)` → `layoutRed.setVisibility(0/8)`（`:1131-1137`） | `UpgradeViewManager` 弹窗 |
| ⚠️ App 自更新的一个真 bug | – | 老代判新函数 `serverHaveLatestAPK(ctx, apkName)`：**任何异常 → `return true`**（`hisilicon/dv/updateapp/UpgradeManager.java:99-113`）⇒ 解析失败就永远提示「有新版」；SigmaStar 侧有一份逐行孪生（`sigmastar/upgrade/app/UpgradeTaskManager.java`） |

---

## 2. TUWIN 1.6.4 的固件更新

> 语料：`_work/tuwin_src/sources/`，`com.tuwinsmart.tuwin` 缩写为 `~`。
> **两代实现并存，但新的那代是彻底的死代码。** 现役走 UI 的是老路径 `~.core.util.UpgradeHelper`(1033 行) + `UpgradeDevice`(911 行) + `~.presentation.p010ui.mine.UpgradeActivity`；新架构 `~.core.update.DeviceOtaCoordinator` 只在 DI 里被构造（`~.p009di/AppModuleKt.java`），**`execute()` 无任何调用点，且 `DeviceOtaOperations` 这个端口没有任何实现类**（grep 全仓零命中）⇒ 它的超时、重启语义、版本回读**全都无法确认可用**。
> ⚠️ 因此：SHA-256 校验、`WAITING_FOR_DEVICE`、`CONFIRMING_VERSION` 回读、`CANCELLED` 终态这些**只存在于死代码里**，TUWIN 今天 shipped 的 OTA 路径**一个都没有**。
> ⇒ **新架构仍是该抄的形状**（它是唯一把闭环画出来的设计），但我们要清楚：抄的是一份未验证的图纸，不是官方已验证的行为。老路径才是线上真实行为的证据。

### 2.1 固件从哪来

**云端版本检查**（`~.data/source/remote/api/TuwinSuspendApi.java:16-25`）：

| 端点 | 方法 | 参数 | 响应 |
|---|---|---|---|
| `newota` | GET | 当前固件版本（参数名被混淆成 `C3077cm.f1938n`）、`boardversion` | `UpgradeBean` |
| `banner` | GET | – | `BannerResponse` |
| **`appupdatecheker`**（官方就少一个 `c`，不是抄错） | GET | 同一个混淆常量 `C3077cm.f1938n`、`platform`（**实参恒为字面量 `"android"`**，`RoutedAppUpdateGateway.java:53`） | `CheckUpdateBean`（**App 自更新，不是相机固件**） |

调用链 `RoutedInternetCloudGateway.java:241` → `getCurrentVersion(version, boardVersion)`，走 `HttpClientProfile.INTERNET_API` 且必须持「互联网租约」（`withInternetLease`，`:346`）。
⚠️ **`newota` 的 base URL 字面量在 jadx 产物里找不到**（`NetworkRuntime.java:271` 传的是 `baseUrl = null`），`UpgradeBean.class` 也缺失 —— 只从字段访问证实了 `data.version` 与 `data.fw_url`（`UpgradeHelper.java:371,373`）。**别猜这两个。**
✅ **但 App 自更新那条的 base URL 找到了**：`FeedbackApiEndpoint.BASE_URL = "http://ali.tuwinsmart.com/"`（明文 HTTP，同文件还挂着 `URL = "http://ali.tuwinsmart.com/feedback"`）。⇒ 「TUWIN 检版域名字面量缺失」只对相机 OTA 成立。

**下载主机白名单**（`_work/tuwin_src/sources/com/tuwinsmart/tuwin/core/internet/ExternalUrlPolicy.java:8`，`authorize()` 在 `:30-48` 拒非 http(s)、带凭据、白名单外的 host）：
`tuwinwifihelp.tuwinsmart.com`、`ride5helpnew.tuwinsmart.com`、`appdownload.tuwinsmart.com`、`yomu.atlasmeta.one`、`tuwin.tmall.com`、`work.weixin.qq.com`。INFERRED：`appdownload.tuwinsmart.com` 是固件 CDN。

**镜像来源 = 手机侧从云端给的 URL 下载**（不是 APK 内置、也不是从相机推）。落地记录 `LocalUpgradeBean`（`_work/tuwin_src/sources/com/tuwinsmart/tuwin/core/util/LocalUpgradeBean.java:6-19`，字段全 CONFIRMED）：
`version, newVersion, updateLog, file, fileSize:Long?, expectedFileName, model, boardversion, deviceTypeCode, ride3ProFirmwareModel, ride3ProFirmwareHw, ride3ProFirmwareSw`；缓存进 `ACache` 键 `"versionList"`（Gson `List<LocalUpgradeBean>`），靠 `MutableStateFlow<Long> versionListRevision` 做失效。

⚠️ **未找到任何 region / channel / locale 门控** —— TUWIN 的匹配只有 `(version, boardversion, model)` 三元组 + 设备类型规则。这点和 XTU（有 region + hardware）明显不同。

### 2.2 完整流程（老路径，即现役）

| # | 阶段 | 证据 |
|---|---|---|
| 1 | UI 入口 `UpgradeActivity`（两套布局 `ActivityUpgradeBinding` / `ActivityM3FirmwareUpgradeBinding`），入口可见性由 `Ride3ProSettingsOtaEntryPolicy` 决定 | `presentation/p010ui/mine/UpgradeActivity.java` |
| 1b | 启动自动检查：`checkFirmwareUpdatesAtStartup` / `requestFirmwareUpdatesAtStartup`，按 `StartupOtaCheckCohort`+`StartupOtaCheckBatch` 分批，`OtaPromptThrottlePolicy` 限流防骚扰 | `UpgradeHelper.java:166`、`OtaPromptThrottlePolicy.java:1-71` |
| 2 | 版本检查 `checkHasUpdate(version, boardversion, deviceType, model)` → `showNewUpdate`/`showNoUpdate` | `UpgradeActivity.java:659-667` |
| 3 | **先 HEAD 探远端大小**：`fetchRemoteFileSizeRouted(url, Internet("ota_package_head"))`；拿不到就中止并记 `"OTA package verification failed: remote file size unavailable"` | `UpgradeHelper.java:382,490,521` |
| 4 | 去重/复用已下载包：`OtaPackageGroupPolicy.findFreshPackage`、`hasUsableCachedPackage(file, expectedFileName, expectedFileSize)` | `:500,502,440` |
| 5 | 入队 `OtaSerialTaskQueue`（**同一时刻只允许一个 OTA 下载**）+ 申请限流令牌 | `:507-512` |
| 6 | 下载 → `handleDownloadSuccess(File)` | `:527-547,647` |
| 7 | 就绪提示 `new_firmware_found_format` | `:660` |
| 8 | 设备页再次 `loadUpgradeInfo()` | `UpgradeActivity.java:644` |
| 9 | 起飞前再验缓存包仍可用，否则 `GenericInstallFailed` + `showDownloadFailed()` | `:671-683` |
| 10 | **按 profile 分派传输**：`OtaDeviceProfilePolicy.resolve(deviceType).core.transport` → `startRide3ProUpgrade` / `startM3Upgrade` / `startLegacyUpgrade` | `:685-696` |
| 11 | RIDE5 先停录像，失败则 `pause_recording_failed` + 回退到 ready 态 | `:699-733` |
| 12–14 | 传输 → 进度 → 相机自动重启（`firmware_upgrade_notice`） | `:526-577` |
| 15 | **重连闸门** `FirmwareUpgradeReconnectGate.markPending(ssid)` → `matches(ssid)` → `completeIfMatches(ssid)` → `reset()` | `core/util/FirmwareUpgradeReconnectGate.java:14-36` |
| 16 | 成功 UX 由 `Ride3ProUpgradeSuccessPolicy`、`UpgradeInstallCopyPolicy`、`UpgradeSuccessActionPolicy` 组合 | `presentation/p010ui/mine/` |

**新架构（我们要照抄的）**：`DeviceOtaCoordinator(uploadQueue, download: suspend (TransferRequest)->TransferResult, upload: suspend (TransferUploadRequest)->TransferResult)`（`core/update/DeviceOtaCoordinator.java:63`），暴露 `progress: StateFlow<DeviceOtaProgress>`（`:129`），单一入口 `suspend fun execute(plan: DeviceOtaPlan): DeviceOtaResult`（`:165`）。

`DeviceOtaPlan` = `(expectedVersion, packageFile, upload: TransferUploadRequest, operations: DeviceOtaOperations, packageDownload: TransferRequest, packageIntegrity: IntegrityPolicy, diagnostics: DiagnosticContext)`（`DeviceOtaPlan.java:5-12`）。

**每机型要实现的端口 `DeviceOtaOperations`**（`DeviceOtaOperations.java:5-11`）—— 这六个方法就是 OTA 的最小完备抽象：
```
queryUpdate(): Boolean      readVersion(): String      waitForDeviceReady()
installUploadedPackage()    waitForReboot()            reconnect()
```

**14 态状态机**（`DeviceOtaState.java:12-25`，按声明序）：
`IDLE → QUERYING → UPDATE_AVAILABLE → DOWNLOADING_PACKAGE → VERIFYING_PACKAGE → WAITING_FOR_DEVICE → UPLOADING_TO_DEVICE → INSTALLING → WAITING_FOR_REBOOT → RECONNECTING → CONFIRMING_VERSION → COMPLETED / FAILED / CANCELLED`

**每个状态的迁移条件与失败落点**（执行体 `DeviceOtaCoordinator.java:196-320`，逐行核过）：

| # | 进入态 | 触发/前置 | 成功后 | 失败落点 | 行 |
|---|---|---|---|---|---|
| 1 | `IDLE` | 构造初值（进度 `DeviceOtaProgress(IDLE,null,null,null,null)`） | — | — | `:70` |
| 2 | `QUERYING` | `execute(plan)` 起手 | `queryUpdate()` 返回 true | **返回 false → `transition(IDLE)` + `UpdateUnavailable`，不是 FAILED**；抛异常 → FAILED | `:196-209` |
| 3 | `UPDATE_AVAILABLE` | `queryUpdate()==true` | — | 后续任一异常 → FAILED | `:210` |
| 4 | `DOWNLOADING_PACKAGE` | **仅当 `plan.packageDownload != null`**（包已在本地就整态跳过） | `TransferResult.Downloaded` 且落盘路径 == `plan.packageFile.absoluteFile` | 文件不对 → `IllegalStateException("OTA package was written to an unexpected file")`；结果类型是 `Uploaded` → `"OTA package download returned upload result"`；`Cancelled` → CANCELLED | `:211-237` |
| 5 | `VERIFYING_PACKAGE` | 上一步过 | `IntegrityVerifier.verify` 不抛 | `IntegrityMismatchException` → FAILED | `:239-240` |
| 6 | `WAITING_FOR_DEVICE` | 校验过 | `waitForDeviceReady()` 返回 | 抛/取消 → FAILED / CANCELLED | `:241-248` |
| 7 | `UPLOADING_TO_DEVICE` | 设备就绪 | 结果必须是 `TransferResult.Uploaded` | 结果是 `Downloaded` → `"OTA upload returned download result"`；`Failed(err)` → FAILED | `:249-270` |
| 8 | `INSTALLING` | 上传成功 | `installUploadedPackage()` 返回 | FAILED | `:271-278` |
| 9 | `WAITING_FOR_REBOOT` | 装机指令已下发 | `waitForReboot()` 返回 | FAILED | `:279-286` |
| 10 | `RECONNECTING` | 设备重启完 | `reconnect()` 返回 | FAILED | `:287-294` |
| 11 | `CONFIRMING_VERSION` | 路由重建 | `readVersion()` 非 null 且与 `expectedVersion` **严格 `Intrinsics.areEqual`** | null → `"OTA version confirmation returned no version"`；不符 → `"OTA version mismatch: expected=…, actual=…"` | `:295-311` |
| 12 | `COMPLETED` | 版本一致 | `Completed(version, plan.upload.target.grant.routeRevision)` | — | `:312-313` |
| 13/14 | `FAILED` / `CANCELLED` | 任意 `Throwable` / `CancellationException` | 终态。⚠️ `transition()` 写进度时把字节数清成 null（`:782-784`），而 `failed()`/`cancelled()` 用 `copy$default` **保留上一帧的 transferred/total**（`:786-795`） | | `:786-795` |

⇒ **整条链上没有任何重试**（一次失败即返回 `FAILED`，重试策略在上一层 UI/调用方）；`packageDownload == null` 是唯一合法的跳态分支。这两条我们要照抄：状态机自己不管重试，重试归调用方。

> 这条正是 XTU 和 idGoLive 都缺的能力：**升级后回读版本确认成功**。我们应当实现它。

### 2.3 三机型送包机制

| | RIDE3PRO / RIDE6 | M3 | RIDE5 |
|---|---|---|---|
| 端点 | `POST /api/firmware/upload` | `POST /upload/{savepath}/{filesize}` | `GET /cgi-bin/hisnet/checkupgradepktinfo.cgi` 后走裸 socket `POST /sd` |
| 声明 | `ride3pro/Ride3ProApiService.java:99-101` | `m3/M3ApiService.java:132-134` | `ride5/Ride5CgiApi.java:19-20` |
| 头 | `Content-Type: application/octet-stream`、`Content-Encoding: identity`、`Connection: close` | Retrofit 默认 + `@Streaming` | 手写 HTTP/1.1（`UpgradeDevice.java:178-180`） |
| 参数 | query `md5sum`、`model`、`hw`、`sw` | `@Path(encoded=true) savepath`、`filesize: Long` | query `-model`、`-softversion`、`-pktlen` |
| 体 | `@Body RequestBody`，`contentLength = file.length()` ⇒ **固定 Content-Length、整文件、HTTP 层不 chunked** | 同，`contentLength = fileSize` | **逐块** |
| 写循环 | 8192 字节读 + `sink.write` + `flush` + `onProgress(written,total)` | 同形状 8192 | **每块一条新 socket、一个 multipart POST**；块大小 = 相机给的 `unitsize`，块内再按 1024 字节写（`UpgradeDevice.java:188-199`） |
| **客户端算 MD5？** | **是**，整文件 MD5，8192 分块 `MessageDigest.update`，逐字节 `String.format("%02x")` 拼接 ⇒ **32 位小写十六进制、无分隔**（`Ride3ProFirmwareUploadRepository$uploadFirmware$3.java:135-157`）。⚠️ 它**只作为 `md5sum` query 送给设备侧自校验，App 从不拿它跟云端下发的任何值比对**（云端也没有可比的值） | **未找到**（M3 路径不摘要） | **未找到** |
| model/hw/sw 来源 | `Ride3ProFirmwareMeta` **三级取值**（`UpgradeActivity.java:936-947`，`resolveRide3ProFirmwareMeta(File)`）：① `LocalUpgradeBean` 非空 → `create(bean.ride3ProFirmwareModel, …Hw, …Sw)`（三者 `trim` 后任一为空即整体返回 null，`Ride3ProFirmwareMeta.java:108-133`）；② 仍为 null → **`fromNewOta(bean.boardversion, bean.newVersion)`** = `model` 取 boardversion（空则兜底 `DEFAULT_MODEL="R3Pro"`）、**`hw` 恒为兜底 `DEFAULT_HW_VERSION="1.1"`**、`sw` 取云端版本串（`Ride3ProFirmwareMeta.java:17-18,137-154`）；③ 还不行 → 从文件名解析：正则 `^(.+?)_cardvr_HW(.+?)_SW(.+?)_upgrade\.bin$`(IGNORE_CASE)，解析前先 `trim → substringBefore("?") → substringAfterLast("/") → substringAfterLast("\\") → URLDecoder.decode(UTF-8)`（decode 失败保留原值，`Ride3ProFirmwareMetaParser.java:22-55`），版本 token `trim` 后 `_`→`.`（`HW1_20`→`1.20`，`:57-59`）。上传前再兜一次：`firmwareMeta` 仍为 null 才 `parse(file.name)`，出不来 → `IllegalStateException(firmware_info_parse_failed_format)`（`…$uploadFirmware$3.java:160-162`）；文件不存在 → `IllegalArgumentException(firmware_file_not_exist)`（`:79-82`） | n/a | `upgradeModel`(设备信息) + `currentBean.newVersion` + `file.length()`（`UpgradeActivity$checkUpgradePktInfo$1$response$1.java:45`） |
| **超时** | 新架构 `HttpClientProfile.DEVICE_UPLOAD`：connect `10s` / read `120s` / write `120s`（`StreamingTransferEngine.java:334`、`RoutedOkHttpProviderKt.java:82-84`）；Ride3Pro 走「长操作」client（`Ride3ProSessionExecutorResolver.longOperationServiceOrLegacy()`） | connect `5s` / read `120s` / write `120s`（`M3HttpClientCompatibilityKt`） | 每块一条 socket，`setSoTimeout(5000)`（= `Ride3ProSessionTransport.MENU_CONNECT_TIMEOUT_MILLIS`，`Ride3ProSessionTransport.java:66`） |
| savepath 怎么来 | n/a | **硬编码字面量 `"mnt/sdcard"`**（`M3DeviceRepositoryImpl$uploadFirmware$2.java:171`） | n/a |
| 分块循环？ | 否，一次 POST | 否，一次 POST | **是**：`UpgradeDevice.uploadFirmware(File, byte[] block, long uploadedCount)` 每块调一次，`uploadedCount` 是运行偏移（`UpgradeDevice.java:151`，调用于 `:780,:789`） |
| socket 细节 | 端点授权 `EndpointPurpose.OTA` 允许路径前缀 `{"/api/firmware/"}`（profile `DEVICE_UPLOAD`）与 `{"/api/"}`（`DEVICE_LONG_OPERATION`）（`Ride3ProDeviceAdapterSession.java:823`） | – | `EndpointGrant.requireOtaGrant()` 取唯一允许端口 → `authorizeSocket(..., EndpointPurpose.OTA, host, port)` → `deviceNetwork.socketFactory.createSocket(host,port)`；multipart boundary 固定 `--------------------------0f063f014f74bfea`，`User-Agent: curl/7.50.3`，`Expect: 100-continue`，表单体 `name="filename"; filename="<name>"` + `Content-Type: application/octet-stream`（`UpgradeDevice.java:164-188`） |
| 响应 | `Ride3ProApiResponse<T>`，`isSuccess()` 否则 `IllegalStateException(errorMessage)` | `M3UploadResponse` | 裸文本 → `Ride5CgiPayload` |

✅ **`checkupgradepktinfo.cgi` 的响应字段名与协商出的块大小已找到（此前标「未找到」）**：回包是一组 `key=value`，App 只取 **`offset` 与 `unitsize`** 两个字段（`UpgradeActivity.java:1473-1478`），**缺任一个或为空白即 `IllegalArgumentException("RIDE5 OTA packet check did not return offset and unitsize")`**。语义（`UpgradeDevice.upload(File, offset, unitsize)` `:711-745`）：`blockLength = Integer.parseInt(unitsize)` ⇒ **分块尺寸由相机决定，不是 App 常量**；`uploadedCount = Long.parseLong(offset)` ⇒ **`offset` 是相机报回来的续传游标**；总块数 `chuncks = ceil(file.length()/unitsize)`。`pktlen` 传的是 `file.length()`（总量），与分块无关 —— 这点原文结论不变。
⚠️ 完成判定**不是回读版本、也不是设备回执**，而是**分块计数**：`getChunck(0,file) >= chuncks` 才 `onUploadSuccess()`，否则 `notifyUploadFailed()`（`UpgradeDevice.java:727-743`；`MSG_UPLOAD_FAILED = 10003`，无 listener 时走 handler）。⇒ 「相机其实没装上」在这条通道上无法被发现。

### 2.4 版本与兼容门限

`OtaQueryPolicy.build(version, boardversion, deviceType, model): OtaQuery?`（`core/util/OtaQueryPolicy.java:34-90`）—— **返回 null 就不发查询，静默当作"无更新"**：

| 规则 | 行 |
|---|---|
| M3：版本走 `M3OtaVersionPolicy.normalize`，boardVersion 强制 `"M3"` | `:47,60-62` |
| 非 M3：version/boardversion trim，空→`""` | `:48-53,63-66` |
| **version 或 boardversion 为空 ⇒ null** | `:68` |
| **任一等于 `"unknown"`（忽略大小写）⇒ null** | `:69` |
| `model` 仅非 M3 使用 | `:70-73` |
| RIDE5：model 缺失/`unknown` ⇒ null | `:75-78` |
| **RIDE5：boardversion 与 model 归一化后相等 ⇒ null**（去非字母数字+大写比较；即固件没真更新过就不查） | `:79-81,93-97` |
| RIDE3PRO/RIDE6：从版本串剥掉两个前缀（常量被 jadx 内联成 `C3059bv.f1711aE`、`ExifInterface.GPS_MEASUREMENT_INTERRUPTED`，**字面值未还原**） | `:82-85` |
| 其它（含 RIDE5）：boardversion `.`→删、`-`→`_` | `:86-89` |

`M3OtaVersionPolicy`（`:6-46`，`BOARD_VERSION="M3"`）：`normalize()` 用正则 `([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})` 把 `"Mon DD YYYY"` 型构建戳转成 **`yyyyMMdd`**（12 项月份表 `:9`），否则退而取串内 `\d{8}`（`:42-43`），再否则原样 trim。⇒ **M3 的固件版本就是 8 位日期戳，比较是字典序/数值**。这与 XTU 的 `\d{8}` 规则一致，可以共用一个版本归一化器。

`UpgradeBoardVersionPolicy.matches(cached, current, deviceType)`（`:37-68`）：只有归一化板号相等才复用缓存记录（M3 恒为 `"M3"`；R3Pro/R6 原样 trim；其它去非字母数字+大写）；空串 ⇒ false。

其它门：`OtaExpectedFileSizePolicy`、`OtaPackageFilePolicy`、`OtaPackageGroupPolicy`、`OtaPackageIdentity`、`OtaCurrentQueryIdentity`、`SessionBoundOtaIdentityPolicy`（**OTA 身份绑定到具体连接会话**，防止 A 相机的包推给 B）、`DeviceDetailFirmwareTypePolicy`。

### 2.5 失败处理

| 关注点 | 行为 | 证据 |
|---|---|---|
| 串行化 | 一次只有一个 OTA 下载（`OtaSerialTaskQueue`）；入队失败即释放限流令牌 | `UpgradeHelper.java:511,512` |
| 防重复弹窗 | `OtaPromptThrottlePolicy.tryAcquire/release` | `:507,512` |
| 下载完整性 | `IntegrityPolicy` 有 **4 个子类**：`None` / `ExpectedSize(bytes)` / `Sha256(hex)` / `ExpectedSizeAndSha256(bytes,hex)`（`core/transfer/IntegrityPolicy.java:15,42,90,140`）。`IntegrityVerifier.verify(file,policy)`：`None` 直接 return；尺寸不符 → `IntegrityMismatchException("Transfer size does not match expected value")`；SHA-256 走 `MessageDigest("SHA-256")` + `byte[8192]` 流式 + `%02x` 小写无分隔，**比较用 `String.equals(ignoreCase=true)`**（所以云端给大写也行），不等 → `"Transfer SHA-256 does not match expected value"`（`core/transfer/IntegrityVerifier.java:38-52,57-71`）。⚠️ 但这套只在死掉的新架构里被调；**现役老路径用的是「HEAD 探远端大小 + 本地按名字与字节数复用」** | `core/transfer/IntegrityPolicy.java:15,42,90,140`、`core/transfer/IntegrityVerifier.java:38-52,57-71` |
| **本地包复用判据** | `hasUsableCachedPackage(filePath, expectedFileName, expectedFileSize)` = 三者非空/正数后转 `OtaPackageFilePolicy.isExpectedPackage(File, name, size)` —— **只比文件名与字节大小，从不比对包内容哈希**（`UpgradeHelper.java:440-466`）。⇒ 同名同长度但内容不同的包会被判「可用」 | `UpgradeHelper.java:440-466` |
| 落点校验 | `"OTA package was written to an unexpected file"` | `DeviceOtaCoordinator.java:225,345,441` |
| 结果类型校验 | `"OTA package download returned upload result"` / 反向 `"OTA upload returned download result"`（同一状态机被 jadx 反成多份内联副本，逐份都在） | `:235,355,451`、`:268,388,483` |
| **升级后版本回读** | 新架构：`CONFIRMING_VERSION` 不符即 FAILED（`:295-311`）。**线上老路径没有回读**：上传成功回调 → `suppressRepeatDownloadPrompt()` + `showInstallationSafetyDialog()`，安全窗常量 `COUNTDOWN_SECONDS = 30`（`UpgradeActivity.java:96`、`:543-558`、`:795`）；重连后靠 `FirmwareUpgradeReconnectGate` 判「回来的是不是原来那台」——`normalizeSsid` = trim + 去外层引号 + 空→null，而 **`matches()` 在 `pendingSsid == null` 时匹配任意 SSID（fail-open）**（`core/util/FirmwareUpgradeReconnectGate.java:29-45`）；版本号最终来源是重连后 `DeviceSessionManager` 重新探测出的会话 identity，再回填 `PreferenceHelper.getCacheDevices()` 的 `ConnectedDevice.version` —— **`UpgradeActivity` 自己不查版本** | `DeviceOtaCoordinator.java:295-311`、`UpgradeActivity.java:96,543-558,795`、`core/util/FirmwareUpgradeReconnectGate.java:29-45` |
| 重试/续传 | `TransferRetryPolicy = Disabled \| ResumeFromCheckpoint(maxRetries)`；⚠️ **maxRetries 具体值未找到**（构造注入）。可确定的是 **`DeviceOtaCoordinator` 整条链上没有任何重试**：一次 `Throwable` 即 `FAILED` 返回，重试归上层 UI（`:196-320`） | `DeviceOtaCoordinator.java:196-320` |
| 取消 | `DeviceOtaResult.Cancelled`、`TransferState.Cancelled`；RIDE5 靠 `bCancelUpload` 每块检查（`:162,200`）并在拆除时强制置位（`:225,235,250,263`） | |
| 传输中 socket 错误 | `UpgradeDevice.java:208-390` 穷尽 try/finally 关流关 socket；若 `uploadedCount == file.length()` 则置 `bCancelUpload` 并重抛 ⇒ **长度已齐的失败不会被重试** | |
| **外部供电守卫（R3Pro/R6）** | `SocketException` 消息含 `"connection reset"`（递归 cause）或含 `result=-10` ⇒ `OtaFailurePrompt.ExternalPowerCheck`，提示「请接上电源后重试」 | `OtaFailurePolicy.java:6,20-23,40-46` |
| **M3 失败分类** | `installationAwaitingResult && isTimeout` ⇒ `M3InstallationTimeout`；错误消息 `"2D"` ⇒ `M3SdCardRemoved`；`"3D"` ⇒ `M3SdCardNotFormatted`；否则 `GenericInstallFailed` | `OtaFailurePolicy.java:24-31` |
| 重启与重连 | `WAITING_FOR_REBOOT → RECONNECTING → CONFIRMING_VERSION` + `FirmwareUpgradeReconnectGate` 的 pending-SSID 闩锁 | |
| 部分上传清理 | **未找到** `delete()`；`OtaPackageGroupPolicy.findFreshPackage` 只重新校验大小/名字 ⇒ INFERRED：陈旧包被忽略而非删除。⚠️ 更反直觉的一条：上传成功后调的所谓「清理」`Ride3ProUpgradeSuccessPolicy.clearUploadedFirmware(path)` **在 dex 里是个 `return true` 的空桩**（`Ride3ProUpgradeSuccessPolicy.java:13-15`，方法体只有 1 行），名字像删包其实什么都不做 ⇒ **TUWIN 现役路径成功后既不删也不校验本地包**，这点和 XTU 的 `SendSoftActivity.dealSuccess()` 真删（§1.4）刚好相反 | |
| 相机侧装机失败恢复 | 只能靠文案让用户手动断电重连（`upgrade_success_restart`） | |

> ⚠️ `M3SdCardNotFormatted` 说明 **TUWIN 的 M3 在装机时会检查 SD 卡格式状态** —— 这是三家唯一在 OTA 里对 SD 状态做硬校验的实现，也解释了为什么它需要 `sdformat` 能力。

### 2.6 进度与 UX

- 字节：`DeviceOtaProgress(state, transferredBytes:Long?, totalBytes:Long?, routeRevision:Long?, error:Throwable?)`（`core/update/DeviceOtaProgress.java:5-9`），`StateFlow` 暴露，初值 `DeviceOtaProgress(IDLE,null,null,null,null)`。**`routeRevision` 把进度绑到某一次网络路由纪元** —— 换网即作废，这个设计值得抄。
- 阶段：14 态枚举。
- 百分比：`OtaUpgradeFlow.onUploadProgress(total,progress,signal)` → `total>0 ? coerceIn((progress*100)/total,0,100) : 0`（**`total<=0` 时不是除零而是直接 0%**）；`beginUpload(initialPercent)` 夹在 `0..99`；**到 100% 后走哪个态由 `OtaCompletionSignal` 决定**：`UploadAccepted` ⇒ `AwaitingDeviceAcknowledgement`，`InstallationCompleted` ⇒ **`InstallingAwaitingDeviceResult`**（⚠️ 原文写成 `InstallationSucceeded` 是错的 —— `InstallationSucceeded` 只能由显式的 `installationSucceeded()` 进入，`OtaUpgradeFlow.java:64-86`）。安全窗：`enterInstallingSafetyWindow(s)`→`InstallingSafetyWindow(coerceAtLeast(s,0))`、`tickSafetyWindow(remaining)` 同（`:87-112`）。
- UI 态：`OtaUpgradeState = Idle · Preparing · Uploading(percent) · AwaitingDeviceAcknowledgement · InstallingAwaitingDeviceResult · InstallationSucceeded · InstallingSafetyWindow · AwaitingUserConfirmation · AwaitingReconnectVerification · Failed`；除 `Idle`/`AwaitingReconnectVerification`/`Failed` 外**全部锁住导航**（`getBlocksNavigation()`，`:26-37`）。

**文案（默认英文，行号在 `_work/_str_default.txt`）—— 这批文案直接暴露了状态机**：

| key | 文本 |
|---|---|
| `new_firmware_found_format` | New firmware found: %1$s |
| `new_firmware_available(_title/_default)` | New firmware available! / New firmware available |
| `new_firmware_connect_to_upgrade` | New firmware found! Connect device to upgrade |
| `firmware_download_title/progress_format/background` | Downloading firmware / `%1$d%%` / Download in background |
| `firmware_download_keep_network` | You can continue using the app. Please keep the phone connected to the network. |
| `firmware_download_complete_message` | Firmware is ready. Connect to the device to start the upgrade. |
| `firmware_download_failed_message` | Firmware download failed. Check the network and start the update again. |
| `firmware_upload_dialog_title/warning` | Firmware is uploading / **Do not operate the device to avoid device crash** |
| `firmware_upload_uploaded_label/total_label/remaining_label` | Uploaded / Total size / Remaining |
| `firmware_upload_failed_title/message` | Firmware upload failed / Check the device connection and try again |
| `firmware_upgrade` / `firmware_upgrade_notice` | Firmware Upgrade / Note: After successful upload, the device will automatically restart… |
| `m3_firmware_install_success` / `m3_firmware_install_timeout` | Firmware installed successfully / Installation timed out and failed |
| `ride3pro_upgrade_external_power_check` | Please connect power, then retry the upgrade. |
| `upgrade_firmware_warning` | **Upgrading firmware will restore the device to factory settings.** |
| `upgrade_installing_title/confirm_countdown/upgrading_countdown` | Upgrading, do not operate the device / `Confirm (%ds)` / `Upgrading (%ds)` |
| `upgrade_installing_firmware_subtitle` | **Red light flashes quickly, exit the app and reconnect the device after the red light turns off** |
| `upgrade_installing_message` | The red light will flash for 30 seconds, this is normal |
| `upgrade_success_press_center_button_message` | Press the center button on the device to start it |
| `upgrade_success_restart` | A rapidly flashing red indicator means installation is in progress… |

⇒ 三个我们从文案里能读出的**产品级事实**：(a) 刷固件会**恢复出厂设置**，必须提前告知；(b) RIDE 系靠**红灯快闪**表达装机中，且装机期约 30 秒，App 只能倒计时后让用户**按机身中键**确认；(c) 装机期间**禁止操作设备**，所以 UI 必须硬锁导航。

### 2.7 有没有内置固件 / 工厂模式

- **无内置固件**：`_work/tuwin/assets/` 只有 `dexopt` 与 `privacy.html`。
- **本地固件目录是 App 私有区，不是公共下载目录**：`UpgradeHelper.UPGRADE_DIRECTORY_NAME = "upgrade"`（`UpgradeHelper.java:73`），`upgradeBaseDirectory()` = **`new File(context.getFilesDir(), "upgrade")`**（`:634-636`）⇒ 用户用系统浏览器/文件管理器**放不进这个目录**，所以「自己下好固件丢给 App」这条路事实上是关着的。下载完成后停留展示常量 `DOWNLOAD_COMPLETE_DISPLAY_MILLIS = 2000`（`:72`）。
- **有本地选文件路径（老 M3/RIDE5 时代）**：`UpgradeDevice.getUpgradePath()` → `getDownloadDevPath()` 带 `mkdirs()`，过滤 `*.appsw`；`getLocalFirmwareByPrefix(prefix)` = `startsWith && endsWith(".appsw")`（`:107-113`），`getMatchedFirmware()` = 全部 `.appsw` 再 `Arrays.sort`（`:129-135`）。**这就是"从存储里挑一个固件文件"的钩子。** ⚠️ 但后缀只认老包：Ride3Pro 新包后缀是 **`.bin`** 且必须匹配 §2.3 那条 `_cardvr_HW…_SW…_upgrade\.bin$` 正则 ⇒ 本地扫描器看不见新格式的包。
- `AboutBuildRecoverPolicy` 是构建信息 tap-to-reveal 开发门，**不在 OTA 路径上**；未找到 OTA 专用的工厂/开发模式。

### 2.8 未找到、不要猜的清单

1. `newota` 的 base URL 字面量。（✅ 但**同文件的 App 自更新端点 `appupdatecheker` 的 base URL 已确认 = `http://ali.tuwinsmart.com/`**，`FeedbackApiEndpoint.java:10-12` —— 只有相机 OTA 那条仍是运行时注入。）
2. `UpgradeBean` 的完整字段（class 文件缺失，只证实 `version`、`fw_url`）。
3. `C3077cm.f1938n` 的字面取值（即 `newota` **与 `appupdatecheker` 共用**的那个版本参数名）。
4. ~~`checkupgradepktinfo.cgi` 的响应 schema 与它协商的块大小~~ → **已解决，见 §2.3**：字段就是 `offset` 与 `unitsize`，二者缺一即抛；`unitsize` 是分块尺寸、`offset` 是续传游标。
5. `TransferRetryPolicy.ResumeFromCheckpoint` 的 maxRetries 取值与续传偏移的线上格式。（新架构 `DeviceOtaCoordinator` 整条链**本身不含任何重试**，一次异常即 `FAILED` 返回，所以这个值只能在调用方找，而调用方不存在。）
6. RIDE3PRO/RIDE6 版本串里被剥掉的两个前缀字面值。
7. 任何 region/channel/locale 门控（**不存在**）。

### 2.9 App 自更新：与相机 OTA 是完全两套（已移出我们的范围，只留事实）

> 对应 `docs/evidence/tuwin` 附录三（传输·OTA·联网）§3.7 与 §8.1 第 1 行。「两套别混」这个结论成立，这里补齐实值。

`core/update/AppUpdateCoordinator` + `TuwinApplication`，**与相机固件无关**：不占设备面 `RouteState`、不需要 `EndpointGrant`，只用 `NetworkPlane.INTERNET` 的 READY 路由。

- 状态枚举 `AppUpdateCheckState` **7 个**：`IDLE / WAITING_FOR_INTERNET_ROUTE / CHECKING / NO_UPDATE / UPDATE_AVAILABLE / CHECK_FAILED / CHECK_CANCELLED`（`core/update/AppUpdateCheckState.java:12-18`）；结果 `AppUpdateCheckResult` 5 个子类：`WaitingForInternetRoute / NoUpdate / UpdateAvailable(key,response,routeRevision) / Failed(key,error) / Cancelled(key,reason)`（`AppUpdateCheckResult.java:17,65,133,215,283`）。⇒ 与相机侧那 14 态（§2.2）**没有一个名字重合**。
- 去重键实参：`AppUpdateCheckKey(versionCode, releaseChannel, serverEnvironment)`，调用点写死 **`AppUpdateCheckKey(250, "stable", "production")`**（`TuwinApplication.java:433`，两个字符串常量在同文件 `:146-147`）⇒ versionCode 是**编译期常量 250**，不是运行时读的。
- 端点拼写：官方就写作 **`@GET("appupdatecheker")`**（少一个 `c`，`TuwinSuspendApi.java:17`），`platform` 实参恒为 `"android"`（`RoutedAppUpdateGateway.java:53`）。baseUrl = `FeedbackApiEndpoint.BASE_URL = "http://ali.tuwinsmart.com/"` —— **明文 HTTP**，profile `INTERNET_API`。
- 判定：`hasNewVersion==false` → `NO_UPDATE`；否则 `lease.requireActive()` 后要求 `appInfo` 非空且 `isNetworkDownloadUrl(downloadUrl)`（`HttpUrl.parse` 非空 + scheme ∈ {http,https} + username/password 为空 + host 非空，`AppUpdateCoordinatorKt.java:14-23`），不满足 → `CHECK_FAILED` + `IllegalStateException("Invalid app update response")`（`AppUpdateCoordinator.java:513-521`）。路由拿不到 → `WAITING_FOR_INTERNET_ROUTE`，由 `NetworkRuntime.addInternetRouteListener` 在 READY 后重触发（`:498-500`）。
- 弹窗抑制键：`appUpdatePromptKey(bean) = trim(version) + "\|" + trim(downloadUrl)`，`presentedAppUpdateKeys` 是个 `LinkedHashSet`（`TuwinApplication.java:247,469-470,510`）。
- 下载安装：落 `filesDir/app-updates/tuwin_smart_<System.currentTimeMillis()>.apk`（`:591`），`TransferRequest(…, ResumePolicy.Disabled, …, IntegrityPolicy.None, …)`（`:610`）⇒ **官方不校验 APK 哈希**；再 `FileProvider.getUriForFile(pkg + ".fileProvider")` + `ACTION_VIEW` + `application/vnd.android.package-archive`（`:680-682`）。
⇒ **结论不变：我们不做 App 自更新**，所以既不必复刻 `appupdatecheker` 的拼写，也不必复刻它的明文 HTTP。这层留下的唯一价值是条对照：官方连 APK 都不校验哈希。


---

## 3. idGoLive 1.3.6 的固件更新

> 语料：`_work/idgo_live_src/`（`sources\` = Java，`resources\` = 资源与 .so）。

**核心结论：Java 层从不上传固件。** App 只算出一个**手机侧绝对路径**交给 `updateFw()`，`libcontrol.so` 自己读文件（日志串 `fwSize: %d`）并推过去，再用 PTP 事件 96–100 回报进度。⚠️ **推送载体不是 PTP 对象，而是 FTP**：同一颗 `libcontrol.so` 里实测有 `ftplib`、`convert2FtpPath`、`FtpOpenPort`、`PASV`、`STOR` 这组符号（PTP 只管控制面与事件回传），详见 §0 第 5 条与 §5.5。`SPHOST.BRN` **只是 App 侧的文件名约定** —— `SPHOST`/`BRN` 这两个串在 `libcontrol.so` 里**完全不存在**。

### 3.1 云端 API（CONFIRMED）

| 端点 | 方法 | 参数 | 响应 |
|---|---|---|---|
| `https://www.id221.com/api/app/firmware/area_check` | GET（okhttp，无 header 无 body） | **无** —— 服务器只能按客户端 IP 推断区域 | `{"code":int,"data":{"area_support":string}}`，仅 `code==200` 时用 |
| `https://idcam.oss-cn-shenzhen.aliyuncs.com/Firmware/firmware.json` | GET（okhttp） | **无** | 顶层：`update_time`、**`support_updated_version`**（逗号分隔的「当前可升级固件版本」白名单）、`description`、`firmware_list:[{product,name,region,version,download}]` |

证据：`ConnectDeviceManager.java:362,375-382`；`DownloadFirmwareActivity.java:220,170-173`；字段声明 `entity/FirmwareInfo.java:20-24`。**真实清单已抓到**：`_work/firmware/firmware.json`（4 条 C5Pro：cn/tw/ja/th，`update_time = 2025-06-05 17:22:17`，`support_updated_version = "25.03.21C,25.03.21M,25.03.21J,25.03.21T"`）。

⚠️ **顶层那三个字段 App 一个都不解析** —— `FirmwareInfo` 的 DTO 只有 list 里那 5 个键，`support_updated_version`（云端明确表达了「哪些版本才允许被升」）与 `update_time` 被整段丢掉。这是 §5.2「云端早就给了信息、客户端没接」的**第四例**，也是四家里唯一一份「可读的公开固件索引」，我们做检版策略时应当把它接上。
⚠️ **产品/型号/区域/当前版本从不发给任何端点** —— 型号与版本匹配 100% 在客户端做（`OptionSetting.java:977`）。这是三家中最"哑"的云端：一个静态 JSON 清单扔在 OSS 上。

**版本格式（CONFIRMED，从解析器反推，`utils/VersionChecker.java:12-25,48-56`）**：
- `前缀 _ YYYYMMDD _ 后缀`，按 `_` 切成**恰好 3 段**，第 2 段必须匹配 `\d{8}`，否则整条被拒。
- 判定：`prefix` 相等 **且** `suffix` 相等 **且** `cameraDate <= fwDate`（**字符串比较**）。
- 相机侧串先剥空格；剥完还剩空格即判无效。
- 形如 `GK1000_20240315_v12`（INFERRED 示例，非实测值）。**全仓无 semver。**
- 相机版本来自原生：`CameraFixedInfo.getCameraVersion()` → `ICatchCameraInfo.getCameraFWVersion()` → `JCameraInfo.getCameraFWVersion(sessionID)`；能力门 `ICH_CAM_CAP_FW_VERSION`。

**`area_check` 在 1.3.6 里实际什么都不拦**：`area_support` 被存进 `SPUtil.saveUserArea` 后，只有两处读它且都丢弃返回值（`ConnectDeviceManager.java:389` 打日志、`LaunchActivity.java:307`），**没有任何固件列表消费它**。INFERRED：区域限制是服务端按 IP 做的，`firmware.json` 里的 `region` 只是展示用（`DownloadFirmwareAdapter.java:51-52`）。

### 3.2 下载与落地

| 步骤 | 代码 |
|---|---|
| 关于页入口 → 下载固件列表 | `AboutAppActivity.java:118` → `DownloadFirmwareActivity` |
| 行点击 → 确认对话框 | `DownloadFirmwareActivity.java:256-277` |
| **目标文件名/目录推导** | `:326-334`：把 `download` URL 按 `/` 切，要求 ≥4 段，`name = 最后一段`，`dir = getExternalCacheDir()+"/Firmware/"+倒数第二段+"/"`。⚠️ **装机时走的是另一个函数**：`VersionChecker.getFirmwarePath(ctx, camVersion)`（`VersionChecker.java:27-40`）同样算出 `str2 = URL 末段`**却把它丢掉、只返回目录**，所以推送路径永远是「目录 + 常量 `SPHOST.BRN`」（`OptionSetting.java:991-992`）——URL 末段到底叫什么，装机阶段根本不参与判断 |
| 装机前唯一闸门 | `FileTools.checkFwUpgradeFile(dir, "SPHOST.BRN")`（`FileTools.java:31-38`）＝ **`new File(dir,name).exists()`**（目录不存在时顺手 `mkdir()`）⇒ 不校验长度、不校验哈希、不校验型号 |
| HTTP GET 落盘 | `utils/FirmwareDownloader.java:25-77`（`mkdirs()`、8192 字节循环、进度 = `bytes/contentLength`） |
| 成功后登记 | `:113-118` → `FirmwareInfo.saveFirmwareList`（SharedPreferences `firmware_prefs` / 键 `firmware_list` / Gson） |

**完整性校验：完全没有。** 无 MD5/SHA/CRC，不校验 `Content-Length`，无重试、无断点续传。只有 `response.isSuccessful()` 与之后的 `File.exists()`。每次读取时会顺手清掉指向已删文件的登记行（`FirmwareInfo.java:56-68`）。另有一个**写死的死桩**：`VersionChecker.isFirmwareNewerThanTargetDate(String)` 的函数体就是 `return false`（`VersionChecker.java:44-46`）⇒ 官方曾经打算做「比某个目标日期才允许升」的门槛，代码留在包里但永不生效。

**`SPHOST.BRN` 怎么进相机 —— 排除法 + 原生符号，CONFIRMED 是 native 推的（载体见本节开头：FTP over the session）**：
- 不是 HTTP `/app/`：`net/` 包里没有任何上传路径（见 3.4）。
- 不是拷 SD 卡：`FileTools.copyFile(rawResId, path)` 能把 `res/raw` 写到 `<path>sphost.BRN`（`FileTools.java:70`，**注意是小写 `s`**，与装机时查的 `SPHOST.BRN` 只差大小写 ⇒ 在区分大小写的文件系统上这条路径即使命中也过不了 `checkFwUpgradeFile`），但**零调用者**，且 `res/raw` 里只有 mp3、许可文本与 `client_secret.json`。
- 是原生：`OptionSetting.java:991-992` 拼 `firmwarePath + AppInfo.FW_UPGRADE_FILENAME` → `:306` `cameraAction.updateFW(fileName)` → `SdkApi/CameraAction.java:329` `cameraAssist.updateFw(curCamera.getSDKsession().getSession(), str)` → `ICatchCameraAssistImpl.java:347-348` → `JCameraAssist.updateFw_Jni(transportType, ip, vendorID, productID, fd, sessionID, path)`。
- ✅ **原 INFERRED 已在真实清单上验证**：抓到的 `firmware.json` 里 **4 条 download URL 无一例外**形如 `https://idcam.oss-cn-shenzhen.aliyuncs.com/Firmware/C5Pro_<region>/SPHOST.BRN`（`_work/firmware/firmware.json`）⇒ 「文件名约定由云端 URL 布局维持」成立，且 `region` 目录段（`C5Pro_cn`/`_tw`/`_ja`/`_th`）就是本地目录名。⚠️ 但这也意味着：**只要厂商哪天换个文件名，`getFirmwarePath()` 拼出来的 `SPHOST.BRN` 就会找不到**，而它只会给出一句 `setting_updatefw_upgrade_file_not_exist`（§3.5）。

### 3.3 触发与状态机

菜单项 `setting_update_fw` **无条件加入**（`UIDisplaySource.java:86`）→ `OptionSetting.java:1268-1278`：**要求 `isSDCardExist()`**，否则弹 `dialog_no_sd`。

| 项 | 值 |
|---|---|
| `updateFw(session, path)` 的 path | `<externalCacheDir>/Firmware/<URL倒数第二段>/SPHOST.BRN`（绝对路径，手机侧） |
| 调用线程 | 后台 `new Thread(...)`（`OptionSetting.java:303-317`），非 UI 线程 |
| 返回 `true` | 什么都不做 —— UI 只等事件（`:306-308`） |
| 返回 `false` | 关进度 + `setting_updatefw_failedInfo`（`:309-315`） |
| `notifyUpdateFw()` | **零参数、零调用者**（`ICatchCameraAssistImpl.java:217-218`）；原生符号 `ICatchCameraAssist_net::notifyUpdateFw()` 存在。INFERRED：SDK 预留的"推之前先知会相机"钩子，idGoLive 没用 |
| 装机期 UI | `MyProgressDialog` 显示 `setting_updatefw + "..."` —— **不确定态进度条，无百分比**（`:302`） |

事件投递：`cameraAction.addEventListener(sdkEventId, listener)` → JNI `Java_..._JCameraControl_addEventListener`；注册在 `Function/SDKEvent.java:452-485`，拆除 `:592-618`；**监听器在调用前一次性武装 5 个（97,98,96,99,100）**（`OptionSetting.java:297-301`）。

| 事件 | App msg | 处理 | 界面结果 |
|---|---|---|---|
| 96 `FW_UPDATE_CHECK` | 25 | `SDKEvent.java:278-287` | **只打日志**（`OptionSetting.java:396-398`） |
| 97 `FW_UPDATE_COMPLETED` | 13 | `:289-298` | 关进度 + `setting_updatefw_closeAppInfo`，按钮 OK |
| 98 `FW_UPDATE_POWEROFF` | 14 | `:311-320` | **注销全部 5 个监听** + `closeAppInfo` + 按钮变 `dialog_btn_exit` → `ExitApp.getInstance().exit()`（**App 自己退出**） |
| 99 `FW_UPDATE_CHKSUMERR` | 26 | `:267-276` | 注销全部 5 个 + **不可取消**对话框 `setting_updatefw_chec_sum_failed` |
| 100 `FW_UPDATE_NG` | 27 | `:300-309` | 注销全部 5 个 + 不可取消 `setting_updatefw_failed` |

**"相机即将重启 / 请勿断电"这类提示：不存在（未找到）。** 与重启相关的只有 97/98 上的 `closeAppInfo`（"请重启 App"）和 98 时 App 自杀。原生侧有对应的等待语义：日志串 `"not receive camera ready to update fw event"` ⇒ INFERRED：**native 会阻塞等一个"相机准备好接收固件"的事件（推测就是 96）才开推**；相机接受镜像时发 96、为刷机重启时发 98。

### 3.4 两条 HTTP IC 通道：确认没有升级命令

- **LyIC `?custom=1&cmd=NNNN`**（`192.168.1.254`，活：`ConnectDeviceManager.java:90` 就 `new LyIC()`）：`net/RequestInfo.java:8-66` 是完整字面量表（1001/1002/2001-2018/2008/2011/2015-2018/3001/3003-3006/3010-3012/3014-3018/3021/3024/3029/3030/3031/3044/4003/4004/8010-8013/8888），**没有任何升级/OTA/fw 命令**。最接近的只是读版本 `cmd=3012`。
- **`/app/*` JSON 其实是两套，一套活的 + 一套死的**：
  - `192.168.169.1` 上由 `M1RequestInfo` + `QzIC` 实现（**活**：`ConnectDeviceManager.java:137` `new QzIC()`）。整套 `capability / getproductinfo / getdeviceattr / getparamitems / getparamvalue / getsdinfo / sdformat / getmediainfo / getrecduration / getfilelist` 里**没有升级端点**。
  - `192.168.1.254` 上还并存**另一套同形状 JSON**（`LyWyzRequestInfo` + `LyCmdWithYzIC`，`urlGetCapability=…/app/capability` 等一一对应），**但唯一的入口 `ConnectDeviceManager.try2connectLywyzIC(Handler)`（`:112`）在全仓零调用点** ⇒ 整条 `.254` 的 JSON profile 是**死代码**，App 在 `.254` 上只会选中 `cmd=` 那套。⇒ 「`.254` 有两套 API」是设备侧事实，不是 App 事实；我们复现时两套都该实现，按 `getdeviceattr` 是否回 JSON 分流。
- 对 `sources/com/icatch/golive/net/` 做 `upgrade|firmware|\bfw|ota|BRN|update` 忽略大小写全量搜索，只命中云端的 `area_check` 行和镜头旋转 URL。
- ⚠️ **一条被漏掉的通用上传原语**：`ApiCaller.uploadFile(File)`（`net/ApiCaller.java:144-160`）会往 **`POST http://192.168.1.254/upload`** 发 multipart（表单字段名 `"file"` = `ShareInternalUtility.STAGING_PARAM`，`application/octet-stream`，`ProgressRequestBody` 打「上传进度: n% (x/y bytes)」，client 绑 `WifiNetwork.getSocketFactory()`，超时 connect 30 s / read 60 s / write 60 s）——但**全仓没有任何调用者**。⚠️ 别和 iCatch SDK 里同名的 `ICatchCameraPlayback.uploadFile(String,String)` → `JCameraPlayback.uploadFile`（PTP 原生，传媒体文件）混为一谈，两者毫无关系。⇒ 结论不变但更硬：**HTTP 侧连"给固件用"的通道都不需要新造，官方只是没接线**。

⇒ **对 idGoLive 系相机，非 PTP/FTP 的 HTTP 通道根本没有可用的刷固件路径**；只有走 iCatch SDK（native 侧 FTP over session，见 §0 第 5 条）的机型可以。

### 3.5 门限与失败处理

| 关注点 | 行为 | 证据 |
|---|---|---|
| "有更新"提示 | **没有。** 无推送、无角标、无连接后自动弹窗。更新是纯 pull：用户点"更新固件"时，从**本地已下载列表**里挑一个能用的 | `OptionSetting.java:976-996` |
| 型号/区域匹配 | 只比 `prefix`+`suffix`+日期；**region 从不参与比较** | `VersionChecker.java:27-56` |
| "已是最新" | 判定用 `fwDate >= camDate` ⇒ **日期相等也算合法，允许重刷**；无匹配则与"没有包"共用同一句 `setting_updatefw_no_resource` | `:55`、`OptionSetting.java:979-989` |
| 文件缺失 | `setting_updatefw_upgrade_file_not_exist`，参数 `$1$`=SPHOST.BRN、`$2$`=`/idGoLive/Resoure/` | `:991-995`、`AppInfo.java:23,28` |
| ⚠️ **文案与实际路径不一致（官方 bug）** | 对话框说 `/idGoLive/Resoure/`，代码实际读的是 `<externalCacheDir>/Firmware/<dir>/` | `AppInfo.java:28` vs `VersionChecker.java:35` |
| 重试/超时 | App 层对下载和刷机**都没有**；`updateFw` 抛出的所有 SDK 异常（`IchCameraMode/DeviceProp/Device/InvalidSession/NotSupported/Socket/TimeOut`）被吞 → 返回 `false` → 一句 `failedInfo` | `CameraAction.java:330-358` |
| 原生超时旋钮 | `getPtpTimeoutCheckCount`、`getPtpTimeoutCheckIntervalInSecs`、`setConnectionCheckParam`、`enablePtpReconnection`；App 默认 `connectionCheckInterval=4`、`connectionCheckTimes=4` | `libcontrol.so`、`AppInfo.java:52-53` |
| 相机拒绝 | 原生日志 token `fw_not_support` | `libcontrol.so` |
| 多客户端争用 | 文案 `multil_client_upgrade_tips`（"其他用户正在连接，现在您不能升级设备"）存在但**无代码引用** ⇒ 客户端没做保护 | `strings.xml:592`、`values-zh:291` |
| 校验和失败(99) | 拆掉 5 个监听 + 不可取消对话框，点掉就完事；包留在缓存，不重下不清理 | `OptionSetting.java:399-418` |
| POWEROFF(98) | 拆监听 + 对话框 + 按钮触发 `ExitApp.exit()`；PTP 会话随相机一起断 | `:374-393` |
| 成功后清理 | 无（未找到）；只有用户自己在下载页手动删单条/全删 | `DownloadFirmwareActivity.java:280-313` |

### 3.6 进度与文案

英文在 `resources/res/values/strings.xml`，中文在 **`values-zh/strings.xml`**（⚠️ `values-zh-rCN/` 里这些键**一个都没有** —— 生效的中文包是 `values-zh`）。

| key | EN | ZH |
|---|---|---|
| `setting_update_fw` | Update firmware | 更新固件 |
| `setting_updateFW_prompt` | Do you want to update software? | 是否需要更新FW? |
| `setting_updatefw_failedInfo` | Update software failure | 更新FW失败，请确认相机是否支持此功能或是否使用正确的固件。 |
| `setting_updatefw_failed` | Update failed | 更新FW失败 |
| `setting_updatefw_chec_sum_failed` | FW update check sum error! | （未翻译，同英文） |
| `setting_updatefw_closeAppInfo` | Software update completed, please restart the APP | 已完成FW更新，请重新启动应用 |
| `setting_updatefw_no_resource` | No firmware package that needs to be updated was detected this time. Please check 「Download Firmware」 | 本次未检测到需更新的固件包，请到「下载固件」处查看 |
| `setting_updatefw_upgrade_file_not_exist` | The upgrade file $1$ does not exist, please put the file to this path:$2$. | 升级文件$1$不存在，请将文件放置到这个路径：$2$。 |
| `download_firmware` / `downloading_file` / `download_done` / `message_download_failed` | Download Firmware / Downloading files / Download complete / Download failed | 下载固件 / 正在下载 / 下载完成 / 下载失败 |
| `network_error_please_check` / `no_data` / `warm_reminder` / `dialog_delete_all` | Network request failed… / No data / Warm tips / All delete | – |
| `multil_client_upgrade_tips` | other users are connecting, and now you can't upgrade device. | 其他用户正在连接，现在您不能升级设备 ——**未被引用** |
| `device_cannot_lens_rotation` | Please upgrade the device firmware to activate this function. | – （能力门文案，不在 OTA 流程里） |

**进度粒度：只有下载阶段有百分比**（`"downloading_file…%s %.1f%%"`，`DownloadFirmwareActivity.java:130`）；**刷机阶段零进度**，只有一个转圈。

### 3.7 App 自更新：这个版本里根本没实现

- `urlGetIdCamSoftwareVersionInfo = "https://api.idalbum.com/album.php/api/getIdCamSoftwareVersionInfo"`（`RequestInfo.java:29`）**零引用**；文案 `version_upgrade` / `version_upgrade_info` **零引用**。
- 全 `sources/com/icatch/golive` 里**没有** `.apk` 下载、没有 `ACTION_VIEW`/FileProvider 安装意图。
⇒ idGoLive 1.3.6 只有相机 OTA，没有 App 自更新。别把它的存在当成缺口。

### 3.8 原生面（`libcontrol.so`）—— clean-room 必须复刻什么

按要求的 sweep（`grep -aoE "[A-Za-z_]*fw[A-Za-z_]*|[A-Za-z_]*upgrade[A-Za-z_]*|[A-Za-z_]*brn[A-Za-z_]*"`）结果**全集**只有：
```
Firmware   firmware   fw   fwSize   fw_not_support   fwrite
```
`upgrade`/`Upgrade`/`UPGRADE`、`BRN`、`SPHOST`、`UPDATE_FW`、`OTA` **零命中**。且 `Firmware`/`firmware` 这两个**不是 OTA** —— 上下文是一堆媒体类型串（`… M4A Firmware Windows Image Format Undefined Audio WMA OGG AAC …`），别过度解读。

更深的扫描（CONFIRMED，这些才是要复刻的）：

| Token | 含义 |
|---|---|
| `com::icatchtek::control::core::ICatchCameraAssist_net::updateFw(std::shared_ptr<ICatchCameraSession>, std::string)` | **PTP-over-IP 固件推送**；第 2 参 = 手机侧绝对路径 |
| `…::ICatchCameraAssist_net::notifyUpdateFw()` | 预通知钩子，无参（App 未用） |
| `…::ICatchCameraAssistAbstract::updateFw(...)` / `notifyUpdateFw()` | 基类 vtable 项（USB 变体在别处） |
| `Java_com_icatchtek_control_core_jni_JCameraAssist_updateFw` / `_notifyUpdateFw` / `_addEventListener` / `_removeEventListener` | JNI 边界名（若复用该 SDK 的 Java 壳必须同名） |
| `Java_..._JCameraInfo_getCameraFWVersion`、`isOldTimelapseFw()` | 喂给 `VersionChecker` 的版本串来源 |
| 日志 `updateFw`、`fwSize: %d` | 它 stat/读手机侧文件 ⇒ **传输是按文件长度驱动的** |
| 日志 `not receive camera ready to update fw event` | **阻塞等待相机"就绪可收固件"事件**（推测 96） |
| 日志 `fw_not_support` | 镜像被拒 / 目标不支持 |
| `JCameraConfig_*`：`enablePTPIP`、`enablePtpReconnection`、`getPtpTimeoutCheckCount/IntervalInSecs`、`setConnectionCheckParam` | 长时刷机期间的 PTP-IP 传输与超时控制 |

**真正的 clean-room 空白（Java 层拿不到，只能抓包）**：PTP 厂商操作码、`SPHOST.BRN` 的容器/头格式、分块尺寸、以及事件 96–100 在线上如何编码。96–100 是 Java 侧的 **SDK 逻辑 ID**；被 strip 过的 `.so` 不暴露任何 vendor-op 符号或操作码串。**只有对真机刷一次固件、同时抓 PTP 流量才能还原。**


---

## 4. 三家 OTA 横向对比

| 维度 | TUWIN | XTU GO | idGoLive |
|---|---|---|---|
| 云端检版 | `GET newota?version=&boardversion=`（**base URL 字面量未找到**） | `GET /v1/push/api/getNewestVersion`，3 个 **CSV 列表参数**（多设备一次查）；另有 2 代老接口 | 静态 OSS `firmware.json`，**零参数** |
| 匹配键 | `(version, boardversion, model)` | `model-region-hardware` 三元组（缺省 `region="G"`、`hardware="V1.0"`） | `prefix` + `suffix` + 8 位日期 |
| 版本格式 | M3 = `yyyyMMdd`（从 `"Mon DD YYYY"` 归一化）；R3Pro/R6 = 文件名 `HW`/`SW` token，`_`→`.` | `\d{8}` 日期戳，字典序或 int 比 | `_YYYYMMDD_` 三段式，**日期相等也允许重刷** |
| region 门控 | **无** | 有（服务端 + 客户端键） | 有 `area_check` 但**客户端不消费**，实际按 IP 由服务端裁 |
| 下载完整性 | **HEAD 探远端大小** + `IntegrityPolicy.ExpectedSize` + 落点文件名校验 | `.tmp` 后缀 + 失败即删（有个"删整个目录"的 bug） | **完全没有**（无 MD5、不校验长度、无重试） |
| 送包通道数 | 3（REST 整文件 / M3 整文件 / RIDE5 分块） | **4 种实现，只有 72B 帧那条活着（两个入口）**：`DeviceUpgradeActivity` + `SendSoftActivity`；海思 CGI、200B 帧三种实现、Amba Gen-A、iCatch/Mini1 全死 | 1（native **FTP over 会话**，非 PTP 对象；PTP 只管控制面与事件） |
| 客户端算 MD5？ | R3Pro/R6 有（8192 流式、`%02x` 小写 32 位；**只当 query 发给设备，App 从不与云端值比对**）；M3、RIDE5 无 | Amba 有；海思 CGI 无；8080 现役两条用**正确补零**的 `FileUtils.getFileMD5`（丢前导零的 `BigInteger` 版只在死代码里） | 无（native 算 CRC 随包送出） |
| **装机后回读版本** | **无**——⚠️ 回读只存在于从未被调用的 `DeviceOtaCoordinator`；现役路径上传返回后弹 30 秒安全窗即宣告成功（版本改由重连后的会话 identity 回填，无人比对） | **无**（重连后会重写 `CameraParameters.VersionNo`，同样无人比对） | **无**（只有事件 97/98，无版本比对） |
| 感知相机重启 | ⚠️ 只有 RIDE5 一条通道挂了 `FirmwareUpgradeReconnectGate`，且它**不校验任何东西**，只让设备列表继续刷新 | 无（文案让用户自己重启；72B 变体让用户**在相机屏幕上**点升级） | 半有（事件 98 `POWEROFF` → App 自杀） |
| 取消 | 有（`Cancelled` / `bCancelUpload`），但 `FirmwareUploadDialog` 是 `setCancelable(false)` 且 UI **无 CANCEL 态**；R3Pro/M3 走 `lifecycleScope` 无取消标志 | 下载可取消；**送包不可取消**；且二次点击会**再起一条线程共用同一 socket 字段** | 不可取消 |
| 断电/误操作守卫 | 文案 + 事后 `ExternalPowerCheck`；⚠️ **清单里根本没有 `WAKE_LOCK` 权限**，也没有覆盖设备传输的前台服务 | ⚠️ **两条活路径一条有一条没有**：`SendSoftActivity` 有 `addFlags(FLAG_KEEP_SCREEN_ON)`（`:59`），`DeviceUpgradeActivity` 没有；两条都无 WakeLock、无前台服务 | **无任何提示**，无 WakeLock，刷机跑在裸 `new Thread` 上 |
| SD 卡前置 | M3 会分类 `M3SdCardRemoved` / `M3SdCardNotFormatted`，但**只能靠写失败发现**；M3/RIDE5 的 SD 就是暂存介质 | 预检取 `getSdCardInfo`+`getBatteryInfo` 但**返回值被丢弃**；`_failed_battery`/`_failed_nocard` 等**九条**文案存在却零引用 | **强制** `isSDCardExist()`，否则弹 `dialog_no_sd`（唯一被强制的前置条件） |
| 「刷机会恢复出厂」告知 | **有**（`upgrade_firmware_warning`） | 未找到 | 未找到 |
| 本地选文件刷机 | 有代码（`*.appsw` 读取器）但**零调用者** | 有界面 `activity_upload_firmware` 但**宿主 Activity 漏了 manifest 条目** | 有文案（但**路径写错了**，见 3.5） |
| 进度粒度 | 14 态 + 字节 + 百分比 + `routeRevision`（但都是死代码的） | 4 种通道各不同：每块 / 1% / 只写日志 | 下载有百分比；**刷机阶段零进度**，只有转圈 |
| App 自更新（**已移出我们的范围**） | 有（`app_update_*` 12 条文案） | 有（`firmWareModel=XTUGO_Android` + int versionCode + `ACTION_VIEW`） | **未实现**（端点与文案都是死的） |
| 镜像是否自描述 | 未取到样本 | ✅ **256B 头含型号 + 版本 + 长度**（§5.4） | ✅ **`SUNP BURN FILE` 头含长度 + `CHKS` 日期戳**（§5.3） |
| App 是否读镜像头 | ❌ 从不 | ❌ 从不 | ❌ 从不 |

**跨品牌结论**：

1. **「云检版」这一层可以统一，「送包」这一层不能。** 三家检版都是「拿设备自报的三元组去问服务器要 URL」，形状一致；送包却是 3 / 1(活) / 1 条互不兼容的通道。所以我们的 OTA 抽象应该是：**统一的 check/plan 层 + 每 profile 一个 `OtaTransport`**。
2. **版本格式统一成「抽 8 位日期戳」即可覆盖三家。** TUWIN M3、XTU、idGoLive 全都把 `yyyyMMdd` 当版本真身，一个 `FirmwareVersion.from(String): LocalDate?` 工具能通吃。⚠️ 但更根本的问题是 **TUWIN 的 OTA 路径里根本没有版本比较**（比较权在云端），App 会把**相等甚至更旧**的版本照装。
3. ⚠️ **更正：三家都没有「升级后回读版本确认成功」。** TUWIN 那套 14 态状态机与回读逻辑**只存在于从未被调用的 `DeviceOtaCoordinator`**；现役路径上传返回后弹 30 秒倒计时就宣告成功。⇒ 这不再"是官方某家的最佳实践"，而是**官方三家共同的能力空白**，也是我们最该自建的护栏之一（§6.1 R3）。
4. **三家都不做下载完整性**（TUWIN 只探长度、XTU 不解析云端已有的 `size`、idGoLive 完全不校验），也都没有相机侧的失败恢复。而 §5 实测证明：**OSS ETag 就是 MD5、XTU 云端就带 `size`、镜像头部就写着型号与版本** —— 我们不需要任何厂商配合就能把这三件事做对。
5. **设备侧比 App 侧聪明。** 真实镜像里带着 CRC 校验、A/B 双槽（`keep original Firmware1/2`）和 SD 卡更新（`[CardUpdate]`）；三个 Android App 一个都没用上。⇒ 我们做 OTA 的价值主张不是"复刻官方"，而是**把官方漏掉的四道闸门补上**。

---

## 5. 真实固件镜像实测（本次下载分析）

> 前四节全部来自静态逆向，有个根本盲点：**三个 APK 里没有任何固件样本**（按 `.sw/.appsw/.bin/.brn/.img/.rom/.fw` 全量搜索，只命中 Kotlin 的 `DebugProbesKt.bin`），所以镜像内部结构此前无法回答。本节是**真实镜像的实测结果**。
> 语料与脚本：`_work/firmware/`（`firmware.json`、`C5Pro_{cn,tw,ja}.BRN`、`XTU S7PRO_20.8.6.1.20260910.bin`、`XTUMINI2Pro_V1.8.1.9.20260914.bin`、`analyze.js`、`deep.js`、`tags.js`、`peek.js`）。这些是分析产物，不在仓库里。

### 5.1 获取渠道与合法性

| 品牌 | 是否拿到 | 途径 |
|---|---|---|
| idGoLive（iCatch C5Pro） | ✅ 3 个区域版本 | 公开静态清单 `https://idcam.oss-cn-shenzhen.aliyuncs.com/Firmware/firmware.json`（**GET 无参数无鉴权**）→ 同目录 `SPHOST.BRN` |
| XTU（S7PRO、Mini2Pro） | ✅ 2 个型号 | 公开 API `GET https://server4.gkuvision.com/v1/push/api/getNewestVersion?firm_ware_model=<型号>&region=G&hardware_version=V1.0`（**无鉴权，型号可枚举**）→ `file_url` 指向 `vidvault-asia.oss-cn-hangzhou.aliyuncs.com` |
| TUWIN | ❌ | `newota` 的 base URL 字面量不在反编译产物里（运行时注入），只有下载白名单 `appdownload.tuwinsmart.com` 可猜，未验证 |

顺带两个事实：**XTU 云端可按型号枚举**；`XTUS7` 与 `XTUMAX3` 返回**空列表**——不是所有型号都有 OTA，S7 就没有。

⚠️ 合法性边界：这些是厂商公开给终端用户、接口不做任何鉴权的固件二进制。本节只做结构观察用于互操作，不复制内容、不重新分发。README 已声明厂商固件与商标不在本项目授权范围内。

### 5.2 关键发现：云端其实给了校验信息，三家都没用

| 渠道 | 提供的完整性数据 | App 是否使用 |
|---|---|---|
| 阿里云 OSS 的 `ETag` | 单次上传的 ETag **就是整个文件的 MD5**。实测 `C5Pro_cn.BRN` 的 ETag `15941FC39D4D822F288444891FAE73AB` 与本地 `md5sum` **逐字符相同** | ❌ 从不比对 |
| XTU 云 API | 响应带 **`size`**（如 `54490165`）与 `create_time` | ❌ **App 的 DTO 根本没有这两个字段**（`GetDeviceNewestVersionResponse.java:48-56` 只声明 8 个字段）。实测下载长度与 `size` 完全相等 ⇒ 服务端早就给了，客户端没接 |
| 镜像自身 | 见 5.3–5.5，头部含型号/版本/长度 | ❌ 三家 App **没有一个读过镜像的一个字节** |

⇒ **不需要厂商配合，我们现在就能做「下载后校验 + 校验镜像头部与本机型号匹配」。** 这是本文最重要的一条可执行结论。

### 5.3 iCatch 镜像 = 凌阳 `SUNP BURN FILE` 烧录容器

`C5Pro_*.BRN`：16,110,960 B（cn/tw/th **同尺寸**）、14,780,272 B（ja）。

| 偏移 | 内容 |
|---|---|
| `0x00` | 魔数 **`SUNP BURN FILE\0\0`**（16B）—— Sunplus/凌阳系烧录容器 |
| `0x10` | u32 LE = **文件总长**（`0x00f5d570` = 16110960；cn 与 tw 均实测相等） |
| `0x14` / `0x18` | `0xab600` / `0xac600`（相差 0x1000，疑为 bootcode 段界） |
| `0x1c` | **`0x42c720`** —— 区域化载荷起点 |
| `0x24` | `0xf5d320`（= 文件长 − 624，末条记录偏移） |
| `0x1fc` | **4 字节逐版本不同的摘要**，正落在 ARM 异常向量表尾槽（bootcode checksum 的经典位置）。cn `cc36b5fa` / tw `d9237ea6` / ja `1ba563c4` |

**内部有自描述的版本记录**（`FIFD` 记录 @`0xbdb38`，紧跟 `CHKS` + 构建戳）：

| 版本 | 镜像内 `CHKS` 串 | 镜像内 `{iCatCh}` 串 | 清单 `version` | 吻合 |
|---|---|---|---|---|
| cn | `CHKS25/08/04+27F` | `{iCatCh}50804.10` | `C5Pro_20250804_CK` | ✅ |
| tw | `CHKS25/05/27+23A` | `{iCatCh}50527.09` | `C5Pro_20250527_TW` | ✅ |
| ja | `CHKS25/05/27+254` | `{iCatCh}50527.09` | `C5Pro_20250527_JA` | ✅ |

⇒ **镜像里烙着与云端清单一致的日期戳。** idGoLive 的 `VersionChecker` 只拿相机上报的版本串去比云端字符串，**从不打开文件核对这个 `CHKS`** —— 一条现成的、零成本的错刷防线被浪费。

**前 4.3MB 是 iCatch 的 ROM monitor / cardISP 控制台**，实测串直接暴露了设备侧更新引擎：

```
SUNP BURN FILE   cardISP progress %u/%u   cardISP fail %x   ISP OK
D:\firmware.brn  Example: boot D:\5000TEST.BIN   Read file to DRAM. Ex. read TEST.BIN 0x41000000
:NO_BOOT   :RAWCBOOT0   FW boot error %x
[CardUpdate]update Firmware1.   [CardUpdate]keep original Firmware1.
[CardUpdate]update Firmware2.   [CardUpdate]keep original Firmware2.
spiChipErase  spiBlockErase  nandEraseAll  emmcerase0  erase whole embedded storage
Calculate checksum for specific memory range.
checksum(0x%08x+0x%x) 32:0x%08x, 8:0x%04x
SP5K_RELEASE_VERSION : %s     BASEFW_RELEASE_VERSION: %s
|  iCatch ISP Version [%s.%s]  |     SIDC_MODEL = "iCatch V37"
Copyright (c) 1996-2015 Express Logic Inc. * ThreadX Cortex-A7/GNU Version G5.7.5.2
```

三条**改变风险判断**的结论：
1. **存在 `[CardUpdate]` 卡更新机制** —— 相机可以自己从 SD 卡读镜像刷机。三个 Android App 都没暴露它，但**这就是变砖后的恢复入口**（见 6.1 R9）。
2. **`update Firmware1/2` + `keep original Firmware1/2` 是 A/B 双槽** —— 设备保留原始固件并可回退，大幅降低"必然变砖"的概率。
3. **芯片是 iCatch SP5K 系（Sunplus 血统）+ ThreadX/Cortex-A7** —— 与 §3 的 `HiCam<chip>` 命名、`{iCatCh}` 通道串互相印证。

**区域差异定位**（cn vs tw，尺寸完全相同）：`0x00–0x42c738` 之间**只有 8 + 6 字节不同**（就是上表那两条版本记录），从 `0x42c738` 起 11.2MB 全变 ⇒ **头部 `0x1c` 就是区域化分界**；区域差别不是打补丁，而是多语言资源整段重排。cn vs ja 额外差在头部 `0x11–0x1ff`（长度字段自然不同）。

### 5.4 XTU 镜像有两种**互不兼容**的容器格式

同一品牌、同一云端 API，两种格式，按 SoC 分道。

**(a) S7PRO（SigmaStar/海思系）：256 字节自描述头**

| 偏移 | 内容 | 实测 |
|---|---|---|
| `0x00` | 魔数 8B `15 25 12 08 e3 9c 68 11` | 两型号魔数不同 ⇒ 魔数含版本信息 |
| `0x0c` | u32 LE = **文件总长** `0x045161a8` = 72442280 | ✅ 与实际长度相等 |
| `0x10` | u32 = **256**（头长度） | |
| `0x14` | **32B 型号字段 = `"XTUS7PRO"`** | ✅ |
| `0x54` | **32B 版本字段 = `"20.8.6.1.20260910"`** | ✅ 与云端 `version` 去前缀去 `.G` 后一致 |

外层是 zip（`XTUS7PRO_...G.zip` → 单个 `.bin`），zip 尺寸与云端 `size` 字段**完全相等**。

⇒ **这是最有价值的一条：镜像头里就写着型号和版本，读 64 字节就能回答"这镜像是不是给这台的"。** 而 App 只把文件名和 MD5 发给相机，从不读头。

熵分布另显示 `0x60000–0x430000` 有 3.9MB 平均熵 **1.205**（近乎全零的预留区），另有 768KB 熵 7.894 的压缩载荷 —— 典型分区级镜像 + 预留布局，**刷错就是结构性破坏**。

**(b) Mini2Pro（iCatch 系）：多组件 `.devfw` 容器**

```
0x08 u32 = 144（头长度）   0x0c u32 = 3（条目数）
0x14 + k*0x40 起是 64 字节条目名：
  [0] "bld_pba.devfw"
  [1] "dsp_rom_lnx_rfs_dtb.devfw"
```
容器内嵌**设备侧升级代理**，日志串直接证实校验环节：

```
Start firmware CRC check...
Cannot read fw image header!
Verifying image CRC ... 0x%x != 0x%x failed!
analyzing firmware file: %s
firmware update %s finish
```

⇒ **设备会校验镜像头 CRC 并明确报 validate failed。** 这解释了 idGoLive 事件 99 `FW_UPDATE_CHKSUMERR` 的来源——它是**设备侧** CRC 失败的回报，不是 App 算的。也意味着：**传输截断设备能拒；但"型号对、镜像错"这种逻辑错误只有 App 能防，而三家都没防。**

### 5.5 实测对本文前面章节的修正

| 之前的说法 | 实测后的结论 |
|---|---|
| 「镜像内部结构无法离线研究」 | **推翻**。清单与云 API 均可公开访问，已拿到 5 份真实镜像并解析出头结构 |
| 「`.sw`/`.bin` 是否有可校验的头，无法确定」（XTU 审计的显式空白） | **确定有**：256B 头含型号与版本；iCatch 侧是 144B 头 + 条目表 + 设备端 CRC |
| 「云契约里没有任何 checksum 字段，App 结构上无法校验下载」 | **部分推翻**：XTU 云端**实际返回 `size`**，OSS **ETag 即 MD5**。App 的 DTO 没解析而已 |
| 「iCatch 固件通过 PTP 推送」 | **修正为 FTP**（`libcontrol.so` 内嵌 `ftplib` + `convert2FtpPath`，PTP 只管控制面） |
| 「无 A/B 回退、无卡恢复」 | **修正**：Android App 层确实没有，但**镜像内嵌的 bootcode 有 `[CardUpdate]` 与 Firmware1/2 双槽** |

---

## 6. 变砖风险审计：三家缺的护栏，以及我们必须自建的

三份安全审计（TUWIN / XTU / idGoLive）合并去重，按「发生概率 × 不可逆程度」排序。**R1–R6 我认为不可妥协**，R7 之后是纵深防御。

### 6.1 护栏清单

| # | 必须自建的护栏 | 缺了会怎样 | 三家现状 | 我们的成本 |
|---|---|---|---|---|
| **R1** | **送包前校验镜像头部 ↔ 本机型号** | 刷入同 SoC 但 sensor/DDR/分区表不同的镜像 → 结构性破坏 | **三家全部：App 从不读镜像一个字节**；XTU 甚至按 4 字符芯片号 `substring(5,9)` 判兼容 | **低**（§5.4 证明头部可读，一次 `pread` + 字符串比对） |
| **R2** | **端到端完整性：下载后与送包前各校验一次** | 截断/位翻转的镜像进入不可逆阶段 | TUWIN 只有 HEAD 探长度；XTU 不解析云端 `size`；idGoLive **完全不校验，且 `onComplete` 在 `close()` 之前触发** | **低**（OSS ETag=MD5、XTU `size` 现成） |
| **R3** | **装机后回读版本 + 硬超时 + 明确的"设备未返回"状态** | 变砖不可检测，用户以为成功 | **三家 OTA 流程里全无**（见 §0 第 2 条）。TUWIN 有代码但从未接线；idGoLive 靠 97/98 事件但无看门狗，相机不回话就是永久转圈。⚠️ 但要分清：**「读版本」三家重连后都会做**（XTU `HomeActivity.java:1743-1746` 重写 `CameraParameters.VersionNo`、TUWIN 回填 `ConnectedDevice.version`、idGoLive 走 `getCameraFWVersion`），**缺的只是拿它和期望值比对** ⇒ 成本比看上去低 | 中（需各机型版本读取命令，我们已有 `getDeviceInfo`） |
| **R4** | **息屏/休眠/进程死亡防护：前台服务 + WakeLock + keepScreenOn 覆盖整个传输窗口** | doze 挂起 socket → 相机擦除后等不到数据 → 最经典砖 | **XTU 两条活路径只有一条有息屏保护**（`SendSoftActivity.java:59` 有 `addFlags(128)`；`DeviceUpgradeActivity` 全程没有，那条 `WakeLock` 只在死掉的 `UpgradePresenter` 里且只盖下载段）；**TUWIN 清单里根本没有 `WAKE_LOCK` 权限**；idGoLive 有权限但从不申请 | 低-中 |
| **R5** | **强制前置条件：电量/是否接电/SD 卡/剩余空间，不满足就禁止动作** | 擦除中途掉电 | TUWIN 只强制"先停录像"，电量/SD 全靠事后报错；XTU 取了 `getBatteryInfo()`/`getSdCardInfo()` **返回值直接丢弃**；idGoLive 只强制 SD 存在 | 低（读我们已有的 `DeviceStatus`） |
| **R6** | **单一协议帧，靠探测协商而非"记住的布尔值"** | 帧格式发错 → 设备把长度字段当文件名解析 | **XTU 最严重**：8080 上 72B 与 200B 两种帧**共用 cmd=2**，选哪种取决于**数据库里存的 `isAmba`** 而非活探测；负响应 `RECV_FAILED(3)` 与 `PUT_MD5(518)` 声明了但从不检查 | 中（probe 框架已有，需扩成"帧协商"） |
| **R7** | **原子暂存 + 显式 commit（带文件名与哈希的独立提交命令）** | 参数缺失的 commit 会"刷上次暂存的任何东西" | **XTU 海思路径最糟**：`GET /cgi-bin/hi3510/upgrade.cgi` **不带任何参数**，且暂存落在**持久存储**（`name="sd"`）。Amba 落 `/tmp` 反而最安全 | 受限于相机固件；我们只能选更安全的那条通道 |
| **R8** | **独占写锁：一机一写者，尊重设备侧 busy 负响应** | 两台手机同时推 → 两个镜像字节交错 | 三家**零保护**。idGoLive 有文案 `multil_client_upgrade_tips` 但**无代码引用**；TUWIN/XTU 连文案都没有 | 低（App 侧单飞队列 + 设备侧尽力而为） |
| **R9** | **可恢复路径：暴露 `[CardUpdate]` 卡刷 + 允许"从本地文件刷" + 允许降级救援** | 刷坏后无任何出路 | 三家 App 层**都没有**：XTU 建了 `activity_upload_firmware` 界面却**漏了 manifest 条目**；TUWIN 的 `.appsw` 本地读取器**零调用者**；idGoLive 的 `FileTools.copyFile(rawResId,path)` **无人调用且路径拼接有 bug**。**但 §5.3 证明设备侧本来就有卡更新与 A/B 双槽** | 低（写文档 + 一个"从文件刷"入口），需真机验证卡刷 |
| **R10** | **版本序策略：默认拒绝等于或低于当前版本，降级需显式勾选 + 警告** | 无谓重刷 / 被诱导降级 | **TUWIN 的 OTA 路径完全没有版本比较**（相等与更旧照装）；XTU 全是严格 `>` ⇒ **一旦发布坏镜像，已升级设备再也退不回去**；idGoLive 用 `<=`，**日期相等也允许重刷** | 低 |
| **R11** | **诚实的 UI 与失败面：区分"传输完成"与"升级成功"；失败文案可操作、不误导、保留镜像以便重试** | 用户在最危险时刻被引导离开或重刷 | XTU 失败文案 `devicer_manager_transfer_failed` **是未翻译的葡萄牙语**，内容是"离开本页重试"；`SendSoftActivity` 成功后**删掉本地固件**；idGoLive 把 `/idGoLive/Resoure/` 写进提示但代码读的是另一个目录 | 低 |
| **R12** | **「刷固件会恢复出厂」必须事先告知并二次确认** | 用户数据丢失（非砖但不可挽回） | **只有 TUWIN 有**（`upgrade_firmware_warning`）；XTU/idGoLive 完全不提 | 低 |

### 6.2 三家共同的设计缺陷（值得单独记住）

1. **相机只被告知文件名。** XTU 四条通道、TUWIN 三条通道、idGoLive 的 FTP 路径，**没有一条向设备传输型号标识**。设备只能从文件名猜。⇒ 协议层没有"这台机器不该收这个包"的拒绝依据，责任全在 App，而 App 没读镜像。
2. **"记住的协议标志"与"探测出的协议"是两回事。** XTU 用持久化的 Room 列 `isAmba` 选帧格式（R6 的根因），TUWIN 用 `SessionBoundOtaIdentity` 绑会话（做对了）。⇒ 我们的 profile 必须**每次连接重新探测**，绝不把协议选择结果当设备属性缓存。
3. **厂商把失败模式写全了却没实现。** XTU 九条 `gku_firmware_update_failed_*`（battery/nocard/offline/timeout/verify/…）只存在于两个 `R.java`。⇒ 这份文案清单等于厂商替我们写好的需求规格，可直接当我们的状态机。
4. **重复的"孪生死代码"是真实风险源。** XTU 有 `CameraParameters.java` 与拼错的 `CameraParmeras.java` 两份，各带独立的 `IsAmbaDevice`/`MustUpdate` 静态量。⇒ 我们绝不允许协议状态存在第二份真相。

### 6.3 一句话判断

**做 OTA 是可行的，但前提是接受"我们对设备的了解不如它自己的 bootcode"。** 设备侧有 CRC 校验、有 A/B 双槽、有卡更新兜底；App 侧什么都没有。所以正确姿势不是照抄官方 App（那等于照抄一堆死代码和漏检），而是：**把镜像头部校验、完整性、息屏保护、版本回读这四件官方没做的事做出来，再把官方没暴露的卡刷恢复路径暴露出来。** 这四件事成本都是"低"到"中"，而它们正是官方 App 会刷砖的地方。

---

## 7. 我们 App 的现状与功能差距

### 7.1 现状（`composeApp/src/commonMain/`）

> ⚠️ 下面这段是 2026-09-20 的快照，**第一批功能已提交**（不再是"535 行未提交"）。**我们侧的权威清单以 `docs/impl/command-surface.md` 与 [三家操作对照](../evidence/operations-matrix.md) 为准**；本节只保留与 OTA/差距判断直接相关的部分，规模数字已按当前工作树复核。

- **4 个 Tab**：Devices / Live / Files / Settings（`App.kt`）。
- **`CameraProtocol` 现有 37 个方法 + 11 个属性**（`core/protocol/CameraProtocol.kt:24-409`；2026-09-26 复核，此前这里记的 26 + 5 是 09-23 的数）。下面这句当时的枚举已不完整：在 `readBack / listModes / setNamedMode / stopCapture / getDeviceSettings / setDeviceSetting / ensureAccessPoint / onSessionClosed` 之后又加了 `deleteAllFiles`、`getWifi`/`getWifiChannel`/`setWifiChannel`、`currentRotation`、直播的 `startLive`/`supportsLive`，以及 `supportsReboot`/`supportsCameraWifiRead` 两个能力门 —— **完整清单以代码为准**。原 18 条：`probe / connect / getStatus / getSettings / setSetting / setMode / capture / record / listFiles / deleteFile / thumbnail / download / previewUrl / getDeviceInfo / formatSd / factoryReset / reboot / syncTime` + `events`。
- **仍然缺的接口能力**：无 `Capability` 协商（UI 只能"点了才知道不支持"）、无逐命令超时/串行约束、无 `disconnect()`、`previewUrl` 非 suspend 因而不能探测或带令牌。⚠️ ~~无 `OtaOperations`~~ **已过时**：`core/ota/Ota.kt` 里有 `OtaTransport`（`readVersion` + `install`）与 `OtaCoordinator`（9 态：`Idle/WaitingForDevice/Uploading/Installing/WaitingForReboot/Reconnecting/ConfirmingVersion/Completed/Cancelled/Failed`，含 **60 s 重启硬超时**与「版本读不到 / 与期望不符即 Failed」两条判据），海思一条通道已接（`brand/xtu/HisiliconOtaTransport.kt` = `fileupload.cgi` + `upgrade.cgi`）。
- ⚠️ **`CameraProfile`（`:180`）仍是死代码**，全仓无构造点 ⇒ "profile 驱动发现"从未实现。协议选择是 `core/net/DeviceDiscovery.kt` 的"遍历 `registry.all()`，第一个 `probe()` 返回 true 就用"，**注册顺序即优先级**。
- ⚠️ **`CameraSession.authToken` 在 `TuwinRestProtocol.kt:94` 被赋值后，后续没有任何请求读它** ⇒ TUWIN 的 seed 握手做完就丢。
- **`events` 已接**（`AppState.kt` 的 `collectEvents`），但两个协议仍只在本地 `record()` 时自回声 `RecordingChanged`，`BatteryChanged`/`Disconnected` 无人发射，且 1.5 s 轮询会整体覆盖。
- ⚠️ **`syncTime()` 仍复用 `Op.Settings`** ⇒ 点一次对时会灰掉整页设置项（未满足「灰掉控件必须给理由」）。
- ⚠️ **`Screens.kt:1299` 有 `session?.platform == DevicePlatform.TUWIN_REST` 的 UI 分支** —— 这是统一接口要消灭的东西，现在多了一处真实品牌分支。
- **`CameraHttp` 是具体类不是接口**（`core/transport/CameraHttp.kt:51`），两个驱动按构造吃它 ⇒ 裸 TCP（M3）、72B 二进制帧（XTU 8080）、PTP/FTP（iCatch）**没有插入点**。`DevicePlatform.AMBARELLA / SIGMASTAR / TUWIN_M3 / ICATCH` 四个枚举值零实现（`core/model/Models.kt:19-39`，未注册 ⇒ `probe` 永远不会命中）。
- **simulator 已经演海思 CGI 的 OTA**：`simulator/.../Main.kt:84` 收 `POST fileupload.cgi`（只取 multipart 里的文件名），`Main.kt:208-222` 的 `upgrade.cgi` 从文件名里抽 `\d{8}` 当新 `softversion` 并打印"camera rebooting" ⇒ **R3（回读比对）在桌面上可验**；但**它不校验镜像头部、不校验长度、来者不拒** ⇒ R1/R2 无处测（§7.3 第 9 条）。非 HTTP 协议（8080 帧 / PTP / FTP）依旧无法在桌面验证。

### 7.2 缺失功能清单

> ⚠️ **口径统一（2026-09-22 回写）**：本节只讲**功能/交互层**的差距。**逐条命令层的完整台账不在这里维护**，36 条缺项的全集与 49/85 的机械比对唯一出处是 [三家操作对照](../evidence/operations-matrix.md) §3 第 5 条（「缺 36 个海思命令」那条）+ [我们已实现的命令面](../impl/command-surface.md)。两边若冲突，以那份对照表 + `docs/impl/command-surface` 为准。
> ✅ **但那份清单点名的「优先补」13 条命令已按判组落进下面的 A/B 两组**（A16/A17 + B16–B21；WiFi 三条 `getwifi`/`setwifichannel`/`wifisleep` 分别挂在 A4 / B8 / B10，未另开行），逐条出处写成 `_work/re/xtugo/catalog-http.md`（机械提取的 URL 字面量 + 调用点 `path:line`）与 [XTU GO 档案](../evidence/xtugo.md) 的附录 A §1.2 / §1.3 / §2.1（参数、取值域、响应键）。这 13 条**没有一条落进 C 组**，理由见 C 组开头的判组说明。
> ⚠️ **状态列已按对照表校正**：A 组里 A1/A2/A3/A4/A5/A7 六条在第一批落地时**已不再是「完全没有」**，旧表述保留在「备注」里作为原始判断。

按「三家（或两家）都有 → 只有一家有好做法 → 我们该主动跳过的」排序。`成本` 是相对我们现有骨架的估计：S = 半天内，M = 1–2 天，L = 3 天以上。

**A 组 · 三家都有，我们该补的（含已落地的）**

| # | 功能 | 官方证据 | 成本 | 备注 |
|---|---|---|---|---|
| A1 | **SD 卡信息 + 格式化** | TUWIN `m3_sd_status_*` 9 态；XTU `cam_setting_sdcard_space`+`format_sd_card`+**`format_timeout`**；idGo `setting_format`/`format_device` | ~~S~~ **已做大半** | ✅ **已实现（XTU 真机验证）**：容量走 `getstorageinfo.cgi`（`docs/impl/command-surface` H-41），格式化走 `setformat.cgi?&-type=all`（H-33），SD 态走 `getsdstate.cgi` 的 `sdstate` 原始串（H-09）。⚠️ **剩余**：`getcardstatus.cgi`/`getstateofsetting.cgi` 两条卡状态命令没接 ⇒ **TUWIN 那 9 态卡状态（含 `slow_card` 慢卡）仍是一行都映射不出来**，这正是 §2.7 里 `M3SdCardRemoved`/`M3SdCardNotFormatted` 只能"靠写失败发现"的原因 |
| A2 | **恢复出厂** | TUWIN 有专屏 `M3FactoryResetActivity` 且**报告失败码**；XTU `pref_reset`；idGo `reset_device` + "移除全部数据" 措辞 | S → 小 | ✅ **已实现（XTU）**：`setreset.cgi` + 官方孪生别名 `setrecover.cgi`（`docs/impl/command-surface` H-32）。⚠️ 官方那条 `getresetinfo.cgi`（**先问一遍再确认**）仍是缺项 ⇒ 我们只能盲发；危险操作的二次确认已在 UI 侧自做 |
| A3 | **对时（time sync）** | TUWIN 把它做成**连接流程的一个具名步骤** `connection_step_ride3pro_syncing_time`；XTU `pref_time_calibration` + `gku_timezone`；idGo 有 `CAMERA_DATE`/`CAMERA_DATE_TIMEZONE` 两个 PTP 属性 | ~~S~~ **S（已做完）** | ✅ **已实现**：海思 `setsysparam.cgi?-timezone=<IANA>` 后**紧接** `settime.cgi?&-sync=on`（`docs/impl/command-surface` H-30/H-31，成对缺一不可），且已按 TUWIN 的做法挂进连接流程自动做一次（`AppState.kt:537-541`）。相机时间错 → 文件名时间戳错 → 相册分组全乱，**这条隐性基础设施已补齐** |
| A4 | **改相机 WiFi 名/密码** | TUWIN `M3WifiSettingsActivity` + 校验规则 + "重启后生效" + **复制密码到剪贴板**；XTU `ModifyWifiActivity`；idGo 有真实约束文案（名 ≤20、密码 8–10 位纯数字） | M → 小 | ✅ **已实现（XTU）**：`setwifi.cgi?&-ssid=&-passwd=&-encrypt=&-channel=`（`docs/impl/command-surface` H-29）。⚠️ **剩余**：读侧 `getwifi.cgi` 与信道侧 `getwifichannel.cgi`/`setwifichannel.cgi`、休眠 `wifisleep.cgi` **都没接** ⇒ 现在只能"写"不能"回显当前值"，"重启后生效"与校验规则也还没抄。`getwifi.cgi` 的出处与形状：`_work/re/xtugo/catalog-http.md` 的 `SetDataUtils.java:319`（走 `getMap2`，键集合**由设备决定**，见海思CGI全表 §2.1「WiFi 信息」行）⇒ 不能假定固定字段表，待真机抓一次回包 |
| A5 | **设备信息 / 关于页** | 三家都有 `AboutCameraActivity`/`AboutActivity`/`AboutAppActivity`，含固件版本、设备 ID、序列号 | ~~S~~ **已实现（XTU）** | ✅ `getDeviceInfo` 有 UI 入口（`Screens.kt:1212` → `state.loadDeviceInfo()`），海思侧读 `getdeviceattr.cgi` + `getfirmwareverison.cgi` + `gethardversion.cgi`（`docs/impl/command-surface` H-01/H-02/H-42，**两个拼错的名字是官方字面量，已按原样抄**）。⚠️ **剩余**：官方还调 `getcamerainfo.cgi` 与 `getclientinfo.cgi`（两条都在缺的 36 里），且 XTU 的「关于」页在老海思机器上混用两套方言（`hisilicon/dv` vs `sigmastar/newUi`），我们只覆盖了 NewAPP 一套 |
| A6 | **多设备记忆** | TUWIN `DeviceListActivity` + 记住 SSID/密码 + 可编辑别名/备注；idGo `CameraSlot` 持久注册表；XTU `DeviceManagerActivity` | M | 我们现在每次都要重连重扫 |
| A7 | **相机固件更新（OTA）** | 见第 1–4 节 | L → **骨架已落 1/4** | ✅ **海思一条通道已经通了**：`core/ota/Ota.kt`（`OtaTransport` + `OtaCoordinator`，9 态 + 60 s 重启硬超时 + **装机后回读比对**）+ `brand/xtu/HisiliconOtaTransport.kt`（`fileupload.cgi` 上传 → `upgrade.cgi` 无参触发，与官方 §1.2(b) 逐字一致），且 `firmwareUpdateSupported()` 只认 HISILICON（`AppState.kt:922-927`），其余平台按钮显示「不支持」。⚠️ **剩余 = §6.1 的 R1/R2/R4/R6/R8 全未做**：不读镜像头部、不比长度/哈希、无前台服务与 WakeLock、无帧协商、无独占写锁。桌面模拟器已能演「上传→应用→版本变化→回读比对」（`simulator/.../Main.kt:84,208-222`），但**设备侧不拒绝错包**，所以 R1/R2 无处测 |
| A8 | ~~App 自更新~~ | — | — | **已移出范围**（本项目不通过 App 分发更新）。三家的做法仍记录在 §1.7 / §2.7 / §3.7 |
| A9 | **连接向导：具名步骤 + 每步失败给解法** | TUWIN `connection_step_*` 12 变体 + `connection_timeout_solutions` + `device_not_found_solutions`；idGo `launch_help_qa_1..10`；XTU `UserGuideActivity` | M | **性价比最高的一项**：卡顿/连不上正是你换掉官方 App 的动机，反过来也是我们最容易被投诉的地方 |
| A10 | **批量选择（全选/取消全选 + 计数按钮）** | 三家都是 `Delete ( %d )` / `Download ( %d )` 计数入按钮；TUWIN `select_all`/`unselect_all`/`selected_count_format` | S | 我们现在只能单文件下载/删除 |
| A11 | **下载管理器独立页 + 前台服务** | TUWIN `DownloadListActivity` + `download_service_channel_name` + 每任务 `not_started/waiting/paused/error/finished` + **retry-all / clear-all**；idGo `LYDownloadFileActivity`；XTU `download_manager` | L | 我们现在下载随 App 生命周期，杀进程即断。**这是「比官方好用」的主战场** |
| A12 | **相册按日分组（+ 按时分小时）** | TUWIN `DailyDetailActivity` + `Ride5HourlyPlaybackActivity` + `ride5_hour_timeline`；XTU `calendarview`；idGo `MultiPb*` | M | 我们现在是扁平列表 |
| A13 | **相机端回放（不下载就能看）** | 三家都有 `VideoPbActivity`/`RemoteVideo` | M | 海思/Ambarella 都有 `rtsp` 回放路径可用 |
| A14 | **能力门与设置冲突矩阵** | idGo `setting_burst_set`「先关掉连拍才能设这个」、`current_size_not_support_slow_motion`「把分辨率调成 720P120fps 才能慢动作」；TUWIN `mode_switch_too_frequent` | M | 我们目前只会 `setSetting` 然后等失败 |
| A15 | **模式切换的收尾保护** | TUWIN `m3_wait_photo_finish`/`m3_wait_stop_recording`/`m3_confirm_exit`；idGo `stream_error_cannot_open_gallery`「拍摄中禁用」 | S | 防止半途切模式导致丢文件 |
| A16 | **能力上报（相机告诉你这台机器有哪些开关）** | XTU 海思 `getcapability.cgi?&-workmode=%d&-type=%d`（响应键 `capability`）与 `getdevcapabilities.cgi?`（键 `devcapabilities`）；idGoLive 活路径上也有一条 `/app/capability`；TUWIN 用设备自描述菜单 `/api/menu/xml` 达到同一目的 | M | **缺**。这正是 §7.1 里那条「无 `Capability` 协商（UI 只能"点了才知道不支持"）」的协议侧解药，也是 A14 的前提：先问能力，再谈冲突矩阵。⚠️ `type` 的取值域（`CONFIG_*`）与返回串的分隔格式档案只记到 `doForStringByKey` 一层，**待真机确认**，不要按猜测解析。依据：`_work/re/xtugo/catalog-http.md`（`Setting.java:542`、`:530` 两条 OWN 字面量）+ 海思CGI全表 §1.2 |
| A17 | **相册「全部删除」** | XTU `deleteallfiles.cgi?`（`RemoteFileManager.deleteAllFiles(ip)`，判据**只看 200**）；idGoLive LyIC 全删 `cmd=4004`（与单删 `cmd=4003` 成对）；⚠️ TUWIN **没有**设备侧全删，批量=逐条 `delete?file_index=` | S | **缺**，与 A10 是一对：A10 是 UI 侧的全选，这条是设备侧的一键清空。官方对它**没有任何二次确认的证据**（拿到 200 就算成功）⇒ 我们做的时候要自做确认 + 回显被删数量（对齐 A10 的 `Delete ( %d )`）。依据：`_work/re/xtugo/catalog-http.md`（`RemoteFileManager.java:54`）+ 海思CGI全表 §1.3；idGo 侧见 `docs/evidence/idgolive §6.5`「删除」行 |

**B 组 · 只有一两家有，但明显是好点子（低成本高感知）**

| # | 功能 | 唯一来源 | 成本 | 为什么值得 |
|---|---|---|---|---|
| B1 | **格式化逾期提醒**（距上次格式化 N 天就提示） | TUWIN `sd_card_format_elapsed_days` | S | 一句话文案 + 一个本地时间戳，能省掉大量「卡坏了」误报 |
| B2 | **下载耗时预估 + 跳过已下载** | idGo `Total Files: $1$, Download Time: $2$ mins $3$ s`、`Skipped $1$ videos and $4$ downloaded photos` | S | 我们已有 `sizeBytes`，估算就是 `Σsize / 实测速率` |
| B3 | **预览画质/省电档** | TUWIN `low_quality_preview` / `power_saving_preview`（带解释弹窗） | M | 弱网下体验差别巨大 |
| B4 | **弱网自动降流分辨率** | idGo `text_resolution_adjust` | M | 同上，自动化版本 |
| B5 | **预览页上的快捷设置面板**（不离开预览就能改分辨率/模式） | XTU `newUi/deviceFastSetting/*` | M | 官方阵营里最顺手的交互 |
| B6 | **分享时转码**（厂商编码 → MP4，带进度） | TUWIN `ride3pro_share_transcoding_message` | L | 不转码分享出去就是打不开的文件 |
| B7 | **收藏集**（独立空态） | XTU `FavoriteDatabaseHelper` | S | 一个本地表 |
| B8 | **相机 WiFi 信道选择**（2.4/5G 抗干扰） | XTU `WifiChannelActivity` + 海思 `getwifichannel.cgi?` / `setwifichannel.cgi?&-wifichannel=%d`（键 `wifichannel`，`doForIntByKey`） | S | 命令现成但我们**一条都没接**（`_work/re/xtugo/catalog-http.md` 有 `Setting.java:522`、`:526` 两条 OWN 字面量，`附录A` 零命中）。现在只能把信道塞在 `setwifi.cgi` 的 `-channel=` 里（A4），既读不回来也不能单独调 |
| B9 | **远程重启相机** | TUWIN `reboot_device` + 确认 | S | 我们 TUWIN REST 已有 `/api/reboot`，缺 UI |
| B10 | **相机休眠 / 远程唤醒**（含"全部唤醒"） | idGo `camera_sleep`/`camera_awake`/`dialog_awake_all`；XTU 侧 `wifisleep.cgi?`（`Setting.sleep(ip)`，`doForSuccess`）+ 唤醒**不走 HTTP**，是本地 UDP magic packet（6×`0xFF` + 16×MAC，发到 `<ip 去末段>.255:9`，连发 5 次；MAC 串要求长度 17 且含 `:`） | M | 电池机型的刚需。⚠️ 抄之前记住：**XTU 的"唤醒"根本不给相机发命令**，发的是网络层广播，所以它能不能醒取决于相机网卡是否支持 WoL —— 待真机确认 |
| B11 | **「另一个客户端正在预览本机」冲突提示** | idGo `media_text_previewing_by_other` | S | 多手机抢一台相机时非常必要 |
| B12 | **反馈时自动附上诊断信息**（型号/固件版本/相机时间/App 日志/机型） | TUWIN `feedback_*` 12 键；XTU `feedback_problem_*` 10 类 | M | 开源项目尤其需要，能省掉来回问 |
| B13 | **权限用途说明页**（说清哪个权限解锁哪个功能） | XTU `bluetooth_permission_description_hyh`；idGo `album_permission_camera_video_failed_hint` | S | 你之前定的「灰掉控件必须给理由」同源 |
| B14 | **运行时开关 App 日志** | XTU `gku_app_log_tip`（"需重启生效"） | S | 排查真机问题必备 |
| B15 | **无障碍标签 + 无限滚动加载** | TUWIN `ride5_hour_timeline_accessibility`、`ride3pro_storage_pull_up_to_load_more` | S | 打磨项 |
| B16 | **定时拍照的秒数回读与设置** | XTU 海思 `gettimerinfo.cgi?` / `settimerinfo.cgi?&-time=%d`（单位**秒**，`doForIntByKey("time")`；老方言 `HttpProxy.java:294` 另有一条同名串） | S | **缺**。我们 `setMode()` 能进 TimerPhoto，却读不到也设不了倒计时 ⇒ 定时拍只能盲填。⚠️ 别和延时摄影的 `settimelapseinfo.cgi?-time=` 混用（那条只有单个 `?`，与 A 套 `?&-time=` 不同形） |
| B17 | **连拍的张数与间隔** | XTU 海思 `getburstinfo.cgi?`（回包里取 `time`（秒）与 `count`（张），任一缺失或非数字即 -1）+ 写侧 `setburstinfo.cgi?&-time=%d&-count=%d`，**官方在客户端硬校验 `time∈[0,30]`、`count∈[0,30]`**，越界直接返回 -1 不发请求 | S | **缺**。我们现在只有 `photo.cgi?&-type=photoburst`（一次按完、参数不可调），`附录A` 里没有任何 burst 参数命令 ⇒ 连拍设置项是空的。官方那对 0–30 的本地校验可以直接抄成我们的输入约束 |
| B18 | **点测光开关** | XTU 海思 `getspotmeter.cgi?` / `setspotmeter.cgi?&-enable=%d`（0/1，`doForBoolean`） | S | **缺**。逆光/车窗场景的刚需，代价只有一个布尔开关 |
| B19 | **时间戳水印（OSD）** | XTU 海思 `gettimeosd.cgi?` / `settimeosd.cgi?&-enable=%d`（0/1） | S | **缺**。相册分组强依赖文件名时间戳（A3 对时那条），水印是"用户能肉眼验对时结果"的那一环。⚠️ 它是否也出现在 `getprimarymenuitem.cgi` 的自描述菜单里**待真机确认** —— 若在，就要防止和动态菜单项重复显示 |
| B20 | **机身屏幕自动休眠时长** | XTU 海思 `getscreenautosleep.cgi?` / `setscreenautosleep.cgi?&-time=%d`，取值域由资源数组 `@array/screen_auto_sleep_values` 决定 = `{0,1,3,5}` | S | **缺**。省电 + 屏寿命，成本近似等于加一行 4 选 1。⚠️ 档案未标该字段的**单位**（同区的 `setautoshutdown` 标的是分钟，不能照推），待真机确认 |
| B21 | **开机自动动作（上电即录）** | XTU 海思 `getbootaction.cgi?` / `setbootaction.cgi?&-action=%s`，`DV` 侧取值 `{idle, record, timelapse}`（`Common.java:8-10`），资源数组另列 `recordlapse`/`recordloop`/`recordslow` | S | **缺**。头盔/车载「装上就开机录」是运动相机的真实用法。⚠️ 两套取值不一致（代码 3 值 vs 数组 6 值），**以真机实际接受哪个为准** |

**C 组 · 明确不做（官方的营销/变现/追踪面）**

> 📌 **本轮 13 条缺项的判组说明**：`getcapability`/`getdevcapabilities`/`deleteallfiles`/`getwifi`/`setwifichannel`/`wifisleep`/`gettimerinfo`/`settimerinfo`/`getburstinfo`/`getspotmeter`/`gettimeosd`/`getscreenautosleep`/`setbootaction` **一条都不进 C**。判据是 [三家操作对照](../evidence/operations-matrix.md) §4 那张「明确不做」表的口径 —— 它排除的是云账号与会员、广告归因与统计上报、全景 VR/直播、GPS 轨迹、App 自更新、相机侧 AI 包这六类**运营面**，而这 13 条全是相机本地 HTTP 端口上的能力/设置命令，与 §4 任何一行都不相干（那份清单 §5 反而把其中三条排进了落地顺序第 2、3 步）。⇒ 全部按「A（两家以上有）/ B（只有 XTU 海思一条，但低成本高感知）」判组：A16、A17 两条是跨品牌能力，B16–B21 六条是海思独有。
> 剩下的 23 条缺项按 §4 口径同样没有该进 C 的 ⇒ 维持那份清单 §5 第 5 行的处置：**"真机用到才补"，不预先造 API 面**，因此本文件不逐条列出。

- XTU 整个 `loginmodule`：注册/登录/改密/注销账号、设备共享（属主邮箱 + 被分享者权限清单）、**4G 流量卡充值**、云存储套餐与「30 天免费试用」漏斗、公告推送、事件日志云。
- idGoLive 的广告面：`ACCESS_ADSERVICES_AD_ID/_ATTRIBUTION/_CUSTOM_AUDIENCE/_TOPICS`、install-referrer、`READ_PHONE_STATE`；为拿 YouTube/FB RTMP key 而引入的整套 Google/Facebook OAuth 客户端。
- 平台专属推广：抖音/B 站直播入口、Meitu 美颜、云端音乐库（版权）、高德地图后端、`codeest`/`scankit` 扫码 SDK。
- idGoLive 的 `LY*` 模块（另一家的云相机栈）、`awss3v4signer` 直传 S3。
- TUWIN 的行车记录仪垂直词（`sentinel_mode`、`parking_mode`、前后摄像头紧急录像、`riding_recorder`）——**除非我们真接的机器有预录缓冲，否则不做**。
- **不要照抄 XTU 的代码结构**：它为了三个芯片各留一套近乎重复的 UI（`actioncam/amba`、`actioncam/hisilicon`、`actioncam/sigmastar/{OldUi,newUi}`），外加 `dashcam/icatch` 是 idGoLive 的一份反编译拷贝。正确做法是 TUWIN 那种「一套 UI + 每机型一个 Adapter」。
- 全景/VR 拼接（idGo `com.icatchtek.pancam` + `libpanorama_vr.so` 16MB）、GPS 轨迹回放（XTU 高德 20MB）：**这两项正是官方卡顿的根因**，与项目「保持精简」的目标直接冲突，除非将来有明确需求再单独立项。

### 7.3 建议的落地顺序


**第一批（把已有骨架用满，几乎零新协议工作）**

1. **把 `events` Flow 接上** —— 现在两个协议都在发事件、没人收。收进 `AppState` 后，录制状态/电量就不再靠轮询。
2. **A5 关于页 + A2 恢复出厂 + B9 远程重启 + A1 SD 信息与格式化** —— 全塞进现在空着的 Settings Tab，海思命令都已存在。
3. **A3 对时** —— 做成连接流程的一个具名步骤（学 TUWIN），顺带解决相册时间戳问题。
4. **A10 批量选择 + A12 按日分组** —— Files Tab 的两个最大体验缺口。全选做出来后就顺手接 **A17 `deleteallfiles.cgi?`**（设备侧一键清空，命令一条，比逐条删少 N 次往返）。

**第二批（连接与下载，"比官方好用"的主战场）**

5. **A9 连接向导：具名步骤 + 每步失败解法**。
6. **A11 下载管理器 + 前台服务 + 通知**（含 B2 耗时预估与跳过已下载）。
7. **A6 多设备记忆 + A4 改相机 WiFi**。
8. **A14/A15 能力门与收尾保护** —— 把"点了才报错"变成"点之前就告诉你为什么不能点"（与你定的「灰掉的控件必须显示原因」一致）。⚠️ 顺序上 **A16 `getcapability.cgi`/`getdevcapabilities.cgi` 是它的前提**：不先问能力，冲突矩阵就只能继续靠硬编码机型表（这也是 `docs/evidence/operations-matrix` §5 把 capability 一族排在第 2 步的原因）。

**第三批（OTA，单独立项）**

9. **A7 相机固件更新（OTA）** —— 单独立项，且**必须先满足 §6.1 的 R1–R6 才允许对用户开放**。拆法：统一 `check/plan` 层（版本一律归一化成 `yyyyMMdd`）+ 每 profile 一个 `OtaTransport` + 照抄 TUWIN `DeviceOtaOperations` 六方法（`queryUpdate / readVersion / waitForDeviceReady / installUploadedPackage / waitForReboot / reconnect`）与 14 态状态机。
   - **实现顺序按风险从低到高**：先 XTU S7PRO（现役 72B 帧 / Ambarella，且**这是唯一"相机自己在机身 UI 上完成最后一步"的路径，App 侧风险最低**）；再 idGoLive/iCatch（FTP，需自建 CRC 预期）；TUWIN 的 M3/RIDE5 把镜像落到**持久存储**且 `upgrade.cgi` 无参数，风险最高，放最后。
   - **必做的四件官方没做的事**：读镜像头部比对型号（§5.4）、下载后与送包前各校验一次（§5.2）、前台服务 + WakeLock 覆盖整个窗口、装机后回读版本 + 硬超时。
   - **模拟器要同步扩**：加 `fileupload.cgi`/`upgrade.cgi`、一个假的重启与版本变更、以及**"头部型号不匹配就拒绝"的设备侧行为**，否则 OTA 无法在桌面验证，R1 也无处测。
   - **文档要写恢复路径**：把 §5.3 发现的 `[CardUpdate]` 卡刷与 A/B 双槽做成面向用户的救援说明——这是官方有却没讲的事。

**验证口径**：以上每一项都要在真机（MuMu 起步，最终 XTU 实体机）跑通才算完成；桌面模拟器通过不等于功能可用。**OTA 尤其如此——在没有对一台真实机器完成"升级→重启→回读版本一致"的闭环之前，不发布该功能、不提示用户可升级。**

---

## 变更记录（2026-09-22 全量复现档案回写）

> 「处」= 本文被改的小节；「依据」= `docs/evidence/` 里支撑该改动的文件与小节。**前 19 行**是本轮前半段已落的 OTA / 现状回写（§0–§7.1，此前未记账，这里补齐），**后 5 行**是本轮收尾（§7.2 的命令级差距判组 + §2.5 表格断列修复）。

| 处 | 改了什么 | 依据（`docs/evidence/`） |
|---|---|---|
| §0 第 2 条 | 「三家都没回读版本」补上实据并收窄为**读数有、比对缺**：30 秒安全窗落到 `UpgradeActivity.java:96 COUNTDOWN_SECONDS = 30` 与 `:543-558`、`:795` | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.5`（装机后回读版本的代码路径）、§8.1 第 2 行 |
| §0 第 3 条 + §1.2 开头「可达性优先」段 | XTU 四条送包通道逐个重判活/死：72B 帧**两条入口都活**、海思 CGI 与 200B 三实现全死；翻案「`SendSoftActivity` 三个静态字段从未赋值」（实为 `com/my/AppSettingActivity.java:74-76` 赋值、`UpgradeVersionReadyActivity.java:72` 拉起）；`UpgradeFirmwareActivity` 无清单条目却有点击入口 ⇒ `ActivityNotFoundException` | `docs/evidence/xtugo §11`（组件可达性）、§1.4 组件表 `SendSoftActivity` 行；机器提取层 `_work/re/xtugo/manifest.md` + `components.tsv` |
| §0 第 4 条 + §1.4「防误操作」行 | 息屏保护从「全线为零」改判为**两条活路径一条有一条没有**（`SendSoftActivity.java:59` 有 `addFlags(128)`；`DeviceUpgradeActivity` 全程无，那处 `addFlags(67108864)` 是 Intent flag），`WakeLock` 只在死掉的 `UpgradePresenter` 且只盖下载段 | `docs/evidence/xtugo §11`；`docs/evidence/xtugo 附录D（操作复现表） §9.4（c）` 的 `SendSoftActivity` 步骤表 |
| §1.2 通道表 | 表头新增**「是否现役」列**，六行逐条标注；200B 行写清三种实现各自的分块（1024 / 5120 / 10240）与"三个实现全死" | `docs/evidence/xtugo 附录D（操作复现表） §9.4` 送包 (a)(c) 两张步骤表；`docs/evidence/xtugo §3.3`（8080 的 72B 帧逐字段） |
| §1.2(c) | 补 `com/hisi/hisiFW/FwUpload.java` 的完整形状：步骤与 `FwUpload2` 逐条相同、分块 `byte[5120]`、**全仓连一个构造点都没有**；新增「同名不同包的 `RegisterDeviceToServer` 两份、用途不同」警示与第三/四份拷贝位置 | `docs/evidence/xtugo 附录D（操作复现表） §9.4`「（a）`com.hisi.hisiFW.FwUpload`」小节；`docs/evidence/xtugo §11` 第 7 行（`FwUpload.HOST` 是 6 个默认 IP 常量之一） |
| §1.2(d) | 两条 72B 活路径的六处差异（运行时网关 IP vs 连接 IP、息屏、成功后删包 vs 不删、文案、错误码同源、`isAmba` 来源）；`MSG_TYPE` 序数更正为 **`RECV_RTMP=4`**（旧文写 5） | `docs/evidence/xtugo 附录D（操作复现表） §9.4`（c）；`docs/evidence/xtugo §3.3` |
| §1.4「校验回读」行 | 补上"读数从哪来"：相机重启后 `getdeviceattr.cgi` 重解析 `softversion` → `CameraParameters.VersionNo`（`HomeActivity.java:1743-1746`） | `docs/evidence/xtugo 附录A（海思 CGI 全表） §1.4`（`getdeviceattr.cgi` 响应字段全集）；`docs/evidence/xtugo 附录D（操作复现表） §1.5` |
| §2.1 云端端点表 | 新增 **`appupdatecheker`** 一行（官方就少一个 `c`、参数是同一个混淆常量 + `platform` 恒 `"android"`、返回 `CheckUpdateBean`、**属 App 自更新不是相机固件**），并把「检版域名字面量缺失」限定为只对 `newota` 成立：base URL 实为 `http://ali.tuwinsmart.com/`（明文 HTTP） | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.7、§6.2` 域名表、§8.1 第 1 行 |
| §2.2 | 14 态从"一行枚举"扩成**逐态迁移表**（进入态 / 触发前置 / 成功后 / 失败落点 / 行号），含 `QUERYING` 返回 false 落 `IDLE`+`UpdateUnavailable` 而非 FAILED、`packageDownload == null` 是唯一合法跳态、整链无重试；`CONFIRMING_VERSION` 写明判据是 `readVersion()` 非 null 且与 `expectedVersion` 严格相等 | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.4`（14 态：状态名、迁移条件、失败落点）、§3.1（`DeviceOtaOperations` 六方法） |
| §2.3 三机型送包表 | 补 5 行：客户端是否算 MD5、`model`/`hw`/`sw` 的**三级取值**（bean → `fromNewOta` 兜底 → 文件名正则）、三组超时分块数值、`checkupgradepktinfo.cgi` 的 `offset`/`unitsize`（旧文标「未找到」，现已解决）、完成判定是**分块计数**不是回读 | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.2`（四字段来源）、§3.3（分块与超时）、§3.4 |
| §2.5 失败处理表 | 补 5 行：`IntegrityPolicy` 四子类与 `IntegrityVerifier` 逐判据（SHA-256 比较忽略大小写）、**本地包复用只比文件名+字节数**、结果类型双向校验的两条异常串、`CONFIRMING_VERSION` vs 30 秒安全窗、`clearUploadedFirmware()` 是 `return true` 空桩 | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.5、§3.6`（本地固件文件选择与校验） |
| §2.6 进度与 UX | 到 100% 后的分支更正：`InstallationCompleted` ⇒ **`InstallingAwaitingDeviceResult`**（旧文误写 `InstallationSucceeded`）；补 `total<=0` 直接 0%、`beginUpload` 夹在 0..99、安全窗两个函数、`routeRevision` 绑网络路由纪元 | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.4`（迁移与 `OtaCompletionSignal`）、§1.6（`routeRevision` 语义） |
| §2.7 本地固件 | 「本地固件目录」改判：现役路径是 **`filesDir/upgrade`**（App 私有区，用户放不进），`*.appsw` 扫描器看不见新的 `_cardvr_HW…_SW…_upgrade.bin` 包；补 `DOWNLOAD_COMPLETE_DISPLAY_MILLIS = 2000` | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.6` |
| §2.8 未找到清单 | 第 1 条收窄（App 自更新端点 base URL 已确认）、第 4 条划掉（`offset`/`unitsize` 已解决并指回 §2.3）、第 5 条补「调用方不存在所以取值无处可查」 | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.3、§3.7、§8.1` 第 2 行 |
| §2.9（**新增整节**） | App 自更新与相机 OTA 的对照补齐：`AppUpdateCheckState` **7 态**全列、`AppUpdateCheckResult` 5 子类、去重键实参 `AppUpdateCheckKey(250, "stable", "production")`（versionCode 是编译期常量）、`isNetworkDownloadUrl` 四个条件、弹窗抑制键 `trim(version) + "\|" + trim(downloadUrl)`、APK 落盘走 `IntegrityPolicy.None`（**官方连 APK 都不校验哈希**）。同时保留「已移出我们范围」的判断 | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.7、§8.1` 第 1 行 |
| §3 开头 + §3.1/§3.2/§3.4（idGoLive） | 送包载体由「PTP 对象」改判为 **FTP over 会话**；补 `firmware.json` 顶层三字段（含 `support_updated_version` 白名单）App 一个都不解析、`/app/*` JSON 是「一套活 + 一套死」（`LyWyzRequestInfo` 的唯一入口 `try2connectLywyzIC` 零调用点）、被漏掉的上传原语 `ApiCaller.uploadFile()` → `POST /upload` 无人调用 | `docs/evidence/idgolive §3`（11 条传输通道）、§6.5/§6.7、§9 第 3 行；`docs/evidence/idgolive 附录B（HTTP 与 TCP 命令） §3.1`（两套 `/app` JSON）、§5.2（`try2connectLywyzIC` 零调用）、§5.4（`POST /upload`）；⚠️ FTP 一条是本文对 `libcontrol.so` 的**符号实测**（`ftplib`/`convert2FtpPath`/`STOR`），档案里没有对应条目 ⇒ 按「待真机抓包确认」用 |
| §4 对比表 + 跨品牌结论第 3 条 | 「装机后回读版本」三家统一改为**无**，并把"读数"与"比对"分开写；送包通道数、客户端算 MD5、断电/误操作守卫三行按新的活/死判定改写 | `docs/evidence/tuwin 附录三（传输·OTA·联网） §3.5`；`docs/evidence/xtugo §11` |
| §6.1 R3/R4 两行 | R3 成本由「中-高」下调为「中」并改述为"把已有读数拿来比对"；R4 改为"XTU 两条活路径一条有一条没有 + TUWIN 清单里没有 `WAKE_LOCK`" | 同上，加 `docs/evidence/xtugo 附录E（权限·组件·域名） §8` 的权限判定 |
| §7.1 现状段 | 快照更新为第一批落地后：`CameraProtocol` 26 方法 + 5 属性、`core/ota/Ota.kt` 的 `OtaTransport`/`OtaCoordinator`（9 态 + 60 s 重启硬超时 + 回读比对）已存在 ⇒ 旧文「无 `OtaOperations`」划掉；同时点名模拟器已能桌面演 R3、但 R1/R2 无处测 | `docs/impl/command-surface`（我们侧权威清单） |
| §7.2 口径段 | 去掉指向本文**不存在的 §7.4** 的悬空引用，改为指向 `docs/evidence/operations-matrix` §3 第 5 条 + `附录A`；并声明那份清单点名的 13 条优先缺项已判组落进本节 A/B | `docs/evidence/operations-matrix §3` 第 5 条、§5 第 2–3 条 |
| §7.2 **A 组新增 A16/A17** | A16 能力上报（`getcapability.cgi?&-workmode=%d&-type=%d` + `getdevcapabilities.cgi?`）、A17 全部删除（`deleteallfiles.cgi?`，idGo `cmd=4004` 成对，TUWIN 无设备侧全删）。两条都标 **缺**，并与 §7.1 的「无 `Capability` 协商」、A10 的全选 UI 挂上钩 | `_work/re/xtugo/catalog-http.md`（`Setting.java:542`/`:530`、`RemoteFileManager.java:54`）+ `docs/evidence/xtugo 附录A（海思 CGI 全表） §1.2、§1.3`；`docs/evidence/operations-matrix §3` 第 5 条、§5 第 2–3 条；idGo 侧 `docs/evidence/idgolive §6.5` |
| §7.2 **B 组新增 B16–B21 + A4/B8/B10 补命令名** | B16 定时拍秒数（`gettimerinfo`/`settimerinfo`）、B17 连拍张数与间隔（`getburstinfo` + 官方 0–30 本地硬校验）、B18 点测光、B19 时间水印、B20 屏幕自动休眠（`{0,1,3,5}`）、B21 开机动作（3 值 vs 6 值不一致）；A4 补 `getwifi.cgi` 出处、B8 补 `getwifichannel`/`setwifichannel`、B10 补 `wifisleep.cgi` + WoL 广播 | `_work/re/xtugo/catalog-http.md`（`Setting.java:195`/`:199`/`:153`/`:256`/`:264`/`:338`/`:215`/`:522`/`:526`/`:464`、`SetDataUtils.java:319`）+ `docs/evidence/xtugo 附录A（海思 CGI 全表） §1.2、§2.1`；判组口径 `docs/evidence/operations-matrix §4` |
| §7.2 **C 组判组说明（新增一段）** | 记明这 13 条**无一进 C**：`docs/evidence/operations-matrix` §4 的「明确不做」只覆盖云账号/广告归因/全景与直播/GPS/App 自更新/相机侧 AI 包六类运营面，而这 13 条都是相机本地 HTTP 能力命令；剩余 23 条按「真机用到才补」不逐条列出 | `docs/evidence/operations-matrix §4` 全表、§5 第 2/3/5 条 |
| §2.5 表格断列（4 行） | 修 `check-tables.mjs` 报的 4 行「期望 3 列、实际 2 列」（原 355/356/359/360，即下载完整性、本地包复用判据、升级后版本回读、重试/续传）：各补一个**证据**单元格，内容只取该行行内已注的 `path:line`，不新增事实；`TransferRetryPolicy = Disabled \| ResumeFromCheckpoint(maxRetries)` 那行的转义竖线保持原样 | `tools/re/check-tables.mjs`（只读校验，未用 `--fix`）：修后输出「所有表格列数一致」 |



