# Android 端设备对接文档 · 固件 v1.0.1（三链路模式）

| 项 | 值 |
|----|-----|
| **固件版本** | 以 `firmware/release/ota_manifest.json` 的 **`version`** 为准（如 `1.0.2`）；设备上报 **`body.version`** 字符串同值；Android 解析见 **§3.4.0.1** |
| **文档版本** | `1.0.1`（与固件对齐；文件名 `Android端设备对接文档_v1.0.1.md`） |
| **协议/GATT 基线** | BLE: 服务 `0xFFE0`；特征 **FFE1～FFE4**（**已移除 FFE5**）<br>UART: 串口 JSON + `\n`<br>WiFi: MQTT JSON |
| **芯片 / 分区** | ESP32-S3，16MB Flash；OTA 镜像 `ESPRCCar.bin` ≤ 6MiB |
| **推荐构建** | 量产用 **Release**（见 §1.5）：**USB 调试串口无输出**；BLE 下 W/E 走 **0xFFE3** Notify |

本文档面向 Android 应用开发者，说明如何与本仓库 **ESP32-S3 固件**进行连接与数据交互。

固件支持**三种链路模式**（编译期三选一）：

| 模式 | 宏定义 | 通信方式 | 本文档章节 |
|------|--------|----------|-----------|
| **WiFi + MQTT** | `CONFIG_APP_LINK_WIFI` | 数据流量/热点配网，MQTT 通信 | §8 |
| **BLE（蓝牙）** | `CONFIG_APP_LINK_BLE` | 低功耗蓝牙 GATT 通信 | §2～§6 BLE章节 |
| **UART（串口）** | `CONFIG_APP_LINK_UART` | FT232/Type-C 转串口，有线通信 | §7 UART章节 |

三种模式的**控制/心跳 JSON 协议完全相同**，仅物理层不同。

**OTA 机制（重要）**：

| 模式 | 询问是否升级 | 同意升级 | 固件传输 | 完成汇报 |
|------|----------------|----------|----------|----------|
| **WiFi + MQTT** | 设备上报版本，**云端**下发 `new_version` + `esp32_url` | 云端策略 | **HTTPS** 下载 | 心跳 `body.version` |
| **BLE / UART** | 手机 **每次连接** 发 `message_type=**6**`（链路就绪）；设备回 `**1002**`/`phase=query` | 手机回 `head.message_type=**0**` | **OTA 帧协议**（`OT` 包头，§3.4.2）；每 DATA 字节 **ACK 帧** 确认，错误 **重传** | 设备再发 `message_type=**1002**`（`body.phase=done`） |

详见 **§3.4.0**（BLE）、**§7.5**（UART）。`message_type` 含义总表见 **§4.0**。

协议以固件源码为准：`main/link_ble/link_ble.c`、`main/link_uart/link_uart.c`、`main/link_wifi/mqttconf_commun.c`、`main/protocol/remote_control.c`、`main/protocol/heart_payload.c`。**固件的 `idf.py` 编译与烧录由设备/产线侧自行完成，本文不展开构建步骤。**

**快速导航**：

| 链路 | 入口章节 |
|------|----------|
| **BLE** | §2 广播连接 → §3 GATT → §3.4 OTA → **§3.4.0.1 版本解析** → **§5.0 Android 必读** → §5.6 示例 → §6 检查清单 |
| **UART** | §7 硬件/串口 → **§7.2.0 波特率设置** → §7.5 OTA → **§3.4.0.1 版本解析** → §7.8 检查清单 |
| **WiFi + MQTT** | §8 热点配网 → §8.2 MQTT 主题与 JSON → §8.3 HTTP OTA |
| **App 功能总表** | **附录 A**（连接/遥控/OTA/配网一览） |
| **旧固件 OTA 验证** | **§7.5.5** |
| **OTA_MGR 启动日志** | **§7.5.6** |

### 随包交付物（给 Android 联调/升级）

Android 做 BLE / UART OTA **只需要两个文件**（运行 `scripts\copy_firmware_release.bat` 后生成）：

| 文件 | 用途 |
|------|------|
| **`firmware/release/ota/ESPRCCar.bin`** | 实际通过 BLE / UART 推送的应用固件镜像 |
| **`firmware/release/ota_manifest.json`** | Android 读取的版本 JSON：目标版本、固件字节数、SHA256 |

`firmware/release/manifest.json` 是完整发布包清单（含 factory 烧录文件），`VERSION.txt` 是人工阅读说明；Android OTA 不依赖它们。

联调前请确认：固件 `menuconfig` 链路模式与 App 一致；设备 `body.version` 与随包 **`firmware/release/ota_manifest.json`** 的 `version` 字段一致（§3.4.0.1）。BLE 模式另需按 §3.5 解析 **0xFFE3** 的 `message_type` **1 / 4 / 5**（勿再订阅已删除的 **0xFFE5**）。

### 相对旧版 App 的协议变更摘要（v1.0.1）

- **BLE/UART 链路握手**：手机 **每次打开串口 / GATT 连接成功** 后 **必须先发** `message_type=**6**`（§4.2.5）；设备 **立即回** `1002`/`phase=query`（**不**再上电主动发 1002）；App 可多次发 `6`，设备每次均回——见 §7.2.2 Debug 日志对照。
- **OTA 同意**：设备握手后**必问** `1002/query`；手机**仅当** APK 内 `manifest.version` **高于** `query.body.version`（版本有变化）才发 **`0`**；版本相同**不回 `0`**、不推包；完成后设备发 **`1002`**（`phase=done`）。见 §3.4.0.1。
- **OTA 固件确认**：设备回 **完整 ACK/NAK 帧**（BLE 经 **0xFFE4** 按帧字节 Notify；UART 按帧写回）；`status≠0` 时 App **原 SEQ 重传**。
- **控制 JSON**：手机下发后 **无 `OK` 文本回复**。
- **删除 GATT `0xFFE5`**：告警/错误改由 **`0xFFE3` Notify** 推送，`head.message_type` **4**=告警、**5**=错误，`body.msg` 为文本。
- **心跳仍在 `0xFFE3` / UART 串口**：`message_type` **1**，**连接成功立即 1 条**，之后周期 **UART 3s / BLE 10s**，字段见 §3.5-A。
- **命令下发间隔**（BLE/UART 共用）：手机→设备 **任意相邻两条 JSON** 间隔 **≥100ms**；摇杆控制建议 **100～200ms** 一发，且须 **≤500ms** 以免固件超时停车（§5.0.2、§7.2.3）。
- **停车专用 JSON**（§4.4.1、§4.4.2）：松手/停止时发 **全位 0**（`speed/direction/mode/val` 均为 0）；**前进+左转等多键同时抬起** 也必须用同一条 JSON，**每 300ms** 重复写。
- **UART 波特率固定 115200**：App **设置页**应有「串口波特率」项，但当前固件只支持 **115200**；每次 `open` 后必须 `setParameters(115200, 8, N, 1)`（§7.2.0）。
- **OTA 帧协议**（§3.4.2）：`OT` 包头 + CRC8 + ACK/NAK，错误 **原 SEQ 重传**（§5.0.4）。
- **Release 固件**：**USB 调试串口（UART0）关闭**，`idf.py monitor` 基本无输出；BLE 诊断靠 **0xFFE3**（见 §1.5）。
- **设备无 WS2812 状态灯**，连接与 OTA 状态请依据 JSON / App UI。
- **手机 → 设备** 写入 **0xFFE1** 的 `message_type` **4** 仍为 **GPIO `pin_setctrl`**（与 **0xFFE3** 上设备→手机的 **4/5** 方向不同，勿混淆）。
- **勿将 OTA 同意 `message_type=0` 与遥控重启 `message_type=1` 混淆**（后者为手机→设备重启命令，见 §4.2）。

---

## 1. 固件侧能力与前提

### 1.1 链路模式（编译期三选一）

固件通过 `menuconfig` → `esp32s3_MYSDK` → **链路模式** 选择。**同一时间仅有一种模式被编译进固件**（未选中的 `link_wifi` / `link_ble` / `link_uart` 源码不会链接），可节省 Flash。

| 模式 | 通信方式 | 配网方式 | OTA 方式 | 本文档章节 |
|------|----------|----------|----------|-----------|
| **WiFi + MQTT** | STA 连路由器，MQTT 收发 JSON | SoftAP 热点写入 WiFi + `device_id` | 云端 JSON 触发 **HTTPS** 下载 | §8 |
| **BLE（NimBLE）** | GATT 特征值收发 JSON | SoftAP 写入 `device_id` + `ble_adv_name` | **0xFFE2** 二进制流 | §2～§6 |
| **UART（串口）** | UART1（GPIO17 TX / GPIO18 RX）JSON + `\n` | SoftAP 仅写入 `device_id`；长按 GPIO4 重置 | 与 BLE 相同 opcode **0x01/0x02/0x03** | §7 |

默认示例配置见 `sdkconfig.defaults`（当前为 **`CONFIG_APP_LINK_UART=y`**）。

在 **BLE** 链路下还可通过：

- **`APP_BLE_OTA`**：启用/关闭 **0xFFE2** 固件推送（默认 `CONFIG_APP_BLE_OTA=y`）
- **`APP_BLE_TX_POWER`**：ESP32 **发射**功率（贴机默认 **-24 dBm**）；**接收**灵敏度不可在固件里单独加大，见 §1.1.1

关闭 `APP_BLE_OTA` 后向 **0xFFE2** 写入将返回 **Write Not Permitted**。

**BLE 模式与 WiFi 射频**：仅在未配置 `device_id` / `ble_adv_name`、或长按 GPIO0/GPIO4 进入配网时开启 **SoftAP**；保存并重启后（或正常运行启动时）固件调用 `provision_wifi_shutdown()` **停止并 deinit WiFi**，仅保留 NimBLE。进入配网前会先 `link_ble_stop()`，避免 AP 与蓝牙同时发射。

**Release 构建**：见 **§1.5**（USB 调试串口停止；业务 UART1 / BLE 不受影响）。

#### 1.1.1 发射 vs 接收（贴机必读）

| 方向 | 谁发包 | 固件能做什么 |
|------|--------|----------------|
| **ESP32 → 手机**（Notify/广播） | 设备发射 | 已支持 **-24～-9 dBm**（`APP_BLE_TX_POWER`，默认 **-24 dBm**）；连接建立后会再次 `esp_ble_tx_power_set` |
| **手机 → ESP32**（Write/OTA） | 手机发射 | ESP32 **没有**「加大接收功率」的公开 API；灵敏度由射频硬件决定 |

若贴板后 **手机写的包设备收不到/OTA 易断**，请 Android 侧：

1. OTA/遥控期间 **不要** 开省电、勿后台杀蓝牙；保持前台 GATT 连接。
2. `requestConnectionPriority(CONNECTION_PRIORITY_HIGH)`（改善调度，不保证加大手机发射功率）。
3. 手机系统蓝牙发射无法统一 API 控制时，靠 **贴天线区域、缩短距离**；必要时略提高 `APP_BLE_TX_POWER`（例如 -15 dBm）换双向裕量。

双天线模组可在 menuconfig → **Bluetooth Controller → RX antenna** 选外置天线脚（与硬件一致）。

### 1.5 Debug / Release 与日志/业务串口（UART 模式必读）

固件里有两类输出，**不要混为一谈**：

| 类型 | 代码 | 默认落点 | 手机 App 是否应解析 |
|------|------|----------|---------------------|
| **调试日志** | `ESP_LOGx`（如 `OTA_MGR`、`LINK_UART`、`APP`） | **控制台 UART**（`menuconfig` → *Channel for console output*；仓库 Debug 默认 **UART0 / USB**） | **否**（纯文本行，非 JSON 协议） |
| **业务数据** | `link_uart` 的 `uart_write_bytes` | **UART1** GPIO17 TX / GPIO18 RX（`CONFIG_APP_UART_LINK_*`） | **是**（JSON 行、OT 帧等） |

**默认硬件（本仓库 `sdkconfig.defaults`）**：

| 外设 | 引脚 | 典型连接 | Debug | Release |
|------|------|----------|-------|---------|
| **UART0** | GPIO43/44 | 板载 USB → `idf.py monitor` 的 COM | `ESP_LOG` **有** | `CONFIG_ESP_CONSOLE_NONE` → **无** |
| **UART1** | GPIO17/18 | FT232 ↔ 手机 OTG | 业务 JSON/OTA/心跳 | 同上 |

**若你把控制台「重定向」到 UART1**（`menuconfig` → *Component config* → *ESP System Settings* → 控制台 UART 选 **UART1**，或板子 USB 芯片只接 GPIO17/18）：

- `idf.py monitor` 所连 COM 口上会同时出现 **`OTA_MGR` / `LINK_UART` 等日志文本**，以及 **真实业务 JSON**（心跳、`1002` 等），**混在同一物理线**。
- 日志里的 `UART1 RX …`、`UART1 TX 心跳(1): …` 是 **ESP_LOG 打的说明文字**；其中 `TX 心跳(1): {…}` 一行通常与线上发出的 JSON **内容一致**，但 **仍须用读线程按 JSON 解析**，不要把整段 `I (548) LINK_UART: …` 当日志行去 `cJSON_Parse`。
- 此种接线下：**不能用「监视器有 `OTA_MGR`」代替「手机已发 `6`」**；握手仍看是否收到 **`message_type=1002`** 的 **纯 JSON 行**（§4.2.5）。

**BLE 模式（对照）**：Release 下 `ESP_LOGW`/`ESP_LOGE` 经 **`esp_log_set_vprintf` 钩子** 转到 **0xFFE3**（`message_type` 4/5），**不是** UART 重定向；UART 模式 **无** 此钩子。

**Release 编译命令（推荐量产）**：

```bash
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.release" build flash
```

`sdkconfig.defaults.release`：`CONFIG_ROBO_APP_FW_RELEASE=y`、`CONFIG_ESP_CONSOLE_NONE=y`、默认日志 **WARN**。

**对 Android 联调**：

- 默认两路：业务只看 **UART1**；USB 监视器仅看人读日志。
- 控制台重定向到 UART1 时：App 读线程需 **只解析 `{` 开头、`\n` 结尾** 的 JSON，**忽略** `I (xxx) TAG:` 前缀的日志行。
- Release + **UART**：无 `ESP_LOG`；靠心跳 / **1002** / UI（§7.5.6）。

### 1.2 配网（SoftAP 热点）与各模式必填项

三种模式在未完成必填 NVS 时，均会进入 **SoftAP 配网页**（默认热点名见 `CONFIG_ROBIOT_WIFI_SSID`，一般为 `esp32-apconfig`），手机连接后访问 **`192.168.4.1`** 提交表单。

| 链路 | 配网 HTML（SPIFFS） | 页面上显示的字段 |
|------|---------------------|------------------|
| WiFi | `www/index.html` | WiFi 名称/密码 + 设备 ID |
| BLE | `www/index_ble.html` | 设备 ID + 蓝牙广播名（**无** WiFi 项） |
| UART | `www/index_uart.html` | **仅设备 ID**（**无** WiFi、无蓝牙名） |

| NVS 键 | WiFi 模式 | BLE 模式 | UART 模式 |
|--------|-----------|----------|-----------|
| `device_id` | 必填 | 必填 | 必填 |
| `wifi_ssid` / `wifi_pass` | 必填 | — | — |
| `ble_adv_name` | — | 必填 | — |

> 安装、烧录与改配网页后如何刷新 SPIFFS，见 [`安装与配网指南.md`](安装与配网指南.md)。

**各模式启动条件**（`main/app/app_run.c`）：

- **WiFi**：有 `wifi_ssid` 则 STA 联网并拉取 MQTT 配置；无则进配网。
- **BLE**：`device_id` 与 `ble_adv_name` 均非空才启动广播；否则进配网。配网完成后**关闭 WiFi**，仅 BLE（默认 **-12 dBm**）。
- **UART**：`device_id` 非空则启动 UART1；否则进配网。正常运行时**不连 WiFi、不启 BLE**；配网结束后同样**关闭 WiFi**。

**按键**：UART 模式仅 **GPIO4** 长按约 2 秒可再次进入配网；WiFi/BLE 另有 GPIO0 等逻辑见 `wifidevnum_config.c`。

### 1.3 设备号（`device_id`）与蓝牙广播名（`ble_adv_name`）的对应关系

