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

| 项 | 值 |
|----|-----|
| **固件版本** | `1.0.1`（`CONFIG_MY_APP_VERSION`，心跳 `body.version` 同值） |
| **文档版本** | `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（`sdkconfig.defaults.release`）：无 UART 日志，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 模式走 HTTP，BLE/UART 模式走二进制流（opcode 0x01/0x02/0x03）。

协议以固件源码为准：`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 → §5 Android 示例 → §6 检查清单 |
| **UART** | §7 硬件/串口 → §7.5 OTA → §7.8 检查清单 |
| **WiFi + MQTT** | §8 热点配网 → §8.2 MQTT 主题与 JSON → §8.3 HTTP OTA |

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

与本文 **v1.0.1** 一并提供的二进制建议来自 `firmware/release/`（运行 `scripts\copy_firmware_release.bat` 后生成）：

| 文件 | 用途 |
|------|------|
| **`release/ota/ESPRCCar.bin`** | **BLE / UART** 二进制 OTA 包；**WiFi** 模式由云端 HTTPS 下发 URL 升级 |
| **`release/VERSION.txt`** | 版本号、编译时间、OTA 文件 SHA256 |
| **`release/manifest.json`** | 同上 + factory 各 bin 校验 |
| **本文档** | `Android端设备对接文档_v1.0.1.md`（含 WiFi / BLE / UART 三种链路） |

联调前请确认：固件 `menuconfig` 链路模式与 App 一致；心跳 `body.version` 为 **`1.0.1`**。BLE 模式另需按 §3.5 解析 **0xFFE3** 的 `message_type` **1 / 4 / 5**（勿再订阅已删除的 **0xFFE5**）。

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

- **删除 GATT `0xFFE5`**：告警/错误改由 **`0xFFE3` Notify** 推送，`head.message_type` **4**=告警、**5**=错误，`body.msg` 为文本。
- **心跳仍在 `0xFFE3`**：`message_type` **1**，周期默认 **3s**，字段见 §3.5-A。
- **Release 固件默认无串口日志**；诊断依赖 BLE Notify，勿依赖 USB 监视器。
- **设备无 WS2812 状态灯**，日志与连接状态请仅依据 JSON / App UI。
- **手机 → 设备** 写入 **0xFFE1** 的 `message_type` **4** 仍为 **GPIO `pin_setctrl`**（与 **0xFFE3** 上设备→手机的 **4/5** 方向不同，勿混淆）。

---

## 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_OTA` 后向 **0xFFE2** 写入将返回 **Write Not Permitted**。

**Release 构建**：默认关闭 UART 控制台（`CONFIG_ESP_CONSOLE_NONE`）；固件 `W`/`E` 日志在 BLE 已连接时经 **0xFFE3** 即时 Notify 推送（与周期心跳共用特征值，见 §3.5），或经 UART 发送（见 §7.5）。

### 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` 均非空才启动广播；否则进配网。
- **UART**：`device_id` 非空则启动 UART1；否则进配网。正常运行时**不连 WiFi、不启 BLE**。

