Compare commits

..
4 Commits
Author SHA1 Message Date
小龙虾 929faf1d38 refactor: 清理遗留死代码(−353 行)
仓库经多轮迭代积累了不少不再被引用的中间件代码。本次以当前前端依赖的
HTTP 端点为基准(/system/status、/logs、/can/send、/spi/send、/ota/upload、
/ota/update、/ota/relay、/restart),移除以下模块的未引用代码:

- main/transport/protocol.{h,cpp}(−257 行)
  * 移除 CMD_* / ACK_* 宏(CMD_HANDSHAKE_REQ 等 16 个)
  * 移除未引用方法:crc16Modbus、buildFrame、parseFrame、aesEncrypt、
    aesDecrypt、sessionEncrypt、sessionDecrypt、generateRandomNonce
  * 保留 init / scrambleGenerate / sessionKeyInit / sessionKeyClear
    (被 ota_relay 实际调用)

- main/relay/ota_relay.{h,cpp}(−84 行)
  * 移除未引用方法:startHandshake、probeBootHandshake、isStructuredAckWithPayload
  * 移除未使用枚举值:RELAY_HANDSHAKE_IDLE、RELAY_RECEIVING、RELAY_TRANSFER_DONE
  * 移除字段 ota_meta_t.chunk_count 与宏 OTA_META_NVS_KEY

- main/server/wifi_ota.cpp(−255 行)
  * 移除 6 个未被前端调用的 HTTP 端点:/health、/wifi/status、/can/status、
    /ota/status、/ota/esp32_status、/api/version
  * 移除其处理器与路由注册
  * 移除 mbedtls/private/sha256.h 直接引用(SHA-256 已委托 Esp32OtaCore)

- main/server/ble_ota.cpp、main/relay/ota_relay.cpp
  * 同步移除 chunk_count 引用
2026-06-15 00:24:29 +08:00
小龙虾 9ca53ae66e feat(ota): 解耦ESP32 OTA核心逻辑到独立模块并增强BLE控制功能
- 新增ota目录和esp32_ota_core.cpp实现ESP32 OTA核心功能,支持begin/write/finalize操作
- 修改CMakeLists.txt添加ota源文件和包含目录
- 重构ble_ota.cpp将OTA写入/收尾逻辑委托给Esp32OtaCore,移除mbedtls直接引用
- 新增BLE_OTA_CTRL_RESTART_ESP32(0xFA)命令用于重启ESP32设备
- 更新BLE_OTA_CTRL_TRIGGER_ESP32(0xF8)逻辑,要求前端提供SHA-256校验并比对
- 添加SHA-256校验不匹配错误码BLE_OTA_FINISH_ERR_SHA_MISMATCH(0x85)
- 优化OTA收尾流程,设备端读取分区计算SHA-256并与前端提供的哈希比对
- 添加BLE_OTA_NOTIFY_SHA256通知类型用于上报设备端计算的SHA-256值
- 更新app_config.h中的BLE OTA命令描述文档
- 移除BleOta类中的OTA状态变量,仅保留BLE传输层相关状态
- 更新版本构建时间和Git哈希信息
2026-06-14 23:03:28 +08:00
小龙虾 2caf10b9de feat(ota): 添加OTA回滚保护和固件完整性校验功能
- 启用CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE和CONFIG_APP_ROLLBACK_ENABLE配置选项
- 在main.cpp中添加esp_ota_mark_app_valid_cancel_rollback()调用,防止意外回滚
- 实现SHA-256固件完整性校验,包括计算、存储和验证功能
- 添加ota_mutex.h头文件用于ESP32 OTA全局互斥锁管理

feat(health_monitor): 实现线程安全的健康状态管理

- 将HealthMonitor类的健康状态改为私有成员并通过mutex保护
- 添加getState()和setState()方法提供线程安全的状态访问
- 修复多任务并发访问健康状态时的竞争条件问题

feat(ota_relay): 增强中继模块的线程安全性和可靠性

- 为OtaRelay类添加静态mutex保护状态变量访问
- 实现StateLockGuard RAII锁管理器确保异常安全
- 添加全局超时机制防止OTA过程无限等待
- 改进flash写入逻辑支持非4字节对齐数据处理

refactor(app_config): 更新版本信息和配置常量

