# Android 端设备对接文档 · 固件 v1.0.2

> **读者**：Android 开发。  
> **控制 JSON 在 UART、BLE、MQTT 上格式完全相同**；差异仅在传输层，组包规则一致。

---

## 0. 三十秒看懂

| 项目 | 说明 |
|------|------|
| 适用链路 | **UART / BLE / MQTT** 下发控制时，JSON 结构相同 |
| 控制入口 | `head.message_type = 3`（`message_type=4` 已废弃） |
| **行走 / 转向** | 只有 `body.pwm_ctrl`（前后左右 + 力度，**无 GPIO 号**） |
| **GPIO / 发射** | 只有 `body.pin_setctrl`（`pin` + `val`） |
| **铁律** | **凡行走相关 JSON（控制 / 松手 / 全停），UART·BLE·MQTT 一律不得带 `pin_setctrl`** |
| 无 ACK | 控制 JSON 发出后不要等待 `OK` |
| 发送节奏 | 控制中 100～200ms 一条；间隔不得超过 500ms |
| UART 行尾 | 串口每条 JSON 以 `\n` 结尾 |
| 行走全停 | 仅 `pwm_ctrl` 四字段全 `0`；连发 **3 条**（0 / +100ms / +100ms）后停 |
| GPIO | 单独 `pin_setctrl`；与行走 JSON **分包**（主动同包见 §3.4） |

---

## 1. 本版相对 v1.0.1 的变更

1. **融合控制**：`message_type=3` 统一入口；行走用 `pwm_ctrl`，GPIO 用 `pin_setctrl`。  
2. **行走新格式**：`speed_mode/speed_val` + `steer_mode/steer_val`，一条 JSON 可「前进 + 左转」。  
3. **行走与 GPIO 解耦**：停车 / 松手 / 方向控制 **不带 pin**；GPIO 另发。  
4. **`message_type=4` 废弃**。  
5. **OTA 专题**（§6）：OT **分块 DATA**（按波特率自适应，UART 单帧最大 **1024B**）；`1002` 下发 `ota_chunk` / `ota_chunk_max`；460800 下 **6MB 典型 ~4min**。  
6. **日志上送**（§4.1）：Release 下 `ESP_LOGW`/`ESP_LOGE` → `message_type=4/5` JSON（UART/BLE）。  
7. **设备号**（§2.2）：配网填完整号 `CN110200000001`（无 `app2dev/`）；第 3～6 位选车型驱动。  
8. **App 待改**（§8）：固件 v1.0.2 已就绪项 vs 建议下一版 App 修改清单。

---

## 2. 链路基础（UART / BLE / MQTT）

控制 payload **同一份 JSON**，固件均走 `remote_control_apply_json()`。

| 链路 | App 侧要点 |
|------|------------|
| **UART** | 8N1；波特率 **须与固件 menuconfig 一致**（本仓库常用 **460800**；可选 115200/230400/921600）；每条 JSON 末尾 `\n`；须按行组包（§6） |
| **BLE** | 写 **0xFFE1**；Notify **0xFFE3** 收心跳/停车推送；连接后发 `message_type=6` |
| **MQTT** | 订阅设备下行主题；发布控制 JSON 到设备上行主题；**组包与 UART 相同** |

### 2.1 连接与心跳

- UART / BLE：open 后发 `message_type=6`，收到 `1002/query` 后再认为已连接。  
- 心跳 `message_type=1`：UART 约 3s，BLE 约 10s，MQTT 由固件周期上报。  
- **OTA 进行中**（已发 `message_type=0` 至 `1002/done` 或失败中止）：设备 **暂停心跳**，改发 **`1002` / `phase=active`** 识别 JSON（仅一次，见 §6.2）。  
- OTA **失败**后设备发 `1002/done(ok=0)`，随后 **恢复周期心跳**。OTA **成功**约 1s 后重启，重连后再收心跳。  
- OTA 完整流程见 **§6**。

### 2.2 设备号与配网（UART / BLE / WiFi 通用）

| 项 | 说明 |
|----|------|
| 格式 | 纯设备号，如 **`CN110200000001`**，**不要** `app2dev/` 前缀 |
| 配网 | 长按 GPIO4 → 连热点 `192.168.4.1` → 填写完整设备号（UART 无 WiFi 下拉选型号） |
| 车型 | 取第 **3～6** 位（`1101` / `1102` / `1201`）自动选控制策略，无需单独选型号 |
| 上报 | 心跳、`1002` 的 `body.device_ID` 均为纯号；固件会剥掉历史 `app2dev/` 前缀 |

---

## 3. 控制协议（核心）

### 3.0 两类控制

```
message_type = 3
├── pwm_ctrl      行走：前后左右 + 力度（无 pin 字段）
└── pin_setctrl   GPIO：pin + val（仅此处出现引脚号）
```

| 对象 | 含义 |
|------|------|
| `pwm_ctrl` | 前进 / 后退 / 左转 / 右转及力度 |
| `pin_setctrl` | 某 GPIO 拉高或拉低（发射、继电器等） |