**按键**：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`，默认 3s）+ **即时告警/错误**（`4`/`5`，见 §3.5）；Read 为最近一次 Notify 的 UTF-8 JSON |
| OTA 状态 | `0xFFE4` | Read、Notify | **OTA 过程 JSON 状态**：Read 为最近一次状态串；成功处理 **0x01 / 每包 0x02 / 0x03 收尾** 后会 **Notify**（需订阅 CCCD） |

### 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.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 二进制协议（0xFFE2）

首字节为操作码（固件总长度使用 **小端 uint32**）：

| Opcode | 载荷 | 含义 |
|--------|------|------|
| `0x01` | 后跟 **4 字节** `uint32_t` 固件总长度（**小端**） | 开始 OTA 会话，长度须与随后所有 `0x02` 数据之和一致 |
| `0x02` | 后跟 **1～511 字节**原始固件数据 | 顺序写入一块镜像数据（整包 ATT 写长度 ≤512，故数据最多 511 字节） |
| `0x03` | 无额外数据 | 结束会话：校验已写字节等于 `0x01` 声明长度 → `esp_ota_end` → 切换启动分区 → **延迟后重启** |

单包拷贝上限 **512 字节**（含 opcode）。建议在手机端 **协商较大 MTU** 后再传大包，但仍须满足上述单包上限。

#### 3.4.3 「传一包、应一包」

1. **ATT 写应答**：对 **0xFFE2** 使用 **带响应的 Write**（`WRITE_TYPE_DEFAULT`）时，从机返回 **Write Response** 即表示本包已被固件处理完毕（opcode 合法且 `0x02` 已写入 Flash 缓冲）；若返回错误 ATT 码则表示本包失败，应重试或中止会话。  
2. **JSON 状态 Notify（推荐与日志联调）**：订阅 **`0xFFE4`** 的 Notify 后，固件在 **`0x01` 成功、`0x02` 每成功一包、`0x03` 成功或失败路径** 会推送短 **UTF-8 JSON**（见 §3.6）。App 可在 `onCharacteristicChanged` 中解析 `written` 等字段做进度条。

若使用 **Write Without Response**，则无 ATT 层「单包确认」，请依赖 **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` → 约 **2 秒** 后自动重启 |
| 断开连接 | BLE 断开 **不会** 自动 `abort` 未完成的 OTA 句柄；建议 App 断连前发 `0x03` 失败则重新 `0x01` 全量重传 |

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

**OTA 支持模式**：BLE 和 UART 模式使用**完全相同的 OTA 二进制协议**（opcode 0x01/0x02/0x03）。

| 传输模式 | 发送通道 | 状态回复通道 | 数据格式 |
|----------|----------|--------------|----------|
| BLE | Write 0xFFE2 | Notify 0xFFE4 | 原始二进制 |
| UART | 串口发送 | 串口接收 JSON | 原始二进制 |

**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. 进度条用 **0xFFE4** 的 `written` / `expect`，不要假设每包 Notify 都到达（可丢包，以 Write 成功为准）。

#### 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)` 时应 **停止发送** 并提示用户；可 Read **0xFFE4** 最近一次 JSON 查 `err` 字段。

#### 3.4.9 0xFFE4 状态 JSON 完整字段

| `ota` 字段 | `ok` | 其它字段 | 含义 |
|------------|------|----------|------|
| `begin` | `1` | `expect` | 会话已开始，可发 `0x02` |
| `begin` | `0` | `err` | `size` / `no_partition` / `esp_ota_begin` |
| `chunk` | `1` | `written` | 已累计写入字节（从 0 递增） |
| `chunk` | `0` | `err` | `overflow` / `write` |
| `end` | `1` | — | 即将重启（设备约 300 ms 内断开） |
| `end` | `0` | `err` | `incomplete` / `esp_ota_end` / `set_boot` |

**注意**：每个成功的 `0x02` 都会 Notify 一次 `chunk`（大包时 Notify 较密），UI 可做节流，仅每 N 包刷新一次。

#### 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` 失败或启动异常。

#### 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 失败、回退或设备重启未连上同一设备 | 读 **0xFFE3** 心跳 `body.version` 确认；检查OTA失败回退日志 |
| 传输极慢 | MTU 仍为 23 | `requestMtu` 并等 `onMtuChanged` 后再传 |
| 中途断连 | 距离/干扰 | 靠近设备；实现断点续传需 App 侧重新 `0x01` 全量 |

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

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

#### A. 周期心跳（设备 → 手机，约 3s 一条）

- 生成逻辑：`heart_payload_json_malloc()`（`main/protocol/heart_payload.c`）。
- `head.message_type` = **1**
- `body`（**BLE 不含 `ip`**；MQTT 仍带 STA `ip`）：
  - **`voltage`**：两位小数字符串，电压值（见下方电压计算说明）
  - **`device_ID`**：`app2dev/<device_id>`
  - **`version`**：`CONFIG_MY_APP_VERSION`（当前运行固件版本，用于判断OTA是否成功）
- **周期**：默认 **3000 ms**（`BLE_HEART_PERIOD_MS`，编译期可改）。