固件**不会**在运行时根据 `device_id` 自动计算 `ble_adv_name`；两项均在配网页分别写入 NVS。为便于用户从机身标贴上的**标准设备号**联想到如何扫描蓝牙，建议产线/Android 端采用**统一命名约定**（以下仅为**推荐规则**，截取细节以你们出厂脚本为准）。

| 概念 | 示例 | 说明 |
|------|------|------|
| 标准 **`device_id`** | `CN110200000001` | 前缀（如 `CN`）+ **型号四位** `1102` + **序列**（如 `000001`）等；完整串用于 MQTT 主题、心跳 `device_ID`、以及固件按第 3～6 字符选择驱动策略（见 `device_model.h`）。 |
| NVS **`ble_adv_name`（基础名）** | `110201` | 建议由 **型号 `1102`** + **序列简写**（如将 `000001` 规范为两位 `01`）拼接；须为 **ASCII**，且预留固件追加 **2 字节后缀 `MP`** 后仍不超过广播/缓冲区限制（见 §2.2）。 |
| 手机扫描到的 **完整本地名** | `110201MP` | **仅**在基础名后追加 **`MP`**（字母 **M** 再 **P**），由 `link_ble.c` 实现；若基础名已以 `MP` 结尾则不再重复追加。 |

**后缀拼写**：对外广播与 App 过滤一律以 **`MP`** 为准，**无 `PM` 等其它后缀**。

### 1.4 车型 1102：舵机不动时先查 GPIO16 编译角色

**1102** 的左转/右转在固件里**固定使用 `GPIO16` 的舵机 PWM**（`device_1102.c` → `rc_pwm_set_aux_servo_angle_deg(16, …)`）。但 `rc_pwm_control.c` 规定：**仅当该引脚在 menuconfig 中被配置为「舵机 / SERVO」时才会更新占空比**；若配置为 **电调 / ESC**，则函数直接返回，**LEDC 脉宽不会随 JSON 变化**，现象就是 **BLE 已收到 `pwm_ctrl`、日志正常，舵机仍不转**。

- 修改路径：`idf.py menuconfig` → **`esp32s3_MYSDK`** → **`GPIO16 PWM 角色`** → 选 **「舵机（初始化 90 度 / 1500us）」**（对应 `CONFIG_APP_PWM_IO16_SERVO=y`）。
- 仓库默认：`sdkconfig.defaults` 已按 **1102 接转向舵机** 将 GPIO16 设为 **SERVO**；若你们产线该脚接电调，请改回 **ESC** 并勿用 1102 的 IO16 转向映射。

---

## 2. BLE 广播与连接参数

### 2.1 广播类型

- **可连接、非定向**（`BLE_GAP_CONN_MODE_UND`）
- **通用可发现**（`BLE_GAP_DISC_MODE_GEN`）
- 广播字段含：**通用发现标志**、**BR/EDR 不支持**、**完整本地名称**（与下方名称一致）

### 2.2 设备名（扫描过滤依据）

- GAP 设备名与广播中的 **Complete Local Name** 均来自 NVS 键 **`ble_adv_name`**，再经固件追加 **`MP`**（见 §1.3、§2.4）。
- 若未配置或读失败，固件默认基础名为 **`ESP32-BLE`**，完整扫描名一般为 **`ESP32-BLEMP`**。
- 名称缓冲区长度为 **32 字节**（含结尾 `'\0'` 的 C 字符串语义），应用侧扫描过滤时请使用 UTF-8 字节长度，并注意经典广播包对名称长度的限制（通常完整名称不宜过长，以免无法放入 31 字节 AD 结构）。

### 2.3 地址

- 使用 **Public 地址** 发起广播与连接（`BLE_OWN_ADDR_PUBLIC`）。

### 2.4 广播名后缀 `MP`（便于 App 过滤）

固件在设置 GAP / 广播 **Complete Local Name** 时，会在 NVS 中的 `ble_adv_name` 后 **自动追加后缀 `MP`**（若已以 `MP` 结尾则不再追加）。若名称过长会截断后再追加，以保证仍落在缓冲区与广播包长度限制内。

**Android 扫描过滤建议**：使用 `ScanRecord.getDeviceName()` 或过滤条件 **`endsWith("MP")` / `contains("MP")`**，以区分本协议设备与其它 BLE 广播（手机端无需在系统蓝牙设置里填写名称，按广播名过滤即可）。

---

## 3. GATT 服务与特征

所有 UUID 均为 **Bluetooth SIG 16-bit UUID**（客户端构造为 `0000ffe0-0000-1000-8000-00805f9b34fb` 形式）。

### 3.1 服务

| 名称 | UUID（16-bit） |
|------|----------------|
| 主服务 | `0xFFE0` |

### 3.2 特征

| 名称 | UUID | 属性 | 说明 |
|------|------|------|------|
| 控制通道 | `0xFFE1` | Write、Write Without Response | 写入 **UTF-8 JSON 文本**，单次最大 **2048 字节**（固件侧缓冲区限制） |
| OTA 通道 | `0xFFE2` | Write、Write Without Response | **二进制分包 OTA**（见 **§3.4、§3.4.5～3.4.11**）；若 `CONFIG_APP_BLE_OTA` 未启用则写入返回 **Write Not Permitted**（ATT `0x03`） |
| 心跳/告警通道 | `0xFFE3` | Read、Notify | **心跳**（`message_type=1`，连接后首条 + **10s**）+ **停车态**（`message_type=3`，超时后 **300ms**，§4.4.1）+ **告警/错误**（`4`/`5`）；Read 为最近一次 Notify |
| OTA 确认 | `0xFFE4` | Read、Notify | **OTA 应答帧**（`OP_ACK`/`OP_NAK`，§3.4.2）；按帧字节 Notify，App 组帧后校验 **SEQ/CRC** |

### 3.3 控制通道（0xFFE1）写入语义

- 仅处理 **Write / Write Without Response**；不支持 Read、Notify、Indicate。
- 载荷为 **一段完整 JSON 字符串**（固件用 `cJSON_Parse` 解析）。**不支持跨多包 ATT 写自动拼帧**——若未在更高层自行拼包，请保证 **单次写入即完整 JSON**。
- 固件使用 **2049 字节** 缓冲（含预留 `'\0'`），有效 JSON 长度受 **2048** 限制。

### 3.4 OTA 通道（0xFFE2）与手机侧文件

#### 3.4.0 OTA 会话总流程（BLE / UART 共用逻辑）

手机 **每次连接** 发 **`message_type=6`** 后，设备 **回** `1002/query`（可用来确认串口已连通）；手机同意后进入二进制写入；结束后设备汇报结果。

```mermaid
sequenceDiagram
    participant App as 手机 App
    participant ESP as ESP32

    App->>ESP: message_type=6（串口/BLE 连接成功）
    ESP->>App: JSON head.message_type=1002 body.phase=query
    alt 用户同意升级
        App->>ESP: JSON head.message_type=0
        App->>ESP: 0x01 + 固件长度(4B LE)
        loop 固件数据
            App->>ESP: 0x02+数据 或裸字节
            ESP->>App: ACK/NAK 帧确认(BLE:0xFFE4 / UART:同线回写)
        end
        App->>ESP: 0x03
        Note over ESP: 校验、换分区、约 1s 后发结果再重启
        ESP->>App: JSON message_type=1002 body.phase=done
    else 拒绝或超时
        App->>App: 仅维持心跳与控制
    end
```

#### 3.4.0.1 固件版本号解析（Android OTA 必读）

##### 职责分工（易混，必读）

| 角色 | 行为 | 是否与版本相同有关 |
|------|------|-------------------|
| **设备（ESP32）** | 手机发 `message_type=6` 后，**每次必回** `1002` / `phase=query`（主动询问「是否要给我升级」） | **无关**——版本相同也会发 query |
| **手机（App）** | 收到 query 后解析 `body.version`，与 APK 内 `manifest.version` **比对** | **有关**——**仅当本地包版本高于设备当前版** 才回 `message_type=0` 同意 OTA |
| **版本相同** | 握手成功（已收到 `1002`），**不回 `0`**，**不推固件**，照常心跳/遥控 | — |
| **版本有变化** | 本地 `manifest.version` **>** `query.body.version` 时，提示用户（可选）后发 `0`，再推 OT 帧 | — |

> **纠正常见误解**  
> - ❌ 不是「两边版本一样，设备就不发升级询问」——设备**总会**在握手后发 `1002/query`。  
> - ❌ 不是「收到 `1002/query` 就必须回 `0` 升级」——**版本没变化就不要升级**。  
> - ✅ 正确：**设备负责问**，**App 负责比版本、有新版才答 `0`**。

设备与随包交付物使用**同一套语义版本号**（`X.Y.Z` 字符串，如 `"1.0.2"`），由固件编译期 `CONFIG_MY_APP_VERSION`（`menuconfig → Project Version`）写入，**OTA 出包后写入 `firmware/release/ota_manifest.json`**。Android **必须统一解析 `body.version`**，不要从日志行、`TAG:` 文本或其它字段猜版本。

##### 设备侧：三条 JSON 来源（路径相同）

| 场景 | `head.message_type` | `body` 关键字段 | `body.version` 含义 |
|------|---------------------|-----------------|---------------------|
| 握手 / OTA 询问 | **1002** | `phase` = **`"query"`** | **当前运行**固件版本（发 `6` 后立即收到） |
| OTA 结束汇报 | **1002** | `phase` = **`"done"`** | 升级**目标**版本（`ok=1` 时；失败时可能仍为旧版） |
| 周期心跳 | **1** | `voltage`、`device_ID` | **当前运行**固件版本（握手后首条 + 每 10s） |

**解析规则（Kotlin / 任意语言）**：

1. `cJSON`/JSON 解析成功后，读取 **`json["head"]["message_type"]`** 与 **`json["body"]`**（对象）。
2. 版本字段固定为 **`body["version"]`**，类型 **String**，格式 **`^\d+\.\d+\.\d+$`**（如 `1.0.2`）。
3. **`1002`** 须再根据 **`body["phase"]`** 分支：`query` = 询问/握手，`done` = OTA 结果。
4. **不要**把 `body.device_ID`（纯设备号，如 `CN110200000001`）或 OT 帧 `VER=0x02` 当作固件版本号。

**握手后收到的 1002/query 示例（当前运行版本）**：

```json
{
  "head": { "message_type": 1002 },
  "body": {
    "phase": "query",
    "device_ID": "CN110200000001",
    "version": "1.0.2"
  }
}
```

**OTA 成功后 1002/done 示例（目标版本）**：

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

##### 随包交付物：App 内置 OTA 包元数据

运行 `scripts\copy_firmware_release.bat`（须先 `idf.py build`）后，Android OTA 只取两个文件：

| 文件 | Android 用途 |
|------|----------------|
| **`ota/ESPRCCar.bin`** | 实际推送给设备的 OTA 镜像（≤ 6 MiB） |
| **`ota_manifest.json`** | **机器可读版本 JSON**：`version`、`image`、`bytes`、`sha256` |

**`ota_manifest.json` 示例**（版本号以出包时为准）：

```json
{
  "project": "ESPRCCar",
  "chip": "esp32s3",
  "version": "1.0.2",
  "generated_utc": "2026-06-04T07:27:56Z",
  "image": "ESPRCCar.bin",
  "bytes": 942848,
  "sha256": "7b0b13a6a92500fff7a1e9ccb127c896750720e69f9a686f63111e88be8c8aed"
}
```

**App 建议**：将 `ESPRCCar.bin` + `ota_manifest.json` 打进 APK，例如放在 `assets/ota/` 下；OTA 前读取本地 `version` 与设备 `body.version` 比对，并用 `bytes` / `sha256` 校验 APK 内固件文件完整性。

##### Android 统一解析与 OTA 判定（参考实现）

```kotlin
/** 从设备 JSON 行提取固件版本；无法解析返回 null */
fun parseDeviceFirmwareVersion(json: JSONObject): String? {
    val body = json.optJSONObject("body") ?: return null
    val ver = body.optString("version", "").trim()
    if (!ver.matches(Regex("""\d+\.\d+\.\d+"""))) return null
    return ver
}

/** 从随包 manifest 读取待推送 OTA 版本与校验信息 */
data class OtaBundleMeta(
    val version: String,
    val imageBytes: Long,
    val sha256: String,
)

fun loadOtaBundleMeta(context: Context): OtaBundleMeta {
    val raw = context.assets.open("ota/ota_manifest.json").bufferedReader().readText()
    val root = JSONObject(raw)
    return OtaBundleMeta(
        version = root.getString("version"),
        imageBytes = root.getLong("bytes"),
        sha256 = root.getString("sha256").lowercase(),
    )
}

/** 语义化版本比较：1=本地新, 0=相同, -1=设备新 */
fun compareSemVer(a: String, b: String): Int {
    val pa = a.split(".").map { it.toInt() }
    val pb = b.split(".").map { it.toInt() }
    for (i in 0 until 3) {
        val d = pa[i] - pb[i]
        if (d != 0) return d.compareTo(0)
    }
    return 0
}

/** 收到设备 1002/query 后：仅版本有变化（App 包更新）才应同意 OTA */
fun shouldStartOta(deviceVersion: String, bundleVersion: String): Boolean {
    return compareSemVer(bundleVersion, deviceVersion) > 0
}

/** 处理 1002/query：握手成功与是否升级分开 */
fun onDeviceOtaQuery(json: JSONObject, bundle: OtaBundleMeta) {
  val deviceVer = parseDeviceFirmwareVersion(json) ?: return
  markLinkHandshakeOk()  // 收到 1002 即双向连通，与是否升级无关
  if (!shouldStartOta(deviceVer, bundle.version)) {
    // 版本相同或 App 包更旧：不回 message_type=0，不推固件
    return
  }
  // 可选：弹窗确认后
  sendMessageType0AgreeOta()
}
```

**推荐 OTA 流程（UART / BLE 相同）**：

```mermaid
flowchart TD
    A[串口 open / BLE 连接] --> B[发 message_type=6]
    B --> C{3s 内收到 1002/query?}
    C -->|否| Z[显示未连接]
    C -->|是| HOK[握手成功 UI 可显示已连接]
    HOK --> D[解析 query.body.version]
    D --> E[读 manifest.version]
    E --> F{本地版 > 设备版?}
    F -->|否 版本相同或更旧| G[不回 0 / 不 OTA / 正常控制心跳]
    F -->|是 版本有变化| H[发 message_type=0 同意]
    H --> I[OT 帧推送 ESPRCCar.bin]
    I --> J{收到 1002/done ok=1?}
    J -->|是| K[重启后重连 再发 6]
    K --> L[心跳 body.version == manifest.version?]
    L -->|是| M[OTA 成功]
    L -->|否| N[可能回滚]
    J -->|否| N
```

| 阶段 | 比对对象 | 判定 |
|------|----------|------|
| 握手后 | 是否收到 `1002/query` | 收到 → **已连接**（与是否升级无关） |
| 是否升级 | `ota_manifest.version` vs `query.body.version` | 本地 **>** 设备 → **才**发 `0` 并 OTA；**相等** → **禁止**发 `0` |
| 传输前 | `ESPRCCar.bin` 文件长度 vs `ota_manifest.bytes` | 不一致则不要用该包 |
| 传输前（可选） | 本地文件 SHA256 vs `ota_manifest.sha256` | 校验 APK 内镜像完整性 |
| 重启后 | `心跳.body.version` 或再次 `1002/query.body.version` | **等于** `manifest.version` → 成功 |

**注意**：

- `1002/done` 的 `body.version` 表示**写入目标**，最终以重启后 **`message_type=1` 心跳**里的 `body.version` 为准。
- `written` 为已写入字节数，成功时应等于 `ota_manifest.bytes`。
- 文档文件名随版本复制为 `firmware/release/Android端设备对接文档_vX.Y.Z.md`（与 `manifest.version` 对齐）。

**① 设备 → 手机：询问（`message_type = 1002`）**