固件先处理 `pwm_ctrl`，再处理 `pin_setctrl`。  
**App 组包必须分场景，不能把 GPIO 塞进行走 JSON。**

---

### 3.0.1 铁律：行走 JSON 禁止带 `pin`（UART / BLE / MQTT 通用）

> **只要是行走相关下发（含 MQTT），`body` 里只能有 `pwm_ctrl`，不能有 `pin_setctrl`。**

适用：**UART、BLE、MQTT 任意链路**，规则相同。

| 场景 | 能否带 `pin_setctrl` | `body` 内容 |
|------|---------------------|-------------|
| 控制中：前进 / 后退 / 转向 | ❌ **禁止** | 仅 `pwm_ctrl` |
| 只松前后 | ❌ **禁止** | 仅 `pwm_ctrl` |
| 只松左右 | ❌ **禁止** | 仅 `pwm_ctrl` |
| 前后 + 转向全停（§3.2.2） | ❌ **禁止** | 仅 `pwm_ctrl` 四字段全 `0` |
| 需要关 GPIO / 发射 | ❌ 不与行走同包 | **另发一条**仅 `pin_setctrl` |
| 主动：边走边控 GPIO（§3.4） | ✅ 可同包 | `pwm_ctrl` + `pin_setctrl` |

**错误（MQTT / 串口 / BLE 都不要这样发）：**

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":0,"speed_val":0,"steer_mode":0,"steer_val":0},"pin_setctrl":{"pin":16,"val":0}}}
```

**正确——行走与 GPIO 分两路：**

```text
# 行走全停 ×3（只有 pwm_ctrl）
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":0,"speed_val":0,"steer_mode":0,"steer_val":0}}}

# 需要关 GPIO 时，MQTT/串口/BLE 另发一条（只有 pin_setctrl）
{"head":{"message_type":3},"body":{"pin_setctrl":{"pin":16,"val":0}}}
```

---

### 3.0.2 MQTT 行走控制专则

MQTT 与 UART/BLE **共用同一套 JSON**，无额外字段。

| MQTT 场景 | 发什么 | 禁止 |
|-----------|--------|------|
| 遥控前进 / 转向 | 仅 `pwm_ctrl` | ❌ 不要带 `pin_setctrl` |
| 单轴松手 | 仅 `pwm_ctrl`（该轴置 0） | ❌ 不要带 `pin_setctrl` |
| 全停 3 条 | 仅 `pwm_ctrl` 全 0 ×3 | ❌ **禁止**在全停里夹 `pin` |
| 开发射 / 关发射 | 仅 `pin_setctrl` | 不要混进全停 JSON |
| 边走边发射 | §3.4 同包 | 仅主动控制时允许 |

MQTT 发布行走 JSON 时，检查 `body`：**若存在 `pin_setctrl` 且当前意图是行走控制/停车，则为错误组包。**

---

### 3.1 `pwm_ctrl` — 行走与转向

> 以下示例 **全部只有 `pwm_ctrl`**，适用于 UART / BLE / MQTT。

#### 字段

| 字段 | 含义 | 取值 |
|------|------|------|
| `speed_mode` | 前后 | `0` 无；`1` 前；`2` 后 |
| `speed_val` | 前后力度 | `0～200` |
| `steer_mode` | 左右 | `0` 无；`1` 左；`3` 右 |
| `steer_val` | 左右力度 | `0～200` |

#### 方向互斥

- 前后：`speed_mode` 只能 `0` / `1` / `2` 之一，不能同时前+后。  
- 左右：`steer_mode` 只能 `0` / `1` / `3` 之一，不能同时左+右。  
- 可组合：前+左、前+右、后+左、后+右。

#### 示例

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":1,"speed_val":80,"steer_mode":0,"steer_val":0}}}
```

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":1,"speed_val":80,"steer_mode":1,"steer_val":50}}}
```

---

### 3.2 停车与松手（只有 `pwm_ctrl`）

**UART / BLE / MQTT 全停、松手 JSON 均禁止出现 `pin_setctrl`。**

#### 3.2.1 单轴松手

| 操作 | 写法 |
|------|------|
| 只松前后 | `speed_mode=0, speed_val=0` |
| 只松左右 | `steer_mode=0, steer_val=0` |
| 全松 | §3.2.2 |

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":0,"speed_val":0,"steer_mode":1,"steer_val":50}}}
```