**示例：**

```json
{
  "head": { "message_type": 1 },
  "body": {
    "voltage": "12.34",
    "device_ID": "app2dev/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。Read 返回 **最近一次** OTA 状态 JSON（UTF-8）；Notify 在 OTA 各步骤更新（需对 **0xFFE4** 写 CCCD 开启 Notify）。
- **与 0xFFE3 区分**：`0xFFE3` 承载心跳与告警 JSON；`0xFFE4` 仅在 **OTA 会话** 内随 **0xFFE2** 变化。`onCharacteristicChanged` 中按 UUID 与 `message_type` 分支。
- **Notify 载荷示例**（字段以固件为准，以下为典型形状）：
  - 开始成功：`{"ota":"begin","ok":1,"expect":<uint>}`  
  - 开始失败：`{"ota":"begin","ok":0,"err":"no_partition"}` 或 `"esp_ota_begin"`、`"size"` 等  
  - 数据包成功：`{"ota":"chunk","ok":1,"written":<uint>}`（`written` 为已累计写入总字节）  
  - 数据包失败：`{"ota":"chunk","ok":0,"err":"overflow"}` 或 `"write"`  
  - 结束成功（重启前）：`{"ota":"end","ok":1}`  
  - 结束失败：`{"ota":"end","ok":0,"err":"incomplete"}`、`"esp_ota_end"`、`"set_boot"` 等

---

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

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

### 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 == 1`：远程重启

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

**示例：**

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

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

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

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mode` | number | 是 | 控制模式，见 4.5 节设备映射 |
| `val` | number | 是 | 控制量，与 `mode` 联合解释 |
| `type` | number | 否 | 固件读取；若缺省或非数字，按 **0** 处理（当前映射逻辑未使用 `type` 分支） |

### 4.4 `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.5 `pwm_ctrl.mode` 与 `val`（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° |

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.6 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.7 错误与静默失败

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

---

## 5. Android 实现要点

### 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. **心跳 / 告警**：对 **`0xFFE3`** 开启 **Notify**（CCCD `0x0001`）。在 `onCharacteristicChanged` 中按 `head.message_type` 解析：**1**=周期心跳（§3.5-A），**4**=告警、**5**=错误（§3.5-B，Release 固件即时推送）。
9. **OTA（可选）**：对 **`0xFFE4`** 开启 **Notify**，再按 **§3.4** 向 **`0xFFE2`** 顺序写入 `0x01` → 多包 `0x02` → `0x03`；建议带响应 Write 并解析 **§3.6** JSON。

### 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 OTA 参考实现（Kotlin 骨架）

下列逻辑与固件 `gatt_svr_chr_ota_access` / `ble_ota_*` 一致，便于直接联调；需自行接入 `BluetoothGatt`、权限与线程。

```kotlin
// CCCD：对 0xFFE4 开启 Notify
val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")

fun enableOtaStatusNotify(gatt: BluetoothGatt, otaStat: BluetoothGattCharacteristic) {
    gatt.setCharacteristicNotification(otaStat, true)
    val cccd = otaStat.getDescriptor(CCCD) ?: return
    cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
    gatt.writeDescriptor(cccd)
}

/** 根据协商 MTU 计算每包固件数据上限（不含 opcode 0x02） */
fun otaChunkDataSize(mtu: Int): Int = minOf(511, (mtu - 3) - 1)

/** 组包：0x01 + imageSize LE */
fun buildBeginPacket(imageSize: Int): ByteArray {
    require(imageSize > 0)
    return byteArrayOf(
        0x01,
        (imageSize and 0xFF).toByte(),
        ((imageSize shr 8) and 0xFF).toByte(),
        ((imageSize shr 16) and 0xFF).toByte(),
        ((imageSize shr 24) and 0xFF).toByte()
    )
}

/** 组包：0x02 + slice */
fun buildChunkPacket(slice: ByteArray, offset: Int, len: Int): ByteArray {
    val out = ByteArray(1 + len)
    out[0] = 0x02
    System.arraycopy(slice, offset, out, 1, len)
    return out
}

fun buildEndPacket(): ByteArray = byteArrayOf(0x03)

/** 解析 0xFFE4 Notify（可用 org.json 或 Gson） */
fun parseOtaStatusJson(json: String): OtaStatus? {
    // 期望字段：ota, ok, expect?, written?, err?
    // 示例：{"ota":"chunk","ok":1,"written":65536}
    ...
}

/**
 * 发送顺序（须在主线程/GATT 队列中串行）：
 * 1. writeCharacteristic(CHR_OTA, begin, WRITE_TYPE_DEFAULT)
 * 2. onCharacteristicWrite 成功 → 循环 write chunk，直到 fileOffset == imageSize
 * 3. write end
 * 4. 收到 0xFFE4 {"ota":"end","ok":1} 或断连后重新扫描，读 0xFFE3 version 校验
 */
```

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