- **语义**：设备**主动请求升级询问**（「我当前是 `body.version`，你要不要给我刷新版？」）。**不表示** App 必须立刻升级。
- **触发条件**：手机经 **0xFFE1** / UART1 发送 **`message_type=6`**（§4.2.5，链路就绪）后，设备 **立即** 回询问 JSON（**不会**在未收到 `6` 前主动推送；**与设备/App 版本是否相同无关**，每次握手都会回）。
- **App 响应**：解析 `body.version` 后，**仅当**随包 `manifest.version` **更高** 时发 **`message_type=0`**（§3.4.0.1）；否则**静默跳过 OTA**，握手仍视为成功。
- **BLE**：经 **0xFFE3** Notify（与心跳同特征值）；**每次** App 发 `6` 都会回 `1002`（通常每连接发一次 `6`）。
- **UART**：每次 **`UsbSerialPort.open` 成功** 并发 `6` + `\n` 后，UART1 回一行 `1002` + `\n`；**收到 `1002` 即表示串口已与设备双向连通**。

```json
{
  "head": { "message_type": 1002 },
  "body": {
    "phase": "query",
    "device_ID": "CN110200000001",
    "version": "1.0.1"
  }
}
```

**② 手机 → 设备：同意开始 OTA（`message_type = 0`）**

- **BLE**：Write **0xFFE1** 完整 JSON。
- **UART**：**单独一行** JSON + `\n`（与 OT 帧 **分行**；可同一次 `write` 但中间必须有 `\n`）。
- 收到后固件置位 **armed**，才接受 **OT 帧** / 裸 `0x01`～`0x03`。
- **`body` 可为空对象 `{}`**。
- **错误示例**：`{"head":{"message_type":0},...}\n` 与 OT 帧 **无换行粘连** 或 OT 紧跟在同一 JSON 解析缓冲内 → 固件报「尚未收到 0」或忽略 OT。

```json
{ "head": { "message_type": 0 }, "body": {} }
```

> **注意**：`message_type = 0` **仅表示同意 OTA**。**不是**心跳，也 **不是** 遥控重启（重启为手机→设备 **`message_type = 1`**，§4.2）。

**③ 二进制固件**：见 §3.4.2～3.4.3；须先完成步骤 ②。

**④ 设备 → 手机：结果（`message_type` 仍为 `1002`，只改 `body`）**

```json
{
  "head": { "message_type": 1002 },
  "body": {
    "phase": "done",
    "ok": 1,
    "device_ID": "CN110200000001",
    "version": "1.0.1",
    "written": 1048576
  }
}
```

失败时 `ok` 为 `0`，可选 `err` 字符串（如 `incomplete`、`write`）。成功后约 **1s** 设备重启；重连后心跳 `body.version` 应为目标版本。

#### 3.4.1 应使用哪个文件

- 使用与本工程 **相同分区表**、**同一芯片与 Flash 配置** 下 `idf.py build` 生成的 **应用镜像**即可。
- 本仓库 CMake `project(ESPRCCar)`。  
  - **从 Git 下载（推荐联调/量产）**：**`firmware/release/ota/ESPRCCar.bin`**（见 `firmware/README.md`）  
  - **本地刚编译**：**`build/ESPRCCar.bin`**  
  即 **待写入 OTA 分区的纯固件镜像**（不是整片 `merged.bin` / 不是带 Bootloader 的合并包）。
- 镜像 **字节长度**必须 **不大于** OTA 应用分区大小。当前 `partitions.csv` 中 `ota_0` / `ota_1` 各为 **0x600000（6 MiB）**，且需 **16 MB Flash**（见 `sdkconfig.defaults`）。

#### 3.4.2 OTA 帧协议（推荐，`ota_frame_protocol`）

所有 OTA 二进制经 **0xFFE2**（或 UART）发送 **完整帧**；实现见 `main/protocol/ota_frame_protocol.c`。

**帧结构**

| 偏移 | 字段 | 说明 |
|------|------|------|
| 0～1 | HEAD | `0x4F 0x54`（`'O''T'`） |
| 2 | VER | `0x02` |
| 3 | OP | `0x01` BEGIN / `0x02` DATA / `0x03` END / `0x80` ACK / `0x81` NAK |
| 4～5 | SEQ | `uint16` LE，**重传须保持同一 SEQ** |
| 6～7 | LEN | `uint16` LE，PAYLOAD 长度 |
| 8… | PAYLOAD | BEGIN=4B 镜像长度 LE；DATA=固件字节（**建议每帧 1B**）；END=空 |
| +0 | CRC8 | 从 VER 到 PAYLOAD 末 **XOR** |
| +1 | TAIL | `0x16` |

**DATA 应答 ACK**（`OP=0x80`，SEQ 与请求相同）：PAYLOAD **`[data_byte][status]`**，`status==0` 成功，否则 **原帧重传**。  
**BEGIN/END 应答**：PAYLOAD **`[status]`** 仅 1 字节。  
**NAK**（`OP=0x81`）：CRC/长度错误等。

**Android**：从 **0xFFE4** 组满一整帧再校验；`status!=0` 或 `data_byte` 不符 → **重传**。

#### 3.4.3 旧版裸 opcode（兼容）

无 `OT` 头、首字节为 `0x01/0x02/0x03` 时仍支持，DATA 为单字节回显。新 App 请用 §3.4.2。

- 未 **message_type=0** 前写 **0xFFE2** → Write Not Permitted。  
- 建议 **WRITE_TYPE_DEFAULT**；应答以 **0xFFE4** 组帧为准。

#### 3.4.4 与 menuconfig 的关系

- **`CONFIG_APP_BLE_OTA=y`**（默认）：允许 **0xFFE2** OTA，并推送 **0xFFE4** 状态。  
- 关闭 **`APP_BLE_OTA`**：**0xFFE2** 一律 **Write Not Permitted**；**0xFFE4** 仍可发现，但 OTA 流程不会产生状态 Notify。

#### 3.4.5 会话状态机（必读）

固件在 `link_ble.c` 中维护 **单次 OTA 会话**，规则如下：

| 规则 | 说明 |
|------|------|
| 顺序 | 必须先 **`0x01`** 成功，再发若干 **`0x02`**，最后 **`0x03`** |
| 新会话 | 再次写入 **`0x01`** 会 **中止上一会话**（`esp_ota_abort`）并重新开始 |
| `0x02` 前置 | 未 `begin` 就发 `0x02` / `0x03` → ATT **`0x0E`（UNLIKELY）** |
| 长度一致 | 所有 `0x02` 载荷之和 **必须等于** `0x01` 声明的 `image_size`，否则 `0x03` 返回 **`incomplete`** |
| 溢出 | 累计写入超过声明长度 → **`overflow`**，会话被中止，需重新 `0x01` |
| `0x02` 空数据 | 仅 opcode、无数据字节 → **`0x0E`（UNLIKELY）** |
| 结束重启 | `0x03` 成功：`esp_ota_end` → `esp_ota_set_boot_partition` → 约 **1 秒** 后自动重启 |
| 断开连接 | BLE 断开会自动清空会话；UART 在 RX 超时/链路清空时会中止未完成 OTA。App 重连后需重新 `6 → 0 → BEGIN` 全量重传 |

目标分区：`esp_ota_get_next_update_partition(NULL)`（`ota_0` / `ota_1` 双分区交替写入，与 USB 烧录分区表一致）。

**OTA 支持模式**：BLE / UART 共用 **§3.4.2 帧协议**。

| 传输模式 | 询问/结果 JSON | 固件通道 | 应答 |
|----------|----------------|----------|------|
| BLE | Notify **0xFFE3**（1002） | Write **0xFFE2** | Notify **0xFFE4**（ACK/NAK 帧） |
| UART | UART1 JSON 行（1002） | UART1 写帧 | UART1 读 ACK/NAK 帧 |

**OTA 失败回退机制**：
- 固件内置 OTA 状态管理器，启动时自动检测上次 OTA 是否成功
- 如果新固件连续启动失败（最多3次），自动回退到上一版本
- 心跳 JSON 中 `body.version` 字段可用于确认当前运行版本

#### 3.4.6 单包字节布局（示例）

设固件文件 `ESPRCCar.bin` 大小为 **1,048,576**（0x00100000）字节：

**① 开始（5 字节）**

| 偏移 | 值 | 含义 |
|------|-----|------|
| 0 | `0x01` | begin |
| 1～4 | `00 00 10 00` | `image_size` 小端 = 1048576 |

**② 数据（1 + N 字节，N ≤ 511）**

| 偏移 | 值 | 含义 |
|------|-----|------|
| 0 | `0x02` | chunk |
| 1～N | 固件原始字节 | 按文件顺序，**不可跳变、不可重复计数** |

**③ 结束（1 字节）**

| 偏移 | 值 | 含义 |
|------|-----|------|
| 0 | `0x03` | end（无后续字节；多带字节会被忽略） |

#### 3.4.7 MTU 与每包数据长度

固件单包处理缓冲 **512 字节**（含 opcode），因此：

- **`0x02` 最大数据长度** = `min(511, ATT有效载荷 - 1)`
- **ATT 有效载荷** ≈ `negotiatedMtu - 3`（ATT 头 3 字节）

| 协商 MTU | 约等于每包固件数据 | 约需 `0x02` 包数（1MiB 镜像） |
|----------|-------------------|------------------------------|
| 23（默认） | 19 字节 | ~55000（极慢，务必协商 MTU） |
| 247 | 243 字节 | ~4300 |
| 517 | 511 字节 | ~2050 |

**Android 建议**：

1. `connectGatt` 后 `requestMtu(517)`，在 `onMtuChanged` 里用 **实际 MTU** 计算 `chunkSize = min(511, mtu - 4)`。  
2. OTA 阶段使用 **`WRITE_TYPE_DEFAULT`（带响应）**，在 `onCharacteristicWrite` 收到 **GATT_SUCCESS** 后再发下一包 `0x02`。  
3. 进度条用 **已确认 DATA 帧字节数 / 文件总长度**；**0xFFE4** 返回的是 ACK/NAK 帧字节流，需组帧解析后再累计进度，勿按单字节回显累计。

#### 3.4.8 ATT 错误码（写 0xFFE2 失败时）

| ATT 码 | 宏名（常见） | 典型原因 |
|--------|--------------|----------|
| `0x03` | Write Not Permitted | `CONFIG_APP_BLE_OTA=n` |
| `0x0D` | Invalid Attribute Value Length | `0x01` 长度字段不全；`0x02` 导致累计超长 |
| `0x0E` | Unlikely | 未 begin 就 chunk/end；`0x02` 无数据；Flash 写入失败；`esp_ota_begin/end` 失败等 |

App 在 `onCharacteristicWrite(status != GATT_SUCCESS)` 时应 **停止发送** 并提示用户；失败原因以 **步骤 ④ `1002` + `ok:0` + `err`** 或 ATT 码为准。

#### 3.4.9 0xFFE4 与 OTA 结果 JSON（当前固件）

| 通道 | 内容 | 何时出现 |
|------|------|----------|
| **0xFFE4** Notify | **ACK/NAK 帧**（§3.4.2，按字节拆分 Notify） | 每处理一帧请求 |
| **0xFFE3** Notify / UART 行 | **`message_type=1002`**，`body.phase=done` | END 成功或失败中止后 |

旧版「单字节回显」仅在不带 `OT` 头的兼容模式出现。`{"ota":"begin"...}` **不再** 由 0xFFE4 推送。

#### 3.4.10 OTA 测试前置（固件侧）

1. `menuconfig` → **链路模式 = BLE**，**启用 BLE GATT OTA**（`APP_BLE_OTA=y`）。  
2. `idf.py build` 生成待测镜像：**`build/ESPRCCar.bin`**（非 `flash_project_args` 整片包）。  
3. 确认 `.bin` 大小 **≤ 6 MiB**（`partitions.csv` 中 `ota_0`/`ota_1` 各 `0x600000`）。  
4. 设备已配网：`device_id`、`ble_adv_name` 非空，能扫描到 `*MP` 广播名。  
5. **首次 USB 烧录** 与 **OTA 包** 须为 **同一分区表、同一芯片目标（esp32s3）**；否则可能 `esp_ota_end` 失败或启动异常。  
6. App 已实现：连接后 **先发 `message_type=6`**，收到 **1002/query** 后再决定是否回 **0**，再发 OT 帧 **BEGIN/DATA/END**。

#### 3.4.11 常见失败与排查

| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 写 0xFFE2 立即 `0x03` | 未开 `APP_BLE_OTA` | 重编固件并确认 `sdkconfig` |
| `begin` / `esp_ota_begin` | 镜像大于 OTA 分区、分区表不匹配 | 换小镜像或改 `partitions.csv` 后全量重烧 |
| `chunk` / `overflow` | 多发数据或 `image_size` 填错 | 重新 `0x01`，严格按文件大小 |
| `end` / `incomplete` | 少发字节就 `0x03` | 核对 `written` 与文件 MD5/长度 |
| `end` / `esp_ota_end` | 镜像损坏、非合法 app bin | 确认是 `ESPRCCar.bin` 应用镜像 |
| OTA 后版本未变 | OTA 失败、回退或设备重启未连上同一设备 | 读心跳 `body.version` 与 `manifest.version` 比对（§3.4.0.1）；检查 `1002/done` 的 `err`/`written` |
| 传输极慢 | MTU 仍为 23 | `requestMtu` 并等 `onMtuChanged` 后再传 |
| 写 0xFFE2 `0x03` 拒绝 | 未先回 **message_type=0** | 先 Write **0xFFE1** 同意 JSON |
| 收不到 0xFFE4 回显 | 未订阅 0xFFE4 CCCD | 开启 Notify 后再发固件 |
| 中途断连 | 距离/干扰 | 靠近设备；断连后需重新连接、重新 **0** + **0x01** 全量 |

### 3.5 心跳 / 告警通道（0xFFE3）Notify

- **属性**：Read + Notify（手机须对 **0xFFE3** 写 CCCD 开启 Notify）。
- **两类载荷共用本特征值**（通过 `head.message_type` 区分；Android 在 `onCharacteristicChanged` 中分支解析）：

#### A. 周期心跳（设备 → 手机；连接成功立即首条）

- 生成逻辑：`heart_payload_json_malloc()`（`main/protocol/heart_payload.c`）。
- `head.message_type` = **1**
- `body`（**BLE 不含 `ip`**；MQTT 仍带 STA `ip`）：
  - **`voltage`**：两位小数字符串，电压值（见下方电压计算说明）
  - **`device_ID`**：纯设备号，如 `CN110200000001`
  - **`version`**：`CONFIG_MY_APP_VERSION`（当前运行固件版本，用于判断OTA是否成功）
- **周期**：UART **3000 ms**（`UART_HEART_PERIOD_MS`），BLE **10000 ms**（`BLE_HEART_PERIOD_MS`）。
- **首条时机**：BLE **GATT 连接成功**后立即 Notify 一条；UART **收到 `message_type=6` 握手后**立即发一条（与 `1002/query` 同轮），之后 UART 每 3s 一条。用于 App 尽快拿到电压、设备号、版本判断在线。

**示例：**

```json
{
  "head": { "message_type": 1 },
  "body": {
    "voltage": "12.34",
    "device_ID": "CN110200000001",
    "version": "1.0.0"
  }
}
```

**电压计算说明**：

固件通过 **GPIO5** 的 ADC 采样，使用分压电阻（上拉100kΩ + 下拉10kΩ）测量电池电压。

计算公式：
```
V电池 = VADC × 分压系数
分压系数 = (R上拉 + R下拉) / R下拉 = (100k + 10k) / 10k = 11

因此：V电池 = ADC读数 × 11
```

- 电池电压范围：支持约 0~36V（受限于ESP32-S3 ADC输入范围和分压比）
- 测量精度：约 ±0.1V（受ADC精度和分压电阻精度影响）

**OTA 版本确认**：
- 心跳中的 `body.version` 字段表示当前运行的固件版本
- OTA 成功后设备重启，重新连接后读取心跳中的 `version`，应与目标版本一致
- 如果版本未变化，可能是 OTA 失败或自动回退到上一版本

#### B. 告警 / 错误（设备 → 手机，**出现时立即另发一条**，与心跳无关）

- **仅 Release 构建**且 BLE 已连接时，固件对 `ESP_LOGW` / `ESP_LOGE` 触发即时 Notify（不向 UART 打印）。
- `head.message_type` = **4**（Warning）或 **5**（Error）
- `body`：**仅**字段 **`msg`**（字符串，为日志正文，通常为 `TAG: 说明`）

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

```json
{
  "head": { "message_type": 4 },
  "body": { "msg": "LINK_BLE: heartbeat notify rc=8" }
}
```

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

```json
{
  "head": { "message_type": 5 },
  "body": { "msg": "DRV_MGR: init failed" }
}
```