#### 3.2.2 行走全停（3 条，仅 `pwm_ctrl`）

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":0,"speed_val":0,"steer_mode":0,"steer_val":0}}}
```

| 条次 | 时机 |
|------|------|
| 第 1 条 | 全 0 后立刻 |
| 第 2 条 | +100ms |
| 第 3 条 | +100ms |

发完 3 条结束，不要循环发送。  
**3 条内容相同，且都不要带 `pin_setctrl`。**

#### 3.2.3 固件超时停车

500ms 无 JSON、链路清空、断连等：固件只停行走，**不会**自动拉低 GPIO。  
App 需关 GPIO 时，MQTT/串口/BLE **另发** `pin_setctrl.val=0`。

---

### 3.3 `pin_setctrl` — GPIO（独立 JSON）

**只有控制 GPIO 时才发此对象。** 与行走分包。

```json
{"head":{"message_type":3},"body":{"pin_setctrl":{"pin":16,"val":1}}}
```

```json
{"head":{"message_type":3},"body":{"pin_setctrl":{"pin":16,"val":0}}}
```

- 关 GPIO 必须显式 `val=0`。  
- 行走全停 / 松手：**禁止**与 `pwm_ctrl` 同包。  
- 旧 `message_type=4` 改为 `message_type=3` + 仅 `pin_setctrl`。

---

### 3.4 唯一允许同包的场景：主动边走边控 GPIO

仅用户操控中需要同时行走 + GPIO 时：

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":1,"speed_val":80,"steer_mode":1,"steer_val":50},"pin_setctrl":{"pin":16,"val":1}}}
```

松手仍分两段：

```text
# ① 行走全停 ×3（只有 pwm）
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":0,"speed_val":0,"steer_mode":0,"steer_val":0}}}

# ② 关 GPIO（只有 pin，另发）
{"head":{"message_type":3},"body":{"pin_setctrl":{"pin":16,"val":0}}}
```

---

### 3.5 发送节奏

| 场景 | 间隔 / 次数 |
|------|-------------|
| 行走控制中 | 100～200ms 一条 |
| 最大间隔 | ≤ 500ms |
| 行走全停 | 3 条：0 / +100ms / +100ms，**仅 pwm_ctrl** |
| 单轴松手 | 1 条 `pwm_ctrl`，不带 pin |

---

### 3.6 Kotlin 组包（UART / BLE / MQTT 共用）

```kotlin
data class WalkCtrl(val speedMode: Int, val speedVal: Int, val steerMode: Int, val steerVal: Int)

/** 行走：只有 pwm_ctrl — 控制/松手/全停一律用这个 */
fun buildWalkJson(walk: WalkCtrl): String =
    """{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed_mode":${walk.speedMode},"speed_val":${walk.speedVal},"steer_mode":${walk.steerMode},"steer_val":${walk.steerVal}}}}"""

/** GPIO：只有 pin_setctrl — MQTT/串口/BLE 另发 */
fun buildPinJson(pin: Int, val: Int): String =
    """{"head":{"message_type":3},"body":{"pin_setctrl":{"pin":$pin,"val":$val}}}"""

/** 仅 §3.4 主动同包；禁止用于停车 */
fun buildWalkAndPinJson(walk: WalkCtrl, pin: Int, pinVal: Int): String =
    """{"head":{"message_type":3},"body":{"pwm_ctrl":{...},"pin_setctrl":{"pin":$pin,"val":$pinVal}}}"""

val STOP_WALK_JSON = buildWalkJson(WalkCtrl(0, 0, 0, 0))

fun sendWalkStopBurst(publish: (String) -> Unit) {
    publish(STOP_WALK_JSON)  // 无 pin
    postDelayed(100) { publish(STOP_WALK_JSON) }
    postDelayed(200) { publish(STOP_WALK_JSON) }
}

// MQTT 行走：mqttPublish(buildWalkJson(...))
// MQTT 关 GPIO：mqttPublish(buildPinJson(16, 0))  // 与全停分开
```

组包前校验：

```kotlin
fun assertWalkOnly(json: String) {
    val body = JSONObject(json).getJSONObject("body")
    check(!body.has("pin_setctrl")) { "行走 JSON 禁止带 pin_setctrl" }
    check(body.has("pwm_ctrl")) { "行走 JSON 必须有 pwm_ctrl" }
}
```

---

## 4. 设备 → 手机（解析）

| `message_type` | 含义 |
|----------------|------|
| `1` | 心跳（OTA 进行中 **暂停**，见 §6.2） |
| `3` | 行走停车推送（仅 `pwm_ctrl` 全 0，最多 3 条） |
| `4` | **告警**（`ESP_LOGW`，Release 固件即时推送） |
| `5` | **错误**（`ESP_LOGE`，Release 固件即时推送） |
| `1002` | OTA（`phase=query` / `active` / `done`，见 §6） |

收到停车推送：UI 同步行走停止；GPIO 不变，要关则 App 另发 `buildPinJson`。

### 4.1 告警 / 错误日志 JSON（W / E → 手机）

**Release 固件**在链路已建立后，将 `ESP_LOGW` / `ESP_LOGE` **即时**打成 JSON 发给手机（与心跳无关，**出现一条推一条**）：

| 链路 | 通道 | 条件 |
|------|------|------|
| **BLE** | Notify **0xFFE3** | GATT 已连接 |
| **UART** | UART1 文本行 + `\n` | 曾收到对端 RX（发过 `6` 等） |