- 更新BUILD_TIME至最新编译时间
- 更新GIT_HASH至最新提交
- 添加BLE_OTA_CTRL_VERIFY_SHA256命令常量定义
2026-06-14 17:10:24 +08:00
小龙虾 6a6bc9dab4 feat(version): 更新固件版本号至 0.3.12,修正编译时间和 Git 哈希
feat(ble): 增加 BLE OTA 窗口确认机制,优化数据包处理
fix(html): 更新链接以在新标签页中打开,改善用户体验
2026-06-13 01:24:11 +08:00
40 changed files with 6747 additions and 1950 deletions
+503 -87
View File
@@ -1,149 +1,565 @@
# ESP32-C3 OTA Relay
ESP32-C3FH4 OTA 中继固件,通过 WiFi 接收上位机 OTA 固件包,通过 SPI 转发给下位机(GD32)。
**固件版本: v0.3.12** | 构建时间: 2026-06-14 | 基于 ESP-IDF v6.0
## 项目概述
ESP32-C3FH4 OTA 中继固件,作为 **GD32E508VET6** 的 OTA 升级桥梁。通过 **WiFi (HTTP)** 或 **BLE (GATT)** 从上位机(手机/PC)接收固件,存储在本地 SPI Flash 的 `gd32_ota` 分区,再通过 **SPI 总线** 经四阶段安全握手协议转发给 GD32。
```
[手机/PC 上位机] --WiFi/BLE--> [ESP32-C3FH4] --SPI--> [GD32E508VET6]
|
+-- gd32_ota 分区 (252KB)
+-- 健康自检 (温度 + WiFi RSSI)
+-- 本机 OTA (ESP32 自升级)
```
## 硬件平台
- **芯片:** ESP32-C3FH4 (RISC-V, 单核)
- **Flash:** 4MB
- **编译工具链:** ESP-IDF v6.0, riscv32-esp-elf
| 项目 | 规格 |
|------|------|
| **芯片** | ESP32-C3FH4 (RISC-V, 单核, 160MHz) |
| **Flash** | 4MB |
| **SPI 引脚** | MOSI=GPIO5, MISO=GPIO7, SCLK=GPIO6, CS=GPIO4 |
| **SPI 速率** | 4MHz, Mode 0 |
| **UART 调试** | TX=GPIO21, RX=GPIO20, 115200 baud |
| **编译工具链** | ESP-IDF v6.0, riscv32-esp-elf |
### 分区表 (`config/partitions.csv`)
| 分区 | 类型 | 偏移 | 大小 | 用途 |
|------|------|------|------|------|
| nvs | data/nvs | 0x9000 | 24KB | NVS 存储 |
| otadata | data/ota | 0xF000 | 8KB | OTA 启动信息 |
| gd32_ota | data/spiffs | 0x11000 | 252KB | GD32 固件缓存 |
| app0 | app/ota_0 | 0x50000 | 1.25MB | ESP32 OTA 槽位 0 |
| app1 | app/ota_1 | 0x190000 | 1.25MB | ESP32 OTA 槽位 1 |
| spiffs | data/spiffs | 0x2D0000 | 1.06MB | 文件系统 |
## 功能模块
| 模块 | 描述 |
|------|------|
| `health_monitor` | 温度 + WiFi RSSI 监控 |
| `ota_relay` | OTA 数据包接收与转发 |
| `wifi_ota` | WiFi OTA HTTP 服务器 + CAN 帧转发 |
| `spi_master` | SPI 主站,与 GD32 通信 |
| `protocol` | CRC16 校验 + AES-128-CBC 解密(硬件加速) |
| 模块 | 文件 | 描述 |
|------|------|------|
| **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 状态) |
| **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 会话密钥管理) |
## 目录结构
```
ESP32_COMM/
├── CMakeLists.txt
├── CMakeLists.txt # 顶层 CMake 项目定义
├── sdkconfig.defaults # SDK 配置默认值 (WiFi RX/TX BA=6)
├── config/
│ └── partitions.csv # 4MB Flash 分区表
├── cmake/
│ └── gen_version_script.cmake # 构建时自动生成 version.h
├── main/
│ ├── CMakeLists.txt
│ ├── CMakeLists.txt # 注册 11 个源文件 + 依赖
│ ├── include/
│ │ └── app_config.h # WiFi / OTA 参数配置
│ ├── core/main.cpp # 任务创建入口
│ ├── relay/ota_relay.cpp # OTA 中继逻辑
│ │ ├── 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 核心
│ ├── transport/
│ │ ├── spi_master.cpp # SPI 主站 + CANFD 3通道发送
│ │ └── protocol.cpp # 协议层 (CRC16 / AES-128-CBC)
│ │ ├── spi_master.{h,cpp} # SPI 主站 + CANFD 3 通道
│ │ └── protocol.{h,cpp} # 协议层 (CRC16 / AES / nonce)
│ ├── server/
│ │ └── wifi_ota.cpp # WiFi OTA HTTP 服务器
│ │ ├── wifi_ota.{h,cpp} # WiFi OTA HTTP 服务器
│ │ └── ble_ota.{h,cpp} # BLE GATT OTA 服务器
│ └── monitor/
│ ├── health_monitor.cpp # 健康监控
│ └── debug_log.cpp # UDP 调试日志
└── tools/
└── ota_host_web.html # 上位机 WebUI(CAN 发送 + OTA 上传)
│ ├── health_monitor.{h,cpp} # 温度 + RSSI 健康监控
│ └── debug_log.{h,cpp} # UART0 + UDP + 环形缓冲区日志
├── components/
│ └── cjson/ # cJSON 组件
├── managed_components/
│ └── espressif__mdns/ # mDNS 组件
├── tools/
│ ├── index.html # WiFi OTA WebUI 控制台
│ ├── ble_ota.html # BLE OTA WebUI 控制台 (Web Bluetooth)
│ ├── app.js # WebUI JavaScript
│ ├── style.css # WebUI 样式
│ ├── serve.bat # Python HTTP 服务器启动脚本 (端口 8080)
│ ├── serial_monitor.py # 串口日志采集工具 (COM15)
│ └── ota_upload_test.py # OTA 上传测试脚本
├── docs/
│ ├── esp32_ota_relay_design.md # 架构设计文档
│ ├── DEVELOPMENT_GUIDE.md # 开发者指南 (T0-T8)
│ └── COMMUNICATION_AUDIT_2026-05-05.md # 通信审计
├── .vscode/ # VS Code 调试/编译配置 (COM15, clangd)
└── .devcontainer/ # Docker 开发容器 (ESP-IDF 6.0)
```
## WiFi 工作模式
## FreeRTOS 任务
支持 AP / STA 两种模式,通过 `app_config.h` 配置:
4 个主任务全部固定运行在 ESP32-C3 单核(核 0),通过 FreeRTOS 队列通信:
```c
// AP 模式(ESP32 当热点)
CONFIG_WIFI_MODE_IS_STA = 0
WIFI_AP_SSID = "ESP32_OTA_RELAY" // 默认热点名称
WIFI_AP_PASSWORD = "12345678" // 默认密码
// 上位机连接热点后访问: http://192.168.31.1
| 任务名 | 函数 | 优先级 | 栈大小 | 职责 |
|--------|------|--------|--------|------|
| `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 转发 |
// STA 模式(连接路由器)
CONFIG_WIFI_MODE_IS_STA = 1
WIFI_STA_SSID = "004舰桥" // 路由器名称
WIFI_STA_PASSWORD = "q13362875622" // 路由器密码
// 上位机和 ESP32 在同一局域网,通过 DHCP 分配的 IP 访问
额外后台任务:
| 任务名 | 优先级 | 栈大小 | 职责 |
|--------|--------|--------|------|
| `wifi_reconnect` | 5 | 4096 | STA 断线重连(退避算法,独立任务避免死锁)|
| `ble_ota_finish` | 5 | 4096 | BLE ESP32 OTA 异步收尾(SHA 校验 + end + reboot)|
| `nimble_host` | NimBLE 内部 | NimBLE 内部 | BLE 协议栈事件循环 |
### 跨任务通信
- **OTA 调度**: WiFi/BLE 任务通过 `OtaRelay::dispatchQueue` (FreeRTOS 队列, 深度 4) 发送调度信号给 `ota_relay` 任务;结果通过 `resultQueue` 回传
- **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 连接状态和重连计数器
- **健康状态**: `HealthMonitor::m_mutex` 保护 healthState/temperature/RSSI
- **UDP 日志**: `DebugLog::m_logRingMutex` 保护 HTTP `/logs` 环形缓冲区
## OTA 核心流程
### 一、GD32 OTA 中继(四阶段 + APP 验证)
上位机上传 GD32 固件到 ESP32 本地 `gd32_ota` 分区 → 收到触发命令后启动状态机:
```
阶段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)
```
## CANFD 3通道 SPI 协议
#### 安全握手协议
GD32 通信帧格式:`[通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]`
1. **握手请求**: ESP32 发送 `0x00 0xAC`(最多重试 10 次,200ms 超时)
2. **Challenge**: GD32 回复 36B 数据包(12B UID + 8B nonce 片段 + 16B 加密会话密钥)
3. **验证**: ESP32 验证 nonce 匹配,用 MasterKey 派生 `K_session = AES(MasterKey, nonce_server)` 并比对
4. **确认**: ESP32 发送 `nonce_server ^ 0x55` 经 `K_session` AES-ECB 加密
5. **完成**: GD32 回复 `0xBA 0xC0`,建立 AES 加密通道
#### 数据包转发
- 每包 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 分钟
### 二、ESP32 本机 OTA(WiFi + BLE 共用)
核心逻辑封装在 `Esp32OtaCore` 单例中,WiFi 和 BLE 通道共用:
| 步骤 | 方法 | 说明 |
|------|------|------|
| 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` → 可选重启 |
| - | `abort()` | 中止并清理(释放 mutex + 复位状态) |
**SHA-256 校验**: 固件写入完成后从 OTA 分区读回计算,不做增量 SHA。前端与设备端比对一致才 set_boot,否则返回 `ESP_ERR_INVALID_VERSION`。
**回滚保护**: `esp_ota_mark_app_valid_cancel_rollback()` 在启动时标记当前固件有效。启用 `CONFIG_APP_ROLLBACK_ENABLE` 后,新固件崩溃自动回退。
## 通信协议
### SPI OTA 帧格式(底层传输)
`esp32_ota_core.cpp` 中的 `otaBuildFrame` / `otaParseFrame`:
```
| SOF(0xA5, 1B) | TYPE(1B) | LEN(2B LE) | PAYLOAD(LEN) | CRC8(1B) |
```
| TYPE | 名称 | 用途 |
|------|------|------|
| 0x01 | OTA_CMD_REQ | OTA 请求帧 |
| 0x02 | OTA_CMD_DATA | OTA 数据帧 |
| 0x03 | OTA_CMD_CANCEL | 取消 OTA |
| 0xB0 | APP_ENTER_BOOT_CMD | 通知 GD32 APP 进入 Bootloader |
| 0x80 | RSP_ACK | 响应:数据就绪 |
| 0x81 | RSP_WAIT | 响应:GD32 忙,请等待 |
| 0x82 | RSP_ERROR | 响应:错误 |
### CANFD 3 通道 SPI 协议
ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器):
```
| 通道编号(1B) | 扩展帧ID(4B BE) | 数据(1~64B) |
```
| 通道 | 用途 |
|------|------|
| CH0 | 主通信通道 |
| CH1 | 备用通道 1 |
| CH2 | 备用通道 2 |
| CH1 | 备用通道 |
| CH2 | 备用通道 |
### GD32 命令 ID 定义
| 域 | 命令 | ID |
|---|---|---|
|------|------|------|
| 系统 | PING / RESET / GET_VERSION / GET_STATUS / SET_STATUS | 0x0001~0x0005 |
| Flash | ERASE / WRITE / READ / VERIFY | 0x1001~0x1004 |
| OTA | START / DATA / END / ABORT | 0x2001~0x2004 |
| 传感器 | READ / CONFIG | 0x3001~0x3002 |
| Bootloader | ENTER / JUMP_APP | 0xF001~0xF002 |
## 上位机 WebUI
## WiFi 工作模式
`tools/ota_host_web.html` — 浏览器打开即可使用,无需安装。
支持 AP/STA 双模式,启动时优先 STA,超时自动回退 AP。
### HTTP API
### AP 模式(设备当热点)
| 方法 | 路径 | 功能 |
|------|------|------|
| POST | `/ota/upload` | 上传固件 (.bin) |
| POST | `/ota/relay` | 触发 OTA 中继 |
| GET | `/ota/status` | 查询 OTA 状态 |
| POST | `/can/send` | 发送 CAN 帧到 GD32 |
| GET | `/can/status` | CAN 通道状态 |
| GET | `/health` | ESP32 健康状态 |
| GET | `/wifi/status` | WiFi 连接状态 |
| 参数 | 值 |
|------|------|
| SSID | `ESP32_OTA_RELAY` |
| 密码 | `12345678` |
| IP | 192.168.31.1 |
| 信道 | 6 |
| 最大客户端 | 4 |
### CAN 帧发送示例
### STA 模式(连接路由器)
| 参数 | 值 |
|------|------|
| SSID | `004舰桥` |
| 连接超时 | 30 秒 |
| 最大重试 | 5 次(超限后自动切换 AP 模式) |
| IP 分配 | DHCP(可切换静态 IP 192.168.31.32)|
| 国家码 | CN(允许信道 1-13)|
**冷启动优化**: 复位原因为 POWERON/BROWNOUT 时,清除 WiFi NVS 缓存 + PHY 校准数据,增加 500ms 启动延迟,防止长时间断电后校准残留导致连接失败。
### mDNS 发现
设备在局域网内通过 `esp32-ota-relay.local` 可发现(需 `managed_components/espressif__mdns` 组件)。
## BLE GATT 服务
基于 **NimBLE** 协议栈,设备名 `ESP32-OTA-RELAY`,ATT MTU 512 字节,支持 GD32 和 ESP32 两种 OTA 目标。
| UUID | 特征 | 属性 | 用途 |
|------|------|------|------|
| 0xFFFF | Service | - | OTA GATT 服务 |
| 0x0001 | CTRL | Write | 控制命令:`0xF7`=触发GD32中继, `0xF8+32B SHA`=ESP32 OTA, `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 | 固件版本字符串 |
### BLE 数据传输
- **ACK 窗口**: 每 32 包等待一次 DATA_OUT ACK(带累计字节数验证)
- **窗口重传**: ACK 超时或字节数不足时重发整个窗口(最多 3 次)
- **ESP32 收尾**: BLE 传输完成后由前端发 `0xF8 + 32B 文件 SHA-256`,设备端创建异步任务执行 finalize(含 SHA 比对),结果通过 FINISH 事件通知前端
- **GD32 触发**: 传输完成后自动发 `0xF7` 触发 SPI 中继
- **合并包**: NimBLE 可能将多个 writeWithoutResponse 合并到同一 mbuf,接收端支持一次解析多个数据包
## HTTP API (WiFi WebUI)
| 方法 | 端点 | 功能 | 请求体 | 响应 |
|------|------|------|--------|------|
| 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 | `/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":".."}` |
| POST | `/restart` | 重启 ESP32 | - | 500ms 后 `esp_restart()` |
### POST /ota/upload 协议
通过 JSON 头中的 `type` 字段自动区分目标:
```json
// ESP32 OTA
{"type": "esp32", "total_size": 524288}
<binary firmware data...>
// GD32 OTA
{"type": "gd32", "total_size": 131072}
<binary firmware data...>
```
- **ESP32 模式**: 直接写入 OTA 分区,进度上报,完成后调用 `/ota/update` 收尾
- **GD32 模式**: 写入 `gd32_ota` 分区 → 擦除 → 计算 SHA-256 → 自动触发 SPI 中继
### curl 示例
```bash
# 查询系统状态
curl http://192.168.31.1/system/status | jq
# 发送 CAN 帧
curl -X POST http://192.168.31.1/can/send \
-H "Content-Type: application/json" \
-d '{"ch": 0, "id": 12345678, "dlc": 8, "data": "0102030405060708"}'
-d '{"ch":0,"id":0x18DA00F1,"dlc":8,"data":"0102030405060708"}'
# SPI 直接发送
curl -X POST http://192.168.31.1/spi/send \
-H "Content-Type: application/json" \
-d '{"type":"01","data":"00AC","timeout_ms":1000}'
# 获取日志
curl http://192.168.31.1/logs
```
## 任务配置 (单核 ESP32-C3)
## WebUI 工具
所有任务固定运行在 CPU 核 0:
### WiFi OTA 控制台 (`tools/index.html`)
| 任务 | 优先级 | 栈大小 | 说明 |
|------|--------|--------|------|
| health_mon | configMAX_PRIORITIES - 4 | 4096 | 健康监控 |
| ota_relay | configMAX_PRIORITIES - 2 | 8192 | OTA 中继 |
| wifi_ota | configMAX_PRIORITIES - 3 | 8192 | WiFi OTA |
- ESP32 固件上传与 OTA 升级(含 SHA-256 前端校验)
- GD32 固件上传(支持 .bin/.hex)与 SPI 中继触发
- 实时 OTA 流程阶段显示(6 阶段进度追踪)
- CAN 帧手动发送面板
- SPI 直接发送面板
- 系统状态监控(温度、RSSI、空闲堆、最小堆、GD32 APP/BOOT 版本、OTA 缓存)
- 调试日志面板(HTTP `/logs` 轮询,按级别过滤)
- 一键重启 ESP32
## 调试日志与上位机通信
### BLE OTA 控制台 (`tools/ble_ota.html`)
当前与上位机相关的通信分成两类:
- **Web Bluetooth API** 连接(需 Chrome/Edge/Android Chromium)
- GD32 与 ESP32 双目标切换
- 一键刷写(自动重连)
- 窗口 ACK 重传机制(最多 3 次重试)
- SHA-256 前端+设备端比对校验(校验面板显示)
- FINISH 阶段事件通知
- 设备重启 + 自动重连(退避 1.6x 指数,最多 12 次)
1. **业务通信:HTTP**
- WebUI 通过 HTTP 接口与 ESP32 通信
- 典型接口:`/system/status`、`/logs`、`/can/send`、`/ota/*`
- 其中日志面板实际通过 **轮询 `GET /logs`** 获取日志
## 健康监控
2. **调试镜像:UART0 + UDP**
- 所有 `DEBUG_*` 日志会同时写到 UART0、UDP、以及内部环形缓冲区
- UDP 目标端口为 `6666`,适合独立调试接收器使用
- 但 **当前 tools WebUI 不直接监听 UDP 6666**
### 健康状态等级
因此,当前 WebUI 场景下:
- `/system/status` 用于在线状态/健康状态展示
- `/logs` 用于日志展示
- 不再需要额外的 `{"t":"hb"}` 假日志心跳包
| 等级 | 名称 | 触发条件 | OTA 影响 |
|------|------|----------|----------|
| 0 | SYS_NORMAL | 正常 | 允许 |
| 1 | SYS_WARNING | 温度 ≥ 80°C 或 RSSI < -75 dBm | 允许(带警告)|
| 2 | SYS_CRITICAL | RSSI < -85 dBm | ESP32 OTA 禁止,SPI 中继允许 |
| 3 | SYS_SAFE_SHUTDOWN | 温度 ≥ 100°C | 所有 OTA 禁止 |
## 编译
### 监控指标
```bash
idf.py set-target esp32c3
idf.py fullclean
idf.py build
idf.py flash monitor
- **温度**: 1 秒定时器读取内部温度传感器(20°C~80°C 量程)
- **WiFi RSSI**: WiFi 任务每 10 秒检查一次
- **空闲堆**: 每 5 秒巡检,低于 10KB 触发警告
### OTA 前健康检查
- `checkBeforeOta()`: 完整检查(温度 + RSSI + WiFi 连接状态)
- `checkBeforeOtaRelay()`: 仅检查温度(中继阶段不依赖 WiFi)
## 诊断日志
三条并行输出路径:
1. **UART0**: GPIO21(TX), GPIO20(RX), 115200 baud — 本地串口调试
2. **UDP 单播**: `192.168.31.3:6666` — 独立调试接收端
3. **环形缓冲区**: 64 槽 × 256 字节,互斥保护 — 供 HTTP `GET /logs` 轮询拉取
日志级别: `E`(错误) / `W`(警告) / `I`(信息) / `D`(调试),运行时可调。
UDP socket 延迟创建(首次 `send()` 时才分配),WiFi 未连接时 `sendto()` 静默跳过。
**当前 WebUI 日志面板通过 HTTP `/logs` 轮询获取日志,不直接监听 UDP 6666。**
## 配置参数 (`main/include/app_config.h`)
| 分类 | 参数 | 默认值 | 说明 |
|------|------|--------|------|
| WiFi | `CONFIG_WIFI_MODE` | 1 (STA) | 0=AP, 1=STA |
| WiFi | `WIFI_STA_SSID` | "004舰桥" | 路由器名称 |
| WiFi | `WIFI_AP_SSID` | "ESP32_OTA_RELAY" | 热点名称 |
| 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 包健康检查 |
| 阈值 | `TEMP_WARNING` | 80°C | 温度警告阈值 |
| 阈值 | `TEMP_CRITICAL` | 100°C | 温度危险阈值 |
| 阈值 | `WIFI_RSSI_CRITICAL` | -85 dBm | RSSI 危险阈值 |
| 调试 | `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 单包载荷 |
## 安全配置
| 参数 | 位置 | 说明 | 状态 |
|------|------|------|------|
| `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 | 运行时从握手包获取 | 仅保留默认占位 |
| `K_session` | 运行时派生 | `AES(MasterKey, nonce_server)` | 每次 OTA 重新生成 |
| SHA-256 | 最终校验 | 从分区读回计算,前端比对 | WiFi/BLE 均已实现 |
## 构建与部署
### 一、本地编译(Windows PowerShell + ESP-IDF v6.0)
首次编译前需设置工具链环境变量(PowerShell):
```powershell
# ── 设置 ESP-IDF v6.0 工具链环境 ──
$env:IDF_VERSION = "6.0"
$env:IDF_PYTHON_ENV_PATH = "C:\Espressif\tools\python\v6.0\venv"
$env:IDF_TOOLS_PATH = "C:\Espressif\tools"
$env:IDF_PATH = "F:\.espressif\v6.0\esp-idf"
$env:ESP_ROM_ELF_DIR = "C:\Espressif\tools\esp-rom-elfs\20241011"
# ── 添加工具链到 PATH ──
$env:PATH = "C:\Espressif\tools\ccache\4.12.1\ccache-4.12.1-windows-x86_64;" +
"C:\Espressif\tools\cmake\4.0.3\bin;" +
"C:\Espressif\tools\esp-clang\esp-20.1.1_20250829\esp-clang\bin;" +
"C:\Espressif\tools\idf-exe\1.0.3\;" +
"C:\Espressif\tools\ninja\1.12.1\;" +
"C:\Espressif\tools\riscv32-esp-elf\esp-15.2.0_20251204\riscv32-esp-elf\bin;" +
"C:\Espressif\tools\riscv32-esp-elf\esp-15.2.0_20251204\riscv32-esp-elf\riscv32-esp-elf\bin;" +
"C:\Espressif\tools\python\v6.0\venv\Scripts;" + $env:PATH
```
## 待确认参数
编译命令:
- AES-128 主密钥 (MASTER_KEY_HERE)
- 搅伴算法种子 (SCRAMBLE_CONST_HERE)
- 芯片唯一码 (CHIP_UID_HERE)
- OTA Service UUID (需与 APP 侧协商)
```powershell
# ── 进入项目目录 ──
Set-Location "G:\Robot_v2\DCU\ESP32_COMM"
# ── 全量编译(自动生成 version.h,确认零 error/warning)──
& "C:\Espressif\tools\python\v6.0\venv\Scripts\python.exe" `
"F:\.espressif\v6.0\esp-idf\tools\idf.py" build
# ── 目标文件 ──
# build/ESP32_COMM.bin ← OTA 烧录用
# build/bootloader/bootloader.bin
# build/partition_table/partition-table.bin
```
### 二、烧录方式
#### 方式 A:串口烧录(首次 / 恢复)
连接 ESP32-C3 到串口(默认 COM15),直接在 PowerShell 中烧录:
```powershell
# 全量烧录(固件 + bootloader + 分区表)
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 flash
# 仅烧录应用程序(分区不变时快速更新)
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 app-flash
# 烧录 + 监控
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 flash monitor
```
#### 方式 B:WiFi OTA 烧录(ESP32 自升级,无需串口线)
前提:ESP32 已通过 WiFi 联网(或 AP 模式),上位机与 ESP32 在同一网络。
**步骤 1 — 启动 WebUI 服务**:
```powershell
# 在项目目录下运行
tools\serve.bat
# 浏览器打开 http://localhost:8080/tools/index.html
```
**步骤 2 — 连接设备**:
- 在页面顶部输入 ESP32 IP 地址(AP 模式为 `192.168.31.1`,STA 模式查路由器分配 IP)
- 点击 **"测试"** 确认连通,状态指示应变为绿色
**步骤 3 — 上传固件**:
- 在 **"ESP32 自身升级"** 卡片中,拖拽或点击选择 `build/ESP32_COMM.bin`
- 页面自动读取文件大小并计算 SHA-256
- 点击 **"上传并升级 ESP32"**
- WebUI 自动执行:上传 → OTA 写入 → SHA-256 校验 → 设置启动分区 → 重启
- 重启后页面连接断开,ESP32 以新固件启动(约 10~15 秒)
> ⚠️ 如果上传后 ESP32 无法正常启动(反复重启),说明固件有严重 bug,此时需用 **串口烧录** 恢复。
#### 方式 C:BLE OTA 烧录(无需 WiFi)
前提:上位机(手机/PC)支持 Web Bluetooth(Chrome / Edge / Android Chromium)。
- 浏览器打开 `tools/ble_ota.html`
- 点击 **"连接设备"**,在设备选择器中选择 `ESP32-OTA-RELAY`
- 选择固件文件,点击 **"一键刷写"**
- 通过 GATT Notify 实时查看进度和 SHA-256 校验结果
### 三、GD32 SPI OTA 中继烧录
GD32 固件通过 ESP32 中转烧录,不需要串口连接 GD32:
**步骤 1** — 在 ESP32 WebUI 的 **"GD32 SPI 中继"** 卡片中上传 GD32 固件(.bin 或 .hex)
**步骤 2** — 上传完成后,系统自动计算 SHA-256 并触发 SPI 中继转发
**步骤 3** — ESP32 通过四阶段安全握手协议将固件逐包转发给 GD32 Bootloader
**步骤 4** — 转发完成后 GD32 APP 启动,ESP32 轮询 PING 确认 APP 存活(最多 15 秒)
> 也可手动点击 **"触发 GD32 OTA 中继"** 按钮重新触发转发。
### 四、Python 辅助工具
```powershell
# 串口日志采集(COM15, 5 分钟)
python serial_monitor.py
# OTA 上传测试(HTTP)
python ota_upload_test.py <esp32_ip> <firmware.bin>
```
### 五、开发容器
`.devcontainer/` 提供 Docker 容器,含 ESP-IDF v6.0 工具链和 riscv32-esp-elf 编译器,支持 VS Code Remote-Containers 开箱即用。
## SDK 配置 (`sdkconfig.defaults`)
```ini
CONFIG_ESP_WIFI_RX_BA_WIN=6
CONFIG_ESP_WIFI_TX_BA_WIN=6
CONFIG_LWIP_MAX_SOCKETS=10
CONFIG_LWIP_TCP_WND_DEFAULT=11480
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
```
## OTA 回滚保护
启用 `CONFIG_APP_ROLLBACK_ENABLE` 后:
1. 新固件首次启动必须在主循环前调用 `esp_ota_mark_app_valid_cancel_rollback()`
2. 若新固件在标记前崩溃/重启,bootloader 自动回退到上一个有效分区
3. `main.cpp:140` 在任务创建完成后执行此标记
## 固件版本管理
位于 `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` 获取
- 输出到 `main/include/version.h`,仅在内容变化时写入以避免不必要的重编译
+1314
View File
File diff suppressed because it is too large Load Diff
-54
View File
@@ -1,54 +0,0 @@
# gen_version.cmake - 自动生成版本信息头文件
# 包含版本号、编译时间、Git 信息
# 版本号(可手动修改)
set(FW_VERSION_MAJOR 0)
set(FW_VERSION_MINOR 3)
set(FW_VERSION_PATCH 10)
# 获取编译时间
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")
# 获取 Git 信息(如果可用)
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
else()
set(GIT_HASH "unknown")
endif()
# 生成版本头文件
set(VERSION_HEADER_CONTENT
"// Auto-generated version info - DO NOT EDIT
#pragma once
// 固件版本
#define FW_VERSION_MAJOR ${FW_VERSION_MAJOR}
#define FW_VERSION_MINOR ${FW_VERSION_MINOR}
#define FW_VERSION_PATCH ${FW_VERSION_PATCH}
#define FW_VERSION_STRING \"${FW_VERSION_MAJOR}.${FW_VERSION_MINOR}.${FW_VERSION_PATCH}\"
// 编译时间
#define BUILD_TIME \"${BUILD_TIME}\"
// Git 信息
#define GIT_HASH \"${GIT_HASH}\"
")
# 检查文件是否需要更新
if(EXISTS ${VERSION_HEADER_PATH})
file(READ ${VERSION_HEADER_PATH} EXISTING_CONTENT)
if(NOT "${EXISTING_CONTENT}" STREQUAL "${VERSION_HEADER_CONTENT}")
file(WRITE ${VERSION_HEADER_PATH} "${VERSION_HEADER_CONTENT}")
message(STATUS "Version header updated: ${VERSION_HEADER_PATH}")
endif()
else()
file(WRITE ${VERSION_HEADER_PATH} "${VERSION_HEADER_CONTENT}")
message(STATUS "Version header generated: ${VERSION_HEADER_PATH}")
endif()
+1 -1
View File
@@ -8,7 +8,7 @@
# 版本号(可手动修改)
set(FW_VERSION_MAJOR 0)
set(FW_VERSION_MINOR 3)
set(FW_VERSION_PATCH 11)
set(FW_VERSION_PATCH 12)
# 获取编译时间
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")
+2
View File
@@ -53,6 +53,8 @@ CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y
# OTA
CONFIG_ESP_OTA_IMG_VERIFICATION=y
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
CONFIG_APP_ROLLBACK_ENABLE=y
# HTTP server
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
+970
View File
@@ -0,0 +1,970 @@
<!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>&#128203; 项目: ESP32_COMM (ESP32-C3 OTA Relay)</span>
<span>&#128197; 日期: 2026-06-13</span>
<span>&#128268; 固件版本: v0.3.12</span>
<span>&#128101; 审查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">&#128202;</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">&#128680;</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);">&#10004; 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">&#9888; 影响</div>
帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。
</div>
<div class="fix-box">
<div class="fix-label">&#128295; 修复建议</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);">&#10004; 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">&#9888; 影响</div>
攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。
</div>
<div class="fix-box">
<div class="fix-label">&#128295; 修复建议</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);">&#10004; 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">&#9888; 影响</div>
非对齐写入导致 ESP_ERR_INVALID_ARG 或触发 read-modify-write 异常,GD32固件最后一个chunk损坏或丢失。
</div>
<div class="fix-box">
<div class="fix-label">&#128295; 修复建议</div>
flashWriteChunk 内部做4字节对齐对齐(不足部分 read-modify-write),或在上层确保只传对齐参数。
</div>
</div>
</div>
</div>
<!-- Other CRITICAL Findings -->
<div class="section">
<div class="section-title"><span class="icon">&#128308;</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);">&#10004; 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">&#128295; 修复建议</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);">&#10004; 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 &lt; 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">&#128295; 修复建议</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);">&#10004; 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">&#128295; 修复建议</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);">&#10004; 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">&#128295; 修复建议</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);">&#10004; 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">&#128295; 修复建议</div>限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。</div>
</div>
</div>
</div>
<!-- HIGH Findings -->
<div class="section">
<div class="section-title"><span class="icon">&#128992;</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">&#127959;</span> 架构级隐患</div>
<div class="arch-card">
<h4>&#9312; 全局状态泛滥,无封装无同步</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>&#9313; 单通道SPI + 多任务无仲裁</h4>
<p>SpiMaster 单例是唯一的SPI通信通道,但没有任何仲裁机制。WiFi/BLE/健康监控/OTA中继四个功能模块各自直接调用,协议级操作(请求-响应对)不是原子的。需要从架构层面引入SPI总线仲裁器。</p>
</div>
<div class="arch-card">
<h4>&#9314; 安全架构不完整</h4>
<p>认证握手存在但密钥硬编码→形同虚设;固件校验字段存在但未填充→形同虚设;CORS开放→任何人可触发OTA。<strong>安全功能"看起来有"但实际上不提供保护</strong>,这种"安全假象"比没有安全功能更危险,因为它给了虚假的信心。</p>
</div>
</div>
<!-- Priority Fix Order -->
<div class="section">
<div class="section-title"><span class="icon">&#128736;</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">&#9989;</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>
+316
View File
@@ -0,0 +1,316 @@
# 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 | 回归 + 性能 + 长期稳定性 |
+2
View File
@@ -1,6 +1,7 @@
idf_component_register(
SRCS
"core/main.cpp"
"ota/esp32_ota_core.cpp"
"relay/ota_relay.cpp"
"transport/spi_master.cpp"
"transport/protocol.cpp"
@@ -11,6 +12,7 @@
INCLUDE_DIRS
"."
"core"
"ota"
"relay"
"transport"
"server"
+21
View File
@@ -11,6 +11,7 @@
#include "esp_system.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "esp_ota_ops.h"
#include "monitor/health_monitor.h"
#include "relay/ota_relay.h"
@@ -19,15 +20,25 @@
#include "server/ble_ota.h"
#include "transport/protocol.h"
#include "monitor/debug_log.h"
#include "app_config.h"
#include "ota_mutex.h"
/**
* @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。
*/
// 全局 ESP32 OTA 互斥变量(声明在 ota_mutex.h)
SemaphoreHandle_t g_esp32OtaMutex = NULL;
volatile bool g_esp32OtaOccupied = false;
extern "C" void app_main(void)
{
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
// 创建 ESP32 OTA 全局互斥 Mutex
g_esp32OtaMutex = xSemaphoreCreateMutex();
g_esp32OtaOccupied = false;
// 初始化网络接口 + 事件循环(必须最早)
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
@@ -123,6 +134,16 @@ extern "C" void app_main(void)
DEBUG_INFO("MAIN", "BLE OTA 任务已创建");
}
// ★ OTA 回滚保护:标记当前固件有效
// 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数
// 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区
esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback();
if (ota_valid_err == ESP_OK) {
DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效");
} else {
DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动", esp_err_to_name(ota_valid_err));
}
DEBUG_INFO("MAIN", "========================================");
DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中");
DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)",
+4 -1
View File
@@ -1,4 +1,4 @@
/*
/*
* app_config.h - 统一配置文件(C++ 版本)
* ESP32-C3 OTA 中继项目
*
@@ -7,6 +7,7 @@
*/
#pragma once
#include <cstdint>
#include "freertos/FreeRTOS.h"
#include "version.h"
// ============================================================
@@ -95,6 +96,8 @@ inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003;
inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004;
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_GD32 = 0xF7;
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_ESP32 = 0xF8;
inline constexpr uint8_t BLE_OTA_CTRL_VERIFY_SHA256 = 0xF9; // SHA-256哈希验证命令(仅从分区读回算 SHA-256,前端比对后再决定是否触发)
inline constexpr uint8_t BLE_OTA_CTRL_RESTART_ESP32 = 0xFA; // 重启 ESP32 命令(配套前端 expectedReboot 自动重连)
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;
+36
View File
@@ -0,0 +1,36 @@
/*
* ota_mutex.h - ESP32 OTA 全局互斥(BLE / WiFi 通道共享)
*
* 同一时刻只允许一个 OTA 源(BLE 或 WiFi)操作 ESP32 OTA 分区。
* 变量定义 + Mutex 创建在 main.cpp app_main()。
*/
#pragma once
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include <cstdint>
extern SemaphoreHandle_t g_esp32OtaMutex;
extern volatile bool g_esp32OtaOccupied;
// 获取全局 OTA 占用标志(带超时),成功返回 true
// 内部: Take Mutex → 检查标志 → 若空闲则置 true → Give Mutex
static inline bool esp32_ota_mutex_acquire(uint32_t timeout_ms) {
if (!g_esp32OtaMutex) return false;
if (xSemaphoreTake(g_esp32OtaMutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) return false;
if (g_esp32OtaOccupied) {
xSemaphoreGive(g_esp32OtaMutex);
return false;
}
g_esp32OtaOccupied = true;
xSemaphoreGive(g_esp32OtaMutex);
return true;
}
// 释放全局 OTA 占用标志
// 内部: Take Mutex → 清除标志 → Give Mutex
static inline void esp32_ota_mutex_release(void) {
if (!g_esp32OtaMutex) return;
xSemaphoreTake(g_esp32OtaMutex, portMAX_DELAY);
g_esp32OtaOccupied = false;
xSemaphoreGive(g_esp32OtaMutex);
}
+4 -4
View File
@@ -4,11 +4,11 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 11
#define FW_VERSION_STRING "0.3.11"
#define FW_VERSION_PATCH 12
#define FW_VERSION_STRING "0.3.12"
// 编译时间
#define BUILD_TIME "2026-06-03 03:00:47"
#define BUILD_TIME "2026-06-14 23:55:14"
// Git 信息
#define GIT_HASH "a59364e"
#define GIT_HASH "unknown"
+59 -36
View File
@@ -3,6 +3,8 @@
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控
*
* 所有功能封装为 HealthMonitor 类方法。
* 所有对 m_healthState / m_lastTemperature / m_lastWifiRssi 的访问
* 均通过 m_mutex 保护,消除跨任务竞态。
*/
#include <stdio.h>
#include <string.h>
@@ -18,11 +20,6 @@
#include "debug_log.h"
#include "server/wifi_ota.h"
// ============================================================
// 静态成员
// ============================================================
health_state_t HealthMonitor::healthState = SYS_NORMAL;
// ============================================================
// 单例
// ============================================================
@@ -44,36 +41,54 @@ void HealthMonitor::init()
}
// ============================================================
// 获取当前健康状态
// 线程安全的健康状态读写
// ============================================================
health_state_t HealthMonitor::getState()
{
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
health_state_t s = m_healthState;
if (m_mutex) xSemaphoreGive(m_mutex);
return s;
}
void HealthMonitor::setState(health_state_t newState)
{
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
m_healthState = newState;
if (m_mutex) xSemaphoreGive(m_mutex);
}
// ============================================================
// 获取当前健康状态(public 接口)
// ============================================================
health_state_t HealthMonitor::getCurrent()
{
health_state_t state;
if (m_mutex) {
xSemaphoreTake(m_mutex, portMAX_DELAY);
}
state = healthState;
if (m_mutex) {
xSemaphoreGive(m_mutex);
}
return state;
return getState();
}
float HealthMonitor::getLastTemperature()
{
return m_lastTemperature;
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
float t = m_lastTemperature;
if (m_mutex) xSemaphoreGive(m_mutex);
return t;
}
bool HealthMonitor::getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms)
{
if (m_lastWifiRssiMs <= 0) {
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
int64_t lastMs = m_lastWifiRssiMs;
int8_t rssi = m_lastWifiRssi;
if (m_mutex) xSemaphoreGive(m_mutex);
if (lastMs <= 0) {
return false;
}
int64_t now_ms = esp_timer_get_time() / 1000;
uint32_t age_ms = (now_ms > m_lastWifiRssiMs) ? (uint32_t)(now_ms - m_lastWifiRssiMs) : 0;
uint32_t age_ms = (now_ms > lastMs) ? (uint32_t)(now_ms - lastMs) : 0;
if (out_rssi) *out_rssi = m_lastWifiRssi;
if (out_rssi) *out_rssi = rssi;
if (out_age_ms) *out_age_ms = age_ms;
return true;
}
@@ -91,15 +106,18 @@ void HealthMonitor::tempMonitorCallback(void *arg)
return;
}
if (self.m_mutex) xSemaphoreTake(self.m_mutex, portMAX_DELAY);
self.m_lastTemperature = temp;
if (self.m_mutex) xSemaphoreGive(self.m_mutex);
if (temp >= TEMP_CRITICAL) {
healthState = SYS_SAFE_SHUTDOWN;
self.setState(SYS_SAFE_SHUTDOWN);
DEBUG_ERR("TEMP", "!!! 危险 !!! 温度 %.1f°C >= %d°C,触发安全关机", temp, TEMP_CRITICAL);
DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN");
} else if (temp >= TEMP_WARNING) {
if (healthState < SYS_WARNING) {
healthState = SYS_WARNING;
health_state_t cur = self.getState();
if (cur < SYS_WARNING) {
self.setState(SYS_WARNING);
DEBUG_WARN("TEMP", "警告: 温度 %.1f°C >= %d°C", temp, TEMP_WARNING);
DEBUG_WARN("TEMP", "健康状态变更为: SYS_WARNING");
}
@@ -144,8 +162,9 @@ void HealthMonitor::taskEntry(void *pvParameters)
if (free_heap < HEAP_WARNING_MIN) {
DEBUG_WARN("MON", "堆内存不足: %lu < %lu", free_heap, HEAP_WARNING_MIN);
}
if (healthState >= SYS_CRITICAL) {
DEBUG_ERR("MON", "健康状态危险: %d", healthState);
health_state_t cur = self.getState();
if (cur >= SYS_CRITICAL) {
DEBUG_ERR("MON", "健康状态危险: %d", cur);
}
vTaskDelay(pdMS_TO_TICKS(5000));
}
@@ -165,14 +184,15 @@ void HealthMonitor::logEvent(const char *event, int value)
health_status_t HealthMonitor::checkBeforeOta()
{
health_status_t status = HEALTH_MON_OK;
health_state_t cur = getState();
if (healthState >= SYS_SAFE_SHUTDOWN) {
if (cur >= SYS_SAFE_SHUTDOWN) {
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_SAFE_SHUTDOWN,禁止传输");
return HEALTH_MON_SHUTDOWN;
} else if (healthState >= SYS_CRITICAL) {
} else if (cur >= SYS_CRITICAL) {
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_CRITICAL,禁止传输");
return HEALTH_MON_CRITICAL;
} else if (healthState >= SYS_WARNING) {
} else if (cur >= SYS_WARNING) {
status = HEALTH_MON_WARNING;
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意");
}
@@ -210,18 +230,19 @@ health_status_t HealthMonitor::checkBeforeOta()
health_status_t HealthMonitor::checkBeforeOtaRelay()
{
health_status_t status = HEALTH_MON_OK;
health_state_t cur = getState();
if (healthState >= SYS_SAFE_SHUTDOWN) {
if (cur >= SYS_SAFE_SHUTDOWN) {
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_SAFE_SHUTDOWN,禁止传输");
return HEALTH_MON_SHUTDOWN;
} else if (healthState >= SYS_CRITICAL) {
} else if (cur >= SYS_CRITICAL) {
/* SYS_CRITICAL 可能是由 WiFi RSSI 过低导致的,
* 但 SPI 中继阶段不依赖 WiFi,只检查温度/硬件等本地状态。
* 温度达到 TEMP_CRITICAL(100°C) 时 g_health_state 会被设为
* 温度达到 TEMP_CRITICAL(100°C) 时 m_healthState 会被设为
* SYS_SAFE_SHUTDOWN,在上面的分支已经拦截。
* 所以 SYS_CRITICAL 在中继阶段只可能是 WiFi 导致的,允许继续。 */
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_CRITICAL (WiFi 相关),SPI 中继允许继续");
} else if (healthState >= SYS_WARNING) {
} else if (cur >= SYS_WARNING) {
status = HEALTH_MON_WARNING;
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_WARNING,继续传输");
}
@@ -243,21 +264,23 @@ health_status_t HealthMonitor::checkBeforeOtaRelay()
health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
{
if (rssi < WIFI_RSSI_CRITICAL) {
healthState = SYS_CRITICAL;
setState(SYS_CRITICAL);
DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL);
logEvent("WIFI危险", rssi);
return HEALTH_MON_CRITICAL;
} else if (rssi < WIFI_RSSI_NORMAL) {
if (healthState < SYS_WARNING) {
healthState = SYS_WARNING;
health_state_t cur = getState();
if (cur < SYS_WARNING) {
setState(SYS_WARNING);
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
logEvent("WIFI警告", rssi);
}
return HEALTH_MON_WARNING;
}
if (healthState == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) {
healthState = SYS_NORMAL;
health_state_t cur = getState();
if (cur == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) {
setState(SYS_NORMAL);
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm", rssi, WIFI_RSSI_NORMAL);
logEvent("WIFI恢复", rssi);
}
+8 -4
View File
@@ -54,7 +54,7 @@ public:
/** @brief 记录一条健康事件日志。 */
void logEvent(const char *event, int value);
/** @brief 获取当前系统健康状态。 */
/** @brief 获取当前系统健康状态(线程安全)。 */
health_state_t getCurrent();
/** @brief 根据 WiFi RSSI 更新健康状态。 */
@@ -66,10 +66,13 @@ public:
/** @brief 获取最近一次 WiFi RSSI 及其年龄。 */
bool getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms);
/** @brief 全局健康状态(public,供外部读取)。 */
static health_state_t healthState;
private:
/** @brief 加锁读取健康状态。 */
health_state_t getState();
/** @brief 加锁设置健康状态。 */
void setState(health_state_t newState);
HealthMonitor() = default;
~HealthMonitor() = default;
HealthMonitor(const HealthMonitor&) = delete;
@@ -77,6 +80,7 @@ private:
SemaphoreHandle_t m_mutex = NULL;
temperature_sensor_handle_t m_tempHandle = NULL;
health_state_t m_healthState = SYS_NORMAL;
float m_lastTemperature = 0.0f;
int8_t m_lastWifiRssi = -127;
int64_t m_lastWifiRssiMs = 0;
+301
View File
@@ -0,0 +1,301 @@
/*
* esp32_ota_core.cpp - ESP32 本机 OTA 核心流程实现(WiFi / BLE 通道共用)
*
* 设计依据 WiFi 原流程(wifi_ota.cpp 旧实现),修复已知 bug:
* - 旧 wifi_ota.cpp:625 在 esp_ota_end 成功后又调 esp_ota_abort → 用 m_ended 标志避免
* - SHA 统一从分区读回计算,与 BLE 增量计算方式统一为本风格
*/
#include <string.h>
#include <ctype.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
#include "mbedtls/private/sha256.h"
#include "esp32_ota_core.h"
#include "ota_mutex.h"
#include "app_config.h"
#include "monitor/debug_log.h"
#define TAG "OTA_CORE"
Esp32OtaCore& Esp32OtaCore::instance()
{
static Esp32OtaCore inst;
return inst;
}
void Esp32OtaCore::resetState()
{
m_active = false;
m_handle = 0;
m_partition = nullptr;
m_received = 0;
m_shaComputed = false;
memset(m_sha256, 0, sizeof(m_sha256));
m_ended = false;
}
esp_err_t Esp32OtaCore::begin(size_t total_size)
{
// 若本核心已有未完成会话,先中止并释放全局互斥
if (m_active) {
DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA 会话,先中止旧会话");
if (!m_ended) {
esp_ota_abort(m_handle);
}
esp32_ota_mutex_release();
resetState();
}
// ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用
if (!esp32_ota_mutex_acquire(1000)) {
DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin");
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",
boot_partition->label, boot_partition->size, boot_partition->address);
// 选择非当前启动的 OTA 分区(OTA_0 → OTA_1 fallback)
m_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
if (m_partition == NULL ||
m_partition->address == boot_partition->address) {
m_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
}
if (m_partition == NULL) {
DEBUG_ERR(TAG, "找不到可用的 OTA 分区");
esp32_ota_mutex_release();
return ESP_FAIL;
}
DEBUG_INFO(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx",
m_partition->label, m_partition->size, m_partition->address);
if (total_size > m_partition->size) {
DEBUG_ERR(TAG, "固件太大: %u > 分区大小: %u",
(unsigned)total_size, (unsigned)m_partition->size);
m_partition = nullptr;
esp32_ota_mutex_release();
return ESP_FAIL;
}
// 统一传 total_size 给 esp_ota_begin(WiFi 风格)
esp_err_t err = esp_ota_begin(m_partition, total_size, &m_handle);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err));
m_partition = nullptr;
m_handle = 0;
esp32_ota_mutex_release();
return err;
}
m_received = 0;
m_active = true;
m_shaComputed = false;
m_ended = false;
memset(m_sha256, 0, sizeof(m_sha256));
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节",
m_partition->label, (unsigned)total_size);
return ESP_OK;
}
esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len)
{
if (!m_active || m_partition == nullptr) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_ota_write(m_handle, data, len);
if (err == ESP_OK) {
m_received += len;
} else {
DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u)", esp_err_to_name(err), (unsigned)len);
}
return err;
}
esp_err_t Esp32OtaCore::computeShaFromPartition()
{
if (m_partition == NULL || m_received == 0) {
return ESP_ERR_INVALID_STATE;
}
DEBUG_INFO(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received);
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0);
uint8_t *read_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
if (!read_buf) {
DEBUG_ERR(TAG, "SHA 计算: malloc 失败");
mbedtls_sha256_free(&ctx);
return ESP_ERR_NO_MEM;
}
size_t offset = 0;
while (offset < m_received) {
size_t to_read = m_received - offset;
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
esp_err_t rd_err = esp_partition_read(m_partition, offset, read_buf, to_read);
if (rd_err != ESP_OK) {
DEBUG_ERR(TAG, "SHA 计算: 分区读失败 @offset %u: %s",
(unsigned)offset, esp_err_to_name(rd_err));
free(read_buf);
mbedtls_sha256_free(&ctx);
return rd_err;
}
mbedtls_sha256_update(&ctx, read_buf, to_read);
offset += to_read;
}
free(read_buf);
mbedtls_sha256_finish(&ctx, m_sha256);
mbedtls_sha256_free(&ctx);
m_shaComputed = true;
char hex[65] = {0};
for (int i = 0; i < 32; ++i) {
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02x", m_sha256[i]);
}
DEBUG_INFO(TAG, "设备端 SHA-256: %s", hex);
return ESP_OK;
}
esp_err_t Esp32OtaCore::computeDeviceSha(char *out_device_sha_hex)
{
if (!m_active || m_partition == NULL) {
return ESP_ERR_INVALID_STATE;
}
// 若已计算过,直接复用
if (!m_shaComputed) {
esp_err_t err = computeShaFromPartition();
if (err != ESP_OK) return err;
}
if (out_device_sha_hex) {
for (int i = 0; i < 32; ++i) {
snprintf(&out_device_sha_hex[i * 2], 65 - i * 2, "%02x", m_sha256[i]);
}
out_device_sha_hex[64] = '\0';
}
return ESP_OK;
}
esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
char *out_device_sha_hex)
{
if (!m_active) {
DEBUG_ERR(TAG, "finalize: OTA 未激活");
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 校验(若前端提供)──
if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) {
esp_err_t sha_err = computeShaFromPartition();
if (sha_err != ESP_OK) {
DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err));
esp32_ota_mutex_release();
resetState();
return sha_err;
}
char device_hex[65] = {0};
for (int i = 0; i < 32; ++i) {
snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02x", m_sha256[i]);
}
bool 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;
break;
}
}
if (out_device_sha_hex) {
strncpy(out_device_sha_hex, device_hex, 64);
out_device_sha_hex[64] = '\0';
}
if (!match) {
DEBUG_ERR(TAG, "SHA-256 不匹配! frontend=%s device=%s,中止",
frontend_sha_hex, device_hex);
esp32_ota_mutex_release();
resetState();
return ESP_ERR_INVALID_VERSION; // 调用方据此返回错误给前端
}
DEBUG_INFO(TAG, "SHA-256 校验通过");
} else if (out_device_sha_hex) {
// 未提供前端 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]);
}
out_device_sha_hex[64] = '\0';
}
}
// ── 设置启动分区 ──
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));
esp32_ota_mutex_release();
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);
// 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理
// (此处不 reset,以便 BLE finish task 在重启前可查询状态)
if (reboot) {
DEBUG_INFO(TAG, "[FINALIZE] 500ms 后重启...");
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
// 不会返回
}
return ESP_OK;
}
void Esp32OtaCore::abort()
{
if (!m_active) {
return;
}
if (!m_ended) {
esp_ota_abort(m_handle);
}
esp32_ota_mutex_release();
resetState();
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
}
+82
View File
@@ -0,0 +1,82 @@
/*
* esp32_ota_core.h - ESP32 本机 OTA 核心流程(WiFi / BLE 通道共用)
*
* 将 ESP32 自身固件升级的完整生命周期封装为单例:
* begin → write*(N) → finalize / abort
*
* 统一以 WiFi 流程为基准:
* - esp_ota_begin 第二参数传 total_size
* - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算)
* - finalize 顺序: esp_ota_end → 比对前端 SHA → esp_ota_set_boot_partition → 可选重启
* - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle
*
* WiFi 与 BLE 仅传输方式不同(HTTP / GATT),OTA 写入与收尾逻辑完全复用本核心。
*/
#ifndef ESP32_OTA_CORE_H
#define ESP32_OTA_CORE_H
#include <stddef.h>
#include "esp_err.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
class Esp32OtaCore {
public:
static Esp32OtaCore& instance();
// ── 阶段 1:开启 OTA 会话 ──
// total_size: 固件总大小(字节),用于分区大小校验和传给 esp_ota_begin
// 返回 ESP_OK / ESP_ERR_INVALID_STATE(被其它通道占用)/ ESP_FAIL
esp_err_t begin(size_t total_size);
// ── 阶段 2:写入固件数据(可在 begin 后多次调用)──
// 纯 esp_ota_write,不做增量 SHA(SHA 在 finalize 时统一从分区读回计算)
esp_err_t write(const uint8_t *data, size_t len);
// ── 阶段 3:收尾(end → 校验 SHA → 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
// 返回:
// ESP_OK 成功(若 reboot=true,本调用不会返回)
// ESP_ERR_INVALID_VERSION SHA 不匹配(已 abort 并清理)
// 其他 end/set_boot 失败(已清理)
esp_err_t finalize(const char *frontend_sha_hex, bool reboot,
char *out_device_sha_hex);
// ── 仅计算设备端 SHA(不 end、不 set_boot、不重启)──
// 供前端只读校验场景(如 WiFi verify_only 模式)
// out_device_sha_hex: 输出缓冲区,至少 65 字节
// 返回 ESP_OK / 失败
esp_err_t computeDeviceSha(char *out_device_sha_hex);
// ── 中止并清理(abort + 释放 mutex + 状态复位)──
// 若 esp_ota_end 已成功(m_ended=true),则跳过 esp_ota_abort 仅复位状态
void abort();
// ── 状态查询 ──
bool isActive() const { return m_active; }
size_t getReceived() const { return m_received; }
bool isRebootPending() const { return m_rebootPending; }
void setRebootPending(bool v) { m_rebootPending = v; }
private:
Esp32OtaCore() = default;
// 从 OTA 分区读回计算 SHA-256(WiFi 风格),结果写入 m_sha256,置 m_shaComputed=true
esp_err_t computeShaFromPartition();
// 状态全复位(不调 esp_ota_abort,由调用方根据 m_ended 决定)
void resetState();
bool m_active = false;
esp_ota_handle_t m_handle = 0;
const esp_partition_t *m_partition = nullptr;
size_t m_received = 0;
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
+324 -79
View File
@@ -1,10 +1,11 @@
/*
/*
* ota_relay.cpp - OTA 中继核心状态机实现
* T2.3, T2.4, T3.1 - T3.7
*
* 所有功能封装为 OtaRelay 类方法。
*/
#include <string.h>
#include <stdlib.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -14,6 +15,8 @@
#include "nvs_flash.h"
#include "nvs.h"
#include "aes/esp_aes.h"
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
#include "mbedtls/private/sha256.h"
#include "ota_relay.h"
#include "spi_master.h"
@@ -31,12 +34,81 @@ volatile bool OtaRelay::gd32AppAlive = false;
gd32_version_t OtaRelay::gd32Version = {};
QueueHandle_t OtaRelay::dispatchQueue = NULL;
QueueHandle_t OtaRelay::resultQueue = NULL;
SemaphoreHandle_t OtaRelay::s_stateMutex = NULL;
OtaRelay& OtaRelay::instance()
{
static OtaRelay inst;
return inst;
}
void OtaRelay::initMutex()
{
if (!s_stateMutex) {
s_stateMutex = xSemaphoreCreateMutex();
}
}
relay_state_t OtaRelay::getState()
{
StateLockGuard lock;
return relayState;
}
uint32_t OtaRelay::getRelaySeq()
{
StateLockGuard lock;
return relaySeq;
}
uint32_t OtaRelay::getRelayTotalPackets()
{
StateLockGuard lock;
return relayTotalPackets;
}
bool OtaRelay::getGd32AppAlive()
{
StateLockGuard lock;
return gd32AppAlive;
}
void OtaRelay::getGd32Version(gd32_version_t *out)
{
if (!out) return;
StateLockGuard lock;
*out = gd32Version;
}
void OtaRelay::setState(relay_state_t s)
{
StateLockGuard lock;
relayState = s;
}
void OtaRelay::setRelaySeq(uint32_t seq)
{
StateLockGuard lock;
relaySeq = seq;
}
void OtaRelay::setRelayTotalPackets(uint32_t total)
{
StateLockGuard lock;
relayTotalPackets = total;
}
void OtaRelay::setGd32AppAlive(bool alive)
{
StateLockGuard lock;
gd32AppAlive = alive;
}
void OtaRelay::setGd32Version(const gd32_version_t &ver)
{
StateLockGuard lock;
gd32Version = ver;
}
// ============================================================
@@ -129,9 +201,6 @@ bool OtaRelay::isFinishPlainAck(const uint8_t *payload, size_t len)
return false;
}
bool OtaRelay::isStructuredAckWithPayload(const uint8_t *payload, size_t len)
{ return payload && len >= 6U && payload[0] == 0xBAU && payload[1] == 0xC0U; }
esp_err_t OtaRelay::pollUntilExpectedAckShape(const char *stage,
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len,
@@ -234,7 +303,13 @@ esp_err_t OtaRelay::metaLoad(ota_meta_t *meta)
if (nvs_get_u32(handle, "total_size", &val) == ESP_OK) meta->total_size = val;
if (nvs_get_u32(handle, "stored_size", &val) == ESP_OK) meta->stored_size = val;
if (nvs_get_u32(handle, "last_seq", &val) == ESP_OK) meta->last_seq = val;
if (nvs_get_u32(handle, "chunk_count", &val) == ESP_OK) meta->chunk_count = val;
size_t sha_len = sizeof(meta->checksum);
if (nvs_get_blob(handle, "checksum", meta->checksum, &sha_len) == ESP_OK && sha_len == sizeof(meta->checksum)) {
// checksum 已从 NVS 恢复
} else {
memset(meta->checksum, 0, sizeof(meta->checksum));
}
nvs_close(handle);
return ESP_OK;
@@ -259,7 +334,7 @@ esp_err_t OtaRelay::metaSave(const ota_meta_t *meta)
nvs_set_u32(handle, "total_size", meta->total_size);
nvs_set_u32(handle, "stored_size", meta->stored_size);
nvs_set_u32(handle, "last_seq", meta->last_seq);
nvs_set_u32(handle, "chunk_count", meta->chunk_count);
nvs_set_blob(handle, "checksum", meta->checksum, sizeof(meta->checksum));
err = nvs_commit(handle);
nvs_close(handle);
@@ -298,6 +373,131 @@ esp_err_t OtaRelay::metaClear()
return OtaRelay::metaSave(&meta);
}
/**
* @brief 计算 gd32_ota 分区中已存储固件的 SHA-256
* @param out_sha256 输出 32 字节 SHA-256 摘要。
* @return ESP_OK 成功,其他失败。
*/
esp_err_t OtaRelay::computeFlashSha256(uint8_t out_sha256[32])
{
if (!out_sha256) return ESP_ERR_INVALID_ARG;
ota_meta_t meta;
esp_err_t err = metaLoad(&meta);
if (err != ESP_OK || meta.total_size == 0) {
DEBUG_ERR("OTA", "computeSha256: 元数据无效");
return ESP_ERR_INVALID_STATE;
}
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
if (mbedtls_sha256_starts(&ctx, 0) != 0) {
mbedtls_sha256_free(&ctx);
return ESP_FAIL;
}
static constexpr size_t kChunkSize = 1024;
uint8_t *buf = (uint8_t *)malloc(kChunkSize);
if (!buf) {
mbedtls_sha256_free(&ctx);
return ESP_ERR_NO_MEM;
}
uint32_t remaining = meta.total_size;
uint32_t offset = 0;
while (remaining > 0) {
size_t to_read = (remaining > kChunkSize) ? kChunkSize : remaining;
err = flashReadChunk(offset, buf, to_read);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "computeSha256: Flash 读取失败 offset=%" PRIu32, offset);
free(buf);
mbedtls_sha256_free(&ctx);
return err;
}
if (mbedtls_sha256_update(&ctx, buf, to_read) != 0) {
free(buf);
mbedtls_sha256_free(&ctx);
return ESP_FAIL;
}
offset += to_read;
remaining -= to_read;
}
free(buf);
int ret = mbedtls_sha256_finish(&ctx, out_sha256);
mbedtls_sha256_free(&ctx);
return (ret == 0) ? ESP_OK : ESP_FAIL;
}
/**
* @brief 固件接收完毕后,计算 SHA-256 并写入元数据
* @return ESP_OK 成功,其他失败。
*/
esp_err_t OtaRelay::metaFinalizeChecksum()
{
ota_meta_t meta;
esp_err_t err = metaLoad(&meta);
if (err != ESP_OK) return err;
if (meta.total_size == 0 || meta.stored_size < meta.total_size) {
DEBUG_ERR("OTA", "metaFinalize: 固件不完整 stored=%" PRIu32 " total=%" PRIu32,
meta.stored_size, meta.total_size);
return ESP_ERR_INVALID_STATE;
}
err = computeFlashSha256(meta.checksum);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "metaFinalize: SHA-256 计算失败");
return err;
}
char hex[65] = {0};
for (int i = 0; i < 32; ++i)
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", meta.checksum[i]);
DEBUG_INFO("OTA", "固件 SHA-256: %s", hex);
return metaSave(&meta);
}
/**
* @brief 中继转发前校验固件完整性
* @return ESP_OK 校验通过,ESP_ERR_INVALID_CRC 校验失败。
*/
esp_err_t OtaRelay::metaVerifyChecksum()
{
ota_meta_t meta;
esp_err_t err = metaLoad(&meta);
if (err != ESP_OK) return err;
// 全零 checksum 表示从未计算过(兼容旧固件)
if (bytesAllValue(meta.checksum, sizeof(meta.checksum), 0x00)) {
DEBUG_WARN("OTA", "metaVerify: checksum 未计算,跳过校验(旧数据兼容)");
return ESP_OK;
}
uint8_t computed[32] = {0};
err = computeFlashSha256(computed);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "metaVerify: SHA-256 计算失败");
return err;
}
if (memcmp(computed, meta.checksum, 32) != 0) {
char hex_stored[65] = {0}, hex_computed[65] = {0};
for (int i = 0; i < 32; ++i) {
snprintf(&hex_stored[i * 2], sizeof(hex_stored) - i * 2, "%02X", meta.checksum[i]);
snprintf(&hex_computed[i * 2], sizeof(hex_computed) - i * 2, "%02X", computed[i]);
}
DEBUG_ERR("OTA", "固件完整性校验失败!");
DEBUG_ERR("OTA", " 存储 SHA-256: %s", hex_stored);
DEBUG_ERR("OTA", " 计算 SHA-256: %s", hex_computed);
return ESP_ERR_INVALID_CRC;
}
DEBUG_INFO("OTA", "固件完整性校验通过 (SHA-256 匹配)");
return ESP_OK;
}
/**
* @brief Flash 写入接口
* @param offset 分区内写入偏移。
@@ -316,7 +516,58 @@ esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t
}
}
return esp_partition_write(instance().m_gd32Partition, offset, data, len);
// --- offset 必须4字节对齐 ---
if (offset % 4 != 0) {
DEBUG_ERR("OTA", "flashWriteChunk offset非4对齐: offset=%" PRIu32, offset);
return ESP_ERR_INVALID_ARG;
}
const esp_partition_t *part = instance().m_gd32Partition;
// --- len已对齐:直接写入 ---
if (len % 4 == 0) {
if (offset + len > part->size) {
DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu part_size=%" PRIu32,
offset, len, part->size);
return ESP_ERR_INVALID_SIZE;
}
return esp_partition_write(part, offset, data, len);
}
// --- len未对齐:read-modify-write ---
size_t aligned_len = (len + 3) & ~(size_t)3;
if (offset + aligned_len > part->size) {
DEBUG_ERR("OTA", "flashWriteChunk对齐后越界: offset=%" PRIu32 " len=%zu aligned=%zu part=%" PRIu32,
offset, len, aligned_len, part->size);
return ESP_ERR_INVALID_SIZE;
}
DEBUG_WARN("OTA", "flashWriteChunk len非4对齐: len=%zu aligned_len=%zu offset=%" PRIu32,
len, aligned_len, offset);
// 读回尾部padding字节(offset+len处,最多3字节)
size_t pad_len = aligned_len - len;
uint8_t pad[4] = {0};
esp_err_t err = esp_partition_read(part, offset + len, pad, pad_len);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回padding失败: offset=%" PRIu32 " pad=%zu err=%s",
offset + len, pad_len, esp_err_to_name(err));
return err;
}
// 拼接:data + pad -> aligned_buf
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
if (!aligned_buf) {
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
return ESP_ERR_NO_MEM;
}
memcpy(aligned_buf, data, len);
memcpy(aligned_buf + len, pad, pad_len);
err = esp_partition_write(part, offset, aligned_buf, aligned_len);
free(aligned_buf);
return err;
}
/**
@@ -362,27 +613,6 @@ esp_err_t OtaRelay::requestGd32AppEnterBootloader()
}
ota_result_t OtaRelay::probeBootHandshake()
{
/* 0xB0 timeout: probe if GD32 is already in BOOT.
* Send one lightweight HANDSHAKE_REQ, accept only echo ACK 0x00 0xAC
* as "already in BOOT" signal; otherwise fail immediately. */
DEBUG_INFO("OTA", "0xB0 timeout, probing if GD32 already in BOOT...");
uint8_t payload[] = {0x00, 0xAC};
uint8_t resp_type = 0U;
uint8_t resp_payload[64] = {0};
size_t resp_len = 0U;
esp_err_t err = SpiMaster::instance().otaTransfer(SPI_OTA_CMD_REQ, payload, sizeof(payload),
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 500);
if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK && isHandshakeEchoAck(resp_payload, resp_len)) {
DEBUG_INFO("OTA", "BOOT probe success: GD32 already in BOOT, continue OTA");
return OTA_RESULT_OK;
}
DEBUG_ERR("OTA", "BOOT probe failed: GD32 not in BOOT, abort (err=%d resp_type=0x%02X)", err, resp_type);
return OTA_RESULT_ERR_TIMEOUT;
}
ota_result_t OtaRelay::sendHandshakeReq()
{
DEBUG_INFO("OTA", "=== 阶段1: 握手开始 ===");
@@ -579,7 +809,7 @@ ota_result_t OtaRelay::sendFirmwareSize(uint32_t size)
* @brief 从 Flash 向 GD32 转发存储的固件(48B/包)
* @return 操作结果码。
*/
ota_result_t OtaRelay::relayPacketsFromFlash()
ota_result_t OtaRelay::relayPacketsFromFlash(TickType_t deadline_tick)
{
DEBUG_INFO("OTA", "=== 阶段3: 从 Flash 转发固件 ===");
@@ -613,6 +843,12 @@ ota_result_t OtaRelay::relayPacketsFromFlash()
}
}
/* 全局超时检查:避免单包 WAIT 循环累计导致整体挂起 */
if (deadline_tick != 0 && xTaskGetTickCount() >= deadline_tick) {
DEBUG_ERR("OTA", "全局超时: 包 %u/%u 已超限,中止转发", seq, total_packets);
return OTA_RESULT_ERR_USER_ABORT;
}
uint8_t plain48[GD32_OTA_PACKET_SIZE];
memset(plain48, 0xFF, sizeof(plain48));
size_t chunk_size = GD32_OTA_PACKET_SIZE;
@@ -895,42 +1131,6 @@ ota_result_t OtaRelay::finishOta()
return OTA_RESULT_OK;
}
/**
* @brief 启动完整 OTA 流程的主入口
* @return 操作结果码。
*/
ota_result_t OtaRelay::startHandshake()
{
ota_result_t res;
esp_err_t boot_err = requestGd32AppEnterBootloader();
if (boot_err != ESP_OK) {
res = probeBootHandshake();
if (res != OTA_RESULT_OK) {
reset();
return res;
}
}
res = sendHandshakeReq();
if (res != OTA_RESULT_OK) { reset(); return res; }
res = sendNonceAndAuthenticate();
if (res != OTA_RESULT_OK) { reset(); return res; }
ota_meta_t meta;
OtaRelay::metaLoad(&meta);
res = sendFirmwareSize(meta.total_size);
if (res != OTA_RESULT_OK) { reset(); return res; }
res = relayPacketsFromFlash();
if (res != OTA_RESULT_OK) { reset(); return res; }
res = finishOta();
return res;
}
/**
* @brief 从 Flash 向 GD32 转发固件
* @return 操作结果码。
@@ -942,43 +1142,85 @@ ota_result_t OtaRelay::forwardFirmware()
return OTA_RESULT_ERR_STORAGE;
}
DEBUG_INFO("OTA", "开始固件转发: %u 字节, %u 块",
meta.total_size, meta.chunk_count);
DEBUG_INFO("OTA", "开始固件转发: %u 字节",
meta.total_size);
// 固件完整性校验
{
esp_err_t verify_err = metaVerifyChecksum();
if (verify_err != ESP_OK) {
DEBUG_ERR("OTA", "固件完整性校验失败,中止转发");
return OTA_RESULT_ERR_CRC;
}
}
/* 全局超时:动态计算,min = 5min, max = 10min, 按 total_size/1024*2ms 递增 */
constexpr uint32_t kMinGlobalTimeoutMs = 5U * 60U * 1000U;
constexpr uint32_t kMaxGlobalTimeoutMs = 10U * 60U * 1000U;
uint32_t dynamic_ms = kMinGlobalTimeoutMs + (meta.total_size / 1024U) * 2U;
uint32_t global_timeout_ms = (dynamic_ms > kMaxGlobalTimeoutMs) ? kMaxGlobalTimeoutMs : dynamic_ms;
TickType_t start_tick = xTaskGetTickCount();
TickType_t deadline_tick = start_tick + pdMS_TO_TICKS(global_timeout_ms);
DEBUG_INFO("OTA", "全局超时: %u ms (%u 秒), deadline tick=%u",
global_timeout_ms, global_timeout_ms / 1000U, (unsigned)deadline_tick);
requestGd32AppEnterBootloader();
/* 在每个阶段前检查全局超时,超时则 reset() 并返回 OTA_RESULT_ERR_USER_ABORT */
if (xTaskGetTickCount() >= deadline_tick) {
DEBUG_ERR("OTA", "全局超时: 在 requestBootloader 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
reset();
return OTA_RESULT_ERR_USER_ABORT;
}
ota_result_t res = sendHandshakeReq();
if (res != OTA_RESULT_OK) { reset(); return res; }
if (xTaskGetTickCount() >= deadline_tick) {
DEBUG_ERR("OTA", "全局超时: 在 sendHandshakeReq 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
reset();
return OTA_RESULT_ERR_USER_ABORT;
}
res = sendNonceAndAuthenticate();
if (res != OTA_RESULT_OK) { reset(); return res; }
if (xTaskGetTickCount() >= deadline_tick) {
DEBUG_ERR("OTA", "全局超时: 在 sendNonceAndAuthenticate 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
reset();
return OTA_RESULT_ERR_USER_ABORT;
}
res = sendFirmwareSize(meta.total_size);
if (res != OTA_RESULT_OK) { reset(); return res; }
res = relayPacketsFromFlash();
if (xTaskGetTickCount() >= deadline_tick) {
DEBUG_ERR("OTA", "全局超时: 在 sendFirmwareSize 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
reset();
return OTA_RESULT_ERR_USER_ABORT;
}
res = relayPacketsFromFlash(deadline_tick);
if (res != OTA_RESULT_OK) { reset(); return res; }
if (xTaskGetTickCount() >= deadline_tick) {
DEBUG_ERR("OTA", "全局超时: 在 relayPacketsFromFlash 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
reset();
return OTA_RESULT_ERR_USER_ABORT;
}
res = finishOta();
return res;
}
/**
* @brief 获取当前状态
* @return 当前中继状态枚举值。
*/
relay_state_t OtaRelay::getState()
{
/** @brief 当前 OTA 中继状态机状态。 */
return relayState;
}
/**
* @brief 重置状态机
*/
void OtaRelay::reset()
{
StateLockGuard lock;
/** @brief 当前 OTA 中继状态机状态。 */
relayState = RELAY_IDLE;
relaySeq = 0;
@@ -998,6 +1240,8 @@ void OtaRelay::taskEntry(void *pvParameters)
{
DEBUG_INFO("OTA", "OTA 中继任务启动");
initMutex();
/** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */
dispatchQueue = xQueueCreate(4, sizeof(uint32_t));
/** @brief OTA 结果回传队列,将执行结果返回给调用方。 */
@@ -1026,6 +1270,8 @@ void OtaRelay::taskEntry(void *pvParameters)
DEBUG_INFO("OTA", "OTA 转发成功完成");
/* 保持 RELAY_DONE 状态 3 秒,让网页轮询有机会看到完成状态 */
vTaskDelay(pdMS_TO_TICKS(3000));
} else if (result == OTA_RESULT_ERR_USER_ABORT) {
DEBUG_ERR("OTA", "OTA 全局超时中止 (OTA_RESULT_ERR_USER_ABORT=%d)", result);
} else {
DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result);
}
@@ -1033,4 +1279,3 @@ void OtaRelay::taskEntry(void *pvParameters)
}
}
+44 -15
View File
@@ -1,4 +1,4 @@
/*
/*
* ota_relay.h - OTA 中继核心状态机
* T2.3, T3.1 - T3.7
*
@@ -10,24 +10,22 @@
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "transport/protocol.h"
#include "transport/spi_master.h"
#include "esp_partition.h"
#define OTA_META_NVS_NAMESPACE "ota_meta"
#define OTA_META_NVS_KEY "meta"
typedef struct {
uint32_t total_size;
uint32_t stored_size;
uint32_t last_seq;
uint32_t chunk_count;
uint8_t checksum[32];
} ota_meta_t;
typedef enum {
RELAY_IDLE = 0,
RELAY_HANDSHAKE_IDLE,
RELAY_HANDSHAKE_SENT,
RELAY_CHALLENGE_SENT,
RELAY_CHALLENGE_RESPOND,
@@ -35,13 +33,11 @@ typedef enum {
RELAY_AUTH_COMPLETE,
RELAY_SIZE_SENT,
RELAY_SIZE_ACK,
RELAY_RECEIVING,
RELAY_RELAY_ACTIVE,
RELAY_PACKET_SENT,
RELAY_PACKET_ACK_OK,
RELAY_PACKET_ACK_RETRY,
RELAY_RECEIVE_DONE,
RELAY_TRANSFER_DONE,
RELAY_FINISH_SENT,
RELAY_DONE,
RELAY_APP_VERIFY,
@@ -64,9 +60,24 @@ public:
static OtaRelay& instance();
static void taskEntry(void *pvParameters);
ota_result_t startHandshake();
ota_result_t forwardFirmware();
relay_state_t getState();
/* ---- 线程安全的状态访问接口 ----
* OTA 任务写、HTTP 任务读,通过 mutex 保证一致性。
* 状态机内部(OTA 任务)可直接写 static 成员,
* 跨任务读/写必须走这些 API。
*/
static relay_state_t getState();
static uint32_t getRelaySeq();
static uint32_t getRelayTotalPackets();
static bool getGd32AppAlive();
static void getGd32Version(gd32_version_t *out);
static void setState(relay_state_t s);
static void setRelaySeq(uint32_t seq);
static void setRelayTotalPackets(uint32_t total);
static void setGd32AppAlive(bool alive);
static void setGd32Version(const gd32_version_t &ver);
void reset();
static esp_err_t flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len);
@@ -75,8 +86,11 @@ public:
static esp_err_t metaSave(const ota_meta_t *meta);
static esp_err_t metaUpdate(uint32_t stored_size, uint32_t last_seq);
static esp_err_t metaClear();
static esp_err_t computeFlashSha256(uint8_t out_sha256[32]);
static esp_err_t metaFinalizeChecksum();
static esp_err_t metaVerifyChecksum();
// 公开状态
// 公开状态(状态机内部直接访问;跨任务请用 getter/setter)
static relay_state_t relayState;
static uint32_t relaySeq;
static uint32_t relayTotalPackets;
@@ -85,6 +99,26 @@ public:
static QueueHandle_t dispatchQueue;
static QueueHandle_t resultQueue;
// 线程安全锁
static SemaphoreHandle_t s_stateMutex;
static void initMutex();
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50);
class StateLockGuard {
public:
explicit StateLockGuard(TickType_t timeout = kLockTimeout) {
m_locked = (s_stateMutex && xSemaphoreTake(s_stateMutex, timeout) == pdTRUE);
}
~StateLockGuard() {
if (m_locked && s_stateMutex) { xSemaphoreGive(s_stateMutex); }
}
bool isLocked() const { return m_locked; }
StateLockGuard(const StateLockGuard&) = delete;
StateLockGuard& operator=(const StateLockGuard&) = delete;
private:
bool m_locked = false;
};
// 实例状态(public,供状态机方法访问)
const esp_partition_t *m_gd32Partition = NULL;
session_ctx_t m_sessionCtx;
@@ -96,11 +130,10 @@ public:
static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36;
esp_err_t requestGd32AppEnterBootloader();
ota_result_t probeBootHandshake();
ota_result_t sendHandshakeReq();
ota_result_t sendNonceAndAuthenticate();
ota_result_t sendFirmwareSize(uint32_t size);
ota_result_t relayPacketsFromFlash();
ota_result_t relayPacketsFromFlash(TickType_t deadline_tick = 0);
ota_result_t finishOta();
static int aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out);
@@ -117,8 +150,6 @@ public:
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 bool isTransferDoneAck(const uint8_t *payload, size_t len);
static bool isStructuredAckWithPayload(const uint8_t *payload, size_t len);
static esp_err_t pollUntilExpectedAckShape(const char *stage,
uint8_t *resp_type,
@@ -142,5 +173,3 @@ private:
};
#endif // OTA_RELAY_H
+332 -295
View File
@@ -9,7 +9,7 @@
*
* GATT 服务:
* Service UUID: 0xFFFF
* - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级
* - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级(含 32B SHA-256 校验并 reboot),0xFA=重启ESP32
* - DATA_IN (0x0002, Write): cmd=0x00=GD32数据, cmd=0x01=ESP32数据
* - DATA_OUT (0x0003, Notify): 推送响应
* - VERSION (0x0004, Read): 固件版本
@@ -34,8 +34,12 @@
#include "services/gatt/ble_svc_gatt.h"
#include "host/ble_att.h"
// SHA-256 计算已抽离到 Esp32OtaCore,本文件不再直接引用 mbedtls
#include "app_config.h"
#include "ota_mutex.h"
#include "ble_ota.h"
#include "esp32_ota_core.h"
#include "ota_relay.h"
#include "health_monitor.h"
#include "monitor/debug_log.h"
@@ -43,6 +47,7 @@
#define TAG "BLE_OTA"
#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
@@ -52,20 +57,19 @@
#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_ACK_WINDOW 8
#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 不匹配
// ============================================================
// 前向声明(GAP event handler 在广播函数之前定义,需要声明)
// 前向声明
// ============================================================
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);
// ============================================================
// 静态状态
// ============================================================
// ============================================================
BleOta& BleOta::instance()
{
@@ -76,92 +80,105 @@ BleOta& BleOta::instance()
// File-scope val_handle for GATT service definition (struct literal needs compile-time address)
static uint16_t s_data_out_val_handle = 0;
// ESP32 自身 OTA 状态
// ESP32 自身 OTA 收尾任务(OTA 写入/校验/重启逻辑委托给 Esp32OtaCore)
/**
* @brief 异步完成 ESP32 OTA 收尾并触发重启。
*
* 实际的 esp_ota_end / SHA 校验 / esp_ota_set_boot_partition / esp_restart 全部
* 由 Esp32OtaCore::finalize 完成。本任务仅负责:补发 ACK → 调 finalize → 上报 SHA → 通知前端。
* @param pvParameters 任务参数,当前未使用。
*/
static void ble_esp32_finish_and_reboot_task(void *pvParameters)
/**
* @brief 0xFA 重启任务:为了不卡 NimBLE host task,仅在这里推送 FINISH 后 500ms 重启。
* @param pvParameters 任务参数,未使用。
*/
static void ble_esp32_restart_task(void *pvParameters)
{
(void)pvParameters;
DEBUG_INFO(TAG, "[RESTART] 推送 FINISH_REBOOT_NOW 后 500ms 重启");
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
// 不会返回
}
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)BleOta::instance().m_esp32Received);
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 (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) {
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 int)BleOta::instance().m_esp32Received);
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived());
int64_t t0_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_end()");
// 通知前端:开始 finalize
sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0);
esp_err_t err = esp_ota_end(BleOta::instance().m_esp32OtaHandle);
int64_t t1_us = esp_timer_get_time();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "[FINISH] esp_ota_end 失败: %s (耗时=%lld ms)",
esp_err_to_name(err), (long long)((t1_us - t0_us) / 1000));
// 关键:reboot=false,让 finalize 真正返回、拿到 device_sha_hex,
// 再依次推送 SHA 通知 + FINISH_DONE_OTA_END + FINISH_REBOOT_NOW,
// 最后延迟重启。若 reboot=true,core 内部直接 esp_restart(),
// 下面所有 notifyResponse / sendFinishEvent 都不会执行,
// 前端也就拿不到设备端 SHA 与"收尾完成/即将重启"事件。
char device_sha_hex[65] = {0};
esp_err_t err = core.finalize(frontend_sha_hex,
/*reboot=*/false,
device_sha_hex);
if (err == ESP_OK) {
// 1) 把设备端 SHA 通过 SHA256 通知推给前端
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));
}
// 2) 等待 SHA 通知从 NimBLE host 队列送出
vTaskDelay(pdMS_TO_TICKS(150));
// 3) 推送"OTA 收尾完成"与"即将重启"事件
sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0);
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
// 4) 通知全部发出后延迟 500ms 重启
DEBUG_INFO(TAG, "[FINISH] 通知已发送,500ms 后重启...");
vTaskDelay(pdMS_TO_TICKS(500));
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;
} else {
DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err));
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err);
esp_ota_abort(BleOta::instance().m_esp32OtaHandle);
BleOta::instance().m_esp32OtaActive = false;
BleOta::instance().m_esp32OtaHandle = 0;
BleOta::instance().m_esp32UpdatePartition = NULL;
BleOta::instance().m_esp32Received = 0;
BleOta::instance().m_esp32RebootPending = false;
vTaskDelete(NULL);
return;
}
DEBUG_INFO(TAG, "[FINISH] esp_ota_end 完成 (耗时=%lld ms)",
(long long)((t1_us - t0_us) / 1000));
sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, (uint32_t)((t1_us - t0_us) / 1000));
int64_t t2_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_set_boot_partition()");
sendFinishEvent(BLE_OTA_FINISH_BEGIN_SET_BOOT, 0);
err = esp_ota_set_boot_partition(BleOta::instance().m_esp32UpdatePartition);
int64_t t3_us = esp_timer_get_time();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "[FINISH] 设置启动分区失败: %s (耗时=%lld ms)",
esp_err_to_name(err), (long long)((t3_us - t2_us) / 1000));
sendFinishEvent(BLE_OTA_FINISH_ERR_SET_BOOT, (uint32_t)err);
BleOta::instance().m_esp32OtaActive = false;
BleOta::instance().m_esp32OtaHandle = 0;
BleOta::instance().m_esp32UpdatePartition = NULL;
BleOta::instance().m_esp32Received = 0;
BleOta::instance().m_esp32RebootPending = false;
vTaskDelete(NULL);
return;
}
DEBUG_INFO(TAG, "[FINISH] esp_ota_set_boot_partition 完成 (耗时=%lld ms)",
(long long)((t3_us - t2_us) / 1000));
sendFinishEvent(BLE_OTA_FINISH_DONE_SET_BOOT, (uint32_t)((t3_us - t2_us) / 1000));
BleOta::instance().m_esp32OtaActive = false;
BleOta::instance().m_esp32OtaHandle = 0;
BleOta::instance().m_esp32UpdatePartition = NULL;
BleOta::instance().m_esp32Received = 0;
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,800ms 后重启...");
int64_t t4_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 即将进入 800ms 重启前等待");
sendFinishEvent(BLE_OTA_FINISH_BEGIN_DELAY, 800);
vTaskDelay(pdMS_TO_TICKS(800));
int64_t t5_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 800ms 等待结束 (实际耗时=%lld ms)",
(long long)((t5_us - t4_us) / 1000));
sendFinishEvent(BLE_OTA_FINISH_DONE_DELAY, (uint32_t)((t5_us - t4_us) / 1000));
DEBUG_INFO(TAG, "ESP32 即将重启");
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
esp_restart();
if (frontend_sha_hex) free(frontend_sha_hex);
vTaskDelete(NULL);
}
// ============================================================
@@ -182,86 +199,37 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
return (int)copy_len;
}
// ─────────────────────────────────────────────────────────────
// ESP32 OTA 薄包装:实际 begin/write/finalize 已抽离到 Esp32OtaCore
// 此处仅保留 BLE 侧的状态复位与日志,供 data_in_access 调用。
// ─────────────────────────────────────────────────────────────
/**
* @brief 初始化 ESP32 本机 OTA 写入会话并选择目标分区。
* @brief 初始化 ESP32 本机 OTA 写入会话。
* 委托 Esp32OtaCore::begin,并复位 BLE 传输相关状态。
* @param total_size 固件总大小(字节)。
* @return ESP_OK 成功,其他为失败错误码。
*/
static esp_err_t ble_esp32_ota_begin(size_t total_size)
{
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx",
boot_partition->label, boot_partition->size, boot_partition->address);
if (BleOta::instance().m_esp32OtaActive) {
DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA,会先中止旧会话");
esp_ota_abort(BleOta::instance().m_esp32OtaHandle);
BleOta::instance().m_esp32OtaActive = false;
BleOta::instance().m_esp32OtaHandle = 0;
BleOta::instance().m_esp32UpdatePartition = NULL;
BleOta::instance().m_esp32Received = 0;
BleOta::instance().m_esp32RebootPending = false;
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
}
BleOta::instance().m_esp32UpdatePartition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
if (BleOta::instance().m_esp32UpdatePartition == NULL ||
BleOta::instance().m_esp32UpdatePartition->address == boot_partition->address) {
BleOta::instance().m_esp32UpdatePartition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
}
if (BleOta::instance().m_esp32UpdatePartition == NULL) {
DEBUG_ERR(TAG, "找不到可用的 OTA 分区");
return ESP_FAIL;
}
DEBUG_INFO(TAG, "ESP32 OTA 目标分区: %s, size=%u, address=0x%lx",
BleOta::instance().m_esp32UpdatePartition->label,
BleOta::instance().m_esp32UpdatePartition->size,
BleOta::instance().m_esp32UpdatePartition->address);
if (total_size > BleOta::instance().m_esp32UpdatePartition->size) {
DEBUG_ERR(TAG, "ESP32 固件太大: %u > 分区大小: %u",
total_size, BleOta::instance().m_esp32UpdatePartition->size);
BleOta::instance().m_esp32UpdatePartition = NULL;
return ESP_FAIL;
}
esp_err_t err = esp_ota_begin(BleOta::instance().m_esp32UpdatePartition, total_size, &BleOta::instance().m_esp32OtaHandle);
esp_err_t err = Esp32OtaCore::instance().begin(total_size);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err));
BleOta::instance().m_esp32UpdatePartition = NULL;
BleOta::instance().m_esp32OtaHandle = 0;
return err;
}
BleOta::instance().m_esp32Received = 0;
BleOta::instance().m_esp32OtaActive = true;
// 复位 BLE 传输相关状态
BleOta::instance().m_esp32RebootPending = false;
BleOta::instance().m_esp32LastCmd = 0;
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s", BleOta::instance().m_esp32UpdatePartition->label);
return ESP_OK;
}
/**
* @brief 向 ESP32 OTA 分区写入一段固件数据。
* @param data 待写入数据指针。
* @param len 写入长度(字节)。
* @return ESP_OK 成功,其他为失败错误码。
* @brief 中止 ESP32 OTA 会话并清理(薄包装)。
*/
static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len)
static void ble_esp32_ota_abort()
{
if (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_ota_write(BleOta::instance().m_esp32OtaHandle, data, len);
if (err == ESP_OK) {
BleOta::instance().m_esp32Received += len;
}
return err;
Esp32OtaCore::instance().abort();
BleOta::instance().m_esp32RebootPending = false;
}
/**
@@ -341,14 +309,14 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
uint8_t buf[4] = {0};
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", buf[0]);
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
// 检查固件是否存储完毕 → 触发 GD32 OTA 中继
@@ -365,7 +333,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
meta.stored_size, meta.total_size);
}
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
if (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) {
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
return 0;
}
@@ -374,20 +342,63 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
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,
NULL,
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 收尾任务已创建(由设备端异步完成)");
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
}
} else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {
// 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host
DEBUG_INFO(TAG, "收到 0xFA 重启请求,创建 restart task(500ms 后 esp_restart())");
if (BleOta::instance().m_esp32RebootPending) {
DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL");
return 0;
}
BleOta::instance().m_esp32RebootPending = true;
BaseType_t ok = xTaskCreate(ble_esp32_restart_task,
"ble_ota_restart",
2048,
NULL,
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 0xFA 重启任务失败");
BleOta::instance().m_esp32RebootPending = false;
}
}
// 0xF7 = 触发 GD32 中继
// 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对,
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启)
// 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
}
@@ -398,8 +409,8 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
* [0] cmd (0x00=数据)
* [1-2] seq uint16 LE - 分块序号
* [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效)
* [7..n-4] payload 固件数据
* [n-4..n] crc 校验 (预留)
* [7..n] payload 固件数据
* 注: 已移除CRC字段(BLE链路层已有CRC校验)
*/
/**
* @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。
@@ -420,177 +431,199 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
uint8_t buf[BLE_MTU_SIZE] = {0};
// 使用较大静态缓冲区:writeValueWithoutResponse 快速发送时,
// NimBLE 可能将多个写请求合并到同一个 mbuf 链中
// ★ static: NimBLE host task 栈只有 4096 字节,不能用栈分配
// ★ 4096 字节可容纳 8 个合并包(8×491=3928),避免截断丢包
static constexpr size_t BLE_OTA_BUF_SIZE = 4096;
static uint8_t buf[BLE_OTA_BUF_SIZE];
int recv_len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
if (recv_len < 12) {
DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 12)", recv_len);
if (recv_len < 8) {
DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 8: 7头+1数据)", recv_len);
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
// 解析包头
uint8_t cmd = buf[0];
uint16_t seq = (uint16_t)(buf[1] | (buf[2] << 8));
uint32_t total_size = (uint32_t)(buf[3] | (buf[4] << 8) |
(buf[5] << 16) | (buf[6] << 24));
// 循环处理 mbuf 中的所有完整数据包
size_t offset = 0;
while (offset + 7 <= (size_t)recv_len) {
// 解析包头
uint8_t cmd = buf[offset + 0];
uint16_t seq = (uint16_t)(buf[offset + 1] | (buf[offset + 2] << 8));
uint32_t total_size = (uint32_t)(buf[offset + 3] | (buf[offset + 4] << 8) |
(buf[offset + 5] << 16) | (buf[offset + 6] << 24));
size_t data_offset = 7;
size_t data_len = (size_t)recv_len - data_offset;
size_t data_offset = offset + 7;
// 尾 4 字节 CRC 预留,实际 payload = data_len - 4
if (data_len < 4) {
DEBUG_WARN(TAG, "DATA_IN 负载不足: data_len=%zu", data_len);
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
data_len -= 4; // 减去尾部 CRC 预留
// 根据包序号和固件总大小计算期望 payload,而非依赖 recv_len
// 这样即使 NimBLE mbuf 合并导致缓冲区截断,也不会误判包长度
uint32_t expected_payload = (total_size > 0)
? ((uint32_t)BLE_OTA_CHUNK_PAYLOAD < (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD)
? (uint32_t)BLE_OTA_CHUNK_PAYLOAD
: (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD))
: (uint32_t)BLE_OTA_CHUNK_PAYLOAD;
DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu",
cmd, seq, total_size, data_len);
size_t data_len = (size_t)expected_payload;
size_t remaining_after_header = (size_t)recv_len - data_offset;
// --- ESP32 自身 OTA:直接写 OTA 分区 ---
if (cmd == BLE_OTA_CMD_ESP32_DATA) {
if (seq == 0) {
if (total_size == 0) {
DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size);
return BLE_ATT_ERR_UNLIKELY;
}
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
const esp_partition_t *check_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
if (check_partition == NULL || check_partition->address == boot_partition->address) {
check_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
}
if (check_partition == NULL) {
DEBUG_ERR(TAG, "找不到 ESP32 OTA 目标分区");
return BLE_ATT_ERR_UNLIKELY;
}
if (total_size > check_partition->size) {
DEBUG_ERR(TAG, "ESP32 固件太大: %" PRIu32 " > OTA分区 %u",
total_size, check_partition->size);
return BLE_ATT_ERR_UNLIKELY;
}
esp_err_t err = ble_esp32_ota_begin(total_size);
if (err != ESP_OK) {
return BLE_ATT_ERR_UNLIKELY;
// 如果缓冲区中实际可用数据不足,截断 data_len
if (remaining_after_header < expected_payload) {
if (remaining_after_header == 0) {
// 没有更多数据,退出循环
break;
}
// 缓冲区截断,使用实际可用数据
data_len = remaining_after_header;
}
esp_err_t err = ble_esp32_ota_write(&buf[data_offset], data_len);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s",
seq, esp_err_to_name(err));
return BLE_ATT_ERR_UNLIKELY;
}
// 当前包总长度: header(7) + payload(data_len)
size_t packet_len = 7 + data_len;
DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u",
seq, (unsigned int)BleOta::instance().m_esp32Received);
DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu offset=%zu",
cmd, seq, total_size, data_len, offset);
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
(total_size > 0 && BleOta::instance().m_esp32Received >= total_size)) {
sendWindowAck(cmd, seq, (uint32_t)BleOta::instance().m_esp32Received);
}
if (total_size > 0 && BleOta::instance().m_esp32Received >= total_size) {
DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)",
(unsigned int)BleOta::instance().m_esp32Received, total_size, seq + 1);
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 收尾任务失败");
BleOta::instance().m_esp32RebootPending = false;
// --- ESP32 自身 OTA:直接写 OTA 分区 ---
if (cmd == BLE_OTA_CMD_ESP32_DATA) {
if (seq == 0) {
if (total_size == 0) {
DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size);
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启");
} else {
DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发");
// 分区选择/大小校验/互斥由 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;
}
}
esp_err_t err = Esp32OtaCore::instance().write(&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;
}
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);
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
(total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) ||
isLastPacket) {
if (isLastPacket) {
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 ? " [尾包]" : "");
BleOta::instance().m_esp32LastCmd = cmd;
BleOta::instance().m_esp32LastSeq = seq;
// 注:与 WiFi 流程对齐,传输完成后由前端发送 0xF8 + SHA-256 触发 finalize,
// 设备端不再自动创建收尾任务。
}
}
return 0;
}
// --- GD32 OTA:首个分块时创建存储元数据 ---
else if (cmd == BLE_OTA_CMD_GD32_DATA) {
if (seq == 0 && total_size > 0) {
uint32_t rounded_size = (uint32_t)(((total_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE);
if (total_size == 0U || total_size > OTA_MAX_FIRMWARE_SIZE || rounded_size > OTA_MAX_FIRMWARE_SIZE) {
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;
}
// --- GD32 OTA:首个分块时创建存储元数据 ---
if (seq == 0 && total_size > 0) {
uint32_t rounded_size = (uint32_t)(((total_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE);
if (total_size == 0U || total_size > OTA_MAX_FIRMWARE_SIZE || rounded_size > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " rounded48=%" PRIu32 " max=%zu",
total_size, rounded_size, OTA_MAX_FIRMWARE_SIZE);
return BLE_ATT_ERR_UNLIKELY;
ota_meta_t meta = {};
meta.total_size = total_size;
meta.stored_size = 0;
meta.last_seq = 0;
esp_err_t err = OtaRelay::metaSave(&meta);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "元数据保存失败");
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu",
meta.total_size, data_len);
}
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;
}
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;
}
// --- GD32 写入 Flash ---
uint32_t flash_offset = meta.stored_size;
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;
}
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;
}
uint32_t stored_size = flash_offset + (uint32_t)data_len;
err = OtaRelay::metaUpdate(stored_size, seq);
if (err != ESP_OK) {
DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq);
}
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
(meta.total_size > 0 && stored_size >= meta.total_size) ||
(expected_payload < BLE_OTA_CHUNK_PAYLOAD)) {
sendWindowAck(cmd, seq, stored_size);
}
DEBUG_DBG(TAG, "Flash 写入成功: seq=%u offset=%" PRIu32 " stored=%" PRIu32,
seq, flash_offset, stored_size);
// --- 检查 GD32 固件是否接收完毕 ---
if (OtaRelay::metaLoad(&meta) == ESP_OK &&
meta.total_size > 0 &&
meta.stored_size >= meta.total_size) {
DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
meta.stored_size, meta.total_size, seq + 1);
// 计算并存储 SHA-256 校验和
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));
}
}
} else {
DEBUG_WARN(TAG, "DATA_IN 未知 cmd: 0x%02X offset=%zu", cmd, offset);
}
ota_meta_t meta = {};
meta.total_size = total_size;
meta.stored_size = 0;
meta.last_seq = 0;
meta.chunk_count = (total_size + (uint32_t)data_len - 1U) / (uint32_t)data_len;
// 推进到下一个包
offset += packet_len;
esp_err_t err = OtaRelay::metaSave(&meta);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "元数据保存失败");
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32 ", payload=%zu",
meta.total_size, meta.chunk_count, data_len);
}
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;
}
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;
}
// --- GD32 写入 Flash ---
// 按已存储字节数顺序追加,兼容更大的 BLE payload,避免把步长硬编码死。
uint32_t flash_offset = meta.stored_size;
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;
}
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;
}
uint32_t stored_size = flash_offset + (uint32_t)data_len;
err = OtaRelay::metaUpdate(stored_size, seq);
if (err != ESP_OK) {
DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq);
}
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
(meta.total_size > 0 && stored_size >= meta.total_size)) {
sendWindowAck(cmd, seq, stored_size);
}
DEBUG_DBG(TAG, "Flash 写入成功: seq=%u offset=%" PRIu32 " stored=%" PRIu32,
seq, flash_offset, stored_size);
// --- 检查 GD32 固件是否接收完毕 ---
if (OtaRelay::metaLoad(&meta) == ESP_OK &&
meta.total_size > 0 &&
meta.stored_size >= meta.total_size) {
DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
meta.stored_size, meta.total_size, seq + 1);
// 检查剩余数据是否足够组成一个完整的包头
if (offset + 7 > (size_t)recv_len) break;
}
return 0;
@@ -733,17 +766,17 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
// 向对端请求更快的连接参数,提升 OTA 吞吐
struct ble_gap_upd_params up = {};
up.itvl_min = 6; // 7.5 ms 最小连接间隔
up.itvl_max = 12; // 15 ms 最大连接间隔
up.itvl_max = 6; // 7.5 ms 固定间隔(避免对端选大值)
up.latency = 0; // 无延迟
up.supervision_timeout = 2000; // 2 秒监控超时
up.min_ce_len = 0; // 控制器自行决定
up.max_ce_len = 0;
up.min_ce_len = 12; // 最小连接事件长度 12×0.625=7.5ms,尽量多传包
up.max_ce_len = 12;
int rc = ble_gap_update_params(BleOta::instance().m_connHandle, &up);
if (rc != 0) {
DEBUG_WARN(TAG, "请求更新连接参数失败: rc=%d(对端不一定接受,继续运行)", rc);
} else {
DEBUG_INFO(TAG, "已请求更快的连接参数: itvl=%.1f-%.1fms latency=0",
up.itvl_min * 1.25f, up.itvl_max * 1.25f);
DEBUG_INFO(TAG, "已请求更快的连接参数: itvl=%.1fms ce_len=%.1fms latency=0",
up.itvl_min * 1.25f, up.min_ce_len * 0.625f);
}
} else {
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
@@ -883,6 +916,11 @@ static void ble_ota_on_sync(void)
// GATT 服务已在 serverStart() 中注册,不再重复注册
// 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY
DEBUG_INFO(TAG, "DATA_OUT 值句柄: %u", s_data_out_val_handle);
if (s_data_out_val_handle == 0) {
DEBUG_WARN(TAG, "DATA_OUT 值句柄为 0,通知功能不可用");
}
BleOta::instance().m_bleReady = true;
ble_ota_start_advertising();
}
@@ -973,8 +1011,7 @@ esp_err_t BleOta::serverStart()
nimble_port_freertos_init(ble_ota_nimble_host_task);
// Sync val_handle to class member
BleOta::instance().mDataOutValHandle = s_data_out_val_handle;
// s_data_out_val_handle 由 on_sync 回调填充,此处不复制
DEBUG_INFO(TAG, "BLE OTA 服务器初始化完成");
return ESP_OK;
}
@@ -990,7 +1027,7 @@ esp_err_t BleOta::notifyResponse(const uint8_t *data, size_t len)
if (!BleOta::instance().m_bleConnected) {
return ESP_ERR_NOT_SUPPORTED;
}
if (BleOta::instance().mDataOutValHandle == 0) {
if (s_data_out_val_handle == 0) {
DEBUG_WARN(TAG, "DATA_OUT 值句柄未设置");
return ESP_ERR_INVALID_STATE;
}
@@ -1003,7 +1040,7 @@ esp_err_t BleOta::notifyResponse(const uint8_t *data, size_t len)
return ESP_ERR_NO_MEM;
}
int rc = ble_gattc_notify_custom(BleOta::instance().m_connHandle, BleOta::instance().mDataOutValHandle, om);
int rc = ble_gattc_notify_custom(BleOta::instance().m_connHandle, s_data_out_val_handle, om);
if (rc != 0) {
DEBUG_WARN(TAG, "通知发送失败: rc=%d", rc);
return ESP_FAIL;
+7 -8
View File
@@ -1,6 +1,9 @@
/*
* ble_ota.h - BLE OTA GATT 服务器
* T5.1 - T5.5
*
* ESP32 本机 OTA 的写入/收尾逻辑已抽离到 Esp32OtaCore(WiFi/BLE 共用)。
* 本类仅保留 BLE 传输层相关状态(连接、收尾任务去重、ACK 窗口)。
*/
#ifndef BLE_OTA_H
#define BLE_OTA_H
@@ -23,15 +26,11 @@ public:
bool m_bleReady = false;
bool m_bleConnected = false;
uint16_t m_connHandle = 0xFFFF;
uint16_t mDataOutValHandle = 0;
bool m_esp32OtaActive = false;
esp_ota_handle_t m_esp32OtaHandle = 0;
const esp_partition_t *m_esp32UpdatePartition = NULL;
size_t m_esp32Received = 0;
bool m_esp32RebootPending = false;
uint8_t m_esp32LastCmd = 0;
uint16_t m_esp32LastSeq = 0xFFFFU;
// BLE 传输相关(OTA 写入状态已移至 Esp32OtaCore)
bool m_esp32RebootPending = false; // 收尾任务去重标志
uint8_t m_esp32LastCmd = 0; // 最后一次数据包命令(用于补发 ACK)
uint16_t m_esp32LastSeq = 0xFFFFU; // 最后一次数据包序号(用于补发 ACK)
};
#endif // BLE_OTA_H
+304 -402
View File
File diff suppressed because it is too large Load Diff
+33 -12
View File
@@ -1,17 +1,19 @@
/*
/*
* wifi_ota.h - WiFi OTA 服务器
* T4.1 - T4.5
*
* WifiOta 类封装 WiFi 初始化、HTTP OTA 服务器和网络状态查询。
* ESP32 本机 OTA 的写入/收尾逻辑已抽离到 Esp32OtaCore(WiFi/BLE 共用)。
*/
#ifndef WIFI_OTA_H
#define WIFI_OTA_H
#include "ota_relay.h"
#include "esp_ota_ops.h"
#include "esp_http_server.h"
#include "esp_event.h"
#include <atomic>
// SHA-256 验证所需类型前向声明
typedef enum {
OTA_WIFI_MODE_AP,
OTA_WIFI_MODE_STA
@@ -29,28 +31,47 @@ public:
static int8_t getRssi();
static bool isConnected();
// 以下状态为 public,供 C 回调函数访问
// ── WiFi 连接状态(WiFi 事件回调 / HTTP / reconnect task 共享,需 mutex 保护)──
bool getStaConnected() const { return m_staConnected.load(); }
void setStaConnected(bool v) { m_staConnected.store(v); }
int getStaRetryCount() const { return m_staRetryCount.load(); }
void setStaRetryCount(int v) { m_staRetryCount.store(v); }
bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); }
void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); }
bool getGd32StorageDone() const { return m_gd32StorageDone.load(); }
void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); }
bool getRelayTriggered() const { return m_relayTriggered.load(); }
void setRelayTriggered(bool v) { m_relayTriggered.store(v); }
// ── 状态互斥锁(保护复合读-改-写操作)──
SemaphoreHandle_t getStateMutex() const { return m_stateMutex; }
void stateLock() { if (m_stateMutex) xSemaphoreTake(m_stateMutex, portMAX_DELAY); }
void stateUnlock() { if (m_stateMutex) xSemaphoreGive(m_stateMutex); }
// ── 初始化/运行时状态(仅 OTA 任务访问,无需额外锁)──
ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP;
bool m_coldBoot = false;
bool m_relayTriggered = false;
bool m_staConnected = false;
int m_staRetryCount = 0;
bool m_staRetryExhausted = false;
SemaphoreHandle_t m_staConnectedSem = NULL;
SemaphoreHandle_t m_staGotIpSem = NULL;
TaskHandle_t m_reconnectTask = nullptr;
bool m_esp32OtaActive = false;
esp_ota_handle_t m_esp32OtaHandle = 0;
const esp_partition_t *m_esp32UpdatePartition = NULL;
size_t m_esp32Received = 0;
bool m_gd32StorageDone = false;
httpd_handle_t m_httpServer = NULL;
private:
// ── 跨任务共享状态(atomic + m_stateMutex 保护)──
std::atomic<bool> m_staConnected{false};
std::atomic<int> m_staRetryCount{0};
std::atomic<bool> m_staRetryExhausted{false};
std::atomic<bool> m_gd32StorageDone{false};
std::atomic<bool> m_relayTriggered{false};
SemaphoreHandle_t m_stateMutex = NULL;
void initAp();
esp_err_t initSta();
void setStaticIp();
static void wifiEventHandler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data);
static void reconnectTask(void *pvParameters);
esp_err_t httpServerStart();
esp_err_t waitStaConnected(char *ip_str, size_t ip_len);
};
+4 -261
View File
@@ -5,7 +5,9 @@
*
* 加密实现使用 ESP32-C3 硬件 AES 加速器(通过 mbedtls port)
*
* 所有功能封装为 Protocol 类的静态方法。
* 当前保留的接口:init、scrambleGenerate(nonce 生成)、
* sessionKeyInit / sessionKeyClear(会话密钥生命周期管理)。
* 帧构建、AES-CBC 加解密、CRC16 等中间件代码已移除。
*/
#include <string.h>
#include <stdio.h>
@@ -29,225 +31,12 @@ void Protocol::init()
if (m_initialized) return;
DEBUG_INFO("PROT", "=== 初始化协议层 ===");
DEBUG_INFO("PROT", "协议层功能: CRC16 Modbus + 硬件 AES-128-CBC");
DEBUG_INFO("PROT", "协议层功能: nonce 扰码 + 会话密钥管理");
m_initialized = true;
DEBUG_INFO("PROT", "协议层就绪");
}
/**
* @brief CRC16 Modbus(LE,低字节在前)
*/
uint16_t Protocol::crc16Modbus(const uint8_t *data, size_t len)
{
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
if (crc & 0x0001) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return (crc >> 8) | (crc << 8);
}
/**
* @brief 构建 SPI 帧: SOF + CMD + LEN(2LE) + PAYLOAD + CRC16(2LE)
*/
int Protocol::buildFrame(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_frame)
{
if (len > FRAME_MAX_PAYLOAD) {
DEBUG_ERR("PROT", "Payload 太大: %zu > %d", len, FRAME_MAX_PAYLOAD);
return -1;
}
size_t idx = 0;
out_frame[idx++] = FRAME_SOF;
out_frame[idx++] = cmd;
out_frame[idx++] = (uint8_t)(len & 0xFF);
out_frame[idx++] = (uint8_t)((len >> 8) & 0xFF);
if (payload && len > 0) {
memcpy(&out_frame[idx], payload, len);
idx += len;
}
uint16_t crc = crc16Modbus(out_frame, idx);
out_frame[idx++] = (uint8_t)(crc & 0xFF);
out_frame[idx++] = (uint8_t)((crc >> 8) & 0xFF);
DEBUG_DBG("PROT", "帧构建: cmd=0x%02X, len=%zu, crc=0x%04X", cmd, len, crc);
return idx;
}
/**
* @brief 解析并校验一帧 SPI 协议数据。
*/
int Protocol::parseFrame(const uint8_t *frame, size_t frame_len,
uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len)
{
if (frame_len < 6) {
DEBUG_WARN("PROT", "帧太短: %zu < 6", frame_len);
return -1;
}
size_t idx = 0;
if (frame[idx++] != FRAME_SOF) {
DEBUG_WARN("PROT", "无效 SOF: 0x%02X (期望 0x%02X)", frame[0], FRAME_SOF);
return -1;
}
uint8_t cmd = frame[idx++];
size_t payload_len = frame[idx] | (frame[idx + 1] << 8);
idx += 2;
if (payload_len > FRAME_MAX_PAYLOAD) {
DEBUG_ERR("PROT", "Payload 长度太大: %zu > %d", payload_len, FRAME_MAX_PAYLOAD);
return -1;
}
size_t expected_len = 1 + 1 + 2 + payload_len + 2;
if (frame_len < expected_len) {
DEBUG_WARN("PROT", "帧太短: %zu < %zu (期望)", frame_len, expected_len);
return -1;
}
uint16_t received_crc = frame[idx + payload_len] | (frame[idx + payload_len + 1] << 8);
uint16_t calc_crc = crc16Modbus(frame, idx + payload_len);
if (received_crc != calc_crc) {
DEBUG_WARN("PROT", "CRC 不匹配: 收到=0x%04X, 计算=0x%04X", received_crc, calc_crc);
return -1;
}
DEBUG_DBG("PROT", "帧解析: cmd=0x%02X, len=%zu, crc=0x%04X OK", cmd, payload_len, calc_crc);
if (out_payload && out_len) {
memcpy(out_payload, &frame[idx], payload_len);
*out_len = payload_len;
}
if (out_cmd) {
*out_cmd = cmd;
}
return 0;
}
/**
* @brief 使用 AES-128 CBC 对明文进行加密(PKCS#7 填充)。
*/
int Protocol::aesEncrypt(const uint8_t *plaintext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *ciphertext_out)
{
if (!plaintext || !key || !iv || !ciphertext_out) {
DEBUG_ERR("PROT", "AES 加密参数无效");
return -1;
}
// PKCS#7 填充
size_t padded_len = ((len / 16) + 1) * 16;
uint8_t padded[FRAME_MAX_PAYLOAD + 16];
if (padded_len > sizeof(padded)) {
DEBUG_ERR("PROT", "填充后长度超出缓冲区: %zu", padded_len);
return -1;
}
memcpy(padded, plaintext, len);
uint8_t pad_val = (uint8_t)(padded_len - len);
memset(&padded[len], pad_val, pad_val);
DEBUG_DBG("PROT", "AES 加密: 明文=%zu 字节, 填充后=%zu 字节", len, padded_len);
// 硬件 AES-CBC 加密(使用 ESP32-C3 硬件 AES 加速器)
esp_aes_context aes_ctx;
esp_aes_init(&aes_ctx);
int ret = esp_aes_setkey(&aes_ctx, key, 128);
if (ret != 0) {
DEBUG_ERR("PROT", "esp_aes_setkey 失败: %d", ret);
esp_aes_free(&aes_ctx);
return -1;
}
uint8_t iv_copy[16];
memcpy(iv_copy, iv, 16);
ret = esp_aes_crypt_cbc(&aes_ctx, ESP_AES_ENCRYPT, padded_len, iv_copy, padded, ciphertext_out);
esp_aes_free(&aes_ctx);
if (ret != 0) {
DEBUG_ERR("PROT", "esp_aes_crypt_cbc 加密失败: %d", ret);
return -1;
}
DEBUG_DBG("PROT", "AES 加密成功: %zu -> %zu 字节", len, padded_len);
return padded_len;
}
/**
* @brief 使用 AES-128 CBC 对密文进行解密(去除 PKCS#7 填充)。
*/
int Protocol::aesDecrypt(const uint8_t *ciphertext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *plaintext_out)
{
if (!ciphertext || !key || !iv || !plaintext_out) {
DEBUG_ERR("PROT", "AES 解密参数无效");
return -1;
}
if (len % 16 != 0 || len == 0) {
DEBUG_ERR("PROT", "Ciphertext 长度无效: %zu (必须为 16 的倍数)", len);
return -1;
}
DEBUG_DBG("PROT", "AES 解密: 密文=%zu 字节", len);
// 硬件 AES-CBC 解密(使用 ESP32-C3 硬件 AES 加速器)
esp_aes_context aes_ctx;
esp_aes_init(&aes_ctx);
int ret = esp_aes_setkey(&aes_ctx, key, 128);
if (ret != 0) {
DEBUG_ERR("PROT", "esp_aes_setkey 失败: %d", ret);
esp_aes_free(&aes_ctx);
return -1;
}
uint8_t iv_copy[16];
memcpy(iv_copy, iv, 16);
ret = esp_aes_crypt_cbc(&aes_ctx, ESP_AES_DECRYPT, len, iv_copy, ciphertext, plaintext_out);
esp_aes_free(&aes_ctx);
if (ret != 0) {
DEBUG_ERR("PROT", "esp_aes_crypt_cbc 解密失败: %d", ret);
return -1;
}
// 移除 PKCS#7 填充
uint8_t pad = plaintext_out[len - 1];
if (pad > 16) {
DEBUG_ERR("PROT", "无效填充: %d", pad);
return -1;
}
size_t orig_len = len - pad;
DEBUG_DBG("PROT", "AES 解密成功: %zu -> %zu 字节 (填充=%d)", len, orig_len, pad);
return orig_len;
}
/**
* @brief 根据时间戳、地址和随机种子生成 16 字节 nonce。
*/
@@ -304,49 +93,3 @@ void Protocol::sessionKeyClear(session_ctx_t *sess)
sess->used = 0;
}
}
/**
* @brief 使用会话上下文加密数据。
*/
int Protocol::sessionEncrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out)
{
if (!sess || !in || !out) return -1;
uint8_t iv_copy[16];
memcpy(iv_copy, sess->iv, 16);
int ret = aesEncrypt(in, len, sess->key, iv_copy, out);
if (ret > 0) {
memcpy(sess->iv, &out[ret - 16], 16);
}
return ret;
}
/**
* @brief 使用会话上下文解密数据。
*/
int Protocol::sessionDecrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out)
{
if (!sess || !in || !out) return -1;
uint8_t iv_copy[16];
memcpy(iv_copy, sess->iv, 16);
int ret = aesDecrypt(in, len, sess->key, iv_copy, out);
if (ret > 0) {
memcpy(sess->iv, &in[len - 16], 16);
}
return ret;
}
/**
* @brief 使用 ESP-IDF 硬件 RNG 生成随机数
*/
void Protocol::generateRandomNonce(uint8_t *nonce, size_t len)
{
esp_fill_random(nonce, len);
}
-35
View File
@@ -17,28 +17,6 @@
#define FRAME_MAX_PAYLOAD (64) // 安全最大 payload
#define FRAME_OVERHEAD (6) // SOF + CMD + LEN(2) + CRC16(2)
// 命令码(对应 GD32 OTA 协议)
#define CMD_HANDSHAKE_REQ (0xF0) // 握手请求
#define CMD_CHALLENGE (0xF1) // 挑战/随机数
#define CMD_SESSION_KEY (0xF2) // 会话密钥
#define CMD_SEND_SIZE (0xF3) // 发送固件大小
#define CMD_DATA_PACKET (0xF4) // 数据包
#define CMD_DATA_END (0xF5) // 数据结束
#define CMD_TRANSFER_DONE (0xF6) // 传输完成
#define CMD_FINISH_OTA (0xF7) // 结束 OTA
#define CMD_ACK (0x80) // 通用 ACK
#define CMD_ERROR (0x81) // 通用错误
// ACK 状态码
#define ACK_OK (0x00) // 成功
#define ACK_UNSUPPORTED (0x01) // 命令不支持
#define ACK_AUTH_FAILED (0x02) // 认证失败
#define ACK_SIZE_OOR (0x03) // 大小超出范围
#define ACK_CRC_FAILED (0x04) // CRC 校验失败
#define ACK_RETRY_EXCEED (0x05) // 重试次数超限
#define ACK_SESSION_INVALID (0x06) // 会话密钥无效
#define ACK_FLASH_FAILED (0x07) // Flash 写入失败
// 安全常量(定义在 app_config.h 中)
inline constexpr size_t MASTER_KEY_HERE_LEN = 16;
inline constexpr size_t SCRAMBLE_CONST_HERE_LEN = 16;
@@ -63,23 +41,10 @@ public:
void init();
static uint16_t crc16Modbus(const uint8_t *data, size_t len);
static int buildFrame(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_frame);
static int parseFrame(const uint8_t *frame, size_t frame_len,
uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len);
static int aesEncrypt(const uint8_t *plaintext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *ciphertext_out);
static int aesDecrypt(const uint8_t *ciphertext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *plaintext_out);
static void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
uint32_t rand_seed, uint8_t *nonce_out);
static int sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session);
static void sessionKeyClear(session_ctx_t *sess);
static int sessionEncrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out);
static int sessionDecrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out);
static void generateRandomNonce(uint8_t *nonce, size_t len);
private:
Protocol() = default;
+74 -19
View File
@@ -10,6 +10,7 @@
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/spi_master.h"
#include "soc/spi_struct.h"
#include "esp_err.h"
@@ -32,7 +33,8 @@ SpiMaster& SpiMaster::instance()
// 内部 SPI 传输
// ============================================================
esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len,
uint8_t *rx_buf, size_t rx_len)
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms)
{
if (m_spiDevice == NULL) {
return ESP_ERR_INVALID_STATE;
@@ -75,21 +77,33 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len,
t.tx_buffer = tx_ptr;
t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
esp_err_t ret = spi_device_transmit(m_spiDevice, &t);
TickType_t ticks = (timeout_ms == 0U) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms);
esp_err_t ret = spi_device_queue_trans(m_spiDevice, &t, ticks);
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "spi_device_transmit fail: %s len=%u tx=%p rx=%p",
esp_err_to_name(ret), (unsigned)(total_len * 8U), t.tx_buffer, t.rx_buffer);
DEBUG_WARN("SPI", "spi_device_queue_trans fail: %s len=%u",
esp_err_to_name(ret), (unsigned)(total_len * 8U));
return ret;
}
if (ret == ESP_OK && rx_len > 0U && rx_ptr != rx_buf) {
spi_transaction_t *ret_trans = NULL;
ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, ticks);
(void)ret_trans;
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "spi_device_get_trans_result timeout/fail: %s len=%u tx=%p rx=%p",
esp_err_to_name(ret), (unsigned)(total_len * 8U), t.tx_buffer, t.rx_buffer);
return ret;
}
if (rx_len > 0U && rx_ptr != rx_buf) {
memcpy(rx_buf, rx_ptr, rx_len);
}
return ret;
return ESP_OK;
}
esp_err_t SpiMaster::transferRequestResponse(uint8_t cmd,
const uint8_t *payload, size_t payload_len,
uint8_t *rx_buf, size_t rx_len)
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms)
{
if ((payload_len > 0U && payload == NULL) || (rx_len > 0U && rx_buf == NULL)) {
return ESP_ERR_INVALID_ARG;
@@ -121,7 +135,8 @@ esp_err_t SpiMaster::transferRequestResponse(uint8_t cmd,
esp_err_t ret = transferClocked(tx_staging, total_len,
(rx_len > 0U) ? rx_staging : NULL,
(rx_len > 0U) ? total_len : 0U);
(rx_len > 0U) ? total_len : 0U,
timeout_ms);
if (ret == ESP_OK && rx_len > 0U) {
memcpy(rx_buf, &rx_staging[cmd_len + payload_len], rx_len);
}
@@ -209,6 +224,14 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
// ============================================================
esp_err_t SpiMaster::init()
{
LockGuard lock(m_mutex);
// 创建 SPI 总线互斥锁(必须在 SPI 设备初始化之前)
m_mutex = xSemaphoreCreateMutex();
if (m_mutex == NULL) {
DEBUG_ERR("SPI", "SPI 互斥锁创建失败");
return ESP_ERR_NO_MEM;
}
DEBUG_INFO("SPI", "=== 初始化 SPI2 总线 ===");
spi_bus_config_t bus_cfg = {};
@@ -255,19 +278,20 @@ esp_err_t SpiMaster::syncTransfer(uint8_t cmd, const uint8_t *payload,
size_t payload_len, uint8_t *recv_buf,
size_t recv_len, uint32_t timeout_ms)
{
(void)timeout_ms;
return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len);
LockGuard lock(m_mutex);
return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len, timeout_ms);
}
esp_err_t SpiMaster::send(uint8_t cmd, const uint8_t *payload, size_t len)
{
return transferRequestResponse(cmd, payload, len, NULL, 0);
LockGuard lock(m_mutex);
return transferRequestResponse(cmd, payload, len, NULL, 0, 0U);
}
esp_err_t SpiMaster::recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms)
{
(void)timeout_ms;
return transferRequestResponse(cmd, NULL, 0, recv_buf, len);
LockGuard lock(m_mutex);
return transferRequestResponse(cmd, NULL, 0, recv_buf, len, timeout_ms);
}
// ============================================================
@@ -277,6 +301,7 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
LockGuard lock(m_mutex);
uint8_t tx_frame[SPI_OTA_FRAME_MAX];
size_t tx_len = 0;
if (!otaBuildFrame(type, payload, payload_len, tx_frame, &tx_len)) {
@@ -285,17 +310,24 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa
DEBUG_DBG("SPI", "OTA TX: type=0x%02X len=%u first4=%02X%02X%02X%02X",
type, (unsigned)tx_len, tx_frame[0], tx_frame[1], tx_frame[2], tx_frame[3]);
esp_err_t ret = transferClocked(tx_frame, tx_len, NULL, 0);
esp_err_t ret = transferClocked(tx_frame, tx_len, NULL, 0, timeout_ms);
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "OTA 请求发送失败: %s", esp_err_to_name(ret));
return ret;
}
return otaPollResponse(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
}
esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
LockGuard lock(m_mutex);
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
}
esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
constexpr size_t kPollBytes = 96U;
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
@@ -305,7 +337,12 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
uint8_t dummy[kPollBytes];
memset(dummy, 0xFF, sizeof(dummy));
esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw));
/* 用剩余时间作为单次 SPI 事务超时,避免总等待远超 deadline */
TickType_t now = xTaskGetTickCount();
uint32_t remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U;
if (remain_ms == 0U) break;
esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), remain_ms);
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "OTA 响应读取失败: %s", esp_err_to_name(ret));
vTaskDelay(pdMS_TO_TICKS(10));
@@ -355,6 +392,7 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
esp_err_t SpiMaster::appRequestBootloader()
{
LockGuard lock(m_mutex);
DEBUG_INFO("SPI", "请求 GD32 APP 进入 Bootloader...");
constexpr uint32_t kMaxRetries = 3U;
@@ -393,6 +431,7 @@ esp_err_t SpiMaster::appRequestBootloader()
esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version)
{
LockGuard lock(m_mutex);
constexpr uint8_t kAppPingType = 0xA1U;
constexpr size_t kAppPingPollBytes = 32U;
@@ -511,13 +550,14 @@ esp_err_t SpiMaster::transferWithRetry(uint8_t cmd, const uint8_t *payload,
size_t payload_len, uint8_t *recv_buf,
size_t recv_len, int max_retries)
{
LockGuard lock(m_mutex);
esp_err_t ret;
int retry_count = 0;
DEBUG_INFO("SPI", "SPI 传输 (带重试): cmd=0x%02X, 最大重试=%d", cmd, max_retries);
while (retry_count < max_retries) {
ret = syncTransfer(cmd, payload, payload_len,
ret = syncTransferUnlocked(cmd, payload, payload_len,
recv_buf, recv_len, 3000);
if (ret == ESP_OK) {
if (retry_count > 0) {
@@ -544,6 +584,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id,
uint8_t *recv_buf, uint8_t recv_max,
uint32_t timeout_ms)
{
LockGuard lock(m_mutex);
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
return -1;
}
@@ -559,7 +600,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id,
memcpy(&tx_buf[tx_len], data, dlc);
tx_len += dlc;
esp_err_t ret = syncTransfer(0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms);
esp_err_t ret = syncTransferUnlocked(0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms);
if (ret != ESP_OK) {
return -1;
}
@@ -570,6 +611,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id,
int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc)
{
LockGuard lock(m_mutex);
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
return -1;
}
@@ -585,7 +627,7 @@ int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id,
memcpy(&tx_buf[tx_len], data, dlc);
tx_len += dlc;
esp_err_t ret = send(0, tx_buf, tx_len);
esp_err_t ret = sendUnlocked(0, tx_buf, tx_len);
if (ret != ESP_OK) {
return -1;
}
@@ -593,4 +635,17 @@ int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id,
return 0;
}
// ============================================================
// 无锁内部版本实现
// ============================================================
esp_err_t SpiMaster::syncTransferUnlocked(uint8_t cmd, const uint8_t *payload,
size_t payload_len, uint8_t *recv_buf,
size_t recv_len, uint32_t timeout_ms)
{
return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len, timeout_ms);
}
esp_err_t SpiMaster::sendUnlocked(uint8_t cmd, const uint8_t *payload, size_t len)
{
return transferRequestResponse(cmd, payload, len, NULL, 0, 0U);
}
+31 -3
View File
@@ -9,6 +9,8 @@
#include <stdint.h>
#include "driver/spi_master.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
// SPI 配置常量
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
@@ -23,7 +25,7 @@
#define SPI_OTA_CMD_DATA (0x02)
#define SPI_OTA_CMD_CANCEL (0x03)
#define SPI_APP_ENTER_BOOT_CMD (0xB0)
#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5)
#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5) // GD32 APP 固件须同步修改 ACK 字节为 0xB5
#define SPI_OTA_RSP_ACK (0x80)
#define SPI_OTA_RSP_WAIT (0x81)
#define SPI_OTA_RSP_ERROR (0x82)
@@ -140,13 +142,39 @@ private:
SpiMaster& operator=(const SpiMaster&) = delete;
spi_device_handle_t m_spiDevice = NULL;
SemaphoreHandle_t m_mutex = NULL;
// RAII 锁守卫
class LockGuard {
public:
explicit LockGuard(SemaphoreHandle_t mutex) : m_lock(mutex) {
if (m_lock) { xSemaphoreTake(m_lock, portMAX_DELAY); }
}
~LockGuard() {
if (m_lock) { xSemaphoreGive(m_lock); }
}
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
SemaphoreHandle_t m_lock;
};
// 内部 SPI 传输
esp_err_t transferClocked(const uint8_t *tx_buf, size_t tx_len,
uint8_t *rx_buf, size_t rx_len);
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms = 0U);
esp_err_t transferRequestResponse(uint8_t cmd, const uint8_t *payload, size_t payload_len,
uint8_t *rx_buf, size_t rx_len);
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms = 0U);
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
esp_err_t syncTransferUnlocked(uint8_t cmd, const uint8_t *payload,
size_t payload_len, uint8_t *recv_buf,
size_t recv_len, uint32_t timeout_ms);
esp_err_t sendUnlocked(uint8_t cmd, const uint8_t *payload, size_t len);
esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
// OTA 帧工具(静态,无状态)
static uint8_t otaCrc8(const uint8_t *data, size_t len);
static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len,
+53
View File
@@ -0,0 +1,53 @@
import urllib.request, json, time, sys
ESP_IP = "192.168.31.127"
# Step 1: Upload ESP32 firmware
bin_path = r"G:\Robot_v2\DCU\ESP32_COMM\build\ESP32_COMM.bin"
with open(bin_path, "rb") as f:
fw_data = f.read()
header = json.dumps({"type": "esp32", "total_size": len(fw_data)}).encode("utf-8")
body = header + fw_data
print(f"[1/3] Uploading {len(fw_data)} bytes...")
req = urllib.request.Request(f"http://{ESP_IP}/ota/upload", data=body, method="POST")
req.add_header("Content-Type", "application/octet-stream")
try:
with urllib.request.urlopen(req, timeout=120) as resp:
result = resp.read().decode()
print(f" Upload OK: {result}")
except urllib.error.HTTPError as e:
body_text = e.read().decode()
print(f" Upload FAILED: HTTP {e.code} - {body_text}")
sys.exit(1)
# Step 2: Trigger update
print("[2/3] Triggering OTA update...")
time.sleep(1)
req2 = urllib.request.Request(f"http://{ESP_IP}/ota/update", data=b"", method="POST")
try:
with urllib.request.urlopen(req2, timeout=10) as resp:
result2 = resp.read().decode()
print(f" Update OK: {result2}")
except (ConnectionResetError, urllib.error.URLError, OSError) as e:
print(f" Update triggered (connection reset expected): {type(e).__name__}")
# Step 3: Wait for ESP32 to come back
print("[3/3] Waiting for ESP32 to reboot with new firmware...")
for attempt in range(1, 31):
time.sleep(5)
try:
req3 = urllib.request.Request(f"http://{ESP_IP}/system/status")
with urllib.request.urlopen(req3, timeout=5) as resp:
data = json.loads(resp.read().decode())
ver = data.get("version", "?")
bld = data.get("build_time", "?")
upt = data.get("uptime_sec", 0)
print(f" Online! version={ver} build={bld} uptime={upt}s")
break
except Exception:
if attempt % 5 == 0:
print(f" Still waiting... ({attempt}/30)")
else:
print(" ESP32 did not come back within 150 seconds")
-12
View File
@@ -1,12 +0,0 @@
*** Begin Patch
*** Update File: G:\Robot_v2\DCU\ESP32_COMM\main\transport\spi_master.h
@@ #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
- #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
+ #define SPI_CLOCK_HZ (10 * 1000 * 1000) // 10MHz
@@ #define SPI_QUEUE_SIZE (1)
- #define SPI_QUEUE_SIZE (1)
+ #define SPI_QUEUE_SIZE (3)
@@ #define SPI_OTA_FRAME_MAX (96)
- #define SPI_OTA_FRAME_MAX (96)
+ #define SPI_OTA_FRAME_MAX (128)
*** End Patch
+1
View File
@@ -1,5 +1,6 @@
# WiFi perf -- OTA acceleration
CONFIG_ESP_WIFI_RX_BA_WIN=6
CONFIG_ESP_WIFI_TX_BA_WIN=6
CONFIG_LWIP_MAX_SOCKETS=10
CONFIG_LWIP_TCP_WND_DEFAULT=11480
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
+15
View File
@@ -0,0 +1,15 @@
import serial, sys, time
ser = serial.Serial('COM15', 115200, timeout=1)
outfile = open('G:\\Robot_v2\\DCU\\ESP32_COMM\\serial_log.txt', 'w', encoding='utf-8')
start = time.time()
while time.time() - start < 300: # 最多 5 分钟
line = ser.readline()
if line:
try:
text = line.decode('utf-8', errors='replace').rstrip()
outfile.write(text + '\n')
outfile.flush()
except:
pass
outfile.close()
ser.close()
+304
View File
@@ -0,0 +1,304 @@
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')
+147
View File
@@ -0,0 +1,147 @@
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')
+37
View File
@@ -0,0 +1,37 @@
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')
+20
View File
@@ -0,0 +1,20 @@
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
View File
@@ -0,0 +1,25 @@
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)
+20
View File
@@ -0,0 +1,20 @@
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:]))
+500 -185
View File
File diff suppressed because it is too large Load Diff
+653 -409
View File
File diff suppressed because it is too large Load Diff
+34 -11
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继控制台</title>
<link rel="stylesheet" href="style.css?v=20260530b">
<link rel="stylesheet" href="style.css?v=20260614c">
</head>
<body>
<div class="container">
@@ -68,8 +68,11 @@
<div class="file-meta-item"><span>类型</span><b id="fileTypeEsp32">-</b></div>
<div class="file-meta-item"><span>大小</span><b id="fileSizeEsp32">-</b></div>
</div>
<div class="file-meta-item" style="margin-top:6px;">
<span>SHA-256</span>
<b id="fileSha256Esp32" style="font-size:10px;word-break:break-all;font-family:monospace;">-</b>
</div>
</div>
</label>
<button class="btn btn-primary btn-block" id="uploadEsp32Btn" onclick="startUpload('esp32')" disabled style="margin-top:10px;">
上传并升级 ESP32
</button>
@@ -96,7 +99,6 @@
<div class="file-meta-item"><span>大小</span><b id="fileSizeGd32">-</b></div>
</div>
</div>
</label>
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled style="margin-top:10px;margin-bottom:8px;">
上传固件到缓存
</button>
@@ -126,8 +128,8 @@
</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=20260509-0046" style="margin-top:10px;text-decoration:none;">
切换到 BLE 蓝牙刷写
<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>
@@ -177,11 +179,32 @@
<div class="card">
<div class="card-head"><span class="ico">🔀</span> OTA 流程阶段</div>
<div class="card-body">
<div class="stage-flow">
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">握手认证</div></div>
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输准备</div></div>
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div></div>
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">完成跳转</div></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>
<!-- 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>
@@ -271,7 +294,7 @@
<span id="toastMsg">操作成功</span>
</div>
<script src="app.js?v=20260603b"></script>
<script src="app.js?v=20260614f"></script>
</body>
</html>
+157 -12
View File
@@ -321,7 +321,7 @@ select option { background: var(--bg-panel); color: var(--text); }
/* === PRIMARY STATUS BADGES (large) === */
.badge-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
}
.badge-stat {
background: var(--bg-elevated); border: 1px solid var(--border);
@@ -373,9 +373,129 @@ select option { background: var(--bg-panel); color: var(--text); }
.stage-label {
font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3;
}
.stage-detail {
font-size: 10px; color: var(--accent); text-align: center; line-height: 1.2;
margin-top: 2px; min-height: 14px;
font-family: var(--font-mono);
transition: color .3s;
}
.stage-node.live .stage-label,
.stage-node.done .stage-label,
.stage-node.err .stage-label { color: var(--text); }
.stage-node.live .stage-detail { color: var(--accent); }
.stage-node.done .stage-detail { color: var(--green); }
.stage-node.err .stage-detail { color: var(--red); }
/* Stage status bar */
.stage-status-bar {
display: flex; align-items: center; gap: 10px;
margin-top: 14px; padding: 10px 16px;
border-radius: 20px;
background: var(--bg-elevated); border: 1px solid var(--border);
font-size: 13px;
transition: all .3s;
}
.stage-status-bar .ssb-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
background: var(--text-dim);
transition: all .3s;
}
.stage-status-bar .ssb-text {
color: var(--text-dim); font-size: 13px; font-weight: 500;
letter-spacing: .01em;
}
/* idle — muted, with subtle pulse */
.stage-status-bar .ssb-dot { animation: dotPulse 2.5s ease-in-out infinite; }
@keyframes dotPulse {
0%, 100% { opacity: .45; }
50% { opacity: .8; }
}
/* active — accent colored */
.stage-status-bar.active {
border-color: rgba(34,211,238,.25);
background: linear-gradient(135deg, rgba(34,211,238,.06), rgba(34,211,238,.02));
}
.stage-status-bar.active .ssb-dot {
background: var(--accent);
box-shadow: 0 0 6px var(--accent-dim);
animation: dotPulseLive 1.2s ease-in-out infinite;
}
.stage-status-bar.active .ssb-text { color: var(--accent); font-weight: 600; }
@keyframes dotPulseLive {
0%, 100% { opacity: .7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.25); }
}
/* ok — green */
.stage-status-bar.ok {
border-color: rgba(34,197,94,.25);
background: linear-gradient(135deg, rgba(34,197,94,.06), rgba(34,197,94,.02));
}
.stage-status-bar.ok .ssb-dot {
background: var(--green);
box-shadow: 0 0 6px rgba(34,197,94,.3);
animation: none;
}
.stage-status-bar.ok .ssb-text { color: var(--green); font-weight: 600; }
/* err — red */
.stage-status-bar.err {
border-color: rgba(239,68,68,.25);
background: linear-gradient(135deg, rgba(239,68,68,.06), rgba(239,68,68,.02));
}
.stage-status-bar.err .ssb-dot {
background: var(--red);
box-shadow: 0 0 6px rgba(239,68,68,.3);
animation: none;
}
.stage-status-bar.err .ssb-text { color: var(--red); font-weight: 600; }
/* Stage verification info panel */
.stage-verify-info {
margin-top: 12px; padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border);
}
.svi-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 6px 0; font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,.03);
}
.svi-row:last-child { border-bottom: none; }
.svi-key {
color: var(--text-label); font-weight: 600;
text-transform: uppercase; letter-spacing: .06em; font-size: 10px;
min-width: 100px; flex-shrink: 0;
padding-top: 2px;
}
.svi-val {
color: var(--text-dim); font-family: var(--font-mono); font-size: 11px;
word-break: break-all;
}
.svi-val.ok { color: var(--green); font-weight: 600; }
.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; }
/* === LOG PANEL === */
.log-panel { display: flex; flex-direction: column; min-height: 380px; }
@@ -417,6 +537,9 @@ select option { background: var(--bg-panel); color: var(--text); }
.log-line .lv.D { color: var(--text-label); }
.log-line .lg { color: #a78bfa; flex-shrink: 0; }
.log-line .lm { color: var(--text); word-break: break-all; }
/* BLE log compat */
.log-line .t { color: var(--text-label); flex-shrink: 0; }
.log-line .tag { color: #a78bfa; flex-shrink: 0; }
.log-foot {
display: flex; justify-content: space-between; align-items: center;
@@ -434,19 +557,41 @@ select option { background: var(--bg-panel); color: var(--text); }
/* === TOAST === */
.toast {
position: fixed; bottom: 28px; right: 28px; z-index: 2000;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 20px;
position: fixed; top: 45%; left: 50%; transform: translateX(-50%) translateY(30px);
z-index: 9999;
display: flex; align-items: center; gap: 10px;
transform: translateY(120px); opacity: 0;
transition: transform .25s ease, opacity .25s ease;
box-shadow: 0 8px 30px rgba(0,0,0,.55);
padding: 14px 24px; border-radius: 10px;
font-size: 14px; font-weight: 600; color: #fff; pointer-events: none;
opacity: 0;
transition: all .35s cubic-bezier(.4,0,.2,1);
box-shadow: 0 8px 32px rgba(0,0,0,.35);
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.ok { border-left: 3px solid var(--green); }
.toast.err { border-left: 3px solid var(--red); }
.toast.warn { border-left: 3px solid var(--amber); }
.toast .toast-ico { font-size: 20px; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
.toast.ok { background: #16a34a; }
.toast.err { background: #dc2626; }
.toast.warn { background: #d97706; }
.toast .toast-ico { font-size: 18px; }
/* === GRID ROWS === */
.row-2, .row-3, .row-4 { display: grid; gap: 10px; }
.row-2 { grid-template-columns: 1fr 1fr; }
.row-3 { grid-template-columns: repeat(3, 1fr); }
.row-4 { grid-template-columns: repeat(4, 1fr); }
/* === SUB ACTIONS === */
.sub-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
/* === FOOTER NOTE === */
.footer-note { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--text-dim); }
/* === CODE === */
code { color: #d3f9ff; font-family: var(--font-mono); }
/* === FILE INFO BORDER === */
.file-info { border: 1px solid var(--border); }
/* === PILL LIVE === */
.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; }
/* === RESPONSIVE === */
@media (max-width: 1100px) {