> **与 §4.4 区分**：**手机 → 设备** 写入 **0xFFE1** 时 `message_type = 4` 表示 **GPIO `pin_setctrl`**；**设备 → 手机** 经 **0xFFE3 Notify** 的 `message_type = 4/5` 表示 **告警/错误日志**，方向与特征值均不同。

- **Read**：返回**最近一次**经 0xFFE3 Notify 的 JSON（可能是心跳，也可能是告警/错误）。
- **离线**：未连接期间不缓存日志。

### 3.6 OTA 确认通道（0xFFE4）

- **属性**：Read + Notify。OTA 进行中 Notify 为 **单字节二进制**（非 JSON）。
- **与 0xFFE3 区分**：
  - **0xFFE3**：UTF-8 JSON — 心跳(1)、告警(4)、错误(5)、**OTA 询问/结果(1002)**。
  - **0xFFE4**：仅 **ACK/NAK 帧字节流**；`onCharacteristicChanged` 中 **按 UUID 分支**，0xFFE4 不要用 `JSONObject` 解析。
- **进度**：组出 ACK 帧且 `status==0` 后，按该 DATA 帧确认的固件字节数累计；总进度 = `confirmedBytes / fileSize`。
- **会话结果**：以 **0xFFE3** 上 `message_type=1002` 且 `phase=done` 为准（§3.4.0 ④）。

---

## 4. 应用层 JSON 协议（写入 0xFFE1 / UART 行）

解析入口：`remote_control_apply_json()` / `ota_offer_parse_phone_response()`（`main/protocol/remote_control.c`、`ota_offer_protocol.c`）。

### 4.0 `head.message_type` 速查（易混项）

| 值 | 典型方向 | 通道 | 含义 |
|----|----------|------|------|
| **0** | 手机 → 设备 | FFE1 / UART | **同意开始 OTA**（随后才能写 0xFFE2 或 UART 二进制） |
| **1** | 设备 → 手机 | FFE3 Notify / UART | **周期心跳**（`body.version` 等） |
| **1** | 手机 → 设备 | FFE1 / UART | **远程重启**（仅 `head`，无 `body`） |
| **2** | 手机 → 设备 | FFE1 / UART | **链路清空**（中止 OTA、停车、丢弃武装；BLE 另清本地缓冲） |
| **6** | 手机 → 设备 | FFE1 / UART | **链路就绪**（串口 open / BLE 连接成功）；设备 **回 `1002`/query**（§4.2.5） |
| **3** | 手机 → 设备 | FFE1 / UART | PWM `pwm_ctrl`（含 **停车专用**，§4.4.1） |
| **4** | 手机 → 设备 | FFE1 / UART | GPIO `pin_setctrl` |
| **4** / **5** | 设备 → 手机 | FFE3 Notify | 告警 / 错误（Release，§3.5-B） |
| **1002** | 设备 → 手机 | FFE3 / UART | OTA **询问**（`phase=query`）或 **结果**（`phase=done`） |

### 4.1 基本结构

必须包含顶层对象 **`head`**，且为 JSON **对象**；否则：

- **仅 WiFi 构建**（`CONFIG_APP_LINK_WIFI`）：会走 MQTT/OTA 相关分支。
- **仅 BLE 构建**：**静默丢弃**（不执行控制）。

有效消息需满足：

- `head.message_type` 为 **数字**
- **`message_type == 1`（远程重启）**：仅需合法 **`head`**，**不要求**顶层 **`body`**；解析成功后固件立即 `esp_restart()`（与设备型号无关；**MQTT 与 BLE 写入同一套 JSON 时同样生效**）。
- **`message_type == 3` / `4`**：须存在顶层 **`body`**（类型未强制校验为 object，但下文字段均按 object 使用）。

> **与 §3.5 心跳区分**：设备经 **0xFFE3 Notify** 下发的业务心跳里 `message_type` 也为 **1**，那是**设备→手机**方向；此处 **1** 指**手机/云端→设备**写入 **0xFFE1**（或 MQTT 载荷）时的**控制语义**：收到即重启。

### 4.2 `message_type == 2`：链路清空（卡顿 / 积压恢复）

- **行为**：中止进行中的 **OTA 二进制会话**、取消 **OTA 武装**（`message_type=0` 状态）、**立即停车**，并清空固件侧重放相关缓冲（BLE 含 **0xFFE1** 解析缓冲）。
- **不重启**设备；**不**重置「本连接周期是否已发过 OTA 询问」的标记（仍按 §3.4 开机/连接策略）。
- **BLE 断开**时固件也会自动执行同等清空。

**手机 → 设备示例（写 0xFFE1）：**

```json
{ "head": { "message_type": 2 }, "body": {} }
```

**Android 侧卡顿建议（固件无法替 App 清空 GATT 写队列）：**

1. **先**向 **0xFFE1** 写入上表 JSON（`message_type=2`），再停止向 **0xFFE2** 写 OTA 数据。
2. `setCharacteristicNotification(FFE3/FFE4, false)` 后重新 `true`，丢弃旧 Notify 回调里未处理的缓存。
3. 仍卡顿时 **`disconnect()` → 延时 300～500ms → `connectGatt()`**（最干净）；重连后 **再次 Write `message_type=6`**，等 **1002/query**。
4. OTA 过程中避免无节流连续 `writeCharacteristic`；卡顿时不要继续灌 **0xFFE2**。

**发射功率**：贴机默认 **`APP_BLE_TX_POWER_N24`（-24 dBm）**；固件对广播/扫描/连接统一设档，连接成功后再设一次。接收侧见 **§1.1.1**。

### 4.2.5 `message_type == 6`：链路就绪（串口 / BLE 连接成功）【必读】

Android **无法**从 `UsbSerialPort.open()` 或 USB 权限 alone 判断数据是否已到 ESP32（可能只连上 FT232 转接板、线未接、波特率错误等）。因此约定：

1. **每次**串口 `open` 成功并启动读线程后，App **主动发一条** `message_type=6` + `\n`（或 BLE 写入 **0xFFE1**）。
2. 设备收到后 **立即** 回 `message_type=1002`、`body.phase=query`（OTA 版本询问，同时充当 **握手应答**）。
3. App **收到 `1002`** → UI 显示「已连接」；对比 `body.version` 决定是否再发 `message_type=0` 升级。
4. **每次重新 open 串口 / BLE 重连** 都要 **再发一次 `6`**（不要沿用上次的「已连接」状态）。

**手机 → 设备示例（UART 行尾 `\n`；BLE 写 0xFFE1）：**

```json
{
  "head": { "message_type": 6 },
  "body": { "status": "connected" }
}
```

| 字段 | 说明 |
|------|------|
| `body.status` | 建议固定 `"connected"`（固件不校验，可省略 `body` 或 `{}`） |
| 设备响应 | 一行 `1002` / `phase=query`（含 `device_ID`、`version`） |
| 超时建议 | 发 `6` 后 **3s** 内未收到 `1002` → 提示「串口未连通 / 检查接线与 **App 设置波特率是否与固件一致**」（§7.2.0） |

> **与心跳区分**：设备仍会周期发 `message_type=1` 心跳，但 **单向**——仅收心跳 **不能** 证明 App 写入已被设备收到；**必须以「发 `6` → 收 `1002`」为在线依据**。

> **固件不会过滤「重复发 6」**：同一连接周期内 App 多次发 `6`，设备 **每次都会** 再回一行 `1002/query`（无「只答一次」限制）。**过滤/去重由 App 自行处理**（例如已收到 `1002` 则不再弹窗）。

### 4.3 `message_type == 1`：远程重启

- **行为**：校验 JSON 含对象 **`head`** 且 `head.message_type` 为数字 **1** 后，**立即重启**（不解析 `body`）。
- **用途**：全型号统一远程复位；请谨慎使用（无额外鉴权时，能发 JSON 的客户端均可触发）。

**示例：**

```json
{
  "head": {
    "message_type": 1
  }
}
```

### 4.4 `message_type == 3`：PWM 控制

`body` 内对象键：**`pwm_ctrl`**（对象）。

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mode` | number | 是 | 控制模式，见 4.6 节设备映射 |
| `val` | number | 是 | 控制量，与 `mode` 联合解释 |
| `type` | number | 否 | 固件读取；若缺省或非数字，按 **0** 处理（当前映射逻辑未使用 `type` 分支） |
| `speed` | number | 否 | 与 `direction` 同时存在且均为 **0** 时走 **停车专用**（§4.4.1） |
| `direction` | number | 否 | 同上 |

#### 4.4.1 停车专用 JSON（防断连 / 松手保持）

满足以下**任一**条件时，固件**只执行停车**（`rc_vehicle_stop()`），不按 `mode/val` 驱动：

1. **推荐**：`pwm_ctrl.speed == 0` 且 `pwm_ctrl.direction == 0`（建议同时 `mode:0`、`val:0`）。  
2. **兼容**：`pwm_ctrl.mode == 0` 且 `pwm_ctrl.val == 0`（即使带 `type` 字段也视为停车）。

**手机 → 设备（写 0xFFE1 / UART1）**：

- **任意相邻两条 JSON**（含控制、握手、OTA 同意等）间隔须 **≥100ms**，避免串口/USB 积压与固件解析不及（UART 线长与间隔见 **§7.2.3**）。
- 用户松手或 UI 进入停止态时，**必须每 300ms 重复写入** **全位 0** 停车 JSON（不要只发一次）；多键组合见 **§4.4.2**。
- 正常摇杆控制：建议 **每 100～200ms** 一发；**不得超过 500ms** 无控制，否则约 **500ms** 后固件会判超时并自行停车。

**全位 0 停车 JSON（与固件 `remote_control_stop_json_malloc()` 一致，推荐紧凑一行）**：

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed":0,"direction":0,"mode":0,"val":0}}}
```

（格式化写法同上字段，四键 **必须均为 0**。）

#### 4.4.2 多键同时松开（前进 + 左转等）

协议规定：**每条** `message_type=3` 的 JSON 里只有 **一个** `pwm_ctrl`；固件每次只执行 **一种** `mode`（`rc_vehicle_control`）。因此「前进 + 左转」时，App 通常有两种写法：

| App 实现 | 组合按下时（例：前进+左转） | **全部方向键都已抬起** 时 |
|----------|---------------------------|---------------------------|
| **1102 组合 mode**（推荐） | 发 `mode=5`（前左），`val` 为油门/转向量（见 `device_1102.c`） | **必须**发 §4.4.1 **全位 0** 停车 JSON |
| **分键轮询** | 100～200ms 间交替发 `mode=1`（前进）与 `mode=3`（左转）等 | **禁止**只停一侧；**必须**发 **全位 0** 停车 JSON |

**规则（必读）**：

1. 维护「当前仍按下的方向键」集合；**仅当集合变为空**（例如前进、左转 **一起松开**）时，进入停车态。
2. 停车态下 **只发** 全位 0 JSON（上表一行），并 **每 300ms** 重复，**不要**再发 `mode=1,val:0` 或 `mode=3,val:0` 等「单通道清零」——上一帧可能仍保持电机或舵机状态。
3. 若 **仍有一个键按着**（只抬左转、前进仍按）：可继续发前进对应的 `mode=1` JSON；**不是**全松场景。

**错误示例**（多键已全松仍动车 / 舵机不归中）：

| 错误写法 | 后果 |
|----------|------|
| 只发 `mode=0,val=0`（无 `speed`/`direction`） | 可能走兼容停车分支，组合控制后 **不可靠** |
| 全松后仍发 `mode=1,val:0` 想停电机 | 舵机可能保持左转角 |
| 全松后仍发 `mode=3,val:0` 想回中 | 电机可能仍保持前进占空比 |
| 全松后 **只发一次** 全位 0 就停发 | 约 500ms 后固件再次超时停车，体验不一致 |

**Kotlin（方向键集合 → 全松发停车）**：

```kotlin
/** 与固件 remote_control_stop_json_malloc() 一致 */
const val STOP_ALL_ZERO_JSON =
    """{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed":0,"direction":0,"mode":0,"val":0}}}"""

private val pressedDirections = mutableSetOf<String>() // 如 "forward", "left"

fun onDirectionDown(id: String) { pressedDirections.add(id) }

fun onDirectionUp(id: String) {
    pressedDirections.remove(id)
    if (pressedDirections.isEmpty()) {
        startStopRepeatEvery300ms() // 内部写 STOP_ALL_ZERO_JSON + "\n"
    }
}
```

> **1102 组合 mode 速查**：`5`=前左、`6`=前右、`7`=后左、`8`=后右；单键 `1`～`4` 为前进/后退/左转/右转。无论用组合还是分键，**全松** 均用同一条全位 0 JSON。

**设备 → 手机（0xFFE3 Notify）**：若 **500ms** 内未收到任何控制 JSON，固件自动停车，并 **每 300ms** 经 **0xFFE3** Notify **同结构** 停车 JSON（`message_type` 仍为 **3**），直至再次收到控制。App 在 `onCharacteristicChanged` 中应对 **1**（心跳）与 **3**（停车态）分支解析。

**遥控超时**：`RC_CMD_TIMEOUT_MS = **500**`（原 App 侧若按 600ms 设计，请改为 ≤500ms 与固件一致）。

**UART 模式补充（固件 v1.0.1+）**：

- 设备→手机的停车 JSON **仅在对端 UART1 曾有 RX 且最近约 1.2s 内仍有字节** 时发送；**拔线/无数据** 后约 **1.2s 内停推**（至多约 3～4 条，间隔 300ms）。
- **从未插线/未发 `6`**：不启动周期停车推送；上电不会因看门狗误刷串口。
- 心跳仍按「曾 RX + 15s 窗口」发送（允许 App 只收不发）。

### 4.5 `message_type == 4`：GPIO 脉冲/shot

`body` 内对象键：**`pin_setctrl`**（对象）。

| 字段 | 类型 | 说明 |
|------|------|------|
| `pin` | number | 引脚号 |
| `val` | number | 电平/值 |

**注意**：当前默认设备策略 **1101 / 1201** 的 `shot` 实现为 **空操作**（`device_1101.c` / `device_1201.c` 中 `(void)pin; (void)val;`）。若未来某型号实现 `shot`，协议形状已预留。

### 4.6 `pwm_ctrl.mode` 与 `val`（停车见 §4.4.1、多键全松见 §4.4.2）

**1101 / 1201** 在固件中 **control 映射一致**（见 `device_1101.c` 注释：复用 1201 映射）。

| `mode` | 含义（概括） | `val` 行为摘要 |
|--------|----------------|----------------|
| `1` | 电机 A 侧（与 B 互斥） | `val < 50`：双电机 0%；否则电机 A 占空按 `val/2 - 10`，B 为 0 |
| `2` | 电机 B 侧（与 A 互斥） | `val < 50`：双电机 0%；否则电机 B 占空按 `val/2 - 10`，A 为 0 |
| `3` | 舵机角度（一个方向） | `val < 45`：90°；`val < 70`：`50 + val + 7` 度；否则 135° |
| `4` | 舵机角度（另一方向） | `val < 45`：90°；`val < 70`：`130 - val - 7` 度；否则 45° |

**1102**（`device_1102.c`）另支持 **组合 mode**（单条 JSON 同时驱动前进/后退与转向，见固件 `device_1102_control`）：

| `mode` | 含义 |
|--------|------|
| `1`～`4` | 前进 / 后退 / 左转 / 右转（单动作） |
| `5` | 前左 |
| `6` | 前右 |
| `7` | 后左 |
| `8` | 后右 |

> 1102 用 `mode=5` 表示「前进+左转」时，**全部键抬起** 仍须 §4.4.2 **全位 0** 停车 JSON，不能只发 `mode=5,val:0`。

PWM 控制底座为 `rc_pwm_control`（统一 50Hz），引脚定义：

- 驱动芯片1：`IO10`、`IO21`
- 驱动芯片2：`IO11`、`IO12`
- AUX：`IO15`、`IO16`（通过 `menuconfig` 选择舵机/电调）

`IO15/IO16` 初始化：
- 若设为舵机：90 度（1500us）
- 若设为电调：1500us 中位

> 注：当前 `device_1101` / `device_1201` 的 `mode=1/2` 主要使用 `IO10/IO11` 输出，`IO21/IO12` 在底座层已初始化并预留给后续车型策略。

### 4.7 JSON 示例

**PWM（例如左电机通道，mode=1）：**