- `head.message_type` = **4**（Warning）或 **5**（Error）  
- `body`：**仅** `msg`（字符串，通常为 `TAG: 说明`，已去掉行首 `W/E` 与级别前缀）  
- **不缓存**：未连接 / 未握手期间产生的日志 **不会** 补发。  
- **OTA 进行中**：告警/错误 JSON **仍会发送**（与 OT ACK 二进制并存；读线程遇 `{` 按行解析 JSON）。

**告警示例（`message_type = 4`）：**

```json
{"head":{"message_type":4},"body":{"msg":"OTA_STREAM: OTA session aborted"}}
```

**错误示例（`message_type = 5`）：**

```json
{"head":{"message_type":5},"body":{"msg":"OTA_STREAM: OTA error at chunk: overflow"}}
```

> **方向勿混**：**手机 → 设备** 的 `message_type=3` 里 `pin_setctrl` 是 GPIO 控制；**设备 → 手机** 的 `4`/`5` 是日志，二者方向与含义均不同。

**App 解析（UART / BLE 相同）：**

```kotlin
when (messageType) {
    1 -> onHeartbeat(body)
    4, 5 -> onDeviceLog(messageType, body.getString("msg"))  // 告警/错误
    1002 -> onOtaPhase(body.getString("phase"), body)
}
```

---

## 5. 串口接收组包（遥控 / 握手阶段）

> **仅适用于未进入 OTA 二进制阶段**（尚未发 `message_type=0`）。OTA 推包时读线程规则见 **§6.5**。

```kotlin
private val rxTextBuffer = StringBuilder()
fun onSerialBytesForJsonOnly(bytes: ByteArray) {
    rxTextBuffer.append(bytes.toString(Charsets.UTF_8))
    while (true) {
        val idx = rxTextBuffer.indexOf("\n")
        if (idx < 0) break
        val line = rxTextBuffer.substring(0, idx).trim('\r', '\n', ' ')
        rxTextBuffer.delete(0, idx + 1)
        if (line.isEmpty() || !line.startsWith("{")) continue
        runCatching { JSONObject(line) }.onSuccess { handleJson(it) }
    }
}
```

MQTT / BLE 按各自 SDK 收完整 JSON 字符串后，解析逻辑与上相同。

---

## 6. OTA 专题（UART / BLE）

### 6.1 文件与分区

| 项 | 说明 |
|----|------|
| 固件镜像 | `firmware/release/ota/ESPRCCar.bin` 或本地 `build/ESPRCCar.bin` |
| 版本清单 | 同目录 `ota_manifest.json`（`version` 须高于设备当前版） |
| 大小上限 | ≤ **6 MiB**（`ota_0` / `ota_1` 分区） |

### 6.2 JSON 阶段（握手 + 状态识别）

**`message_type=1002` 的 `body.phase` 三态：**

| phase | 方向 | 何时 | 含义 |
|-------|------|------|------|
| `query` | 设备→手机 | 收到 `6` 后 | 握手 + OTA 参数（`device_ID`、`version`、`uart_baud`、`ota_chunk` 等，§6.2.1） |
| `active` | 设备→手机 | 收到 `0` 后 **立即一条** | **OTA 识别 JSON**：已进入升级，**暂停心跳**（含同套 OTA 参数） |
| `done` | 设备→手机 | OTA 结束或失败 | `ok=1` 成功将重启；`ok=0` 失败（含 `err`、`written`），随后 **恢复心跳** |

**时序：**

| 步骤 | 方向 | 内容 |
|------|------|------|
| 1 | 手机→设备 | `message_type=6` + `\n`（每次 open 串口 / BLE 连接后） |
| 2 | 设备→手机 | `1002` / `phase=query` |
| 3 | 手机→设备 | **仅当** `manifest.version` **>** `query.body.version`：`message_type=0` + `\n` |
| 4 | 设备→手机 | `1002` / `phase=active`（**替代心跳**，告知 App 进入 OTA） |
| 5 | 手机→设备 | OT 帧 BEGIN → DATA（分块）→ END（§6.3，**每块等 ACK 再发下一块**） |
| 6 | 设备→手机 | `1002` / `phase=done` |
| 7 | 设备 | `ok=1` 时约 **1s** 后重启；`ok=0` 时 **恢复周期心跳**，可重试 OTA |

#### 6.2.1 `1002` 体字段（`query` / `active` 均可能携带）

| 字段 | 类型 | 说明 |
|------|------|------|
| `phase` | string | `query` / `active` / `done` |
| `device_ID` | string | 纯设备号 |
| `version` | string | 固件版本 |
| `uart_baud` | number | **UART 模式**：固件当前波特率（如 **460800**）；BLE 无此字段 |
| `ota_chunk` | number | 设备建议 **DATA 块字节数**（固件按波特率计算，32～240） |
| `ota_chunk_max` | number | 固定 **240** |
| `ota_ack_timeout_ms` | number | 建议 App 等 ACK 超时（ms） |

**示例 — 握手（步骤 2，460800）：**

```json
{"head":{"message_type":1002},"body":{"phase":"query","device_ID":"CN110200000001","version":"1.0.2","uart_baud":460800,"ota_chunk":876,"ota_chunk_max":1024,"ota_ack_timeout_ms":1200}}
```

