Compare commits
4
Commits
2875ef2145
...
2586582e57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2586582e57 | ||
|
|
98b5cc1ba1 | ||
|
|
5b2c5f8ff9 | ||
|
|
5060db0a8e |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev-server",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8080"],
|
||||
"port": 8080,
|
||||
"autoPort": true
|
||||
},
|
||||
{
|
||||
"name": "tools-server",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8080", "--directory", "tools"],
|
||||
"port": 8080,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# ESP32-C3 OTA Relay
|
||||
|
||||
**固件版本: v0.3.12** | 构建时间: 2026-06-14 | 基于 ESP-IDF v6.0
|
||||
**固件版本: v0.3.12** | 构建时间: 2026-06-17 | 基于 ESP-IDF v6.0
|
||||
|
||||
## 项目概述
|
||||
|
||||
@@ -43,12 +43,13 @@ ESP32-C3FH4 OTA 中继固件,作为 **GD32E508VET6** 的 OTA 升级桥梁。
|
||||
| **core** | `core/main.cpp` | 程序入口,初始化各模块并创建 4 个 FreeRTOS 任务 |
|
||||
| **health_monitor** | `monitor/health_monitor.{h,cpp}` | 温度 + WiFi RSSI 监控,OTA 前健康检查 |
|
||||
| **debug_log** | `monitor/debug_log.{h,cpp}` | 三路日志输出:UART0 + UDP + 环形缓冲区 |
|
||||
| **ota_relay** | `relay/ota_relay.{h,cpp}` | GD32 OTA 中继核心状态机(四阶段 15 状态) |
|
||||
| **ota_coordinator** | `ota/ota_coordinator.{h,cpp}` | **全局 OTA 协调器**(单例):串行化 GD32/ESP32 OTA 操作,统一传输层入口,9 状态系统级状态机 |
|
||||
| **ota_relay** | `relay/ota_relay.{h,cpp}` | GD32 OTA 中继**事件驱动状态机**(6 阶段 + ERROR):通过 `dispatchQueue` 接收调度信号,`processEvent()` 驱动状态转换 |
|
||||
| **esp32_ota_core** | `ota/esp32_ota_core.{h,cpp}` | ESP32 本机 OTA 核心(begin/write/finalize/abort,WiFi/BLE 共用) |
|
||||
| **wifi_ota** | `server/wifi_ota.{h,cpp}` | WiFi AP/STA + HTTP OTA 服务器 + CAN 帧转发 |
|
||||
| **ble_ota** | `server/ble_ota.{h,cpp}` | BLE GATT 服务器(NimBLE 协议栈) |
|
||||
| **spi_master** | `transport/spi_master.{h,cpp}` | SPI 主站驱动 + CANFD 3 通道 + OTA 帧协议 |
|
||||
| **protocol** | `transport/protocol.{h,cpp}` | 协议层(nonce 搅伴生成、AES 会话密钥管理) |
|
||||
| **protocol** | `transport/protocol.{h,cpp}` | 协议层(nonce 搅伴生成、AES 会话密钥管理、运行时 SCRAMBLE_CONST) |
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -61,14 +62,16 @@ ESP32_COMM/
|
||||
├── cmake/
|
||||
│ └── gen_version_script.cmake # 构建时自动生成 version.h
|
||||
├── main/
|
||||
│ ├── CMakeLists.txt # 注册 11 个源文件 + 依赖
|
||||
│ ├── CMakeLists.txt # 注册 12 个源文件 + 依赖
|
||||
│ ├── include/
|
||||
│ │ ├── app_config.h # 统一配置 (constexpr: WiFi/BLE/OTA/阈值/密钥)
|
||||
│ │ ├── version.h # 自动生成版本信息 (v0.3.12)
|
||||
│ │ └── ota_mutex.h # BLE/WiFi OTA 全局互斥
|
||||
│ ├── core/main.cpp # app_main 入口,4 个 FreeRTOS 任务
|
||||
│ ├── relay/ota_relay.{h,cpp} # GD32 OTA 中继状态机
|
||||
│ ├── ota/esp32_ota_core.{h,cpp} # ESP32 本机 OTA 核心
|
||||
│ ├── relay/ota_relay.{h,cpp} # GD32 OTA 中继事件驱动状态机
|
||||
│ ├── ota/
|
||||
│ │ ├── esp32_ota_core.{h,cpp} # ESP32 本机 OTA 核心
|
||||
│ │ └── ota_coordinator.{h,cpp} # 全局 OTA 协调器
|
||||
│ ├── transport/
|
||||
│ │ ├── spi_master.{h,cpp} # SPI 主站 + CANFD 3 通道
|
||||
│ │ └── protocol.{h,cpp} # 协议层 (CRC16 / AES / nonce)
|
||||
@@ -107,7 +110,7 @@ ESP32_COMM/
|
||||
| `health_mon` | `HealthMonitor::taskEntry` | configMAX_PRIORITIES - 4 | 4096 | 温度监控 (1s 定时器) + 堆巡检 (5s) + 健康状态管理 |
|
||||
| `wifi_ota` | `WifiOta::taskEntry` | configMAX_PRIORITIES - 3 | 8192 | WiFi (AP/STA) + HTTP 服务器 (端口 80) + 状态轮询 |
|
||||
| `ble_ota` | `BleOta::taskEntry` | configMAX_PRIORITIES - 3 | 8192 | BLE GATT 服务器初始化 + NimBLE Host 任务 + 心跳保活 |
|
||||
| `ota_relay` | `OtaRelay::taskEntry` | configMAX_PRIORITIES - 2 | 12288 | 等待调度信号 → 四阶段状态机 → SPI 转发 |
|
||||
| `ota_relay` | `OtaRelay::taskEntry` | configMAX_PRIORITIES - 2 | 12288 | 等待 dispatchQueue 调度信号 → 事件驱动状态机(6 阶段)→ SPI 转发 → resultQueue 回传结果 |
|
||||
|
||||
额外后台任务:
|
||||
|
||||
@@ -119,7 +122,8 @@ ESP32_COMM/
|
||||
|
||||
### 跨任务通信
|
||||
|
||||
- **OTA 调度**: WiFi/BLE 任务通过 `OtaRelay::dispatchQueue` (FreeRTOS 队列, 深度 4) 发送调度信号给 `ota_relay` 任务;结果通过 `resultQueue` 回传
|
||||
- **OTA 协调**: **OtaCoordinator** 管理系统级 OTA 状态(9 状态),提供 `beginGd32Storage/completeGd32Storage/triggerGd32Relay/beginEsp32Ota` 等统一入口,内部 mutex 保护状态读写
|
||||
- **OTA 调度**: WiFi/BLE 任务通过 `OtaRelay::dispatchQueue` (FreeRTOS 队列, 深度 4) 发送调度信号给 `ota_relay` 任务;结果通过 `resultQueue` 回传;OtaCoordinator 也向此队列投递中继触发命令
|
||||
- **OTA 互斥**: `g_esp32OtaMutex` (全局 Mutex) + `g_esp32OtaOccupied` 标志,防止 BLE 和 WiFi 同时操作 ESP32 OTA 分区
|
||||
- **中继状态**: `OtaRelay::s_stateMutex` 保护 relayState/relaySeq/relayTotalPackets 等跨任务状态(`StateLockGuard` RAII 封装)
|
||||
- **WiFi 状态**: `WifiOta::m_stateMutex` + `std::atomic` 保护 WiFi 连接状态和重连计数器
|
||||
@@ -128,29 +132,54 @@ ESP32_COMM/
|
||||
|
||||
## OTA 核心流程
|
||||
|
||||
### 一、GD32 OTA 中继(四阶段 + APP 验证)
|
||||
### 一、GD32 OTA 中继(事件驱动状态机,6 阶段 + ERROR)
|
||||
|
||||
上位机上传 GD32 固件到 ESP32 本地 `gd32_ota` 分区 → 收到触发命令后启动状态机:
|
||||
上位机上传 GD32 固件到 ESP32 本地 `gd32_ota` 分区(通过 **OtaCoordinator** 协调上传状态)→ 收到触发命令后由 **OtaRelay** 事件驱动状态机执行转发:
|
||||
|
||||
```
|
||||
阶段1 ─ 安全握手 (5 状态)
|
||||
RELAY_HANDSHAKE_SENT → RELAY_CHALLENGE_SENT → RELAY_CHALLENGE_RESPOND
|
||||
→ RELAY_SESSION_KEY_SENT → RELAY_AUTH_COMPLETE
|
||||
状态机总览:
|
||||
|
||||
阶段2 ─ 固件大小协商 (2 状态)
|
||||
RELAY_SIZE_SENT → RELAY_SIZE_ACK
|
||||
|
||||
阶段3 ─ 数据包转发 (4 状态)
|
||||
RELAY_RELAY_ACTIVE → RELAY_PACKET_SENT → RELAY_PACKET_ACK_OK
|
||||
[重试] RELAY_PACKET_ACK_RETRY
|
||||
|
||||
阶段4 ─ 完成与跳转 (3 状态)
|
||||
RELAY_RECEIVE_DONE → RELAY_FINISH_SENT → RELAY_DONE
|
||||
|
||||
阶段5 ─ APP 存活验证
|
||||
RELAY_APP_VERIFY (PING GD32 APP, 最多 30 次 × 500ms)
|
||||
IDLE ──(EVT_START)──→ HANDSHAKING ──→ SIZE_NEG ──→ TRANSFERRING
|
||||
↑ │ │ │
|
||||
│ (error) (error) (error)
|
||||
│ ↓ ↓ ↓
|
||||
└──── DONE ←── FINISHING ←────────────────────────── ERROR
|
||||
│ ↑ │
|
||||
└──── (EVT_START) ───┘ (EVT_START)───┘
|
||||
```
|
||||
|
||||
| 阶段 | 状态 | 输入事件 | 输出/转换 |
|
||||
|------|------|----------|-----------|
|
||||
| **① 安全握手** | `HANDSHAKING` | EVT_START / EVT_RESP_OK | `doHandshakeReq()` → `doChallengeAndAuth()` → 建立 `K_session` AES 加密通道 |
|
||||
| **② 大小协商** | `SIZE_NEG` | EVT_START | `doSendFirmwareSize()` → 加载 NVS 元数据中的 `total_size` |
|
||||
| **③ 数据包转发** | `TRANSFERRING` | EVT_START | `doRelayPackets()` → 逐包 AES 加密转发 + 结构化 ACK 校验 + 健康检查 |
|
||||
| **④-⑤ 完成与验证** | `FINISHING` | EVT_START | `doFinishAndVerify()` → 发送 FINISH → PING 轮询 APP 存活(最多 30 次 × 500ms) |
|
||||
| **完成** | `DONE` | EVT_START → 重入 IDLE | 状态机复位 |
|
||||
| **错误** | `ERROR` | 任意错误超时/健康危险 | `reset()` → IDLE |
|
||||
|
||||
#### 协调器系统级状态(OtaCoordinator)
|
||||
|
||||
```
|
||||
状态流转:
|
||||
|
||||
IDLE ──→ GD32_STORING ──→ GD32_STORED ──→ GD32_RELAYING ──→ GD32_COMPLETE
|
||||
│ │
|
||||
├──→ ESP32_RECEIVING ──→ ESP32_FINALIZING ──→ ESP32_REBOOTING
|
||||
└──→ ERROR (任意阶段)
|
||||
```
|
||||
|
||||
| 系统状态 | 含义 |
|
||||
|----------|------|
|
||||
| `OTA_SYS_IDLE` | 空闲,可接受新请求 |
|
||||
| `OTA_SYS_GD32_STORING` | 传输层正在存储 GD32 固件到 Flash |
|
||||
| `OTA_SYS_GD32_STORED` | GD32 固件已完整存储,等待触发中继 |
|
||||
| `OTA_SYS_GD32_RELAYING` | SPI 中继转发进行中 |
|
||||
| `OTA_SYS_GD32_COMPLETE` | GD32 OTA 完成 |
|
||||
| `OTA_SYS_ESP32_RECEIVING` | 接收 ESP32 自身固件 |
|
||||
| `OTA_SYS_ESP32_FINALIZING` | SHA 校验 + end + set_boot |
|
||||
| `OTA_SYS_ESP32_REBOOTING` | 即将重启 |
|
||||
| `OTA_SYS_ERROR` | 错误状态 |
|
||||
|
||||
#### 安全握手协议
|
||||
|
||||
1. **握手请求**: ESP32 发送 `0x00 0xAC`(最多重试 10 次,200ms 超时)
|
||||
@@ -159,26 +188,30 @@ ESP32_COMM/
|
||||
4. **确认**: ESP32 发送 `nonce_server ^ 0x55` 经 `K_session` AES-ECB 加密
|
||||
5. **完成**: GD32 回复 `0xBA 0xC0`,建立 AES 加密通道
|
||||
|
||||
> **GD32 UID **: 运行时从握手 Challenge 包解包获得,不再使用编译期常量 `GD32_CHIP_UID`。
|
||||
|
||||
#### 数据包转发
|
||||
|
||||
- 每包 48 字节固件载荷,使用 `K_session` AES-ECB 加密
|
||||
- 包结构: `session(4B) + seq(4B) + encrypted48(48B) + if_end(4B) + crc32(4B)` = 64B
|
||||
- 每 32 包执行一次 OTA 健康检查(温度/堆)
|
||||
- 支持重试(单包最多 10 次),结构化 ACK 含 seq 校验,残留 ACK 自动过滤
|
||||
- 全局超时: 动态计算 5~10 分钟
|
||||
- 支持结构化 ACK 含 seq 校验 + 错误码(`0x04`=CRC 错误),残留 ACK 自动过滤
|
||||
- 单包最多 10 次重试,全局超时动态计算 5~10 分钟
|
||||
- **Flash 元数据**: 通过 NVS 记录固件 `total_size`/`stored_size`/`last_seq`/`checksum`,用于完整性校验和中继恢复判断
|
||||
|
||||
### 二、ESP32 本机 OTA(WiFi + BLE 共用)
|
||||
|
||||
核心逻辑封装在 `Esp32OtaCore` 单例中,WiFi 和 BLE 通道共用:
|
||||
核心逻辑封装在 `Esp32OtaCore` 单例中,WiFi 和 BLE 通道共用。
|
||||
传输层通过 **OtaCoordinator** 统一入口(`beginEsp32Ota()`)发起操作,由协调器管理系统状态并委托 `Esp32OtaCore` 执行底层写入:
|
||||
|
||||
| 步骤 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `begin(total_size)` | 选择非当前 OTA 分区,获取全局互斥,`esp_ota_begin` |
|
||||
| 2 | `write(data, len)` | 循环调用 `esp_ota_write`(WiFi 通过 HTTP 流,BLE 通过 GATT 块) |
|
||||
| 3 | `finalize(sha_hex, reboot)` | `esp_ota_end` → 从分区读回计算 SHA-256 → 比对前端 SHA → `esp_ota_set_boot_partition` → 可选重启 |
|
||||
| 3 | `finalize(sha_hex, reboot)` | 从分区读回计算 SHA-256 → 比对前端 SHA → `esp_ota_end` → `esp_ota_set_boot_partition` → 可选重启 |
|
||||
| - | `abort()` | 中止并清理(释放 mutex + 复位状态) |
|
||||
|
||||
**SHA-256 校验**: 固件写入完成后从 OTA 分区读回计算,不做增量 SHA。前端与设备端比对一致才 set_boot,否则返回 `ESP_ERR_INVALID_VERSION`。
|
||||
**SHA-256 校验**: 固件写入完成后从 OTA 分区读回计算,不做增量 SHA。前端与设备端 SHA 比对一致才执行 finalize(`Esp32OtaCore::finalize()` → `esp_ota_end` → `esp_ota_set_boot_partition`),否则返回 `ESP_ERR_INVALID_VERSION`。过程中系统状态由 `OtaCoordinator` 跟踪(`ESP32_RECEIVING → ESP32_FINALIZING → ESP32_REBOOTING`)。
|
||||
|
||||
**回滚保护**: `esp_ota_mark_app_valid_cancel_rollback()` 在启动时标记当前固件有效。启用 `CONFIG_APP_ROLLBACK_ENABLE` 后,新固件崩溃自动回退。
|
||||
|
||||
@@ -198,6 +231,7 @@ ESP32_COMM/
|
||||
| 0x02 | OTA_CMD_DATA | OTA 数据帧 |
|
||||
| 0x03 | OTA_CMD_CANCEL | 取消 OTA |
|
||||
| 0xB0 | APP_ENTER_BOOT_CMD | 通知 GD32 APP 进入 Bootloader |
|
||||
| 0xB5 | APP_ENTER_BOOT_ACK_OK | GD32 APP 确认进入 Bootloader |
|
||||
| 0x80 | RSP_ACK | 响应:数据就绪 |
|
||||
| 0x81 | RSP_WAIT | 响应:GD32 忙,请等待 |
|
||||
| 0x82 | RSP_ERROR | 响应:错误 |
|
||||
@@ -263,7 +297,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器)
|
||||
| UUID | 特征 | 属性 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 0xFFFF | Service | - | OTA GATT 服务 |
|
||||
| 0x0001 | CTRL | Write | 控制命令:`0xF7`=触发GD32中继, `0xF8+32B SHA`=ESP32 OTA, `0xFA`=重启ESP32 |
|
||||
| 0x0001 | CTRL | Write | 控制命令:`0xF7`=触发GD32中继, `0xF8+32B SHA`=ESP32 OTA, `0xF9+32B SHA`=仅SHA校验(不入库/不重启), `0xFA`=重启ESP32 |
|
||||
| 0x0002 | DATA_IN | Write | 固件数据块:`cmd(1)+seq(2)+total_size(4)+payload` |
|
||||
| 0x0003 | DATA_OUT | Notify | 推送:ACK(窗口确认, 0xA1+8B) / FINISH(阶段事件, 0xB1+6B) / SHA256(0xC1+33B) |
|
||||
| 0x0004 | VERSION | Read | 固件版本字符串 |
|
||||
@@ -272,7 +306,8 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器)
|
||||
|
||||
- **ACK 窗口**: 每 32 包等待一次 DATA_OUT ACK(带累计字节数验证)
|
||||
- **窗口重传**: ACK 超时或字节数不足时重发整个窗口(最多 3 次)
|
||||
- **ESP32 收尾**: BLE 传输完成后由前端发 `0xF8 + 32B 文件 SHA-256`,设备端创建异步任务执行 finalize(含 SHA 比对),结果通过 FINISH 事件通知前端
|
||||
- **乱序重排**: Reorder buffer(32 槽)吸收 BLE 窗口内乱序到达的包,按 seq 排序后顺序写入 `Esp32OtaCore`
|
||||
- **ESP32 收尾**: BLE 传输完成后由前端发 `0xF8 + 32B 文件 SHA-256`,设备端读回 OTA 分区做 SHA 比对,通过后完成 `esp_ota_end`、设置启动分区并重启;`0xF9` 命令仅做 SHA 比对不入库(预检),FINISH 仅上报完成/重启/错误
|
||||
- **GD32 触发**: 传输完成后自动发 `0xF7` 触发 SPI 中继
|
||||
- **合并包**: NimBLE 可能将多个 writeWithoutResponse 合并到同一 mbuf,接收端支持一次解析多个数据包
|
||||
|
||||
@@ -283,7 +318,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器)
|
||||
| POST | `/ota/upload` | 上传固件 | JSON 头 + binary 数据 | `{"status":"ok", "target":"esp32"}` |
|
||||
| POST | `/ota/update` | 完成 ESP32 OTA | `{"sha256":"...","verify_only":true/false}` | `{"status":"ok","sha256":"..."}` |
|
||||
| POST | `/ota/relay` | 触发 GD32 中继 | - | `{"status":"ok"}` |
|
||||
| GET | `/system/status` | 系统状态 JSON | - | 含版本/WiFi/OTA/温度/堆/GD32 版本等 |
|
||||
| GET | `/system/status` | 系统状态 JSON | - | 含版本/WiFi/OTA/温度/堆/GD32 版本及**OtaCoordinator 系统级状态**等 |
|
||||
| GET | `/logs` | 拉取调试日志 | - | JSON 数组字符串 |
|
||||
| POST | `/can/send` | 发送 CAN 帧 | `{"ch":0,"id":123,"dlc":8,"data":"0102..."}` | `{"status":"ok","recv":"..."}` |
|
||||
| POST | `/spi/send` | SPI 直接发送 | `{"type":"01","data":"00AC","timeout_ms":1000}` | `{"status":"ok","resp_type":".."}` |
|
||||
@@ -291,7 +326,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器)
|
||||
|
||||
### POST /ota/upload 协议
|
||||
|
||||
通过 JSON 头中的 `type` 字段自动区分目标:
|
||||
通过 JSON 头中的 `type` 字段自动区分目标,传输层通过 **OtaCoordinator** 统一管理系统状态:
|
||||
|
||||
```json
|
||||
// ESP32 OTA
|
||||
@@ -303,8 +338,8 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器)
|
||||
<binary firmware data...>
|
||||
```
|
||||
|
||||
- **ESP32 模式**: 直接写入 OTA 分区,进度上报,完成后调用 `/ota/update` 收尾
|
||||
- **GD32 模式**: 写入 `gd32_ota` 分区 → 擦除 → 计算 SHA-256 → 自动触发 SPI 中继
|
||||
- **ESP32 模式**: `OtaCoordinator::beginEsp32Ota()` → `Esp32OtaCore::write()` 流式写入 OTA 分区 → 前端调 `/ota/update` 收尾
|
||||
- **GD32 模式**: `OtaCoordinator::beginGd32Storage()` → 写入 `gd32_ota` 分区 → `OtaCoordinator::completeGd32Storage()` → 计算 SHA-256 → 自动触发 SPI 中继
|
||||
|
||||
### curl 示例
|
||||
|
||||
@@ -332,10 +367,10 @@ curl http://192.168.31.1/logs
|
||||
|
||||
- ESP32 固件上传与 OTA 升级(含 SHA-256 前端校验)
|
||||
- GD32 固件上传(支持 .bin/.hex)与 SPI 中继触发
|
||||
- 实时 OTA 流程阶段显示(6 阶段进度追踪)
|
||||
- 实时 OTA 流程阶段显示(6 阶段进度追踪,含 **OtaCoordinator 系统级状态**)
|
||||
- CAN 帧手动发送面板
|
||||
- SPI 直接发送面板
|
||||
- 系统状态监控(温度、RSSI、空闲堆、最小堆、GD32 APP/BOOT 版本、OTA 缓存)
|
||||
- 系统状态监控(温度、RSSI、空闲堆、最小堆、GD32 APP/BOOT 版本、OTA 缓存、**协调器系统状态**)
|
||||
- 调试日志面板(HTTP `/logs` 轮询,按级别过滤)
|
||||
- 一键重启 ESP32
|
||||
|
||||
@@ -392,30 +427,37 @@ UDP socket 延迟创建(首次 `send()` 时才分配),WiFi 未连接时 `s
|
||||
| WiFi | `CONFIG_WIFI_MODE` | 1 (STA) | 0=AP, 1=STA |
|
||||
| WiFi | `WIFI_STA_SSID` | "004舰桥" | 路由器名称 |
|
||||
| WiFi | `WIFI_AP_SSID` | "ESP32_OTA_RELAY" | 热点名称 |
|
||||
| WiFi | `WIFI_RSSI_WARNING` | -75 dBm | RSSI 警告阈值 |
|
||||
| WiFi | `WIFI_RSSI_CRITICAL` | -85 dBm | RSSI 危险阈值 |
|
||||
| OTA | `OTA_BLOCK_SIZE` | 8192 | 读写块大小 |
|
||||
| OTA | `OTA_MAX_FIRMWARE_SIZE` | 216KB | GD32 固件上限 |
|
||||
| OTA | `GD32_OTA_PACKET_SIZE` | 48 | 每包载荷字节 |
|
||||
| OTA | `OTA_RESEND_RETRIES` | 10 | 单包最大重发次数 |
|
||||
| OTA | `OTA_PACKET_HEALTH_CHECK_INTERVAL` | 32 | 每 N 包健康检查 |
|
||||
| OTA | `OTA_PACKET_DELAY_MS` | 5 | 每包发送后延时(ms) |
|
||||
| OTA | `OTA_WIFI_RSSI_CRITICAL` | -88 dBm | OTA 期间 RSSI 危险阈值 |
|
||||
| OTA | `OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS` | 15000 | OTA 前 RSSI 采样最大时效 |
|
||||
| 阈值 | `TEMP_WARNING` | 80°C | 温度警告阈值 |
|
||||
| 阈值 | `TEMP_CRITICAL` | 100°C | 温度危险阈值 |
|
||||
| 阈值 | `WIFI_RSSI_CRITICAL` | -85 dBm | RSSI 危险阈值 |
|
||||
| 阈值 | `HEAP_WARNING_MIN` | 10240 B | 堆最小值警告 |
|
||||
| 调试 | `DEBUG_LOG_IP` | "192.168.31.3" | UDP 日志目标 IP |
|
||||
| 调试 | `DEBUG_LOG_PORT` | 6666 | UDP 日志端口 |
|
||||
| BLE | `BLE_DEVICE_NAME` | "ESP32-OTA-RELAY" | BLE 广播名称 |
|
||||
| BLE | `BLE_MTU_SIZE` | 512 | ATT MTU |
|
||||
| BLE | `BLE_OTA_ACK_WINDOW` | 32 | ACK 窗口大小 |
|
||||
| BLE | `BLE_OTA_CHUNK_PAYLOAD` | 480 | BLE 单包载荷 |
|
||||
| BLE | `BLE_OTA_REORDER_WINDOW` | 32 | 乱序重排缓冲区大小 |
|
||||
| BLE | `BLE_OTA_CTRL_VERIFY_SHA256` | 0xF9 | SHA-256 预检命令字 |
|
||||
|
||||
## 安全配置
|
||||
|
||||
| 参数 | 位置 | 说明 | 状态 |
|
||||
|------|------|------|------|
|
||||
| `MASTER_KEY[16]` | app_config.h:110 | AES-128 主密钥 | ⚠️ 占位值,生产环境需更换 |
|
||||
| `SCRAMBLE_CONST[16]` | app_config.h:115 | Nonce 搅伴常量 | ⚠️ 占位值,生产环境需更换 |
|
||||
| `GD32_CHIP_UID[12]` | app_config.h:125 | 运行时从握手包获取 | 仅保留默认占位 |
|
||||
| `MASTER_KEY[16]` | app_config.h | AES-128 主密钥 | ⚠️ 占位值,生产环境需更换 |
|
||||
| `SCRAMBLE_CONST[16]` | Protocol 类运行时生成 | Nonce 搅伴常量 | 每次启动由硬件 RNG 随机生成 |
|
||||
| `GD32_CHIP_UID[12]` | 运行时从握手 Challenge 包解包 | GD32 芯片 UID | 不再使用编译期常量 |
|
||||
| `K_session` | 运行时派生 | `AES(MasterKey, nonce_server)` | 每次 OTA 重新生成 |
|
||||
| SHA-256 | 最终校验 | 从分区读回计算,前端比对 | WiFi/BLE 均已实现 |
|
||||
| SHA-256 | 最终校验 | 从分区读回计算,前端比对 | WiFi/BLE/GD32 均已实现 |
|
||||
|
||||
## 构建与部署
|
||||
|
||||
@@ -515,15 +557,15 @@ tools\serve.bat
|
||||
|
||||
GD32 固件通过 ESP32 中转烧录,不需要串口连接 GD32:
|
||||
|
||||
**步骤 1** — 在 ESP32 WebUI 的 **"GD32 SPI 中继"** 卡片中上传 GD32 固件(.bin 或 .hex)
|
||||
**步骤 1** — 在 ESP32 WebUI 的 **"GD32 SPI 中继"** 卡片中上传 GD32 固件(.bin 或 .hex),上传过程由 **OtaCoordinator** 管理系统状态(`GD32_STORING → GD32_STORED`)
|
||||
|
||||
**步骤 2** — 上传完成后,系统自动计算 SHA-256 并触发 SPI 中继转发
|
||||
**步骤 2** — 上传完成后系统自动计算 SHA-256 并校验完整性,通过后由 **OtaRelay 事件驱动状态机** 执行转发
|
||||
|
||||
**步骤 3** — ESP32 通过四阶段安全握手协议将固件逐包转发给 GD32 Bootloader
|
||||
**步骤 3** — ESP32 通过六阶段安全握手协议(HANDSHAKING → SIZE_NEG → TRANSFERRING → FINISHING → DONE)将固件逐包转发给 GD32 Bootloader
|
||||
|
||||
**步骤 4** — 转发完成后 GD32 APP 启动,ESP32 轮询 PING 确认 APP 存活(最多 15 秒)
|
||||
|
||||
> 也可手动点击 **"触发 GD32 OTA 中继"** 按钮重新触发转发。
|
||||
> 也可手动点击 **"触发 GD32 OTA 中继"** 按钮重新触发转发(通过 `OtaCoordinator::triggerGd32Relay()` 投递)。
|
||||
|
||||
### 四、Python 辅助工具
|
||||
|
||||
@@ -554,12 +596,12 @@ CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
|
||||
启用 `CONFIG_APP_ROLLBACK_ENABLE` 后:
|
||||
1. 新固件首次启动必须在主循环前调用 `esp_ota_mark_app_valid_cancel_rollback()`
|
||||
2. 若新固件在标记前崩溃/重启,bootloader 自动回退到上一个有效分区
|
||||
3. `main.cpp:140` 在任务创建完成后执行此标记
|
||||
3. `main.cpp:145` 在任务创建完成后执行此标记
|
||||
|
||||
## 固件版本管理
|
||||
|
||||
位于 `cmake/gen_version_script.cmake`,在每次 `idf.py build` 时自动执行:
|
||||
- **版本号**: `0.3.12`(手动修改 CMake 文件中的 MAJOR/MINOR/PATCH)
|
||||
- **构建时间**: 自动写入 `%Y-%m-%d %H:%M:%S`
|
||||
- **Git Hash**: 自动从 `git rev-parse --short HEAD` 获取
|
||||
- **Git Hash**: 自动从 `git rev-parse --short HEAD` 获取 (当前 `5b2c5f8`)
|
||||
- 输出到 `main/include/version.h`,仅在内容变化时写入以避免不必要的重编译
|
||||
|
||||
@@ -1,970 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32_COMM 高风险代码评审报告</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--bg-card: #1c2128;
|
||||
--border: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--critical: #f85149;
|
||||
--critical-bg: rgba(248,81,73,0.12);
|
||||
--critical-border: rgba(248,81,73,0.4);
|
||||
--high: #d29922;
|
||||
--high-bg: rgba(210,153,34,0.12);
|
||||
--high-border: rgba(210,153,34,0.4);
|
||||
--medium: #58a6ff;
|
||||
--medium-bg: rgba(88,166,255,0.12);
|
||||
--medium-border: rgba(88,166,255,0.4);
|
||||
--accent: #58a6ff;
|
||||
--success: #3fb950;
|
||||
--success-bg: rgba(63,185,80,0.12);
|
||||
--code-bg: #0d1117;
|
||||
--table-stripe: rgba(110,118,129,0.05);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', Helvetica, Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.report-header {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.report-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, #f85149, #d29922, #58a6ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.report-header .subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.report-meta span { display: flex; align-items: center; gap: 0.4rem; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.stat-card .number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-card.critical { border-color: var(--critical-border); }
|
||||
.stat-card.critical .number { color: var(--critical); }
|
||||
.stat-card.high { border-color: var(--high-border); }
|
||||
.stat-card.high .number { color: var(--high); }
|
||||
.stat-card.medium { border-color: var(--medium-border); }
|
||||
.stat-card.medium .number { color: var(--medium); }
|
||||
.stat-card.total { border-color: var(--accent); }
|
||||
.stat-card.total .number { color: var(--accent); }
|
||||
.stat-card.verified { border-color: var(--success); }
|
||||
.stat-card.verified .number { color: var(--success); }
|
||||
|
||||
/* Dimension Table */
|
||||
.section { margin-bottom: 3rem; }
|
||||
|
||||
.section-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.section-title .icon { font-size: 1.2rem; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:nth-child(even) td { background: var(--table-stripe); }
|
||||
|
||||
/* Severity Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.critical { background: var(--critical-bg); color: var(--critical); border: 1px solid var(--critical-border); }
|
||||
.badge.high { background: var(--high-bg); color: var(--high); border: 1px solid var(--high-border); }
|
||||
.badge.medium { background: var(--medium-bg); color: var(--medium); border: 1px solid var(--medium-border); }
|
||||
|
||||
/* Finding Cards */
|
||||
.finding-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.2rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.finding-card:hover { border-color: var(--text-muted); }
|
||||
|
||||
.finding-card.critical { border-left: 4px solid var(--critical); }
|
||||
.finding-card.high { border-left: 4px solid var(--high); }
|
||||
|
||||
.finding-header {
|
||||
padding: 1.2rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.finding-header:hover { background: var(--bg-tertiary); }
|
||||
|
||||
.finding-title-group { flex: 1; }
|
||||
|
||||
.finding-number {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.finding-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.finding-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.finding-body {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.finding-card.open .finding-body { display: block; }
|
||||
|
||||
.finding-body .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-value { color: var(--text-primary); }
|
||||
|
||||
.code-block {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 0.8rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-block .comment { color: #6a9955; }
|
||||
.code-block .keyword { color: #c586c0; }
|
||||
.code-block .type { color: #4ec9b0; }
|
||||
.code-block .string { color: #ce9178; }
|
||||
.code-block .highlight { background: rgba(248,81,73,0.15); display: inline; padding: 0.1em 0.2em; border-radius: 3px; }
|
||||
|
||||
.impact-box {
|
||||
background: rgba(248,81,73,0.06);
|
||||
border: 1px solid rgba(248,81,73,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.impact-box .impact-label {
|
||||
color: var(--critical);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.fix-box {
|
||||
background: rgba(63,185,80,0.06);
|
||||
border: 1px solid rgba(63,185,80,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.fix-box .fix-label {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* Architecture Section */
|
||||
.arch-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.arch-card h4 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.arch-card p { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.7; }
|
||||
|
||||
/* Priority Table */
|
||||
.priority-table { margin-top: 1rem; }
|
||||
|
||||
.priority-table .p0 { color: var(--critical); font-weight: 700; }
|
||||
.priority-table .p1 { color: var(--high); font-weight: 600; }
|
||||
.priority-table .p2 { color: var(--medium); font-weight: 500; }
|
||||
.priority-table .p3 { color: var(--text-secondary); }
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.progress-bar .fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.fill.critical-fill { background: var(--critical); }
|
||||
.fill.high-fill { background: var(--high); }
|
||||
.fill.medium-fill { background: var(--medium); }
|
||||
|
||||
/* Chart */
|
||||
.chart-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1.5rem;
|
||||
height: 200px;
|
||||
padding: 1rem 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-bar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.chart-bar-stack {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
border-radius: 6px 6px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-bar-stack > div {
|
||||
width: 100%;
|
||||
transition: height 0.6s ease;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Expand/Collapse all */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.report-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container { padding: 1rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.finding-header { flex-direction: column; }
|
||||
.finding-badges { justify-content: flex-start; }
|
||||
.chart-container { gap: 0.8rem; }
|
||||
.chart-bar-stack { width: 40px; }
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { background: white; color: black; }
|
||||
.finding-body { display: block !important; }
|
||||
.stat-card, .finding-card, .arch-card { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="report-header">
|
||||
<h1>ESP32_COMM 高风险代码评审报告</h1>
|
||||
<div class="subtitle">多维度并行审查 + 对抗性验证 · 4维度 × 33项确认发现</div>
|
||||
<div class="report-meta">
|
||||
<span>📋 项目: ESP32_COMM (ESP32-C3 OTA Relay)</span>
|
||||
<span>📅 日期: 2026-06-13</span>
|
||||
<span>🔌 固件版本: v0.3.12</span>
|
||||
<span>👥 审查Agent: 4 Review + 36 Verify + 1 Synthesize</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card total">
|
||||
<div class="number">33</div>
|
||||
<div class="label">确认发现</div>
|
||||
</div>
|
||||
<div class="stat-card critical">
|
||||
<div class="number">8</div>
|
||||
<div class="label">CRITICAL</div>
|
||||
</div>
|
||||
<div class="stat-card high">
|
||||
<div class="number">18</div>
|
||||
<div class="label">HIGH</div>
|
||||
</div>
|
||||
<div class="stat-card medium">
|
||||
<div class="number">4</div>
|
||||
<div class="label">MEDIUM</div>
|
||||
</div>
|
||||
<div class="stat-card verified">
|
||||
<div class="number">33/36</div>
|
||||
<div class="label">验证通过率</div>
|
||||
</div>
|
||||
<div class="stat-card total">
|
||||
<div class="number">2</div>
|
||||
<div class="label">已驳回(误报)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dimension Chart -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">📊</span> 各维度发现分布</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">8</div>
|
||||
<div class="chart-bar-stack" style="height:160px;">
|
||||
<div style="height:60px;background:var(--critical);" title="2 CRITICAL"></div>
|
||||
<div style="height:80px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:20px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">内存安全</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">10</div>
|
||||
<div class="chart-bar-stack" style="height:180px;">
|
||||
<div style="height:54px;background:var(--critical);" title="3 CRITICAL"></div>
|
||||
<div style="height:108px;background:var(--high);" title="6 HIGH"></div>
|
||||
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">并发/RTOS</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">9</div>
|
||||
<div class="chart-bar-stack" style="height:170px;">
|
||||
<div style="height:72px;background:var(--critical);" title="4 CRITICAL"></div>
|
||||
<div style="height:72px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">OTA/固件</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">7</div>
|
||||
<div class="chart-bar-stack" style="height:150px;">
|
||||
<div style="height:43px;background:var(--critical);" title="2 CRITICAL"></div>
|
||||
<div style="height:86px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:14px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">SPI/协议</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOP 3 Critical -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🚨</span> TOP 3 最危险 Bug</div>
|
||||
|
||||
<!-- Finding 1 -->
|
||||
<div class="finding-card critical open" id="f1">
|
||||
<div class="finding-header" onclick="toggleFinding('f1')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #1 — CRITICAL</div>
|
||||
<div class="finding-title">SPI 总线无并发保护 — 多任务竞争导致协议损坏</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">并发竞态</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">spi_master.h:73-142, ota_relay.cpp, wifi_ota.cpp</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">并发/RTOS</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<p>SpiMaster 是单例但<strong>没有任何互斥锁</strong>。多个 FreeRTOS 任务直接调用 SPI 方法:ota_relay 任务 (优先级 MAX-2)、wifi_ota 任务 (MAX-3)、health_monitor 任务 (MAX-4)。协议操作是多步非原子的(如 otaTransfer = 发送帧 + 轮询响应,CS在两次传输之间释放),高优先级任务可在协议序列中间抢占。</p>
|
||||
<div class="code-block"><span class="comment">// spi_master.h — 无任何mutex成员</span>
|
||||
<span class="keyword">class</span> <span class="type">SpiMaster</span> {
|
||||
<span class="keyword">private</span>:
|
||||
<span class="type">spi_device_handle_t</span> m_spiDevice = <span class="keyword">NULL</span>; <span class="comment">// ← 唯一成员,无锁</span>
|
||||
};</div>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
在 SpiMaster 中添加 <code>SemaphoreHandle_t m_mutex</code>,所有公共方法入口加锁。对于多步协议操作(otaTransfer等),使用统一的锁粒度保护整个请求-响应序列。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finding 2 -->
|
||||
<div class="finding-card critical" id="f2">
|
||||
<div class="finding-header" onclick="toggleFinding('f2')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #2 — CRITICAL</div>
|
||||
<div class="finding-title">硬编码占位密钥 — 认证体系形同虚设</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">安全/OTA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">app_config.h:107-115</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">OTA/固件完整性</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<p>MASTER_KEY 使用 NIST AES 测试向量,SCRAMBLE_CONST 使用简单递增序列,GD32_CHIP_UID 使用 0x00-0x0B。注释标注"需填入实际密钥"但从未替换。</p>
|
||||
<div class="code-block"><span class="comment">// ⚠️ 请填入实际密钥 ← 注释说"需填入"但从未填入</span>
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> MASTER_KEY[] = {
|
||||
<span class="highlight">0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF</span>, <span class="comment">// ← NIST AES测试向量!</span>
|
||||
<span class="highlight">0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10</span>
|
||||
};
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> SCRAMBLE_CONST[] = {
|
||||
<span class="highlight">0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88</span>, <span class="comment">// ← 简单递增序列</span>
|
||||
<span class="highlight">0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00</span>
|
||||
};
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> GD32_CHIP_UID[] = {
|
||||
<span class="highlight">0x00, 0x01, 0x02, 0x03, 0x04, 0x05</span>, <span class="comment">// ← 0x00-0x0B</span>
|
||||
<span class="highlight">0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B</span>
|
||||
};</div>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
从安全存储(NVS/efuse)读取密钥;移除所有硬编码占位值;添加编译期断言确保非测试密钥;MASTER_KEY和SCRAMBLE_CONST应在首次设备配准时由安全随机数生成。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finding 3 -->
|
||||
<div class="finding-card critical" id="f3">
|
||||
<div class="finding-header" onclick="toggleFinding('f3')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #3 — CRITICAL</div>
|
||||
<div class="finding-title">Flash 写入无对齐检查 — 可能导致固件损坏</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">内存安全/OTA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">ota_relay.cpp:308-320, wifi_ota.cpp:711/740, ble_ota.cpp:575</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">内存安全 + OTA完整性</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="type">esp_err_t</span> OtaRelay::flashWriteChunk(<span class="type">uint32_t</span> offset, <span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len)
|
||||
{
|
||||
<span class="comment">// ... 无对齐检查 ...</span>
|
||||
<span class="keyword">return</span> <span class="highlight">esp_partition_write</span>(instance().m_gd32Partition, offset, data, len); <span class="comment">// ← offset/len可能非4字节对齐</span>
|
||||
}</div>
|
||||
<p>两个调用路径产生非对齐值:(1) WiFi OTA: gd32_offset 从 httpd_req_recv 返回值累加,最后一个chunk是固件余数;(2) BLE OTA: data_len = recv_len - 11(7字节头+4字节CRC),不保证4字节对齐。esp_partition_write 要求 offset 和 size 均4字节对齐。</p>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
非对齐写入导致 ESP_ERR_INVALID_ARG 或触发 read-modify-write 异常,GD32固件最后一个chunk损坏或丢失。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
flashWriteChunk 内部做4字节对齐对齐(不足部分 read-modify-write),或在上层确保只传对齐参数。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other CRITICAL Findings -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🔴</span> 其他 CRITICAL 级发现</div>
|
||||
|
||||
<div class="finding-card critical" id="f4">
|
||||
<div class="finding-header" onclick="toggleFinding('f4')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #4 — CRITICAL</div>
|
||||
<div class="finding-title">BLE/WiFi 双通道 OTA 同时触发 — ESP32 OTA状态冲突</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.h:42-46, ble_ota.h</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<p>BleOta 和 WifiOta 各自维护独立的 m_esp32OtaActive、m_esp32OtaHandle、m_esp32UpdatePartition,但底层 esp_ota_begin/write/end 操作的是同一个OTA分区。两个通道同时触发时:第二个 esp_ota_begin 会失败或覆盖句柄;esp_ota_write 可能交错写入同一分区;导致固件损坏+变砖。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>添加全局 OTA 互斥标志,同一时刻只允许一个OTA源(BLE或WiFi)。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f5">
|
||||
<div class="finding-header" onclick="toggleFinding('f5')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #5 — CRITICAL</div>
|
||||
<div class="finding-title">SPI 协议帧仅用 XOR 伪校验 — 数据损坏不可检测</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">spi_master.cpp:134-141</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="type">uint8_t</span> SpiMaster::otaCrc8(<span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len) {
|
||||
<span class="type">uint8_t</span> crc = <span class="highlight">0</span>;
|
||||
<span class="keyword">for</span> (<span class="type">size_t</span> i = <span class="highlight">0</span>; i < len; ++i)
|
||||
<span class="highlight">crc ^= data[i]</span>; <span class="comment">// ← 简单XOR,非CRC8</span>
|
||||
<span class="keyword">return</span> crc;
|
||||
}</div>
|
||||
<p>XOR校验无法检测:偶数个相同位置的位翻转、字节交换、全零段丢失。固件数据的静默损坏几乎不可检测。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>替换为 CRC-8/MAXIM 或 CRC-16/CCITT,与GD32团队协商。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f6">
|
||||
<div class="finding-header" onclick="toggleFinding('f6')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #6 — CRITICAL</div>
|
||||
<div class="finding-title">SPI_OTA_SOF 与 SPI_APP_ENTER_BOOT_ACK_OK 值碰撞 (0xA5)</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">spi_master.h:21,26</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="keyword">#define</span> <span class="highlight">SPI_OTA_SOF</span> (<span class="highlight">0xA5</span>) <span class="comment">// OTA帧起始标记</span>
|
||||
<span class="keyword">#define</span> <span class="highlight">SPI_APP_ENTER_BOOT_ACK_OK</span> (<span class="highlight">0xA5</span>) <span class="comment">// APP确认进入Bootloader</span></div>
|
||||
<p>同一值 0xA5 表示两种完全不同的语义。GD32在OTA窗口回复ACK时,ESP32可能将ACK字节误解析为OTA帧起始,导致协议状态机混乱。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>修改其中一个常量值,消除歧义。例如将 SPI_APP_ENTER_BOOT_ACK_OK 改为 0xB5。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f7">
|
||||
<div class="finding-header" onclick="toggleFinding('f7')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #7 — CRITICAL</div>
|
||||
<div class="finding-title">OTA 会话无全局超时 — 可无限挂起</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">ota_relay.cpp:76-91,562; ota_relay.h:59</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<p>waitUntilNotWait() 每次调用有 max_wait_rounds 上限,但整个 forwardFirmware() 流程没有全局超时。GD32 持续返回 WAIT 时,单个 SEND_SIZE阶段可挂起 ~78秒,完整流程可挂起数分钟。OTA_RESULT_ERR_USER_ABORT 已定义但从未使用。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>在 forwardFirmware() 入口记录开始时间,每个阶段检查全局超时(如5分钟),超时则中止并清理。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f8">
|
||||
<div class="finding-header" onclick="toggleFinding('f8')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #8 — CRITICAL</div>
|
||||
<div class="finding-title">HTTP CORS 允许所有来源 — 任意网页可触发 OTA</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.cpp:58</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block">httpd_resp_set_hdr(req, <span class="string">"Access-Control-Allow-Origin"</span>, <span class="highlight">"*"</span>);</div>
|
||||
<p>任何网页都可以向 ESP32 发起 OTA 请求。结合占位密钥问题,攻击链完整:恶意网页 → 上传伪造固件 → 通过占位密钥认证 → 烧录恶意GD32固件。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HIGH Findings -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🟠</span> HIGH 级关键发现</div>
|
||||
<div class="toolbar">
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">共 18 项 HIGH 级发现,点击展开详情</span>
|
||||
<button onclick="toggleAll('high')">展开/折叠全部</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>严重度</th><th>文件</th><th>问题</th><th>影响</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>9</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:121</code></td><td>WiFi事件回调中调用 esp_wifi_connect() 重连</td><td>在系统事件任务中调用可能死锁</td></tr>
|
||||
<tr><td>10</td><td><span class="badge high">HIGH</span></td><td><code>health_monitor.cpp</code></td><td>healthState 静态变量无锁跨任务读写</td><td>竞态导致健康判断错误</td></tr>
|
||||
<tr><td>11</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:80-84</code></td><td>5个静态变量(relayState等)无锁跨任务访问</td><td>协议状态不一致</td></tr>
|
||||
<tr><td>12</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.h:33-47</code></td><td>WifiOta 所有成员 public 且无锁</td><td>HTTP回调与WiFi事件回调竞争</td></tr>
|
||||
<tr><td>13</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.h</code></td><td>BleOta 成员无锁,BLE GATT回调与主任务竞争</td><td>BLE OTA 数据损坏</td></tr>
|
||||
<tr><td>14</td><td><span class="badge high">HIGH</span></td><td><code>debug_log.cpp:296</code></td><td>log() 栈分配 msg[512] + log_buf[600] = 1112字节</td><td>低栈任务中栈溢出</td></tr>
|
||||
<tr><td>15</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:1008</code></td><td>spi_send_handler 栈分配 1728+ 字节</td><td>HTTP server栈仅8KB,接近溢出</td></tr>
|
||||
<tr><td>16</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.cpp:456</code></td><td>sendNonceAndAuthenticate() nonce 仅用 esp_random() 一次</td><td>认证会话密钥可预测</td></tr>
|
||||
<tr><td>17</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:25</code></td><td>ota_meta_t.checksum[32] 从未填充/验证</td><td>固件完整性无保障</td></tr>
|
||||
<tr><td>18</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:429-444</code></td><td>ESP32 OTA 完成→重启,无回滚验证</td><td>新固件若无法启动将变砖</td></tr>
|
||||
<tr><td>19</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.cpp:258</code></td><td>syncTransfer/recv 忽略 timeout_ms 参数</td><td>SPI事务可能无限等待</td></tr>
|
||||
<tr><td>20</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:599-629</code></td><td>esp_ota_write 失败后 break,未调用 esp_ota_abort</td><td>OTA分区处于不一致状态</td></tr>
|
||||
<tr><td>21</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.cpp:439-444</code></td><td>BLE OTA 帧预留4字节CRC但减去计算不匹配</td><td>最后几个字节被误截断</td></tr>
|
||||
<tr><td>22</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.h</code></td><td>SPI仅4线,无硬件流控/就绪信号</td><td>高速传输时GD32来不及处理</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Architecture Concerns -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🏗</span> 架构级隐患</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>① 全局状态泛滥,无封装无同步</h4>
|
||||
<p>项目中有 <strong>5个静态全局变量</strong> (relayState, relaySeq, relayTotalPackets, gd32AppAlive, gd32Version) 和 <strong>大量public成员</strong> (WifiOta/BleOta所有状态),跨越3+个FreeRTOS任务和多个ISR/callback上下文访问,<strong>没有任何一个</strong>被mutex/atomic/critical section保护。这是系统性问题,不是个别bug。</p>
|
||||
</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>② 单通道SPI + 多任务无仲裁</h4>
|
||||
<p>SpiMaster 单例是唯一的SPI通信通道,但没有任何仲裁机制。WiFi/BLE/健康监控/OTA中继四个功能模块各自直接调用,协议级操作(请求-响应对)不是原子的。需要从架构层面引入SPI总线仲裁器。</p>
|
||||
</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>③ 安全架构不完整</h4>
|
||||
<p>认证握手存在但密钥硬编码→形同虚设;固件校验字段存在但未填充→形同虚设;CORS开放→任何人可触发OTA。<strong>安全功能"看起来有"但实际上不提供保护</strong>,这种"安全假象"比没有安全功能更危险,因为它给了虚假的信心。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Fix Order -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🛠</span> 优先修复顺序</div>
|
||||
<table class="priority-table">
|
||||
<thead>
|
||||
<tr><th>优先级</th><th>修复项</th><th>工作量</th><th>紧急度</th><th>进度</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>添加 SPI 总线互斥锁</td>
|
||||
<td>小</td>
|
||||
<td>极高 — 生产环境必崩</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>替换硬编码密钥为安全存储</td>
|
||||
<td>中</td>
|
||||
<td>极高 — 安全漏洞</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>Flash 写入对齐检查</td>
|
||||
<td>小</td>
|
||||
<td>极高 — 数据损坏</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>添加全局 OTA 超时+中止机制</td>
|
||||
<td>中</td>
|
||||
<td>高 — 设备挂死</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>ESP32 OTA 添加回滚验证</td>
|
||||
<td>中</td>
|
||||
<td>高 — 变砖风险</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>替换 XOR 为 CRC-8/16 校验</td>
|
||||
<td>小</td>
|
||||
<td>高 — 固件损坏</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>消除 0xA5 值碰撞</td>
|
||||
<td>极小</td>
|
||||
<td>高 — 协议混乱</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>添加 OTA 单源互斥 (BLE/WiFi)</td>
|
||||
<td>中</td>
|
||||
<td>中高</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>为所有跨任务状态添加锁/atomic</td>
|
||||
<td>大</td>
|
||||
<td>中高</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>修复 WiFi 事件回调中的重连调用</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>修复大栈分配问题</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>限制 CORS 来源</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>实现 ota_meta_t 校验填充与验证</td>
|
||||
<td>中</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Dismissed -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">✅</span> 已驳回发现(误报)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>原始严重度</th><th>问题描述</th><th>驳回原因</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="badge critical" style="text-decoration:line-through;opacity:0.5;">CRITICAL</span></td>
|
||||
<td>SPI DMA缓冲区栈分配导致数据损坏/硬Fault</td>
|
||||
<td>ESP-IDF 6.0 的 spi_master 驱动内置 <code>setup_dma_priv_buffer()</code> 函数自动检测并对齐DMA缓冲区,栈分配仅导致性能损失,不引发数据损坏</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge high" style="text-decoration:line-through;opacity:0.5;">HIGH</span></td>
|
||||
<td>TOCTOU: relayState 检查后 reset() 无锁</td>
|
||||
<td>虽然模式存在,但当前代码流程中检查和重置在同一线程同步执行,实际竞态窗口极小,风险被高估</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="report-footer">
|
||||
<p>ESP32_COMM 高风险代码评审报告 · 由 4维并行审查 + 36项对抗性验证 生成 · 2026-06-13</p>
|
||||
<p style="margin-top:0.5rem;">审查维度: 内存安全 · 并发/RTOS · OTA/固件完整性 · SPI通信/协议</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleFinding(id) {
|
||||
const card = document.getElementById(id);
|
||||
if (card) card.classList.toggle('open');
|
||||
}
|
||||
|
||||
let allHighOpen = false;
|
||||
function toggleAll() {
|
||||
allHighOpen = !allHighOpen;
|
||||
document.querySelectorAll('.finding-card').forEach(card => {
|
||||
if (allHighOpen) card.classList.add('open');
|
||||
else card.classList.remove('open');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
# ESP32_COMM 通信梳理(2026-05-05)
|
||||
|
||||
## 结论
|
||||
|
||||
当前“ESP32 ↔ 上位机(tools WebUI)”的主通信方式是 **HTTP**,不是 UDP。
|
||||
|
||||
### WebUI 实际在用的接口
|
||||
|
||||
- `GET /system/status`:每 1 秒轮询一次,用于在线状态、WiFi、heap、温度、OTA 状态展示
|
||||
- `GET /logs`:每 1 秒轮询一次,用于拉取并清空日志环形缓冲区
|
||||
- `POST /can/send`:发送 CAN 请求到 GD32
|
||||
- `POST /ota/upload` / `POST /ota/relay` / `POST /ota/update`:OTA 相关
|
||||
|
||||
## 日志链路
|
||||
|
||||
所有 `DEBUG_*` 日志目前会并行写入三处:
|
||||
|
||||
1. **UART0**
|
||||
- 本地串口调试
|
||||
2. **UDP 单播** → `DEBUG_LOG_IP:DEBUG_LOG_PORT`
|
||||
- 可供独立 UDP 调试接收端使用
|
||||
3. **内部环形缓冲区**
|
||||
- 被 `GET /logs` 读取
|
||||
- 这是当前 WebUI 日志面板真正使用的通道
|
||||
|
||||
## 为什么 `{"t":"hb"}` 是多余的
|
||||
|
||||
之前在 `wifi_ota_task()` 里,每 3 秒会发送一次:
|
||||
|
||||
```json
|
||||
{"t":"hb"}
|
||||
```
|
||||
|
||||
它是通过 `debug_log_send()` 发出的,所以会同时进入:
|
||||
- UART
|
||||
- UDP
|
||||
- `/logs` 对应的环形缓冲区
|
||||
|
||||
这会导致 WebUI 把它显示成一条普通日志:
|
||||
|
||||
- level: `I`
|
||||
- tag: `ESP32`
|
||||
- message: `{"t":"hb"}`
|
||||
|
||||
但 WebUI 本来已经每秒轮询 `/system/status`,完全可以判断设备在线,不需要再靠这种“伪日志心跳”。
|
||||
|
||||
## 已处理
|
||||
|
||||
已删除 `wifi_ota_task()` 中每 3 秒发送一次的 `{"t":"hb"}`。
|
||||
|
||||
## 现在是否还有重复/不必要通信
|
||||
|
||||
### 对 WebUI 来说
|
||||
|
||||
有两条并行链路,但用途不同,不算重复:
|
||||
|
||||
- `/system/status`:结构化状态
|
||||
- `/logs`:文本日志
|
||||
|
||||
这两条都保留是合理的。
|
||||
|
||||
### 对底层日志输出来说
|
||||
|
||||
日志同时发 UART + UDP + ring buffer,属于“一份日志三路镜像”。
|
||||
|
||||
这对调试友好,但从“最简设计”角度看,**UDP 在当前 WebUI 场景里不是必需的**。
|
||||
|
||||
也就是说:
|
||||
|
||||
- **必须保留:** ring buffer + `/logs`
|
||||
- **建议保留:** UART0
|
||||
- **可选保留:** UDP 6666
|
||||
|
||||
如果后续你想继续精简,可以做“方案2”:把 UDP 也做成可关闭,甚至默认关闭。
|
||||
@@ -1,125 +0,0 @@
|
||||
# ESP32-C3FH4 OTA 中继项目 — 程序开发说明
|
||||
|
||||
**委托开发方:** 请完整执行以下所有任务,完成后停止。不要在中途停止。
|
||||
|
||||
**开发范围:** ESP32-C3FH4 固件开发,充当手机 APP/PC 与 GD32E508VET6 之间的 OTA 中继器。
|
||||
|
||||
**核心工作模式:** ESP32 通过 WiFi 或 BLE 接收上位机发送的 GD32 固件 → 存入本地 Flash → 通过 SPI 转发给 GD32 执行 OTA 升级。
|
||||
|
||||
> **重要:** 开发过程中如需与 GD32 固件侧确认接口(如 AES 密钥长度、搅伴算法细节等),请先按设计文档实现接口定义并预留 hook,实际参数值用占位符标注,最终由委托方补充。
|
||||
|
||||
---
|
||||
|
||||
## 参考文件
|
||||
|
||||
| 文件 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 架构设计文档 | `/workzone/esp32_ota_relay/esp32_ota_relay_design.md` | 完整协议定义、模块架构、状态机设计 |
|
||||
| 开发任务清单 | `/workzone/esp32_ota_relay/tasks.md` | 所有 48 条开发任务的详细描述 |
|
||||
|
||||
**开发顺序:** T0 → T1 → T2 → T3 → T4/T5(可并行)→ T6 → T7 → T8
|
||||
|
||||
---
|
||||
|
||||
## 系统架构(必读)
|
||||
|
||||
```
|
||||
[手机APP/PC] --WiFi/BLE--> [ESP32-C3FH4] --SPI--> [GD32E508VET6]
|
||||
|
|
||||
+-- 存储 GD32 固件到 Flash
|
||||
+-- 四阶段安全握手协议
|
||||
+-- 内置温度/WiFi 自检
|
||||
```
|
||||
|
||||
ESP32 的角色是**数据透传 + 存储中继**,GD32 OTA 的加密握手和数据传输逻辑全部由 ESP32 透传到 GD32,ESP32 本身不解析固件内容。
|
||||
|
||||
---
|
||||
|
||||
## 通信协议要点
|
||||
|
||||
### ESP32 ↔ GD32(SPI)
|
||||
|
||||
完整映射 GD32 原有 CANFD OTA 四阶段流程:
|
||||
|
||||
- **阶段一(安全握手):** 0x00 AC 握手 → 16B nonce 挑战 → 握手包响应 → 0xBA C0 完成
|
||||
- **阶段二(传输准备):** 发送固件总大小 → 0xBA C0 + seq 确认
|
||||
- **阶段三(数据分包):** 每包 48B + CRC,循环发送直到最后一包带 IFEND
|
||||
- **阶段四(结束跳转):** 0xBA C0 + FF FF FF FF 完成确认 → 0x0A AC 刷写结束 → GD32 跳转 APP
|
||||
|
||||
帧格式:`SOF(0xEB) + CMD + LEN(2B LE) + PAYLOAD(LEN) + CRC16(2B LE)`
|
||||
|
||||
命令码:0xF0 HANDSHAKE_REQ / 0xF1 CHALLENGE / 0xF2 SESSION_KEY / 0xF3 SEND_SIZE / 0xF4 DATA_PACKET / 0xF5 DATA_END / 0xF6 TRANSFER_DONE / 0xF7 FINISH_OTA
|
||||
|
||||
### APP ↔ ESP32(WiFi HTTP / BLE GATT)
|
||||
|
||||
- WiFi:HTTP POST `/ota/upload`,JSON 响应进度
|
||||
- BLE:4 个 GATT Characteristic(Control Point / Data In / Data Out / Version)
|
||||
- APP→ESP32 使用明文帧格式(加密由 ESP32 在转发给 GD32 时处理)
|
||||
|
||||
---
|
||||
|
||||
## 存储方案(方案 A)
|
||||
|
||||
固件先完整存入 ESP32 Flash,再转发给 GD32:
|
||||
|
||||
- 分区:`gd32_ota`(256KB,用于存 GD32 固件 binary)
|
||||
- 元数据文件:`ota_meta.json`(total_size / stored_size / last_seq)
|
||||
- 断点续传:APP 重连后从 `last_seq + 1` 继续发送
|
||||
- 存储完成后自动触发 SPI 转发流程
|
||||
|
||||
---
|
||||
|
||||
## 自检与保护
|
||||
|
||||
不外挂传感器,使用 ESP32 内置传感器:
|
||||
|
||||
- **芯片温度**(正常 < 80°C / 警告 80-100°C / 停机 > 100°C)— 1s 轮询
|
||||
- **WiFi RSSI**(正常 > -70 / 弱 -70~-85 / 断开 < -85 dBm)— 10s 轮询
|
||||
- **BLE 连接状态** — 事件驱动
|
||||
|
||||
OTA 每包发送前检查健康状态,CRITICAL 时暂停,SAFE_SHUTDOWN 时中止并清除 meta。
|
||||
|
||||
---
|
||||
|
||||
## 开发要求
|
||||
|
||||
1. **使用 ESP-IDF v5.x**,基于 CMake 构建系统,目标芯片 `esp32c3`
|
||||
2. **遵循 FreeRTOS 任务架构**:System Monitor / OTA Relay / WiFi Server / BLE GATT 四个任务
|
||||
3. **AES 使用 mbedTLS**(ESP32 内置),预置主密钥用占位符 `MASTER_KEY_HERE`
|
||||
4. **搅伴算法**实现需与 GD32 固件侧匹配,预留参数配置接口
|
||||
5. **Flash 写入**使用 esp_partition API,追加写入不断点
|
||||
6. **SPI 使用 VSPI (SPI2)**,GPIO6-9,CS=GPIO4,时钟 40MHz,mode=0
|
||||
7. **所有任务完成后**,清理会话密钥、释放内存、返回完成信号
|
||||
8. **不允许中途停止**,必须完成 T0~T8 所有任务后,才能停止运行或退出
|
||||
|
||||
---
|
||||
|
||||
## 开发产出要求
|
||||
|
||||
1. 完整可编译的 ESP32-C3 工程代码(CMake 项目结构)
|
||||
2. 所有代码放在 `/workzone/esp32_ota_relay/` 目录下
|
||||
3. 代码中必须有完整的 `main/` 目录(`main.c` / `app_main.c` / `ota_relay.c` / `spi_master.c` / `wifi_ota.c` / `ble_ota.c` / `protocol.c`)
|
||||
4. 不得使用任何需要外部 API Key 的服务(AES、WiFi、BLE 均使用 ESP-IDF 内置库)
|
||||
5. 完成后更新 `/workzone/esp32_ota_relay/tasks.md` 的进度记录
|
||||
|
||||
---
|
||||
|
||||
## GD32 固件侧依赖说明
|
||||
|
||||
以下接口需 GD32 固件侧提供参数后才能完整验证,请预留 hook 并用占位符标注:
|
||||
|
||||
| 接口 | 占位符 | 说明 |
|
||||
|---|---|---|
|
||||
| 主密钥 | `MASTER_KEY_HERE` | AES-128 主密钥,16 bytes |
|
||||
| 搅伴算法参数 | `SCRAMBLE_CONST_HERE` | 时间戳+内存地址+rand 的固定 seed |
|
||||
| 芯片唯一码 | `CHIP_UID_HERE` | GD32 芯片唯一 ID 用于密钥派生 |
|
||||
| 会话密钥 K_session | - | 由 Step 4/5 双方协商生成,存储在 RAM |
|
||||
| AES 算法模式 | AES-128 CBC | 由 ESP32 和 GD32 双方协商一致即可 |
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
如需向委托方确认接口参数,请在代码中用 `// TODO: confirm with GD32 firmware team` 标注,不得自行假定。
|
||||
|
||||
**开发过程中遇到任何问题,请继续尝试解决,不要停止。**
|
||||
@@ -1,316 +0,0 @@
|
||||
# BLE OTA 刷写修复测试计划
|
||||
|
||||
> 对应修复:问题1(偶发丢包检测错误)+ 问题2(通道占用延迟)
|
||||
> 涉及文件:`ble_ota.h` / `ble_ota.cpp` / `ble_ota.html`
|
||||
|
||||
---
|
||||
|
||||
## 一、正常流程测试
|
||||
|
||||
### T-01 标准 ESP32 OTA 全流程(基线回归)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 固件大小 | 使用常规固件(~200KB–1MB) |
|
||||
| 操作步骤 | BLE 连接 → 选择文件 → 一键刷写 |
|
||||
| 预期结果 | 刷写完成,设备重启,日志无报错 |
|
||||
|
||||
**验证要点:**
|
||||
- 每个窗口 ACK 字节数与期望完全匹配
|
||||
- 日志中出现 `已写 X/2035 包` 进度连续递增
|
||||
- 日志出现 `[FINISH] ESP32 OTA 刷写完成,等待重启` + toast 成功提示
|
||||
- 设备重启后 `readVersion` 返回新版本号
|
||||
|
||||
### T-02 标准 GD32 OTA 中继全流程(回归验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 目标 | GD32(经 ESP32 中继 SPI 转发) |
|
||||
| 操作步骤 | BLE 连接 → 选择 GD32 固件 → 一键刷写 |
|
||||
| 预期结果 | ESP32 中继正常,GD32 收到完整固件 |
|
||||
|
||||
**验证要点:**
|
||||
- ESP32 端去重逻辑仅作用于 `BLE_OTA_CMD_ESP32_DATA`(0x01),不影响 GD32 通道(0x02)
|
||||
- GD32 窗口 ACK 正常返回
|
||||
- 刷写完成后 CTRL 命令触发成功
|
||||
|
||||
### T-03 尾包非整窗口场景
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | `packetTotal % BLE_OTA_ACK_WINDOW != 0`,如固件 976752 字节 / 480 = 2035 包,最后一个窗口仅 19 包(seq 2016–2034) |
|
||||
| 预期 | 最后一个窗口的 `endSeq = 2034`,ACK 正常返回 |
|
||||
|
||||
**验证要点:**
|
||||
- `expectedBytes = Math.min((endSeq+1)*480, totalSize)` 正确截断
|
||||
- 尾包 `data_len < BLE_OTA_CHUNK_PAYLOAD` 触发 `isLastPacket`
|
||||
- 设备创建收尾任务,发送 `[FINISH] 即将执行 esp_ota_end()`
|
||||
|
||||
### T-04 多窗口连续刷写稳定性
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 固件 > 500KB(至少 16 个窗口) |
|
||||
| 操作 | 连续执行 3 次完整刷写 |
|
||||
| 预期 | 每次均成功,无丢包报错 |
|
||||
|
||||
**验证要点:**
|
||||
- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中正确重置为 0
|
||||
- 旧 OTA 会话被先中止(日志出现 `检测到未完成的 ESP32 OTA,会先中止旧会话`)
|
||||
- 每次刷写的 `m_esp32Received` 从 0 开始累加
|
||||
|
||||
---
|
||||
|
||||
## 二、边界条件测试
|
||||
|
||||
### T-05 固件大小恰好为窗口整数倍
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 构造固件大小 = N × 32 × 480,如 15360 字节(1 窗口)或 491520 字节(32 窗口) |
|
||||
| 预期 | 无尾包,最后一个窗口 `endSeq = packetTotal - 1`,`expectedBytes = totalSize` |
|
||||
|
||||
**验证要点:**
|
||||
- 最后一个包 `data_len == BLE_OTA_CHUNK_PAYLOAD`,不触发 `isLastPacket`
|
||||
- 完成条件走 `m_esp32Received >= total_size` 分支
|
||||
|
||||
### T-06 固件极小(单包/小于窗口)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 固件 < 480 字节(仅 1 包)或 480–15360 字节(1 窗口内) |
|
||||
| 预期 | 单包即触发尾包检测或窗口 ACK |
|
||||
|
||||
**验证要点:**
|
||||
- 单包场景:`seq=0` 同时满足 `(seq+1)%32==0` 否、但 `isLastPacket` 是
|
||||
- 设备发送 ACK,`m_esp32Received` 等于实际固件大小
|
||||
- `expectedBytes` 正确等于 `totalSize`
|
||||
|
||||
### T-07 固件极大(接近分区上限)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 固件大小接近 OTA 分区 size(如 ~1.5MB) |
|
||||
| 预期 | 完整刷写成功,约 50+ 个窗口全部通过 |
|
||||
|
||||
**验证要点:**
|
||||
- `m_esp32ExpectedSeq` 跨窗口持续递增,不会溢出 `uint16_t`(最大值 65535)
|
||||
- `ble_esp32_ota_begin` 中 `total_size > check_partition->size` 检查仍正确拒绝超大固件
|
||||
|
||||
### T-08 payloadSize 非默认值
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 在页面修改 payload 大小为 256 / 400 / 498 |
|
||||
| 预期 | 包总数自动调整,每窗口 ACK 字节校验正确 |
|
||||
|
||||
**验证要点:**
|
||||
- 设备端 `BLE_OTA_CHUNK_PAYLOAD = 480` 是固定值
|
||||
- 若 Web 端 payload < 480,设备端 `remaining_after_header < BLE_OTA_CHUNK_PAYLOAD + 4` 会走 `data_len = remaining - 4` 分支
|
||||
- 若 Web 端 payload > 480,设备端会按 480 解析,剩余数据被当作下一包处理(可能出错)
|
||||
- **注意:** payload 大小必须与设备端 `BLE_OTA_CHUNK_PAYLOAD` 一致,此为已知约束
|
||||
|
||||
### T-09 BLE 断开重连后重新刷写
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 刷写到一半 → 手动断开 BLE → 重新连接 → 再次刷写 |
|
||||
| 预期 | 新刷写从 `ble_esp32_ota_begin` 重新开始 |
|
||||
|
||||
**验证要点:**
|
||||
- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中重置为 0
|
||||
- 旧 OTA 会话被中止,`m_esp32OtaActive` 被清除
|
||||
- 全局互斥 `esp32_ota_mutex_release` 被正确调用
|
||||
|
||||
---
|
||||
|
||||
## 三、故障注入测试
|
||||
|
||||
### T-10 BLE 链路层偶发丢包自动恢复(核心修复验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 在 BLE 信号较弱的环境中刷写(距离远/有干扰),或临时将包间延迟移除后快速发送 |
|
||||
| 预期 | 检测到丢包 → 自动重发窗口 → 恢复成功 |
|
||||
|
||||
**验证要点(日志检查):**
|
||||
- 出现 `丢包检测: seq=X 丢失Y字节(Z包),第1次重发整个窗口` 警告日志
|
||||
- 重发后 ACK 字节数匹配,继续下一窗口
|
||||
- 设备端日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y`
|
||||
- 最终刷写成功,无数据损坏
|
||||
|
||||
### T-11 单窗口连续 2 次丢包(重试用尽)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 极端信号劣化,使一个窗口连续 3 次发送都有丢包 |
|
||||
| 预期 | 2 次重试用尽后抛出错误 |
|
||||
|
||||
**验证要点:**
|
||||
- 日志出现第 1 次、第 2 次重发记录
|
||||
- 最终报错:`BLE 丢包检测: seq=X 期望Y字节 实际Z字节 丢失W字节(V包) 已重试2次`
|
||||
- `uploadFirmware` 返回 `false`,toast 显示失败
|
||||
- `state.lastUploadStatus = 'failed'`
|
||||
- 用户手动重新刷写可成功
|
||||
|
||||
### T-12 通道占用 → 立即失败(核心修复验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 先通过 WiFi 通道启动 ESP32 OTA(占用互斥锁),然后通过 BLE 发起刷写 |
|
||||
| 预期 | BLE 刷写在 < 1 秒内失败,而非等待 10 秒超时 |
|
||||
|
||||
**验证要点(日志检查):**
|
||||
- 设备端日志:`ESP32 OTA 被其他通道占用,拒绝 BLE OTA begin`
|
||||
- Web 端日志按顺序出现:
|
||||
1. `[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝`
|
||||
2. Toast 弹出 `ESP32 OTA 被其他通道占用,请稍后重试`
|
||||
3. `写入失败: 设备端 FINISH 错误: stage=0x83 value=0`
|
||||
4. `[AUTO] 一键刷写中止`
|
||||
- **关键时序**:从 FINISH 通知到写入失败之间 < 500ms(此前需要 10 秒)
|
||||
- 不应出现 `等待 ACK 超时` 日志
|
||||
|
||||
### T-13 通道占用后重试成功
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | T-12 失败后 → 等待 WiFi OTA 完成 → 重新 BLE 刷写 |
|
||||
| 预期 | 互斥锁已释放,BLE 刷写成功 |
|
||||
|
||||
**验证要点:**
|
||||
- `esp32_ota_mutex_release` 在 WiFi OTA 完成后被调用
|
||||
- `g_esp32OtaOccupied` 被清除为 `false`
|
||||
- BLE 刷写正常走完全流程
|
||||
|
||||
### T-14 esp_ota_end / set_boot_partition 失败快速中断
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 使用损坏的固件文件(无有效 ESP32 镜像头),使 `esp_ota_end` 返回错误 |
|
||||
| 预期 | 设备发送 `FINISH stage=0x81`,Web 端立即报错 |
|
||||
|
||||
**验证要点:**
|
||||
- Web 端收到 FINISH 0x81,toast `ESP32 OTA 刷写失败`
|
||||
- `ackWaiter` 被 reject(如果此时有等待中的 ACK)
|
||||
- `uploadFirmware` 快速返回 `false`
|
||||
- 设备端 `esp_ota_abort` 被调用,互斥锁释放
|
||||
|
||||
### T-15 刷写过程中用户中止
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 刷写进行中点击「中止」按钮 |
|
||||
| 预期 | 当前窗口完成后抛出 `用户中止` 错误 |
|
||||
|
||||
**验证要点:**
|
||||
- `state.abortRequested = true` 在窗口循环开头被检测
|
||||
- `throw new Error('用户中止')` 被触发
|
||||
- `finally` 块清理 `ackWaiter`、设置 `state.uploading = false`
|
||||
- 设备端 OTA 会话在下次刷写时被 `ble_esp32_ota_begin` 清理
|
||||
|
||||
### T-16 窗口末尾丢包恢复(最常见场景)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 场景 | 窗口 0(seq 0–31),其中 seq=31 在首次发送时丢失 |
|
||||
| 预期 | 重发整个窗口后,seq=0–30 被跳过(重复包),seq=31 写入成功 |
|
||||
|
||||
**验证要点:**
|
||||
- 首次发送:seq=0–30 写入成功,`m_esp32ExpectedSeq = 31`
|
||||
- seq=31 丢失,设备未收到,不发送 ACK(因为 ACK 触发条件在 seq=31)
|
||||
- 主机 waitForAck 超时 → 触发窗口重发
|
||||
- 重发时 seq=0 到 seq=30:`seq < m_esp32ExpectedSeq(31)` → 重复包跳过
|
||||
- 重发时 seq=31:`seq == expectedSeq(31)` → 写入成功,`expectedSeq` 变为 32
|
||||
- 触发 ACK,`m_esp32Received = 32 × 480 = 15360`,匹配期望值
|
||||
- 设备日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y`
|
||||
|
||||
### T-17 窗口中间丢包恢复(严格顺序检测验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 场景 | 窗口 0(seq 0–31),其中 seq=15 在首次发送时丢失,seq=16–31 正常到达 |
|
||||
| 预期 | 乱序包被拒绝,重发后缺失包被填补 |
|
||||
|
||||
**验证要点:**
|
||||
- 首次发送:seq=0–14 写入成功,`m_esp32ExpectedSeq = 15`
|
||||
- seq=15 丢失
|
||||
- seq=16 到达:`seq(16) > expectedSeq(15)` → **乱序包跳过**,设备日志出现 `ESP32 OTA 乱序包,跳过: seq=16 expected=15 (前序包丢失)`
|
||||
- seq=17–31 同样被跳过(乱序)
|
||||
- seq=31 被跳过但仍触发 ACK(因 `(seq+1)%32==0`),ACK 回报 `m_esp32Received = 15 × 480 = 7200`
|
||||
- 主机检测到 7200 < 15360,触发重发
|
||||
- 重发:seq=0–14 跳过(重复),seq=15 写入(`expectedSeq` 变为 16),seq=16–31 写入
|
||||
- 最终 `m_esp32Received = 15360`,匹配期望值
|
||||
- 最终刷写成功,固件数据完整
|
||||
|
||||
### T-18 窗口内多包丢失跨轮次恢复
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 场景 | 窗口内 seq=5 和 seq=20 同时丢失 |
|
||||
| 预期 | 第 1 次重发填补 seq=5,第 2 次重发填补 seq=20 |
|
||||
|
||||
**验证要点:**
|
||||
- 首次:seq=0–4 写入,seq=5 丢失,seq=6–31 被拒绝(乱序),ACK 报 2400 字节
|
||||
- 第 1 次重发:seq=0–4 跳过,seq=5 写入,seq=6–19 写入,seq=20 丢失,seq=21–31 拒绝,ACK 报 9600 字节
|
||||
- 第 2 次重发:seq=0–19 跳过,seq=20 写入,seq=21–31 写入,ACK 报 15360 字节
|
||||
- 2 次重试内完成(`MAX_WINDOW_RETRIES = 2`),刷写成功
|
||||
|
||||
---
|
||||
|
||||
## 四、性能与回归测试
|
||||
|
||||
### T-19 包间延迟对总刷写时间的影响
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 测量 | 记录完整刷写耗时(修改前 vs 修改后) |
|
||||
| 预期 | 每窗口增加 ~62ms(31 × 2ms),64 窗口约增加 ~4 秒,可接受 |
|
||||
|
||||
**参考计算:**
|
||||
- 2035 包 ÷ 32 包/窗口 ≈ 64 窗口
|
||||
- 每窗口 31 个间隔 × 2ms = 62ms
|
||||
- 总额外延迟 ≈ 64 × 62ms ≈ 3.97 秒
|
||||
- 总刷写时间预计从 ~30 秒增加到 ~34 秒
|
||||
|
||||
### T-20 连续多次刷写无内存泄漏
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 连续刷写 5 次(每次完整流程) |
|
||||
| 预期 | 设备 free heap 无明显下降 |
|
||||
|
||||
**验证要点:**
|
||||
- `ble_esp32_ota_begin` 中旧会话清理完整(abort + mutex release + 状态重置)
|
||||
- `m_esp32ExpectedSeq` 每次重置,不会累积
|
||||
- 收尾任务(`ble_esp32_finish_and_reboot_task`)正常删除
|
||||
|
||||
### T-21 刷写完成后版本验证
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 刷写完成 → 设备重启 → 重新 BLE 连接 → 读取版本号 |
|
||||
| 预期 | 版本号与新固件匹配 |
|
||||
|
||||
**验证要点:**
|
||||
- `esp_ota_set_boot_partition` 指向新分区
|
||||
- 设备从新分区启动
|
||||
- `readVersion` 返回 `version.h` 中定义的版本号
|
||||
|
||||
---
|
||||
|
||||
## 五、修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `main/server/ble_ota.h` | 新增 `m_esp32ExpectedSeq` 字段,用于跟踪下一个期望的包序号 |
|
||||
| `main/server/ble_ota.cpp` | `ble_esp32_ota_begin` 重置期望序号;DATA_IN 回调添加严格顺序检测(拒绝重复包 + 拒绝乱序包) |
|
||||
| `tools/ble_ota.html` | 包间 2ms 延迟;整窗口重传重试(最多 2 次,50ms 重发前等待);FINISH 错误事件立即 reject ackWaiter |
|
||||
|
||||
## 六、测试优先级
|
||||
|
||||
| 优先级 | 用例 | 说明 |
|
||||
|--------|------|------|
|
||||
| P0 | T-01, T-10, T-12, T-16, T-17 | 核心功能回归 + 两个修复的直接验证 + 丢包恢复场景 |
|
||||
| P1 | T-03, T-09, T-13, T-15, T-18 | 边界场景 + 恢复路径 + 多包丢失 |
|
||||
| P2 | T-05, T-06, T-07, T-11, T-14 | 极端边界 + 重试耗尽 |
|
||||
| P3 | T-02, T-04, T-08, T-19, T-20, T-21 | 回归 + 性能 + 长期稳定性 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
SRCS
|
||||
"core/main.cpp"
|
||||
"ota/esp32_ota_core.cpp"
|
||||
"ota/ota_coordinator.cpp"
|
||||
"relay/ota_relay.cpp"
|
||||
"transport/spi_master.cpp"
|
||||
"transport/protocol.cpp"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "monitor/health_monitor.h"
|
||||
#include "relay/ota_relay.h"
|
||||
#include "ota/ota_coordinator.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "server/wifi_ota.h"
|
||||
#include "server/ble_ota.h"
|
||||
@@ -79,6 +80,10 @@ extern "C" void app_main(void)
|
||||
Protocol::instance().init();
|
||||
DEBUG_INFO("MAIN", "协议层初始化完成");
|
||||
|
||||
// ★ 全局 OTA 协调器初始化(在 OTA task 创建前,确保 mutex 就绪)
|
||||
OtaCoordinator::instance().init();
|
||||
DEBUG_INFO("MAIN", "OTA 全局协调器已初始化");
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().init();
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_ERR("MAIN", "SPI 总线初始化失败: %d", ret);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* app_config.h - 统一配置文件(C++ 版本)
|
||||
* ESP32-C3 OTA 中继项目
|
||||
*
|
||||
@@ -102,6 +102,8 @@ inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00;
|
||||
inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01;
|
||||
inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480;
|
||||
inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11;
|
||||
// Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW)
|
||||
inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32;
|
||||
|
||||
// ============================================================
|
||||
// 安全常量(需与 GD32 固件团队确认)
|
||||
@@ -112,10 +114,8 @@ inline constexpr uint8_t MASTER_KEY[] = {
|
||||
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10
|
||||
};
|
||||
|
||||
inline constexpr uint8_t SCRAMBLE_CONST[] = {
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
|
||||
};
|
||||
// SCRAMBLE_CONST 已移至 Protocol 类,运行时由硬件 RNG 随机生成
|
||||
inline constexpr size_t SCRAMBLE_CONST_LEN = 16;
|
||||
|
||||
// GD32_CHIP_UID 仅保留为默认占位,运行时以握手包解出的芯片地址 UID 为准。
|
||||
inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#define FW_VERSION_STRING "0.3.12"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-06-14 23:55:14"
|
||||
#define BUILD_TIME "2026-06-17 03:09:19"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "unknown"
|
||||
#define GIT_HASH "98b5cc1"
|
||||
|
||||
@@ -196,7 +196,7 @@ esp_err_t DebugLog::uartInit()
|
||||
}
|
||||
|
||||
m_uartInitialized = true;
|
||||
ESP_LOGI(TAG, "UART0 调试输出已初始化 (TX=GPIO%d, RX=GPIO%d, %d baud)",
|
||||
ESP_LOGD(TAG, "UART0 调试输出已初始化 (TX=GPIO%d, RX=GPIO%d, %d baud)",
|
||||
UART_DEBUG_TX_GPIO, UART_DEBUG_RX_GPIO, UART_DEBUG_BAUD_RATE);
|
||||
|
||||
return ESP_OK;
|
||||
|
||||
+89
-35
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 设计依据 WiFi 原流程(wifi_ota.cpp 旧实现),修复已知 bug:
|
||||
* - 旧 wifi_ota.cpp:625 在 esp_ota_end 成功后又调 esp_ota_abort → 用 m_ended 标志避免
|
||||
* - SHA 统一从分区读回计算,与 BLE 增量计算方式统一为本风格
|
||||
* - SHA 统一从分区读回计算,不在传输过程中做增量 SHA
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
@@ -54,7 +54,14 @@ void Esp32OtaCore::resetState()
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
{
|
||||
// 若本核心已有未完成会话,先中止并释放全局互斥
|
||||
// ★ 全局互斥:必须在清理旧会话之前检查,
|
||||
// 否则 WiFi 的 begin() 会先释放 BLE 持有的锁,再自己获取,导致互斥失效
|
||||
if (!esp32_ota_mutex_acquire(1000)) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 若本核心已有未完成会话(同一通道的重试),先中止并释放全局互斥
|
||||
if (m_active) {
|
||||
DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA 会话,先中止旧会话");
|
||||
if (!m_ended) {
|
||||
@@ -62,13 +69,12 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
}
|
||||
esp32_ota_mutex_release();
|
||||
resetState();
|
||||
}
|
||||
|
||||
// ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用
|
||||
// 重新获取全局互斥(刚刚释放了)
|
||||
if (!esp32_ota_mutex_acquire(1000)) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin");
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 重新获取互斥失败");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
|
||||
DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx",
|
||||
@@ -100,8 +106,15 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// 统一传 total_size 给 esp_ota_begin(WiFi 风格)
|
||||
esp_err_t err = esp_ota_begin(m_partition, total_size, &m_handle);
|
||||
// BLE OTA 修复:传固件实际大小而非分区全尺寸,避免全分区擦除导致
|
||||
// 长时间阻塞触发 Task Watchdog(ESP32-C3 单核 5s 超时)。
|
||||
// esp_ota_begin 按 image_size 擦除;esp_ota_end 仅校验已写数据,
|
||||
// 未擦除残留扇区不影响校验。size 对齐到 64KB 块边界确保尾部干净。
|
||||
size_t erase_size = (total_size + 0xFFFF) & ~0xFFFFULL;
|
||||
if (erase_size > m_partition->size) {
|
||||
erase_size = m_partition->size;
|
||||
}
|
||||
esp_err_t err = esp_ota_begin(m_partition, erase_size, &m_handle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err));
|
||||
m_partition = nullptr;
|
||||
@@ -219,7 +232,7 @@ esp_err_t Esp32OtaCore::computeDeviceSha(char *out_device_sha_hex)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 收尾 OTA 会话:end、校验 SHA、set_boot、可选重启。
|
||||
* @brief 收尾 OTA 会话:校验 SHA、end、set_boot、可选重启。
|
||||
* @param frontend_sha_hex 前端提供的 64 字符 hex SHA-256。
|
||||
* @param reboot 成功后是否重启。
|
||||
* @param out_device_sha_hex 输出设备端 SHA hex 缓冲区。
|
||||
@@ -233,26 +246,14 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// ── esp_ota_end ──
|
||||
DEBUG_INFO(TAG, "[FINALIZE] esp_ota_end...");
|
||||
esp_err_t err = esp_ota_end(m_handle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "esp_ota_end 失败: %s", esp_err_to_name(err));
|
||||
// end 失败时仍可 abort(未真正 end)
|
||||
esp_ota_abort(m_handle);
|
||||
m_ended = false;
|
||||
esp32_ota_mutex_release();
|
||||
resetState();
|
||||
return err;
|
||||
}
|
||||
m_ended = true; // ★ end 成功,后续不得再 abort
|
||||
DEBUG_INFO(TAG, "[FINALIZE] esp_ota_end 完成");
|
||||
|
||||
// ── SHA-256 校验(若前端提供)──
|
||||
// ── 先读回分区计算 SHA-256,并与前端文件 SHA 比对 ──
|
||||
if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 校验 SHA-256...");
|
||||
esp_err_t sha_err = computeShaFromPartition();
|
||||
if (sha_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err));
|
||||
esp_ota_abort(m_handle);
|
||||
m_ended = false;
|
||||
esp32_ota_mutex_release();
|
||||
resetState();
|
||||
return sha_err;
|
||||
@@ -263,11 +264,11 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02x", m_sha256[i]);
|
||||
}
|
||||
|
||||
bool match = true;
|
||||
bool sha_match = true;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (tolower((unsigned char)frontend_sha_hex[i]) !=
|
||||
tolower((unsigned char)device_hex[i])) {
|
||||
match = false;
|
||||
sha_match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -277,16 +278,19 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
out_device_sha_hex[64] = '\0';
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
DEBUG_ERR(TAG, "SHA-256 不匹配! frontend=%s device=%s,中止",
|
||||
if (sha_match) {
|
||||
DEBUG_INFO(TAG, "SHA-256 校验通过");
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s",
|
||||
frontend_sha_hex, device_hex);
|
||||
esp_ota_abort(m_handle);
|
||||
m_ended = false;
|
||||
esp32_ota_mutex_release();
|
||||
resetState();
|
||||
return ESP_ERR_INVALID_VERSION; // 调用方据此返回错误给前端
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
DEBUG_INFO(TAG, "SHA-256 校验通过");
|
||||
} else if (out_device_sha_hex) {
|
||||
// 未提供前端 SHA,但调用方仍想拿到设备端 SHA
|
||||
// 未提供前端 SHA,但仍计算设备端 SHA 供输出
|
||||
if (computeShaFromPartition() == ESP_OK) {
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&out_device_sha_hex[i * 2], 65 - i * 2, "%02x", m_sha256[i]);
|
||||
@@ -295,8 +299,20 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
}
|
||||
}
|
||||
|
||||
// ── esp_ota_end ──
|
||||
esp_err_t err = esp_ota_end(m_handle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "esp_ota_end 失败: %s (0x%x)", esp_err_to_name(err), (unsigned)err);
|
||||
// end 失败时仍可 abort(未真正 end)
|
||||
esp_ota_abort(m_handle);
|
||||
m_ended = false;
|
||||
esp32_ota_mutex_release();
|
||||
resetState();
|
||||
return err;
|
||||
}
|
||||
m_ended = true; // ★ end 成功,后续不得再 abort
|
||||
|
||||
// ── 设置启动分区 ──
|
||||
DEBUG_INFO(TAG, "[FINALIZE] esp_ota_set_boot_partition...");
|
||||
err = esp_ota_set_boot_partition(m_partition);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
|
||||
@@ -304,7 +320,6 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
resetState();
|
||||
return err;
|
||||
}
|
||||
DEBUG_INFO(TAG, "[FINALIZE] esp_ota_set_boot_partition 完成");
|
||||
|
||||
esp32_ota_mutex_release();
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received);
|
||||
@@ -313,7 +328,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
// (此处不 reset,以便 BLE finish task 在重启前可查询状态)
|
||||
|
||||
if (reboot) {
|
||||
DEBUG_INFO(TAG, "[FINALIZE] 500ms 后重启...");
|
||||
DEBUG_INFO(TAG, "500ms 后重启 ESP32");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
esp_restart();
|
||||
// 不会返回
|
||||
@@ -336,3 +351,42 @@ void Esp32OtaCore::abort()
|
||||
resetState();
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 强制退出 OTA 状态机并释放互斥锁。
|
||||
*
|
||||
* 用于新通道(WiFi/BLE)发现锁被残留占用时,主动走完整的状态机退出流程:
|
||||
* 1. 若会话活跃(m_active)→ 走 abort() 完整清理(esp_ota_abort + 释放锁 + 复位状态)
|
||||
* 2. 若会话不活跃但锁被占(g_esp32OtaOccupied)→ 仅释放残留锁
|
||||
* 3. 否则无需操作
|
||||
*
|
||||
* @return true 执行了清理(调用方可重试 begin),false 无需清理。
|
||||
*/
|
||||
bool Esp32OtaCore::forceCleanup()
|
||||
{
|
||||
if (m_active) {
|
||||
// 会话仍活跃(前一个通道未正常结束),走完整 abort 流程
|
||||
DEBUG_WARN(TAG, "强制清理: 中止活跃的 OTA 会话 (received=%u)", (unsigned)m_received);
|
||||
abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 会话不活跃,但锁可能被残留占用
|
||||
if (!g_esp32OtaMutex) return false;
|
||||
|
||||
// 在互斥保护下检查 g_esp32OtaOccupied
|
||||
if (xSemaphoreTake(g_esp32OtaMutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false;
|
||||
|
||||
bool was_occupied = g_esp32OtaOccupied;
|
||||
if (was_occupied) {
|
||||
g_esp32OtaOccupied = false;
|
||||
}
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
|
||||
if (was_occupied) {
|
||||
DEBUG_WARN(TAG, "强制清理: 释放残留 OTA 锁(无活跃会话)");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 统一以 WiFi 流程为基准:
|
||||
* - esp_ota_begin 第二参数传 total_size
|
||||
* - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算)
|
||||
* - finalize 顺序: esp_ota_end → 比对前端 SHA → esp_ota_set_boot_partition → 可选重启
|
||||
* - finalize 顺序: 读回 SHA-256 → 比对前端 SHA → esp_ota_end → esp_ota_set_boot_partition → 可选重启
|
||||
* - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle
|
||||
*
|
||||
* WiFi 与 BLE 仅传输方式不同(HTTP / GATT),OTA 写入与收尾逻辑完全复用本核心。
|
||||
@@ -48,7 +48,7 @@ public:
|
||||
*/
|
||||
esp_err_t write(const uint8_t *data, size_t len);
|
||||
|
||||
// ── 阶段 3:收尾(end → 校验 SHA → set_boot → 可选重启)──
|
||||
// ── 阶段 3:收尾(校验 SHA → end → set_boot → 可选重启)──
|
||||
// frontend_sha_hex: 前端提供的 64 字符 hex SHA-256,nullptr 表示跳过校验直接应用
|
||||
// reboot: true 则 set_boot 成功后延时 500ms 调 esp_restart()
|
||||
// out_device_sha_hex: 输出设备端计算出的 SHA hex(缓冲区至少 65 字节),可为 nullptr
|
||||
@@ -57,7 +57,7 @@ public:
|
||||
// ESP_ERR_INVALID_VERSION SHA 不匹配(已 abort 并清理)
|
||||
// 其他 end/set_boot 失败(已清理)
|
||||
/**
|
||||
* @brief 收尾 OTA 会话:end、校验 SHA、set_boot、可选重启。
|
||||
* @brief 收尾 OTA 会话:校验 SHA、end、set_boot、可选重启。
|
||||
* @param frontend_sha_hex 前端提供的 64 字符 hex SHA-256。
|
||||
* @param reboot 成功后是否重启。
|
||||
* @param out_device_sha_hex 输出设备端 SHA hex 缓冲区。
|
||||
@@ -84,16 +84,21 @@ public:
|
||||
*/
|
||||
void abort();
|
||||
|
||||
// ── 强制退出状态机并释放互斥锁 ──
|
||||
// 用于新通道发现锁被残留占用时,主动走完整的状态机退出流程:
|
||||
// 活跃会话 → abort();仅锁残留 → 直接释放;均无 → 不操作
|
||||
// 返回 true = 执行了清理(调用方可重试 begin)
|
||||
/**
|
||||
* @brief 强制退出 OTA 状态机并释放互斥锁。
|
||||
* @return true 执行了清理,false 无需清理。
|
||||
*/
|
||||
bool forceCleanup();
|
||||
|
||||
// ── 状态查询 ──
|
||||
/** @brief 查询 OTA 是否激活。 */
|
||||
bool isActive() const { return m_active; }
|
||||
/** @brief 获取已接收的字节数。 */
|
||||
size_t getReceived() const { return m_received; }
|
||||
/** @brief 查询是否等待重启。 */
|
||||
bool isRebootPending() const { return m_rebootPending; }
|
||||
/** @brief 设置重启待处理标志。 */
|
||||
void setRebootPending(bool v) { m_rebootPending = v; }
|
||||
|
||||
private:
|
||||
Esp32OtaCore() = default;
|
||||
|
||||
@@ -117,7 +122,6 @@ private:
|
||||
uint8_t m_sha256[32] = {0};
|
||||
bool m_shaComputed = false;
|
||||
bool m_ended = false; // ★ esp_ota_end 成功后置 true,防 abort 误用
|
||||
bool m_rebootPending = false; // BLE 收尾任务去重用
|
||||
};
|
||||
|
||||
#endif // ESP32_OTA_CORE_H
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* ota_coordinator.cpp - 全局 OTA 协调器实现
|
||||
*
|
||||
* OtaCoordinator 是系统级 OTA 状态的唯一权威来源。
|
||||
* 传输层(WiFi/BLE)通过本协调器发起所有 OTA 操作。
|
||||
*/
|
||||
#include "ota_coordinator.h"
|
||||
#include "esp32_ota_core.h"
|
||||
#include "relay/ota_relay.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
#define TAG "OTA_COORD"
|
||||
|
||||
// ============================================================
|
||||
// 单例
|
||||
// ============================================================
|
||||
OtaCoordinator& OtaCoordinator::instance()
|
||||
{
|
||||
static OtaCoordinator inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 线程安全辅助
|
||||
// ============================================================
|
||||
bool OtaCoordinator::lock()
|
||||
{
|
||||
// ★ 修复: 返回 take 结果,由调用方传回 unlock()。
|
||||
// 旧实现用成员 bool m_locked 记录 take 结果,多任务并发 lock() 时
|
||||
// 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give,
|
||||
// mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。
|
||||
if (!m_mutex) return false;
|
||||
bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE);
|
||||
if (!ok) {
|
||||
DEBUG_WARN(TAG, "lock() 超时 (%u ms),本次状态变更被跳过",
|
||||
(unsigned)pdTICKS_TO_MS(kLockTimeout));
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void OtaCoordinator::unlock(bool handle)
|
||||
{
|
||||
// ★ 修复: 仅在对应的 lock() 成功 take 时才 give。
|
||||
if (handle && m_mutex) {
|
||||
xSemaphoreGive(m_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 卡死自愈检查(必须在持锁状态下调用)
|
||||
// ============================================================
|
||||
void OtaCoordinator::watchdogCheckLocked()
|
||||
{
|
||||
// 仅对"忙"状态(可能卡死的)做检查
|
||||
switch (m_sysState) {
|
||||
case OTA_SYS_GD32_STORING:
|
||||
case OTA_SYS_GD32_RELAYING:
|
||||
case OTA_SYS_ESP32_RECEIVING:
|
||||
case OTA_SYS_ESP32_FINALIZING:
|
||||
case OTA_SYS_ESP32_REBOOTING:
|
||||
break;
|
||||
default:
|
||||
return; // IDLE/STORED/COMPLETE/ERROR 无需检查
|
||||
}
|
||||
|
||||
if (m_busySinceTick == 0) {
|
||||
// 未记录起始时刻(理论上不应发生),保守不触发
|
||||
return;
|
||||
}
|
||||
|
||||
TickType_t elapsed = xTaskGetTickCount() - m_busySinceTick;
|
||||
if (elapsed < kBusyWatchdogTicks) {
|
||||
return; // 未超时
|
||||
}
|
||||
|
||||
// ★ 超时自愈:强制转 ERROR。
|
||||
// ERROR 不在任何 beginXxx() 的拒绝列表中,下次请求会自动从 ERROR 恢复。
|
||||
// 清理由各子系统自行负责(abortCurrent 中会按 ERROR 不做实际清理,仅复位状态)。
|
||||
DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制自愈 → ERROR",
|
||||
(int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks));
|
||||
m_sysState = OTA_SYS_ERROR;
|
||||
m_busySinceTick = 0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
void OtaCoordinator::init()
|
||||
{
|
||||
if (!m_mutex) {
|
||||
m_mutex = xSemaphoreCreateMutex();
|
||||
if (m_mutex) {
|
||||
DEBUG_INFO(TAG, "OTA 全局协调器已初始化");
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "协调器 mutex 创建失败");
|
||||
}
|
||||
}
|
||||
m_sysState = OTA_SYS_IDLE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0;
|
||||
|
||||
// ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态,
|
||||
// 否则用户重启后看到的状态是 IDLE 但 NVS 里其实有未中继的固件,前后不一致。
|
||||
ota_meta_t boot_meta;
|
||||
if (OtaRelay::metaLoad(&boot_meta) == ESP_OK &&
|
||||
boot_meta.total_size > 0 &&
|
||||
boot_meta.stored_size >= boot_meta.total_size) {
|
||||
m_sysState = OTA_SYS_GD32_STORED;
|
||||
m_gd32StorageDone = true;
|
||||
DEBUG_INFO(TAG, "启动恢复: NVS 检测到完整 GD32 固件 (%" PRIu32 " 字节), → GD32_STORED",
|
||||
boot_meta.total_size);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GD32 存储阶段
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size)
|
||||
{
|
||||
bool h = lock();
|
||||
|
||||
// ★ 先做卡死自愈检查:若之前忙状态卡死,自动转 ERROR 继续后续流程
|
||||
if (h) watchdogCheckLocked();
|
||||
|
||||
// 系统忙检查:仅拒绝真正"在传输"的活跃状态
|
||||
// STORED / COMPLETE 是"待中继/已中继"的非活跃状态 → 允许新存储覆盖
|
||||
if (h && (m_sysState == OTA_SYS_ESP32_RECEIVING ||
|
||||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
|
||||
m_sysState == OTA_SYS_ESP32_REBOOTING ||
|
||||
m_sysState == OTA_SYS_GD32_STORING ||
|
||||
m_sysState == OTA_SYS_GD32_RELAYING)) {
|
||||
DEBUG_WARN(TAG, "系统忙 (state=%d),拒绝 GD32 存储请求", (int)m_sysState);
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 如果之前已有存储但未中继(STORED)或刚中继完(COMPLETE),先清理再开始新存储
|
||||
if (h && (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE)) {
|
||||
DEBUG_INFO(TAG, "清理旧 GD32 存储状态,开始新存储 (size=%" PRIu32 ")", total_size);
|
||||
OtaRelay::metaClear();
|
||||
}
|
||||
|
||||
if (h) {
|
||||
m_sysState = OTA_SYS_GD32_STORING;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
|
||||
DEBUG_INFO(TAG, "GD32 固件存储开始: %" PRIu32 " 字节, 系统状态 → GD32_STORING", total_size);
|
||||
}
|
||||
unlock(h);
|
||||
return h ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
void OtaCoordinator::completeGd32Storage()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h && m_sysState == OTA_SYS_GD32_STORING) {
|
||||
m_sysState = OTA_SYS_GD32_STORED;
|
||||
m_gd32StorageDone = true;
|
||||
m_busySinceTick = 0; // ★ STORED 是非忙状态
|
||||
DEBUG_INFO(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED");
|
||||
} else if (h) {
|
||||
DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState);
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GD32 中继阶段
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::triggerGd32Relay()
|
||||
{
|
||||
bool h = lock();
|
||||
|
||||
// ★ 卡死自愈检查
|
||||
if (h) watchdogCheckLocked();
|
||||
|
||||
// 校验:必须处于"已存储"或"已中继完成"状态
|
||||
if (h && m_sysState != OTA_SYS_GD32_STORED && m_sysState != OTA_SYS_IDLE) {
|
||||
if (m_sysState == OTA_SYS_GD32_STORING) {
|
||||
DEBUG_WARN(TAG, "GD32 正在存储中,拒绝触发中继");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (m_sysState == OTA_SYS_GD32_RELAYING) {
|
||||
DEBUG_WARN(TAG, "GD32 中继已在进行中");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
|
||||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
|
||||
m_sysState == OTA_SYS_ESP32_REBOOTING) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 进行中,拒绝中继触发");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证固件是否确实已存储(通过 NVS 元数据)
|
||||
ota_meta_t meta;
|
||||
if (h && (OtaRelay::metaLoad(&meta) != ESP_OK ||
|
||||
meta.total_size == 0 ||
|
||||
meta.stored_size < meta.total_size)) {
|
||||
DEBUG_WARN(TAG, "GD32 固件未完整存储,拒绝触发中继");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 检查 OtaRelay 是否空闲
|
||||
if (h && OtaRelay::getState() != RELAY_IDLE &&
|
||||
OtaRelay::getState() != RELAY_DONE &&
|
||||
OtaRelay::getState() != RELAY_ERROR) {
|
||||
DEBUG_WARN(TAG, "OtaRelay 正忙 (state=%d),拒绝触发", (int)OtaRelay::getState());
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 向 OtaRelay task 发送启动消息
|
||||
if (h && !OtaRelay::dispatchQueue) {
|
||||
DEBUG_ERR(TAG, "OtaRelay dispatchQueue 未就绪");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (!h) {
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
m_sysState = OTA_SYS_GD32_RELAYING;
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
|
||||
DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING");
|
||||
|
||||
uint32_t cmd = 1;
|
||||
BaseType_t ret = xQueueSend(OtaRelay::dispatchQueue, &cmd, pdMS_TO_TICKS(200));
|
||||
if (ret != pdTRUE) {
|
||||
DEBUG_ERR(TAG, "向 OtaRelay 发送启动消息失败");
|
||||
m_sysState = OTA_SYS_GD32_STORED;
|
||||
m_busySinceTick = 0; // 回退到 STORED(非忙)
|
||||
unlock(h);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
unlock(h);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyRelayStarted()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h && m_sysState != OTA_SYS_GD32_RELAYING) {
|
||||
m_sysState = OTA_SYS_GD32_RELAYING;
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
|
||||
DEBUG_INFO(TAG, "中继已开始, 系统状态 → GD32_RELAYING");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyRelayComplete(bool success)
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
if (success) {
|
||||
m_sysState = OTA_SYS_GD32_COMPLETE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0; // ★ COMPLETE 非忙
|
||||
DEBUG_INFO(TAG, "GD32 中继完成, 系统状态 → GD32_COMPLETE");
|
||||
} else {
|
||||
m_sysState = OTA_SYS_ERROR;
|
||||
m_busySinceTick = 0; // ★ ERROR 非忙(可被后续请求恢复)
|
||||
DEBUG_ERR(TAG, "GD32 中继失败, 系统状态 → ERROR");
|
||||
}
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ESP32 OTA 阶段
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size)
|
||||
{
|
||||
bool h = lock();
|
||||
|
||||
// ★ 卡死自愈检查
|
||||
if (h) watchdogCheckLocked();
|
||||
|
||||
// 系统忙检查
|
||||
if (h && (m_sysState == OTA_SYS_GD32_STORING ||
|
||||
m_sysState == OTA_SYS_GD32_RELAYING)) {
|
||||
DEBUG_WARN(TAG, "GD32 正在 %s,拒绝 ESP32 OTA",
|
||||
m_sysState == OTA_SYS_GD32_STORING ? "存储" : "中继");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t err = ESP_ERR_INVALID_STATE;
|
||||
if (h) {
|
||||
// 委托 Esp32OtaCore 处理分区/互斥逻辑
|
||||
err = Esp32OtaCore::instance().begin(total_size);
|
||||
if (err == ESP_OK) {
|
||||
m_sysState = OTA_SYS_ESP32_RECEIVING;
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 开始: %" PRIu32 " 字节, 系统状态 → ESP32_RECEIVING", total_size);
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
unlock(h);
|
||||
return err;
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyEsp32ReceiveComplete()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
|
||||
m_sysState = OTA_SYS_ESP32_FINALIZING;
|
||||
DEBUG_INFO(TAG, "ESP32 固件接收完毕, 系统状态 → ESP32_FINALIZING");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyEsp32FinalizeComplete()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
m_sysState = OTA_SYS_ESP32_REBOOTING;
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成, 系统状态 → ESP32_REBOOTING");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyEsp32Rebooting()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
m_sysState = OTA_SYS_ESP32_REBOOTING;
|
||||
DEBUG_INFO(TAG, "ESP32 即将重启, 系统状态 → ESP32_REBOOTING");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 中止
|
||||
// ============================================================
|
||||
void OtaCoordinator::abortCurrent()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
DEBUG_INFO(TAG, "中止当前 OTA 操作 (state=%d)", (int)m_sysState);
|
||||
|
||||
if (m_sysState == OTA_SYS_GD32_STORING) {
|
||||
OtaRelay::metaClear();
|
||||
} else if (m_sysState == OTA_SYS_GD32_RELAYING) {
|
||||
OtaRelay::instance().reset();
|
||||
} else if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
|
||||
m_sysState == OTA_SYS_ESP32_FINALIZING) {
|
||||
Esp32OtaCore::instance().abort();
|
||||
}
|
||||
|
||||
m_sysState = OTA_SYS_IDLE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0; // ★ 离开忙状态
|
||||
DEBUG_INFO(TAG, "已中止, 系统状态 → IDLE");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态查询
|
||||
// ============================================================
|
||||
ota_system_state_t OtaCoordinator::getSystemState() const
|
||||
{
|
||||
// 对于纯读取,短暂加锁保证一致性
|
||||
OtaCoordinator* self = const_cast<OtaCoordinator*>(this);
|
||||
bool h = self->lock();
|
||||
ota_system_state_t s = m_sysState;
|
||||
self->unlock(h);
|
||||
return s;
|
||||
}
|
||||
|
||||
bool OtaCoordinator::isIdle() const
|
||||
{
|
||||
return (getSystemState() == OTA_SYS_IDLE ||
|
||||
getSystemState() == OTA_SYS_GD32_STORED ||
|
||||
getSystemState() == OTA_SYS_GD32_COMPLETE);
|
||||
}
|
||||
|
||||
bool OtaCoordinator::isGd32Busy() const
|
||||
{
|
||||
ota_system_state_t s = getSystemState();
|
||||
return (s == OTA_SYS_GD32_STORING ||
|
||||
s == OTA_SYS_GD32_RELAYING);
|
||||
}
|
||||
|
||||
bool OtaCoordinator::isEsp32Busy() const
|
||||
{
|
||||
ota_system_state_t s = getSystemState();
|
||||
return (s == OTA_SYS_ESP32_RECEIVING ||
|
||||
s == OTA_SYS_ESP32_FINALIZING ||
|
||||
s == OTA_SYS_ESP32_REBOOTING);
|
||||
}
|
||||
|
||||
const char* OtaCoordinator::getSystemStateString() const
|
||||
{
|
||||
switch (getSystemState()) {
|
||||
case OTA_SYS_IDLE: return "IDLE";
|
||||
case OTA_SYS_GD32_STORING: return "GD32_STORING";
|
||||
case OTA_SYS_GD32_STORED: return "GD32_STORED";
|
||||
case OTA_SYS_GD32_RELAYING: return "GD32_RELAYING";
|
||||
case OTA_SYS_GD32_COMPLETE: return "GD32_COMPLETE";
|
||||
case OTA_SYS_ESP32_RECEIVING: return "ESP32_RECEIVING";
|
||||
case OTA_SYS_ESP32_FINALIZING: return "ESP32_FINALIZING";
|
||||
case OTA_SYS_ESP32_REBOOTING: return "ESP32_REBOOTING";
|
||||
case OTA_SYS_ERROR: return "ERROR";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
bool OtaCoordinator::getGd32AppAlive() const
|
||||
{
|
||||
return OtaRelay::getGd32AppAlive();
|
||||
}
|
||||
|
||||
bool OtaCoordinator::isGd32StorageDone() const
|
||||
{
|
||||
OtaCoordinator* self = const_cast<OtaCoordinator*>(this);
|
||||
bool h = self->lock();
|
||||
bool done = m_gd32StorageDone;
|
||||
self->unlock(h);
|
||||
return done;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 完全复位
|
||||
// ============================================================
|
||||
void OtaCoordinator::reset()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
m_sysState = OTA_SYS_IDLE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0; // ★ 复位
|
||||
DEBUG_INFO(TAG, "协调器已完全复位");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* ota_coordinator.h - 全局 OTA 协调器
|
||||
*
|
||||
* OtaCoordinator 是系统级 OTA 状态的唯一权威来源,负责:
|
||||
* 1. 串行化 GD32 中继与 ESP32 OTA 操作(同一时刻只能进行一种)
|
||||
* 2. 为传输层(WiFi/BLE)提供统一的 OTA 操作入口
|
||||
* 3. 通过 FreeRTOS 队列向 OtaRelay task 投递中继命令
|
||||
* 4. 向上层(web UI / status API)暴露系统级状态
|
||||
*
|
||||
* 线程模型:
|
||||
* - 状态读写由内部 mutex 保护
|
||||
* - begin/complete 接口可在任意任务中调用
|
||||
* - triggerGd32Relay() 向 OtaRelay task 的 dispatchQueue 发送消息
|
||||
*/
|
||||
#ifndef OTA_COORDINATOR_H
|
||||
#define OTA_COORDINATOR_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_err.h"
|
||||
|
||||
// ============================================================
|
||||
// 系统级 OTA 状态枚举
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
OTA_SYS_IDLE = 0,
|
||||
OTA_SYS_GD32_STORING, // 正在从传输层接收 GD32 固件
|
||||
OTA_SYS_GD32_STORED, // GD32 固件已完整存储,等待中继触发
|
||||
OTA_SYS_GD32_RELAYING, // 正在通过 SPI 向 GD32 中继固件
|
||||
OTA_SYS_GD32_COMPLETE, // GD32 OTA 完成(APP 已存活确认)
|
||||
OTA_SYS_ESP32_RECEIVING, // 正在接收 ESP32 自身固件
|
||||
OTA_SYS_ESP32_FINALIZING, // 正在收尾 ESP32 OTA(SHA校验 + end + set_boot)
|
||||
OTA_SYS_ESP32_REBOOTING, // 即将重启 ESP32
|
||||
OTA_SYS_ERROR // 错误状态
|
||||
} ota_system_state_t;
|
||||
|
||||
/**
|
||||
* @brief 全局 OTA 协调器(单例)
|
||||
*
|
||||
* 提供系统级 OTA 状态管理与操作串行化。
|
||||
* 传输层(WiFi/BLE)通过本协调器发起所有 OTA 操作,
|
||||
* 不再直接调用 OtaRelay / Esp32OtaCore 的底层接口。
|
||||
*/
|
||||
class OtaCoordinator {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 OtaCoordinator 单例实例。
|
||||
*/
|
||||
static OtaCoordinator& instance();
|
||||
|
||||
// ── 初始化 ──
|
||||
/**
|
||||
* @brief 初始化协调器(创建 mutex 和队列)。
|
||||
* 在 OtaRelay task 创建前调用。
|
||||
*/
|
||||
void init();
|
||||
|
||||
// ── GD32 存储阶段(传输层 → Flash)──
|
||||
/**
|
||||
* @brief 传输层开始接收 GD32 固件前调用。
|
||||
* 若系统正忙(ESP32 OTA 进行中或 GD32 中继中),返回失败。
|
||||
* @param total_size 固件总字节数。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 系统忙。
|
||||
*/
|
||||
esp_err_t beginGd32Storage(uint32_t total_size);
|
||||
|
||||
/**
|
||||
* @brief GD32 固件接收完毕时调用。
|
||||
* 更新系统状态为 OTA_SYS_GD32_STORED。
|
||||
*/
|
||||
void completeGd32Storage();
|
||||
|
||||
// ── GD32 中继阶段 ──
|
||||
/**
|
||||
* @brief 触发 GD32 中继转发。
|
||||
* 向 OtaRelay task 的 dispatchQueue 发送启动消息。
|
||||
* @return ESP_OK 消息已投递,ESP_ERR_INVALID_STATE 不允许触发。
|
||||
*/
|
||||
esp_err_t triggerGd32Relay();
|
||||
|
||||
/**
|
||||
* @brief OtaRelay task 回调:中继已开始。
|
||||
* 由 OtaRelay 在开始 forwardFirmware() 时调用。
|
||||
*/
|
||||
void notifyRelayStarted();
|
||||
|
||||
/**
|
||||
* @brief OtaRelay task 回调:中继已完成。
|
||||
* 由 OtaRelay 在 forwardFirmware() 返回后调用。
|
||||
* @param success true 成功,false 失败。
|
||||
*/
|
||||
void notifyRelayComplete(bool success);
|
||||
|
||||
// ── ESP32 OTA 阶段 ──
|
||||
/**
|
||||
* @brief ESP32 OTA 开始接收固件。
|
||||
* 内部委托 Esp32OtaCore::begin()。
|
||||
* @param total_size 固件总字节数。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 系统忙或通道被占用。
|
||||
*/
|
||||
esp_err_t beginEsp32Ota(uint32_t total_size);
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA 固件接收完毕,准备收尾。
|
||||
* 更新系统状态为 OTA_SYS_ESP32_FINALIZING。
|
||||
*/
|
||||
void notifyEsp32ReceiveComplete();
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA 收尾完成(finalize 成功)。
|
||||
*/
|
||||
void notifyEsp32FinalizeComplete();
|
||||
|
||||
/**
|
||||
* @brief ESP32 即将重启。
|
||||
*/
|
||||
void notifyEsp32Rebooting();
|
||||
|
||||
// ── 中止 ──
|
||||
/**
|
||||
* @brief 中止当前 OTA 操作(GD32 或 ESP32)。
|
||||
* 内部调用对应子系统的 abort/reset。
|
||||
*/
|
||||
void abortCurrent();
|
||||
|
||||
// ── 状态查询 ──
|
||||
/**
|
||||
* @brief 获取当前系统级 OTA 状态。
|
||||
*/
|
||||
ota_system_state_t getSystemState() const;
|
||||
|
||||
/**
|
||||
* @brief 检查系统是否空闲(可接受新的 OTA 请求)。
|
||||
*/
|
||||
bool isIdle() const;
|
||||
|
||||
/**
|
||||
* @brief 检查是否有 GD32 相关操作正在进行。
|
||||
*/
|
||||
bool isGd32Busy() const;
|
||||
|
||||
/**
|
||||
* @brief 检查是否有 ESP32 OTA 正在进行。
|
||||
*/
|
||||
bool isEsp32Busy() const;
|
||||
|
||||
/**
|
||||
* @brief 获取系统状态的可读字符串描述。
|
||||
*/
|
||||
const char* getSystemStateString() const;
|
||||
|
||||
// ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)──
|
||||
/**
|
||||
* @brief 获取 GD32 APP 存活状态。
|
||||
*/
|
||||
bool getGd32AppAlive() const;
|
||||
|
||||
/**
|
||||
* @brief GD32 固件存储完成标志(供状态查询)。
|
||||
*/
|
||||
bool isGd32StorageDone() const;
|
||||
|
||||
// ── 完全复位 ──
|
||||
/**
|
||||
* @brief 完全复位协调器和所有子系统状态。
|
||||
*/
|
||||
void reset();
|
||||
|
||||
private:
|
||||
OtaCoordinator() = default;
|
||||
~OtaCoordinator() = default;
|
||||
OtaCoordinator(const OtaCoordinator&) = delete;
|
||||
OtaCoordinator& operator=(const OtaCoordinator&) = delete;
|
||||
|
||||
// 线程安全辅助
|
||||
// ★ lock() 返回是否成功 take;unlock(handle) 据此决定是否 give。
|
||||
// 旧实现用共享 bool m_locked 记录 take 结果,多任务并发 lock() 时
|
||||
// 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give,
|
||||
// mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。
|
||||
bool lock();
|
||||
void unlock(bool handle);
|
||||
|
||||
// ★ 卡死自愈检查:若忙状态停留超过 watchdog 超时,强制转为 ERROR。
|
||||
// 用于在中继 task 因任何原因(崩溃/SPI 死锁/看门狗复位残留)未能回调
|
||||
// notifyRelayComplete/notifyEsp32*Complete 时,让协调器自动脱离卡死状态。
|
||||
// 必须在已持锁状态下调用。
|
||||
void watchdogCheckLocked();
|
||||
|
||||
// 系统状态
|
||||
ota_system_state_t m_sysState = OTA_SYS_IDLE;
|
||||
|
||||
// 进入当前忙状态的时刻(tick)。IDLE/STORED/COMPLETE/ERROR 时为 0。
|
||||
TickType_t m_busySinceTick = 0;
|
||||
|
||||
// GD32 存储跟踪
|
||||
bool m_gd32StorageDone = false;
|
||||
|
||||
// 互斥锁
|
||||
SemaphoreHandle_t m_mutex = nullptr;
|
||||
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100);
|
||||
|
||||
// ★ 忙状态 watchdog 超时。GD32 中继理论最大耗时 ≈ 60s(全局超时) + 15s(APP存活轮询) + 1s(bootloader) ≈ 76s,
|
||||
// 取 90 s,正常流程远不会触及,仅在 task 卡死时触发自愈。
|
||||
static constexpr TickType_t kBusyWatchdogTicks = pdMS_TO_TICKS(90U * 1000U);
|
||||
};
|
||||
|
||||
#endif // OTA_COORDINATOR_H
|
||||
+446
-498
File diff suppressed because it is too large
Load Diff
+135
-242
@@ -1,8 +1,16 @@
|
||||
/*
|
||||
* ota_relay.h - OTA 中继核心状态机
|
||||
* T2.3, T3.1 - T3.7
|
||||
* ota_relay.h - OTA 中继事件驱动状态机
|
||||
*
|
||||
* OtaRelay 类封装 OTA 中继状态机、Flash 存储和元数据管理。
|
||||
* OtaRelay 将 GD32 SPI OTA 协议封装为事件驱动的状态机:
|
||||
* 1. 每个协议阶段对应一个状态(共 6 个阶段状态 + ERROR)
|
||||
* 2. 状态通过 processEvent() 驱动转换,而非顺序赋值
|
||||
* 3. 外部(OtaCoordinator / transport layer)通过 dispatchQueue 触发中继
|
||||
* 4. forwardFirmware() 保留为便捷入口,内部驱动状态机循环
|
||||
*
|
||||
* 状态转换:
|
||||
* IDLE → HANDSHAKING → SIZE_NEG → TRANSFERRING → FINISHING → DONE
|
||||
* 任意状态 ──(error)──→ ERROR
|
||||
* ERROR/DONE ──(reset)──→ IDLE
|
||||
*/
|
||||
#ifndef OTA_RELAY_H
|
||||
#define OTA_RELAY_H
|
||||
@@ -24,26 +32,31 @@ typedef struct {
|
||||
uint8_t checksum[32];
|
||||
} ota_meta_t;
|
||||
|
||||
// ============================================================
|
||||
// 阶段状态枚举(精简为 7 个状态,原 17 个"微状态"合并为阶段)
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
RELAY_IDLE = 0,
|
||||
RELAY_HANDSHAKE_SENT,
|
||||
RELAY_CHALLENGE_SENT,
|
||||
RELAY_CHALLENGE_RESPOND,
|
||||
RELAY_SESSION_KEY_SENT,
|
||||
RELAY_AUTH_COMPLETE,
|
||||
RELAY_SIZE_SENT,
|
||||
RELAY_SIZE_ACK,
|
||||
RELAY_RELAY_ACTIVE,
|
||||
RELAY_PACKET_SENT,
|
||||
RELAY_PACKET_ACK_OK,
|
||||
RELAY_PACKET_ACK_RETRY,
|
||||
RELAY_RECEIVE_DONE,
|
||||
RELAY_FINISH_SENT,
|
||||
RELAY_DONE,
|
||||
RELAY_APP_VERIFY,
|
||||
RELAY_ERROR = 0xFF
|
||||
RELAY_IDLE = 0, // 空闲
|
||||
RELAY_HANDSHAKING, // 阶段1: 握手 + 挑战认证
|
||||
RELAY_SIZE_NEG, // 阶段2: 固件大小协商
|
||||
RELAY_TRANSFERRING, // 阶段3: 数据包传输
|
||||
RELAY_FINISHING, // 阶段4-5: 结束OTA + APP存活验证
|
||||
RELAY_DONE, // 完成
|
||||
RELAY_ERROR = 0xFF // 错误
|
||||
} relay_state_t;
|
||||
|
||||
// ============================================================
|
||||
// 事件类型(驱动状态转换)
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
RELAY_EVT_START = 0, // 启动中继流程
|
||||
RELAY_EVT_RESP_OK, // 收到 GD32 有效响应
|
||||
RELAY_EVT_RESP_ERROR, // 收到 GD32 错误响应
|
||||
RELAY_EVT_TIMEOUT, // 超时
|
||||
RELAY_EVT_HEALTH_CRIT, // 健康检查危险
|
||||
RELAY_EVT_ABORT // 外部中止
|
||||
} relay_event_t;
|
||||
|
||||
typedef enum {
|
||||
OTA_RESULT_OK = 0,
|
||||
OTA_RESULT_ERR_TIMEOUT = -1,
|
||||
@@ -59,140 +72,116 @@ class OtaRelay {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取OtaRelay单例引用.
|
||||
* @return OtaRelay单例引用.
|
||||
*/
|
||||
static OtaRelay& instance();
|
||||
|
||||
/**
|
||||
* @brief OTA中继FreeRTOS任务入口.
|
||||
* @param pvParameters FreeRTOS任务参数.
|
||||
*/
|
||||
static void taskEntry(void *pvParameters);
|
||||
|
||||
/**
|
||||
* @brief 从Flash向GD32转发固件(主流程).
|
||||
* @brief 从Flash向GD32转发固件(便捷入口,内部驱动状态机循环).
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
ota_result_t forwardFirmware();
|
||||
|
||||
/* ---- 线程安全的状态访问接口 ----
|
||||
* OTA 任务写、HTTP 任务读,通过 mutex 保证一致性。
|
||||
* 状态机内部(OTA 任务)可直接写 static 成员,
|
||||
* 跨任务读/写必须走这些 API。
|
||||
// ── 事件驱动接口 ──
|
||||
/**
|
||||
* @brief 向状态机投递事件,驱动状态转换.
|
||||
* @param event 事件类型.
|
||||
* @param payload 关联数据(响应载荷等),可为nullptr.
|
||||
* @param len 数据长度.
|
||||
* @return 当前操作结果码(OTA_RESULT_OK 表示事件已处理且可继续).
|
||||
*/
|
||||
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
|
||||
// ── 状态查询(线程安全)──
|
||||
/**
|
||||
* @brief 获取当前中继状态.
|
||||
* @return 当前中继状态.
|
||||
*/
|
||||
static relay_state_t getState();
|
||||
/**
|
||||
* @brief 获取中继当前序号.
|
||||
* @return 当前序号值.
|
||||
*/
|
||||
static uint32_t getRelaySeq();
|
||||
/**
|
||||
* @brief 获取中继总包数.
|
||||
* @return 总包数.
|
||||
*/
|
||||
static uint32_t getRelayTotalPackets();
|
||||
/**
|
||||
* @brief 获取GD32 APP存活标志位.
|
||||
* @return true 存活,false 未存活.
|
||||
*/
|
||||
static bool getGd32AppAlive();
|
||||
/**
|
||||
* @brief 获取GD32固件版本.
|
||||
* @param out 输出参数,版本结构体指针.
|
||||
*/
|
||||
static void getGd32Version(gd32_version_t *out);
|
||||
/**
|
||||
* @brief 设置中继状态.
|
||||
* @param s 目标状态值.
|
||||
*/
|
||||
static void setState(relay_state_t s);
|
||||
/**
|
||||
* @brief 设置中继当前序号.
|
||||
* @param seq 序号值.
|
||||
*/
|
||||
static void setRelaySeq(uint32_t seq);
|
||||
/**
|
||||
* @brief 设置中继总包数.
|
||||
* @param total 总包数.
|
||||
*/
|
||||
static void setRelayTotalPackets(uint32_t total);
|
||||
/**
|
||||
* @brief 设置GD32 APP存活标志位.
|
||||
* @param alive 存活状态.
|
||||
*/
|
||||
static void setGd32AppAlive(bool alive);
|
||||
/**
|
||||
* @brief 获取GD32固件版本.
|
||||
* @param ver 待设置的版本结构体引用.
|
||||
* @brief 设置GD32固件版本.
|
||||
*/
|
||||
static void setGd32Version(const gd32_version_t &ver);
|
||||
|
||||
/**
|
||||
* @brief 重置状态机.
|
||||
* @brief 重置状态机到 IDLE.
|
||||
*/
|
||||
void reset();
|
||||
|
||||
// ── Flash 操作 ──
|
||||
/**
|
||||
* @brief Flash写入接口(支持非4字节对齐写入).
|
||||
* @param offset 分区内写入偏移.
|
||||
* @param data 待写入的数据指针.
|
||||
* @param len 数据长度(字节).
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len);
|
||||
/**
|
||||
* @brief 从OTA缓存分区读取数据块.
|
||||
* @param offset 分区内读取偏移.
|
||||
* @param data 读取缓冲区指针.
|
||||
* @param len 读取长度(字节).
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t flashReadChunk(uint32_t offset, uint8_t *data, size_t len);
|
||||
/**
|
||||
* @brief 从Flash加载元数据.
|
||||
* @param meta 输出参数,加载后的元数据结构体指针.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t metaLoad(ota_meta_t *meta);
|
||||
/**
|
||||
* @brief 保存元数据到Flash.
|
||||
* @param meta 待保存的元数据结构体指针.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t metaSave(const ota_meta_t *meta);
|
||||
/**
|
||||
* @brief 传输过程中更新元数据.
|
||||
* @param stored_size 已存储的固件大小(字节).
|
||||
* @param last_seq 最后成功的分块序号.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t metaUpdate(uint32_t stored_size, uint32_t last_seq);
|
||||
/**
|
||||
* @brief OTA完成或中止时清除元数据.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t metaClear();
|
||||
/**
|
||||
* @brief 计算gd32_ota分区中已存储固件的SHA-256.
|
||||
* @param out_sha256 输出32字节SHA-256摘要.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t computeFlashSha256(uint8_t out_sha256[32]);
|
||||
/**
|
||||
* @brief 固件接收完毕后计算SHA-256并写入元数据.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t metaFinalizeChecksum();
|
||||
/**
|
||||
* @brief 校验固件完整性(SHA-256).
|
||||
* @return ESP_OK 校验通过,ESP_ERR_INVALID_CRC 校验失败.
|
||||
*/
|
||||
static esp_err_t metaVerifyChecksum();
|
||||
|
||||
// 公开状态(状态机内部直接访问;跨任务请用 getter/setter)
|
||||
// ── 公开状态成员 ──
|
||||
static relay_state_t relayState;
|
||||
static uint32_t relaySeq;
|
||||
static uint32_t relayTotalPackets;
|
||||
@@ -201,33 +190,19 @@ public:
|
||||
static QueueHandle_t dispatchQueue;
|
||||
static QueueHandle_t resultQueue;
|
||||
|
||||
// 线程安全锁
|
||||
// ── 线程安全锁 ──
|
||||
static SemaphoreHandle_t s_stateMutex;
|
||||
/**
|
||||
* @brief 初始化状态互斥锁.
|
||||
*/
|
||||
static void initMutex();
|
||||
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50);
|
||||
|
||||
class StateLockGuard {
|
||||
public:
|
||||
/**
|
||||
* @brief 构造时获取互斥锁.
|
||||
* @param timeout 等待超时(时钟节拍),默认50ms.
|
||||
*/
|
||||
explicit StateLockGuard(TickType_t timeout = kLockTimeout) {
|
||||
m_locked = (s_stateMutex && xSemaphoreTake(s_stateMutex, timeout) == pdTRUE);
|
||||
}
|
||||
/**
|
||||
* @brief 析构时释放互斥锁.
|
||||
*/
|
||||
~StateLockGuard() {
|
||||
if (m_locked && s_stateMutex) { xSemaphoreGive(s_stateMutex); }
|
||||
}
|
||||
/**
|
||||
* @brief 检查是否成功获取锁.
|
||||
* @return true 已锁定,false 未锁定.
|
||||
*/
|
||||
bool isLocked() const { return m_locked; }
|
||||
StateLockGuard(const StateLockGuard&) = delete;
|
||||
StateLockGuard& operator=(const StateLockGuard&) = delete;
|
||||
@@ -235,7 +210,7 @@ public:
|
||||
bool m_locked = false;
|
||||
};
|
||||
|
||||
// 实例状态(public,供状态机方法访问)
|
||||
// ── 实例状态 ──
|
||||
const esp_partition_t *m_gd32Partition = NULL;
|
||||
session_ctx_t m_sessionCtx;
|
||||
uint8_t m_nonceServer[16] = {0};
|
||||
@@ -245,177 +220,95 @@ public:
|
||||
static constexpr size_t GD32_UID_LEN = 12;
|
||||
static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36;
|
||||
|
||||
/**
|
||||
* @brief 请求GD32 APP进入Bootloader.
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
esp_err_t requestGd32AppEnterBootloader();
|
||||
/**
|
||||
* @brief 发送握手请求.
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
ota_result_t sendHandshakeReq();
|
||||
/**
|
||||
* @brief 发送随机数并与GD32相互认证.
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
ota_result_t sendNonceAndAuthenticate();
|
||||
/**
|
||||
* @brief 向GD32发送固件总大小.
|
||||
* @param size 固件总字节数.
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
ota_result_t sendFirmwareSize(uint32_t size);
|
||||
/**
|
||||
* @brief 从Flash向GD32转发固件包.
|
||||
* @param deadline_tick 截止时间戳(可选),0表示无超时.
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
ota_result_t relayPacketsFromFlash(TickType_t deadline_tick = 0);
|
||||
/**
|
||||
* @brief 完成OTA并发送结束指令.
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
ota_result_t finishOta();
|
||||
|
||||
/**
|
||||
* @brief 使用AES-128-ECB加密单个16字节块.
|
||||
* @param key 16字节密钥.
|
||||
* @param in 16字节输入数据.
|
||||
* @param out 16字节输出数据.
|
||||
* @return 0 成功,-1 失败.
|
||||
*/
|
||||
static int aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
/**
|
||||
* @brief 记录响应载荷日志.
|
||||
* @param stage 阶段名称字符串.
|
||||
* @param resp_type 响应类型.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
*/
|
||||
static void logRespPayload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len);
|
||||
/**
|
||||
* @brief 循环等待直到响应不再是WAIT状态.
|
||||
* @param stage 阶段名称字符串.
|
||||
* @param resp_type 响应类型输出.
|
||||
* @param resp_payload 响应载荷缓冲区.
|
||||
* @param resp_max 缓冲区最大长度.
|
||||
* @param resp_len 实际响应长度输出.
|
||||
* @param timeout_ms 单次轮询超时毫秒.
|
||||
* @param max_wait_rounds 最大WAIT轮次.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
esp_err_t waitUntilNotWait(const char *stage, uint8_t *resp_type,
|
||||
uint8_t *resp_payload, size_t resp_max,
|
||||
size_t *resp_len, uint32_t timeout_ms,
|
||||
uint32_t max_wait_rounds);
|
||||
|
||||
/**
|
||||
* @brief 判断是否为带序号的结构化ACK.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
* @param expected_seq 期望的序号.
|
||||
* @return true 匹配成功,false 不匹配.
|
||||
*/
|
||||
static bool isStructuredAckWithSeq(const uint8_t *payload, size_t len, uint32_t expected_seq);
|
||||
/**
|
||||
* @brief 解析结构化错误帧.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
* @param seq_out 输出错误序号.
|
||||
* @param err_out 输出错误码.
|
||||
* @return true 解析成功,false 解析失败.
|
||||
*/
|
||||
static bool parseStructuredError(const uint8_t *payload, size_t len, uint32_t *seq_out, uint8_t *err_out);
|
||||
/**
|
||||
* @brief 判断是否为握手回显ACK.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
* @return true 是握手回显ACK,false 不是.
|
||||
*/
|
||||
static bool isHandshakeEchoAck(const uint8_t *payload, size_t len);
|
||||
/**
|
||||
* @brief 判断是否为挑战应答ACK.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
* @return true 是挑战应答ACK,false 不是.
|
||||
*/
|
||||
static bool isChallengePayloadAck(const uint8_t *payload, size_t len);
|
||||
/**
|
||||
* @brief 判断是否为普通确认ACK.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
* @return true 是确认ACK,false 不是.
|
||||
*/
|
||||
static bool isConfirmPlainAck(const uint8_t *payload, size_t len);
|
||||
/**
|
||||
* @brief 判断是否为大小序号0的ACK.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
* @return true 是大小序号0ACK,false 不是.
|
||||
*/
|
||||
static bool isSizeSeq0Ack(const uint8_t *payload, size_t len);
|
||||
/**
|
||||
* @brief 判断是否为普通的结束ACK.
|
||||
* @param payload 载荷指针.
|
||||
* @param len 载荷长度.
|
||||
* @return true 是结束ACK,false 不是.
|
||||
*/
|
||||
static bool isFinishPlainAck(const uint8_t *payload, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 轮询直到收到预期的ACK形状.
|
||||
* @param stage 阶段名称字符串.
|
||||
* @param resp_type 响应类型输出.
|
||||
* @param resp_payload 响应载荷缓冲区.
|
||||
* @param resp_max 响应缓冲区最大长度.
|
||||
* @param resp_len 实际响应长度输出.
|
||||
* @param poll_timeout_ms 轮询超时毫秒.
|
||||
* @param wait_rounds WAIT状态最大轮询次数.
|
||||
* @param maxExtraPolls 额外轮询次数.
|
||||
* @param matcher 匹配回调函数.
|
||||
* @return ESP_OK 成功,其他失败.
|
||||
*/
|
||||
static esp_err_t pollUntilExpectedAckShape(const char *stage,
|
||||
uint8_t *resp_type,
|
||||
uint8_t *resp_payload,
|
||||
size_t resp_max,
|
||||
size_t *resp_len,
|
||||
uint32_t poll_timeout_ms,
|
||||
uint32_t wait_rounds,
|
||||
uint32_t maxExtraPolls,
|
||||
bool (*matcher)(const uint8_t *, size_t));
|
||||
/**
|
||||
* @brief 解析并验证挑战应答载荷.
|
||||
* @param resp_payload 响应载荷指针.
|
||||
* @param resp_len 响应长度.
|
||||
* @param out_k_session 输出会话密钥.
|
||||
* @param out_uid 输出GD32 UID.
|
||||
* @return true 验证通过,false 验证失败.
|
||||
*/
|
||||
static bool parseAndVerifyChallengePayload(const uint8_t *resp_payload, size_t resp_len,
|
||||
uint8_t *out_k_session, uint8_t *out_uid);
|
||||
/**
|
||||
* @brief 使用AES-128-ECB加密48字节数据.
|
||||
* @param key 16字节密钥.
|
||||
* @param in 48字节输入数据.
|
||||
* @param out 48字节输出数据.
|
||||
* @return true 加密成功,false 加密失败.
|
||||
*/
|
||||
static bool aes128EcbEncrypt48Bytes(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
/**
|
||||
* @brief 使用密钥计算48字节数据的CRC32.
|
||||
* @param key 16字节密钥.
|
||||
* @param data48 48字节输入数据.
|
||||
* @param output 输出4字节CRC32结果.
|
||||
*/
|
||||
static void calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48, uint8_t *output);
|
||||
|
||||
private:
|
||||
OtaRelay() = default;
|
||||
~OtaRelay() = default;
|
||||
OtaRelay(const OtaRelay&) = delete;
|
||||
OtaRelay& operator=(const OtaRelay&) = delete;
|
||||
|
||||
// ── 状态机内部类型 ──
|
||||
/**
|
||||
* @brief 状态处理函数类型:接收事件 + 载荷,返回结果码.
|
||||
* 返回 OTA_RESULT_OK 表示事件已成功处理且状态机可继续;
|
||||
* 返回负值表示错误,状态机转入 RELAY_ERROR.
|
||||
*/
|
||||
typedef ota_result_t (OtaRelay::*StateHandler)(relay_event_t event,
|
||||
const uint8_t *payload, size_t len);
|
||||
|
||||
struct RelayStateDef {
|
||||
const char *name; // 状态名称(日志用)
|
||||
StateHandler handler; // 事件处理器
|
||||
};
|
||||
|
||||
// ── 状态表(const,编译期确定)──
|
||||
static const RelayStateDef s_stateTable[];
|
||||
|
||||
// ── 各状态的事件处理器 ──
|
||||
ota_result_t handleIdle(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleHandshaking(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleSizeNeg(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleTransferring(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleFinishing(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleDone(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleError(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
|
||||
// ── 协议子步骤(由状态 handler 内部调用)──
|
||||
/**
|
||||
* @brief 请求GD32 APP进入Bootloader.
|
||||
*/
|
||||
esp_err_t requestGd32AppEnterBootloader();
|
||||
/**
|
||||
* @brief 执行握手请求(发送 HANDSHAKE_REQ).
|
||||
*/
|
||||
ota_result_t doHandshakeReq();
|
||||
/**
|
||||
* @brief 执行挑战认证.
|
||||
*/
|
||||
ota_result_t doChallengeAndAuth();
|
||||
/**
|
||||
* @brief 发送固件总大小.
|
||||
*/
|
||||
ota_result_t doSendFirmwareSize(uint32_t size);
|
||||
/**
|
||||
* @brief 转发下一批数据包(由 TRANSFERRING 状态循环调用).
|
||||
*/
|
||||
ota_result_t doRelayPackets(TickType_t deadline_tick);
|
||||
/**
|
||||
* @brief 发送结束指令并验证 APP 存活.
|
||||
*/
|
||||
ota_result_t doFinishAndVerify();
|
||||
|
||||
// ── 传输进度跟踪(状态机内部)──
|
||||
uint32_t m_transferTotalPackets = 0;
|
||||
uint32_t m_transferCurrentSeq = 0;
|
||||
uint32_t m_transferFlashOffset = 0;
|
||||
uint32_t m_transferRetryCount = 0;
|
||||
TickType_t m_transferDeadline = 0;
|
||||
|
||||
// ── 加密/工具方法(不变)──
|
||||
static int aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static void logRespPayload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len);
|
||||
esp_err_t waitUntilNotWait(const char *stage, uint8_t *resp_type,
|
||||
uint8_t *resp_payload, size_t resp_max,
|
||||
size_t *resp_len, uint32_t timeout_ms,
|
||||
uint32_t max_wait_rounds);
|
||||
static bool isStructuredAckWithSeq(const uint8_t *payload, size_t len, uint32_t expected_seq);
|
||||
static bool parseStructuredError(const uint8_t *payload, size_t len, uint32_t *seq_out, uint8_t *err_out);
|
||||
static bool isHandshakeEchoAck(const uint8_t *payload, size_t len);
|
||||
static bool isChallengePayloadAck(const uint8_t *payload, size_t len);
|
||||
static bool isConfirmPlainAck(const uint8_t *payload, size_t len);
|
||||
static bool isSizeSeq0Ack(const uint8_t *payload, size_t len);
|
||||
static bool isFinishPlainAck(const uint8_t *payload, size_t len);
|
||||
static esp_err_t pollUntilExpectedAckShape(const char *stage,
|
||||
uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len,
|
||||
uint32_t poll_timeout_ms, uint32_t wait_rounds,
|
||||
uint32_t maxExtraPolls,
|
||||
bool (*matcher)(const uint8_t *, size_t));
|
||||
static bool parseAndVerifyChallengePayload(const uint8_t *resp_payload, size_t resp_len,
|
||||
uint8_t *out_k_session, uint8_t *out_uid);
|
||||
static bool aes128EcbEncrypt48Bytes(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static void calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48, uint8_t *output);
|
||||
};
|
||||
|
||||
#endif // OTA_RELAY_H
|
||||
+517
-52
@@ -24,6 +24,7 @@
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
|
||||
// NimBLE 协议栈
|
||||
#include "nimble/nimble_port.h"
|
||||
@@ -39,7 +40,9 @@
|
||||
#include "app_config.h"
|
||||
#include "ota_mutex.h"
|
||||
#include "ble_ota.h"
|
||||
#include "wifi_ota.h"
|
||||
#include "esp32_ota_core.h"
|
||||
#include "ota_coordinator.h"
|
||||
#include "ota_relay.h"
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
@@ -48,27 +51,33 @@
|
||||
#define BLE_OTA_NOTIFY_ACK 0xA1
|
||||
#define BLE_OTA_NOTIFY_FINISH 0xB1
|
||||
#define BLE_OTA_NOTIFY_SHA256 0xC1 // 设备端 SHA-256 上报([0xC1] + [32字节])
|
||||
#define BLE_OTA_FINISH_BEGIN_OTA_END 0x01
|
||||
#define BLE_OTA_FINISH_DONE_OTA_END 0x02
|
||||
#define BLE_OTA_FINISH_BEGIN_SET_BOOT 0x03
|
||||
#define BLE_OTA_FINISH_DONE_SET_BOOT 0x04
|
||||
#define BLE_OTA_FINISH_BEGIN_DELAY 0x05
|
||||
#define BLE_OTA_FINISH_DONE_DELAY 0x06
|
||||
#define BLE_OTA_FINISH_DONE 0x02
|
||||
#define BLE_OTA_FINISH_REBOOT_NOW 0x07
|
||||
#define BLE_OTA_FINISH_ERR_OTA_END 0x81
|
||||
#define BLE_OTA_FINISH_ERR_SET_BOOT 0x82
|
||||
#define BLE_OTA_FINISH_ERR_FINALIZE 0x81
|
||||
#define BLE_OTA_ACK_WINDOW 32
|
||||
#define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用
|
||||
#define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用
|
||||
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小
|
||||
#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 // ESP32 OTA SHA-256 不匹配
|
||||
|
||||
// ── 数据队列:将 OTA 数据处理从 NimBLE host task 卸载到工作线程 ──
|
||||
// 避免 flash 操作(esp_ota_begin / esp_ota_write)阻塞 BLE 协议栈导致连接断开。
|
||||
#define BLE_OTA_DATA_QUEUE_LEN 16
|
||||
|
||||
struct BleOtaDataBatch {
|
||||
uint8_t *data;
|
||||
size_t len;
|
||||
};
|
||||
static QueueHandle_t s_otaDataQueue = nullptr;
|
||||
|
||||
// ============================================================
|
||||
// 前向声明
|
||||
// ============================================================
|
||||
static void ble_ota_start_advertising(void);
|
||||
static void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
|
||||
static void sendFinishEvent(uint8_t stage, uint32_t value);
|
||||
static void esp32_reorder_free();
|
||||
static void ble_ota_data_worker_task(void *pvParameters);
|
||||
|
||||
// ============================================================
|
||||
/**
|
||||
@@ -89,8 +98,8 @@ static uint16_t s_data_out_val_handle = 0;
|
||||
/**
|
||||
* @brief 异步完成 ESP32 OTA 收尾并触发重启。
|
||||
*
|
||||
* 实际的 esp_ota_end / SHA 校验 / esp_ota_set_boot_partition / esp_restart 全部
|
||||
* 由 Esp32OtaCore::finalize 完成。本任务仅负责:补发 ACK → 调 finalize → 上报 SHA → 通知前端。
|
||||
* 实际的 SHA 校验 / esp_ota_end / esp_ota_set_boot_partition 全部由
|
||||
* Esp32OtaCore::finalize 完成。本任务仅负责:补发 ACK → 调 finalize → 上报 SHA → 通知前端。
|
||||
* @param pvParameters 任务参数,当前未使用。
|
||||
*/
|
||||
/**
|
||||
@@ -103,8 +112,8 @@ static void ble_esp32_restart_task(void *pvParameters)
|
||||
DEBUG_INFO(TAG, "[RESTART] 推送 FINISH_REBOOT_NOW 后 500ms 重启");
|
||||
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
||||
esp_restart();
|
||||
// 不会返回
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,17 +141,16 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启");
|
||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
esp32_reorder_free();
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived());
|
||||
|
||||
// 通知前端:开始 finalize
|
||||
sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0);
|
||||
OtaCoordinator::instance().notifyEsp32ReceiveComplete();
|
||||
|
||||
// 关键:reboot=false,让 finalize 真正返回、拿到 device_sha_hex,
|
||||
// 再依次推送 SHA 通知 + FINISH_DONE_OTA_END + FINISH_REBOOT_NOW,
|
||||
// 再依次推送 SHA 通知 + FINISH_DONE + FINISH_REBOOT_NOW,
|
||||
// 最后延迟重启。若 reboot=true,core 内部直接 esp_restart(),
|
||||
// 下面所有 notifyResponse / sendFinishEvent 都不会执行,
|
||||
// 前端也就拿不到设备端 SHA 与"收尾完成/即将重启"事件。
|
||||
@@ -152,6 +160,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
device_sha_hex);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
OtaCoordinator::instance().notifyEsp32FinalizeComplete();
|
||||
// 1) 把设备端 SHA 通过 SHA256 通知推给前端
|
||||
if (device_sha_hex[0] != '\0') {
|
||||
uint8_t sha_pkt[33] = {0};
|
||||
@@ -167,28 +176,71 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
vTaskDelay(pdMS_TO_TICKS(150));
|
||||
|
||||
// 3) 推送"OTA 收尾完成"与"即将重启"事件
|
||||
sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0);
|
||||
sendFinishEvent(BLE_OTA_FINISH_DONE, 0);
|
||||
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
||||
|
||||
// 4) 通知全部发出后延迟 500ms 重启
|
||||
DEBUG_INFO(TAG, "[FINISH] 通知已发送,500ms 后重启...");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
||||
esp_restart();
|
||||
// 不会返回
|
||||
} else if (err == ESP_ERR_INVALID_VERSION) {
|
||||
DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
// ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer
|
||||
esp32_reorder_free();
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
// ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer
|
||||
esp32_reorder_free();
|
||||
}
|
||||
|
||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WiFi 共存优化:BLE OTA 传输期间降低 WiFi 射频占用
|
||||
// ============================================================
|
||||
static bool s_wifi_ps_boosted = false;
|
||||
|
||||
/**
|
||||
* @brief BLE OTA 开始时,将 WiFi 切换到最大省电模式,减少射频抢占。
|
||||
* WiFi STA 仍保持连接,但信标监听间隔大幅拉长,BLE 获得更多空中时间。
|
||||
*/
|
||||
static void ble_ota_boost_wifi_ps()
|
||||
{
|
||||
if (s_wifi_ps_boosted) return;
|
||||
esp_err_t err = esp_wifi_set_ps(WIFI_PS_MAX_MODEM);
|
||||
if (err == ESP_OK) {
|
||||
s_wifi_ps_boosted = true;
|
||||
WifiOta::instance().setBleOtaActive(true); // ★ 暂停 WiFi RSSI 轮询
|
||||
DEBUG_INFO(TAG, "WiFi 已切换到 MAX_MODEM 省电模式(BLE OTA 传输优化)");
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "WiFi 省电模式切换失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief BLE OTA 结束/中止时,恢复 WiFi 省电策略为 WIFI_PS_NONE。
|
||||
*/
|
||||
static void ble_ota_restore_wifi_ps()
|
||||
{
|
||||
if (!s_wifi_ps_boosted) return;
|
||||
esp_err_t err = esp_wifi_set_ps(WIFI_PS_NONE);
|
||||
if (err == ESP_OK) {
|
||||
s_wifi_ps_boosted = false;
|
||||
WifiOta::instance().setBleOtaActive(false); // ★ 恢复 WiFi RSSI 轮询
|
||||
DEBUG_INFO(TAG, "WiFi 已恢复 WIFI_PS_NONE 模式");
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "WiFi 省电模式恢复失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NimBLE 工具: 从 mbuf 读取原始数据
|
||||
/**
|
||||
@@ -207,6 +259,273 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
|
||||
return (int)copy_len;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ESP32 OTA 包序号顺序保护:Reorder Buffer(TCP 接收窗口模式)
|
||||
//
|
||||
// 背景:
|
||||
// esp_ota_write 是顺序追加写入,不支持 seek。
|
||||
// 客户端窗口内使用 writeValueWithoutResponse(fire-and-forget),
|
||||
// BLE 空口会导致同一窗口的包乱序到达设备端。
|
||||
//
|
||||
// 策略(窗口级 reorder buffer):
|
||||
// - seq < expected → 重复包,丢弃
|
||||
// - seq == expected → 写入 flash,expected++,
|
||||
// 并 flush 连续紧邻的缓存包
|
||||
// - seq > expected 且在窗口范围内 → 暂存到 slot,不写 flash
|
||||
// - seq 超出窗口范围 → 防御性丢弃 + ACK 报告缺口
|
||||
//
|
||||
// 与旧"丢弃策略"的区别:乱序包不再丢弃,而是暂存到 buffer,
|
||||
// 当缺口(expected)的包到达时按序 flush,从而吸收窗口内乱序,
|
||||
// 彻底消除"重传包与原发包再次乱序"导致的重传风暴。
|
||||
//
|
||||
// 内存:slots 指针/长度数组在 OTA begin 时分配(常驻 ~384B),
|
||||
// 每个 slot 的 payload 缓冲区按需 lazy malloc(最多 480B),
|
||||
// flush 后立即 free。OTA 结束/abort 时统一释放。
|
||||
// 若 begin 时分配失败 → active=false,自动回退到丢弃策略。
|
||||
//
|
||||
// ACK 协议不变:getReceived() 仅在真正按序写入 flash 时增长,
|
||||
// 客户端 resumeSeq = ceil(bytes/payloadSize) 仍正确。
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA reorder buffer 状态。
|
||||
* 窗口级缓存,吸收 BLE 乱序到达的包。
|
||||
*/
|
||||
struct Esp32OtaReorderBuf {
|
||||
bool active = false; ///< 是否已启用(分配成功)
|
||||
bool fallback = false; ///< 是否回退到丢弃策略(buffer 分配失败时)
|
||||
uint32_t expected = 0; ///< 下一个期望按序写入的 seq
|
||||
uint32_t windowBase = 0; ///< 当前窗口起始 seq(= 上次 flush 后的 expected)
|
||||
uint32_t windowSize = 0; ///< 窗口大小(= BLE_OTA_REORDER_WINDOW)
|
||||
uint8_t **slots = nullptr; ///< slots[i]:窗口内第 i 个包的 payload 拷贝(nullptr=未占)
|
||||
size_t *lens = nullptr; ///< lens[i]:slots[i] 的 payload 长度
|
||||
size_t payloadCap = 0; ///< 每个 slot 的容量(= BLE_OTA_CHUNK_PAYLOAD)
|
||||
};
|
||||
|
||||
static Esp32OtaReorderBuf s_reorder;
|
||||
static uint32_t s_fallback_ooo_count = 0; // 回退模式下自上次 ACK 以来的乱序包计数
|
||||
|
||||
// 前向声明(alloc/free 互相引用)
|
||||
static void esp32_reorder_free();
|
||||
|
||||
/**
|
||||
* @brief 分配 reorder buffer(slots 指针/长度数组)。
|
||||
* 在 ble_esp32_ota_begin() 中调用。分配失败则置 fallback=true,
|
||||
* 后续自动回退到丢弃策略。
|
||||
*/
|
||||
static void esp32_reorder_alloc()
|
||||
{
|
||||
// 已分配则先释放(防御性)
|
||||
if (s_reorder.slots || s_reorder.lens) {
|
||||
esp32_reorder_free();
|
||||
}
|
||||
|
||||
s_reorder.expected = 0;
|
||||
s_reorder.windowBase = 0;
|
||||
s_reorder.windowSize = (uint32_t)BLE_OTA_REORDER_WINDOW;
|
||||
s_reorder.payloadCap = BLE_OTA_CHUNK_PAYLOAD;
|
||||
s_reorder.slots = (uint8_t **)calloc(s_reorder.windowSize, sizeof(uint8_t *));
|
||||
s_reorder.lens = (size_t *)calloc(s_reorder.windowSize, sizeof(size_t));
|
||||
s_fallback_ooo_count = 0;
|
||||
|
||||
if (!s_reorder.slots || !s_reorder.lens) {
|
||||
DEBUG_WARN(TAG, "Reorder buffer 分配失败 (slots=%p lens=%p),回退到丢弃策略。"
|
||||
"heap: free=%u min=%u",
|
||||
s_reorder.slots, s_reorder.lens,
|
||||
(unsigned)esp_get_free_heap_size(),
|
||||
(unsigned)esp_get_minimum_free_heap_size());
|
||||
// 释放可能部分成功的分配
|
||||
free(s_reorder.slots); s_reorder.slots = nullptr;
|
||||
free(s_reorder.lens); s_reorder.lens = nullptr;
|
||||
s_reorder.active = false;
|
||||
s_reorder.fallback = true;
|
||||
return;
|
||||
}
|
||||
s_reorder.active = true;
|
||||
s_reorder.fallback = false;
|
||||
DEBUG_INFO(TAG, "Reorder buffer 已启用: window=%u payloadCap=%u heap=%u",
|
||||
(unsigned)s_reorder.windowSize, (unsigned)s_reorder.payloadCap,
|
||||
(unsigned)esp_get_free_heap_size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 释放 reorder buffer 全部内存(含已占用的 slot)。
|
||||
* 同时复位 expected/windowBase 等状态,供下次 OTA begin 重新分配。
|
||||
*/
|
||||
static void esp32_reorder_free()
|
||||
{
|
||||
if (s_reorder.slots) {
|
||||
for (uint32_t i = 0; i < s_reorder.windowSize; ++i) {
|
||||
if (s_reorder.slots[i]) {
|
||||
free(s_reorder.slots[i]);
|
||||
s_reorder.slots[i] = nullptr;
|
||||
}
|
||||
}
|
||||
free(s_reorder.slots);
|
||||
s_reorder.slots = nullptr;
|
||||
}
|
||||
if (s_reorder.lens) {
|
||||
free(s_reorder.lens);
|
||||
s_reorder.lens = nullptr;
|
||||
}
|
||||
s_reorder.expected = 0;
|
||||
s_reorder.windowBase = 0;
|
||||
s_reorder.active = false;
|
||||
s_reorder.fallback = false;
|
||||
s_reorder.windowSize = 0;
|
||||
s_reorder.payloadCap = 0;
|
||||
s_fallback_ooo_count = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief flush expected 对应的连续缓存包到 flash,并释放对应 slot。
|
||||
* 由 esp32_write_ordered 在每次 expected 推进后调用。
|
||||
* @return flush 的包数(不含当前已写入的包)。
|
||||
*/
|
||||
static uint32_t esp32_reorder_flush()
|
||||
{
|
||||
uint32_t flushed = 0;
|
||||
while (s_reorder.active) {
|
||||
uint32_t idx = s_reorder.expected - s_reorder.windowBase;
|
||||
// idx 应始终 < windowSize(窗口未越界)
|
||||
if (idx >= s_reorder.windowSize) break;
|
||||
if (!s_reorder.slots[idx]) break; // 缺口:后续虽有缓存但不连续
|
||||
|
||||
uint8_t *data = s_reorder.slots[idx];
|
||||
size_t len = s_reorder.lens[idx];
|
||||
esp_err_t err = Esp32OtaCore::instance().write(data, len);
|
||||
// 释放 slot
|
||||
free(s_reorder.slots[idx]);
|
||||
s_reorder.slots[idx] = nullptr;
|
||||
s_reorder.lens[idx] = 0;
|
||||
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "Reorder flush 写入失败: seq=%u err=%s",
|
||||
(unsigned)s_reorder.expected, esp_err_to_name(err));
|
||||
// 写入失败不推进 expected,交由上层 abort
|
||||
return flushed;
|
||||
}
|
||||
s_reorder.expected++;
|
||||
flushed++;
|
||||
}
|
||||
return flushed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 按序号顺序写入 ESP32 OTA 数据。
|
||||
*
|
||||
* 启用 reorder buffer 时:吸收窗口内乱序,缺口填满后按序 flush。
|
||||
* 回退模式(buffer 分配失败):执行旧丢弃策略(乱序包丢弃 + ACK 报告缺口)。
|
||||
*
|
||||
* @param seq 包序号。
|
||||
* @param cmd 数据命令字节(用于 ACK)。
|
||||
* @param data 负载数据指针。
|
||||
* @param data_len 负载数据长度。
|
||||
* @return ESP_OK 写入成功或被安全跳过,其他为失败。
|
||||
*/
|
||||
static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd,
|
||||
const uint8_t *data, size_t data_len)
|
||||
{
|
||||
// ── 回退模式:旧丢弃策略 ──
|
||||
if (s_reorder.fallback) {
|
||||
// 重复包
|
||||
if (seq < s_reorder.expected) {
|
||||
DEBUG_DBG(TAG, "ESP32 重复包: seq=%u < expected=%u,跳过",
|
||||
seq, (unsigned)s_reorder.expected);
|
||||
return ESP_OK;
|
||||
}
|
||||
// 乱序包:丢弃 + 节流 ACK 报告缺口
|
||||
// ★ 节流策略:每 BLE_OTA_ACK_WINDOW 个乱序包才发一次 ACK,
|
||||
// 或在窗口边界处发 ACK。避免大量乱序包涌入时触发 ACK 风暴,
|
||||
// 耗尽 NimBLE mbuf 池导致 ESP_ERR_NO_MEM。
|
||||
if (seq > s_reorder.expected) {
|
||||
s_fallback_ooo_count++;
|
||||
bool shouldAck =
|
||||
(s_fallback_ooo_count >= BLE_OTA_ACK_WINDOW) ||
|
||||
((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U);
|
||||
if (shouldAck) {
|
||||
uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived();
|
||||
DEBUG_WARN(TAG, "ESP32 乱序包跳过(回退): seq=%u (expected=%u),节流 ACK bytes=%u (ooo=%u)",
|
||||
seq, (unsigned)s_reorder.expected, (unsigned)curReceived,
|
||||
(unsigned)s_fallback_ooo_count);
|
||||
sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0,
|
||||
curReceived);
|
||||
s_fallback_ooo_count = 0;
|
||||
} else {
|
||||
DEBUG_DBG(TAG, "ESP32 乱序包跳过(回退): seq=%u (expected=%u),抑制 ACK (ooo=%u)",
|
||||
seq, (unsigned)s_reorder.expected,
|
||||
(unsigned)s_fallback_ooo_count);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
// seq == expected:正常写入
|
||||
esp_err_t err = Esp32OtaCore::instance().write(data, data_len);
|
||||
if (err != ESP_OK) return err;
|
||||
s_reorder.expected++;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ── Reorder buffer 模式 ──
|
||||
// 窗口推进检测:若 expected 已超出当前窗口基址,滑动窗口
|
||||
if (s_reorder.expected >= s_reorder.windowBase + s_reorder.windowSize) {
|
||||
s_reorder.windowBase = s_reorder.expected;
|
||||
}
|
||||
|
||||
// 重复包:seq < expected → 已写入,跳过
|
||||
if (seq < s_reorder.expected) {
|
||||
DEBUG_DBG(TAG, "ESP32 重复包: seq=%u < expected=%u,跳过",
|
||||
seq, (unsigned)s_reorder.expected);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// seq == expected:正常写入 + flush 连续缓存包
|
||||
if (seq == s_reorder.expected) {
|
||||
esp_err_t err = Esp32OtaCore::instance().write(data, data_len);
|
||||
if (err != ESP_OK) return err;
|
||||
s_reorder.expected++;
|
||||
uint32_t flushed = esp32_reorder_flush();
|
||||
if (flushed > 0) {
|
||||
DEBUG_DBG(TAG, "Reorder flush: seq=%u 后连续写入 %u 包 (expected→%u)",
|
||||
seq, (unsigned)flushed, (unsigned)s_reorder.expected);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// seq > expected:暂存到 slot(若在窗口范围内)
|
||||
uint32_t idx = seq - s_reorder.windowBase;
|
||||
if (idx < s_reorder.windowSize) {
|
||||
if (s_reorder.slots[idx]) {
|
||||
// 该 slot 已被占用(重复的乱序包),跳过
|
||||
DEBUG_DBG(TAG, "Reorder slot 已占: seq=%u idx=%u,跳过重复包",
|
||||
seq, (unsigned)idx);
|
||||
return ESP_OK;
|
||||
}
|
||||
// data 可能来自静态 buf(ble_ota_data_in_access 的合并缓冲区),
|
||||
// 后续包处理会覆盖它,必须拷贝一份
|
||||
uint8_t *copy = (uint8_t *)malloc(data_len);
|
||||
if (!copy) {
|
||||
DEBUG_WARN(TAG, "Reorder slot malloc 失败: seq=%u,丢弃该包(靠客户端重传)",
|
||||
seq);
|
||||
return ESP_OK; // 不算失败,靠客户端重传补
|
||||
}
|
||||
memcpy(copy, data, data_len);
|
||||
s_reorder.slots[idx] = copy;
|
||||
s_reorder.lens[idx] = data_len;
|
||||
DEBUG_DBG(TAG, "Reorder 缓存乱序包: seq=%u idx=%u (expected=%u)",
|
||||
seq, (unsigned)idx, (unsigned)s_reorder.expected);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// seq 超出窗口范围(防御性,理论不会发生)
|
||||
uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived();
|
||||
DEBUG_WARN(TAG, "ESP32 包超出 reorder 窗口: seq=%u (expected=%u base=%u win=%u),ACK bytes=%u",
|
||||
seq, (unsigned)s_reorder.expected, (unsigned)s_reorder.windowBase,
|
||||
(unsigned)s_reorder.windowSize, (unsigned)curReceived);
|
||||
sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0,
|
||||
curReceived);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ESP32 OTA 薄包装:实际 begin/write/finalize 已抽离到 Esp32OtaCore
|
||||
// 此处仅保留 BLE 侧的状态复位与日志,供 data_in_access 调用。
|
||||
@@ -220,7 +539,14 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
|
||||
*/
|
||||
static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
{
|
||||
esp_err_t err = Esp32OtaCore::instance().begin(total_size);
|
||||
// ★ 通过全局协调器发起 ESP32 OTA
|
||||
esp_err_t err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
// 协调器拒绝 → 尝试强制清理后重试
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 被协调器拒绝,强制清理状态机后重试");
|
||||
Esp32OtaCore::instance().forceCleanup();
|
||||
err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
@@ -228,6 +554,10 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
BleOta::instance().m_esp32LastCmd = 0;
|
||||
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
|
||||
// ★ 分配 reorder buffer(失败则自动回退丢弃策略)
|
||||
esp32_reorder_alloc();
|
||||
// ★ BLE OTA 开始:降低 WiFi 射频占用
|
||||
ble_ota_boost_wifi_ps();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -238,6 +568,10 @@ static void ble_esp32_ota_abort()
|
||||
{
|
||||
Esp32OtaCore::instance().abort();
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
// ★ 释放 reorder buffer
|
||||
esp32_reorder_free();
|
||||
// ★ BLE OTA 中止:恢复 WiFi 射频
|
||||
ble_ota_restore_wifi_ps();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,15 +661,15 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
|
||||
|
||||
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
|
||||
// 检查固件是否存储完毕 → 触发 GD32 OTA 中继
|
||||
// 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = OtaRelay::metaLoad(&meta);
|
||||
if (err == ESP_OK &&
|
||||
meta.total_size > 0 &&
|
||||
meta.stored_size >= meta.total_size) {
|
||||
DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继",
|
||||
DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继",
|
||||
meta.stored_size, meta.total_size);
|
||||
BleOta::instance().signalRelay();
|
||||
OtaCoordinator::instance().triggerGd32Relay();
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
|
||||
meta.stored_size, meta.total_size);
|
||||
@@ -354,14 +688,14 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。
|
||||
if (len < 33) {
|
||||
DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_ARG);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *frontend_sha_hex = (char *)malloc(65);
|
||||
if (!frontend_sha_hex) {
|
||||
DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_NO_MEM);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_NO_MEM);
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
@@ -452,9 +786,36 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
// 将数据拷贝到堆上并送入工作队列,避免在 NimBLE host task 中执行
|
||||
// 耗时的 flash 操作(esp_ota_begin / esp_ota_write)导致 BLE 连接断开。
|
||||
if (s_otaDataQueue) {
|
||||
uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len);
|
||||
if (heap_buf) {
|
||||
memcpy(heap_buf, buf, (size_t)recv_len);
|
||||
BleOtaDataBatch item = { heap_buf, (size_t)recv_len };
|
||||
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
|
||||
DEBUG_WARN(TAG, "DATA_IN 工作队列满,丢弃 %d 字节(等待客户端重传)", recv_len);
|
||||
free(heap_buf);
|
||||
}
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "DATA_IN 堆内存不足 (%d 字节),丢弃", recv_len);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 处理一批 OTA 数据包(在工作线程上下文中调用)。
|
||||
* 包含 ESP32 和 GD32 的数据接收逻辑。
|
||||
* @param buf 数据缓冲区(包含一个或多个合并的包)。
|
||||
* @param recv_len 数据长度。
|
||||
*/
|
||||
static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len)
|
||||
{
|
||||
// 循环处理 mbuf 中的所有完整数据包
|
||||
size_t offset = 0;
|
||||
while (offset + 7 <= (size_t)recv_len) {
|
||||
while (offset + 7 <= recv_len) {
|
||||
// 解析包头
|
||||
uint8_t cmd = buf[offset + 0];
|
||||
uint16_t seq = (uint16_t)(buf[offset + 1] | (buf[offset + 2] << 8));
|
||||
@@ -472,7 +833,7 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
: (uint32_t)BLE_OTA_CHUNK_PAYLOAD;
|
||||
|
||||
size_t data_len = (size_t)expected_payload;
|
||||
size_t remaining_after_header = (size_t)recv_len - data_offset;
|
||||
size_t remaining_after_header = recv_len - data_offset;
|
||||
|
||||
// 如果缓冲区中实际可用数据不足,截断 data_len
|
||||
if (remaining_after_header < expected_payload) {
|
||||
@@ -495,52 +856,72 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
if (seq == 0) {
|
||||
if (total_size == 0) {
|
||||
DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
// 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成
|
||||
esp_err_t err = ble_esp32_ota_begin(total_size);
|
||||
if (err != ESP_OK) {
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0);
|
||||
return BLE_ATT_ERR_OTA_BUSY;
|
||||
}
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t err = Esp32OtaCore::instance().write(&buf[data_offset], data_len);
|
||||
// ★ 会话有效性检查:如果 begin() 失败(如互斥拒绝),后续包也应丢弃
|
||||
if (!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 会话无效,丢弃 seq=%u", seq);
|
||||
offset += packet_len;
|
||||
if (offset + 7 > recv_len) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 防重复写入保护:如果已接收字节 >= 声明大小,跳过(重试引起的重复包)
|
||||
uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived();
|
||||
if (total_size > 0 && curReceived >= total_size) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 已收满 (%u >= %" PRIu32 "),跳过重复 seq=%u",
|
||||
(unsigned)curReceived, total_size, seq);
|
||||
sendWindowAck(cmd, seq, curReceived);
|
||||
offset += packet_len;
|
||||
if (offset + 7 > recv_len) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 使用带序号顺序保护的写入:拒绝乱序包并立即 ACK 通知发送端
|
||||
esp_err_t err = esp32_write_ordered(seq, cmd, &buf[data_offset], data_len);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s",
|
||||
seq, esp_err_to_name(err));
|
||||
ble_esp32_ota_abort(); // core 内部完成 abort + mutex release + 状态复位
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u",
|
||||
seq, (unsigned int)Esp32OtaCore::instance().getReceived());
|
||||
|
||||
// 尾包检测: 期望 payload < 标准负载说明是真正的最后一包
|
||||
// 使用 expected_payload 而非 data_len,避免缓冲区截断导致误触发重启
|
||||
// 完成判定:以实际按序写入字节数为准。
|
||||
bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD);
|
||||
bool transferComplete = (total_size > 0 &&
|
||||
Esp32OtaCore::instance().getReceived() >= total_size);
|
||||
|
||||
// ACK 触发条件
|
||||
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
|
||||
(total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) ||
|
||||
transferComplete ||
|
||||
isLastPacket) {
|
||||
if (isLastPacket) {
|
||||
DEBUG_INFO(TAG, "ESP32 尾包检测: seq=%u data_len=%zu received=%u total=%" PRIu32,
|
||||
if (isLastPacket || transferComplete) {
|
||||
DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32,
|
||||
seq, data_len, (unsigned int)Esp32OtaCore::instance().getReceived(), total_size);
|
||||
}
|
||||
sendWindowAck(cmd, seq, (uint32_t)Esp32OtaCore::instance().getReceived());
|
||||
}
|
||||
|
||||
if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) {
|
||||
DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s",
|
||||
(unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1,
|
||||
isLastPacket ? " [尾包]" : "");
|
||||
if (transferComplete) {
|
||||
DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)",
|
||||
(unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1);
|
||||
OtaCoordinator::instance().notifyEsp32ReceiveComplete();
|
||||
BleOta::instance().m_esp32LastCmd = cmd;
|
||||
BleOta::instance().m_esp32LastSeq = seq;
|
||||
// 注:与 WiFi 流程对齐,传输完成后由前端发送 0xF8 + SHA-256 触发 finalize,
|
||||
// 设备端不再自动创建收尾任务。
|
||||
ble_ota_restore_wifi_ps();
|
||||
}
|
||||
}
|
||||
// --- GD32 OTA:首个分块时创建存储元数据 ---
|
||||
@@ -551,7 +932,12 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " rounded48=%" PRIu32 " max=%zu",
|
||||
total_size, rounded_size, OTA_MAX_FIRMWARE_SIZE);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_SIZE, total_size);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
|
||||
if (OtaCoordinator::instance().beginGd32Storage(total_size) != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "GD32 OTA 协调器拒绝存储(系统忙)");
|
||||
return;
|
||||
}
|
||||
|
||||
ota_meta_t meta = {};
|
||||
@@ -562,38 +948,48 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
esp_err_t err = OtaRelay::metaSave(&meta);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "元数据保存失败");
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
OtaCoordinator::instance().abortCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu",
|
||||
meta.total_size, data_len);
|
||||
ble_ota_boost_wifi_ps();
|
||||
}
|
||||
|
||||
ota_meta_t meta = {};
|
||||
if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0) {
|
||||
DEBUG_ERR(TAG, "GD32 元数据无效,拒绝写入: seq=%u", seq);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
|
||||
if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) {
|
||||
DEBUG_ERR(TAG, "GD32 分包序号不连续: seq=%u last_seq=%" PRIu32,
|
||||
seq, meta.last_seq);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- GD32 写入 Flash ---
|
||||
uint32_t flash_offset = meta.stored_size;
|
||||
if (flash_offset >= meta.total_size) {
|
||||
DEBUG_WARN(TAG, "GD32 OTA 已收满 (%" PRIu32 " >= %" PRIu32 "),跳过重复 seq=%u",
|
||||
flash_offset, meta.total_size, seq);
|
||||
sendWindowAck(cmd, seq, flash_offset);
|
||||
offset += packet_len;
|
||||
if (offset + 7 > recv_len) break;
|
||||
continue;
|
||||
}
|
||||
if (flash_offset + (uint32_t)data_len > meta.total_size) {
|
||||
DEBUG_ERR(TAG, "GD32 写入越界: offset=%" PRIu32 " len=%zu total=%" PRIu32,
|
||||
flash_offset, data_len, meta.total_size);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
|
||||
esp_err_t err = OtaRelay::flashWriteChunk(flash_offset, &buf[data_offset], data_len);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s",
|
||||
seq, flash_offset, esp_err_to_name(err));
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t stored_size = flash_offset + (uint32_t)data_len;
|
||||
@@ -617,7 +1013,8 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
meta.stored_size >= meta.total_size) {
|
||||
DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
|
||||
meta.stored_size, meta.total_size, seq + 1);
|
||||
// 计算并存储 SHA-256 校验和
|
||||
OtaCoordinator::instance().completeGd32Storage();
|
||||
ble_ota_restore_wifi_ps();
|
||||
esp_err_t sha_err = OtaRelay::metaFinalizeChecksum();
|
||||
if (sha_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err));
|
||||
@@ -631,10 +1028,38 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
offset += packet_len;
|
||||
|
||||
// 检查剩余数据是否足够组成一个完整的包头
|
||||
if (offset + 7 > (size_t)recv_len) break;
|
||||
if (offset + 7 > recv_len) break;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
/**
|
||||
* @brief OTA 数据工作线程:从队列接收数据并调用处理函数。
|
||||
* 独立于 NimBLE host task,避免 flash 操作阻塞 BLE 协议栈。
|
||||
*/
|
||||
static void ble_ota_data_worker_task(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
DEBUG_INFO(TAG, "OTA 数据工作线程已启动");
|
||||
|
||||
while (1) {
|
||||
BleOtaDataBatch item;
|
||||
if (xQueueReceive(s_otaDataQueue, &item, portMAX_DELAY) == pdTRUE) {
|
||||
if (!item.data || item.len == 0) {
|
||||
free(item.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 若 BLE 已断开,丢弃队列中的残留数据
|
||||
if (!BleOta::instance().isConnected()) {
|
||||
DEBUG_DBG(TAG, "BLE 已断开,丢弃 %zu 字节队列数据", item.len);
|
||||
free(item.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
ble_ota_process_data_batch(item.data, item.len);
|
||||
free(item.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -661,7 +1086,15 @@ static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
}
|
||||
|
||||
const char *version = FW_VERSION;
|
||||
int rc = os_mbuf_append(ctxt->om, version, strlen(version));
|
||||
// 版本字符串: "vX.Y.Z YYYY-MM-DD HH:MM:SS"
|
||||
char ver_buf[64];
|
||||
int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, FW_BUILD_TIME);
|
||||
if (ver_len < 0 || ver_len >= (int)sizeof(ver_buf)) {
|
||||
ver_len = strlen(version);
|
||||
int rc = os_mbuf_append(ctxt->om, version, ver_len);
|
||||
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
int rc = os_mbuf_append(ctxt->om, ver_buf, ver_len);
|
||||
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
@@ -796,9 +1229,23 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
|
||||
case BLE_GAP_EVENT_DISCONNECT:
|
||||
DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d",
|
||||
event->disconnect.reason);
|
||||
/** @brief BLE GATT 客户端连接状态。 */
|
||||
|
||||
// ★ 断开时清空数据队列,丢弃属于旧会话的残留包
|
||||
if (s_otaDataQueue) {
|
||||
BleOtaDataBatch stale;
|
||||
while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
|
||||
free(stale.data);
|
||||
}
|
||||
}
|
||||
|
||||
BleOta::instance().m_bleConnected = false;
|
||||
BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE;
|
||||
// ★ BLE 断开时恢复 WiFi 射频,并中止未完成的 ESP32 OTA
|
||||
ble_ota_restore_wifi_ps();
|
||||
if (Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "BLE 断开,中止未完成的 ESP32 OTA");
|
||||
ble_esp32_ota_abort();
|
||||
}
|
||||
// 恢复广播以接受新连接
|
||||
ble_ota_start_advertising();
|
||||
return 0;
|
||||
@@ -1104,6 +1551,24 @@ void BleOta::taskEntry(void *pvParameters)
|
||||
(void)pvParameters;
|
||||
DEBUG_INFO(TAG, "=== BLE OTA 任务启动 ===");
|
||||
|
||||
// 创建 OTA 数据队列和工作线程(在 NimBLE 初始化之前完成)
|
||||
s_otaDataQueue = xQueueCreate(BLE_OTA_DATA_QUEUE_LEN, sizeof(BleOtaDataBatch));
|
||||
if (s_otaDataQueue) {
|
||||
BaseType_t wk_ok = xTaskCreate(ble_ota_data_worker_task,
|
||||
"ble_ota_worker",
|
||||
8192, // 6KB→8KB,flash 操作需要足够栈空间
|
||||
NULL,
|
||||
10, // 中等优先级,避免被 NimBLE host task 过度抢占
|
||||
NULL);
|
||||
if (wk_ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "OTA 数据工作线程创建失败,BLE OTA 数据传输不可用");
|
||||
vQueueDelete(s_otaDataQueue);
|
||||
s_otaDataQueue = nullptr;
|
||||
}
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "OTA 数据队列创建失败,BLE OTA 数据传输不可用");
|
||||
}
|
||||
|
||||
esp_err_t err = BleOta::instance().serverStart();
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "BLE 服务器启动失败: %s → BLE OTA 不可用",
|
||||
|
||||
+88
-63
@@ -7,6 +7,7 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -25,6 +26,7 @@
|
||||
#include "ota_mutex.h"
|
||||
#include "wifi_ota.h"
|
||||
#include "esp32_ota_core.h"
|
||||
#include "ota_coordinator.h"
|
||||
#include "ota_relay.h"
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
@@ -513,11 +515,13 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req)
|
||||
// 若 reboot=true,core 内部直接 esp_restart(),HTTP 响应永远发不出去,
|
||||
// 前端会得到 "Failed to fetch" 而错过核对结果。
|
||||
DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived());
|
||||
OtaCoordinator::instance().notifyEsp32ReceiveComplete();
|
||||
esp_err_t err = core.finalize(has_frontend_sha ? frontend_sha_hex : nullptr,
|
||||
/*reboot=*/false,
|
||||
device_sha_hex);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
OtaCoordinator::instance().notifyEsp32FinalizeComplete();
|
||||
// 1) 把包含设备端 SHA 的响应发回前端
|
||||
char resp[320] = {0};
|
||||
snprintf(resp, sizeof(resp),
|
||||
@@ -530,6 +534,7 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req)
|
||||
// 2) 响应发完后延迟 500ms 重启,给 TCP 缓冲区时间 flush 到客户端
|
||||
DEBUG_INFO("OTA", "响应已发送,500ms 后重启...");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
||||
esp_restart();
|
||||
// 不会返回
|
||||
return ESP_OK;
|
||||
@@ -566,6 +571,7 @@ static esp_err_t restart_handler(httpd_req_t *req)
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -610,6 +616,10 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
DEBUG_INFO("WIFI", "=== HTTP: POST /ota/upload ===");
|
||||
DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len);
|
||||
|
||||
// ★ WiFi OTA 入口:强制恢复 WIFI_PS_NONE,防止 BLE OTA 残留省电模式导致断连
|
||||
esp_wifi_set_ps(WIFI_PS_NONE);
|
||||
WifiOta::instance().setBleOtaActive(false);
|
||||
|
||||
// 预读 1 字节判断是 JSON 还是 binary
|
||||
char peek;
|
||||
int peek_ret = httpd_req_recv(req, &peek, 1);
|
||||
@@ -661,8 +671,14 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
Esp32OtaCore &core = Esp32OtaCore::instance();
|
||||
esp_err_t err = core.begin(total_size);
|
||||
// ★ 通过全局协调器发起 ESP32 OTA
|
||||
esp_err_t err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
// 协调器拒绝(系统忙或通道被占用)→ 尝试强制清理后重试
|
||||
DEBUG_WARN("WIFI", "ESP32 OTA 被协调器拒绝,强制清理状态机后重试");
|
||||
Esp32OtaCore::instance().forceCleanup();
|
||||
err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel");
|
||||
@@ -672,6 +688,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
Esp32OtaCore &core = Esp32OtaCore::instance();
|
||||
|
||||
// 堆分配缓冲区,避免栈溢出(HTTP server 栈仅 8KB)
|
||||
uint8_t *fw_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
|
||||
if (!fw_buf) {
|
||||
@@ -747,6 +765,14 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState());
|
||||
OtaRelay::instance().reset();
|
||||
}
|
||||
|
||||
// ★ 通过全局协调器注册 GD32 存储开始
|
||||
if (OtaCoordinator::instance().beginGd32Storage(gd32_valid_size) != ESP_OK) {
|
||||
DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)");
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "system busy with another OTA operation");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
WifiOta::instance().stateLock();
|
||||
WifiOta::instance().setGd32StorageDone(false);
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
@@ -860,6 +886,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
ota_meta_t final_meta;
|
||||
OtaRelay::metaLoad(&final_meta);
|
||||
if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) {
|
||||
// ★ 通知协调器 GD32 存储完成
|
||||
OtaCoordinator::instance().completeGd32Storage();
|
||||
WifiOta::instance().stateLock();
|
||||
WifiOta::instance().setGd32StorageDone(true);
|
||||
WifiOta::instance().stateUnlock();
|
||||
@@ -870,11 +898,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err));
|
||||
}
|
||||
DEBUG_INFO("WIFI", "auto-triggering relay");
|
||||
// 自动触发中继
|
||||
if (OtaRelay::dispatchQueue) {
|
||||
uint32_t cmd = 1; // 1 = start relay
|
||||
xQueueSend(OtaRelay::dispatchQueue, &cmd, 0);
|
||||
}
|
||||
// ★ 通过协调器触发中继
|
||||
OtaCoordinator::instance().triggerGd32Relay();
|
||||
}
|
||||
|
||||
const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\"}";
|
||||
@@ -901,27 +926,10 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
relay_state_t curState = OtaRelay::getState();
|
||||
WifiOta::instance().stateLock();
|
||||
if (WifiOta::instance().getRelayTriggered() && curState != RELAY_IDLE &&
|
||||
curState != RELAY_DONE && curState != RELAY_APP_VERIFY && curState != RELAY_ERROR) {
|
||||
WifiOta::instance().stateUnlock();
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay already triggered");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
/* 上一轮中继已结束/失败/空闲,重置标志允许重新触发 */
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
WifiOta::instance().setGd32StorageDone(false);
|
||||
WifiOta::instance().stateUnlock();
|
||||
|
||||
if (!OtaRelay::dispatchQueue) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay queue not ready");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
WifiOta::instance().signalRelay();
|
||||
if (!WifiOta::instance().getRelayTriggered()) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay trigger failed");
|
||||
// ★ 通过全局协调器触发 GD32 中继
|
||||
esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay();
|
||||
if (trigger_err != ESP_OK) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay trigger failed - system busy or firmware not ready");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -1157,21 +1165,37 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
gd32_version_t pingVer = {};
|
||||
OtaRelay::getGd32Version(&pingVer);
|
||||
|
||||
/* 若尚无 GD32 版本信息且 OTA 中继空闲,尝试一次 SPI PING 获取 */
|
||||
if (pingVer.app_major == 0 &&
|
||||
/* 若尚无 GD32 版本信息且 OTA 中继空闲,尝试 SPI PING 获取(最多 5 次) */
|
||||
// ★ 修复:OTA 活跃期间(存储/中继/ESP32 OTA)跳过 appPing(),
|
||||
// 避免 HTTP 任务长期持有 SPI mutex 与 relay 任务争用,
|
||||
// 导致优先级继承异常 → vTaskPriorityDisinheritAfterTimeout assert。
|
||||
ota_system_state_t curSysState = OtaCoordinator::instance().getSystemState();
|
||||
bool ota_busy = (curSysState == OTA_SYS_GD32_STORING ||
|
||||
curSysState == OTA_SYS_GD32_RELAYING ||
|
||||
curSysState == OTA_SYS_ESP32_RECEIVING ||
|
||||
curSysState == OTA_SYS_ESP32_FINALIZING ||
|
||||
curSysState == OTA_SYS_ESP32_REBOOTING);
|
||||
if (!ota_busy &&
|
||||
pingVer.app_major == 0 &&
|
||||
pingVer.boot_major == 0 &&
|
||||
OtaRelay::getState() == RELAY_IDLE) {
|
||||
static uint32_t last_ping_ms = 0;
|
||||
static uint8_t ping_attempts = 0;
|
||||
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
if (now_ms - last_ping_ms > 5000) { /* 5 秒节流 */
|
||||
if (ping_attempts < 5 && now_ms - last_ping_ms > 5000) { /* 5 秒节流 */
|
||||
last_ping_ms = now_ms;
|
||||
++ping_attempts;
|
||||
uint32_t uptime = 0;
|
||||
gd32_version_t ver = {};
|
||||
if (SpiMaster::instance().appPing(500, &uptime, &ver) == ESP_OK) {
|
||||
OtaRelay::setGd32AppAlive(true);
|
||||
OtaRelay::setGd32Version(ver);
|
||||
ping_attempts = 0; /* 成功后重置,允许后续更新 */
|
||||
} else {
|
||||
OtaRelay::setGd32AppAlive(false);
|
||||
if (ping_attempts >= 5) {
|
||||
DEBUG_WARN("STATUS", "GD32 版本号读取失败,已达最大重试次数 (5),停止尝试");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1182,9 +1206,10 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
|
||||
gd32_version_t snapVer2 = {};
|
||||
OtaRelay::getGd32Version(&snapVer2);
|
||||
char resp[1024];
|
||||
ota_system_state_t sysState = OtaCoordinator::instance().getSystemState();
|
||||
char resp[1280];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, \"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}}",
|
||||
"{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, \"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}}",
|
||||
FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(),
|
||||
(unsigned long)esp_get_free_heap_size(),
|
||||
(unsigned long)esp_get_minimum_free_heap_size(),
|
||||
@@ -1194,9 +1219,11 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
ip_str,
|
||||
rssi,
|
||||
connected ? "true" : "false",
|
||||
OtaCoordinator::instance().getSystemStateString(),
|
||||
(int)sysState,
|
||||
(unsigned long)meta.total_size,
|
||||
(unsigned long)meta.stored_size,
|
||||
WifiOta::instance().getGd32StorageDone() ? "true" : "false",
|
||||
OtaCoordinator::instance().isGd32StorageDone() ? "true" : "false",
|
||||
OtaRelay::getState(),
|
||||
(unsigned long)OtaRelay::getRelaySeq(),
|
||||
(unsigned long)OtaRelay::getRelayTotalPackets(),
|
||||
@@ -1384,35 +1411,20 @@ void WifiOta::signalRelay()
|
||||
ota_meta_t meta;
|
||||
memset(&meta, 0, sizeof(meta));
|
||||
if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) {
|
||||
DEBUG_WARN("WIFI", "GD32 固件未完整存储,拒绝触发转发: stored=%lu total=%lu",
|
||||
(unsigned long)meta.stored_size, (unsigned long)meta.total_size);
|
||||
DEBUG_WARN("WIFI", "GD32 固件未完整存储,拒绝触发转发: stored=%" PRIu32 " total=%" PRIu32,
|
||||
meta.stored_size, meta.total_size);
|
||||
return;
|
||||
}
|
||||
relay_state_t st = OtaRelay::getState();
|
||||
|
||||
// ★ 通过全局协调器触发中继
|
||||
esp_err_t err = OtaCoordinator::instance().triggerGd32Relay();
|
||||
if (err == ESP_OK) {
|
||||
WifiOta::instance().stateLock();
|
||||
if (WifiOta::instance().getRelayTriggered() && st != RELAY_IDLE &&
|
||||
st != RELAY_DONE && st != RELAY_APP_VERIFY && st != RELAY_ERROR) {
|
||||
WifiOta::instance().stateUnlock();
|
||||
DEBUG_WARN("WIFI", "转发正在执行中,状态: %d", st);
|
||||
return;
|
||||
}
|
||||
/* 上一轮中继已结束/失败/空闲,重置标志 */
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
WifiOta::instance().setGd32StorageDone(false);
|
||||
WifiOta::instance().setRelayTriggered(true);
|
||||
WifiOta::instance().stateUnlock();
|
||||
|
||||
if (OtaRelay::dispatchQueue) {
|
||||
uint32_t msg = 1;
|
||||
BaseType_t ret = xQueueSend(OtaRelay::dispatchQueue, &msg, pdMS_TO_TICKS(100));
|
||||
if (ret == pdTRUE) {
|
||||
DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
|
||||
DEBUG_INFO("WIFI", "OTA 中继已通过协调器触发");
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "OTA 调度信号发送失败");
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
}
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "OTA 中继队列尚未就绪");
|
||||
DEBUG_ERR("WIFI", "OTA 中继触发失败: %s", esp_err_to_name(err));
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
}
|
||||
}
|
||||
@@ -1507,7 +1519,7 @@ bool WifiOta::earlyInit()
|
||||
nvs_erase_all(nvs_handle);
|
||||
nvs_commit(nvs_handle);
|
||||
nvs_close(nvs_handle);
|
||||
DEBUG_INFO("WIFI", "冷启动: 已清除 WiFi NVS 缓存 (nvs.net80211)");
|
||||
DEBUG_DBG("WIFI", "冷启动: 已清除 WiFi NVS 缓存 (nvs.net80211)");
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "冷启动: 无 WiFi NVS 缓存分区,跳过清除");
|
||||
}
|
||||
@@ -1517,7 +1529,7 @@ bool WifiOta::earlyInit()
|
||||
nvs_erase_all(nvs_handle);
|
||||
nvs_commit(nvs_handle);
|
||||
nvs_close(nvs_handle);
|
||||
DEBUG_INFO("WIFI", "冷启动: 已清除 PHY 校准缓存,将重新校准");
|
||||
DEBUG_DBG("WIFI", "冷启动: 已清除 PHY 校准缓存,将重新校准");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1527,7 +1539,7 @@ bool WifiOta::earlyInit()
|
||||
if (mtx) {
|
||||
// 通过临时获取再赋值(私有成员在同类内可访问)
|
||||
WifiOta::instance().m_stateMutex = mtx;
|
||||
DEBUG_INFO("WIFI", "状态互斥锁已创建");
|
||||
DEBUG_DBG("WIFI", "状态互斥锁已创建");
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "创建状态互斥锁失败");
|
||||
}
|
||||
@@ -1547,14 +1559,14 @@ bool WifiOta::earlyInit()
|
||||
if (ret != pdPASS) {
|
||||
DEBUG_ERR("WIFI", "创建重连任务失败");
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "WiFi 重连任务已创建");
|
||||
DEBUG_DBG("WIFI", "WiFi 重连任务已创建");
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 WiFi/IP 事件回调(必须在 esp_wifi_init 之前)
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &WifiOta::wifiEventHandler, NULL));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &WifiOta::wifiEventHandler, NULL));
|
||||
DEBUG_INFO("WIFI", "WiFi/IP 事件回调已注册");
|
||||
DEBUG_DBG("WIFI", "WiFi/IP 事件回调已注册");
|
||||
|
||||
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
if (sta_netif == NULL) {
|
||||
@@ -1562,12 +1574,18 @@ bool WifiOta::earlyInit()
|
||||
if (sta_netif == NULL) {
|
||||
DEBUG_ERR("WIFI", "创建 STA netif 失败");
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "STA netif 已创建");
|
||||
DEBUG_DBG("WIFI", "STA netif 已创建");
|
||||
}
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "STA netif 已存在,跳过创建");
|
||||
}
|
||||
|
||||
// ★ 压制 WiFi 框架内部 INFO 日志(pp/net80211/wifi/wifi_init 等 tag 大量启动打印)
|
||||
esp_log_level_set("wifi", ESP_LOG_WARN);
|
||||
esp_log_level_set("wifi_init", ESP_LOG_WARN);
|
||||
esp_log_level_set("pp", ESP_LOG_WARN);
|
||||
esp_log_level_set("net80211", ESP_LOG_WARN);
|
||||
|
||||
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&wifi_cfg));
|
||||
DEBUG_INFO("WIFI", "WiFi driver 初始化成功");
|
||||
@@ -1672,6 +1690,13 @@ void WifiOta::taskEntry(void *pvParameters)
|
||||
while (1) {
|
||||
tick++;
|
||||
|
||||
// ★ BLE OTA 传输期间,暂停 WiFi RSSI 轮询,避免 esp_wifi_sta_get_ap_info()
|
||||
// 触发射频活动干扰 BLE 连接事件
|
||||
if (WifiOta::instance().m_bleOtaActive.load()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 每 10 秒检查一次 RSSI
|
||||
if (tick % 10 == 0) {
|
||||
int8_t rssi = WifiOta::getRssi();
|
||||
|
||||
@@ -97,6 +97,11 @@ public:
|
||||
|
||||
httpd_handle_t m_httpServer = NULL;
|
||||
|
||||
// ★ BLE OTA 传输期间暂停 WiFi 射频活动(减少共存干扰)
|
||||
std::atomic<bool> m_bleOtaActive{false};
|
||||
/** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */
|
||||
void setBleOtaActive(bool v) { m_bleOtaActive.store(v); }
|
||||
|
||||
private:
|
||||
// ── 跨任务共享状态(atomic + m_stateMutex 保护)──
|
||||
std::atomic<bool> m_staConnected{false};
|
||||
|
||||
@@ -40,6 +40,15 @@ void Protocol::init()
|
||||
DEBUG_INFO("PROT", "=== 初始化协议层 ===");
|
||||
DEBUG_INFO("PROT", "协议层功能: nonce 扰码 + 会话密钥管理");
|
||||
|
||||
esp_fill_random(m_scrambleConst, SCRAMBLE_CONST_LEN);
|
||||
|
||||
{
|
||||
char hex[SCRAMBLE_CONST_LEN * 2 + 1] = {0};
|
||||
for (size_t i = 0; i < SCRAMBLE_CONST_LEN; ++i)
|
||||
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", m_scrambleConst[i]);
|
||||
DEBUG_INFO("PROT", "SCRAMBLE_CONST (RNG): %s", hex);
|
||||
}
|
||||
|
||||
m_initialized = true;
|
||||
DEBUG_INFO("PROT", "协议层就绪");
|
||||
}
|
||||
@@ -55,11 +64,11 @@ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
|
||||
memcpy(&state[0], ×tamp, 4);
|
||||
memcpy(&state[4], &mem_addr, 4);
|
||||
memcpy(&state[8], &rand_seed, 4);
|
||||
memcpy(&state[12], SCRAMBLE_CONST, 4);
|
||||
memcpy(&state[12], m_scrambleConst, 4);
|
||||
|
||||
for (int round = 0; round < 3; round++) {
|
||||
for (int i = 0; i < 16; i++) {
|
||||
state[i] ^= SCRAMBLE_CONST[i];
|
||||
state[i] ^= m_scrambleConst[i];
|
||||
state[i] = (state[i] << 1) | (state[i] >> 7);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +76,7 @@ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
|
||||
for (int round = 0; round < 2; round++) {
|
||||
uint32_t *words = (uint32_t *)state;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
words[i] ^= SCRAMBLE_CONST[i * 4 % 16] | ((uint32_t)SCRAMBLE_CONST[i * 4 + 1] << 8);
|
||||
words[i] ^= m_scrambleConst[i * 4 % 16] | ((uint32_t)m_scrambleConst[i * 4 + 1] << 8);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
// 安全常量(定义在 app_config.h 中)
|
||||
inline constexpr size_t MASTER_KEY_HERE_LEN = 16;
|
||||
inline constexpr size_t SCRAMBLE_CONST_HERE_LEN = 16;
|
||||
inline constexpr size_t CHIP_UID_LEN = 12;
|
||||
|
||||
// AES 会话上下文
|
||||
@@ -55,7 +54,7 @@ public:
|
||||
* @param rand_seed 随机种子。
|
||||
* @param nonce_out 输出的 16 字节 nonce 缓冲区。
|
||||
*/
|
||||
static void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
|
||||
void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
|
||||
uint32_t rand_seed, uint8_t *nonce_out);
|
||||
/**
|
||||
* @brief 会话密钥初始化,将密钥拷贝到会话上下文。
|
||||
@@ -76,6 +75,7 @@ private:
|
||||
Protocol(const Protocol&) = delete;
|
||||
Protocol& operator=(const Protocol&) = delete;
|
||||
|
||||
uint8_t m_scrambleConst[SCRAMBLE_CONST_LEN] = {0};
|
||||
bool m_initialized = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -285,13 +285,18 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
|
||||
*/
|
||||
esp_err_t SpiMaster::init()
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
// 创建 SPI 总线互斥锁(必须在 SPI 设备初始化之前)
|
||||
// ★ 修复:先创建 mutex,再使用 LockGuard。
|
||||
// 旧代码在 LockGuard 构造时 m_mutex 仍为 NULL,
|
||||
// 虽然 LockGuard 会跳过 NULL take/give,但在极端情况下
|
||||
// 其他任务可能在 mutex 创建后立即尝试 take,产生竞态。
|
||||
if (!m_mutex) {
|
||||
m_mutex = xSemaphoreCreateMutex();
|
||||
if (m_mutex == NULL) {
|
||||
DEBUG_ERR("SPI", "SPI 互斥锁创建失败");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
}
|
||||
LockGuard lock(m_mutex);
|
||||
|
||||
DEBUG_INFO("SPI", "=== 初始化 SPI2 总线 ===");
|
||||
|
||||
@@ -560,7 +565,6 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
constexpr uint8_t kAppPingType = 0xA1U;
|
||||
constexpr size_t kAppPingPollBytes = 32U;
|
||||
|
||||
uint8_t tx_frame[8] = {0};
|
||||
size_t tx_len = 0U;
|
||||
@@ -574,7 +578,14 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver
|
||||
while (xTaskGetTickCount() <= deadline) {
|
||||
++attempt;
|
||||
|
||||
esp_err_t err = transferClocked(tx_frame, tx_len, NULL, 0U);
|
||||
/* 发送 PING 帧,填充至 16 字节:
|
||||
* GD32 SPI 从机可能在 CS 断言时丢失首字节(EXTI/SPI 中断竞态),
|
||||
* 填充确保即使首字节丢失,后续仍有完整 0xA5 帧头可供扫描定位。 */
|
||||
uint8_t tx_padded[16];
|
||||
memset(tx_padded, 0xFF, sizeof(tx_padded));
|
||||
memcpy(tx_padded, tx_frame, tx_len); /* [0..4]=PING帧, [5..15]=0xFF */
|
||||
|
||||
esp_err_t err = transferClocked(tx_padded, sizeof(tx_padded), NULL, 0U);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_DBG("SPI", "APP PING 请求发送失败[%lu]: %s",
|
||||
(unsigned long)attempt, esp_err_to_name(err));
|
||||
@@ -583,10 +594,10 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver
|
||||
}
|
||||
|
||||
/* 给 GD32 充足时间处理 CS 释放中断并准备响应帧 */
|
||||
vTaskDelay(pdMS_TO_TICKS(3));
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
|
||||
uint8_t rx_raw[kAppPingPollBytes];
|
||||
uint8_t dummy[kAppPingPollBytes];
|
||||
uint8_t rx_raw[48]; /* 增大缓冲区,吸收 SPI 字节偏移 */
|
||||
uint8_t dummy[48];
|
||||
memset(dummy, 0xFF, sizeof(dummy));
|
||||
memset(rx_raw, 0xFF, sizeof(rx_raw));
|
||||
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import sys
|
||||
|
||||
with open('main/server/ble_ota.cpp','r',encoding='utf-8-sig') as f:
|
||||
data=f.read()
|
||||
|
||||
# 1. 添加 SHA_MISMATCH 错误码宏
|
||||
old='#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小'
|
||||
new='#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小\n#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 // ESP32 OTA SHA-256 不匹配'
|
||||
assert old in data, 'macro old not found'
|
||||
data=data.replace(old,new)
|
||||
|
||||
# 2. 修改 ble_esp32_finish_and_reboot_task 接受 frontend SHA 参数
|
||||
old_task='''static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动");
|
||||
|
||||
Esp32OtaCore &core = Esp32OtaCore::instance();
|
||||
|
||||
// 补发最终 ACK,让前端尽快得知最后一包已收到
|
||||
if (BleOta::instance().m_esp32LastSeq != 0xFFFFU) {
|
||||
sendWindowAck(BleOta::instance().m_esp32LastCmd,
|
||||
BleOta::instance().m_esp32LastSeq,
|
||||
(uint32_t)core.getReceived());
|
||||
DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(150));
|
||||
|
||||
if (!core.isActive()) {
|
||||
DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启");
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived());
|
||||
|
||||
// 通知前端:开始 finalize
|
||||
sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0);
|
||||
|
||||
// 委托核心完成: esp_ota_end → SHA 计算 → set_boot_partition → esp_restart
|
||||
// BLE 不传前端 SHA(BLE 协议无 finalize 前校验机制),直接应用;SHA 通过 0xC1 上报供前端比对
|
||||
char device_sha_hex[65] = {0};
|
||||
esp_err_t err = core.finalize(/*frontend_sha_hex=*/nullptr,
|
||||
/*reboot=*/true,
|
||||
device_sha_hex);
|
||||
// reboot=true 时 finalize 正常不会返回(core 内部 esp_restart)
|
||||
|
||||
if (err == ESP_OK) {
|
||||
// 极端情况:reboot 未执行(理论上不会发生),仍上报并标记
|
||||
if (device_sha_hex[0] != '\\0') {
|
||||
// 上报设备端 SHA-256 供前端比对显示([0xC1] + [32 字节])
|
||||
uint8_t sha_pkt[33] = {0};
|
||||
sha_pkt[0] = BLE_OTA_NOTIFY_SHA256;
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
unsigned int byte = 0;
|
||||
sscanf(&device_sha_hex[i * 2], "%02x", &byte);
|
||||
sha_pkt[1 + i] = (uint8_t)byte;
|
||||
}
|
||||
BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt));
|
||||
}
|
||||
sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0);
|
||||
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
}
|
||||
|
||||
vTaskDelete(NULL);
|
||||
}'''
|
||||
|
||||
new_task='''static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
{
|
||||
// pvParameters: heap-allocated char[65] frontend SHA hex string (or nullptr)
|
||||
char *frontend_sha_hex = (char *)pvParameters;
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动");
|
||||
|
||||
Esp32OtaCore &core = Esp32OtaCore::instance();
|
||||
|
||||
// 补发最终 ACK,让前端尽快得知最后一包已收到
|
||||
if (BleOta::instance().m_esp32LastSeq != 0xFFFFU) {
|
||||
sendWindowAck(BleOta::instance().m_esp32LastCmd,
|
||||
BleOta::instance().m_esp32LastSeq,
|
||||
(uint32_t)core.getReceived());
|
||||
DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(150));
|
||||
|
||||
if (!core.isActive()) {
|
||||
DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启");
|
||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived());
|
||||
|
||||
// 通知前端:开始 finalize
|
||||
sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0);
|
||||
|
||||
char device_sha_hex[65] = {0};
|
||||
esp_err_t err = core.finalize(frontend_sha_hex,
|
||||
/*reboot=*/true,
|
||||
device_sha_hex);
|
||||
// reboot=true 时 finalize 正常不会返回(core 内部 esp_restart)
|
||||
|
||||
if (err == ESP_OK) {
|
||||
// 极端情况:reboot 未执行(理论上不会发生),仍上报并标记
|
||||
if (device_sha_hex[0] != '\\0') {
|
||||
uint8_t sha_pkt[33] = {0};
|
||||
sha_pkt[0] = BLE_OTA_NOTIFY_SHA256;
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
unsigned int byte = 0;
|
||||
sscanf(&device_sha_hex[i * 2], "%02x", &byte);
|
||||
sha_pkt[1 + i] = (uint8_t)byte;
|
||||
}
|
||||
BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt));
|
||||
}
|
||||
sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0);
|
||||
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
||||
} else if (err == ESP_ERR_INVALID_VERSION) {
|
||||
DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
}
|
||||
|
||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
||||
vTaskDelete(NULL);
|
||||
}'''
|
||||
assert old_task in data, 'old_task not found'
|
||||
data=data.replace(old_task,new_task)
|
||||
|
||||
# 3. 修改 ble_ota_ctrl_access: 扩展 buf 到 36,解析前端 SHA
|
||||
old_ctrl=''' uint8_t buf[4] = {0};
|
||||
int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
|
||||
if (len < 1) {
|
||||
DEBUG_WARN(TAG, "CTRL 数据为空");
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X", buf[0]);
|
||||
|
||||
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
|
||||
// 检查固件是否存储完毕 → 触发 GD32 OTA 中继
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = OtaRelay::metaLoad(&meta);
|
||||
if (err == ESP_OK &&
|
||||
meta.total_size > 0 &&
|
||||
meta.stored_size >= meta.total_size) {
|
||||
DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继",
|
||||
meta.stored_size, meta.total_size);
|
||||
BleOta::instance().signalRelay();
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
|
||||
meta.stored_size, meta.total_size);
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
|
||||
if (!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
|
||||
return 0;
|
||||
}
|
||||
if (BleOta::instance().m_esp32RebootPending) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
|
||||
return 0;
|
||||
}
|
||||
|
||||
BleOta::instance().m_esp32RebootPending = true;
|
||||
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
||||
"ble_ota_finish",
|
||||
4096,
|
||||
NULL,
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(由设备端异步完成)");
|
||||
}
|
||||
}
|
||||
// 注: BLE_OTA_CTRL_VERIFY_SHA256 (0xF9) 路径已移除。
|
||||
// SHA-256 现由 Esp32OtaCore 在 finalize 时统一从分区读回计算,
|
||||
// 并通过 BLE_OTA_NOTIFY_SHA256 (0xC1) 通知前端比对,校验逻辑与 WiFi 完全一致。'''
|
||||
|
||||
new_ctrl=''' uint8_t buf[36] = {0};
|
||||
int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
|
||||
if (len < 1) {
|
||||
DEBUG_WARN(TAG, "CTRL 数据为空");
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
|
||||
|
||||
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
|
||||
// 检查固件是否存储完毕 → 触发 GD32 OTA 中继
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = OtaRelay::metaLoad(&meta);
|
||||
if (err == ESP_OK &&
|
||||
meta.total_size > 0 &&
|
||||
meta.stored_size >= meta.total_size) {
|
||||
DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继",
|
||||
meta.stored_size, meta.total_size);
|
||||
BleOta::instance().signalRelay();
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
|
||||
meta.stored_size, meta.total_size);
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
|
||||
if (!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
|
||||
return 0;
|
||||
}
|
||||
if (BleOta::instance().m_esp32RebootPending) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 解析前端 SHA-256: [0xF8] + [32 bytes binary SHA]
|
||||
char *frontend_sha_hex = nullptr;
|
||||
if (len >= 33) {
|
||||
frontend_sha_hex = (char *)malloc(65);
|
||||
if (frontend_sha_hex) {
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]);
|
||||
}
|
||||
frontend_sha_hex[64] = '\\0';
|
||||
DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex);
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33,无法获取前端 SHA-256");
|
||||
}
|
||||
|
||||
BleOta::instance().m_esp32RebootPending = true;
|
||||
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
||||
"ble_ota_finish",
|
||||
4096,
|
||||
frontend_sha_hex, // heap string; task 负责 free
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
|
||||
}
|
||||
}
|
||||
// 注: BLE_OTA_CTRL_VERIFY_SHA256 (0xF9) 路径已移除。
|
||||
// SHA-256 现由 Esp32OtaCore 在 finalize 时统一从分区读回计算,
|
||||
// 并通过 BLE_OTA_NOTIFY_SHA256 (0xC1) 通知前端比对,校验逻辑与 WiFi 完全一致。'''
|
||||
assert old_ctrl in data, 'old_ctrl not found'
|
||||
data=data.replace(old_ctrl,new_ctrl)
|
||||
|
||||
# 4. 移除 DATA_IN 中的自动收尾代码(ESP32 收到最后一包后自动创建 finish task)
|
||||
old_auto=''' if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) {
|
||||
DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s",
|
||||
(unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1,
|
||||
isLastPacket ? " [尾包]" : "");
|
||||
BleOta::instance().m_esp32LastCmd = cmd;
|
||||
BleOta::instance().m_esp32LastSeq = seq;
|
||||
|
||||
if (!BleOta::instance().m_esp32RebootPending) {
|
||||
BleOta::instance().m_esp32RebootPending = true;
|
||||
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
||||
"ble_ota_finish",
|
||||
4096,
|
||||
NULL,
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||
ble_esp32_ota_abort(); // core 内部完成 abort + mutex release + 状态复位
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启");
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发");
|
||||
}
|
||||
}
|
||||
}'''
|
||||
|
||||
new_auto=''' if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) {
|
||||
DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s",
|
||||
(unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1,
|
||||
isLastPacket ? " [尾包]" : "");
|
||||
BleOta::instance().m_esp32LastCmd = cmd;
|
||||
BleOta::instance().m_esp32LastSeq = seq;
|
||||
// 注:与 WiFi 流程对齐,传输完成后由前端发送 0xF8 + SHA-256 触发 finalize,
|
||||
// 设备端不再自动创建收尾任务。
|
||||
}
|
||||
}'''
|
||||
assert old_auto in data, 'old_auto not found'
|
||||
data=data.replace(old_auto,new_auto)
|
||||
|
||||
with open('main/server/ble_ota.cpp','w',encoding='utf-8-sig') as f:
|
||||
f.write(data)
|
||||
print('ble_ota.cpp updated')
|
||||
@@ -1,147 +0,0 @@
|
||||
import sys
|
||||
|
||||
path = r'main\server\ble_ota.cpp'
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
data = f.read()
|
||||
|
||||
# 1. 文件头注释:补充 0xFA 说明
|
||||
old = ' * - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级\n'
|
||||
new = ' * - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级(含 32B SHA-256 校验并 reboot),0xFA=重启ESP32\n'
|
||||
assert old in data, 'header old not found'
|
||||
data = data.replace(old, new, 1)
|
||||
|
||||
# 2. 0xF8 分支:移除 "无 SHA 跳过比对" 路径,强制要求 32B SHA
|
||||
old_f8 = ''' } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
|
||||
if (!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
|
||||
return 0;
|
||||
}
|
||||
if (BleOta::instance().m_esp32RebootPending) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 解析前端 SHA-256: [0xF8] + [32 bytes binary SHA]
|
||||
char *frontend_sha_hex = nullptr;
|
||||
if (len >= 33) {
|
||||
frontend_sha_hex = (char *)malloc(65);
|
||||
if (frontend_sha_hex) {
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]);
|
||||
}
|
||||
frontend_sha_hex[64] = '\\0';
|
||||
DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex);
|
||||
}
|
||||
} else {
|
||||
// 前端未传 SHA,说明已经走过 0xF9 先校验、收尾不再重复比对
|
||||
DEBUG_INFO(TAG, "CTRL 0xF8 未传入 SHA-256,finalize 将跳过 SHA 比对与重启前控制流");
|
||||
}
|
||||
|
||||
BleOta::instance().m_esp32RebootPending = true;
|
||||
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
||||
"ble_ota_finish",
|
||||
4096,
|
||||
frontend_sha_hex, // heap string; task 负责 free
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_VERIFY_SHA256) {
|
||||
// 仅校验:从 OTA 分区读回计算 SHA-256,通过 0xC1 上报。不 end / 不 set_boot / 不重启。
|
||||
Esp32OtaCore &core = Esp32OtaCore::instance();
|
||||
if (!core.isActive()) {
|
||||
DEBUG_WARN(TAG, "0xF9 请求被拒绝:ESP32 OTA 会话未激活");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_STATE);
|
||||
return 0;
|
||||
}
|
||||
char device_sha_hex[65] = {0};
|
||||
esp_err_t sha_err = core.computeDeviceSha(device_sha_hex);
|
||||
if (sha_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "0xF9 请求:从 OTA 分区计算 SHA-256 失败: %s", esp_err_to_name(sha_err));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)sha_err);
|
||||
return 0;
|
||||
}
|
||||
uint8_t sha_pkt[33] = {0};
|
||||
sha_pkt[0] = BLE_OTA_NOTIFY_SHA256;
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
unsigned int byte = 0;
|
||||
sscanf(&device_sha_hex[i * 2], "%02x", &byte);
|
||||
sha_pkt[1 + i] = (uint8_t)byte;
|
||||
}
|
||||
esp_err_t notify_err = BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt));
|
||||
if (notify_err != ESP_OK) {
|
||||
DEBUG_WARN(TAG, "0xF9 请求:上报 SHA-256 notify 失败: %s", esp_err_to_name(notify_err));
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "0xF9 请求:已上报设备端 SHA-256,前端可继续比对,%s%s",
|
||||
device_sha_hex, BleOta::instance().m_esp32RebootPending ? "" : "");
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {'''
|
||||
|
||||
new_f8 = ''' } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
|
||||
if (!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
|
||||
return 0;
|
||||
}
|
||||
if (BleOta::instance().m_esp32RebootPending) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对,
|
||||
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。
|
||||
if (len < 33) {
|
||||
DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_ARG);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *frontend_sha_hex = (char *)malloc(65);
|
||||
if (!frontend_sha_hex) {
|
||||
DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_NO_MEM);
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]);
|
||||
}
|
||||
frontend_sha_hex[64] = '\\0';
|
||||
DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex);
|
||||
|
||||
BleOta::instance().m_esp32RebootPending = true;
|
||||
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
||||
"ble_ota_finish",
|
||||
4096,
|
||||
frontend_sha_hex, // heap string; task 负责 free
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||
free(frontend_sha_hex);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {'''
|
||||
assert old_f8 in data, '0xF8 block old not found'
|
||||
data = data.replace(old_f8, new_f8, 1)
|
||||
|
||||
# 3. 结尾协议说明注释:删除 0xF9 行
|
||||
old_tail = ''' // 0xF7 = 触发 GD32 中继
|
||||
// 0xF8 = 触发 ESP32 本机 OTA 收尾(end + set_boot + reboot,默认不重复校验 SHA,前端需先走 0xF9 确认)
|
||||
// 0xF9 = 仅从 OTA 分区读回计算 SHA-256,通过 0xC1 notify 上报前端(不 end / 不 reboot)
|
||||
// 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)'''
|
||||
new_tail = ''' // 0xF7 = 触发 GD32 中继
|
||||
// 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对,
|
||||
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启)
|
||||
// 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)'''
|
||||
assert old_tail in data, 'tail old not found'
|
||||
data = data.replace(old_tail, new_tail, 1)
|
||||
|
||||
with open(path, 'w', encoding='utf-8-sig') as f:
|
||||
f.write(data)
|
||||
print('ble_ota.cpp updated')
|
||||
@@ -1,37 +0,0 @@
|
||||
path = r'tools\style.css'
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
css = f.read()
|
||||
|
||||
# 在 .svi-val.err 之后追加 stage-result 样式
|
||||
old = """.svi-val.err { color: var(--red); font-weight: 600; }"""
|
||||
new = """.svi-val.err { color: var(--red); font-weight: 600; }
|
||||
|
||||
/* Stage result panel - 上位机确认 0x07/0x85 收尾 */
|
||||
.stage-result {
|
||||
margin-top: 12px; padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.stage-result.ok { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); }
|
||||
.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); }
|
||||
.stage-result .sr-head { display: flex; align-items: center; gap: 10px; }
|
||||
.stage-result .sr-ico {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; font-weight: 700; color: #07121c;
|
||||
}
|
||||
.stage-result.ok .sr-ico { background: var(--green); }
|
||||
.stage-result.err .sr-ico { background: var(--red); }
|
||||
.stage-result .sr-title { font-size: 16px; font-weight: 700; }
|
||||
.stage-result.ok .sr-title { color: var(--green); }
|
||||
.stage-result.err .sr-title { color: var(--red); }
|
||||
.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.5; }
|
||||
.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; }"""
|
||||
|
||||
assert old in css, 'svi-val.err CSS not found'
|
||||
css = css.replace(old, new, 1)
|
||||
|
||||
with open(path, 'w', encoding='utf-8-sig') as f:
|
||||
f.write(css)
|
||||
print('style.css updated')
|
||||
@@ -1,20 +0,0 @@
|
||||
path = r'tools\ble_ota.html'
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
html = f.read()
|
||||
|
||||
old_hint = " hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,<mark>写入完成后会先发 0xF9 让设备端读回算 SHA-256</mark>,前端比对一致后才发 0xF8 触发收尾(end + set_boot + reboot)。'"
|
||||
new_hint = " hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,写入完成后发 <b>0xF8</b> + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。'"
|
||||
print('old in html:', old_hint in html)
|
||||
html2 = html.replace(old_hint, new_hint, 1)
|
||||
print('changed:', html2 != html)
|
||||
print('new still present:', new_hint in html2)
|
||||
with open(path, 'w', encoding='utf-8-sig') as f:
|
||||
f.write(html2)
|
||||
print('written, file len:', len(html2))
|
||||
|
||||
# verify
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
reread = f.read()
|
||||
print('re-read len:', len(reread))
|
||||
print('new in reread:', new_hint in reread)
|
||||
print('old in reread:', old_hint in reread)
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import os
|
||||
os.chdir(r'G:\Robot_v2\DCU\ESP32_COMM')
|
||||
path = 'tools/ble_ota.html'
|
||||
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
html = f.read()
|
||||
|
||||
# 计数
|
||||
print('0xF9 BEFORE:', html.count('0xF9'))
|
||||
print('shaWaiter BEFORE:', html.count('shaWaiter'))
|
||||
|
||||
# 1) TARGETS esp32 hint
|
||||
old_hint = " hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,<mark>写入完成后会先发 0xF9 让设备端读回算 SHA-256</mark>,前端比对一致后才发 0xF8 触发收尾(end + set_boot + reboot)。'"
|
||||
new_hint = " hint: 'TEST NEW'"
|
||||
print('hint in html:', old_hint in html)
|
||||
html = html.replace(old_hint, new_hint, 1)
|
||||
|
||||
with open(path, 'w', encoding='utf-8-sig') as f:
|
||||
f.write(html)
|
||||
|
||||
# 验证
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
reread = f.read()
|
||||
print('TEST NEW present:', 'TEST NEW' in reread)
|
||||
print('old hint present:', old_hint in reread)
|
||||
@@ -1,20 +0,0 @@
|
||||
import os
|
||||
os.chdir(r'G:\Robot_v2\DCU\ESP32_COMM')
|
||||
with open('tools/ble_ota.html', 'r', encoding='utf-8-sig') as f:
|
||||
text = f.read()
|
||||
|
||||
# Try a small, single line replace
|
||||
old = " fileSha256: null,"
|
||||
print('old line found:', text.count(old))
|
||||
|
||||
# Now do a multi-line block test
|
||||
old_block = """ fileSha256: null,
|
||||
deviceSha256: null,
|
||||
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
|
||||
reconnecting: false, // 自动重连中
|
||||
reconnectAttempts: 0, // 重连尝试次数
|
||||
shaWaiter: null, // {resolve, reject, timer} 0xC1 等待者
|
||||
};"""
|
||||
|
||||
print('block found:', old_block in text)
|
||||
print('block repr (last 200):', repr(old_block[-200:]))
|
||||
+43
-45
@@ -13,6 +13,11 @@ let logSocket = null;
|
||||
let latestSystemStatus = null;
|
||||
let isEsp32Rebooting = false;
|
||||
let rebootStartTime = 0;
|
||||
|
||||
// ==================== 折叠面板 ====================
|
||||
function toggleCollapse(headEl) {
|
||||
headEl.closest('.card').classList.toggle('collapsed');
|
||||
}
|
||||
// ==================== 刷写进度 ====================
|
||||
var activePhase = null; // 'esp32Ota' | 'gd32Upload' | 'gd32Relay' | null
|
||||
var currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
|
||||
@@ -293,10 +298,8 @@ function applyDisconnectedRuntimeState() {
|
||||
document.getElementById('pillRSSI').textContent = '--';
|
||||
document.getElementById('pillRelayTxt').textContent = '离线';
|
||||
document.getElementById('pillRelay').className = 'pill offline';
|
||||
document.getElementById('mWifi').textContent = '未连接';
|
||||
document.getElementById('mRSSI').textContent = '--';
|
||||
document.getElementById('bOTAProgress').textContent = '--';
|
||||
document.getElementById('bOTAProgress').className = 'v err';
|
||||
var bp = document.getElementById('bOTAProgress');
|
||||
if (bp) { bp.textContent = '--'; bp.className = 'v err'; }
|
||||
resetProgress();
|
||||
resetStageFlow();
|
||||
}
|
||||
@@ -308,11 +311,11 @@ function applyRebootingRuntimeState() {
|
||||
pct.textContent = '重启中 ' + elapsed + 's';
|
||||
var cs = document.getElementById('connStatus');
|
||||
var cst = document.getElementById('connStatusTxt');
|
||||
cs.className = 'conn-indicator warn';
|
||||
cst.textContent = 'ESP32 正在重启...';
|
||||
if (cs) cs.className = 'conn-indicator warn';
|
||||
if (cst) cst.textContent = 'ESP32 正在重启...';
|
||||
var bc = document.getElementById('bConn');
|
||||
bc.textContent = '重启中';
|
||||
bc.className = 'v warn';
|
||||
if (bc) bc.textContent = '重启中';
|
||||
if (bc) bc.className = 'v warn';
|
||||
document.getElementById('pillRelayTxt').textContent = '重启中';
|
||||
document.getElementById('pillRelay').className = 'pill processing';
|
||||
}
|
||||
@@ -323,11 +326,11 @@ function applyUploadingRuntimeState() {
|
||||
pct.textContent = '上传中';
|
||||
var cs = document.getElementById('connStatus');
|
||||
var cst = document.getElementById('connStatusTxt');
|
||||
cs.className = 'conn-indicator warn';
|
||||
cst.textContent = '固件上传中,设备忙...';
|
||||
if (cs) cs.className = 'conn-indicator warn';
|
||||
if (cst) cst.textContent = '固件上传中,设备忙...';
|
||||
var bc = document.getElementById('bConn');
|
||||
bc.textContent = '上传中';
|
||||
bc.className = 'v warn';
|
||||
if (bc) bc.textContent = '上传中';
|
||||
if (bc) bc.className = 'v warn';
|
||||
}
|
||||
// ==================== 连接管理 ====================
|
||||
function updateConnectionUi(connected, text) {
|
||||
@@ -339,8 +342,8 @@ function updateConnectionUi(connected, text) {
|
||||
// connection card indicator
|
||||
var cs = document.getElementById('connStatus');
|
||||
var cst = document.getElementById('connStatusTxt');
|
||||
cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
|
||||
cst.textContent = connected ? (text || '已连接') : '未连接';
|
||||
if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
|
||||
if (cst) cst.textContent = connected ? (text || '已连接') : '未连接';
|
||||
// primary badge
|
||||
var bc = document.getElementById('bConn');
|
||||
bc.textContent = connected ? '在线' : '离线';
|
||||
@@ -442,8 +445,7 @@ function updateSystemStatusUi(data) {
|
||||
// version
|
||||
if (data.version) {
|
||||
document.getElementById('pillVer').textContent = 'v' + data.version;
|
||||
document.getElementById('pillVer').title = data.build_time ? ('编译: ' + data.build_time) : '';
|
||||
document.getElementById('mVersion').textContent = 'v' + data.version + (data.build_time ? ' (' + data.build_time + ')' : '');
|
||||
document.getElementById('pillVer').dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
|
||||
// GD32 版本
|
||||
var mGd32App = document.getElementById('mGd32App');
|
||||
var mGd32Boot = document.getElementById('mGd32Boot');
|
||||
@@ -473,10 +475,8 @@ function updateSystemStatusUi(data) {
|
||||
var uptimeEl = document.getElementById('pillUptime');
|
||||
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
|
||||
// wifi / signal
|
||||
document.getElementById('mWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接';
|
||||
document.getElementById('pillIP').textContent = wifi.ip || '--';
|
||||
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
|
||||
document.getElementById('mRSSI').textContent = rssi;
|
||||
document.getElementById('pillRSSI').textContent = rssi;
|
||||
// heap
|
||||
document.getElementById('mHeap').textContent =
|
||||
@@ -507,59 +507,56 @@ function updateSystemStatusUi(data) {
|
||||
document.getElementById('pillRelayTxt').textContent = relayText;
|
||||
var pr = document.getElementById('pillRelay');
|
||||
if (rs === 255) pr.className = 'pill offline';
|
||||
else if (rs > 0 && rs < 17) pr.className = 'pill processing';
|
||||
else if (rs === 17 || rs === 18) pr.className = 'pill online';
|
||||
else if (rs > 0 && rs < 5) pr.className = 'pill processing';
|
||||
else if (rs === 5) pr.className = 'pill online';
|
||||
else pr.className = 'pill idle';
|
||||
// OTA cache progress (GD32 upload to ESP32 flash)
|
||||
var total = ota.total_size || 0;
|
||||
var stored = ota.stored_size || 0;
|
||||
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
||||
var bp = document.getElementById('bOTAProgress');
|
||||
if (bp) {
|
||||
bp.textContent = storageProgress + '%';
|
||||
bp.className = 'v ' + (storageProgress === 100 ? 'ok' : storageProgress > 0 ? 'warn' : '');
|
||||
}
|
||||
// GD32 relay forwarding progress
|
||||
var relaySeq = ota.relay_seq || 0;
|
||||
var relayTotal = ota.relay_total_packets || 0;
|
||||
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
|
||||
var gd32AppAlive = ota.gd32_app_alive;
|
||||
if (rs > 0 && rs <= 8) {
|
||||
// Authentication or size negotiation phase
|
||||
// ★ 修复:后端实际状态机枚举 (main/relay/ota_relay.h):
|
||||
// 0=RELAY_IDLE, 1=RELAY_HANDSHAKING, 2=RELAY_SIZE_NEG,
|
||||
// 3=RELAY_TRANSFERRING, 4=RELAY_FINISHING, 5=RELAY_DONE, 0xFF=RELAY_ERROR
|
||||
// 旧版 1-18 范围判断已与新状态机不匹配,进度条永远停在认证中阶段。
|
||||
if (rs === 1) {
|
||||
// HANDSHAKING: 握手 + 挑战认证
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(0, '握手与认证中...');
|
||||
} else if (rs === 2) {
|
||||
// SIZE_NEG: 固件大小协商
|
||||
setActivePhase('gd32Relay');
|
||||
if (rs <= 6) {
|
||||
setProgress(0, '认证中: ' + (relayStateDetailMap[rs] || ''));
|
||||
} else {
|
||||
setProgress(0, '协商固件大小...');
|
||||
}
|
||||
} else if (rs >= 9 && rs <= 13) {
|
||||
// Active data forwarding
|
||||
} else if (rs === 3) {
|
||||
// TRANSFERRING: 数据包传输
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(relayProgress, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包');
|
||||
} else if (rs >= 14 && rs <= 15) {
|
||||
// Transfer complete
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(100, '数据转发完成 (' + relayTotal + ' 包)');
|
||||
} else if (rs === 16) {
|
||||
// OTA end sent
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(100, 'OTA 刷写中...');
|
||||
} else if (rs === 17) {
|
||||
// OTA complete
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(100, 'OTA 完成,等待 APP 启动...');
|
||||
} else if (rs === 18) {
|
||||
// APP verify phase
|
||||
} else if (rs === 4) {
|
||||
// FINISHING: 结束OTA + APP存活验证
|
||||
setActivePhase('gd32Relay');
|
||||
if (gd32AppAlive) {
|
||||
markPhaseDone('gd32Relay');
|
||||
setProgress(100, 'GD32 刷写完成');
|
||||
// Mark status bar as success
|
||||
var sb = document.getElementById('stageStatusBar');
|
||||
var st = document.getElementById('stageStatusText');
|
||||
if (sb) sb.className = 'stage-status-bar ok';
|
||||
if (st) st.textContent = 'GD32 刷写完成';
|
||||
} else {
|
||||
setProgress(100, '转发完成,验证 APP 存活...');
|
||||
setProgress(100, '收尾中,等待 APP 启动...');
|
||||
}
|
||||
} else if (rs === 5) {
|
||||
// DONE: 中继完成(metaClear 已清空,relaySeq/relayTotal 会被 reset 为 0)
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(100, 'OTA 完成');
|
||||
} else if (total > 0 && stored < total && !isUploading) {
|
||||
// GD32 upload to cache in progress (handled by upload progress if uploading)
|
||||
setActivePhase('gd32Upload');
|
||||
@@ -1073,7 +1070,8 @@ async function startUpload(chip) {
|
||||
var pct = Math.round((e.loaded / e.total) * 100);
|
||||
uploadProgress = pct;
|
||||
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
document.getElementById('bOTAProgress').textContent = pct + '%';
|
||||
var el = document.getElementById('bOTAProgress');
|
||||
if (el) el.textContent = pct + '%';
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', function () {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+393
-67
@@ -23,6 +23,10 @@
|
||||
<span class="pill idle" id="pillRelay"><span class="indicator"></span><span id="pillRelayTxt">待机</span></span>
|
||||
<span class="pill" id="pillUptime">运行 --</span>
|
||||
</div>
|
||||
<div class="header-sub">
|
||||
<span class="pill">设备名: <b class="pill-val" id="deviceName">-</b></span>
|
||||
<span class="pill">设备ID: <b class="pill-val" id="deviceId">-</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ====== MAIN LAYOUT ====== -->
|
||||
@@ -33,8 +37,18 @@
|
||||
|
||||
<!-- 1. Connection -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">🔗</span> 设备连接</div>
|
||||
<div class="card-head"><span class="ico">🔗</span> 设备连接
|
||||
<span id="bleHeadActions" style="display:none;margin-left:auto;">
|
||||
<button id="connectBtn" class="btn btn-sm btn-primary">连接设备</button>
|
||||
<button id="disconnectBtn" class="btn btn-sm btn-outline" disabled>断开连接</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="display:flex;gap:6px;margin-bottom:14px;">
|
||||
<button id="modeWifiBtn" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" onclick="setConsoleMode('wifi')">WiFi OTA</button>
|
||||
<button id="modeBleBtn" class="btn btn-outline" style="flex:1;font-size:12px;padding:7px 10px;" onclick="setConsoleMode('ble')">BLE 蓝牙</button>
|
||||
</div>
|
||||
<div id="wifiModeCards">
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="espIp">ESP32 IP 地址</label>
|
||||
<div class="input-group">
|
||||
@@ -42,27 +56,45 @@
|
||||
<button class="btn btn-outline" onclick="testConnection()">测试</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-indicator err" id="connStatus">
|
||||
</div>
|
||||
<div id="bleModeCards" style="display:none;">
|
||||
<div class="row-2" id="bleParamRow" style="margin-top:0;">
|
||||
<div class="form-row" style="margin:0;">
|
||||
<label class="form-label" for="payloadSize">每包 Payload</label>
|
||||
<input id="payloadSize" class="input" type="number" min="32" max="498" value="480">
|
||||
</div>
|
||||
<div class="form-row" style="margin:0;">
|
||||
<label class="form-label" for="ctrlHex">触发命令(HEX)</label>
|
||||
<input id="ctrlHex" class="input" type="text" value="F7" maxlength="8">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-indicator err" id="connStatus" style="margin-top:12px;">
|
||||
<span class="light"></span>
|
||||
<span id="connStatusTxt">等待连接</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. ESP32 OTA -->
|
||||
<!-- ====== Shared Target + Firmware + Actions ====== -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">📦</span> ESP32 自身升级</div>
|
||||
<div class="card-head"><span class="ico">🎯</span> 升级目标、固件文件与执行操作</div>
|
||||
<div class="card-body">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;">上传固件并执行 OTA,设备将自动重启</div>
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneEsp32" onclick="if(window.showOpenFilePicker){pickFileViaFSAPI('esp32')}else{document.getElementById('fileInputEsp32').click()}">
|
||||
<div style="display:flex;gap:6px;margin-bottom:14px;">
|
||||
<button id="targetGd32Tab" class="btn btn-outline" style="flex:1;font-size:12px;padding:7px 10px;" onclick="selectFlashTarget('gd32')">GD32</button>
|
||||
<button id="targetEsp32Tab" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" onclick="selectFlashTarget('esp32')">ESP32</button>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--border);margin:0 -16px 14px;padding-top:14px;padding-left:16px;padding-right:16px;">
|
||||
<div id="wifiEsp32UploadPane">
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneEsp32" onclick="pickSharedFirmware('esp32')">
|
||||
<div class="upload-ico">📤</div>
|
||||
<h4>点击或拖拽 ESP32 固件</h4>
|
||||
</div>
|
||||
<input type="file" id="fileInputEsp32" accept=".bin" onchange="handleFileSelect(event, 'esp32')" style="display:none;">
|
||||
<input type="file" id="fileInputEsp32" accept=".bin" onchange="handleSharedFileSelect(event, 'esp32')" style="display:none;">
|
||||
<div class="file-info" id="fileInfoEsp32">
|
||||
<div class="file-info-row">
|
||||
<span style="font-weight:600;">📄 <span id="fileNameEsp32">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearFile('esp32')">清除</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearSharedFirmware('esp32')">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>类型</span><b id="fileTypeEsp32">-</b></div>
|
||||
@@ -73,44 +105,81 @@
|
||||
<b id="fileSha256Esp32" style="font-size:10px;word-break:break-all;font-family:monospace;">-</b>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" id="uploadEsp32Btn" onclick="startUpload('esp32')" disabled style="margin-top:10px;">
|
||||
上传并升级 ESP32
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. GD32 OTA -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">📦</span> GD32 SPI 中继</div>
|
||||
<div class="card-body">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;">上传固件到缓存,再触发 SPI 中继刷写<br><span style="color:var(--accent);">支持 .bin 和 .hex(自动转换)</span></div>
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneGd32" onclick="if(window.showOpenFilePicker){pickFileViaFSAPI('gd32')}else{document.getElementById('fileInputGd32').click()}">
|
||||
<div id="wifiGd32UploadPane" style="display:none;">
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneGd32" onclick="pickSharedFirmware('gd32')">
|
||||
<div class="upload-ico">📤</div>
|
||||
<h4>点击或拖拽 GD32 固件</h4>
|
||||
<p>支持 .bin 和 .hex(自动转换)</p>
|
||||
</div>
|
||||
<input type="file" id="fileInputGd32" accept=".hex,.bin" onchange="handleFileSelect(event, 'gd32')" style="display:none;">
|
||||
<input type="file" id="fileInputGd32" accept=".hex,.bin" onchange="handleSharedFileSelect(event, 'gd32')" style="display:none;">
|
||||
<div class="file-info" id="fileInfoGd32">
|
||||
<div class="file-info-row">
|
||||
<span style="font-weight:600;">📄 <span id="fileNameGd32">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearFile('gd32')">清除</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearSharedFirmware('gd32')">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>类型</span><b id="fileTypeGd32">-</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="fileSizeGd32">-</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled style="margin-top:10px;margin-bottom:8px;">
|
||||
</div>
|
||||
|
||||
<div id="bleUploadPane" style="display:none;">
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZone" style="margin-bottom:12px;"
|
||||
onclick="document.getElementById('firmwareFile').click()"
|
||||
ondragover="event.preventDefault();this.classList.add('dragover')"
|
||||
ondragleave="this.classList.remove('dragover')"
|
||||
ondrop="event.preventDefault();this.classList.remove('dragover');var f=event.dataTransfer.files[0];if(f&&window.bleOta)window.bleOta.notifyFileChange(f,null);">
|
||||
<div class="upload-ico">📤</div>
|
||||
<h4>点击或拖拽文件到此处</h4>
|
||||
<p>支持 .bin 和 .hex 格式</p>
|
||||
</div>
|
||||
<input id="firmwareFile" type="file" accept=".bin,.hex,application/octet-stream" style="display:none;">
|
||||
<div class="file-info" id="fileInfo">
|
||||
<div class="file-info-row">
|
||||
<span style="font-weight:600;">📄 <span id="fileName">-</span></span>
|
||||
<button id="clearFileBtn" class="btn btn-outline btn-sm" onclick="if(window.bleOta)window.bleOta.clearFile()">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>类型</span><b id="fileType">-</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="fileSize">0 B</b></div>
|
||||
<div class="file-meta-item"><span>总包数</span><b id="packetCount">0</b></div>
|
||||
<div class="file-meta-item"><span>已写包</span><b id="sentCount">0</b></div>
|
||||
</div>
|
||||
<div class="file-meta-item" style="margin-top:8px;">
|
||||
<span>SHA-256</span>
|
||||
<b id="fileSha256" style="font-size:10px;word-break:break-all;font-family:var(--font-mono);">-</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-note">建议使用 Chrome / Edge。若首包失败,可将 Payload 从 480 降为 240 再试。</div>
|
||||
</div>
|
||||
<div style="height:12px"></div>
|
||||
<div id="wifiEsp32ActionPane">
|
||||
<button class="btn btn-primary btn-block" id="uploadEsp32Btn" onclick="startUpload('esp32')" disabled>
|
||||
上传并升级 ESP32
|
||||
</button>
|
||||
</div>
|
||||
<div id="wifiGd32ActionPane" style="display:none;">
|
||||
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled style="margin-bottom:8px;">
|
||||
上传固件到缓存
|
||||
</button>
|
||||
<button class="btn btn-outline btn-block" id="relayBtn" onclick="triggerRelay()">
|
||||
触发 GD32 OTA 中继
|
||||
</button>
|
||||
</div>
|
||||
<div class="row-2" id="bleActionPane" style="display:none;">
|
||||
<button id="autoFlashBtn" class="btn btn-primary" disabled>一键刷写</button>
|
||||
<button id="abortBtn" class="btn btn-danger" disabled>中止任务</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Flash Progress -->
|
||||
<!-- 4. Flash Progress + Stage Flow -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">⚡</span> 刷写进度</div>
|
||||
<div class="card-head"><span class="ico">⚡</span> 刷写进度与流程阶段</div>
|
||||
<div class="card-body">
|
||||
<!-- Phase step indicators -->
|
||||
<div class="progress-phase-row">
|
||||
@@ -126,11 +195,45 @@
|
||||
<span id="progressPct">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:14px"></div>
|
||||
<!-- ESP32 OTA stages -->
|
||||
<div class="stage-flow" id="esp32Stages" style="display:none;">
|
||||
<div class="stage-node" id="es1"><div class="stage-dot">1</div><div class="stage-label">固件上传</div><div class="stage-detail" id="esd1"></div></div>
|
||||
<div class="stage-node" id="es2"><div class="stage-dot">2</div><div class="stage-label">OTA 写入</div><div class="stage-detail" id="esd2"></div></div>
|
||||
<div class="stage-node" id="es3"><div class="stage-dot">3</div><div class="stage-label">SHA-256 校验</div><div class="stage-detail" id="esd3"></div></div>
|
||||
<div class="stage-node" id="es4"><div class="stage-dot">4</div><div class="stage-label">应用固件</div><div class="stage-detail" id="esd4"></div></div>
|
||||
<div class="stage-node" id="es5"><div class="stage-dot">5</div><div class="stage-label">重启中</div><div class="stage-detail" id="esd5"></div></div>
|
||||
<div class="stage-node" id="es6"><div class="stage-dot">6</div><div class="stage-label">启动完成</div><div class="stage-detail" id="esd6"></div></div>
|
||||
</div>
|
||||
<!-- BLE OTA stages -->
|
||||
<div class="stage-flow" id="bleStages" style="display:none;">
|
||||
<div class="stage-node" id="bs1"><div class="stage-dot">1</div><div class="stage-label">BLE 连接</div><div class="stage-detail" id="bsd1"></div></div>
|
||||
<div class="stage-node" id="bs2"><div class="stage-dot">2</div><div class="stage-label">GATT 准备</div><div class="stage-detail" id="bsd2"></div></div>
|
||||
<div class="stage-node" id="bs3"><div class="stage-dot">3</div><div class="stage-label">文件准备</div><div class="stage-detail" id="bsd3"></div></div>
|
||||
<div class="stage-node" id="bs4"><div class="stage-dot">4</div><div class="stage-label">数据写入</div><div class="stage-detail" id="bsd4"></div></div>
|
||||
<div class="stage-node" id="bs5"><div class="stage-dot">5</div><div class="stage-label" id="bsl5">校验比对</div><div class="stage-detail" id="bsd5"></div></div>
|
||||
<div class="stage-node" id="bs6"><div class="stage-dot">6</div><div class="stage-label" id="bsl6">完成触发</div><div class="stage-detail" id="bsd6"></div></div>
|
||||
</div>
|
||||
<!-- GD32 OTA stages -->
|
||||
<div class="stage-flow" id="gd32Stages">
|
||||
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">认证握手</div><div class="stage-detail" id="sd1"></div></div>
|
||||
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输协商</div><div class="stage-detail" id="sd2"></div></div>
|
||||
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div><div class="stage-detail" id="sd3"></div></div>
|
||||
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">传输完成</div><div class="stage-detail" id="sd4"></div></div>
|
||||
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">OTA 刷写</div><div class="stage-detail" id="sd5"></div></div>
|
||||
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">校验验证</div><div class="stage-detail" id="sd6"></div></div>
|
||||
</div>
|
||||
<div class="stage-status-bar" id="stageStatusBar">
|
||||
<span class="ssb-dot" id="stageStatusDot"></span>
|
||||
<span class="ssb-text" id="stageStatusText">待机</span>
|
||||
</div>
|
||||
<div class="stage-verify-info" id="stageVerifyInfo" style="display:none;">
|
||||
<div class="svi-row"><span class="svi-key">前端 SHA-256</span><span class="svi-val" id="sviFrontSha">-</span></div>
|
||||
<div class="svi-row"><span class="svi-key">设备 SHA-256</span><span class="svi-val" id="sviDeviceSha">-</span></div>
|
||||
<div class="svi-row"><span class="svi-key">校验结果</span><span class="svi-val" id="sviResult">-</span></div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn btn-outline btn-block" onclick="restartEsp32()">重启 ESP32</button>
|
||||
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260614-ota-order-fix" target="_blank" style="margin-top:10px;text-decoration:none;">
|
||||
切换到 BLE 蓝牙刷写 ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,6 +256,14 @@
|
||||
<div class="v warn" id="bOTAProgress">0%</div>
|
||||
<div class="k">OTA 进度</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v err" id="connBadge">offline</div>
|
||||
<div class="k">BLE 连接</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v" id="gattState" style="font-size:16px;">未就绪</div>
|
||||
<div class="k">GATT 状态</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,11 +274,8 @@
|
||||
<div class="card-body">
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><div class="k">温度</div><div class="v" id="mTemp">--</div></div>
|
||||
<div class="metric"><div class="k">WiFi / IP</div><div class="v" id="mWifi" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">信号强度</div><div class="v" id="mRSSI">--</div></div>
|
||||
<div class="metric"><div class="k">空闲堆</div><div class="v" id="mHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">最低空闲堆</div><div class="v" id="mMinHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">ESP32 版本</div><div class="v" id="mVersion" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">OTA 缓存</div><div class="v" id="mOtaCache" style="font-size:13px;">--</div></div>
|
||||
@@ -175,43 +283,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OTA stage flow -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">🔀</span> OTA 流程阶段</div>
|
||||
<div class="card-body">
|
||||
<!-- ESP32 OTA stages -->
|
||||
<div class="stage-flow" id="esp32Stages" style="display:none;">
|
||||
<div class="stage-node" id="es1"><div class="stage-dot">1</div><div class="stage-label">固件上传</div><div class="stage-detail" id="esd1"></div></div>
|
||||
<div class="stage-node" id="es2"><div class="stage-dot">2</div><div class="stage-label">OTA 写入</div><div class="stage-detail" id="esd2"></div></div>
|
||||
<div class="stage-node" id="es3"><div class="stage-dot">3</div><div class="stage-label">SHA-256 校验</div><div class="stage-detail" id="esd3"></div></div>
|
||||
<div class="stage-node" id="es4"><div class="stage-dot">4</div><div class="stage-label">应用固件</div><div class="stage-detail" id="esd4"></div></div>
|
||||
<div class="stage-node" id="es5"><div class="stage-dot">5</div><div class="stage-label">重启中</div><div class="stage-detail" id="esd5"></div></div>
|
||||
<div class="stage-node" id="es6"><div class="stage-dot">6</div><div class="stage-label">启动完成</div><div class="stage-detail" id="esd6"></div></div>
|
||||
</div>
|
||||
<!-- GD32 OTA stages -->
|
||||
<div class="stage-flow" id="gd32Stages">
|
||||
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">认证握手</div><div class="stage-detail" id="sd1"></div></div>
|
||||
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输协商</div><div class="stage-detail" id="sd2"></div></div>
|
||||
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div><div class="stage-detail" id="sd3"></div></div>
|
||||
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">传输完成</div><div class="stage-detail" id="sd4"></div></div>
|
||||
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">OTA 刷写</div><div class="stage-detail" id="sd5"></div></div>
|
||||
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">校验验证</div><div class="stage-detail" id="sd6"></div></div>
|
||||
</div>
|
||||
<div class="stage-status-bar" id="stageStatusBar">
|
||||
<span class="ssb-dot" id="stageStatusDot"></span>
|
||||
<span class="ssb-text" id="stageStatusText">待机</span>
|
||||
</div>
|
||||
<div class="stage-verify-info" id="stageVerifyInfo" style="display:none;">
|
||||
<div class="svi-row"><span class="svi-key">前端 SHA-256</span><span class="svi-val" id="sviFrontSha">-</span></div>
|
||||
<div class="svi-row"><span class="svi-key">设备 SHA-256</span><span class="svi-val" id="sviDeviceSha">-</span></div>
|
||||
<div class="svi-row"><span class="svi-key">校验结果</span><span class="svi-val" id="sviResult">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAN panel -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">📨</span> CAN 帧发送</div>
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">📨</span> CAN 帧发送
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="can-grid">
|
||||
<div class="form-row" style="margin:0"><label class="form-label">通道</label>
|
||||
@@ -240,8 +317,11 @@
|
||||
</div>
|
||||
|
||||
<!-- SPI panel -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">⚡</span> SPI 发送</div>
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">⚡</span> SPI 发送
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||
<input class="input" type="text" id="spiType" value="01" maxlength="2" placeholder="type" title="帧类型 (hex)" style="width:52px;flex:none;">
|
||||
@@ -295,6 +375,252 @@
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260614f"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
esp32: { file: null, handle: null },
|
||||
gd32: { file: null, handle: null }
|
||||
};
|
||||
(function () {
|
||||
var originalSwitchStageFlow = window.switchStageFlow;
|
||||
window.switchStageFlow = function (target) {
|
||||
if (target === 'ble') {
|
||||
window.currentOtaTarget = 'ble';
|
||||
var esp32El = document.getElementById('esp32Stages');
|
||||
var gd32El = document.getElementById('gd32Stages');
|
||||
var bleEl = document.getElementById('bleStages');
|
||||
if (esp32El) esp32El.style.display = 'none';
|
||||
if (gd32El) gd32El.style.display = 'none';
|
||||
if (bleEl) bleEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
if (typeof originalSwitchStageFlow === 'function') {
|
||||
originalSwitchStageFlow(target);
|
||||
}
|
||||
var bleEl = document.getElementById('bleStages');
|
||||
if (bleEl) bleEl.style.display = 'none';
|
||||
};
|
||||
|
||||
window.setConsoleMode = function (mode) {
|
||||
var isBle = mode === 'ble';
|
||||
window.connectionMode = isBle ? 'ble' : 'wifi';
|
||||
var wifiCards = document.getElementById('wifiModeCards');
|
||||
var bleCards = document.getElementById('bleModeCards');
|
||||
var wifiBtn = document.getElementById('modeWifiBtn');
|
||||
var bleBtn = document.getElementById('modeBleBtn');
|
||||
if (wifiCards) wifiCards.style.display = isBle ? 'none' : '';
|
||||
if (bleCards) bleCards.style.display = isBle ? '' : 'none';
|
||||
var headBtns = document.getElementById('bleHeadActions');
|
||||
if (headBtns) headBtns.style.display = isBle ? '' : 'none';
|
||||
if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary';
|
||||
if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline';
|
||||
applySharedModeUi();
|
||||
syncBleFileFromSelectedTarget();
|
||||
if (isBle) {
|
||||
window.switchStageFlow('ble');
|
||||
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
|
||||
window.bleOta.refreshButtons();
|
||||
}
|
||||
} else {
|
||||
window.switchStageFlow(window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32');
|
||||
}
|
||||
};
|
||||
|
||||
window.selectFlashTarget = function (target) {
|
||||
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
|
||||
applySharedModeUi();
|
||||
syncBleFileFromSelectedTarget();
|
||||
if (window.connectionMode === 'ble') {
|
||||
window.switchStageFlow('ble');
|
||||
} else {
|
||||
window.switchStageFlow(window.flashTarget);
|
||||
}
|
||||
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
|
||||
window.bleOta.refreshTargetUi();
|
||||
}
|
||||
};
|
||||
|
||||
window.handleSharedFileSelect = function (event, chip) {
|
||||
var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null;
|
||||
if (file) {
|
||||
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
|
||||
}
|
||||
if (typeof window.handleFileSelect === 'function') {
|
||||
window.handleFileSelect(event, chip);
|
||||
}
|
||||
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
|
||||
applySharedModeUi();
|
||||
setTimeout(syncBleFileFromSelectedTarget, 0);
|
||||
};
|
||||
|
||||
window.pickSharedFirmware = function (chip) {
|
||||
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
|
||||
applySharedModeUi();
|
||||
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
|
||||
var nativePicker = window.showOpenFilePicker;
|
||||
var capturedHandle = null;
|
||||
window.showOpenFilePicker = function () {
|
||||
return nativePicker.apply(this, arguments).then(function (handles) {
|
||||
capturedHandle = handles && handles.length ? handles[0] : null;
|
||||
return handles;
|
||||
});
|
||||
};
|
||||
Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () {
|
||||
window.showOpenFilePicker = nativePicker;
|
||||
if (!capturedHandle || typeof capturedHandle.getFile !== 'function') {
|
||||
syncBleFileFromSelectedTarget();
|
||||
return;
|
||||
}
|
||||
capturedHandle.getFile().then(function (file) {
|
||||
window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle };
|
||||
syncBleFileFromSelectedTarget();
|
||||
}).catch(function () {
|
||||
syncBleFileFromSelectedTarget();
|
||||
});
|
||||
}).catch(function (err) {
|
||||
window.showOpenFilePicker = nativePicker;
|
||||
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') {
|
||||
window.addLog('W', '文件', 'FSAPI 选择失败: ' + err.message);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32');
|
||||
if (input) input.click();
|
||||
}
|
||||
};
|
||||
|
||||
window.clearSharedFirmware = function (chip) {
|
||||
window.sharedFirmwareFiles[chip] = { file: null, handle: null };
|
||||
if (typeof window.clearFile === 'function') {
|
||||
window.clearFile(chip);
|
||||
}
|
||||
if (window.connectionMode === 'ble' && window.flashTarget === chip && window.bleOta && typeof window.bleOta.clearFile === 'function') {
|
||||
window.bleOta.clearFile();
|
||||
}
|
||||
};
|
||||
|
||||
window.setBleFirmwareInputFile = function (file) {
|
||||
var input = document.getElementById('firmwareFile');
|
||||
if (!input || !file || typeof window.DataTransfer !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
} catch (err) {
|
||||
// Some browsers restrict assignment to file inputs; BLE state is still synced via notifyFileChange.
|
||||
}
|
||||
};
|
||||
|
||||
window.syncBleFileFromSelectedTarget = function () {
|
||||
if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
var isGd32 = window.flashTarget === 'gd32';
|
||||
var chip = isGd32 ? 'gd32' : 'esp32';
|
||||
var suffix = isGd32 ? 'Gd32' : 'Esp32';
|
||||
var stored = window.sharedFirmwareFiles[chip];
|
||||
if (stored && stored.handle && typeof stored.handle.getFile === 'function') {
|
||||
stored.handle.getFile().then(function (file) {
|
||||
stored.file = file;
|
||||
setBleFirmwareInputFile(file);
|
||||
window.bleOta.notifyFileChange(file, stored.handle);
|
||||
}).catch(function () {
|
||||
if (stored.file) {
|
||||
setBleFirmwareInputFile(stored.file);
|
||||
window.bleOta.notifyFileChange(stored.file, stored.handle);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (stored && stored.file) {
|
||||
setBleFirmwareInputFile(stored.file);
|
||||
window.bleOta.notifyFileChange(stored.file, stored.handle || null);
|
||||
return;
|
||||
}
|
||||
var input = document.getElementById('fileInput' + suffix);
|
||||
var info = document.getElementById('fileInfo' + suffix);
|
||||
var hasInfo = info && info.classList.contains('show');
|
||||
var file = input && input.files && input.files[0] ? input.files[0] : null;
|
||||
if (file) {
|
||||
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
|
||||
setBleFirmwareInputFile(file);
|
||||
window.bleOta.notifyFileChange(file, null);
|
||||
return;
|
||||
}
|
||||
if (!hasInfo) {
|
||||
return;
|
||||
}
|
||||
var nameEl = document.getElementById('fileName' + suffix);
|
||||
var typeEl = document.getElementById('fileType' + suffix);
|
||||
var sizeEl = document.getElementById('fileSize' + suffix);
|
||||
var bleInfo = document.getElementById('fileInfo');
|
||||
var bleName = document.getElementById('fileName');
|
||||
var bleType = document.getElementById('fileType');
|
||||
var bleSize = document.getElementById('fileSize');
|
||||
if (bleInfo) bleInfo.classList.add('show');
|
||||
if (bleName && nameEl) bleName.textContent = nameEl.textContent;
|
||||
if (bleType && typeEl) bleType.textContent = typeEl.textContent;
|
||||
if (bleSize && sizeEl) bleSize.textContent = sizeEl.textContent;
|
||||
var packetCount = document.getElementById('packetCount');
|
||||
var sentCount = document.getElementById('sentCount');
|
||||
if (packetCount) packetCount.textContent = '-';
|
||||
if (sentCount) sentCount.textContent = '0';
|
||||
};
|
||||
|
||||
window.attachSharedFirmwareDropBridge = function () {
|
||||
['esp32', 'gd32'].forEach(function (chip) {
|
||||
var zone = document.getElementById(chip === 'gd32' ? 'uploadZoneGd32' : 'uploadZoneEsp32');
|
||||
if (!zone) return;
|
||||
zone.addEventListener('drop', function (event) {
|
||||
var file = event && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0] ? event.dataTransfer.files[0] : null;
|
||||
if (file) {
|
||||
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
|
||||
}
|
||||
window.flashTarget = chip;
|
||||
applySharedModeUi();
|
||||
setTimeout(syncBleFileFromSelectedTarget, 120);
|
||||
}, true);
|
||||
});
|
||||
};
|
||||
|
||||
window.applySharedModeUi = function () {
|
||||
var isBle = window.connectionMode === 'ble';
|
||||
var isGd32 = window.flashTarget === 'gd32';
|
||||
var targetEsp32 = document.getElementById('targetEsp32Tab');
|
||||
var targetGd32 = document.getElementById('targetGd32Tab');
|
||||
if (targetEsp32) targetEsp32.className = isGd32 ? 'btn btn-outline' : 'btn btn-primary';
|
||||
if (targetGd32) targetGd32.className = isGd32 ? 'btn btn-primary' : 'btn btn-outline';
|
||||
var ids = {
|
||||
wifiEsp32UploadPane: !isGd32,
|
||||
wifiGd32UploadPane: isGd32,
|
||||
bleUploadPane: false,
|
||||
wifiEsp32ActionPane: !isBle && !isGd32,
|
||||
wifiGd32ActionPane: !isBle && isGd32,
|
||||
bleActionPane: isBle
|
||||
};
|
||||
Object.keys(ids).forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.style.display = ids[id] ? '' : 'none';
|
||||
});
|
||||
var hint = document.getElementById('readyHint');
|
||||
if (hint) {
|
||||
if (isBle) {
|
||||
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
|
||||
} else if (isGd32) {
|
||||
hint.innerHTML = 'WiFi 模式:上传 GD32 固件到缓存后,再触发 SPI OTA 中继。';
|
||||
} else {
|
||||
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
applySharedModeUi();
|
||||
})();
|
||||
</script>
|
||||
<script src="ble_ota.js?v=20260614f"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+3
-2
@@ -9,8 +9,9 @@ echo ESP32 COMM Host Server
|
||||
echo PORT: %PORT%
|
||||
echo ========================================
|
||||
echo.
|
||||
echo WiFi OTA: http://localhost:%PORT%/index.html
|
||||
echo BLE OTA: http://localhost:%PORT%/ble_ota.html
|
||||
echo OTA Console: http://localhost:%PORT%/index.html
|
||||
echo.
|
||||
echo Switch WiFi / BLE mode in the "Device Connect" card.
|
||||
echo.
|
||||
echo Press Ctrl+C to stop
|
||||
echo ========================================
|
||||
|
||||
@@ -117,6 +117,13 @@ body::before {
|
||||
}
|
||||
.card-head .ico { font-size: 15px; }
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card.collapsed .card-body { display: none; }
|
||||
.card-head { cursor: pointer; }
|
||||
.card-head .collapse-arrow {
|
||||
margin-left: auto; font-size: 10px; opacity: .45;
|
||||
transition: transform .2s;
|
||||
}
|
||||
.card.collapsed .collapse-arrow { transform: rotate(-90deg); }
|
||||
|
||||
/* === FORM ELEMENTS === */
|
||||
.form-row { margin-bottom: 14px; }
|
||||
@@ -593,6 +600,60 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
/* === PILL LIVE === */
|
||||
.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; }
|
||||
|
||||
/* ===== Header sub-row (设备名/设备ID) ===== */
|
||||
.header-sub {
|
||||
display: flex; gap: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4px;
|
||||
padding: 6px 20px 4px;
|
||||
}
|
||||
.pill-val {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ===== Custom Tooltip ===== */
|
||||
.pill[data-tooltip] {
|
||||
position: relative;
|
||||
cursor: help;
|
||||
}
|
||||
.pill[data-tooltip]:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1a2432;
|
||||
color: #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
font-family: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2a3a4e;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.45);
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
animation: tooltipFadeIn .15s ease-out;
|
||||
}
|
||||
.pill[data-tooltip]:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-bottom-color: #2a3a4e;
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
animation: tooltipFadeIn .15s ease-out;
|
||||
}
|
||||
@keyframes tooltipFadeIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(3px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* === RESPONSIVE === */
|
||||
@media (max-width: 1100px) {
|
||||
.main-layout { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user