```json
{
  "head": {
    "message_type": 3
  },
  "body": {
    "pwm_ctrl": {
      "mode": 1,
      "type": 0,
      "val": 80
    }
  }
}
```

**GPIO 协议形状（当前 1101/1201 无实际 shot 效果）：**

```json
{
  "head": {
    "message_type": 4
  },
  "body": {
    "pin_setctrl": {
      "pin": 10,
      "val": 1
    }
  }
}
```

### 4.8 错误与静默失败

- JSON 无法解析（`cJSON_Parse` 失败）：**直接返回**，无错误码回传（BLE GATT Write Success 仍可能返回，属应用层协议问题）。
- 缺少 `head` / `message_type` 非数字 / 缺 `body`：**不执行控制**（BLE 构建下无其它副作用）。

---

## 5. Android 实现要点

> **给 Android 同事**：请优先阅读 **§5.0**（含必做时序、JSON 模板、OTA 组帧 Kotlin），再对照 §3 / §4 细节与 §6 检查清单。

### 5.0 Android App 开发必读（v1.0.1 固件对齐）

本节汇总 **BLE 链路** 下 App 必须实现的行为，可直接转发给客户端团队。

#### 5.0.1 GATT 与 Notify 分工

| UUID | 方向 | App 动作 |
|------|------|----------|
| **0xFFE1** | 手机 → 设备 | Write：控制 JSON、OTA 同意 `message_type=0`、链路清空 `message_type=2` |
| **0xFFE2** | 手机 → 设备 | Write：**OTA 帧**（§3.4.2），非裸 `0x01/0x02`（旧版兼容见 §3.4.3） |
| **0xFFE3** | 设备 → 手机 | **必须开 Notify**；解析 JSON，见下表 |
| **0xFFE4** | 设备 → 手机 | **必须开 Notify**；接收 **ACK/NAK 帧字节流**，组帧后解析（§5.0.4） |

**0xFFE3 `onCharacteristicChanged` 必分支：**

| `head.message_type` | 含义 | App 处理 |
|---------------------|------|----------|
| **1** | 心跳 | 连接后 **很快收到首条**，之后约 **10s** 一条；读 `body.voltage`、`body.device_ID`、`body.version` 判断在线/OTA 版本 |
| **3** | 停车态（设备→手机） | 固件 **500ms 无控制** 后每 **300ms** 推送，载荷同 §4.4.1；**不要**与手机下发的控制混淆 |
| **4** / **5** | 告警 / 错误 | `body.msg` 文本（Release 固件日志转发） |
| **1002** | OTA 询问/结果 | `body.phase` = `query` / `done`（§3.4.0） |

**在线判断**：以 **GATT 已连接 + 收到过 message_type=1 心跳** 为准，不要假设固定 3s 心跳（已改为 **10s**）。

#### 5.0.2 遥控时序（必做，否则误停车或断连）

| 场景 | 周期 / 阈值 | 动作 |
|------|-------------|------|
| **任意 JSON 命令（通用）** | 相邻两条间隔 **≥ 100ms** | 含 `3/4/6/0/2` 等；**禁止** 10ms 级连发或 UI 每帧都写串口 |
| **摇杆/方向控制中** | 建议 **100～200ms** 一发；**≤ 500ms** 必达 | 向 **0xFFE1** / UART1 写 `message_type=3` + `pwm_ctrl`（§4.6） |
| **松手 / UI 停止**（含多键全松） | **每 300ms** 一次 | 写 §4.4.1 **全位 0** 停车 JSON（`speed/direction/mode/val` 均为 0，§4.4.2） |
| **固件侧超时** | **500ms** 无任意控制 JSON | 设备自动停车，并经 **0xFFE3** / UART 每 300ms 推送 `message_type=3` 停车 JSON |

**常见错误（请避免）：**

- 相邻 JSON **间隔小于 100ms**（摇杆 onTouchMove 未节流）→ UART 积压、丢行、偶发不执行。  
- 只发 **一次** `mode:0,val:0` 或停车 JSON，然后停止发送 → 约 **0.5s 后** 固件仍判超时。  
- App 遥控周期仍按 **600ms** 设计 → 请改为 **≤500ms**（同时保持 **≥100ms**）。  
- 停车 JSON 缺少 **`speed`/`direction`** 且 `mode/val` 非 0 → 可能不会停车（见 §4.4.1）。  
- **前进+左转等多键一起松开** 却只发 `mode=1,val:0` 或 `mode=3,val:0` → 另一侧仍保持上一帧输出；须发 **全位 0**（§4.4.2）。  
- 把 **0xFFE3** 上 `message_type=3` 当成手机 GPIO 控制（手机→设备 **0xFFE1** 的 `message_type=4` 才是 GPIO）。

#### 5.0.3 控制 JSON 模板（复制用）

**停车（松手 / 多键全松，每 300ms 写一次；四字段必须全为 0）**：

```json
{"head":{"message_type":3},"body":{"pwm_ctrl":{"speed":0,"direction":0,"mode":0,"val":0}}}
```

> 前进+左转等 **同时抬起** 时用本条，不要拆成 `mode=1,val:0` 与 `mode=3,val:0` 分开发。详见 **§4.4.2**。

**同意 OTA：**

```json
{"head":{"message_type":0},"body":{}}
```

**链路清空（卡顿、断连恢复前）：**

```json
{"head":{"message_type":2},"body":{}}
```

**远程重启设备（慎用）：**

```json
{"head":{"message_type":1}}
```

> 注意：设备→手机心跳也是 `message_type=1`，方向相反；**仅手机 Write 0xFFE1 的 1 表示重启**。

#### 5.0.4 OTA 帧协议（App 必实现）

1. 连接后 **Write 0xFFE1**：`message_type=6`（§4.2.5），再等 **0xFFE3**：`message_type=1002`、`phase=query`。  
2. 用户确认 → **0xFFE1** 写 `message_type=0`。  
3. **0xFFE2** 发 **BEGIN 帧**（含 4B 文件长度 LE）→ 从 **0xFFE4** 收满 **ACK 帧**，`status==0`。  
4. 对每个固件字节发 **DATA 帧**（**每帧 1 字节**，`LEN=1`，`SEQ` 递增）→ 收 ACK：`[data_byte][status]`，`status==0` 且 `data_byte` 一致才发下一字节；否则 **同一 SEQ 重发该 DATA 帧**。  
5. 发 **END 帧** → ACK `status==0` → 等 **0xFFE3** `1002`/`phase=done` → 设备重启。  
6. 帧格式、CRC、`status` 码表见 **§3.4.2**。

**0xFFE4 组帧**：Notify 可能每次 1 字节，App 需 **缓冲** 直到收齐 `OT … 0x16` 整帧再解析；不要按「单字节回显」累加进度（旧协议已废弃）。

#### 5.0.5 Kotlin：OTA 帧组包 / 解析（骨架）

```kotlin
object OtaFrame {
    const val HEAD0 = 0x4F.toByte()
    const val HEAD1 = 0x54.toByte()
    const val VER = 0x02.toByte()
    const val TAIL = 0x16.toByte()
    const val OP_BEGIN = 0x01.toByte()
    const val OP_DATA = 0x02.toByte()
    const val OP_END = 0x03.toByte()
    const val OP_ACK = 0x80.toByte()
    const val OP_NAK = 0x81.toByte()
    const val ACK_OK = 0

    fun crc8(data: ByteArray, off: Int, len: Int): Byte {
        var c = 0
        for (i in off until off + len) c = c xor (data[i].toInt() and 0xFF)
        return c.toByte()
    }

    fun build(op: Byte, seq: Int, payload: ByteArray): ByteArray {
        val plen = payload.size
        val out = ByteArray(8 + plen + 2)
        out[0] = HEAD0; out[1] = HEAD1; out[2] = VER; out[3] = op
        out[4] = (seq and 0xFF).toByte()
        out[5] = ((seq shr 8) and 0xFF).toByte()
        out[6] = (plen and 0xFF).toByte()
        out[7] = ((plen shr 8) and 0xFF).toByte()
        System.arraycopy(payload, 0, out, 8, plen)
        out[8 + plen] = crc8(out, 2, 6 + plen)
        out[8 + plen + 1] = TAIL
        return out
    }

    fun buildBegin(imageSize: Int) = build(OP_BEGIN, 0, byteArrayOf(
        (imageSize and 0xFF).toByte(),
        ((imageSize shr 8) and 0xFF).toByte(),
        ((imageSize shr 16) and 0xFF).toByte(),
        ((imageSize shr 24) and 0xFF).toByte()
    ))

    fun buildData(seq: Int, b: Byte) = build(OP_DATA, seq, byteArrayOf(b))

    fun buildEnd(seq: Int) = build(OP_END, seq, ByteArray(0))

    /** 从 0xFFE4 字节流组帧；返回 null 表示尚未收齐 */
    class Assembler {
        private val buf = ArrayList<Byte>()
        fun feed(bytes: ByteArray): ByteArray? {
            for (b in bytes) buf.add(b)
            while (buf.size >= 10) {
                if (buf[0] != HEAD0 || buf[1] != HEAD1) { buf.removeAt(0); continue }
                val plen = (buf[6].toInt() and 0xFF) or ((buf[7].toInt() and 0xFF) shl 8)
                val total = 8 + plen + 2
                if (buf.size < total) return null
                val frame = ByteArray(total)
                for (i in 0 until total) frame[i] = buf[i]
                repeat(total) { buf.removeAt(0) }
                val crc = crc8(frame, 2, 6 + plen)
                if (crc != frame[8 + plen] || frame[total - 1] != TAIL) continue
                return frame
            }
            return null
        }
    }

  /** 解析 DATA 的 ACK：payload [dataByte][status] */
    fun parseDataAck(frame: ByteArray): Pair<Byte, Int>? {
        if (frame[3] != OP_ACK) return null
        val plen = (frame[6].toInt() and 0xFF) or ((frame[7].toInt() and 0xFF) shl 8)
        if (plen != 2) return null
        return frame[8] to (frame[9].toInt() and 0xFF)
    }
}
```

**发送循环示例（单字节 DATA）：**

```kotlin
var seq = 0
for (b in firmwareBytes) {
    var ok = false
  while (!ok) {
        gatt.write(CHR_OTA, OtaFrame.buildData(seq, b), WRITE_TYPE_DEFAULT)
        val ackFrame = otaAssembler.feed(awaitFfe4Bytes()) ?: continue
        val (echo, status) = OtaFrame.parseDataAck(ackFrame) ?: continue
        ok = (status == OtaFrame.ACK_OK && echo == b)
    }
    seq++
}
```

#### 5.0.6 配网（仅首次，BLE 模式）

1. 连接热点 **`esp32-apconfig`**（或 NVS 配置的 SSID），浏览器打开 **`192.168.4.1`**。  
2. 填写 **设备 ID** + **蓝牙广播名**，保存后设备 **自动重启**。  
3. 重启后 **不再开 WiFi 热点**，仅 BLE 广播（名称 = `ble_adv_name` + **`MP`**）。

#### 5.0.7 App 自测检查（最小集）

- [ ] 连接后 **10s 内** 收到首条心跳 `message_type=1`，含 `voltage`、`version`。  
- [ ] 松手后 **每 300ms** 写停车 JSON，车辆保持停止。  
- [ ] 摇杆控制 Write 间隔 **100～200ms**（且 **≤500ms**）；任意 JSON **≥100ms** 间隔。  
- [ ] OTA：每 DATA 字节 ACK 正确；故意改 ACK 会 **重传成功**。  
- [ ] `onCharacteristicChanged(FFE3)` 已处理 **1 / 3 / 4 / 5 / 1002**。  
- [ ] `0xFFE4` 使用 **帧组装器**，非「每 Notify 1 字节 = 进度 +1」。

---

### 5.1 权限与系统能力

- Android 12（API 31）及以上：需 **`BLUETOOTH_SCAN`**、**`BLUETOOTH_CONNECT`** 等运行时权限；扫描时若需定位行为，仍须遵守系统对 **位置权限** 的要求（依目标 SDK 与厂商策略而定）。
- 在 Manifest 中声明硬件：`android.hardware.bluetooth_le`。
- 使用手机 **BLE Central** 角色连接外围设备。

### 5.2 推荐连接流程

1. 确认已授予蓝牙与（若需要）位置权限。  
2. **扫描**：设备广播完整名为 NVS 中 **`ble_adv_name` + 固件自动追加的 `MP`**（默认 `ESP32-BLEMP`）。`ScanRecord.getDeviceName()` 建议过滤 **`endsWith("MP")`** 或 **`contains("MP")`**（注意 UTF-8 与截断）。  
3. **连接**：`device.connectGatt(context, false, callback)`（是否自动连接按产品需求）。  
4. **发现服务**：`BluetoothGatt.discoverServices()`。  
5. 在 `onServicesDiscovered` 中取得 **Service `0000ffe0-...`**，再取得 **Characteristic `0000ffe1-...`**。  
6. **协商 MTU**（建议）：`requestMtu(517)`（实际以 `onMtuChanged` 回调为准）。单包 ATT 最大载荷与 MTU 相关；在 MTU 较小时，**完整 JSON 必须仍能通过一次 Write 送达**，否则需固件侧支持拼包（当前不支持）。  
7. 将 JSON 转为 **UTF-8 字节数组**，长度 ≤ **2048**，调用 `writeCharacteristic`。  
   - 特征支持 **Write Without Response**，可使用 `WRITE_TYPE_NO_RESPONSE` 以降低延迟（需确认 `PROPERTY_WRITE_NO_RESPONSE`）。
8. **心跳 / 告警 / OTA JSON**：对 **`0xFFE3`** 开启 **Notify**。解析 `message_type`：**1** 心跳（连接后首条 + 10s）、**3** 停车态（固件超时后每 300ms，§4.4.1）、**4/5** 告警错误、**1002** OTA 询问/结果（§3.4.0）。
9. **松手停止**：向 **0xFFE1** 每 **300ms** 写 §4.4.1 停车 JSON；正常控制 **100～200ms** 一发（**≥100ms** 且 **≤500ms**）。
10. **OTA**：见 **§5.0.4**（`OT` 帧 + **0xFFE4** 组帧 ACK）；勿用旧版裸 `0x01/0x02/0x03` 单字节回显计进度。

### 5.3 UUID 常量参考（Kotlin）

```kotlin
val SVC_ROBOT = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val CHR_CONTROL = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
val CHR_OTA = UUID.fromString("0000ffe2-0000-1000-8000-00805f9b34fb")
val CHR_HEARTBEAT = UUID.fromString("0000ffe3-0000-1000-8000-00805f9b34fb")
val CHR_OTA_STATUS = UUID.fromString("0000ffe4-0000-1000-8000-00805f9b34fb")
```

### 5.4 断开与重连

固件在断开后会 **自动重新启动广播**（`BLE_GAP_EVENT_DISCONNECT` → `ble_gap_adv_start`）。应用断开后可再次扫描连接。

### 5.5 与 WiFi 版协议的关系

同一套 **`remote_control_apply_json`** 协议也用于 MQTT 链路；BLE 模式编译下无 MQTT。设计 App 时可将「JSON 命令构造层」复用，仅将发送路径替换为 **GATT Write**。

### 5.6 BLE 参考代码索引

| 主题 | 文档位置 |
|------|----------|
| Notify 开关、JSON 模板 | §5.0.3 |
| 遥控 ≥100ms / 100～200ms / 300ms / ≤500ms 时序 | §5.0.2、§7.2.3 |
| **OTA 帧** 组包/解析/重传 | **§5.0.5**（完整 Kotlin） |
| UUID 常量 | §5.3 |
| OTA 流程说明 | §3.4.0、§3.4.2 |

**`onCharacteristicChanged` 分支示例**：

```kotlin
when (characteristic.uuid) {
    CHR_HEARTBEAT -> {
        val json = JSONObject(String(value, Charsets.UTF_8))
        when (json.getJSONObject("head").getInt("message_type")) {
            1 -> onHeartbeat(json)           // 首条 + 约 10s
            3 -> onDeviceStopNotify(json)    // 固件超时停车，非手机 GPIO
            4, 5 -> onDeviceLog(json)
            1002 -> onOtaPhase(json.getJSONObject("body").getString("phase"), json)
        }
    }
    CHR_OTA_STATUS -> {
        val frame = otaAssembler.feed(value) ?: return
        onOtaAckFrame(frame)  // 见 §5.0.5，校验 SEQ / status 后重传或继续
    }
}
```