**示例 — 识别 JSON（步骤 4）：**

```json
{"head":{"message_type":1002},"body":{"phase":"active","device_ID":"CN110200000001","version":"1.0.2","uart_baud":460800,"ota_chunk":876,"ota_chunk_max":1024,"ota_ack_timeout_ms":1200}}
```

> **App 优先使用 `query`/`active` 里的 `ota_chunk` 与 `ota_ack_timeout_ms`**；若缺失则用 §6.3.3 本地公式（`open` 波特率须与 `uart_baud` 一致）。

**示例 — 成功汇报（步骤 6）：**

```json
{"head":{"message_type":1002},"body":{"phase":"done","ok":1,"device_ID":"CN110200000001","version":"1.0.2","written":889072,"err":""}}
```

**示例 — 失败汇报（步骤 6，超时）：**

```json
{"head":{"message_type":1002},"body":{"phase":"done","ok":0,"device_ID":"CN110200000001","version":"1.0.2","written":4096,"err":"timeout"}}
```

常见 `err`：`timeout` | `reset` | `disconnect` | `incomplete` | `overflow` | `write`（成功时 `err` 为空字符串 `""`）。

App 收到 `phase=active` 后：**不要等心跳**，UI 显示「升级中」；读线程以 **OT ACK 帧** 为主（§6.5）。  
收到 `phase=done` 且 `ok=0` 后：**恢复等心跳**，可提示失败并允许用户重试（重新 `6→0→BEGIN`）。

### 6.3 OT 二进制帧（分块确认）

帧格式：`[4F 54][02][OP][SEQ×2][LEN×2][PAYLOAD…][CRC8][16]`

| OP | 方向 | 典型总长 | 说明 |
|----|------|----------|------|
| `0x01` BEGIN | 手机→设备 | **14** | PAYLOAD=4B 镜像大小（小端） |
| `0x02` DATA | 手机→设备 | **10+plen** | PAYLOAD=**1～1024B**（UART，`ota_chunk_max` 由固件下发；**用 `ota_chunk`**） |
| `0x03` END | 手机→设备 | **10** | PAYLOAD 空 |
| `0x80` ACK | 设备→手机 | BEGIN/END：**11** | 载荷仅 `[status]` |
| `0x80` ACK | 设备→手机 | DATA 1B：**12** | `[echo_byte][status]`（兼容旧版逐字节） |
| `0x80` ACK | 设备→手机 | DATA 多块：**14** | `[len_lo][len_hi][last_byte][status]` |
| `0x81` NAK | 设备→手机 | **11** | CRC/长度等错误 |

**分块参数（v1.0.2 起 · 波特率自适应）：**

| 项 | 说明 |
|----|------|
| `ota_chunk` | **以设备 `1002` JSON 为准**；无则按 §6.3.3 用 `open` 波特率计算 |
| `ota_chunk_max` | **1024**（UART 单帧上限，menuconfig 可配 240～1024） |
| `ota_ack_timeout_ms` | **以设备 JSON 为准**；无则按 §6.3.3 计算 |
| `MAX_RETRIES_PER_CHUNK` | **3**（同 SEQ 重传） |
| 串口 `open` | **必须与 `uart_baud` 相同**，否则 CRC/速度均异常 |

**铁律：**

1. 必须先 `message_type=0`，再发 OT 帧。  
2. 顺序：BEGIN 成功 → 若干 DATA 块 → END。  
3. **每发一块须等设备 ACK**（`status==0` 且长度/末字节校验通过）**再发下一块**；禁止在 ACK 前连发多块（日志里成片 `11 bytes` 为旧版逐字节或灌包过快）。  
4. JSON 行与 OT 帧 **不要拼在同一行**；`0` 后须 `\n`，再发二进制。  
5. **仍须停等 ACK**（一块一确认）；不能在未收 ACK 时流水线发下一块。

#### 6.3.1 固件按波特率推荐块大小与用时（`ota_chunk_max=1024`）

固件 `ota_chunk` 由 §6.3.2 公式算出；下表为 **分块停等 ACK** + USB 往返（**+15ms 典型** / **+30ms 保守**）。

**1MB：**

| `uart_baud` | `ota_chunk` | 典型吞吐 | **1MB 典型** | **1MB 保守** |
|-------------|-------------|----------|--------------|--------------|
| **115200** | **201** | ~5.7 KB/s | **~180s（3min）** | **~258s（4.3min）** |
| **230400** | **426** | ~12 KB/s | **~85s** | **~122s** |
| **460800** | **876** | ~25 KB/s | **~41s** | **~59s** |
| **921600** | **1024** | ~38 KB/s | **~27s** | **~42s** |

**6MB（分区上限）：**

| `uart_baud` | `ota_chunk` | **6MB 典型** | **6MB 保守** |
|-------------|-------------|--------------|--------------|
| **115200** | 201 | **~18min** | **~26min** |
| **230400** | 426 | **~8.5min** | **~12min** |
| **460800** | 876 | **~4.1min（~248s）** | **~6min（~356s）** |
| **921600** | 1024 | **~2.7min（~162s）** | **~4.2min（~254s）** |