```kotlin
when (characteristic.uuid) {
    CHR_HEARTBEAT -> {
        val json = JSONObject(String(value, Charsets.UTF_8))
        when (json.getJSONObject("head").getInt("message_type")) {
            1 -> { /* §3.5-A 心跳 */ }
            4, 5 -> { /* §3.5-B 告警/错误 */ val msg = json.getJSONObject("body").getString("msg") }
        }
    }
    CHR_OTA_STATUS -> { parseOtaStatusJson(String(value, Charsets.UTF_8)) }
}
```

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

1. 扫描连接 → `discoverServices` → `requestMtu(517)`。  
2. 对 **0xFFE4** 写 CCCD 开 Notify；可选开 **0xFFE3** 便于升级后看 `version`。  
3. 从 assets 或下载目录读取 **`ESPRCCar.bin`**，记录 `fileSize`。  
4. 写 **0xFFE2** `buildBeginPacket(fileSize)`，等 **Write Success**，并确认 Notify：`{"ota":"begin","ok":1,"expect":...}`。  
5. 按 `otaChunkDataSize(mtu)` 循环 **0x02**，每包等 Write Success（或观察 `written` 递增）。  
6. 写 **0x03**，等 Notify `{"ota":"end","ok":1}`，设备重启。  
7. 重连后读心跳 **`body.version`**，应与新固件 `CONFIG_MY_APP_VERSION` 一致。

---

## 6. 联调检查清单

- [ ] 固件已烧录 **BLE 链路** 构建，且 NVS 已写入非空 **`device_id`** 与 **`ble_adv_name`**。  
- [ ] 手机蓝牙已打开，应用已获 **扫描/连接** 权限。  
- [ ] 扫描到的设备名以 **`MP` 结尾**（或包含 `MP`），与固件广播规则一致。  
- [ ] 已发现 **0xFFE0** 服务及 **0xFFE1**、**0xFFE3** 特征；已对 **0xFFE3** 开启 **Notify** 并能收到心跳 JSON。  
- [ ] （OTA）固件 **`CONFIG_APP_BLE_OTA=y`**，镜像为 **`build/ESPRCCar.bin`**，大小 **≤ 6 MiB**，与设备 **分区表/芯片** 一致。  
- [ ] （OTA）已对 **0xFFE4** 写 CCCD 开启 **Notify**；**0xFFE2** 使用 **带响应 Write**，且 **等上一包 Success 再发下一包**。  
- [ ] （OTA）已 `requestMtu(517)`，`chunk` 长度 ≤ `min(511, mtu-4)`。  
- [ ] （OTA）流程：**0x01（5B）→ 多包 0x02 → 0x03（1B）**；`0x01` 中长度与文件字节数一致。  
- [ ] （OTA）收到 `{"ota":"end","ok":1}` 后设备重启；重连后 **0xFFE3** 中 `version` 已更新。  
- [ ] （OTA）失败时能读到 **0xFFE4** 的 `err`（`overflow` / `incomplete` / `esp_ota_end` 等）或 ATT **0x0D / 0x0E**。  
- [ ] （OTA）固件支持失败自动回退：如果新固件启动失败（最多3次），自动回退到上一版本。  
- [ ] 心跳 `body.voltage` 显示正确的电池电压值（分压系数=11，GPIO5 ADC采样）。  
- [ ] （Release）`onCharacteristicChanged` 能区分 **0xFFE3** 的 `message_type` **1 / 4 / 5**（告警与心跳同特征值）。  
- [ ] 已协商足够 **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 | 可在 menuconfig 中配置 `CONFIG_APP_UART_LINK_BAUDRATE` |
| **数据位** | 8 bits | - |
| **校验位** | None | - |
| **停止位** | 1 bit | - |
| **流控** | None | 无硬件流控 |
| **帧格式** | JSON + `\n` | 每条 JSON 以换行符 `\n` 结尾 |