### 5.7 OTA 联调推荐顺序（Android 测试）

1. 扫描连接 → `discoverServices` → `requestMtu(517)`（可选，OTA 仍按 **每帧 1 字节 DATA** 最稳）。  
2. 对 **0xFFE3**、**0xFFE4** 写 CCCD 开 Notify。  
3. 先发 **`message_type=6`** → 收到 **1002 / query** → 用户确认 → **Write 0xFFE1** `message_type=0`。  
4. 读 **`ESPRCCar.bin`** → **0xFFE2** 发 `OtaFrame.buildBegin` → 逐字节 `buildData` + **0xFFE4 组帧 ACK** → `buildEnd`（§5.0.5）。  
5. 收到 **1002 / done / ok=1** → 等待重启 → 重连后心跳 **`body.version`** 校验。

---

## 6. 联调检查清单

- [ ] 固件已烧录 **BLE 链路** 构建，且 NVS 已写入非空 **`device_id`** 与 **`ble_adv_name`**。  
- [ ] 手机蓝牙已打开，应用已获 **扫描/连接** 权限。  
- [ ] 扫描到的设备名以 **`MP` 结尾**（或包含 `MP`），与固件广播规则一致。  
- [ ] 已发现 **0xFFE0** 服务及 **0xFFE1～0xFFE4**；**0xFFE3、0xFFE4** 已开 Notify。  
- [ ] 连接后收到 **首条心跳**（`message_type=1`），之后约 **10s** 周期。  
- [ ] 松手 **每 300ms** 写停车 JSON（§4.4.1）；控制 **100～200ms** 一发（**≥100ms** 且 **≤500ms**）。  
- [ ] `onCharacteristicChanged(FFE3)` 已处理 **1 / 3 / 4 / 5 / 1002**。  
- [ ] （OTA）镜像 **`ESPRCCar.bin`** ≤ **6 MiB**；已处理 **1002/query** → **message_type=0**。  
- [ ] （OTA）使用 **`OT` 帧协议**（§3.4.2、§5.0.5），**0xFFE4** 组帧 ACK，`status≠0` 会 **原 SEQ 重传**。  
- [ ] （OTA）收到 **1002/done/ok=1** 后重启；重连 **version** 已更新。  
- [ ] （Release）未依赖 USB 串口看业务日志（§1.5）。  
- [ ] 心跳 `body.voltage` 合理（GPIO5 ADC，分压 ×11）。  
- [ ] 已协商足够 **MTU**（控制 JSON ≤2048 字节时亦建议协商，避免未来扩展踩坑）。  
- [ ] 单次写入的 UTF-8 长度 **≤ 2048**，且为 **合法完整 JSON**。  
- [ ] `head.message_type` 与 `body` 字段与本文 **第 4 节** 一致。  
- [ ] 若固件关闭了 **`APP_BLE_OTA`**，勿向 **0xFFE2** 发送 OTA 数据（将返回 Write Not Permitted）。

---

## 7. UART 串口模式（FT232/Type-C 转串口）

固件支持通过 **UART 串口** 与 Android 设备通信，使用 **FT232** 或 **Type-C 转串口** 模块连接。此模式下协议与 BLE 模式**完全一致**（相同的 JSON 格式），仅物理层从 BLE GATT 改为 UART 串口。

### 7.1 硬件连接（FT232/Type-C 转串口）

使用 **FT232RL** 或 **CH340** 等 USB 转 TTL 串口模块，通过 Type-C OTG 转接线连接 Android 设备：

| ESP32-S3 引脚 | 功能 | 连接至 FT232 模块 | 说明 |
|--------------|------|------------------|------|
| **GPIO17** | U1TXD | RXD (接收) | ESP32 发送 → FT232 接收 |
| **GPIO18** | U1RXD | TXD (发送) | ESP32 接收 ← FT232 发送 |
| **GND** | 地线 | GND | **必须共地** |

**连接示意图：**
```
Android 手机 (Type-C OTG)
    │
    │  Type-C OTG 线
    ▼
┌─────────────┐
│  FT232 模块  │
│  (USB-TTL)   │
└─────────────┘
    │ RXD ────────┐
    │ TXD ────┐   │
    │ GND ──┐ │   │
    │       │ │   │
    ▼       ▼ ▼   ▼
┌──────────────────────┐
│     ESP32-S3         │
│  GPIO17(TX) ─────┐   │
│  GPIO18(RX) ─┐   │   │
│  GND ────────┼───┼───┘
└──────────────┘   │
                   │
                   ▼
              车辆控制板
```

### 7.2 UART 通信参数

| 参数 | 值 | 说明 |
|------|-----|------|
| **波特率** | **115200 固定** | Android 仍建议提供「串口波特率」设置项，但当前固件只支持 **115200**；每次 `open` 后必须使用 115200 |
| **数据位** | 8 bits | - |
| **校验位** | None | - |
| **停止位** | 1 bit | - |
| **流控** | None | 无硬件流控 |
| **帧格式** | JSON + `\n` | 每条 JSON 以换行符 `\n` 结尾；UTF-8，单行、无缩进 |

### 7.2.0 Android 串口波特率设置（固定 115200，必读）

当前固件 UART1 业务串口固定使用 **115200 / 8N1 / 无流控**。Android App 可以在设置页保留「串口波特率」设置项，便于后续扩展，但本版本必须默认并锁定为 **115200**；不要让用户选择 230400 / 460800 / 921600，否则会出现 USB open 成功但 3s 内收不到 `1002/query` 的现象。

#### 必须实现的设置项

| 项 | 要求 |
|----|------|
| **入口** | 设置页（或连接页）提供 **「串口波特率」** / **「UART 速率」** 专设一项 |
| **当前可选值** | **115200**（默认且唯一支持值） |
| **持久化** | 可写入 **SharedPreferences / DataStore** 等本地存储，但值必须校验为 115200 |
| **应用范围** | 每次 `UsbSerialPort.open()` 后 `setParameters(115200, 8, N, 1)`；握手 `6`、遥控、OTA **均用同一速率** |
| **禁止** | 当前版本禁止自动尝试 230400 / 460800 / 921600，禁止失败后静默切速率重连 |

**修改波特率时**：当前版本不支持切换。若以后开放多档速率，串口已 open 时应先 **`close()`**，保存新值后再 **open**；否则 FT232 可能仍按旧速率工作。

**与固件对齐**：

- 联调/量产前确认设备 `CONFIG_APP_UART_LINK_BAUDRATE=115200`，App 默认/保存值也为 **115200**。
- App 误选 **921600/460800/230400** 而固件为 **115200** 时，现象为：能 open USB，但 **3s 内收不到 `1002`**、无有效 JSON。
- **BLE 模式无此项**（GATT 不涉及波特率）。

#### Kotlin 示例（持久化 + open）

```kotlin
object UartLinkSettings {
    private const val PREFS = "uart_link"
    private const val KEY_BAUD = "baudrate"

    /** 当前固件仅支持 115200；保留数组是为了设置页/后续扩展 */
    val ALLOWED_BAUDS = intArrayOf(115200)
    const val DEFAULT_BAUD = 115200

    fun getBaudRate(context: Context): Int {
        val saved = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
            .getInt(KEY_BAUD, DEFAULT_BAUD)
        return if (saved in ALLOWED_BAUDS) saved else DEFAULT_BAUD
    }

    /** 用户在设置页选择后调用；之后所有连接均用此速率，直到再次修改 */
    fun setBaudRate(context: Context, baud: Int) {
        require(baud in ALLOWED_BAUDS) { "unsupported baud: $baud" }
        context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
            .edit()
            .putInt(KEY_BAUD, baud)
            .apply()
    }
}

fun openUartPort(context: Context, port: UsbSerialPort, connection: UsbDeviceConnection) {
    val baud = UartLinkSettings.getBaudRate(context)
    port.open(connection)
    port.setParameters(
        baud,
        UsbSerialPort.DATABITS_8,
        UsbSerialPort.STOPBITS_1,
        UsbSerialPort.PARITY_NONE
    )
}
```

> **产线要求**：出厂固件与 App 均使用 **115200**。本版本不要通过 App 设置切到其它速率。

### 7.2.3 命令间隔与 JSON 线长（App 必读）

**物理发送时间（仅线占时，8N1）**：`T_wire(ms) ≈ (字节数 × 10) / 当前波特率 × 1000`（波特率取 §7.2.0 已保存值）。另需叠加 USB-CDC 调度、ESP32 解析 `cJSON`、任务切换；**不能**按线占时连续背靠背发送。

**典型单行 JSON 线占时（约 91 字节停车 JSON，含 `\n`）**：

| App 设置波特率 | 约线占时 |
|----------------|----------|
| 115200 | ~8 ms |
| 其它速率 | 当前固件不支持，请勿选择 |

> **命令间隔仍须 ≥100ms**（§5.0.2），限制来自固件处理与 USB 调度，而非线速 alone。

**本文档典型 JSON（`cJSON_PrintUnformatted` 紧凑格式，含行尾 `\n`）**：

| 用途 | `message_type` | 约字节数（含 `\n`） | 线占时 @115200 | 与上一条最小间隔 |
|------|----------------|---------------------|----------------|------------------|
| 链路就绪 | 6 | **58** | ~5 ms | **≥100ms**（握手后等 `1002` 再发下一条） |
| OTA 同意 | 0 | **38** | ~3 ms | **≥100ms** |
| 链路清空 | 2 | **38** | ~3 ms | **≥100ms** |
| 远程重启 | 1 | **28** | ~2.4 ms | **≥100ms** |
| 停车 | 3 | **91** | ~8 ms | 松手重复 **300ms**（推荐） |
| 摇杆 PWM（`mode/val`） | 3 | **77～102** | ~7～9 ms | 控制中 **100～200ms**（推荐） |
| GPIO | 4 | **70～90** | ~6～8 ms | **≥100ms** |
| 心跳（设备→手机） | 1 | **110** | ~10 ms | App 只收；若同时下发命令仍 **≥100ms** |
| OTA 询问/结果 | 1002 | **111～130** | ~10～11 ms | 收到后 **≥100ms** 再发 `0` 或 OT 帧 |

> `device_ID` 更长时，心跳 / `1002` 各行约 **+1～2 字节/字符**，线占时略增，**间隔规则不变**。

**间隔怎么选（推荐）**：

| 目标 | 建议间隔 | 说明 |
|------|----------|------|
| **硬下限（所有手机→设备 JSON）** | **≥100ms** | 约为最长单行线占时的 **10 倍** 余量，避免 RX 缓冲溢出与 JSON 粘行 |
| **摇杆 / 方向控制** | **100～200ms** | 约 **5～10 Hz**；须 **≤500ms** 以免 `RC_CMD_TIMEOUT_MS` 触发停车 |
| **松手停车** | **300ms** | 与固件侧停车推送节奏一致 |
| **握手 `6` → 同意 `0`** | 收到 `1002/query` 后 **≥100ms** 再发 `0`；进入 OT 帧阶段后按 §3.4.2，**勿**再穿插控制 JSON |

**示例（摇杆 150ms 节流）**：

```kotlin
private var lastCmdMs = 0L
fun sendPwmIfDue(json: String) {
    val now = SystemClock.elapsedRealtime()
    if (now - lastCmdMs < 100L) return
    lastCmdMs = now
    port.write((json + "\n").toByteArray(Charsets.UTF_8), 500)
}
```

### 7.2.1 串口连接握手（必读）

| 步骤 | 方向 | 内容 |
|------|------|------|
| 1 | 手机 → 设备 | `UsbSerialPort.open` + 启动读线程后，**立即**发 `message_type=**6**` + `\n`（§4.2.5） |
| 2 | 设备 → 手机 | `message_type=**1002**`，`body.phase=**query**`（**握手成功** + OTA 版本） |
| 3 | 手机 | UI 置「已连接」；对比 `body.version` 与本地包，决定是否发 `0` 升级 |
| 4 | 手机 → 设备 | 正常控制 `message_type=3/4` 等；设备周期心跳 `1`（约 10s） |

**每次**用户拔插 OTG、App 重进页面、`port.close()` 后再 `open()`，都必须重复 **步骤 1～2**。不要只靠步骤 4 的心跳判断「已连接」。

### 7.2.2 固件 Debug 联调日志（UART 模式）

**两类输出（必读 §1.5）**：

1. **`ESP_LOG` 文本**（`OTA_MGR`、`APP`、`LINK_UART: UART1 RX…`）→ 打在 **控制台 UART**（默认 USB/UART0；若已重定向控制台则与业务 **同 COM**）。
2. **业务 JSON / OT 帧** → `uart_write_bytes` 到 **UART1**（GPIO17/18）。Debug 下 `LINK_UART: UART1 TX 心跳(1): {...}` 是 **日志里复述** 即将/已经写出的 JSON，**不是** 第二份协议。

| 监视器/读线程可见 | 类型 | 含义 |
|-------------------|------|------|
| `OTA_MGR: Running version:` / `Loaded session:` | 日志 | OTA 状态机/NVS（§7.5.6）；**与是否收到手机 `6` 无直接关系** |
| `OTA was in progress but interrupted` | 日志 WARN | 上次 OTA 未写完就重启；新固件会尝试清除假会话 |
| `握手: 设备不主动发 1002…` | 日志 | 当前握手协议 |
| `UART1 RX N byte(s)` + hexdump | 日志 | 设备在 **UART1** 上收到 N 字节（**无此行 = UART1 未收到数据**） |
| `UART1 RX JSON line: {"head":…}` | 日志 | 已解析为 JSON（应见到 `message_type=6` 等） |
| `UART1 收到 link_ready(6)…` | 日志 | 将回复 `1002/query` |
| `UART1 TX OTA(1002): {"head":…}` | 日志 | 业务线上已写 `1002` JSON |
| `UART1 TX 心跳(1): {"head":…}` | 日志 | 业务线上已写心跳 JSON |
| 单独一行 `{"head":{"message_type":1002},…}` | **业务** | App 应解析此类行；握手成功依据 |
| `I (548) LINK_UART: UART1 TX …` | **日志** | **不要**当 JSON 解析 |

**控制台重定向到 UART1 时**：上述「日志」与「业务 JSON」会交错；App **仅解析以 `{` 开头、以 `\n` 结束** 的行。

**如何确认固件与握手**：启动见 `握手: 设备不主动发 1002…`；发 `6` 后监视器或 App 应出现 **`1002`/`query` 的 JSON 行**（不仅是 `LINK_UART` 日志）。若只有 `UART1 RX 3 byte(s)` 且 `fe 00 e4`，说明 **未发 JSON `6`**。

**Release**：无 `ESP_LOG`；仅用手机侧收 **1002** / 心跳 **1**。

**Kotlin 常量与发送：**

```kotlin
const val MSG_LINK_READY = 6

fun sendLinkReady(port: UsbSerialPort) {
    val json = """{"head":{"message_type":6},"body":{"status":"connected"}}"""
    port.write((json + "\n").toByteArray(Charsets.UTF_8), 3000)
}

/** open 成功后调用；3s 内应收到 message_type=1002 */
fun onSerialOpened(port: UsbSerialPort) {
    linkReadySentAt = SystemClock.elapsedRealtime()
    awaitingLinkAck = true
    sendLinkReady(port)
}
```

**`parseJsonFromDevice` 中处理 `1002`：**

```kotlin
1002 -> {
    val phase = obj.getJSONObject("body").getString("phase")
    if (phase == "query" && awaitingLinkAck) {
        awaitingLinkAck = false
        onUartLinkConfirmed(obj.getJSONObject("body").getString("version"))
    }
    // phase == "done" → OTA 结束，见 §7.5
}
```

### 7.3 Android 串口开发指南

Android 端可使用以下库进行 USB 串口通信：

**推荐库：**
- [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android)（开源，支持 FT232/CH340/CP2102 等）

**Gradle 依赖：**
```gradle
implementation 'com.github.mik3y:usb-serial-for-android:3.4.0'
```

**基本使用示例：**