对比：

| 模式 | 6MB 约 |
|------|--------|
| 旧：逐字节 1B/帧 | **~17 小时** |
| 分块 max=240 @ 460800 | **典型 ~9min，保守 ~16min** |
| **分块 max=1024 @ 460800** | **典型 ~4min，保守 ~6min** |

> 再快需要 **改协议**（流水线多帧再批量 ACK / WiFi HTTP OTA），当前 UART 停等模型下 **460800+1024 块** 已接近 USB 线 practical 上限。  
> 想压到 **6MB &lt;3min**：用 **921600** 波特率 + 确保 App 读线程够快（RTT≈15ms）。

#### 6.3.2 固件计算公式（与 `ota_uart_tune.c` 一致，App 可本地复算）

```text
wire_overhead = 24
ota_chunk_max = 1024          // menuconfig APP_OTA_UART_MAX_CHUNK
ota_chunk = clamp(baud / 512 - wire_overhead, 32, ota_chunk_max)
ota_ack_timeout_ms = clamp((ota_chunk + wire_overhead) * 10 * 1000 / baud * 3 + 800, 1200, 8000)
```

#### 6.3.3 App 分块发送示例（读 `1002` 参数）

```kotlin
object OtaUartTune {
    private const val WIRE_OVERHEAD = 24
    private const val CHUNK_MIN = 32
    private const val CHUNK_MAX_DEFAULT = 1024

    fun chunkForBaud(baud: Int, chunkMax: Int = CHUNK_MAX_DEFAULT): Int {
        if (baud <= 0) return 128
        return (baud / 512 - WIRE_OVERHEAD).coerceIn(CHUNK_MIN, chunkMax)
    }

    fun ackTimeoutMs(baud: Int, chunk: Int): Long {
        val b = if (baud > 0) baud else 115200
        val wireMs = (chunk + WIRE_OVERHEAD) * 10L * 1000L / b
        return (wireMs * 3 + 800).coerceIn(1200, 8000)
    }

    fun fromQueryBody(body: JSONObject, openBaud: Int): Pair<Int, Long> {
        val chunkMax = body.optInt("ota_chunk_max", CHUNK_MAX_DEFAULT)
        val baud = body.optInt("uart_baud", openBaud)
        val chunk = body.optInt("ota_chunk", 0).takeIf { it in CHUNK_MIN..chunkMax }
            ?: chunkForBaud(baud, chunkMax)
        val ackMs = body.optLong("ota_ack_timeout_ms", 0L).takeIf { it > 0 }
            ?: ackTimeoutMs(baud, chunk)
        return chunk to ackMs
    }
}

const val MAX_RETRIES_PER_CHUNK = 3

data class DataAck(val chunkLen: Int, val lastByte: Byte, val status: Int)

/** DATA ACK：1B 载荷 → [echo][status]；多块 → [len_lo][len_hi][last][status] */
fun OtaFrame.parseDataAck(frame: ByteArray): DataAck? {
    if (frame.size < 10 || frame[3] != OP_ACK) return null
    val plen = (frame[6].toInt() and 0xFF) or ((frame[7].toInt() and 0xFF) shl 8)
    return when (plen) {
        2 -> DataAck(1, frame[8], frame[9].toInt() and 0xFF)
        4 -> DataAck(
            (frame[8].toInt() and 0xFF) or ((frame[9].toInt() and 0xFF) shl 8),
            frame[10], frame[11].toInt() and 0xFF
        )
        else -> null
    }
}

fun buildDataChunk(seq: Int, chunk: ByteArray) = build(OP_DATA, seq, chunk)

fun uartOtaWithChunks(
    port: UsbSerialPort,
    firmware: ByteArray,
    chunkSize: Int,          // 来自 1002.body.ota_chunk
    ackTimeoutMs: Long       // 来自 1002.body.ota_ack_timeout_ms
) {
    var seq = 0
    port.write(OtaFrame.buildBegin(firmware.size), 3000)
    require(waitStatusAck(seq, ackTimeoutMs) == 0)
    seq = 1
    var offset = 0
    while (offset < firmware.size) {
        val n = minOf(chunkSize, firmware.size - offset)
        val chunk = firmware.copyOfRange(offset, offset + n)
        var ok = false
        repeat(MAX_RETRIES_PER_CHUNK) {
            port.write(OtaFrame.buildDataChunk(seq, chunk), 5000)
            val ack = waitAckFrame(seq, ackTimeoutMs) ?: return@repeat
            val r = OtaFrame.parseDataAck(ack) ?: return@repeat
            if (r.status == 0 && r.chunkLen == n && r.lastByte == chunk.last()) {
                ok = true
                return@repeat
            }
        }
        check(ok) { "DATA chunk failed at offset=$offset seq=$seq" }
        offset += n
        seq++
    }
    port.write(OtaFrame.buildEnd(seq), 3000)
    require(waitStatusAck(seq, ackTimeoutMs) == 0)
}
```