### 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)
        
        // 配置串口参数（必须匹配固件配置）
        port?.setParameters(115200, 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()
        
        return true
    }
    
    // 发送 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 -> { /* 错误消息 */ }
            }
        } 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` |
| **心跳接收** | Notify 0xFFE3 | 接收 JSON + `\n` |
| **告警/错误** | Notify 0xFFE3 (message_type 4/5) | 接收 JSON + `\n` (message_type 4/5) |
| **JSON 格式** | 完全相同 | 完全相同 |
| **连接检测** | `onConnectionStateChange` | 5秒内未收到数据认为离线 |

**示例 - 发送控制指令（UART）：**
```kotlin
// 与 BLE 0xFFE1 完全相同的 JSON
val controlJson = """
{
    "head": {"message_type": 1},
    "body": {
        "pwm_ctrl": {"throttle": 50, "steer": 30}
    }
}
""".trimIndent()

uartManager.sendJsonCommand(controlJson)
```

**示例 - 接收心跳（UART）：**
```kotlin
// 固件每3秒自动发送，与 BLE 0xFFE3 相同格式
{"head":{"message_type":1},"body":{"device_ID":"110201","voltage":12.5,"percent":80}}
```

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

UART 模式支持与 BLE 模式**完全相同的 OTA 二进制协议**（opcode 0x01/0x02/0x03）。

**OTA 状态回复**（通过串口返回 JSON + `\n`）：
```json
{"ota":"begin","ok":1,"expect":1048576}
{"ota":"chunk","ok":1,"written":8192}
{"ota":"end","ok":1}  // 完成后自动重启
```

### 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. **FT232 模块选择**：推荐使用带 **TX/RX LED 指示灯** 的模块，便于观察数据收发
2. **波特率匹配**：确保 Android 端设置的波特率与固件 `CONFIG_APP_UART_LINK_BAUDRATE` 一致（默认 115200）
3. **数据格式**：每条 JSON 必须以换行符 `\n` 结尾，否则固件无法正确解析
4. **OTG 权限**：Android 需要申请 USB 设备权限才能访问 FT232 模块

```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/8/N/1（与固件配置一致）。  
- [ ] JSON 以换行符 `\n` 结尾，UTF-8 编码。  
- [ ] 能收到周期心跳（默认 3 秒一次），`message_type=1`。  
- [ ] 发送控制 JSON 后车辆有响应（马达/舵机动作）。  
- [ ] 心跳 `body.voltage` 显示正确电池电压值。  
- [ ] 心跳 `body.version` 显示正确固件版本号。  
- [ ] 5 秒内无数据则认为设备离线。  
- [ ] 长按重置键 2 秒可进入配网模式修改 `device_id`。
- [ ] （OTA）支持通过串口发送二进制数据升级固件（opcode 0x01/0x02/0x03）。

---

## 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>` | 周期心跳（约 **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": "app2dev/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 有响应。  
- [ ] 约 3s 周期收到心跳，含 **`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/VERSION.txt` 中 `version=1.0.1`  
3. 将 `docs/Android端设备对接文档_v1.0.1.md` 与 `release/ota/ESPRCCar.bin`（及可选 `manifest.json`）一并交给 Android  

- 文档生成依据：仓库内 `link_ble.c`、`link_uart.c`、`mqttconf_commun.c`、`ota.c`、`heart_payload.c`、`remote_control.c`、`app_run.c`、`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 使用**）