```kotlin
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import com.hoho.android.usbserial.util.SerialInputOutputManager

class UartConnectionManager(private val context: Context) {
    private var port: UsbSerialPort? = null
    private var ioManager: SerialInputOutputManager? = null
    
    // 查找并连接 FT232 设备
    fun connectToDevice(): Boolean {
        val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
        val availableDrivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
        
        val driver = availableDrivers.firstOrNull { 
            it.device.productName?.contains("FT232", ignoreCase = true) == true ||
            it.device.productName?.contains("USB Serial", ignoreCase = true) == true
        } ?: return false
        
        val connection = manager.openDevice(driver.device) ?: return false
        port = driver.ports[0]
        port?.open(connection)
        
        // 波特率：设置页持久化值（§7.2.0），须与固件 CONFIG_APP_UART_LINK_BAUDRATE 一致
        val baud = UartLinkSettings.getBaudRate(context)
        port?.setParameters(baud, UsbSerialPort.DATABITS_8,
                          UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
        
        // 启动 I/O 管理器监听接收数据
        ioManager = SerialInputOutputManager(port, object : SerialInputOutputManager.Listener {
            override fun onNewData(data: ByteArray?) {
                data?.let { processReceivedData(it) }
            }
            override fun onRunError(e: Exception?) {
                Log.e("UART", "Serial error", e)
            }
        })
        ioManager?.start()

        // open 成功 ≠ 已与 ESP32 双向通信；必须发 6 并等 1002（§7.2.1）
        sendLinkReady()
        
        return true
    }

    private fun sendLinkReady() {
        sendJsonCommand("""{"head":{"message_type":6},"body":{"status":"connected"}}""")
    }
    
    // 发送 JSON 命令（自动添加换行符）
    fun sendJsonCommand(json: String) {
        val data = (json + "\n").toByteArray(Charsets.UTF_8)
        port?.write(data, 1000)
    }
    
    // 处理接收数据（按换行符分包）
    private val rxBuffer = StringBuilder()
    
    private fun processReceivedData(data: ByteArray) {
        val text = String(data, Charsets.UTF_8)
        rxBuffer.append(text)
        
        // 按换行符分割 JSON
        var newlineIndex: Int
        while (rxBuffer.indexOf("\n").also { newlineIndex = it } >= 0) {
            val json = rxBuffer.substring(0, newlineIndex)
            rxBuffer.delete(0, newlineIndex + 1)
            
            if (json.isNotBlank()) {
                parseJsonFromDevice(json)
            }
        }
    }
    
    // 解析设备发来的 JSON（与 BLE 0xFFE3 相同格式）
    private fun parseJsonFromDevice(json: String) {
        try {
            val obj = JSONObject(json)
            val head = obj.getJSONObject("head")
            val messageType = head.getInt("message_type")
            
            when (messageType) {
                1 -> { /* 心跳 - §3.5-A；勿单独用作「已连接」 */ }
                4, 5 -> { /* 告警/错误 - §3.5-B */ }
                1002 -> {
                    val phase = obj.getJSONObject("body").getString("phase")
                    if (phase == "query") onUartLinkConfirmed()  // 发 6 后的握手 + OTA 询问
                    else if (phase == "done") onOtaDone(obj.getJSONObject("body"))
                }
                0 -> { /* 不应由设备下发；若收到请忽略 */ }
            }
        } catch (e: Exception) {
            Log.e("UART", "JSON parse error", e)
        }
    }
    
    fun disconnect() {
        ioManager?.stop()
        port?.close()
    }
}
```

### 7.4 UART 模式与 BLE 模式协议对比

| 功能 | BLE 模式 (GATT) | UART 模式 (串口) |
|------|----------------|-----------------|
| **控制指令下发** | Write 0xFFE1 | 发送 JSON + `\n`（**无** `OK` 回复） |
| **心跳接收** | Notify 0xFFE3 | 连接后首条 + 约 **3s** 一次（`message_type=1`） |
| **告警/错误** | Notify 0xFFE3 (message_type 4/5) | 接收 JSON + `\n` (message_type 4/5) |
| **JSON 格式** | 完全相同 | 完全相同 |
| **连接检测** | `onConnectionStateChange` + 发 **6** 收 **1002** | **禁止**仅用 `open()` 或单向心跳判断在线；见 §7.2.1 |
| **连接握手** | Write **6** → Notify **1002/query** | 每次 **open** 后必做（§4.2.5） |
| **OTA 询问/结果** | Notify **0xFFE3**（`message_type=1002`） | JSON 行（`1002`） |
| **OTA 同意** | Write **0xFFE1**（`message_type=0`） | JSON 行（`0`） |
| **OTA 固件确认** | Notify **0xFFE4** ACK/NAK 帧 | UART1 ACK/NAK 帧（§7.5.3） |

**示例 - 发送控制指令（UART）：**
```kotlin
// 与 BLE 0xFFE1 相同；message_type=3 为 PWM，固件不回复 OK
val controlJson = """
{
    "head": {"message_type": 3},
    "body": {
        "pwm_ctrl": {"mode": 1, "type": 0, "val": 50}
    }
}
""".trimIndent()

uartManager.sendJsonCommand(controlJson)
// 以车辆是否动作为准，不要等待 "OK"
```

**示例 - 接收心跳（UART）：**
```kotlin
// 固件连接后立即 1 条，之后每 3s（设备 → 手机），用于在线检测与 OTA 版本比对
{"head":{"message_type":1},"body":{"device_ID":"CN110200000001","voltage":"10.30","version":"1.0.1"}}
```

### 7.5 UART OTA（固件升级）

UART OTA 与 BLE **共用同一套 JSON 状态机**（§3.4.0），仅物理层为 **UART1 JSON 行 + 二进制**。

#### 7.5.0 Android OTA 更新完整说明（两个文件）

Android App 内置或下载 OTA 包时只需要两个文件：

| 文件 | 放置建议 | 用途 |
|------|----------|------|
| `ESPRCCar.bin` | `assets/ota/ESPRCCar.bin` 或 App 私有目录 | 真正写入 ESP32 OTA 分区的固件镜像 |
| `ota_manifest.json` | `assets/ota/ota_manifest.json` 或与 bin 同目录 | 版本 JSON，包含 `version`、`bytes`、`sha256` |

`ota_manifest.json` 字段说明：

| 字段 | 类型 | Android 用途 |
|------|------|--------------|
| `version` | string | 本地 OTA 包目标版本；与设备 `query.body.version` 做语义版本比较 |
| `image` | string | 固件文件名，当前固定 `ESPRCCar.bin` |
| `bytes` | number | 固件文件长度；发 BEGIN 帧时填入该长度 |
| `sha256` | string | 固件文件 SHA256；推送前先校验 APK/下载文件完整性 |

完整流程：

1. App 打开 USB 串口，固定 `setParameters(115200, 8, N, 1)`，启动读线程。
2. App 发送 `{"head":{"message_type":6},"body":{"status":"connected"}}\n`。
3. 设备回复 `1002/query`，App 读取 `query.body.version`，同时认为串口双向握手成功。
4. App 读取本地 `ota_manifest.json`，比较 `ota_manifest.version` 与 `query.body.version`。
5. 若本地版本不高于设备版本：不发 `message_type=0`，不推 `ESPRCCar.bin`，只保持心跳/遥控。
6. 若本地版本更高：先校验 `ESPRCCar.bin` 的文件长度等于 `ota_manifest.bytes`，SHA256 等于 `ota_manifest.sha256`。
7. App 发送 OTA 同意 JSON：`{"head":{"message_type":0},"body":{}}\n`。
8. App 按 §3.4.2 / §7.5.2 发送 OT 帧：BEGIN（payload=固件长度 LE）→ DATA（建议每帧 1 字节）→ END。
9. 每个 DATA 等待设备 ACK 帧；`status==0` 且 echo 字节一致才发下一帧；失败按原 SEQ 重传。
10. END 成功后等待 `1002/done`；`ok=1` 且 `written == ota_manifest.bytes` 表示设备已写完并准备重启。
11. 设备约 1s 后重启；App 重新 open → 发 `6` → 读心跳或 `1002/query`，确认 `body.version == ota_manifest.version`。

禁止事项：

- 不要把 factory 目录里的 `bootloader.bin`、`partition-table.bin`、`storage.bin` 当 OTA 包发给设备。
- 不要只根据 USB `open()` 成功显示已连接，必须以 `6 → 1002/query` 为准。
- 不要未发 `message_type=0` 就发 OT 帧。
- 不要把 JSON 与 OT 帧拼在同一行；`0` 后必须有 `\n`，OT 帧是二进制流。

#### 7.5.1 时序（与 BLE 对齐）

| 步骤 | 方向 | 内容 |
|------|------|------|
| 1 | 手机 → 设备 | `message_type=**6**` + `\n`（**每次 open 串口**） |
| 2 | 设备 → 手机 | `message_type=**1002**`，`body.phase=**query**`**（设备主动询问升级；**每次握手必达**） |
| 3 | 手机 → 设备 | **仅当** `ota_manifest.version` **>** `query.body.version`：`message_type=**0**`** + `\n`；版本相同**跳过** |
| 4 | 手机 → 设备 | **OT 帧** BEGIN → DATA → END（须先完成步骤 3，§3.4.2） |
| 5 | 设备 → 手机 | `message_type=**1002**`，`body.phase=**done**`**（`ok` / `written` / 可选 `err`） |
| 6 | 设备 | 约 **1s** 后重启；重连后心跳 `body.version` 更新 |

**WiFi + MQTT 构建（对照）**：不走本流程，见 §8.3（HTTPS OTA）。

**App 建议**：`1002/query` = **握手成功** + **设备升级询问**（两件事同时发生）。比对版本后：**有新版才回 `0`**，**版本相同绝不 OTA**（§3.4.0.1）。

#### 7.5.2 二进制协议（推荐 OT 帧，与 BLE §3.4.2 相同）

**量产/联调请使用 `OT` 帧协议**（§3.4.2）：BEGIN / DATA / END + ACK/NAK 帧；UART 上 ACK 帧 **写回同一 UART1**（与 BLE **0xFFE4** Notify 等价）。

| 方式 | 说明 |
|------|------|
| **OT 帧（推荐）** | `OtaFrame.buildBegin/Data/End`；每 DATA 等 ACK 帧 `status==0` |
| **旧版裸 `0x01/0x02/0x03`** | 仍兼容，见 §3.4.3；新 App 建议勿再用 |

- 镜像：本地 **`build/ESPRCCar.bin`** 或 **`firmware/release/ota/ESPRCCar.bin`**（≤ **6 MiB**）。
- 须先 **`0` 武装** 再发二进制；JSON 与 OTA 帧 **不要混在同一行**。

#### 7.5.3 UART 与 BLE 的 OTA 确认差异（必读）

| 项目 | BLE | UART（GPIO17/18） |
|------|-----|-------------------|
| 询问 / 结果 JSON | **0xFFE3** Notify | UART1 文本行 + `\n` |
| 同意 OTA | Write **0xFFE1** | JSON 行 + `\n` |
| 固件数据确认（推荐 OT 帧） | **0xFFE4** ACK/NAK 帧（需组帧） | UART1 ACK/NAK 帧（需组帧） |
| 固件数据确认（旧版兼容） | 旧固件可能出现单字节回显 | 旧固件可能出现同线单字节回显 |
| 控制 JSON 后 | 无 ACK | **无** `OK` |
| USB 调试口（UART0） | Release 下 **无日志**（§1.5） | 同左 |

**UART 发送方式（推荐 OT 帧，裸字节仅兼容）**：

1. **OT 帧（推荐）**：按 §3.4.2 发送 BEGIN/DATA/END，等待 ACK/NAK 帧；`status!=0` 按原 SEQ 重传。
2. **裸 `0x01/0x02/0x03`（兼容旧流程）**：仍可用，但新 App 不建议继续依赖该模式。

**UART 读线程必须做双路解复用**：同一 UART1 RX 流里既可能有 JSON 行（`{...}\n`，如心跳/1002），也可能有 OT ACK/NAK 二进制帧（`0x4F 0x54 ... 0x16`）。OTA 阶段读线程应优先组 OT 帧；遇到 `{` 再按换行组 JSON。不要假设一次 `read()` 就是一整帧，USB 串口库可能把一个 OT 帧拆成多次回调。

**JSON 分片/粘包必读（对应你看到的日志现象）**：

- 一次 `read()` 可能只收到半条 JSON（例如只到 `"val":95` 还没收到结尾 `}}}\n`）。
- 一次 `read()` 也可能同时包含：**上一条 JSON 尾巴 + 下一条 JSON 开头**。
- 还可能在同一缓冲里出现：`{"..."}\n{"..."}\n`（两条甚至多条 JSON 连在一起）。
- 因此 **禁止**“每次回调直接当一条完整 JSON 去 parse”。

**Android 正确做法**：

1. 先按字节流累积到 `rxTextBuffer`。
2. 循环查找 `\n`，每次只取一条完整行做 JSON 解析。
3. 解析完从缓冲中移除该行，继续处理剩余内容（可能是下一条 JSON 头部）。
4. 没有 `\n` 的残留内容继续保留，等待下一次回调拼接。

**推荐参考实现（串口文本流）**：

```kotlin
private val rxTextBuffer = StringBuilder()

fun onSerialBytes(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()) continue
        if (!line.startsWith("{")) continue // 过滤日志行或非 JSON 文本

        try {
            handleJson(JSONObject(line))
        } catch (_: Exception) {
            // 丢弃该行，继续处理后续行，避免阻塞
        }
    }
}
```

**遥控连续发送与超时停车（必读）**：

- 固件遥控看门狗按“最近一次收到命令字节”计时，间隔过长会触发自动停车。
- 经验阈值：任意相邻两条控制命令间隔**不要超过 500ms**；推荐稳定在 **100~200ms**。
- 松手/停止时请立刻发送全 0 停车帧，并建议再补发 2~3 次（每次 100~300ms）。
- 若日志出现 `RC timeout: no cmd for xxx ms, vehicle stopped`，说明 App 发送节奏中断，不是固件 JSON 解析失败。

**伪代码（OT 帧，推荐）**：

```kotlin
fun uartOtaWithFrames(port: UsbSerialPort, firmware: ByteArray) {
    var seq = 0
    port.write(OtaFrame.buildBegin(firmware.size), 3000)
    require(waitAckFrame(seq).status == 0)
    seq++

    for (b in firmware) {
        var ok = false
        while (!ok) {
            port.write(OtaFrame.buildData(seq, b), 3000)
            val ack = waitAckFrame(seq)
            ok = (ack.status == 0 && ack.echo == b)
        }
        seq++
    }

    port.write(OtaFrame.buildEnd(seq), 3000)
    require(waitAckFrame(seq).status == 0)
}
```

#### 7.5.4 OTA 耗时与 115200 波特率

理论线速（8N1）：115200 / 10 ≈ **11.5 KB/s**。OTA 实际还受 **ACK 往返、帧协议** 限制，以下为 **OT 帧 + 逐字节 ACK** 量级（与 §3.4.2 一致）：

| App 波特率（§7.2.0） | 理论线速 | 约 1 MB 固件（量级，仅供参考） |
|----------------------|----------|--------------------------------|
| **115200**（默认） | ~11.5 KB/s | ~6～9 min |

**前提**：固件 `CONFIG_APP_UART_LINK_BAUDRATE=115200`，App 设置 **115200**。升级完成以 **重启后心跳 `body.version`** 为准。

**注意**：OTA 过程中请勿断电；失败时固件可能 **自动回退** 上一版本（见 §3.4 OTA 失败回退）。

#### 7.5.5 用「旧固件 + 新 App 包」验证 OTA 是否可用（推荐）

目的：**不依赖 release 目录里现成 bin 是否最新**，而是用你本地 `idf.py build` 出的 **`build/ESPRCCar.bin`**（新代码）灌进 **现场仍在跑旧版本** 的设备；成功后心跳 `body.version` 变为新包版本，即说明 **UART/BLE OTA 链路正确**。

| 步骤 | 操作 | 通过标准 |
|------|------|----------|
| 1 | 板子保持 **旧固件**（如 `8f3a151-dirty`），记录当前 `body.version` | — |
| 2 | 新工程 `idf.py build`，取 **`build/ESPRCCar.bin`**（≤6MiB，分区表一致） | 文件大小与 `VERSION.txt` 可选核对 |
| 3 | App：**open** → 发 **`6`** → 收 **`1002/query`** | 3s 内收到 |
| 4 | 对比 `body.version` 与本地包，发 **`message_type=0`** | 设备进入 armed |
| 5 | 发 **OT 帧** BEGIN → DATA（按 §3.4.2 ACK）→ END | 进度到 100% |
| 6 | 收 **`1002/done`**，`ok=1`，设备约 1s 重启 | — |
| 7 | 重连后发 **`6`**，读心跳 **`body.version`** | **等于新固件 `CONFIG_MY_APP_VERSION`**（如 `1.0.1`） |
| 8 | （可选）`idf.py monitor` 见 `OTA_MGR: New firmware boot detected` / `Version matches` | 新分区启动验证通过 |