**`UartOtaManager.kt` 接入要点：**

1. `UsbSerialPort` **open 波特率 = `query.body.uart_baud`**（如 **460800**）。  
2. 收到 `1002/query` 后：`val (chunk, ackMs) = OtaUartTune.fromQueryBody(body, openBaud)`。  
3. 收到 `1002/active` 后开始 OTA，用 `chunk` / `ackMs` 调用 `uartOtaWithChunks`。  
4. 若 `openBaud != body.uart_baud`，**拒绝 OTA** 并提示波特率不一致。

> `BleOtaManager.kt`：无 `uart_baud`，用 `ota_chunk`（缺省 128）与 `ota_ack_timeout_ms`。

### 6.4 UART 与 BLE 通道对照

| 项目 | BLE | UART |
|------|-----|------|
| 握手 / `0` | Write **0xFFE1** | JSON 行 + `\n` |
| `1002` JSON | Notify **0xFFE3** | UART1 文本行 + `\n` |
| 固件 OT 帧 | Write **0xFFE2** | UART1 二进制写 |
| ACK/NAK | Notify **0xFFE4** | UART1 读回（与 JSON 同线） |
| OTA 期间心跳 | **暂停**（改 `1002/active`） | **暂停**（改 `1002/active`） |
| OTA 期间告警 | Notify **0xFFE3** 仍可能推 `4`/`5` | 同 UART1 JSON 行 |
| 失败后心跳 | **恢复**（`1002/done` 之后） | **恢复** |

### 6.5 UART RX 超时（固件侧，与 App 对齐）

| 阶段 | 无 RX 超时 | 设备行为 |
|------|------------|----------|
| 正常遥控 | **1.2s** | 停停车推送；非 OTA 会话 |
| 已发 `0`、等待 BEGIN | **15s** | 允许 App 在 `0` 后等待 `active`/组包再发 BEGIN |
| OT 传输中（BEGIN～END） | **`ota_ack_timeout_ms + 800ms`**（按波特率，约 1.2～6s） | 中止 OTA → `done(ok=0,err=timeout)` |

> App **`ota_ack_timeout_ms` 须小于固件传输无 RX 超时**（设备侧 = App 值 + 约 800ms）。`0` 后发 BEGIN 前走 **15s** 武装超时。

### 6.6 UART 读线程（OTA 阶段）

手机 `read()` 下行字节流：

- **OTA 进行中**：以 **OT ACK/NAK**（魔数 `4F 54`）为主；**不要**把整个缓冲当 UTF-8 解析。  
- **非 OTA**：按 `\n` 组 JSON 行（§5）。  
- 一次 `read()` 可能只含半帧；须字节缓冲组满再校验 CRC。  
- `1002/done` 仍为 JSON 行，在 OT 帧解析器之外按行处理。

### 6.7 BEGIN ACK 超时与设备重启（联调实录）

手机报 **BEGIN ACK 超时** 时，若设备 monitor 出现：

```text
OTA_FRAME: RX BEGIN seq=0 plen=4
OTA_STREAM: OTA begin: size=... bytes
OTA_STREAM: OTA partition: ota_1 at ...
***ERROR*** A stack overflow in task uart_evt_rx has been detected.
```

说明 **BEGIN 已收到**，但在 `esp_ota_begin` 阶段 **UART RX 任务栈溢出重启**，ACK 来不及发出。

| 侧 | 处理 |
|----|------|
| **固件（必须）** | UART RX 经**指针队列**入 `uart_rx_work`（≥12KB 栈）处理；禁止在 `uart_evt_rx` 栈上放大块缓冲或同步 `esp_ota_begin`；队列满会导致 CRC NAK |
| **App（建议）** | BEGIN ACK 超时时若串口已断开，提示「设备异常重启，请更新固件」而非仅提示握手问题 |

修复后正常日志应含：`OTA_FRAME: TX ACK seq=0 status=0`。

### 6.8 联调检查（OTA · 固件侧）

- [ ] 每次 open 先发 `6`，收到 `1002/query` 再显示已连接  
- [ ] 版本更高才发 `0`；收到 `1002/active` 后 UI 切升级态、**不等心跳**  
- [ ] 串口波特率与 `query.uart_baud` 一致（如 **460800**）  
- [ ] BEGIN **14B** → 等 ACK → 再发 DATA **分块（`ota_chunk`）**  
- [ ] 每 DATA 块等 ACK：`status==0`，`chunkLen` 与 `last_byte` 与发送块一致  
- [ ] 失败收 `1002/done(ok=0)`，之后应再收到周期心跳  
- [ ] 成功收 `1002/done(ok=1)`，约 1s 重启，重连后 `query.body.version` 更新  

---

## 7. 联调检查清单（App + 固件）