**失败排查**：`done` 里 `ok=0` 看 `err`；中途断电可能下次启动 **回退旧分区**；勿在 OTA 中混发控制 JSON。

**与 WiFi OTA 区分**：MQTT/HTTPS 走 §8，**不走** 本表 `6→0→OT 帧` 流程。

#### 7.5.6 启动日志 `OTA_MGR` 说明（Debug 监视器）

上电常见几行（**`ESP_LOG` 文本**，默认在 **UART0/USB**；若控制台已重定向到 UART1，则与业务 JSON **同一 COM**，见 §1.5）：

| 日志 | 含义 | App 是否要处理 |
|------|------|----------------|
| `Running partition: ota_0` / `ota_1` | 当前从哪个 OTA 槽启动 | 否 |
| `Running version: xxx` | 当前运行镜像版本字符串 | 与 `1002/query` 的 `body.version` 对照 |
| `Loaded session: state=1, type=2, ...` | NVS 里留有 **未结束的 OTA 会话**（`state` 见下表） | 一般否；见下行 |
| `OTA was in progress but interrupted` | 上次 OTA **写到一半就断电/重启**，会话被标失败 | 若反复出现且无法升级：App 完整跑完一次 OTA 或产线 **擦 NVS** |
| `Cleared stale OTA session` | 旧版误记的「假进行中」已清除（**无实际传包**） | 否 |
| `OTA session started: type=2` | **仅**在收到 BEGIN/OT 帧开始写 flash 时出现（新固件）；**不应**在仅 `link_uart_start` 时出现 | 否 |

**`Loaded session` 字段**（固件 `ota_manager.c`）：

| 字段 | 值示例 | 说明 |
|------|--------|------|
| `state` | `0` idle / `1` in_progress / `2` pending_verify / `3` success / `4` failed | `1` 且未传完 → 上电 WARN |
| `type` | `0` HTTP / `1` BLE / `2` UART | |
| `target` | `1.0.1` 或历史 `unknown` | 目标版本；`unknown` 多为旧版假会话 |
| `source` | 升级前版本 | |
| `boot_cnt` | 新固件启动计数 | 用于回退判定 |

> **说明**：`8f3a151-dirty` 等为 **Git/编译描述串**，与文档中的 **`1.0.1` 产品版本** 可能并存；以 **`1002` / 心跳里的 `body.version`** 为 App 展示与比对依据。

### 7.6 设备重置（配网模式）

UART 模式下，设备通过 **长按重置按键**（GPIO4，硬件上对应某个物理按键）进入配网模式：

- **长按 2 秒**（GPIO4 接地持续 2 秒）：进入 SoftAP 配网模式
- 此时设备开启 WiFi 热点（SSID: `esp32-apconfig`）
- Android 连接热点后访问 `192.168.4.1` 配置新的 `device_id`
- 配置完成后设备重启并恢复 UART 通信

**注意：** UART 模式下无需配置 WiFi 密码或蓝牙广播名，仅需配置 `device_id`。

### 7.7 调试建议

1. **先分清「日志」与「业务」**（§1.5、§7.2.2）：`OTA_MGR`/`LINK_UART:` 是 **ESP_LOG**；协议 JSON 是 **`uart_write` 的裸 JSON 行**。默认 USB=日志、GPIO17/18=业务；**控制台重定向后同线混合**。
2. **FT232 / 手机线**：接 GPIO17(TX←)、GPIO18(TX→)；带 TX/RX LED 便于看 OTA。
3. **波特率**：使用设置页已保存值（默认 **115200**，§7.2.0），须与固件一致。
4. **JSON**：行尾 **`\n`**；相邻命令建议 **100~200ms** 且不要超过 **500ms**；OTA 阶段勿插控制 JSON。
5. **OTG 权限**：Android USB Host。
6. **App 读线程**：按 `\n` 切行解析，保留半包；跳过不以 `{` 开头的行（混有 `I (123) LINK_UART:` 时必需）。

```xml
<!-- AndroidManifest.xml -->
<uses-feature android:name="android.hardware.usb.host" />
<uses-permission android:name="android.hardware.usb.usb_accessory" />
```

### 7.8 检查清单（UART 模式）

- [ ] 固件已烧录 **UART 链路** 构建（`CONFIG_APP_LINK_UART=y`），且 NVS 已写入非空 **`device_id`**。  
- [ ] FT232/CH340 模块与 ESP32-S3 正确连接：GPIO17→RXD, GPIO18→TXD, GND→GND。  
- [ ] Android 手机支持 OTG 功能，已开启 OTG 开关。  
- [ ] Android 应用已获 **USB 设备访问权限**。  
- [ ] 已实现 **串口波特率** 设置项，但当前版本默认并锁定 **115200**，且与固件 `CONFIG_APP_UART_LINK_BAUDRATE=115200` 一致。  
- [ ] `open` 时使用 `setParameters(已保存波特率, 8, N, 1)`（§7.2.0）。  
- [ ] JSON 以 `\n` 结尾；手机→设备相邻控制帧建议 **100～200ms**，且不要超过 **500ms**（超时会停车）。  
- [ ] 能收到周期心跳（连接后首条 + 约 **3s** 一次），`message_type=1`。  
- [ ] 已实现“按 `\n` 切行 + 半包缓存”，不会把单次回调直接当完整 JSON 解析。  
- [ ] 能处理“上一条尾巴 + 下一条开头”粘包场景（一次回调多条/半条 JSON）。  
- [ ] 发送控制 JSON 后车辆有响应（马达/舵机动作）；**不要**等待 `OK` 文本回复。  
- [ ] 心跳 `body.voltage` 显示正确电池电压值。  
- [ ] 心跳 `body.version` 显示正确固件版本号。  
- [ ] 重连后已重新执行 **6→1002**；超时未收到 **1002** 则显示未连接。  
- [ ] 长按重置键 2 秒可进入配网模式修改 `device_id`。
- [ ] **每次 open 串口** 后已发 **`message_type=6`**，**3s 内** 收到 **1002/query** 才显示「已连接」。
- [ ] 未把「仅收到心跳」误判为已连接（握手必须 **6→1002**）。
- [ ] （OTA）收到 **1002/query** 后，需要升级时已发 **message_type=0**。
- [ ] （OTA）流程：**首条 JSON → 1002/query → 0 → OT 帧**；DATA 按 ACK 帧确认（§3.4.2）。
- [ ] （OTA）已按 **§3.4.0.1** 解析 `body.version`；本地 `ota_manifest.json.version` 与设备版比对后再发 **0**。
- [ ] （OTA）收到 **1002/done/ok=1** 后重启，心跳 `body.version` 与 `ota_manifest.version` 一致。
- [ ] （Release）未用 USB 口调试业务数据（§1.5）。

---

## 附录 A · Android App 功能实现总览（UART / BLE）

下表为 **同一套 JSON 协议** 在 UART1 与 BLE GATT 上的完整能力；WiFi 模式见 §8。

### A.1 连接与在线

| 功能 | 手机动作 | 设备响应 | 必须实现 |
|------|----------|----------|----------|
| **串口波特率（UART）** | 设置页显示 115200，**本地持久化**；`open` 时 `setParameters(115200, 8, N, 1)` | 固件固定 115200 | **是**（§7.2.0） |
| 打开链路 | `UsbSerialPort.open` / `connectGatt` | — | 是 |
| 双向握手 | 写 `message_type=**6**` + `\n` | `1002` / `phase=query` | **是**（禁止仅用 open 或单向心跳） |
| 在线 UI | 3s 内收到 `1002` 才显示已连接 | — | 是 |
| 重连 | 每次 close→open 或 BLE 重连 **再发 `6`** | 再收 `1002` | 是 |
| 周期心跳 | 读线程解析 | `message_type=1`，首条很快 + 约 **3s** | 是（版本/电压展示） |
| 链路清空 | 写 `message_type=2` | 停车 + 中止 OTA 武装 | 卡顿/退出页时建议 |
| 断开 | `close` / `disconnect` | BLE 侧自动清空 | 是 |

### A.2 遥控与状态

| 功能 | `message_type` | 周期/说明 |
|------|----------------|-----------|
| 任意 JSON | — | 手机→设备相邻两条 **≥100ms** |
| 摇杆/方向 | `3` + `pwm_ctrl` | **100～200ms** 推荐；**≤500ms** 必达 |
| 松手停车 | `3` + **全位 0** | `speed/direction/mode/val` 均为 0；**每 300ms**；多键全松同条（§4.4.2） |
| GPIO | `4` + `pin_setctrl` | 按需，相邻 **≥100ms** |
| 远程重启 | 手机→设备 **`1`**（仅 `head`） | 慎用；与设备→手机心跳 **1** 方向不同 |
| 设备停车态推送 | 设备→手机 **`3`**（BLE Notify / UART 行） | 固件 500ms 无控制后每 300ms |
| 告警/错误 | 设备→手机 **`4`/`5`**，`body.msg` | Release 诊断 |

### A.3 OTA（BLE / UART）

| 步骤 | 方向 | 内容 |
|------|------|------|
| 1 | 手机→设备 | `6`（握手） |
| 2 | 设备→手机 | `1002` / `query`（设备主动询问；含当前 `version`） |
| 3 | 手机→设备 | **仅版本有变化**（App 包 **>** 设备）：`0`（同意）；相同则**不发** |
| 4 | 手机→设备 | **OT 帧** BEGIN → DATA → END（§3.4.2；UART/BLE 均推荐） |
| 5 | 设备→手机 | **0xFFE4** Notify 或 UART **ACK 帧**（非裸字节回显时） |
| 6 | 设备→手机 | `1002` / `done`（`ok`/`written`/`err`） |
| 7 | 设备 | ~1s 重启 |
| 8 | 手机 | 重连 → `6` → 心跳 `body.version` 与 `ota_manifest.version` 校验（§3.4.0.1） |

**传输文件**：`build/ESPRCCar.bin` 或交付包 `firmware/release/ota/ESPRCCar.bin`（≤6MiB）；随包 **`ota_manifest.json`** 供 App 解析目标版本与 SHA256。**版本解析与比对**：§3.4.0.1。**旧→新验证**：§7.5.5。

**禁止**：未收 `1002/query` 就发 `0`；未发 `0` 就发 OT 帧；OTA 过程中混发控制 JSON。

### A.4 配网（UART 模式）

| 功能 | 操作 |
|------|------|
| 修改 `device_id` | 长按重置键 2s → SoftAP → 浏览器 `192.168.4.1` |

### A.5 不需要实现的行为

- 控制 JSON **无 `OK` 文本回复**
- 不要等待设备上电 **主动** 发 `1002`（须先发 `6`）
- Release 下 **不要** 依赖 USB 口收业务 JSON

---

## 8. WiFi + MQTT 模式（热点配网 + 数据流量）

本章节适用于 `CONFIG_APP_LINK_WIFI=y` 的固件。设备作为 **MQTT 客户端** 连接 Broker，控制与心跳均为 **UTF-8 JSON**（与 BLE/UART 的 `remote_control` / `heart_payload` 结构一致）。

### 8.1 配网流程

1. 设备上电后若无 `wifi_ssid`，开启 SoftAP（见 §1.2）。
2. 手机连接设备热点，浏览器打开 `http://192.168.4.1`，填写 **WiFi SSID/密码** 与 **`device_id`**。
3. 提交后设备重启，STA 连接路由器，从云端拉取 MQTT Broker 列表（`CONFIG_ROBOIOT_MQTT_URL` + `device_id`）。
4. 拉取失败时使用默认 Broker（固件内 `DEFAULT_MQTT_HOST`，见 `mqttconf_commun.c`）。

### 8.2 MQTT 主题与 JSON

连接成功后设备订阅（`device_id` 为 NVS 中的完整设备号）：

| 方向 | 主题示例 | 说明 |
|------|----------|------|
| 云端/App → 设备 | `app2dev/<device_id>` | 遥控 JSON，走 `remote_control_apply_json` |
| 云端/App → 设备 | `ser2dev/<device_id>` | 同上（服务侧下发） |
| 设备 → 云端/App | `<device_id>` | 周期心跳（WiFi/MQTT 仍约 3s；BLE 见 §3.5 **10s**；UART 见 §3.5 **3s**） |
| 设备 → 云端/App | `dev2app/<device_id>` | 同上副本 |

**心跳 JSON**（`heart_payload_json_malloc`，含 STA `ip`）示例：

```json
{
  "head": { "message_type": 1 },
  "body": {
    "ip": "192.168.1.100",
    "voltage": "12.34",
    "device_ID": "CN110200000001",
    "version": "1.0.1"
  }
}
```

**控制 JSON**：与 §4 节 BLE **0xFFE1** 写入格式相同（`head` + `body`，含 `pwm_ctrl` 等）。

### 8.3 OTA（HTTPS，非 GATT/串口二进制）

WiFi 模式 OTA **不走** §3.4 的 `0x01/0x02/0x03`。云端通过 MQTT 下发 JSON，固件 `ota_update_recmqtt()` 解析后 **HTTPS 下载** 固件并写入 OTA 分区。

典型字段（见 `main/ota.c`）：

| 字段 | 说明 |
|------|------|
| `new_version` | 目标版本；与当前 `CONFIG_MY_APP_VERSION` 相同则跳过 |
| `esp32_url` | 固件 `.bin` 的 HTTPS 下载地址 |

升级成功后设备重启；可通过心跳 **`body.version`** 或 MQTT 上报主题 **`esp32updata`** 核对版本。

### 8.4 检查清单（WiFi 模式）

- [ ] 固件为 **WiFi 链路** 构建（`CONFIG_APP_LINK_WIFI=y`）。  
- [ ] NVS 已写入 **`wifi_ssid` / `wifi_pass` / `device_id`**。  
- [ ] 设备 STA 已获取 IP，MQTT 已连接。  
- [ ] App/云端向 `app2dev/<device_id>` 下发控制 JSON 有响应。  
- [ ] 连接后尽快收到首条心跳，之后约 **10s** 周期，含 **`version`**、**`voltage`**。  
- [ ] OTA 使用 HTTPS URL，勿向 BLE 特征或串口发二进制 OTA 包。

---

## 9. 版本与维护

| 字段 | 说明 |
|------|------|
| **固件版本号** | menuconfig → `MY_APP_VERSION` → 编译宏 `CONFIG_MY_APP_VERSION`（当前 **`1.0.1`**） |
| **运行时可见** | 心跳 JSON → `body.version`（BLE **0xFFE3** / UART 串口 / WiFi MQTT 均同结构） |
| **OTA 校验** | WiFi：HTTPS 升级后读 `body.version`；BLE/UART：二进制 OTA 后读 `body.version`，失败可自动回退（见 §3.4） |
| **文档版本** | 与固件主版本一致；发版时复制/重命名为 `Android端设备对接文档_v<版本>.md` |

**发版检查（维护者）**

1. 修改 `MY_APP_VERSION` → `idf.py build` → `scripts\copy_firmware_release.bat`  
2. 核对 `firmware/release/ota_manifest.json` 中 `version`、`bytes`、`sha256`
3. 将 `docs/Android端设备对接文档_v1.0.1.md` 与 **两个 OTA 文件** `release/ota/ESPRCCar.bin`、`release/ota_manifest.json` 一并交给 Android

- 文档生成依据：仓库内 `link_ble.c`、`link_uart.c`、`ota_offer_protocol.c`、`ota_binary_stream.c`、`mqttconf_commun.c`、`ota.c`、`heart_payload.c`、`remote_control.c`、`app_run.c`、`build_config.h`、`sdkconfig.defaults.release`、`Kconfig.projbuild`、`partitions.csv`、`README.md`。  
- 若固件修改 UUID、最大长度、JSON 字段或 OTA 行为，请 **递增版本号** 并同步更新本文档文件名与 §0 交付物表。

---

**文档路径**：`docs/Android端设备对接文档_v1.0.1.md`  
**配套固件**：`firmware/release/ota/ESPRCCar.bin`（`CONFIG_MY_APP_VERSION="1.0.1"`；**仅 BLE/UART 二进制 OTA 使用**）