- [ ] UART / BLE / MQTT 控制 JSON **格式一致**  
- [ ] **铁律**：行走相关（控制/松手/全停）**只有 `pwm_ctrl`，无 `pin_setctrl`**（含 MQTT）  
- [ ] 全停 3 条仅 `pwm_ctrl`，不发带 pin 的包  
- [ ] GPIO 用 `buildPinJson` **单独**发（MQTT 同规则）  
- [ ] 同包融合**仅** §3.4 主动控制  
- [ ] 前后/左右互斥；控制 100～200ms，≤500ms  
- [ ] UART 行尾 `\n`；串口按行组包  
- [ ] 握手 `6→1002/query`；控制不等 ACK  
- [ ] OTA：`0` 后收 `1002/active`；分块 DATA、每块等 ACK；失败收 `done(ok=0)` 后恢复心跳（§6）  

---

## 8. App 修改清单（配合固件 v1.0.2 · 波特率自适应分块 OTA）

> **不改成 §6.3 分块时仍可逐字节 OTA（极慢）**。实用速度须：**刷新版固件 + 按下列改 App + 串口波特率与固件一致（如 460800）**。

### 8.1 必须修改（P0）

| # | 文件 | 改什么 |
|---|------|--------|
| 1 | `UartRobotManager.kt`（或串口配置处） | `UsbSerialPort` **open 波特率 = 460800**（或与固件 menuconfig 一致）；**不要再用 115200 除非固件也是 115200** |
| 2 | `OtaUartTune.kt`（新建） | 实现 §6.3.2 `OtaUartTune`：`chunkForBaud` / `ackTimeoutMs` / `fromQueryBody` |
| 3 | `OtaFrame.kt` | `buildDataChunk(seq, ByteArray)`；`parseDataAck` 支持 2B / 4B ACK |
| 4 | `UartOtaManager.kt` | 解析 `1002/query` 得 `uart_baud`、`ota_chunk`、`ota_ack_timeout_ms`；**校验 open 波特率一致**；分块循环（§6.3.3）；**收到 ACK 再发下一块** |
| 5 | `UartOtaManager.kt` | 未收到 `1002/active` → FAILED；BEGIN ACK 失败 → FAILED |
| 6 | `UartRobotManager.kt` | OTA 读流：组 OT 帧 + JSON 行（`4`/`5`/`1002`） |
| 7 | `BleOtaManager.kt` | 分块 OTA；`ota_chunk` / `ota_ack_timeout_ms` 从 `1002` 读取（无 `uart_baud`） |

### 8.2 建议优化（P1）

| # | 文件 | 建议 |
|---|------|------|
| 8 | `UartOtaManager.kt` | `MAX_RETRIES_PER_CHUNK = 3`；进度 `offset/size` |
| 9 | `UartDebugViewModel.kt` | 联调页显示 `uart_baud` / `ota_chunk`；失败展示 `done.err` |
| 10 | `OtaProtocol.kt` | 常量旁注释：块大小以 `1002` 为准，勿写死 |
| 11 | `OTA对比文档.md` | 同步 §6.2.1、§6.3.1 波特率表、§6.3.2 公式 |

### 8.3 固件 v1.0.2 已实现（App 可依赖）

- `6` → `1002/query`（含 `uart_baud`、`ota_chunk`、`ota_chunk_max`、`ota_ack_timeout_ms`）  
- `0` → `1002/active`（含同套 OTA 参数），暂停心跳  
- OT DATA **1～1024B/帧**（`APP_OTA_UART_MAX_CHUNK`，默认 1024）  
- 460800 时 `ota_chunk` 通常为 **876**；921600 → **1024**  
- DATA 多块 ACK：`[len_lo][len_hi][last][status]`  
- OTA 传输无 RX 超时 = `ota_ack_timeout_ms + 800ms`（随波特率）  
- 成功 `done(ok=1)` 后约 1s 重启；失败恢复心跳  
- Release：`ESP_LOGW`/`E` → `message_type=4/5`  

### 8.4 参考代码位置（App）

| 模块 | 路径 |
|------|------|
| OTA 状态机 | `com/fcrs2025/arflycar/uart/UartOtaManager.kt` |
| 波特率/块大小 | **新建** `com/fcrs2025/arflycar/ota/OtaUartTune.kt`（§6.3.2） |
| 串口 open 波特率 | `com/fcrs2025/arflycar/uart/UartRobotManager.kt`（须与 `uart_baud` 一致） |
| OT 帧 | `com/fcrs2025/arflycar/ble/OtaFrame.kt` |
| 协议版本 | `com/fcrs2025/arflycar/ota/OtaProtocol.kt`（`DOC_VERSION = "1.0.2"`） |

---

## 附录 A · 日志

| 场景 | 关键字 |
|------|--------|
| 行走 | `PWM fused:` |
| GPIO | `GPIO: pin N ->` |
| 全停 | `停车 JSON` |
| 超时 | `RC timeout` |
| OTA 识别 | `1002/active` |
| OTA ACK | `UART1 TX OTA ACK` |
| OTA 失败 | `1002/done` + `ok:0`；或 `message_type=5` + `msg` |
| 告警推手机 | `message_type=4` + `body.msg` |

---

如与 `v1.0.1` 冲突，以本文 `v1.0.2` 为准。
