From 00ce8dbbb58eccff572329e946c702156ea88787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Sun, 21 Jun 2026 23:48:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(ota):=20=E5=AE=9E=E7=8E=B0=20GD32=20Flash?= =?UTF-8?q?=20=E5=AD=98=E5=82=A8=E6=8A=BD=E8=B1=A1=E5=B1=82=E5=92=8C=20OTA?= =?UTF-8?q?=20=E5=8D=8F=E8=B0=83=E5=99=A8=E6=8E=A5=E5=8F=A3=E8=A7=A3?= =?UTF-8?q?=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 storage/gd32_flash_store.cpp 组件,提供 GD32 Flash 存储抽象层 - 修改 ota_coordinator.cpp/h 使其继承 IRelayListener 接口,实现与 OtaRelay 的解耦设计 - 将 GD32 相关存储操作统一通过协调器访问,解决 H3 绕过协调器的问题 - 添加完整的 Doxygen 注释到所有公共接口和关键函数 - 更新 CMakeLists.txt 构建配置,添加 storage 组件依赖 - 重构元数据加载/清除操作使用 Gd32FlashStore 实例而非 OtaRelay 直接访问 - 添加中继事件回调接口实现,使 OtaRelay 可通知协调器状态变化 --- .qoder/specs/ESP32_OTA_架构分析_task-09f.md | 126 +++ CMakeLists.txt | 6 +- config/partitions.csv | 6 + main/CMakeLists.txt | 60 +- main/include/app_config.h | 82 +- main/include/ota_mutex.h | 19 +- main/include/version.h | 8 +- main/monitor/debug_log.cpp | 5 + main/monitor/debug_log.h | 20 +- main/monitor/health_monitor.h | 13 +- main/ota/ota_coordinator.cpp | 87 +- main/ota/ota_coordinator.h | 88 +- main/relay/ota_relay.cpp | 430 +++------ main/relay/ota_relay.h | 153 +-- main/server/ble_ota.cpp | 44 +- main/server/ble_ota.h | 21 +- main/server/http_handlers.cpp | 836 +++++++++++++++++ main/server/http_handlers.h | 22 + main/server/wifi_ota.cpp | 983 +------------------- main/server/wifi_ota.h | 29 +- main/storage/gd32_flash_store.cpp | 351 +++++++ main/storage/gd32_flash_store.h | 146 +++ main/transport/protocol.cpp | 10 +- main/transport/protocol.h | 4 +- main/transport/spi_master.h | 4 +- ota_upload_test.py | 12 +- serial_monitor.py | 15 +- tools/app.js | 34 +- tools/ble_ota.js | 7 + tools/index.html | 7 +- 30 files changed, 2087 insertions(+), 1541 deletions(-) create mode 100644 .qoder/specs/ESP32_OTA_架构分析_task-09f.md create mode 100644 main/server/http_handlers.cpp create mode 100644 main/server/http_handlers.h create mode 100644 main/storage/gd32_flash_store.cpp create mode 100644 main/storage/gd32_flash_store.h diff --git a/.qoder/specs/ESP32_OTA_架构分析_task-09f.md b/.qoder/specs/ESP32_OTA_架构分析_task-09f.md new file mode 100644 index 0000000..2aa3c75 --- /dev/null +++ b/.qoder/specs/ESP32_OTA_架构分析_task-09f.md @@ -0,0 +1,126 @@ +# ESP32-C3 OTA 中继固件 — 架构分析报告 + +## 总体评价 + +项目核心设计思路**方向正确**:单例模式、协调器串行化、事件驱动状态机、ESP32 OTA 核心复用 — 这些都是优秀的嵌入式架构选择。但在职责分配和模块耦合方面存在显著问题,主要集中在 **OtaRelay 职责过载** 和 **传输层绕过协调器** 两个核心缺陷上。 + +--- + +## 高危问题 (4 个) + +### H1. `OtaRelay` 职责严重超载(9 项职责) +- **文件**: [ota_relay.h](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.h) — 375 行,30+ public 方法 +- **问题**: 同时承担:①事件驱动状态机 ②Flash 分区读写 ③NVS 元数据持久化 ④SHA-256 校验 ⑤AES-128-ECB 加密 ⑥CRC32 计算 ⑦协议帧解析 ⑧挑战认证 ⑨GD32 Bootloader 协商 +- **影响**: 任何存储/加密层的修改都可能波及状态机逻辑,测试和维护成本高 + +### H2. `wifi_ota.cpp` 是 God Class(1827 行,8+ 职责) +- **文件**: [wifi_ota.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/wifi_ota.cpp) +- **问题**: 同时管理 WiFi STA/AP、HTTP 服务器全部路由、GD32 固件接收+Flash 写入、状态查询 API、RSSI 采集、BLE 共存、日志 HTTP 接口 +- **影响**: 修改任何单一功能都需要理解整个文件,回归风险极高 + +### H3. 传输层绕过协调器直接访问 OtaRelay +- **证据**: + - `wifi_ota.cpp` 直接调用 `OtaRelay::flashWriteChunk()`、`OtaRelay::metaSave()`、`OtaRelay::metaUpdate()` + - `ble_ota.cpp` 直接访问 `OtaRelay::resultQueue`、`OtaRelay::metaLoad()`、`OtaRelay::verifyFrontendSha()` +- **矛盾**: [ota_coordinator.h](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.h) L52 注释明确要求"传输层不再直接调用 OtaRelay / Esp32OtaCore 的底层接口",但实际被违反 +- **影响**: 协调器的"唯一入口"设计意图落空,状态一致性无法保证 + +### H4. OtaCoordinator ↔ OtaRelay 双向紧耦合 +- **证据**: 协调器直接操作 `OtaRelay::dispatchQueue`、`getState()`、`metaLoad()`、`metaClear()`([ota_coordinator.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp) 多处);OtaRelay 回调协调器 `notifyRelayStarted/Complete()` +- **影响**: 任一端修改都可能波及另一端,违反依赖倒置原则 + +--- + +## 中危问题 (5 个) + +### M1. 全局互斥机制双重 +- `ota_mutex.h` 全局锁 + `OtaCoordinator` 状态锁两套并行,`Esp32OtaCore::begin()` 被两层锁保护 +- 增加死锁面和调试难度 + +### M2. HealthMonitor 向上依赖 WifiOta +- [health_monitor.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp) `#include "server/wifi_ota.h"` — 底层监控模块依赖上层服务器模块 +- 违反层次化架构原则 + +### M3. OtaRelay 大量公开静态成员 +- `relayState`、`dispatchQueue`、`resultQueue`、`gd32AppAlive` 等 7 个 public static 成员实质是全局变量 +- 虽有 mutex 保护但破坏封装性 + +### M4. SpiMaster 职责偏多(836 行) +- [spi_master.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/spi_master.cpp) 同时承担 SPI 总线驱动、OTA 帧协议、CANFD 通信、APP Ping 协议解析 +- CANFD 与 OTA 帧协议是不同抽象层 + +### M5. 构建系统无模块隔离 +- [main/CMakeLists.txt](file:///g:/Robot_v2/DCU/ESP32_COMM/main/CMakeLists.txt) 所有 10 个 .cpp 文件注册在单一 `idf_component_register` 中 +- 任何模块可 `#include` 任何其他模块头文件,无编译期依赖约束 + +--- + +## 低危问题 (3 个) + +### L1. BleOta → WifiOta 横向耦合 +- BLE 直接调用 `WifiOta::instance().setBleOtaActive()` 管理射频共存,应由协调器管理 + +### L2. WiFi 密码硬编码 +- [app_config.h](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h) 明文写入源码,应使用 NVS 或 Kconfig + +### L3. `const_cast` 绕过 const +- [ota_coordinator.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp) `getSystemState() const` 中 `const_cast` 获取锁,`m_mutex` 应声明为 `mutable` + +--- + +## 并发安全问题 + +| 问题 | 位置 | 风险 | +|---|---|---| +| `processEvent()` 读 `relayState` 未持锁 | ota_relay.cpp | 状态撕裂读 | +| BLE `m_bleConnected`/`m_connHandle` 无保护 | ble_ota.cpp/h | 并发读写 | +| `DebugLog::log()` 自动初始化非线程安全 | debug_log.cpp | 竞态初始化 | +| `DebugLog::log()` 1.1KB 栈帧 | debug_log.cpp | 4096 栈任务中栈溢出高风险 | + +--- + +## 架构亮点(值得保留) + +- **协调器模式**:串行化 GD32/ESP32 操作的思路正确 +- **事件驱动状态机**:OtaRelay 的状态表设计清晰,7 状态 + 事件驱动模式优于线性流程 +- **看门狗自愈**:协调器 90s watchdog 自动脱离卡死状态 +- **RAII 守卫**:StateLockGuard 等模式保证锁释放 +- **ESP32 OTA 核心复用**:BLE/WiFi 共用 Esp32OtaCore,正确隔离传输层差异 + +--- + +## 改进路线图(按优先级) + +### Task 1: 提取 GD32 Flash 存储抽象层 [解决 H1 + H3] +- 创建 `storage/gd32_flash_store.h/.cpp` +- 封装 `flashWriteChunk()`、`flashReadChunk()`、`metaLoad/Save/Update/Clear()`、`computeFlashSha256()` +- 从 OtaRelay 中剥离存储职责 + +### Task 2: 协调器补齐 GD32 存储 API [解决 H3] +- 在 OtaCoordinator 中新增 `writeGd32Chunk()`、`verifyGd32Firmware()` 等接口 +- 传输层(WiFi/BLE)只通过协调器操作,消除绕过 + +### Task 3: 拆分 wifi_ota.cpp [解决 H2] +- HTTP 路由处理提取为 `server/http_handlers.cpp` +- GD32 固件接收逻辑移入协调器或独立 receiver + +### Task 4: 统一互斥机制 [解决 M1] +- 移除 `ota_mutex.h` 全局锁,全部由 OtaCoordinator 管理 + +### Task 5: 解耦 HealthMonitor [解决 M2] +- 提供 `setWifiConnected(bool)` 推送接口,替代主动查询 WifiOta + +### Task 6: 修复并发安全 [解决并发问题] +- `processEvent()` 读 `relayState` 加锁 +- BLE 状态变量加 mutex 或 atomic 保护 +- `m_mutex` 改为 `mutable` + +--- + +## 被否决的方案 + +| 方案 | 否决原因 | +|---|---| +| 将 CMakeLists 拆分为多个 ESP-IDF 组件 | 嵌入式资源受限,多组件增加构建复杂度和 Flash 占用,收益有限 | +| 拆分 SpiMaster 为多个类 | SPI 驱动+帧协议+CANFD 在嵌入式场景下紧耦合合理,强行拆分会增加调用开销 | +| 引入接口/抽象基类 | C++ 虚函数在 ESP32-C3 上增加 Flash 和 RAM 开销,当前规模下不值得 | diff --git a/CMakeLists.txt b/CMakeLists.txt index e43450c..d9d0adc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,6 @@ -cmake_minimum_required(VERSION 3.22) +# ESP32-C3 OTA 中继项目 - 根构建文件 +# 要求 ESP-IDF v6.0+,使用 idf.py build 编译 +cmake_minimum_required(VERSION 3.22) include($ENV{IDF_PATH}/tools/cmake/project.cmake) -project(ESP32_COMM) +project(ESP32_COMM) # 项目名:ESP32 通信模块 diff --git a/config/partitions.csv b/config/partitions.csv index 4aedb95..5c3eea9 100644 --- a/config/partitions.csv +++ b/config/partitions.csv @@ -1,8 +1,14 @@ # ESP32-C3FH4 OTA Relay - Partition Table (4MB Flash) # Name, Type, SubType, Offset, Size, Label +# NVS: 系统配置 + OTA 元数据 (24KB) nvs, data, nvs, 0x9000, 0x6000, +# OTA Data: AB 分区启动标志 (8KB) otadata, data, ota, 0xF000, 0x2000, +# GD32 OTA: GD32 固件缓存区 (252KB),使用 spiffs 子类型以便 esp_partition API 直接读写 gd32_ota, data, spiffs, 0x11000, 0x3F000, +# APP0: ESP32 固件主分区 (1.25MB) app0, app, ota_0, 0x50000, 0x140000, +# APP1: ESP32 固件备份分区 (1.25MB),支持 AB 分区回滚 app1, app, ota_1, 0x190000, 0x140000, +# SPIFFS: 用户数据文件系统 (1.06MB) spiffs, data, spiffs, 0x2D0000, 0x110000, \ No newline at end of file diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 54c3dc9..32f0444 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,41 +1,45 @@ -idf_component_register( +# ESP32-C3 OTA 中继项目 - 主组件构建配置 +idf_component_register( SRCS - "core/main.cpp" - "ota/esp32_ota_core.cpp" - "ota/ota_coordinator.cpp" - "relay/ota_relay.cpp" - "transport/spi_master.cpp" - "transport/protocol.cpp" - "server/wifi_ota.cpp" - "server/ble_ota.cpp" - "monitor/health_monitor.cpp" - "monitor/debug_log.cpp" + "core/main.cpp" # 程序入口 (app_main) + "ota/esp32_ota_core.cpp" # ESP32 本机 OTA 核心(分区写入/SHA验证) + "ota/ota_coordinator.cpp" # OTA 多通道协调器(状态机/互斥) + "relay/ota_relay.cpp" # GD32 SPI OTA 中继状态机 + "storage/gd32_flash_store.cpp" # GD32 Flash 存储抽象层(H1 提取) + "transport/spi_master.cpp" # SPI 总线驱动 + CANFD 接口 + "transport/protocol.cpp" # 帧协议 + AES 加密 + CRC + "server/wifi_ota.cpp" # WiFi HTTP OTA 服务器 + "server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取) + "server/ble_ota.cpp" # BLE GATT OTA 服务器 + "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存) + "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) INCLUDE_DIRS "." "core" "ota" "relay" + "storage" # GD32 Flash 存储抽象层头文件 "transport" "server" "monitor" - "include" + "include" # 公共头文件(app_config.h, version.h, ota_mutex.h) REQUIRES - app_update - cjson - esp_driver_spi - esp_driver_tsens - esp_driver_uart - esp_wifi - esp_netif - esp_event - esp_partition - esp_timer - esp_http_server - esp_blockdev - nvs_flash - freertos - mbedtls - bt + app_update # ESP32 OTA API + cjson # JSON 解析/生成 + esp_driver_spi # SPI 主设备驱动 + esp_driver_tsens # 温度传感器驱动 + esp_driver_uart # UART 驱动(调试日志) + esp_wifi # WiFi STA/AP 模式 + esp_netif # 网络接口层 + esp_event # 事件循环 + esp_partition # Flash 分区操作 + esp_timer # 高精度定时器 + esp_http_server # HTTP OTA 服务器 + esp_blockdev # Flash 块设备 + nvs_flash # NVS 存储(OTA 元数据) + freertos # RTOS 内核 + mbedtls # SHA-256 / AES 加密 + bt # NimBLE BLE 协议栈 ) # 每次构建时自动刷新 version.h(BUILD_TIME / GIT_HASH) diff --git a/main/include/app_config.h b/main/include/app_config.h index 09de0bf..f9f5d93 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -13,9 +13,9 @@ // ============================================================ // 固件版本(由 version.h 自动生成,此处提供 C++ constexpr 访问) // ============================================================ -inline constexpr const char *FW_VERSION = FW_VERSION_STRING; -inline constexpr const char *FW_BUILD_TIME = BUILD_TIME; -inline constexpr const char *FW_GIT_HASH = GIT_HASH; +inline constexpr const char *FW_VERSION = FW_VERSION_STRING; ///< 固件版本字符串(自动生成) +inline constexpr const char *FW_BUILD_TIME = BUILD_TIME; ///< 编译时间字符串(自动生成) +inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(自动生成) // ============================================================ // WiFi 工作模式选择 @@ -27,9 +27,9 @@ inline constexpr uint8_t CONFIG_WIFI_MODE = 1; // ============================================================ // WiFi STA 模式配置(连接路由器) // ============================================================ -inline constexpr const char* WIFI_STA_SSID = "004舰桥"; -inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622"; -inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000; +inline constexpr const char* WIFI_STA_SSID = "004舰桥"; ///< STA 模式目标 WiFi 名称 +inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622"; ///< STA 模式 WiFi 密码 +inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000; ///< STA 连接超时(毫秒) inline constexpr uint8_t WIFI_STA_MAX_RETRY = 5; // 最大重连次数,超过后切换 AP 模式 // STA 静态 IP @@ -44,16 +44,16 @@ inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.31.1"; // ============================================================ // WiFi AP 模式配置(设备当热点) // ============================================================ -inline constexpr const char* WIFI_AP_SSID = "ESP32_OTA_RELAY"; -inline constexpr const char* WIFI_AP_PASSWORD = "12345678"; -inline constexpr uint8_t WIFI_AP_CHANNEL = 6; -inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; +inline constexpr const char* WIFI_AP_SSID = "ESP32_OTA_RELAY"; ///< AP 模式热点名称 +inline constexpr const char* WIFI_AP_PASSWORD = "12345678"; ///< AP 模式热点密码 +inline constexpr uint8_t WIFI_AP_CHANNEL = 6; ///< AP 模式工作信道 +inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; ///< AP 模式最大连接数 // ============================================================ // HTTP 服务器配置 // ============================================================ -inline constexpr uint16_t HTTP_SERVER_PORT = 80; -inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; +inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服务器监听端口 +inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; ///< HTTP 服务器任务栈大小(字节) // ============================================================ // OTA 配置 @@ -68,40 +68,40 @@ inline constexpr uint32_t OTA_PACKET_DELAY_MS = 5; // 每包发送 // ============================================================ // 健康监控阈值 // ============================================================ -inline constexpr int32_t WIFI_RSSI_CRITICAL = -85; -inline constexpr int32_t WIFI_RSSI_WARNING = -75; -inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; -inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; -inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; -inline constexpr int32_t TEMP_WARNING = 80; -inline constexpr int32_t TEMP_CRITICAL = 100; -inline constexpr size_t HEAP_WARNING_MIN = 10240; +inline constexpr int32_t WIFI_RSSI_CRITICAL = -85; ///< WiFi 信号严重警告阈值(dBm) +inline constexpr int32_t WIFI_RSSI_WARNING = -75; ///< WiFi 信号一般警告阈值(dBm) +inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; ///< OTA 传输期间 WiFi 信号临界阈值 +inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; ///< RSSI 采样最大有效期(毫秒) +inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; ///< 每 N 包执行一次健康检查 +inline constexpr int32_t TEMP_WARNING = 80; ///< 芯片温度警告阈值(℃) +inline constexpr int32_t TEMP_CRITICAL = 100; ///< 芯片温度严重警告阈值(℃) +inline constexpr size_t HEAP_WARNING_MIN = 10240; ///< 堆内存警告阈值(字节) // ============================================================ // 调试配置(UDP 日志) // ============================================================ -inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; -inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; -inline constexpr uint16_t DEBUG_LOG_PORT = 6666; +inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; ///< UDP 调试日志开关(1=启用) +inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; ///< UDP 日志目标 IP +inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口 // ============================================================ // BLE OTA 配置 // ============================================================ -inline constexpr const char* BLE_DEVICE_NAME = "ESP32-OTA-RELAY"; -inline constexpr uint16_t BLE_MTU_SIZE = 512; -inline constexpr uint16_t BLE_OTA_SERVICE_UUID = 0xFFFF; -inline constexpr uint16_t BLE_OTA_CTRL_UUID = 0x0001; -inline constexpr uint16_t BLE_OTA_DATA_IN_UUID = 0x0002; -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; -inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; +inline constexpr const char* BLE_DEVICE_NAME = "ESP32-OTA-RELAY"; ///< BLE 广播设备名 +inline constexpr uint16_t BLE_MTU_SIZE = 512; ///< BLE MTU 协商大小 +inline constexpr uint16_t BLE_OTA_SERVICE_UUID = 0xFFFF; ///< OTA GATT 服务 UUID +inline constexpr uint16_t BLE_OTA_CTRL_UUID = 0x0001; ///< OTA 控制特征 UUID(写入命令) +inline constexpr uint16_t BLE_OTA_DATA_IN_UUID = 0x0002; ///< OTA 数据输入特征 UUID(写固件包) +inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003; ///< OTA 数据输出特征 UUID(通知/ACK) +inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004; ///< OTA 版本信息特征 UUID(只读) +inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_GD32 = 0xF7; ///< BLE 控制命令:触发 GD32 中继 OTA +inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_ESP32 = 0xF8; ///< BLE 控制命令:触发 ESP32 本机 OTA +inline constexpr uint8_t BLE_OTA_CTRL_VERIFY_SHA256 = 0xF9; ///< BLE 控制命令:SHA-256 哈希验证 +inline constexpr uint8_t BLE_OTA_CTRL_RESTART_ESP32 = 0xFA; ///< BLE 控制命令:重启 ESP32 +inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; ///< BLE 数据通道:GD32 固件包标识 +inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; ///< BLE 数据通道:ESP32 固件包标识 +inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; ///< BLE 单包数据载荷大小(字节) +inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; ///< BLE 包头+序号+校验开销(字节) // Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW) inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32; @@ -118,9 +118,9 @@ inline constexpr uint8_t MASTER_KEY[] = { inline constexpr size_t SCRAMBLE_CONST_LEN = 16; // GD32_CHIP_UID 仅保留为默认占位,运行时以握手包解出的芯片地址 UID 为准。 -inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U; -inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU; -inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U; +inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U; ///< GD32 设备序列号低字 Flash 地址 +inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU; ///< GD32 设备序列号中字 Flash 地址 +inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U; ///< GD32 设备序列号高字 Flash 地址 inline constexpr uint8_t GD32_CHIP_UID[] = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, diff --git a/main/include/ota_mutex.h b/main/include/ota_mutex.h index a443119..5e26a2b 100644 --- a/main/include/ota_mutex.h +++ b/main/include/ota_mutex.h @@ -9,12 +9,14 @@ #include "freertos/semphr.h" #include -extern SemaphoreHandle_t g_esp32OtaMutex; -extern volatile bool g_esp32OtaOccupied; +extern SemaphoreHandle_t g_esp32OtaMutex; ///< ESP32 OTA 全局互斥锁句柄(main.cpp 中创建) +extern volatile bool g_esp32OtaOccupied; ///< ESP32 OTA 分区占用标志(true=正在OTA) -// 获取全局 OTA 占用标志(带超时),成功返回 true -// 内部: Take Mutex → 检查标志 → 若空闲则置 true → Give Mutex -/** @brief 获取全局 OTA 占用标志(带超时),成功返回 true。 */ +/** + * @brief 获取全局 OTA 占用标志(带超时)。 + * @param timeout_ms 等待互斥锁的超时时间(毫秒)。 + * @return true 成功获取并置占用标志,false 已占用或超时。 + */ 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; @@ -27,9 +29,10 @@ static inline bool esp32_ota_mutex_acquire(uint32_t timeout_ms) { return true; } -// 释放全局 OTA 占用标志 -// 内部: Take Mutex → 清除标志 → Give Mutex -/** @brief 释放全局 OTA 占用标志。 */ +/** + * @brief 释放全局 OTA 占用标志。 + * @note 内部: Take Mutex → 清除标志 → Give Mutex。 + */ static inline void esp32_ota_mutex_release(void) { if (!g_esp32OtaMutex) return; xSemaphoreTake(g_esp32OtaMutex, portMAX_DELAY); diff --git a/main/include/version.h b/main/include/version.h index 5b700f8..d39e515 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 29 -#define FW_VERSION_STRING "0.3.29" +#define FW_VERSION_PATCH 30 +#define FW_VERSION_STRING "0.3.30" // 编译时间 -#define BUILD_TIME "2026-06-19 23:45:14" +#define BUILD_TIME "2026-06-21 23:33:01" // Git 信息 -#define GIT_HASH "59c729d" +#define GIT_HASH "3fff959" diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index 817f9b7..cbc626b 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -108,6 +108,11 @@ bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len) return true; } +/** + * @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。 + * @param out_len 输出 JSON 字符串长度。 + * @return 动态分配的 JSON 字符串,调用者须 free()。 + */ char* DebugLog::ringDrainJson(int *out_len) { if (!out_len) return NULL; diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index e920b3b..39d0252 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -26,9 +26,9 @@ // 调试日志配置(DEBUG_LOG_IP/PORT/ENABLED 已在 app_config.h 中定义) // UART0 调试配置 -#define UART_DEBUG_TX_GPIO 21 -#define UART_DEBUG_RX_GPIO 20 -#define UART_DEBUG_BAUD_RATE 115200 +#define UART_DEBUG_TX_GPIO 21 ///< UART0 TX GPIO 引脚号 +#define UART_DEBUG_RX_GPIO 20 ///< UART0 RX GPIO 引脚号 +#define UART_DEBUG_BAUD_RATE 115200 ///< UART0 波特率 // 日志级别 typedef enum { @@ -74,6 +74,12 @@ public: /** @brief 弹出一条最旧日志并从环形缓冲区移除。 */ bool ringPopOldest(char *out, size_t out_size, int *out_len); + + /** + * @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。 + * @param out_len 输出 JSON 字符串长度。 + * @return 动态分配的 JSON 字符串,调用者须 free()。 + */ char* ringDrainJson(int *out_len); /** @brief 当前日志级别(运行时可改)。 */ @@ -103,9 +109,9 @@ private: }; // 便捷宏 — 直接调用单例方法 -#define DEBUG_ERR(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_ERR, tag, fmt, ##__VA_ARGS__) -#define DEBUG_WARN(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_WARN, tag, fmt, ##__VA_ARGS__) -#define DEBUG_INFO(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_INFO, tag, fmt, ##__VA_ARGS__) -#define DEBUG_DBG(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_DBG, tag, fmt, ##__VA_ARGS__) +#define DEBUG_ERR(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_ERR, tag, fmt, ##__VA_ARGS__) ///< 错误级别日志宏 +#define DEBUG_WARN(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_WARN, tag, fmt, ##__VA_ARGS__) ///< 警告级别日志宏 +#define DEBUG_INFO(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_INFO, tag, fmt, ##__VA_ARGS__) ///< 信息级别日志宏 +#define DEBUG_DBG(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_DBG, tag, fmt, ##__VA_ARGS__) ///< 调试级别日志宏 #endif // DEBUG_LOG_H diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index 2277a1c..7c66eee 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -20,16 +20,17 @@ typedef enum { SYS_SAFE_SHUTDOWN = 3 // 安全关机 } health_state_t; +/** @brief 健康监控模块内部状态码,用于函数返回值。 */ typedef enum { - HEALTH_MON_OK = 0, - HEALTH_MON_WARNING = 1, - HEALTH_MON_CRITICAL = 2, - HEALTH_MON_SHUTDOWN = 3 + HEALTH_MON_OK = 0, ///< 健康检查通过 + HEALTH_MON_WARNING = 1, ///< 健康检查警告(可继续) + HEALTH_MON_CRITICAL = 2, ///< 健康检查危险(建议中止) + HEALTH_MON_SHUTDOWN = 3 ///< 需要安全关机 } health_status_t; // WiFi RSSI 阈值(来自 app_config.h) -#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING -#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL +#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING ///< WiFi 信号正常阈值(别名) +#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL ///< WiFi 信号弱阈值(别名) /** * @brief 系统健康监控类(单例) diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 07e7c05..c70a9b8 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -7,6 +7,7 @@ #include "ota_coordinator.h" #include "esp32_ota_core.h" #include "relay/ota_relay.h" +#include "storage/gd32_flash_store.h" #include "monitor/debug_log.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" @@ -102,10 +103,13 @@ void OtaCoordinator::init() m_gd32StorageDone = false; m_busySinceTick = 0; + // ★ H4 解耦:注册协调器为 OtaRelay 的中继事件监听器 + OtaRelay::setRelayListener(this); + // ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态, // 否则用户重启后看到的状态是 IDLE 但 NVS 里其实有未中继的固件,前后不一致。 ota_meta_t boot_meta; - if (OtaRelay::metaLoad(&boot_meta) == ESP_OK && + if (Gd32FlashStore::instance().metaLoad(&boot_meta) == ESP_OK && boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) { m_sysState = OTA_SYS_GD32_STORED; @@ -140,7 +144,7 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size) // 如果之前已有存储但未中继(STORED)或刚中继完(COMPLETE),先清理再开始新存储 if (h && (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE)) { DEBUG_INFO(TAG, "清理旧 GD32 存储状态,开始新存储 (size=%" PRIu32 ")", total_size); - OtaRelay::metaClear(); + Gd32FlashStore::instance().metaClear(); } if (h) { @@ -167,6 +171,50 @@ void OtaCoordinator::completeGd32Storage() unlock(h); } +// ============================================================ +// GD32 存储操作(传输层统一入口,解决 H3 绕过协调器问题) +// ============================================================ +esp_err_t OtaCoordinator::writeGd32Chunk(uint32_t offset, const uint8_t *data, size_t len) +{ + return Gd32FlashStore::instance().flashWriteChunk(offset, data, len); +} + +esp_err_t OtaCoordinator::saveGd32Meta(const ota_meta_t *meta) +{ + return Gd32FlashStore::instance().metaSave(meta); +} + +esp_err_t OtaCoordinator::loadGd32Meta(ota_meta_t *meta) +{ + return Gd32FlashStore::instance().metaLoad(meta); +} + +esp_err_t OtaCoordinator::updateGd32Meta(uint32_t stored_size, uint32_t last_seq) +{ + return Gd32FlashStore::instance().metaUpdate(stored_size, last_seq); +} + +esp_err_t OtaCoordinator::clearGd32Meta() +{ + return Gd32FlashStore::instance().metaClear(); +} + +esp_err_t OtaCoordinator::verifyGd32Firmware(const uint8_t frontend_sha32[32], + uint8_t out_device_sha[32]) +{ + return Gd32FlashStore::instance().verifyFrontendSha(frontend_sha32, out_device_sha); +} + +esp_err_t OtaCoordinator::finalizeGd32Checksum() +{ + return Gd32FlashStore::instance().metaFinalizeChecksum(); +} + +esp_err_t OtaCoordinator::eraseGd32Partition(uint32_t size) +{ + return Gd32FlashStore::instance().erasePartition(size); +} + // ============================================================ // GD32 中继阶段 // ============================================================ @@ -200,7 +248,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay() // 验证固件是否确实已存储(通过 NVS 元数据) ota_meta_t meta; - if (h && (OtaRelay::metaLoad(&meta) != ESP_OK || + if (h && (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size)) { DEBUG_WARN(TAG, "GD32 固件未完整存储,拒绝触发中继"); @@ -247,6 +295,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay() return ESP_OK; } +/** @brief 通知协调器:GD32 中继已开始,更新系统状态为 GD32_RELAYING。 */ void OtaCoordinator::notifyRelayStarted() { bool h = lock(); @@ -258,6 +307,7 @@ void OtaCoordinator::notifyRelayStarted() unlock(h); } +/** @brief 通知协调器:GD32 中继已完成,根据结果转为 COMPLETE 或 ERROR。 */ void OtaCoordinator::notifyRelayComplete(bool success) { bool h = lock(); @@ -276,6 +326,21 @@ void OtaCoordinator::notifyRelayComplete(bool success) unlock(h); } +// ============================================================ +// IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器) +// ============================================================ +/** @brief IRelayListener 回调:中继已开始,委托给 notifyRelayStarted()。 */ +void OtaCoordinator::onRelayStarted() +{ + notifyRelayStarted(); +} + +/** @brief IRelayListener 回调:中继已完成,委托给 notifyRelayComplete()。 */ +void OtaCoordinator::onRelayComplete(bool success) +{ + notifyRelayComplete(success); +} + // ============================================================ // ESP32 OTA 阶段 // ============================================================ @@ -342,6 +407,7 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size) return err; } +/** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */ void OtaCoordinator::notifyEsp32ReceiveComplete() { bool h = lock(); @@ -352,6 +418,7 @@ void OtaCoordinator::notifyEsp32ReceiveComplete() unlock(h); } +/** @brief 通知协调器:ESP32 OTA 收尾完成(SHA验证+标记启动),状态转为 REBOOTING。 */ void OtaCoordinator::notifyEsp32FinalizeComplete() { bool h = lock(); @@ -362,6 +429,7 @@ void OtaCoordinator::notifyEsp32FinalizeComplete() unlock(h); } +/** @brief 通知协调器:ESP32 即将重启。 */ void OtaCoordinator::notifyEsp32Rebooting() { bool h = lock(); @@ -399,6 +467,11 @@ void OtaCoordinator::restartEsp32() // 不会返回 } +/** + * @brief 将 ESP32 OTA finalize 错误码分类。 + * @param err finalize 返回的 esp_err_t。 + * @return 0=成功,1=SHA不匹配,2=其他失败。 + */ int OtaCoordinator::esp32FinalizeErrCategory(esp_err_t err) { if (err == ESP_OK) return 0; // 成功 @@ -416,7 +489,7 @@ void OtaCoordinator::abortCurrent() DEBUG_INFO(TAG, "中止当前 OTA 操作 (state=%d)", (int)m_sysState); if (m_sysState == OTA_SYS_GD32_STORING) { - OtaRelay::metaClear(); + Gd32FlashStore::instance().metaClear(); } else if (m_sysState == OTA_SYS_GD32_RELAYING) { OtaRelay::instance().reset(); } else if (m_sysState == OTA_SYS_ESP32_RECEIVING || @@ -445,6 +518,7 @@ ota_system_state_t OtaCoordinator::getSystemState() const return s; } +/** @brief 判断系统是否处于空闲状态(IDLE/STORED/COMPLETE)。 */ bool OtaCoordinator::isIdle() const { return (getSystemState() == OTA_SYS_IDLE || @@ -452,6 +526,7 @@ bool OtaCoordinator::isIdle() const getSystemState() == OTA_SYS_GD32_COMPLETE); } +/** @brief 判断 GD32 通道是否正忙(STORING 或 RELAYING)。 */ bool OtaCoordinator::isGd32Busy() const { ota_system_state_t s = getSystemState(); @@ -459,6 +534,7 @@ bool OtaCoordinator::isGd32Busy() const s == OTA_SYS_GD32_RELAYING); } +/** @brief 判断 ESP32 通道是否正忙(RECEIVING/FINALIZING/REBOOTING)。 */ bool OtaCoordinator::isEsp32Busy() const { ota_system_state_t s = getSystemState(); @@ -467,6 +543,7 @@ bool OtaCoordinator::isEsp32Busy() const s == OTA_SYS_ESP32_REBOOTING); } +/** @brief 获取系统状态的字符串名称(用于日志和前端显示)。 */ const char* OtaCoordinator::getSystemStateString() const { switch (getSystemState()) { @@ -483,11 +560,13 @@ const char* OtaCoordinator::getSystemStateString() const } } +/** @brief 获取 GD32 APP 存活状态(委托 OtaRelay)。 */ bool OtaCoordinator::getGd32AppAlive() const { return OtaRelay::getGd32AppAlive(); } +/** @brief 获取 GD32 固件是否已存储完成(线程安全)。 */ bool OtaCoordinator::isGd32StorageDone() const { OtaCoordinator* self = const_cast(this); diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index a20a8db..d4b8f0a 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -19,6 +19,8 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "esp_err.h" +#include "storage/gd32_flash_store.h" +#include "relay/ota_relay.h" // IRelayListener 接口定义(H4 解耦) // ============================================================ // 系统级 OTA 状态枚举 @@ -51,10 +53,11 @@ typedef struct { * 传输层(WiFi/BLE)通过本协调器发起所有 OTA 操作, * 不再直接调用 OtaRelay / Esp32OtaCore 的底层接口。 */ -class OtaCoordinator { +class OtaCoordinator : public IRelayListener { public: /** * @brief 获取 OtaCoordinator 单例实例。 + * @return OtaCoordinator 单例引用。 */ static OtaCoordinator& instance(); @@ -80,6 +83,67 @@ public: */ void completeGd32Storage(); + // ── GD32 存储操作(传输层通过协调器操作 Flash,解决 H3 绕过问题)── + /** + * @brief 向 GD32 Flash 分区写入固件数据块。 + * 传输层(WiFi/BLE)通过此接口写入,不再直接调用 Gd32FlashStore。 + * @param offset 写入偏移。 + * @param data 数据缓冲区。 + * @param len 数据长度。 + * @return ESP_OK 成功。 + */ + esp_err_t writeGd32Chunk(uint32_t offset, const uint8_t *data, size_t len); + + /** + * @brief 保存 GD32 OTA 元数据。 + * @param meta 元数据结构体。 + * @return ESP_OK 成功。 + */ + esp_err_t saveGd32Meta(const ota_meta_t *meta); + + /** + * @brief 加载 GD32 OTA 元数据。 + * @param meta 输出元数据结构体。 + * @return ESP_OK 成功。 + */ + esp_err_t loadGd32Meta(ota_meta_t *meta); + + /** + * @brief 更新 GD32 OTA 元数据的进度。 + * @param stored_size 已存储大小。 + * @param last_seq 最后包序号。 + * @return ESP_OK 成功。 + */ + esp_err_t updateGd32Meta(uint32_t stored_size, uint32_t last_seq); + + /** + * @brief 清除 GD32 OTA 元数据。 + * @return ESP_OK 成功。 + */ + esp_err_t clearGd32Meta(); + + /** + * @brief 校验前端 SHA-256 与设备端固件是否一致。 + * @param frontend_sha32 前端 32 字节 SHA。 + * @param out_device_sha 输出设备端 32 字节 SHA(可为 nullptr)。 + * @return ESP_OK 匹配,ESP_ERR_INVALID_CRC 不匹配。 + */ + esp_err_t verifyGd32Firmware(const uint8_t frontend_sha32[32], + uint8_t out_device_sha[32]); + + /** + * @brief 计算 GD32 固件 SHA-256 并写入元数据。 + * @return ESP_OK 成功。 + */ + esp_err_t finalizeGd32Checksum(); + + /** + * @brief 擦除 GD32 OTA 分区。 + * @param size 擦除大小。 + * @return ESP_OK 成功。 + */ + esp_err_t eraseGd32Partition(uint32_t size); + // ── GD32 中继阶段 ── /** * @brief 触发 GD32 中继转发。 @@ -161,37 +225,44 @@ public: // ── 状态查询 ── /** * @brief 获取当前系统级 OTA 状态。 + * @return 当前 ota_system_state_t 状态值。 */ ota_system_state_t getSystemState() const; /** * @brief 检查系统是否空闲(可接受新的 OTA 请求)。 + * @return true 系统空闲(IDLE/STORED/COMPLETE)。 */ bool isIdle() const; /** * @brief 检查是否有 GD32 相关操作正在进行。 + * @return true 表示 STORING 或 RELAYING。 */ bool isGd32Busy() const; /** * @brief 检查是否有 ESP32 OTA 正在进行。 + * @return true 表示 RECEIVING/FINALIZING/REBOOTING。 */ bool isEsp32Busy() const; /** * @brief 获取系统状态的可读字符串描述。 + * @return 状态名称字符串(如 "IDLE"、"GD32_STORING" 等)。 */ const char* getSystemStateString() const; // ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)── /** * @brief 获取 GD32 APP 存活状态。 + * @return true 表示 GD32 APP 存活。 */ bool getGd32AppAlive() const; /** * @brief GD32 固件存储完成标志(供状态查询)。 + * @return true 表示固件已完整存储。 */ bool isGd32StorageDone() const; @@ -205,10 +276,25 @@ public: /** * @brief 把 finalize 返回的 esp_err_t 映射为前端可读的事件码。 * 统一 BLE/WiFi 两端语义,便于日志/状态一致。 + * @param err finalize 返回的 esp_err_t。 * @return 0=成功;1=SHA 不匹配;2=其他失败。 */ static int esp32FinalizeErrCategory(esp_err_t err); + // ── IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)── + /** + * @brief IRelayListener 回调:中继已开始。 + * 由 OtaRelay task 在 forwardFirmware() 前通过 s_relayListener 调用。 + */ + void onRelayStarted() override; + + /** + * @brief IRelayListener 回调:中继已完成。 + * 由 OtaRelay task 在 forwardFirmware() 返回后通过 s_relayListener 调用。 + * @param success true 成功,false 失败。 + */ + void onRelayComplete(bool success) override; + private: OtaCoordinator() = default; ~OtaCoordinator() = default; diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 29ade54..c33669a 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -16,19 +16,16 @@ #include "freertos/task.h" #include "freertos/queue.h" #include "esp_err.h" -#include "esp_partition.h" -#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 "storage/gd32_flash_store.h" #include "spi_master.h" #include "protocol.h" #include "health_monitor.h" #include "monitor/debug_log.h" -#include "ota_coordinator.h" // ★ 通知 OtaCoordinator 中继开始/完成 +// ★ H4 解耦:OtaRelay 不再直接 #include "ota_coordinator.h", +// 通过 IRelayListener 接口回调通知协调器,依赖方向变为单向。 // ============================================================ // 静态成员定义 @@ -42,6 +39,18 @@ QueueHandle_t OtaRelay::dispatchQueue = NULL; QueueHandle_t OtaRelay::resultQueue = NULL; SemaphoreHandle_t OtaRelay::s_stateMutex = NULL; TaskHandle_t OtaRelay::taskHandle = NULL; +IRelayListener *OtaRelay::s_relayListener = nullptr; + +// ── 中继事件监听器(解决 H4 双向耦合)── +void OtaRelay::setRelayListener(IRelayListener *listener) +{ + s_relayListener = listener; +} + +IRelayListener* OtaRelay::getRelayListener() +{ + return s_relayListener; +} // ============================================================ // 状态表:将每个 relay_state_t 映射到对应的 handler @@ -273,7 +282,7 @@ ota_result_t OtaRelay::handleSizeNeg(relay_event_t event, const uint8_t *payload // 加载元数据获取固件大小 ota_meta_t meta; - if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0) { + if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || meta.total_size == 0) { DEBUG_ERR("OTA", "SIZE_NEG: 元数据无效"); setState(RELAY_ERROR); return OTA_RESULT_ERR_STORAGE; @@ -409,7 +418,7 @@ ota_result_t OtaRelay::handleError(relay_event_t event, const uint8_t *payload, ota_result_t OtaRelay::forwardFirmware() { ota_meta_t meta; - if (OtaRelay::metaLoad(&meta) != ESP_OK) { + if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK) { return OTA_RESULT_ERR_STORAGE; } @@ -417,7 +426,7 @@ ota_result_t OtaRelay::forwardFirmware() // 固件完整性校验 { - esp_err_t verify_err = metaVerifyChecksum(); + esp_err_t verify_err = Gd32FlashStore::instance().metaVerifyChecksum(); if (verify_err != ESP_OK) { DEBUG_ERR("OTA", "固件完整性校验失败,中止转发"); reset(); @@ -492,6 +501,13 @@ void OtaRelay::reset() // ============================================================ // 内部工具函数(不变) // ============================================================ +/** + * @brief 检查缓冲区中所有字节是否为指定值。 + * @param buf 数据缓冲区。 + * @param len 缓冲区长度。 + * @param value 目标字节值。 + * @return true 所有字节均为指定值,false 否则。 + */ static bool bytesAllValue(const uint8_t *buf, size_t len, uint8_t value) { if (!buf || len == 0) return false; @@ -501,6 +517,13 @@ static bool bytesAllValue(const uint8_t *buf, size_t len, uint8_t value) return true; } +/** + * @brief 本地 AES-128-ECB 加密单块(16字节),使用硬件加速。 + * @param key 16 字节密钥。 + * @param in 16 字节明文输入。 + * @param out 16 字节密文输出。 + * @return 0 成功,-1 失败。 + */ int OtaRelay::aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out) { esp_aes_context ctx; @@ -511,6 +534,13 @@ int OtaRelay::aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, return ret; } +/** + * @brief 记录 GD32 SPI 响应的十六进制负载(调试用)。 + * @param stage 当前阶段名称。 + * @param resp_type 响应类型字节。 + * @param payload 响应负载数据。 + * @param len 负载长度。 + */ void OtaRelay::logRespPayload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len) { char hex[193] = {0}; @@ -522,6 +552,17 @@ void OtaRelay::logRespPayload(const char *stage, uint8_t resp_type, const uint8_ n > 0 ? hex : "", len > n ? "..." : ""); } +/** + * @brief 循环轮询 SPI 直到响应不再是 WAIT 类型,超过最大轮询次数后放弃。 + * @param stage 当前阶段名称(日志用)。 + * @param resp_type 输入/输出响应类型(初始为 SPI_OTA_RSP_WAIT)。 + * @param resp_payload 输出响应负载缓冲区。 + * @param resp_max 响应负载缓冲区最大长度。 + * @param resp_len 输出实际响应长度。 + * @param timeout_ms 单次 SPI 轮询超时(毫秒)。 + * @param max_wait_rounds 最大 WAIT 轮询次数。 + * @return ESP_OK 成功,ESP_ERR_TIMEOUT 超时。 + */ esp_err_t OtaRelay::waitUntilNotWait(const char *stage, uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms, uint32_t max_wait_rounds) @@ -539,6 +580,13 @@ esp_err_t OtaRelay::waitUntilNotWait(const char *stage, uint8_t *resp_type, return ESP_OK; } +/** + * @brief 检查响应负载是否为带指定序号的结构化 ACK(0xBA 0xC0 + seq32)。 + * @param payload 响应负载数据。 + * @param len 负载长度。 + * @param expected_seq 期望的序号。 + * @return true 匹配,false 不匹配。 + */ bool OtaRelay::isStructuredAckWithSeq(const uint8_t *payload, size_t len, uint32_t expected_seq) { if (!payload || len < 6) return false; @@ -548,6 +596,14 @@ bool OtaRelay::isStructuredAckWithSeq(const uint8_t *payload, size_t len, uint32 return seq == expected_seq; } +/** + * @brief 解析结构化错误响应(0xBA 0xC0 + seq32 + err_code)。 + * @param payload 响应负载数据。 + * @param len 负载长度。 + * @param seq_out 输出序号(可为 nullptr)。 + * @param err_out 输出错误码(可为 nullptr)。 + * @return true 成功解析为结构化错误,false 不是。 + */ bool OtaRelay::parseStructuredError(const uint8_t *payload, size_t len, uint32_t *seq_out, uint8_t *err_out) { if (!payload || len < 7) return false; @@ -558,18 +614,23 @@ bool OtaRelay::parseStructuredError(const uint8_t *payload, size_t len, uint32_t return true; } +/** @brief 检查响应是否为握手回显 ACK(0x00 0xAC)。 */ bool OtaRelay::isHandshakeEchoAck(const uint8_t *payload, size_t len) { return payload && len >= 2U && payload[0] == 0x00U && payload[1] == 0xACU; } +/** @brief 检查响应是否为挑战认证 ACK(36字节挑战响应)。 */ bool OtaRelay::isChallengePayloadAck(const uint8_t *payload, size_t len) { return payload && len == GD32_CHALLENGE_RESPONSE_LEN; } +/** @brief 检查响应是否为确认明文 ACK(0xBA 0xC0)。 */ bool OtaRelay::isConfirmPlainAck(const uint8_t *payload, size_t len) { return payload && len == 2U && payload[0] == 0xBAU && payload[1] == 0xC0U; } +/** @brief 检查响应是否为固件大小 seq=0 的结构化 ACK。 */ bool OtaRelay::isSizeSeq0Ack(const uint8_t *payload, size_t len) { return isStructuredAckWithSeq(payload, len, 0U); } +/** @brief 检查响应是否为结束指令 ACK(0x0A 0xAC)。 */ bool OtaRelay::isFinishPlainAck(const uint8_t *payload, size_t len) { if (!payload) return false; @@ -577,6 +638,19 @@ bool OtaRelay::isFinishPlainAck(const uint8_t *payload, size_t len) return false; } +/** + * @brief 轮询 SPI 直到匹配指定的 ACK 形状,处理 WAIT 和过期 ACK。 + * @param stage 当前阶段名称。 + * @param resp_type 输入/输出响应类型。 + * @param resp_payload 输入/输出响应负载。 + * @param resp_max 响应负载缓冲区最大长度。 + * @param resp_len 输入/输出实际响应长度。 + * @param poll_timeout_ms 单次 SPI 轮询超时。 + * @param wait_rounds 最大 WAIT 轮询次数。 + * @param maxExtraPolls 最大额外轮询次数。 + * @param matcher ACK 形状匹配函数。 + * @return ESP_OK 成功,其他错误码表示失败。 + */ esp_err_t OtaRelay::pollUntilExpectedAckShape(const char *stage, uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, @@ -609,6 +683,14 @@ esp_err_t OtaRelay::pollUntilExpectedAckShape(const char *stage, return ESP_OK; } +/** + * @brief 解析挑战响应负载,验证 nonce、派生会话密钥和 UID。 + * @param resp_payload GD32 响应负载(36字节)。 + * @param resp_len 响应负载长度。 + * @param out_k_session 输出 16 字节会话密钥。 + * @param out_uid 输出 12 字节 GD32 UID。 + * @return true 验证通过,false 失败。 + */ bool OtaRelay::parseAndVerifyChallengePayload(const uint8_t *resp_payload, size_t resp_len, uint8_t *out_k_session, uint8_t *out_uid) { @@ -641,6 +723,13 @@ bool OtaRelay::parseAndVerifyChallengePayload(const uint8_t *resp_payload, size_ return true; } +/** + * @brief AES-128-ECB 加密 48 字节(分 3 个 16 字节块)。 + * @param key 16 字节密钥。 + * @param in 48 字节明文输入。 + * @param out 48 字节密文输出。 + * @return true 全部加密成功,false 任一块失败。 + */ bool OtaRelay::aes128EcbEncrypt48Bytes(const uint8_t *key, const uint8_t *in, uint8_t *out) { for (int i = 0; i < 3; ++i) @@ -648,6 +737,12 @@ bool OtaRelay::aes128EcbEncrypt48Bytes(const uint8_t *key, const uint8_t *in, ui return true; } +/** + * @brief 使用 CRC32(IEEE 多项式 0xEDB88320)对 key+data48 计算 4 字节校验值。 + * @param key 16 字节密钥(混入 CRC)。 + * @param data48 48 字节数据。 + * @param output 输出 4 字节 CRC32 值(小端序)。 + */ void OtaRelay::calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48, uint8_t *output) { uint32_t crc = 0xFFFFFFFFU; @@ -658,301 +753,10 @@ void OtaRelay::calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48, output[2] = (uint8_t)((crc >> 16) & 0xFFU); output[3] = (uint8_t)((crc >> 24) & 0xFFU); } -// ============================================================ -// Flash / NVS 操作(不变) -// ============================================================ -esp_err_t OtaRelay::metaLoad(ota_meta_t *meta) -{ - if (!meta) return ESP_ERR_INVALID_ARG; - memset(meta, 0, sizeof(ota_meta_t)); - - nvs_handle_t handle; - esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READONLY, &handle); - if (err != ESP_OK) { - return ESP_OK; - } - - uint32_t val = 0; - 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; - - size_t sha_len = sizeof(meta->checksum); - if (nvs_get_blob(handle, "checksum", meta->checksum, &sha_len) == ESP_OK && sha_len == sizeof(meta->checksum)) { - } else { - memset(meta->checksum, 0, sizeof(meta->checksum)); - } - - nvs_close(handle); - return ESP_OK; -} - -esp_err_t OtaRelay::metaSave(const ota_meta_t *meta) -{ - if (!meta) return ESP_ERR_INVALID_ARG; - - nvs_handle_t handle; - esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READWRITE, &handle); - if (err != ESP_OK) { - DEBUG_ERR("OTA", "无法打开 NVS 写入: %s", esp_err_to_name(err)); - return err; - } - - 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_blob(handle, "checksum", meta->checksum, sizeof(meta->checksum)); - err = nvs_commit(handle); - nvs_close(handle); - - if (err != ESP_OK) { - DEBUG_ERR("OTA", "NVS commit 失败: %s", esp_err_to_name(err)); - } - return err; -} - -esp_err_t OtaRelay::metaUpdate(uint32_t stored_size, uint32_t last_seq) -{ - ota_meta_t meta; - esp_err_t err = OtaRelay::metaLoad(&meta); - if (err != ESP_OK) return err; - - meta.stored_size = stored_size; - meta.last_seq = last_seq; - - return OtaRelay::metaSave(&meta); -} - -esp_err_t OtaRelay::metaClear() -{ - DEBUG_INFO("OTA", "清除 OTA 元数据"); - ota_meta_t meta = {}; - return OtaRelay::metaSave(&meta); -} - -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; -} - -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); -} - -esp_err_t OtaRelay::metaVerifyChecksum() -{ - ota_meta_t meta; - esp_err_t err = metaLoad(&meta); - if (err != ESP_OK) return err; - - 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; -} - -esp_err_t OtaRelay::verifyFrontendSha(const uint8_t frontend_sha32[32], - uint8_t out_device_sha[32]) -{ - if (!frontend_sha32) return ESP_ERR_INVALID_ARG; - - // 1) 计算设备端 SHA 并写入 meta.checksum(metaFinalizeChecksum 内部已校验固件完整性) - esp_err_t err = metaFinalizeChecksum(); - if (err != ESP_OK) return err; - - // 2) 读回 meta 拿到 checksum,回传给调用方 - ota_meta_t meta; - err = metaLoad(&meta); - if (err != ESP_OK) return err; - if (out_device_sha) memcpy(out_device_sha, meta.checksum, 32); - - // 3) 比对前端 SHA 与设备端 SHA - if (memcmp(meta.checksum, frontend_sha32, 32) != 0) { - return ESP_ERR_INVALID_CRC; - } - return ESP_OK; -} - -esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len) -{ - if (!instance().m_gd32Partition) { - instance().m_gd32Partition = esp_partition_find_first( - ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota"); - if (!instance().m_gd32Partition) { - DEBUG_ERR("OTA", "找不到 gd32_ota 分区"); - return ESP_ERR_NOT_FOUND; - } - } - - const esp_partition_t *part = instance().m_gd32Partition; - - // ★ 计算 head/tail padding,使写入地址和长度都对齐到 4 字节 - // WiFi HTTP chunk 边界不一定 4 对齐,flash 写入必须对齐。 - size_t head_pad = offset % 4; - uint32_t aligned_offset = offset - head_pad; - size_t total = head_pad + len; - size_t aligned_len = (total + 3) & ~(size_t)3; - - if (aligned_offset + aligned_len > part->size) { - DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu aligned_off=%" PRIu32 " aligned_len=%zu part=%" PRIu32, - offset, len, aligned_offset, aligned_len, part->size); - return ESP_ERR_INVALID_SIZE; - } - - // 快速路径:offset 和 len 都对齐 - if (head_pad == 0 && len % 4 == 0) { - return esp_partition_write(part, offset, data, len); - } - - if (head_pad > 0 || len % 4 != 0) { - DEBUG_WARN("OTA", "flashWriteChunk 非对齐: offset=%" PRIu32 " len=%zu head=%zu aligned_len=%zu", - offset, len, head_pad, aligned_len); - } - - 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; - } - - // 读回 head padding(offset 之前的字节,属于上一次写入的尾部) - if (head_pad > 0) { - esp_err_t err = esp_partition_read(part, aligned_offset, aligned_buf, head_pad); - if (err != ESP_OK) { - DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s", - aligned_offset, head_pad, esp_err_to_name(err)); - free(aligned_buf); - return err; - } - } - - // 拷贝实际数据 - memcpy(aligned_buf + head_pad, data, len); - - // 读回 tail padding(数据末尾到 4 字节边界的字节) - size_t tail_pad = aligned_len - total; - if (tail_pad > 0) { - esp_err_t err = esp_partition_read(part, aligned_offset + total, aligned_buf + total, tail_pad); - if (err != ESP_OK) { - DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s", - aligned_offset + total, tail_pad, esp_err_to_name(err)); - free(aligned_buf); - return err; - } - } - - esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len); - free(aligned_buf); - return err; -} - -esp_err_t OtaRelay::flashReadChunk(uint32_t offset, uint8_t *data, size_t len) -{ - if (!instance().m_gd32Partition) { - instance().m_gd32Partition = esp_partition_find_first( - ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota"); - if (!instance().m_gd32Partition) { - DEBUG_ERR("OTA", "找不到 gd32_ota 分区"); - return ESP_ERR_NOT_FOUND; - } - } - - return esp_partition_read(instance().m_gd32Partition, offset, data, len); -} - // ============================================================ // 协议子步骤实现 // ============================================================ +/** @brief 协议子步骤:请求 GD32 APP 进入 Bootloader OTA 模式。 */ esp_err_t OtaRelay::requestGd32AppEnterBootloader() { DEBUG_INFO("OTA", "尝试通知 GD32 APP 进入 Bootloader OTA 窗口"); @@ -966,6 +770,7 @@ esp_err_t OtaRelay::requestGd32AppEnterBootloader() return err; } +/** @brief 协议子步骤:发送握手请求(0x00 0xAC)并等待 GD32 回显 ACK。 */ ota_result_t OtaRelay::doHandshakeReq() { DEBUG_INFO("OTA", "--- 握手请求 ---"); @@ -1016,6 +821,7 @@ ota_result_t OtaRelay::doHandshakeReq() return OTA_RESULT_ERR_TIMEOUT; } +/** @brief 协议子步骤:执行挑战认证(发送 nonce、验证响应、派生会话密钥、发送确认)。 */ ota_result_t OtaRelay::doChallengeAndAuth() { uint8_t resp_type = 0; @@ -1086,6 +892,7 @@ ota_result_t OtaRelay::doChallengeAndAuth() return OTA_RESULT_OK; } +/** @brief 协议子步骤:发送固件总大小给 GD32 并等待 seq=0 的确认。 */ ota_result_t OtaRelay::doSendFirmwareSize(uint32_t size) { uint8_t resp_type = 0; @@ -1116,12 +923,17 @@ ota_result_t OtaRelay::doSendFirmwareSize(uint32_t size) return OTA_RESULT_OK; } +/** + * @brief 协议子步骤:逐包转发固件数据给 GD32。 + * 流程:从 Flash 读取 → AES 加密 → SPI 发送 → 等待 ACK → 失败则重发。 + * 每 OTA_PACKET_HEALTH_CHECK_INTERVAL 包执行一次健康检查。 + */ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) { DEBUG_INFO("OTA", "开始转发 %" PRIu32 " 包", m_transferTotalPackets); ota_meta_t meta; - if (OtaRelay::metaLoad(&meta) != ESP_OK) { + if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK) { DEBUG_ERR("OTA", "元数据加载失败"); return OTA_RESULT_ERR_STORAGE; } @@ -1154,7 +966,7 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) chunk_size = meta.total_size - (seq * GD32_OTA_PACKET_SIZE); } - esp_err_t err = OtaRelay::flashReadChunk(m_transferFlashOffset, plain48, chunk_size); + esp_err_t err = Gd32FlashStore::instance().flashReadChunk(m_transferFlashOffset, plain48, chunk_size); if (err != ESP_OK) { DEBUG_ERR("OTA", "Flash 读取失败, offset=%" PRIu32 ", err=%d", m_transferFlashOffset, err); return OTA_RESULT_ERR_STORAGE; @@ -1297,6 +1109,7 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) return result; } +/** @brief 协议子步骤:发送 FINISH_OTA 指令并验证 GD32 APP 存活。 */ ota_result_t OtaRelay::doFinishAndVerify() { DEBUG_INFO("OTA", "发送 FINISH_OTA (0x0A 0xAC),等待 GD32 确认即将跳转 APP"); @@ -1365,7 +1178,7 @@ ota_result_t OtaRelay::doFinishAndVerify() DEBUG_INFO("OTA", "=== OTA 完成,等待 GD32 APP 启动 ==="); - OtaRelay::metaClear(); + Gd32FlashStore::instance().metaClear(); // ★ APP 存活验证 gd32AppAlive = false; @@ -1425,8 +1238,8 @@ void OtaRelay::taskEntry(void *pvParameters) } DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg); - // ★ 通知协调器中继已开始(确保协调器状态与实际中继一致) - OtaCoordinator::instance().notifyRelayStarted(); + // ★ H4: 通过 IRelayListener 接口回调通知协调器(不再直接依赖 OtaCoordinator) + if (s_relayListener) s_relayListener->onRelayStarted(); ota_result_t result = instance().forwardFirmware(); uint32_t result_msg = (uint32_t)result; @@ -1441,8 +1254,7 @@ void OtaRelay::taskEntry(void *pvParameters) DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result); } instance().reset(); - // ★ 通知协调器中继结束(成功→GD32_COMPLETE,失败→ERROR→IDLE) - // 避免 m_sysState 卡在 OTA_SYS_GD32_RELAYING,导致后续 beginGd32Storage() 全部被拒 - OtaCoordinator::instance().notifyRelayComplete(result == OTA_RESULT_OK); + // ★ H4: 通过 IRelayListener 接口回调通知协调器中继结束 + if (s_relayListener) s_relayListener->onRelayComplete(result == OTA_RESULT_OK); } } diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 4b4912b..74f1702 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -22,17 +22,9 @@ #include "freertos/task.h" #include "transport/protocol.h" #include "transport/spi_master.h" +#include "storage/gd32_flash_store.h" #include "esp_partition.h" -#define OTA_META_NVS_NAMESPACE "ota_meta" - -typedef struct { - uint32_t total_size; - uint32_t stored_size; - uint32_t last_seq; - uint8_t checksum[32]; -} ota_meta_t; - // ============================================================ // 阶段状态枚举(精简为 7 个状态,原 17 个"微状态"合并为阶段) // ============================================================ @@ -58,26 +50,49 @@ typedef enum { RELAY_EVT_ABORT // 外部中止 } relay_event_t; +/** @brief OTA 中继操作结果码枚举。 */ typedef enum { - OTA_RESULT_OK = 0, - OTA_RESULT_ERR_TIMEOUT = -1, - OTA_RESULT_ERR_CRC = -2, - OTA_RESULT_ERR_AUTH = -3, - OTA_RESULT_ERR_STORAGE = -4, - OTA_RESULT_ERR_GD32 = -5, - OTA_RESULT_ERR_HEALTH = -6, - OTA_RESULT_ERR_USER_ABORT = -7 + OTA_RESULT_OK = 0, ///< 操作成功 + OTA_RESULT_ERR_TIMEOUT = -1, ///< 等待 GD32 响应超时 + OTA_RESULT_ERR_CRC = -2, ///< 固件 CRC/SHA-256 校验失败 + OTA_RESULT_ERR_AUTH = -3, ///< 挑战认证失败 + OTA_RESULT_ERR_STORAGE = -4, ///< Flash 存储错误 + OTA_RESULT_ERR_GD32 = -5, ///< GD32 返回错误 + OTA_RESULT_ERR_HEALTH = -6, ///< 健康检查未通过 + OTA_RESULT_ERR_USER_ABORT = -7 ///< 用户主动中止 } ota_result_t; +// ============================================================ +// IRelayListener - 中继事件监听接口(解决 H4 双向耦合) +// ============================================================ +// OtaCoordinator 实现此接口并注册到 OtaRelay, +// OtaRelay 通过接口回调通知协调器,不再直接 #include ota_coordinator.h。 +// 依赖方向变为单向:OtaCoordinator → OtaRelay(通过接口)。 +/** + * @brief 中继事件监听器接口。 + * OtaCoordinator 实现此接口,OtaRelay 通过回调通知状态变更, + * 消除 OtaRelay → OtaCoordinator 的直接依赖(解决 H4 双向耦合)。 + */ +class IRelayListener { +public: + virtual ~IRelayListener() = default; + /** @brief 中继已开始(forwardFirmware 被调用)。 */ + virtual void onRelayStarted() = 0; + /** @brief 中继已完成。 @param success true=成功,false=失败。 */ + virtual void onRelayComplete(bool success) = 0; +}; + class OtaRelay { public: /** * @brief 获取OtaRelay单例引用. + * @return OtaRelay 单例引用。 */ static OtaRelay& instance(); /** * @brief OTA中继FreeRTOS任务入口. + * @param pvParameters FreeRTOS 任务参数(未使用)。 */ static void taskEntry(void *pvParameters); @@ -100,46 +115,57 @@ public: // ── 状态查询(线程安全)── /** * @brief 获取当前中继状态. + * @return 当前 relay_state_t 状态值。 */ static relay_state_t getState(); /** * @brief 获取中继当前序号. + * @return 当前中继序号。 */ static uint32_t getRelaySeq(); /** * @brief 获取中继总包数. + * @return 本次中继的总包数。 */ static uint32_t getRelayTotalPackets(); /** * @brief 获取GD32 APP存活标志位. + * @return true 表示 GD32 APP 存活。 */ static bool getGd32AppAlive(); /** * @brief 获取GD32固件版本. + * @param out 输出版本信息结构体指针。 */ static void getGd32Version(gd32_version_t *out); /** * @brief 设置中继状态. + * @param s 目标状态。 */ static void setState(relay_state_t s); /** * @brief 设置中继当前序号. + * @param seq 序号值。 */ static void setRelaySeq(uint32_t seq); /** * @brief 设置中继总包数. + * @param total 总包数。 */ static void setRelayTotalPackets(uint32_t total); /** * @brief 设置GD32 APP存活标志位. + * @param alive true 表示存活。 */ static void setGd32AppAlive(bool alive); /** * @brief 设置GD32固件版本. + * @param ver 版本信息结构体引用。 */ static void setGd32Version(const gd32_version_t &ver); /** * @brief 获取 OTA 中继任务句柄。 + * @return FreeRTOS 任务句柄。 */ static TaskHandle_t getTaskHandle(); @@ -148,69 +174,37 @@ public: */ void reset(); - // ── Flash 操作 ── + // ── 中继事件监听器(解决 H4 双向耦合)── /** - * @brief Flash写入接口(支持非4字节对齐写入). + * @brief 注册中继事件监听器(由 OtaCoordinator 在 init 时调用)。 + * @param listener 监听器指针,传 nullptr 取消注册。 */ - static esp_err_t flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len); + static void setRelayListener(IRelayListener *listener); /** - * @brief 从OTA缓存分区读取数据块. + * @brief 获取当前注册的监听器。 + * @return 监听器指针,未注册时为 nullptr。 */ - static esp_err_t flashReadChunk(uint32_t offset, uint8_t *data, size_t len); - /** - * @brief 从Flash加载元数据. - */ - static esp_err_t metaLoad(ota_meta_t *meta); - /** - * @brief 保存元数据到Flash. - */ - static esp_err_t metaSave(const ota_meta_t *meta); - /** - * @brief 传输过程中更新元数据. - */ - static esp_err_t metaUpdate(uint32_t stored_size, uint32_t last_seq); - /** - * @brief OTA完成或中止时清除元数据. - */ - static esp_err_t metaClear(); - /** - * @brief 计算gd32_ota分区中已存储固件的SHA-256. - */ - static esp_err_t computeFlashSha256(uint8_t out_sha256[32]); - /** - * @brief 固件接收完毕后计算SHA-256并写入元数据. - */ - static esp_err_t metaFinalizeChecksum(); - /** - * @brief 校验固件完整性(SHA-256). - */ - static esp_err_t metaVerifyChecksum(); - /** - * @brief 校验前端提供的 SHA-256 是否与设备端计算的一致。 - * BLE/WiFi 传输层共用:内部完成 metaFinalizeChecksum → metaLoad → memcmp。 - * 无论是否匹配,都会通过 out_device_sha 回传设备端 SHA(便于前端核对)。 - * @param frontend_sha32 前端 32 字节原始 SHA(非 hex)。 - * @param out_device_sha 可为 nullptr;非空时输出设备端 32 字节 SHA。 - * @return ESP_OK 匹配;ESP_ERR_INVALID_CRC 不匹配;其他 = 元数据/计算失败。 - */ - static esp_err_t verifyFrontendSha(const uint8_t frontend_sha32[32], - uint8_t out_device_sha[32]); + static IRelayListener* getRelayListener(); + + // ── Flash 操作已迁移至 Gd32FlashStore(storage/gd32_flash_store.h)── // ── 公开状态成员 ── - static relay_state_t relayState; - static uint32_t relaySeq; - static uint32_t relayTotalPackets; - static volatile bool gd32AppAlive; - static gd32_version_t gd32Version; - static QueueHandle_t dispatchQueue; - static QueueHandle_t resultQueue; - static TaskHandle_t taskHandle; + static relay_state_t relayState; ///< 当前状态机状态 + static uint32_t relaySeq; ///< 当前中继会话序号 + static uint32_t relayTotalPackets; ///< 本次中继总包数 + static volatile bool gd32AppAlive; ///< GD32 APP 存活标志 + static gd32_version_t gd32Version; ///< GD32 固件版本信息 + static QueueHandle_t dispatchQueue; ///< 事件分发队列 + static QueueHandle_t resultQueue; ///< 结果回传队列 + static TaskHandle_t taskHandle; ///< OTA 中继任务句柄 + static IRelayListener *s_relayListener; ///< 中继事件监听器(解决 H4) // ── 线程安全锁 ── - static SemaphoreHandle_t s_stateMutex; - static void initMutex(); - static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); + static SemaphoreHandle_t s_stateMutex; ///< 状态成员互斥锁 + static void initMutex(); ///< 初始化状态互斥锁(app_main 中调用) + static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间 + /** @brief RAII 状态锁守护类,自动获取/释放 s_stateMutex。 */ class StateLockGuard { public: explicit StateLockGuard(TickType_t timeout = kLockTimeout) { @@ -227,11 +221,10 @@ public: }; // ── 实例状态 ── - const esp_partition_t *m_gd32Partition = NULL; - session_ctx_t m_sessionCtx; - uint8_t m_nonceServer[16] = {0}; - uint8_t m_kSession[16] = {0}; - uint8_t m_gd32UidRuntime[12] = {0}; + session_ctx_t m_sessionCtx; ///< AES 会话上下文 + uint8_t m_nonceServer[16] = {0}; ///< 服务端随机数 + uint8_t m_kSession[16] = {0}; ///< 会话密钥(挑战认证后生成) + uint8_t m_gd32UidRuntime[12] = {0}; ///< GD32 芯片 UID(运行时握手获取) static constexpr size_t GD32_UID_LEN = 12; static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36; @@ -271,26 +264,34 @@ private: // ── 协议子步骤(由状态 handler 内部调用)── /** * @brief 请求GD32 APP进入Bootloader. + * @return ESP_OK 成功,SPI 通信失败返回错误码。 */ esp_err_t requestGd32AppEnterBootloader(); /** * @brief 执行握手请求(发送 HANDSHAKE_REQ). + * @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_TIMEOUT 超时。 */ ota_result_t doHandshakeReq(); /** * @brief 执行挑战认证. + * @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_AUTH 认证失败。 */ ota_result_t doChallengeAndAuth(); /** * @brief 发送固件总大小. + * @param size 固件总大小(字节)。 + * @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_TIMEOUT 超时。 */ ota_result_t doSendFirmwareSize(uint32_t size); /** * @brief 转发下一批数据包(由 TRANSFERRING 状态循环调用). + * @param deadline_tick 全局超时时刻(0=不限时)。 + * @return OTA_RESULT_OK 成功,负值表示错误。 */ ota_result_t doRelayPackets(TickType_t deadline_tick); /** * @brief 发送结束指令并验证 APP 存活. + * @return OTA_RESULT_OK 成功,负值表示错误。 */ ota_result_t doFinishAndVerify(); diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 245afc3..4537ce4 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -44,6 +44,7 @@ #include "esp32_ota_core.h" #include "ota_coordinator.h" #include "ota_relay.h" +#include "storage/gd32_flash_store.h" #include "health_monitor.h" #include "monitor/debug_log.h" @@ -633,11 +634,13 @@ static void sendFinishEvent(uint8_t stage, uint32_t value) } } +/** @brief 将 session_id 和 code 打包为 32 位 BLE FINISH 事件值。 */ static uint32_t packBleFinishValue(uint16_t session_id, uint16_t code) { return ((uint32_t)code << 16) | (uint32_t)session_id; } +/** @brief 将 32 字节 SHA-256 转为大写十六进制字符串(65字节含'\0')。 */ static void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]) { if (!sha || !out_hex) return; @@ -647,6 +650,7 @@ static void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]) out_hex[64] = '\0'; } +/** @brief 通过 BLE Notify 推送设备端 SHA-256 给前端(用于比对验证)。 */ static void sendSha256Notify(const uint8_t sha[32], uint16_t session_id) { if (!sha) return; @@ -659,6 +663,7 @@ static void sendSha256Notify(const uint8_t sha[32], uint16_t session_id) BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); } +/** @brief BLE 触发 GD32 中继后等待结果的 FreeRTOS 任务。 */ static void ble_gd32_relay_wait_task(void *pvParameters) { BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)pvParameters; @@ -747,7 +752,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, // 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继 ota_meta_t meta = {}; - esp_err_t err = OtaRelay::metaLoad(&meta); + esp_err_t err = OtaCoordinator::instance().loadGd32Meta(&meta); if (err == ESP_OK && meta.total_size > 0 && meta.stored_size >= meta.total_size) { @@ -758,7 +763,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, // ★ 统一走共享 helper:计算设备端 SHA → 比对前端 SHA uint8_t device_sha[32] = {0}; - err = OtaRelay::verifyFrontendSha(frontend_sha, device_sha); + err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha); shaBytesToHexUpper(device_sha, device_sha_hex); sendSha256Notify(device_sha, session_id); DEBUG_INFO(TAG, "GD32 前端 SHA-256: %s, session=%u", frontend_sha_hex, session_id); @@ -1111,7 +1116,7 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) meta.stored_size = 0; meta.last_seq = 0; - esp_err_t err = OtaRelay::metaSave(&meta); + esp_err_t err = OtaCoordinator::instance().saveGd32Meta(&meta); if (err != ESP_OK) { DEBUG_ERR(TAG, "元数据保存失败"); OtaCoordinator::instance().abortCurrent(); @@ -1124,7 +1129,7 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) } ota_meta_t meta = {}; - if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0) { + if (OtaCoordinator::instance().loadGd32Meta(&meta) != ESP_OK || meta.total_size == 0) { DEBUG_ERR(TAG, "GD32 元数据无效,拒绝写入: seq=%u", seq); return; } @@ -1151,7 +1156,7 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) return; } - esp_err_t err = OtaRelay::flashWriteChunk(flash_offset, &buf[data_offset], data_len); + esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(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)); @@ -1159,7 +1164,7 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) } uint32_t stored_size = flash_offset + (uint32_t)data_len; - err = OtaRelay::metaUpdate(stored_size, seq); + err = OtaCoordinator::instance().updateGd32Meta(stored_size, seq); if (err != ESP_OK) { DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq); } @@ -1174,14 +1179,14 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) seq, flash_offset, stored_size); // --- 检查 GD32 固件是否接收完毕 --- - if (OtaRelay::metaLoad(&meta) == ESP_OK && + if (OtaCoordinator::instance().loadGd32Meta(&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); OtaCoordinator::instance().completeGd32Storage(); ble_ota_restore_wifi_ps(); - esp_err_t sha_err = OtaRelay::metaFinalizeChecksum(); + esp_err_t sha_err = OtaCoordinator::instance().finalizeGd32Checksum(); if (sha_err != ESP_OK) { DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); } @@ -1388,7 +1393,6 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) } } else { DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status); - /** @brief BLE GATT 客户端连接状态。 */ BleOta::instance().m_bleConnected = false; } return 0; @@ -1554,7 +1558,6 @@ static void ble_ota_on_sync(void) static void ble_ota_on_reset(int reason) { DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason); - /** @brief NimBLE 协议栈同步完成标志。 */ BleOta::instance().m_bleReady = false; } @@ -1678,30 +1681,9 @@ esp_err_t BleOta::notifyResponse(const uint8_t *data, size_t len) */ bool BleOta::isConnected() { - /** @brief BLE GATT 客户端连接状态。 */ return BleOta::instance().m_bleConnected; } -/** - * @brief 通知中继模块开始向 GD32 转发固件。 - */ -void BleOta::signalRelay() -{ - if (OtaRelay::dispatchQueue == NULL) { - DEBUG_ERR(TAG, "OTA 调度队列未初始化"); - return; - } - - uint32_t msg = 1; - BaseType_t ret = xQueueSend(OtaRelay::dispatchQueue, &msg, pdMS_TO_TICKS(200)); - if (ret != pdTRUE) { - DEBUG_ERR(TAG, "OTA 调度信号发送失败: 队列满"); - return; - } - - DEBUG_INFO(TAG, "OTA 调度信号已发送 → ota_relay_task"); -} - // ============================================================ // BLE OTA FreeRTOS 任务入口 // diff --git a/main/server/ble_ota.h b/main/server/ble_ota.h index 568e951..668c634 100644 --- a/main/server/ble_ota.h +++ b/main/server/ble_ota.h @@ -43,11 +43,6 @@ public: * @return 已连接返回 true,未连接返回 false。 */ bool isConnected(); - /** - * @brief 触发中继模块向 GD32 转发固件。 - */ - void signalRelay(); - /** @brief 获取 BLE OTA 守护任务句柄。 */ TaskHandle_t getTaskHandle() const { return m_taskHandle; } @@ -55,16 +50,16 @@ public: TaskHandle_t getWorkerTaskHandle() const { return m_workerTaskHandle; } // 以下状态为 public,供 C 回调函数访问 - bool m_bleReady = false; - bool m_bleConnected = false; - uint16_t m_connHandle = 0xFFFF; + bool m_bleReady = false; ///< BLE GATT 服务注册完成标志 + bool m_bleConnected = false; ///< BLE 客户端连接状态 + uint16_t m_connHandle = 0xFFFF; ///< BLE 连接句柄(0xFFFF=未连接) // BLE 传输相关(OTA 写入状态已移至 Esp32OtaCore) - bool m_esp32RebootPending = false; // 收尾任务去重标志 - uint8_t m_esp32LastCmd = 0; // 最后一次数据包命令(用于补发 ACK) - uint16_t m_esp32LastSeq = 0xFFFFU; // 最后一次数据包序号(用于补发 ACK) - TaskHandle_t m_taskHandle = nullptr; - TaskHandle_t m_workerTaskHandle = nullptr; + bool m_esp32RebootPending = false; ///< 收尾任务去重标志 + uint8_t m_esp32LastCmd = 0; ///< 最后一次数据包命令(用于补发 ACK) + uint16_t m_esp32LastSeq = 0xFFFFU; ///< 最后一次数据包序号(用于补发 ACK) + TaskHandle_t m_taskHandle = nullptr; ///< BLE OTA 守护任务句柄 + TaskHandle_t m_workerTaskHandle = nullptr; ///< BLE OTA 数据工作线程句柄 }; #endif // BLE_OTA_H diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp new file mode 100644 index 0000000..9827428 --- /dev/null +++ b/main/server/http_handlers.cpp @@ -0,0 +1,836 @@ +/* + * http_handlers.cpp - HTTP 请求处理器实现 + * 从 wifi_ota.cpp 提取的 HTTP handler 函数 + */ +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_http_server.h" +#include "esp_wifi.h" +#include "esp_netif.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "esp_ota_ops.h" +#include "cJSON.h" + +#include "app_config.h" +#include "http_handlers.h" +#include "wifi_ota.h" +#include "esp32_ota_core.h" +#include "ota_coordinator.h" +#include "ota_relay.h" +#include "storage/gd32_flash_store.h" +#include "health_monitor.h" +#include "monitor/debug_log.h" +#include "spi_master.h" +#include "ble_ota.h" + +static constexpr uint32_t GD32_OTA_FLASH_SECTOR_SIZE = 4096U; + +// OTA 接收共享缓冲区 +static uint8_t s_ota_recv_buf[OTA_BLOCK_SIZE]; + +static uint32_t align_up_u32(uint32_t value, uint32_t alignment) +{ + return ((value + alignment - 1U) / alignment) * alignment; +} + +void http_set_cors_headers(httpd_req_t *req) +{ + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); + httpd_resp_set_hdr(req, "Access-Control-Max-Age", "600"); +} + +esp_err_t http_options_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + httpd_resp_set_status(req, "204 No Content"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +esp_err_t esp32_ota_update_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + Esp32OtaCore &core = Esp32OtaCore::instance(); + if (!core.isActive()) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA not started"); + return ESP_FAIL; + } + + char frontend_sha_hex[65] = {0}; + bool has_frontend_sha = false; + bool verify_only = false; + if (req->content_len > 0 && req->content_len < 256) { + char buf[256] = {0}; + int ret = httpd_req_recv(req, buf, req->content_len); + if (ret > 0) { + buf[ret] = '\0'; + cJSON *root = cJSON_Parse(buf); + if (root) { + cJSON *sha_item = cJSON_GetObjectItem(root, "sha256"); + if (sha_item && cJSON_IsString(sha_item)) { + const char *sha_str = cJSON_GetStringValue(sha_item); + if (sha_str && strlen(sha_str) == 64) { + strncpy(frontend_sha_hex, sha_str, 64); + frontend_sha_hex[64] = '\0'; + has_frontend_sha = true; + DEBUG_INFO("OTA", "Frontend SHA-256: %s", frontend_sha_hex); + } + } + cJSON *verify_item = cJSON_GetObjectItem(root, "verify_only"); + if (verify_item && cJSON_IsBool(verify_item)) { + verify_only = cJSON_IsTrue(verify_item); + } + cJSON_Delete(root); + } + } + } + + char device_sha_hex[65] = {0}; + + if (verify_only) { + DEBUG_INFO("OTA", "ESP32 OTA verify-only mode, %u bytes", (unsigned)core.getReceived()); + esp_err_t sha_err = core.computeDeviceSha(device_sha_hex); + if (sha_err != ESP_OK) { + DEBUG_ERR("OTA", "verify-only: SHA 计算失败: %s", esp_err_to_name(sha_err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(sha_err)); + return ESP_FAIL; + } + char ok_resp[256] = {0}; + snprintf(ok_resp, sizeof(ok_resp), + "{\"status\": \"ok\", \"message\": \"SHA-256 computed\", \"sha256\": \"%s\"}", + device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, ok_resp, strlen(ok_resp)); + return ESP_OK; + } + + DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived()); + Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot( + has_frontend_sha ? frontend_sha_hex : nullptr); + + if (fr.err == ESP_OK) { + char resp[320] = {0}; + snprintf(resp, sizeof(resp), + "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", " + "\"sha256\": \"%s\"}", + fr.device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + + DEBUG_INFO("OTA", "响应已发送,重启..."); + OtaCoordinator::instance().restartEsp32(); + return ESP_OK; + } + + if (fr.err == ESP_ERR_INVALID_VERSION) { + char err_resp[256] = {0}; + snprintf(err_resp, sizeof(err_resp), + "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}", + frontend_sha_hex, fr.device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, err_resp, strlen(err_resp)); + } else { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(fr.err)); + } + return ESP_FAIL; +} + +esp_err_t restart_handler(httpd_req_t *req) +{ + (void)req; + http_set_cors_headers(req); + + DEBUG_INFO("OTA", "收到重启请求,重启..."); + + const char resp[] = "{\"status\": \"ok\", \"message\": \"rebooting\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + + OtaCoordinator::instance().restartEsp32(); + return ESP_OK; +} + +static int recv_json_header(httpd_req_t *req, char *buf, int buf_size) +{ + int total = 0; + int depth = 1; + while (total < buf_size - 1) { + int ret = httpd_req_recv(req, buf + total, 1); + if (ret <= 0) return total; + total += ret; + buf[total] = '\0'; + if (buf[total - 1] == '{') depth++; + else if (buf[total - 1] == '}') { + depth--; + if (depth == 0) return total; + } + } + return total; +} + +esp_err_t ota_upload_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + DEBUG_WARN("WIFI", "=== HTTP: POST /ota/upload === content_len=%d", req->content_len); + DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len); + + esp_wifi_set_ps(WIFI_PS_NONE); + WifiOta::instance().setBleOtaActive(false); + + char peek; + int peek_ret = httpd_req_recv(req, &peek, 1); + if (peek_ret <= 0) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "接收失败"); + return ESP_FAIL; + } + + bool is_esp32_ota = false; + uint32_t total_size = 0; + int json_header_len = 0; + uint8_t pre_read_buf[256] = {0}; + int pre_read_len = 1; + pre_read_buf[0] = (uint8_t)peek; + + if (peek == '{') { + char json_buf[256]; + json_buf[0] = peek; + json_header_len = recv_json_header(req, json_buf + 1, sizeof(json_buf) - 1); + if (json_header_len > 0) { + json_header_len++; + json_buf[json_header_len] = '\0'; + pre_read_len = json_header_len; + if (pre_read_len > (int)sizeof(pre_read_buf)) pre_read_len = (int)sizeof(pre_read_buf); + memcpy(pre_read_buf, json_buf, pre_read_len); + + cJSON *root = cJSON_Parse(json_buf); + if (root) { + cJSON *type_item = cJSON_GetObjectItem(root, "type"); + if (type_item && cJSON_IsString(type_item) && + strcmp(cJSON_GetStringValue(type_item), "esp32") == 0) { + is_esp32_ota = true; + DEBUG_INFO("WIFI", "检测到 ESP32 OTA, JSON 头长度: %d 字节", json_header_len); + } + cJSON *size_item = cJSON_GetObjectItem(root, "total_size"); + if (size_item) { + total_size = (uint32_t)cJSON_GetNumberValue(size_item); + } + cJSON_Delete(root); + } + } + } + + if (is_esp32_ota) { + if (total_size == 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing total_size"); + return ESP_FAIL; + } + + esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size); + if (err != ESP_OK) { + if (err == ESP_ERR_INVALID_STATE) { + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); + } else { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); + } + return ESP_FAIL; + } + + Esp32OtaCore &core = Esp32OtaCore::instance(); + uint8_t *fw_buf = s_ota_recv_buf; + + size_t total_firmware_written = 0; + size_t total_received = json_header_len; + int last_percent = 0; + + while (total_received < req->content_len) { + size_t to_read = req->content_len - total_received; + if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + + int ret = httpd_req_recv(req, (char *)fw_buf, to_read); + if (ret <= 0) { + DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret); + core.abort(); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); + return ESP_FAIL; + } + + err = core.write(fw_buf, (size_t)ret); + if (err != ESP_OK) { + DEBUG_ERR("WIFI", "OTA write failed: %s", esp_err_to_name(err)); + core.abort(); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); + return ESP_FAIL; + } + + total_firmware_written += ret; + total_received += ret; + + if (total_size > 0) { + int percent = (int)((total_firmware_written * 100) / total_size); + if (percent / 10 > last_percent / 10) { + last_percent = percent; + DEBUG_INFO("WIFI", "ESP32 OTA 进度: %u / %u (%d%%)", + (unsigned int)total_firmware_written, total_size, percent); + } + } + } + + DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节 (跳过 JSON 头 %d 字节)", (unsigned int)core.getReceived(), json_header_len); + const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", \"target\": \"esp32\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; + } + + const uint32_t gd32_fw_size = (uint32_t)req->content_len; + uint32_t gd32_valid_size; + if (json_header_len > 0 && (uint32_t)json_header_len < gd32_fw_size) { + gd32_valid_size = (total_size > 0 && total_size < gd32_fw_size) ? total_size : (gd32_fw_size - (uint32_t)json_header_len); + } else { + gd32_valid_size = gd32_fw_size; + } + const uint32_t gd32_rounded_size = (uint32_t)(((gd32_valid_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE); + if (gd32_valid_size == 0U || gd32_valid_size > OTA_MAX_FIRMWARE_SIZE || gd32_rounded_size > OTA_MAX_FIRMWARE_SIZE) { + DEBUG_ERR("WIFI", "firmware size invalid: valid=%u rounded48=%u max=%u", + (unsigned)gd32_valid_size, (unsigned)gd32_rounded_size, (unsigned)OTA_MAX_FIRMWARE_SIZE); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "firmware too large or alignment overflow"); + return ESP_FAIL; + } + if (OtaRelay::getState() != RELAY_IDLE) { + DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState()); + OtaRelay::instance().reset(); + } + + if (OtaCoordinator::instance().beginGd32Storage(gd32_valid_size) != ESP_OK) { + DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)"); + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "system busy with another OTA operation"); + return ESP_FAIL; + } + + WifiOta::instance().stateLock(); + WifiOta::instance().setGd32StorageDone(false); + WifiOta::instance().stateUnlock(); + + const uint32_t gd32_erase_size = align_up_u32(gd32_valid_size, GD32_OTA_FLASH_SECTOR_SIZE); + DEBUG_INFO("WIFI", "擦除 gd32_ota 分区: %u bytes (fw=%u)", + (unsigned)gd32_erase_size, (unsigned)gd32_valid_size); + esp_err_t erase_err = OtaCoordinator::instance().eraseGd32Partition(gd32_erase_size); + if (erase_err != ESP_OK) { + DEBUG_ERR("WIFI", "擦除 gd32_ota 分区失败: %s", esp_err_to_name(erase_err)); + char err_msg[96]; + snprintf(err_msg, sizeof(err_msg), "erase gd32_ota failed: %s", esp_err_to_name(erase_err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg); + return ESP_FAIL; + } + OtaCoordinator::instance().clearGd32Meta(); + + uint8_t *buf = s_ota_recv_buf; + + int received = 0; + + uint32_t expected_fw_size_raw = gd32_valid_size; + ota_meta_t meta_raw = { + .total_size = expected_fw_size_raw, + .stored_size = 0, + .last_seq = 0, + .checksum = {0} + }; + OtaCoordinator::instance().saveGd32Meta(&meta_raw); + uint32_t gd32_offset = 0; + + DEBUG_INFO("WIFI", "GD32 raw upload start: %u bytes", expected_fw_size_raw); + + if (json_header_len == 0 && pre_read_len > 0) { + int first_chunk_len = pre_read_len; + if (first_chunk_len > req->content_len) first_chunk_len = req->content_len; + if (first_chunk_len > 0) { + gd32_offset = 0; + esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(gd32_offset, pre_read_buf, (size_t)first_chunk_len); + if (err != ESP_OK) { + DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s", + first_chunk_len, esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); + return ESP_FAIL; + } + received = first_chunk_len; + gd32_offset += (uint32_t)first_chunk_len; + OtaCoordinator::instance().updateGd32Meta(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); + } + } else { + received = pre_read_len; + } + + while (received < req->content_len) { + size_t to_read = req->content_len - received; + if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + + int ret = httpd_req_recv(req, (char *)buf, to_read); + if (ret <= 0) { + DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d", + received, req->content_len); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted"); + return ESP_FAIL; + } + + esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(gd32_offset, buf, (size_t)ret); + if (err != ESP_OK) { + DEBUG_ERR("WIFI", "GD32 write failed: offset=%u len=%d err=%s", + (unsigned)gd32_offset, ret, esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); + return ESP_FAIL; + } + + received += ret; + gd32_offset += (uint32_t)ret; + } + + OtaCoordinator::instance().updateGd32Meta(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); + + DEBUG_INFO("WIFI", "GD32 upload done: %u / %u bytes", (unsigned)gd32_offset, expected_fw_size_raw); + + ota_meta_t final_meta; + OtaCoordinator::instance().loadGd32Meta(&final_meta); + if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) { + OtaCoordinator::instance().completeGd32Storage(); + WifiOta::instance().stateLock(); + WifiOta::instance().setGd32StorageDone(true); + WifiOta::instance().stateUnlock(); + DEBUG_INFO("WIFI", "GD32 firmware fully stored, pre-computing SHA-256..."); + esp_err_t sha_err = OtaCoordinator::instance().finalizeGd32Checksum(); + if (sha_err != ESP_OK) { + DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); + } + } + + const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\", " + "\"message\": \"stored, call POST /ota/relay with sha256 to trigger\"}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; +} + +esp_err_t ota_relay_trigger_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + DEBUG_INFO("WIFI", "=== HTTP: POST /ota/relay ==="); + DEBUG_INFO("WIFI", "收到触发 OTA 中继请求"); + + ota_meta_t meta; + memset(&meta, 0, sizeof(meta)); + if (OtaCoordinator::instance().loadGd32Meta(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "firmware not fully stored"); + return ESP_FAIL; + } + + char frontend_sha_hex[65] = {0}; + if (req->content_len > 0 && req->content_len < 512) { + char buf[512] = {0}; + int ret = httpd_req_recv(req, buf, req->content_len); + if (ret > 0) { + buf[ret] = '\0'; + cJSON *root = cJSON_Parse(buf); + if (root) { + cJSON *sha_item = cJSON_GetObjectItem(root, "sha256"); + if (sha_item && cJSON_IsString(sha_item) && + strlen(cJSON_GetStringValue(sha_item)) == 64) { + strncpy(frontend_sha_hex, cJSON_GetStringValue(sha_item), 64); + frontend_sha_hex[64] = '\0'; + } + cJSON_Delete(root); + } + } + } + if (frontend_sha_hex[0] == '\0') { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + "missing sha256 in body (frontend SHA-256 required)"); + return ESP_FAIL; + } + + uint8_t frontend_sha[32] = {0}; + for (int i = 0; i < 32; ++i) { + char hex[3] = { frontend_sha_hex[i*2], frontend_sha_hex[i*2+1], 0 }; + frontend_sha[i] = (uint8_t)strtoul(hex, nullptr, 16); + } + + uint8_t device_sha[32] = {0}; + char device_sha_hex[65] = {0}; + esp_err_t verify_err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha); + for (int i = 0; i < 32; ++i) { + snprintf(&device_sha_hex[i*2], 3, "%02X", device_sha[i]); + } + + if (verify_err == ESP_ERR_INVALID_CRC) { + char err_resp[256]; + snprintf(err_resp, sizeof(err_resp), + "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", " + "\"device\": \"%s\"}", device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, err_resp, strlen(err_resp)); + return ESP_FAIL; + } else if (verify_err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SHA compute failed"); + return ESP_FAIL; + } + + esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); + if (trigger_err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + "relay trigger failed - system busy or firmware not ready"); + return ESP_FAIL; + } + + char resp[256]; + snprintf(resp, sizeof(resp), + "{\"status\": \"ok\", \"message\": \"OTA relay triggered\", " + "\"sha256\": \"%s\"}", device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + + return ESP_OK; +} + +esp_err_t logs_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + int json_len = 0; + char *json = DebugLog::instance().ringDrainJson(&json_len); + + if (json && json_len > 0) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, json_len); + free(json); + } else { + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "[]", 2); + } + return ESP_OK; +} + +esp_err_t can_send_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + char buf[256] = {0}; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败"); + return ESP_FAIL; + } + buf[ret] = '\0'; + + cJSON *root = cJSON_Parse(buf); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); + return ESP_FAIL; + } + + cJSON *ch_item = cJSON_GetObjectItem(root, "ch"); + cJSON *id_item = cJSON_GetObjectItem(root, "id"); + cJSON *data_item = cJSON_GetObjectItem(root, "data"); + cJSON *dlc_item = cJSON_GetObjectItem(root, "dlc"); + + if (!ch_item || !id_item) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 ch 或 id 字段"); + return ESP_FAIL; + } + + can_channel_t ch = (can_channel_t)cJSON_GetNumberValue(ch_item); + uint32_t ext_id = (uint32_t)cJSON_GetNumberValue(id_item); + uint8_t dlc = dlc_item ? (uint8_t)cJSON_GetNumberValue(dlc_item) : 0; + + uint8_t tx_data[CAN_MAX_DLC] = {0}; + uint8_t tx_dlc = 0; + + if (data_item && cJSON_IsString(data_item)) { + const char *hex_str = cJSON_GetStringValue(data_item); + size_t hex_len = strlen(hex_str); + for (size_t i = 0; i < hex_len / 2 && i < CAN_MAX_DLC; i++) { + char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' }; + tx_data[i] = (uint8_t)strtol(byte_str, NULL, 16); + tx_dlc++; + } + } + + if (tx_dlc == 0 && dlc > 0) { + tx_dlc = (dlc > CAN_MAX_DLC) ? CAN_MAX_DLC : dlc; + } + cJSON_Delete(root); + + uint8_t resp_data[CAN_MAX_DLC] = {0}; + int resp_len = SpiMaster::instance().canSend(ch, ext_id, tx_data, tx_dlc, + resp_data, sizeof(resp_data), 1000); + + char resp[512]; + if (resp_len >= 0) { + char resp_hex[CAN_MAX_DLC * 2 + 1] = {0}; + for (int i = 0; i < resp_len; i++) { + sprintf(&resp_hex[i * 2], "%02X", resp_data[i]); + } + snprintf(resp, sizeof(resp), + "{\"status\": \"ok\", \"ch\": %d, \"id\": %lu, \"sent\": %d, \"recv_len\": %d, \"recv\": \"%s\"}", + ch, (unsigned long)ext_id, tx_dlc, resp_len, resp_hex); + } else { + snprintf(resp, sizeof(resp), + "{\"status\": \"error\", \"code\": %d}", resp_len); + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; +} + +esp_err_t spi_send_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + char buf[512] = {0}; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败"); + return ESP_FAIL; + } + buf[ret] = '\0'; + + cJSON *root = cJSON_Parse(buf); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); + return ESP_FAIL; + } + + cJSON *type_item = cJSON_GetObjectItem(root, "type"); + cJSON *data_item = cJSON_GetObjectItem(root, "data"); + cJSON *timeout_item = cJSON_GetObjectItem(root, "timeout_ms"); + + if (!type_item || !cJSON_IsString(type_item)) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 type 字段"); + return ESP_FAIL; + } + const char *type_hex = cJSON_GetStringValue(type_item); + uint8_t frame_type = (uint8_t)strtol(type_hex, NULL, 16); + + uint32_t timeout_ms = timeout_item ? (uint32_t)cJSON_GetNumberValue(timeout_item) : 1000; + + uint8_t payload_data[SPI_OTA_FRAME_MAX] = {0}; + size_t payload_len = 0; + if (data_item && cJSON_IsString(data_item)) { + const char *hex_str = cJSON_GetStringValue(data_item); + size_t hex_len = strlen(hex_str); + for (size_t i = 0; i < hex_len / 2 && i < SPI_OTA_FRAME_MAX; i++) { + char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' }; + payload_data[i] = (uint8_t)strtol(byte_str, NULL, 16); + payload_len++; + } + } + + DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节", + frame_type, payload_len); + cJSON_Delete(root); + + char resp[1024]; + uint8_t resp_type = 0; + uint8_t resp_payload[SPI_OTA_FRAME_MAX] = {0}; + size_t resp_len = 0; + esp_err_t spi_err = SpiMaster::instance().otaTransfer(frame_type, payload_data, payload_len, + &resp_type, resp_payload, sizeof(resp_payload), &resp_len, + timeout_ms); + + if (spi_err == ESP_OK) { + char resp_hex[SPI_OTA_FRAME_MAX * 2 + 1] = {0}; + for (size_t i = 0; i < resp_len; i++) { + sprintf(&resp_hex[i * 2], "%02X", resp_payload[i]); + } + snprintf(resp, sizeof(resp), + "{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}", + frame_type, payload_len, resp_type, resp_len, resp_hex); + DEBUG_INFO("SPI", "WEB SPI 响应: type=0x%02X len=%zu payload=%s", + resp_type, resp_len, resp_hex); + } else { + snprintf(resp, sizeof(resp), + "{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}", + spi_err, esp_err_to_name(spi_err)); + DEBUG_ERR("SPI", "WEB SPI 失败: %s", esp_err_to_name(spi_err)); + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; +} + +static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + +struct TaskStackMetric { + const char* name; + TaskHandle_t handle; + size_t stack_size_bytes; +}; + +static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle) +{ + if (handle == NULL) return 0; + UBaseType_t highWaterMark = uxTaskGetStackHighWaterMark(handle); + return (size_t)highWaterMark; +} + +static size_t get_task_stack_peak_used_bytes(TaskHandle_t handle, size_t stack_size_bytes) +{ + size_t high_watermark = get_task_stack_high_watermark_bytes(handle); + return (stack_size_bytes >= high_watermark) ? (stack_size_bytes - high_watermark) : 0; +} + +static int append_jsonf(char* buf, size_t cap, int offset, const char* fmt, ...) +{ + if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, args); + va_end(args); + if (n < 0) return offset; + size_t written = (size_t)n; + if (offset + (int)written >= (int)cap) { + buf[cap - 1] = '\0'; + return (int)(cap - 1); + } + return offset + (int)written; +} + +esp_err_t system_status_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + char ip_str[16] = "0.0.0.0"; + int8_t rssi = -127; + bool connected = false; + + if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) { + esp_netif_ip_info_t ip_info; + esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) { + snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); + } + wifi_ap_record_t ap_info; + if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { + rssi = ap_info.rssi; + } + connected = true; + } + gd32_version_t pingVer = {}; + OtaRelay::getGd32Version(&pingVer); + + ota_system_state_t curSysState = OtaCoordinator::instance().getSystemState(); + bool ota_busy = (curSysState == OTA_SYS_GD32_STORING || + curSysState == OTA_SYS_GD32_RELAYING || + curSysState == OTA_SYS_ESP32_RECEIVING || + curSysState == OTA_SYS_ESP32_FINALIZING || + curSysState == OTA_SYS_ESP32_REBOOTING); + if (!ota_busy && + pingVer.app_major == 0 && + pingVer.boot_major == 0 && + OtaRelay::getState() == RELAY_IDLE) { + static uint32_t last_ping_ms = 0; + static uint8_t ping_attempts = 0; + uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000); + if (ping_attempts < 5 && now_ms - last_ping_ms > 5000) { + last_ping_ms = now_ms; + ++ping_attempts; + uint32_t uptime = 0; + gd32_version_t ver = {}; + if (SpiMaster::instance().appPing(500, &uptime, &ver) == ESP_OK) { + OtaRelay::setGd32AppAlive(true); + OtaRelay::setGd32Version(ver); + ping_attempts = 0; + } else { + OtaRelay::setGd32AppAlive(false); + if (ping_attempts >= 5) { + DEBUG_WARN("STATUS", "GD32 版本号读取失败,已达最大重试次数 (5),停止尝试"); + } + } + } + } + + ota_meta_t meta; + memset(&meta, 0, sizeof(meta)); + OtaCoordinator::instance().loadGd32Meta(&meta); + + gd32_version_t snapVer2 = {}; + OtaRelay::getGd32Version(&snapVer2); + ota_system_state_t sysState = OtaCoordinator::instance().getSystemState(); + size_t free_heap = esp_get_free_heap_size(); + size_t min_free_heap = esp_get_minimum_free_heap_size(); + size_t largest_free_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); + size_t internal_free_heap = heap_caps_get_free_size(INTERNAL_HEAP_CAPS); + size_t internal_min_free_heap = heap_caps_get_minimum_free_size(INTERNAL_HEAP_CAPS); + size_t internal_largest_free_block = heap_caps_get_largest_free_block(INTERNAL_HEAP_CAPS); + size_t internal_heap_total = heap_caps_get_total_size(INTERNAL_HEAP_CAPS); + size_t default_heap_total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT); + TaskStackMetric task_metrics[] = { + { "http_server", xTaskGetCurrentTaskHandle(), HTTP_SERVER_STACK_SIZE }, + { "health_mon", HealthMonitor::instance().getTaskHandle(), 4096 }, + { "ota_relay", OtaRelay::getTaskHandle(), 6144 }, + { "wifi_ota", WifiOta::instance().getTaskHandle(), 8192 }, + { "wifi_reconnect", WifiOta::instance().m_reconnectTask, 4096 }, + { "ble_ota", BleOta::instance().getTaskHandle(), 4096 }, + { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), 4096 } + }; + + char resp[4096]; + int len = snprintf(resp, sizeof(resp), + "{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, " + "\"free_heap\": %lu, \"min_free_heap\": %lu, \"largest_free_block\": %lu, " + "\"internal_free_heap\": %lu, \"internal_min_free_heap\": %lu, \"internal_largest_free_block\": %lu, " + "\"internal_heap_total\": %lu, \"default_heap_total\": %lu, " + "\"temperature\": %.1f, \"uptime_sec\": %lu, " + "\"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, " + "\"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, " + "\"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, " + "\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}, " + "\"task_stack\": {", + FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(), + (unsigned long)free_heap, + (unsigned long)min_free_heap, + (unsigned long)largest_free_block, + (unsigned long)internal_free_heap, + (unsigned long)internal_min_free_heap, + (unsigned long)internal_largest_free_block, + (unsigned long)internal_heap_total, + (unsigned long)default_heap_total, + (double)HealthMonitor::instance().getLastTemperature(), + (unsigned long)(esp_timer_get_time() / 1000000), + WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP ? "AP" : "STA", + ip_str, + rssi, + connected ? "true" : "false", + OtaCoordinator::instance().getSystemStateString(), + (int)sysState, + (unsigned long)meta.total_size, + (unsigned long)meta.stored_size, + OtaCoordinator::instance().isGd32StorageDone() ? "true" : "false", + OtaRelay::getState(), + (unsigned long)OtaRelay::getRelaySeq(), + (unsigned long)OtaRelay::getRelayTotalPackets(), + OtaRelay::getGd32AppAlive() ? "true" : "false", + snapVer2.app_major, snapVer2.app_minor, + snapVer2.app_patch, snapVer2.app_build, + snapVer2.boot_major, snapVer2.boot_minor, + snapVer2.boot_patch, snapVer2.boot_build); + + for (size_t i = 0; i < sizeof(task_metrics) / sizeof(task_metrics[0]); ++i) { + len = append_jsonf(resp, sizeof(resp), len, + "%s\"%s\":{\"stack_size_bytes\":%lu,\"high_watermark_bytes\":%lu,\"peak_used_bytes\":%lu}", + i == 0 ? "" : ",", + task_metrics[i].name, + (unsigned long)task_metrics[i].stack_size_bytes, + (unsigned long)get_task_stack_high_watermark_bytes(task_metrics[i].handle), + (unsigned long)get_task_stack_peak_used_bytes(task_metrics[i].handle, task_metrics[i].stack_size_bytes)); + } + len = append_jsonf(resp, sizeof(resp), len, "}}"); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, len); + return ESP_OK; +} diff --git a/main/server/http_handlers.h b/main/server/http_handlers.h new file mode 100644 index 0000000..856dacf --- /dev/null +++ b/main/server/http_handlers.h @@ -0,0 +1,22 @@ +#ifndef HTTP_HANDLERS_H +#define HTTP_HANDLERS_H + +#include "esp_http_server.h" + +// HTTP CORS and options +void http_set_cors_headers(httpd_req_t *req); +esp_err_t http_options_handler(httpd_req_t *req); + +// OTA handlers +esp_err_t esp32_ota_update_handler(httpd_req_t *req); +esp_err_t ota_upload_handler(httpd_req_t *req); +esp_err_t ota_relay_trigger_handler(httpd_req_t *req); +esp_err_t restart_handler(httpd_req_t *req); + +// Utility handlers +esp_err_t logs_handler(httpd_req_t *req); +esp_err_t can_send_handler(httpd_req_t *req); +esp_err_t spi_send_handler(httpd_req_t *req); +esp_err_t system_status_handler(httpd_req_t *req); + +#endif // HTTP_HANDLERS_H diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index b7cfcd5..df99e45 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -13,7 +13,6 @@ #include "esp_http_server.h" #include "esp_system.h" #include "esp_chip_info.h" -#include "esp_timer.h" #include "esp_ota_ops.h" #include "nvs_flash.h" #include "lwip/netdb.h" @@ -32,7 +31,7 @@ #include "monitor/debug_log.h" #include "spi_master.h" #include "ble_ota.h" -#include "cJSON.h" +#include "http_handlers.h" @@ -47,57 +46,17 @@ WifiOta& WifiOta::instance() return inst; } -static constexpr uint32_t GD32_OTA_FLASH_SECTOR_SIZE = 4096U; - -// ★ OTA 接收共享缓冲区:由 OtaCoordinator 互斥串行化,无并发风险,避免频繁 malloc/free 导致堆碎片化 -static uint8_t s_ota_recv_buf[OTA_BLOCK_SIZE]; - -/** - * @brief 按指定对齐值向上取整。 - * @param value 待对齐的值。 - * @param alignment 对齐字节数。 - * @return 对齐后的值。 - */ -static uint32_t align_up_u32(uint32_t value, uint32_t alignment) -{ - return ((value + alignment - 1U) / alignment) * alignment; -} - /** * @brief 预先声明:为 STA 模式配置静态 IP、网关和子网掩码。 */ -/** - * @brief 为 HTTP 响应添加跨域访问控制头。 - * @param req HTTP 请求对象。 - */ -static void http_set_cors_headers(httpd_req_t *req) -{ - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); - httpd_resp_set_hdr(req, "Access-Control-Max-Age", "600"); -} - -/** - * @brief 处理 HTTP OPTIONS 预检请求,返回 204。 - * @param req HTTP 请求对象。 - * @return ESP_OK。 - */ -static esp_err_t http_options_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - httpd_resp_set_status(req, "204 No Content"); - httpd_resp_send(req, NULL, 0); - return ESP_OK; -} - // ---------------------------------------- // STA 后台重连任务(避免在系统事件回调中调用 esp_wifi_connect 死锁) // ---------------------------------------- /** * @brief 独立 FreeRTOS 任务,处理 STA 连接/重连的退避延迟和 esp_wifi_connect() 调用。 * 事件回调仅通过 xTaskNotifyGive 唤醒本任务,不直接在系统事件任务中阻塞或调用 WiFi API。 + * @param pvParameters FreeRTOS 任务参数(未使用)。 */ void WifiOta::reconnectTask(void* pvParameters) { @@ -252,7 +211,6 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base, // WiFi AP 模式初始化 /** * @brief 初始化 SoftAP 模式,供局域网 OTA 使用。 - * @return ESP_OK 成功,其他失败。 */ void WifiOta::initAp() { @@ -359,7 +317,7 @@ esp_err_t WifiOta::initSta() /** * @brief 等待 STA 连接成功并获取 IP(静态 IP 等 CONNECTED,DHCP 等 GOT_IP) * @param ip_str 输出缓冲区,存放获取到的 IP 字符串。 - * @param len 缓冲区长度。 + * @param ip_len 缓冲区长度。 * @return ESP_OK 连接成功,其他失败。 */ esp_err_t WifiOta::waitStaConnected(char *ip_str, size_t ip_len) @@ -445,913 +403,6 @@ void WifiOta::setStaticIp() -/** - * @brief HTTP POST /ota/update - 完成 ESP32 OTA 并重启 - * verify_only=true: 仅校验 SHA-256,不设置启动分区,不重启 - * verify_only=false或缺省: 完整流程(校验 + 设置启动分区 + 重启) - * - * 实际的 end/verify/set_boot/restart 逻辑全部委托给 Esp32OtaCore。 - * @param req - * @return - */ -static esp_err_t esp32_ota_update_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - Esp32OtaCore &core = Esp32OtaCore::instance(); - if (!core.isActive()) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA not started"); - return ESP_FAIL; - } - - // 读取请求体,获取前端 SHA-256 和 verify_only 标志 - char frontend_sha_hex[65] = {0}; - bool has_frontend_sha = false; - bool verify_only = false; - if (req->content_len > 0 && req->content_len < 256) { - char buf[256] = {0}; - int ret = httpd_req_recv(req, buf, req->content_len); - if (ret > 0) { - buf[ret] = '\0'; - cJSON *root = cJSON_Parse(buf); - if (root) { - cJSON *sha_item = cJSON_GetObjectItem(root, "sha256"); - if (sha_item && cJSON_IsString(sha_item)) { - const char *sha_str = cJSON_GetStringValue(sha_item); - if (sha_str && strlen(sha_str) == 64) { - strncpy(frontend_sha_hex, sha_str, 64); - frontend_sha_hex[64] = '\0'; - has_frontend_sha = true; - DEBUG_INFO("OTA", "Frontend SHA-256: %s", frontend_sha_hex); - } - } - cJSON *verify_item = cJSON_GetObjectItem(root, "verify_only"); - if (verify_item && cJSON_IsBool(verify_item)) { - verify_only = cJSON_IsTrue(verify_item); - } - cJSON_Delete(root); - } - } - } - - char device_sha_hex[65] = {0}; - - // ── verify_only 模式:只计算并返回设备端 SHA,不 end/set_boot/reboot ── - if (verify_only) { - DEBUG_INFO("OTA", "ESP32 OTA verify-only mode, %u bytes", (unsigned)core.getReceived()); - esp_err_t sha_err = core.computeDeviceSha(device_sha_hex); - if (sha_err != ESP_OK) { - DEBUG_ERR("OTA", "verify-only: SHA 计算失败: %s", esp_err_to_name(sha_err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(sha_err)); - return ESP_FAIL; - } - char ok_resp[256] = {0}; - snprintf(ok_resp, sizeof(ok_resp), - "{\"status\": \"ok\", \"message\": \"SHA-256 computed\", \"sha256\": \"%s\"}", - device_sha_hex); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, ok_resp, strlen(ok_resp)); - return ESP_OK; - } - - // ── 完整模式:收尾+重启序列由协调器统一封装 ── - // 关键:reboot=false 行为由 finalizeForReboot 内部封装, - // 响应发回前端后再由 restartEsp32() 统一通知+delay+esp_restart。 - DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived()); - Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot( - has_frontend_sha ? frontend_sha_hex : nullptr); - - if (fr.err == ESP_OK) { - // 1) 把包含设备端 SHA 的响应发回前端 - char resp[320] = {0}; - snprintf(resp, sizeof(resp), - "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", " - "\"sha256\": \"%s\"}", - fr.device_sha_hex); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - - // 2) 响应发完后由协调器统一重启(notify + delay + esp_restart) - DEBUG_INFO("OTA", "响应已发送,重启..."); - OtaCoordinator::instance().restartEsp32(); // 不返回 - return ESP_OK; - } - - // 失败:core 已内部清理(abort + mutex release) - if (fr.err == ESP_ERR_INVALID_VERSION) { - char err_resp[256] = {0}; - snprintf(err_resp, sizeof(err_resp), - "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}", - frontend_sha_hex, fr.device_sha_hex); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, err_resp, strlen(err_resp)); - } else { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(fr.err)); - } - return ESP_FAIL; -} - -/** - * @brief HTTP POST /restart - 立即重启 ESP32 - * @param req - * @return - */ -static esp_err_t restart_handler(httpd_req_t *req) -{ - (void)req; - http_set_cors_headers(req); - - DEBUG_INFO("OTA", "收到重启请求,重启..."); - - const char resp[] = "{\"status\": \"ok\", \"message\": \"rebooting\"}"; - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - - OtaCoordinator::instance().restartEsp32(); // 内部 notify + delay + esp_restart,不返回 - return ESP_OK; -} - -// ---------------------------------------- -// 从 HTTP 流中读取完整的 JSON 头(以 } 结尾) -/** - * @brief 返回 JSON 字符串长度,失败返回 0 - * @param req - * @param buf - * @param buf_size - * @return - */ -static int recv_json_header(httpd_req_t *req, char *buf, int buf_size) -{ - int total = 0; - int depth = 1; // 开头的 '{' 已被 peek 消费,从 1 开始 - while (total < buf_size - 1) { - int ret = httpd_req_recv(req, buf + total, 1); // 逐字节读,精确找到 JSON 结尾 - if (ret <= 0) return total; - total += ret; - buf[total] = '\0'; - if (buf[total - 1] == '{') depth++; - else if (buf[total - 1] == '}') { - depth--; - if (depth == 0) return total; // JSON 头读完 - } - } - return total; // fallback: return consumed bytes -} - -// ---------------------------------------- -// 统一 /ota/upload 处理器(自动识别目标) -/** - * @brief HTTP 文件上传处理器,接收固件并写入 Flash。 - * @param req - * @return ESP_OK 成功,其他失败。 - */ -static esp_err_t ota_upload_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - // ★ 入口追踪:用于确认 HTTP 请求是否真的到达设备(排查"前端点了但设备没反应") - DEBUG_WARN("WIFI", "=== HTTP: POST /ota/upload === content_len=%d", req->content_len); - DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len); - - // ★ WiFi OTA 入口:强制恢复 WIFI_PS_NONE,防止 BLE OTA 残留省电模式导致断连 - esp_wifi_set_ps(WIFI_PS_NONE); - WifiOta::instance().setBleOtaActive(false); - - // 预读 1 字节判断是 JSON 还是 binary - char peek; - int peek_ret = httpd_req_recv(req, &peek, 1); - if (peek_ret <= 0) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "接收失败"); - return ESP_FAIL; - } - - bool is_esp32_ota = false; - uint32_t total_size = 0; - int json_header_len = 0; - uint8_t pre_read_buf[256] = {0}; - int pre_read_len = 1; - pre_read_buf[0] = (uint8_t)peek; - - if (peek == '{') { - // JSON 模式:逐字节读完 JSON 头 - char json_buf[256]; - json_buf[0] = peek; - json_header_len = recv_json_header(req, json_buf + 1, sizeof(json_buf) - 1); - if (json_header_len > 0) { - json_header_len++; // 加上已读的 peek 字节 - json_buf[json_header_len] = '\0'; - pre_read_len = json_header_len; - if (pre_read_len > (int)sizeof(pre_read_buf)) pre_read_len = (int)sizeof(pre_read_buf); - memcpy(pre_read_buf, json_buf, pre_read_len); - - cJSON *root = cJSON_Parse(json_buf); - if (root) { - cJSON *type_item = cJSON_GetObjectItem(root, "type"); - if (type_item && cJSON_IsString(type_item) && - strcmp(cJSON_GetStringValue(type_item), "esp32") == 0) { - is_esp32_ota = true; - DEBUG_INFO("WIFI", "检测到 ESP32 OTA, JSON 头长度: %d 字节", json_header_len); - } - cJSON *size_item = cJSON_GetObjectItem(root, "total_size"); - if (size_item) { - total_size = (uint32_t)cJSON_GetNumberValue(size_item); - } - cJSON_Delete(root); - } - } - } - - if (is_esp32_ota) { - // --- ESP32 OTA --- - if (total_size == 0) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing total_size"); - return ESP_FAIL; - } - - // ★ 通过协调器发起(内部已含 forceCleanup 重试) - esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size); - if (err != ESP_OK) { - if (err == ESP_ERR_INVALID_STATE) { - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); - } else { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); - } - return ESP_FAIL; - } - - Esp32OtaCore &core = Esp32OtaCore::instance(); - - // ★ 使用文件级 static 缓冲区,避免堆碎片化(HTTP server 栈仅 8KB) - uint8_t *fw_buf = s_ota_recv_buf; - - size_t total_firmware_written = 0; - size_t total_received = json_header_len; // 已消费的字节数 - int last_percent = 0; - - while (total_received < req->content_len) { - size_t to_read = req->content_len - total_received; - if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; - - int ret = httpd_req_recv(req, (char *)fw_buf, to_read); - if (ret <= 0) { - DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret); - core.abort(); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); - return ESP_FAIL; - } - - err = core.write(fw_buf, (size_t)ret); - if (err != ESP_OK) { - DEBUG_ERR("WIFI", "OTA write failed: %s", esp_err_to_name(err)); - core.abort(); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); - return ESP_FAIL; - } - - total_firmware_written += ret; - total_received += ret; - - if (total_size > 0) { - int percent = (int)((total_firmware_written * 100) / total_size); - if (percent / 10 > last_percent / 10) { - last_percent = percent; - DEBUG_INFO("WIFI", "ESP32 OTA 进度: %u / %u (%d%%)", - (unsigned int)total_firmware_written, total_size, percent); - } - } - } - - DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节 (跳过 JSON 头 %d 字节)", (unsigned int)core.getReceived(), json_header_len); - const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", \"target\": \"esp32\"}"; - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; - } - - // --- GD32 OTA(原逻辑)--- - const uint32_t gd32_fw_size = (uint32_t)req->content_len; - // 网页上传协议:JSON 头 + raw binary。raw binary 大小优先取 JSON 中的 total_size,否则按 content_len - json_header_len 计算。 - uint32_t gd32_valid_size; - if (json_header_len > 0 && (uint32_t)json_header_len < gd32_fw_size) { - gd32_valid_size = (total_size > 0 && total_size < gd32_fw_size) ? total_size : (gd32_fw_size - (uint32_t)json_header_len); - } else { - gd32_valid_size = gd32_fw_size; - } - const uint32_t gd32_rounded_size = (uint32_t)(((gd32_valid_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE); - if (gd32_valid_size == 0U || gd32_valid_size > OTA_MAX_FIRMWARE_SIZE || gd32_rounded_size > OTA_MAX_FIRMWARE_SIZE) { - DEBUG_ERR("WIFI", "firmware size invalid: valid=%u rounded48=%u max=%u", - (unsigned)gd32_valid_size, (unsigned)gd32_rounded_size, (unsigned)OTA_MAX_FIRMWARE_SIZE); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "firmware too large or alignment overflow"); - return ESP_FAIL; - } - if (OtaRelay::getState() != RELAY_IDLE) { - DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState()); - OtaRelay::instance().reset(); - } - - // ★ 通过全局协调器注册 GD32 存储开始 - if (OtaCoordinator::instance().beginGd32Storage(gd32_valid_size) != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)"); - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "system busy with another OTA operation"); - return ESP_FAIL; - } - - WifiOta::instance().stateLock(); - WifiOta::instance().setGd32StorageDone(false); - WifiOta::instance().setRelayTriggered(false); - WifiOta::instance().stateUnlock(); - - const esp_partition_t *gd32_partition = esp_partition_find_first( - ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota"); - if (!gd32_partition) { - DEBUG_ERR("WIFI", "找不到 gd32_ota 分区"); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "找不到 gd32_ota 分区"); - return ESP_FAIL; - } - const uint32_t gd32_erase_size = align_up_u32(gd32_valid_size, GD32_OTA_FLASH_SECTOR_SIZE); - if (gd32_erase_size > gd32_partition->size) { - DEBUG_ERR("WIFI", "gd32_ota 分区空间不足: erase=%u partition=%u", - (unsigned)gd32_erase_size, (unsigned)gd32_partition->size); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "gd32_ota partition too small"); - return ESP_FAIL; - } - - DEBUG_INFO("WIFI", "擦除 gd32_ota 分区: %u bytes (fw=%u, partition=%u)", - (unsigned)gd32_erase_size, (unsigned)gd32_valid_size, (unsigned)gd32_partition->size); - esp_err_t erase_err = esp_partition_erase_range(gd32_partition, 0, gd32_erase_size); - if (erase_err != ESP_OK) { - DEBUG_ERR("WIFI", "擦除 gd32_ota 分区失败: %s", esp_err_to_name(erase_err)); - char err_msg[96]; - snprintf(err_msg, sizeof(err_msg), "erase gd32_ota failed: %s", esp_err_to_name(erase_err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg); - return ESP_FAIL; - } - OtaRelay::metaClear(); - - uint8_t *buf = s_ota_recv_buf; - - int received = 0; - - // GD32 路径与网页上传协议对齐:网页上传的是 raw binary(不带 6B 头)。 - uint32_t expected_fw_size_raw = gd32_valid_size; - ota_meta_t meta_raw = { - .total_size = expected_fw_size_raw, - .stored_size = 0, - .last_seq = 0, - .checksum = {0} - }; - OtaRelay::metaSave(&meta_raw); - uint32_t gd32_offset = 0; - - DEBUG_INFO("WIFI", "GD32 raw upload start: %u bytes", expected_fw_size_raw); - - // JSON 头已解析完毕,pre_read_buf 中的 JSON 字节不是固件数据,不能写入分区。 - // 仅在纯二进制模式(无 JSON 头)时写入 peek 的首字节。 - if (json_header_len == 0 && pre_read_len > 0) { - int first_chunk_len = pre_read_len; - if (first_chunk_len > req->content_len) first_chunk_len = req->content_len; - if (first_chunk_len > 0) { - gd32_offset = 0; - esp_err_t err = OtaRelay::flashWriteChunk(gd32_offset, pre_read_buf, (size_t)first_chunk_len); - if (err != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s", - first_chunk_len, esp_err_to_name(err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); - return ESP_FAIL; - } - received = first_chunk_len; - gd32_offset += (uint32_t)first_chunk_len; - OtaRelay::metaUpdate(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); - } - } else { - received = pre_read_len; // 跳过已消费的 JSON 头字节 - } - - while (received < req->content_len) { - size_t to_read = req->content_len - received; - if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; - - int ret = httpd_req_recv(req, (char *)buf, to_read); - if (ret <= 0) { - DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d", - received, req->content_len); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted"); - return ESP_FAIL; - } - - esp_err_t err = OtaRelay::flashWriteChunk(gd32_offset, buf, (size_t)ret); - if (err != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 write failed: offset=%u len=%d err=%s", - (unsigned)gd32_offset, ret, esp_err_to_name(err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); - return ESP_FAIL; - } - - received += ret; - gd32_offset += (uint32_t)ret; - } - - // update meta with written size - OtaRelay::metaUpdate(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); - - - DEBUG_INFO("WIFI", "GD32 upload done: %u / %u bytes", (unsigned)gd32_offset, expected_fw_size_raw); - - ota_meta_t final_meta; - OtaRelay::metaLoad(&final_meta); - if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) { - // ★ 通知协调器 GD32 存储完成 - OtaCoordinator::instance().completeGd32Storage(); - WifiOta::instance().stateLock(); - WifiOta::instance().setGd32StorageDone(true); - WifiOta::instance().stateUnlock(); - DEBUG_INFO("WIFI", "GD32 firmware fully stored, pre-computing SHA-256..."); - // 预计算 SHA-256(写入 NVS),由前端在 /ota/relay 时再校验通过后触发中继 - esp_err_t sha_err = OtaRelay::metaFinalizeChecksum(); - if (sha_err != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); - } - // ★ 不再自动 triggerGd32Relay:安全闸门已统一在 /ota/relay 处理(带 SHA 校验) - } - - const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\", " - "\"message\": \"stored, call POST /ota/relay with sha256 to trigger\"}"; - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; -} - -/** - * @brief HTTP POST /ota/relay 处理器 - * 必传 body: {"sha256": "<64-char hex>"}。 - * 校验通过后触发 GD32 中继;响应回传设备端 SHA(对齐 BLE 的 sendSha256Notify)。 - */ -static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - DEBUG_INFO("WIFI", "=== HTTP: POST /ota/relay ==="); - DEBUG_INFO("WIFI", "收到触发 OTA 中继请求"); - - // 1) 固件就绪检查 - ota_meta_t meta; - memset(&meta, 0, sizeof(meta)); - if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "firmware not fully stored"); - return ESP_FAIL; - } - - // 2) 解析前端 SHA-256(必须 64 字符 hex) - char frontend_sha_hex[65] = {0}; - if (req->content_len > 0 && req->content_len < 512) { - char buf[512] = {0}; - int ret = httpd_req_recv(req, buf, req->content_len); - if (ret > 0) { - buf[ret] = '\0'; - cJSON *root = cJSON_Parse(buf); - if (root) { - cJSON *sha_item = cJSON_GetObjectItem(root, "sha256"); - if (sha_item && cJSON_IsString(sha_item) && - strlen(cJSON_GetStringValue(sha_item)) == 64) { - strncpy(frontend_sha_hex, cJSON_GetStringValue(sha_item), 64); - frontend_sha_hex[64] = '\0'; - } - cJSON_Delete(root); - } - } - } - if (frontend_sha_hex[0] == '\0') { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, - "missing sha256 in body (frontend SHA-256 required)"); - return ESP_FAIL; - } - - // 3) hex(64) → 32 字节 - uint8_t frontend_sha[32] = {0}; - for (int i = 0; i < 32; ++i) { - char hex[3] = { frontend_sha_hex[i*2], frontend_sha_hex[i*2+1], 0 }; - frontend_sha[i] = (uint8_t)strtoul(hex, nullptr, 16); - } - - // 4) 共享 helper:计算设备端 SHA → 比对 → 回传 device_sha(无论是否匹配都回传) - uint8_t device_sha[32] = {0}; - char device_sha_hex[65] = {0}; - esp_err_t verify_err = OtaRelay::verifyFrontendSha(frontend_sha, device_sha); - for (int i = 0; i < 32; ++i) { - snprintf(&device_sha_hex[i*2], 3, "%02X", device_sha[i]); - } - - if (verify_err == ESP_ERR_INVALID_CRC) { - char err_resp[256]; - snprintf(err_resp, sizeof(err_resp), - "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", " - "\"device\": \"%s\"}", device_sha_hex); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, err_resp, strlen(err_resp)); - return ESP_FAIL; - } else if (verify_err != ESP_OK) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SHA compute failed"); - return ESP_FAIL; - } - - // 5) 校验通过 → 触发中继 - esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); - if (trigger_err != ESP_OK) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, - "relay trigger failed - system busy or firmware not ready"); - return ESP_FAIL; - } - - // 6) 成功响应回传设备端 SHA(对齐 BLE 的 sendSha256Notify) - char resp[256]; - snprintf(resp, sizeof(resp), - "{\"status\": \"ok\", \"message\": \"OTA relay triggered\", " - "\"sha256\": \"%s\"}", device_sha_hex); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - - return ESP_OK; -} - -/** - * @brief HTTP GET /logs - 拉取并清空日志缓冲区 - * @param req - * @return - */ -static esp_err_t logs_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - - int json_len = 0; - char *json = DebugLog::instance().ringDrainJson(&json_len); - - if (json && json_len > 0) { - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, json, json_len); - free(json); - } else { - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, "[]", 2); - } - return ESP_OK; -} - -// HTTP POST /can/send 处理器 -// 上位机发送 CAN 帧,经 ESP32 转发到 GD32 -// 请求体: {"ch": 0, "id": 123456, "data": "0102030405060708", "dlc": 8} -/** - * @brief data 为 hex 字符串(可不填,自动用 dlc 决定实际发送长度) - * @param req - * @return - */ -static esp_err_t can_send_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - char buf[256] = {0}; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败"); - return ESP_FAIL; - } - buf[ret] = '\0'; - - cJSON *root = cJSON_Parse(buf); - if (!root) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); - return ESP_FAIL; - } - - cJSON *ch_item = cJSON_GetObjectItem(root, "ch"); - cJSON *id_item = cJSON_GetObjectItem(root, "id"); - cJSON *data_item = cJSON_GetObjectItem(root, "data"); - cJSON *dlc_item = cJSON_GetObjectItem(root, "dlc"); - - if (!ch_item || !id_item) { - cJSON_Delete(root); - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 ch 或 id 字段"); - return ESP_FAIL; - } - - can_channel_t ch = (can_channel_t)cJSON_GetNumberValue(ch_item); - uint32_t ext_id = (uint32_t)cJSON_GetNumberValue(id_item); - uint8_t dlc = dlc_item ? (uint8_t)cJSON_GetNumberValue(dlc_item) : 0; - - uint8_t tx_data[CAN_MAX_DLC] = {0}; - uint8_t tx_dlc = 0; - - if (data_item && cJSON_IsString(data_item)) { - const char *hex_str = cJSON_GetStringValue(data_item); - size_t hex_len = strlen(hex_str); - for (size_t i = 0; i < hex_len / 2 && i < CAN_MAX_DLC; i++) { - char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' }; - tx_data[i] = (uint8_t)strtol(byte_str, NULL, 16); - tx_dlc++; - } - } - - if (tx_dlc == 0 && dlc > 0) { - tx_dlc = (dlc > CAN_MAX_DLC) ? CAN_MAX_DLC : dlc; - } - cJSON_Delete(root); - - // 发送 CAN 帧到 GD32 - uint8_t resp_data[CAN_MAX_DLC] = {0}; - int resp_len = SpiMaster::instance().canSend(ch, ext_id, tx_data, tx_dlc, - resp_data, sizeof(resp_data), 1000); - - char resp[512]; - if (resp_len >= 0) { - // 将响应 data 转为 hex 字符串 - char resp_hex[CAN_MAX_DLC * 2 + 1] = {0}; - for (int i = 0; i < resp_len; i++) { - sprintf(&resp_hex[i * 2], "%02X", resp_data[i]); - } - snprintf(resp, sizeof(resp), - "{\"status\": \"ok\", \"ch\": %d, \"id\": %lu, \"sent\": %d, \"recv_len\": %d, \"recv\": \"%s\"}", - ch, (unsigned long)ext_id, tx_dlc, resp_len, resp_hex); - } else { - snprintf(resp, sizeof(resp), - "{\"status\": \"error\", \"code\": %d}", resp_len); - } - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; -} - -// HTTP POST /spi/send 处理器 -// 网页传 type + payload,ESP32 自动构建 SOF+TYPE+LEN(L/H)+PAYLOAD+CRC 帧 -// 并通过 spi_ota_transfer 完成完整事务(发送+轮询响应帧+解析)。 -// -// 请求体: {"type": "01", "data": "01020304", "timeout_ms": 1000} -// type: 帧类型 (hex 1 字节,如 01=REQ, 02=DATA, B0=BOOT) -// data: 负载数据 (hex 字符串,可选) -// timeout_ms: 超时毫秒 (可选, 默认 1000) -/** - * @brief HTTP POST /spi/send 处理器,转发上位机请求到 SPI 并返回结果。 - * @param req HTTP 请求对象。 - * @return ESP_OK 成功,ESP_FAIL 表示请求处理失败。 - */ -static esp_err_t spi_send_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - char buf[512] = {0}; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败"); - return ESP_FAIL; - } - buf[ret] = '\0'; - - cJSON *root = cJSON_Parse(buf); - if (!root) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); - return ESP_FAIL; - } - - cJSON *type_item = cJSON_GetObjectItem(root, "type"); - cJSON *data_item = cJSON_GetObjectItem(root, "data"); - cJSON *timeout_item = cJSON_GetObjectItem(root, "timeout_ms"); - - if (!type_item || !cJSON_IsString(type_item)) { - cJSON_Delete(root); - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 type 字段"); - return ESP_FAIL; - } - const char *type_hex = cJSON_GetStringValue(type_item); - uint8_t frame_type = (uint8_t)strtol(type_hex, NULL, 16); - - uint32_t timeout_ms = timeout_item ? (uint32_t)cJSON_GetNumberValue(timeout_item) : 1000; - - // 解析 payload (hex 字符串 → 字节数组) - uint8_t payload_data[SPI_OTA_FRAME_MAX] = {0}; - size_t payload_len = 0; - if (data_item && cJSON_IsString(data_item)) { - const char *hex_str = cJSON_GetStringValue(data_item); - size_t hex_len = strlen(hex_str); - for (size_t i = 0; i < hex_len / 2 && i < SPI_OTA_FRAME_MAX; i++) { - char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' }; - payload_data[i] = (uint8_t)strtol(byte_str, NULL, 16); - payload_len++; - } - } - - DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节", - frame_type, payload_len); - cJSON_Delete(root); - - char resp[1024]; - uint8_t resp_type = 0; - uint8_t resp_payload[SPI_OTA_FRAME_MAX] = {0}; - size_t resp_len = 0; - esp_err_t spi_err = SpiMaster::instance().otaTransfer(frame_type, payload_data, payload_len, - &resp_type, resp_payload, sizeof(resp_payload), &resp_len, - timeout_ms); - - if (spi_err == ESP_OK) { - char resp_hex[SPI_OTA_FRAME_MAX * 2 + 1] = {0}; - for (size_t i = 0; i < resp_len; i++) { - sprintf(&resp_hex[i * 2], "%02X", resp_payload[i]); - } - snprintf(resp, sizeof(resp), - "{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}", - frame_type, payload_len, resp_type, resp_len, resp_hex); - DEBUG_INFO("SPI", "WEB SPI 响应: type=0x%02X len=%zu payload=%s", - resp_type, resp_len, resp_hex); - } else { - snprintf(resp, sizeof(resp), - "{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}", - spi_err, esp_err_to_name(spi_err)); - DEBUG_ERR("SPI", "WEB SPI 失败: %s", esp_err_to_name(spi_err)); - } - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; -} - -static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; - -struct TaskStackMetric { - const char* name; - TaskHandle_t handle; - size_t stack_size_bytes; -}; - -static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle) -{ - if (handle == NULL) return 0; - UBaseType_t highWaterMark = uxTaskGetStackHighWaterMark(handle); - return (size_t)highWaterMark; -} - -static size_t get_task_stack_peak_used_bytes(TaskHandle_t handle, size_t stack_size_bytes) -{ - size_t high_watermark = get_task_stack_high_watermark_bytes(handle); - return (stack_size_bytes >= high_watermark) ? (stack_size_bytes - high_watermark) : 0; -} - -/** - * @brief 追加 JSON 内容到响应缓冲区,自动处理 snprintf 截断。 - */ -static int append_jsonf(char* buf, size_t cap, int offset, const char* fmt, ...) -{ - if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset; - va_list args; - va_start(args, fmt); - int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, args); - va_end(args); - if (n < 0) return offset; - size_t written = (size_t)n; - if (offset + (int)written >= (int)cap) { - buf[cap - 1] = '\0'; - return (int)(cap - 1); - } - return offset + (int)written; -} - -/** - * @brief HTTP GET /system/status 处理器 - * @param req - * @return - */ -static esp_err_t system_status_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - - char ip_str[16] = "0.0.0.0"; - int8_t rssi = -127; - bool connected = false; - - if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) { - esp_netif_ip_info_t ip_info; - esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) { - snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); - } - wifi_ap_record_t ap_info; - if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { - rssi = ap_info.rssi; - } - connected = true; - } - gd32_version_t pingVer = {}; - OtaRelay::getGd32Version(&pingVer); - - /* 若尚无 GD32 版本信息且 OTA 中继空闲,尝试 SPI PING 获取(最多 5 次) */ - // ★ 修复:OTA 活跃期间(存储/中继/ESP32 OTA)跳过 appPing(), - // 避免 HTTP 任务长期持有 SPI mutex 与 relay 任务争用, - // 导致优先级继承异常 → vTaskPriorityDisinheritAfterTimeout assert。 - ota_system_state_t curSysState = OtaCoordinator::instance().getSystemState(); - bool ota_busy = (curSysState == OTA_SYS_GD32_STORING || - curSysState == OTA_SYS_GD32_RELAYING || - curSysState == OTA_SYS_ESP32_RECEIVING || - curSysState == OTA_SYS_ESP32_FINALIZING || - curSysState == OTA_SYS_ESP32_REBOOTING); - if (!ota_busy && - pingVer.app_major == 0 && - pingVer.boot_major == 0 && - OtaRelay::getState() == RELAY_IDLE) { - static uint32_t last_ping_ms = 0; - static uint8_t ping_attempts = 0; - uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000); - if (ping_attempts < 5 && now_ms - last_ping_ms > 5000) { /* 5 秒节流 */ - last_ping_ms = now_ms; - ++ping_attempts; - uint32_t uptime = 0; - gd32_version_t ver = {}; - if (SpiMaster::instance().appPing(500, &uptime, &ver) == ESP_OK) { - OtaRelay::setGd32AppAlive(true); - OtaRelay::setGd32Version(ver); - ping_attempts = 0; /* 成功后重置,允许后续更新 */ - } else { - OtaRelay::setGd32AppAlive(false); - if (ping_attempts >= 5) { - DEBUG_WARN("STATUS", "GD32 版本号读取失败,已达最大重试次数 (5),停止尝试"); - } - } - } - } - - ota_meta_t meta; - memset(&meta, 0, sizeof(meta)); - OtaRelay::metaLoad(&meta); - - gd32_version_t snapVer2 = {}; - OtaRelay::getGd32Version(&snapVer2); - ota_system_state_t sysState = OtaCoordinator::instance().getSystemState(); - size_t free_heap = esp_get_free_heap_size(); - size_t min_free_heap = esp_get_minimum_free_heap_size(); - size_t largest_free_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - size_t internal_free_heap = heap_caps_get_free_size(INTERNAL_HEAP_CAPS); - size_t internal_min_free_heap = heap_caps_get_minimum_free_size(INTERNAL_HEAP_CAPS); - size_t internal_largest_free_block = heap_caps_get_largest_free_block(INTERNAL_HEAP_CAPS); - size_t internal_heap_total = heap_caps_get_total_size(INTERNAL_HEAP_CAPS); - size_t default_heap_total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT); - TaskStackMetric task_metrics[] = { - { "http_server", xTaskGetCurrentTaskHandle(), HTTP_SERVER_STACK_SIZE }, - { "health_mon", HealthMonitor::instance().getTaskHandle(), 4096 }, - { "ota_relay", OtaRelay::getTaskHandle(), 6144 }, - { "wifi_ota", WifiOta::instance().getTaskHandle(), 8192 }, - { "wifi_reconnect", WifiOta::instance().m_reconnectTask, 4096 }, - { "ble_ota", BleOta::instance().getTaskHandle(), 4096 }, - { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), 4096 } - }; - - char resp[4096]; - int len = snprintf(resp, sizeof(resp), - "{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, " - "\"free_heap\": %lu, \"min_free_heap\": %lu, \"largest_free_block\": %lu, " - "\"internal_free_heap\": %lu, \"internal_min_free_heap\": %lu, \"internal_largest_free_block\": %lu, " - "\"internal_heap_total\": %lu, \"default_heap_total\": %lu, " - "\"temperature\": %.1f, \"uptime_sec\": %lu, " - "\"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, " - "\"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, " - "\"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, " - "\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}, " - "\"task_stack\": {", - FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(), - (unsigned long)free_heap, - (unsigned long)min_free_heap, - (unsigned long)largest_free_block, - (unsigned long)internal_free_heap, - (unsigned long)internal_min_free_heap, - (unsigned long)internal_largest_free_block, - (unsigned long)internal_heap_total, - (unsigned long)default_heap_total, - (double)HealthMonitor::instance().getLastTemperature(), - (unsigned long)(esp_timer_get_time() / 1000000), - WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP ? "AP" : "STA", - ip_str, - rssi, - connected ? "true" : "false", - OtaCoordinator::instance().getSystemStateString(), - (int)sysState, - (unsigned long)meta.total_size, - (unsigned long)meta.stored_size, - OtaCoordinator::instance().isGd32StorageDone() ? "true" : "false", - OtaRelay::getState(), - (unsigned long)OtaRelay::getRelaySeq(), - (unsigned long)OtaRelay::getRelayTotalPackets(), - OtaRelay::getGd32AppAlive() ? "true" : "false", - snapVer2.app_major, snapVer2.app_minor, - snapVer2.app_patch, snapVer2.app_build, - snapVer2.boot_major, snapVer2.boot_minor, - snapVer2.boot_patch, snapVer2.boot_build); - - for (size_t i = 0; i < sizeof(task_metrics) / sizeof(task_metrics[0]); ++i) { - len = append_jsonf(resp, sizeof(resp), len, - "%s\"%s\":{\"stack_size_bytes\":%lu,\"high_watermark_bytes\":%lu,\"peak_used_bytes\":%lu}", - i == 0 ? "" : ",", - task_metrics[i].name, - (unsigned long)task_metrics[i].stack_size_bytes, - (unsigned long)get_task_stack_high_watermark_bytes(task_metrics[i].handle), - (unsigned long)get_task_stack_peak_used_bytes(task_metrics[i].handle, task_metrics[i].stack_size_bytes)); - } - len = append_jsonf(resp, sizeof(resp), len, "}}"); - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, len); - return ESP_OK; -} - // ---------------------------------------- // 启动 HTTP OTA 服务器 /** @@ -1381,7 +432,7 @@ esp_err_t WifiOta::httpServerStart() // 从 NVS 恢复固件存储状态(重启后保留) { ota_meta_t boot_meta; - if (OtaRelay::metaLoad(&boot_meta) == ESP_OK && + if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK && boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) { WifiOta::instance().setGd32StorageDone(true); DEBUG_INFO("WIFI", "从 NVS 恢复: 固件已就绪 (%u bytes)", boot_meta.total_size); @@ -1517,32 +568,6 @@ esp_err_t WifiOta::httpServerStart() // ---------------------------------------- // 公开 API -/** - * @brief 通知 OTA 中继开始向 GD32 转发固件。 - */ -void WifiOta::signalRelay() -{ - ota_meta_t meta; - memset(&meta, 0, sizeof(meta)); - if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) { - DEBUG_WARN("WIFI", "GD32 固件未完整存储,拒绝触发转发: stored=%" PRIu32 " total=%" PRIu32, - meta.stored_size, meta.total_size); - return; - } - - // ★ 通过全局协调器触发中继 - esp_err_t err = OtaCoordinator::instance().triggerGd32Relay(); - if (err == ESP_OK) { - WifiOta::instance().stateLock(); - WifiOta::instance().setRelayTriggered(true); - WifiOta::instance().stateUnlock(); - DEBUG_INFO("WIFI", "OTA 中继已通过协调器触发"); - } else { - DEBUG_ERR("WIFI", "OTA 中继触发失败: %s", esp_err_to_name(err)); - WifiOta::instance().setRelayTriggered(false); - } -} - /** * @brief 获取当前 WiFi 工作模式(AP 或 STA)。 * @return 当前 WiFi 模式枚举值。 diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 76ba717..921d36b 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -14,10 +14,10 @@ #include "freertos/task.h" #include -// SHA-256 验证所需类型前向声明 +/** @brief WiFi 工作模式枚举,区分 AP(设备热点)和 STA(连接路由器)模式。 */ typedef enum { - OTA_WIFI_MODE_AP, - OTA_WIFI_MODE_STA + OTA_WIFI_MODE_AP, ///< AP 模式:设备作为热点供局域网设备连接 + OTA_WIFI_MODE_STA ///< STA 模式:设备连接上级路由器 } ota_wifi_mode_t; class WifiOta { @@ -38,11 +38,6 @@ public: * @param pvParameters FreeRTOS 任务参数。 */ static void taskEntry(void *pvParameters); - /** - * @brief 通知 OTA 中继开始向 GD32 转发固件。 - */ - void signalRelay(); - /** * @brief 获取当前 WiFi 工作模式。 * @return 当前 WiFi 模式(AP 或 STA)。 @@ -76,11 +71,6 @@ public: bool getGd32StorageDone() const { return m_gd32StorageDone.load(); } /** @brief 设置 GD32 固件存储完成标志。 */ void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); } - /** @brief 获取中继触发标志。 */ - bool getRelayTriggered() const { return m_relayTriggered.load(); } - /** @brief 设置中继触发标志。 */ - void setRelayTriggered(bool v) { m_relayTriggered.store(v); } - // ── 状态互斥锁(保护复合读-改-写操作)── /** @brief 获取状态互斥锁句柄。 */ SemaphoreHandle_t getStateMutex() const { return m_stateMutex; } @@ -90,13 +80,13 @@ public: void stateUnlock() { if (m_stateMutex) xSemaphoreGive(m_stateMutex); } // ── 初始化/运行时状态(仅 OTA 任务访问,无需额外锁)── - ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP; - bool m_coldBoot = false; - SemaphoreHandle_t m_staConnectedSem = NULL; - SemaphoreHandle_t m_staGotIpSem = NULL; - TaskHandle_t m_reconnectTask = nullptr; + ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP; ///< 当前 WiFi 工作模式 + bool m_coldBoot = false; ///< 是否为冷启动(首次上电) + SemaphoreHandle_t m_staConnectedSem = NULL; ///< STA 连接成功信号量 + SemaphoreHandle_t m_staGotIpSem = NULL; ///< STA 获取 IP 信号量 + TaskHandle_t m_reconnectTask = nullptr; ///< STA 后台重连任务句柄 - httpd_handle_t m_httpServer = NULL; + httpd_handle_t m_httpServer = NULL; ///< HTTP OTA 服务器句柄 // ★ BLE OTA 传输期间暂停 WiFi 射频活动(减少共存干扰) std::atomic m_bleOtaActive{false}; @@ -112,7 +102,6 @@ private: std::atomic m_staRetryCount{0}; std::atomic m_staRetryExhausted{false}; std::atomic m_gd32StorageDone{false}; - std::atomic m_relayTriggered{false}; SemaphoreHandle_t m_stateMutex = NULL; TaskHandle_t m_taskHandle = nullptr; diff --git a/main/storage/gd32_flash_store.cpp b/main/storage/gd32_flash_store.cpp new file mode 100644 index 0000000..dd296f2 --- /dev/null +++ b/main/storage/gd32_flash_store.cpp @@ -0,0 +1,351 @@ +/* + * gd32_flash_store.cpp - GD32 OTA 固件 Flash 存储抽象层实现 + * + * 从 OtaRelay 剥离的 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验。 + */ +#include +#include +#include +#include "esp_partition.h" +#include "nvs_flash.h" +#include "nvs.h" +#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS +#include "mbedtls/private/sha256.h" + +#include "gd32_flash_store.h" +#include "monitor/debug_log.h" + +// ============================================================ +// 单例 +// ============================================================ +Gd32FlashStore& Gd32FlashStore::instance() +{ + static Gd32FlashStore inst; + return inst; +} + +// ============================================================ +// 分区懒初始化 +// ============================================================ +const esp_partition_t* Gd32FlashStore::getPartition() +{ + if (!m_partition) { + m_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota"); + if (!m_partition) { + DEBUG_ERR("OTA", "找不到 gd32_ota 分区"); + } + } + return m_partition; +} + +// ============================================================ +// Flash 分区读写 +// ============================================================ + +/** @brief 向 gd32_ota 分区写入数据块,自动处理非 4 字节对齐的读写。 */ +esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len) +{ + const esp_partition_t *part = getPartition(); + if (!part) return ESP_ERR_NOT_FOUND; + + // 计算 head/tail padding,使写入地址和长度都对齐到 4 字节 + size_t head_pad = offset % 4; + uint32_t aligned_offset = offset - head_pad; + size_t total = head_pad + len; + size_t aligned_len = (total + 3) & ~(size_t)3; + + if (aligned_offset + aligned_len > part->size) { + DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu aligned_off=%" PRIu32 " aligned_len=%zu part=%" PRIu32, + offset, len, aligned_offset, aligned_len, part->size); + return ESP_ERR_INVALID_SIZE; + } + + // 快速路径:offset 和 len 都对齐 + if (head_pad == 0 && len % 4 == 0) { + return esp_partition_write(part, offset, data, len); + } + + if (head_pad > 0 || len % 4 != 0) { + DEBUG_WARN("OTA", "flashWriteChunk 非对齐: offset=%" PRIu32 " len=%zu head=%zu aligned_len=%zu", + offset, len, head_pad, aligned_len); + } + + 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; + } + + // 读回 head padding + if (head_pad > 0) { + esp_err_t err = esp_partition_read(part, aligned_offset, aligned_buf, head_pad); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s", + aligned_offset, head_pad, esp_err_to_name(err)); + free(aligned_buf); + return err; + } + } + + // 拷贝实际数据 + memcpy(aligned_buf + head_pad, data, len); + + // 读回 tail padding + size_t tail_pad = aligned_len - total; + if (tail_pad > 0) { + esp_err_t err = esp_partition_read(part, aligned_offset + total, aligned_buf + total, tail_pad); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s", + aligned_offset + total, tail_pad, esp_err_to_name(err)); + free(aligned_buf); + return err; + } + } + + esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len); + free(aligned_buf); + return err; +} + +/** @brief 从 gd32_ota 分区读取数据块。 */ +esp_err_t Gd32FlashStore::flashReadChunk(uint32_t offset, uint8_t *data, size_t len) +{ + const esp_partition_t *part = getPartition(); + if (!part) return ESP_ERR_NOT_FOUND; + return esp_partition_read(part, offset, data, len); +} + +/** @brief 擦除 gd32_ota 分区指定范围。 */ +esp_err_t Gd32FlashStore::erasePartition(uint32_t size) +{ + const esp_partition_t *part = getPartition(); + if (!part) return ESP_ERR_NOT_FOUND; + if (size > part->size) { + DEBUG_ERR("OTA", "erasePartition: size=%" PRIu32 " > partition=%" PRIu32, size, part->size); + return ESP_ERR_INVALID_SIZE; + } + return esp_partition_erase_range(part, 0, size); +} + +// ============================================================ +// NVS 元数据持久化 +// ============================================================ + +/** @brief 从 NVS 加载 OTA 元数据,命名空间不存在时返回全零结构。 */ +esp_err_t Gd32FlashStore::metaLoad(ota_meta_t *meta) +{ + if (!meta) return ESP_ERR_INVALID_ARG; + memset(meta, 0, sizeof(ota_meta_t)); + + nvs_handle_t handle; + esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READONLY, &handle); + if (err != ESP_OK) { + return ESP_OK; + } + + uint32_t val = 0; + 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; + + size_t sha_len = sizeof(meta->checksum); + if (nvs_get_blob(handle, "checksum", meta->checksum, &sha_len) == ESP_OK && sha_len == sizeof(meta->checksum)) { + } else { + memset(meta->checksum, 0, sizeof(meta->checksum)); + } + + nvs_close(handle); + return ESP_OK; +} + +/** @brief 将 OTA 元数据写入 NVS。 */ +esp_err_t Gd32FlashStore::metaSave(const ota_meta_t *meta) +{ + if (!meta) return ESP_ERR_INVALID_ARG; + + nvs_handle_t handle; + esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "无法打开 NVS 写入: %s", esp_err_to_name(err)); + return err; + } + + 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_blob(handle, "checksum", meta->checksum, sizeof(meta->checksum)); + err = nvs_commit(handle); + nvs_close(handle); + + if (err != ESP_OK) { + DEBUG_ERR("OTA", "NVS commit 失败: %s", esp_err_to_name(err)); + } + return err; +} + +/** @brief 传输过程中更新元数据的 stored_size 和 last_seq 字段。 */ +esp_err_t Gd32FlashStore::metaUpdate(uint32_t stored_size, uint32_t last_seq) +{ + ota_meta_t meta; + esp_err_t err = metaLoad(&meta); + if (err != ESP_OK) return err; + + meta.stored_size = stored_size; + meta.last_seq = last_seq; + + return metaSave(&meta); +} + +/** @brief OTA 完成或中止时清除元数据(全零写入)。 */ +esp_err_t Gd32FlashStore::metaClear() +{ + DEBUG_INFO("OTA", "清除 OTA 元数据"); + ota_meta_t meta = {}; + return metaSave(&meta); +} + +// ============================================================ +// SHA-256 校验 +// ============================================================ + +/** @brief 从 gd32_ota 分区读取固件并计算 SHA-256,输出 32 字节哈希。 */ +esp_err_t Gd32FlashStore::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 并写入 meta.checksum。 */ +esp_err_t Gd32FlashStore::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 校验固件完整性:重新计算 Flash SHA-256 并与 meta.checksum 比对。 */ +esp_err_t Gd32FlashStore::metaVerifyChecksum() +{ + ota_meta_t meta; + esp_err_t err = metaLoad(&meta); + if (err != ESP_OK) return err; + + // 检查 checksum 是否全零(未计算过) + bool all_zero = true; + for (size_t i = 0; i < sizeof(meta.checksum); ++i) { + if (meta.checksum[i] != 0x00) { all_zero = false; break; } + } + if (all_zero) { + 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; +} + +esp_err_t Gd32FlashStore::verifyFrontendSha(const uint8_t frontend_sha32[32], + uint8_t out_device_sha[32]) +{ + if (!frontend_sha32) return ESP_ERR_INVALID_ARG; + + // 1) 计算设备端 SHA 并写入 meta.checksum + esp_err_t err = metaFinalizeChecksum(); + if (err != ESP_OK) return err; + + // 2) 读回 meta 拿到 checksum + ota_meta_t meta; + err = metaLoad(&meta); + if (err != ESP_OK) return err; + if (out_device_sha) memcpy(out_device_sha, meta.checksum, 32); + + // 3) 比对前端 SHA 与设备端 SHA + if (memcmp(meta.checksum, frontend_sha32, 32) != 0) { + return ESP_ERR_INVALID_CRC; + } + return ESP_OK; +} diff --git a/main/storage/gd32_flash_store.h b/main/storage/gd32_flash_store.h new file mode 100644 index 0000000..96120f1 --- /dev/null +++ b/main/storage/gd32_flash_store.h @@ -0,0 +1,146 @@ +/* + * gd32_flash_store.h - GD32 OTA 固件 Flash 存储抽象层 + * + * 从 OtaRelay 中剥离的 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验职责。 + * 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储, + * 不再由 OtaRelay 承担存储职责(解决 H1 职责超载)。 + * + * 线程模型: + * - 所有方法均为 static,由调用方(协调器/OtaRelay/传输层)保证串行化 + * - 分区指针懒初始化,首次使用时查找并缓存 + */ +#ifndef GD32_FLASH_STORE_H +#define GD32_FLASH_STORE_H + +#include +#include "esp_err.h" +#include "esp_partition.h" + +#define OTA_META_NVS_NAMESPACE "ota_meta" ///< OTA 元数据 NVS 命名空间 + +/** @brief OTA 传输元数据结构,持久化到 NVS 用于断点续传和完整性校验。 */ +typedef struct { + uint32_t total_size; ///< 固件总大小(字节) + uint32_t stored_size; ///< 已存储到 Flash 的大小(字节) + uint32_t last_seq; ///< 最后一次成功写入的包序号 + uint8_t checksum[32]; ///< 固件 SHA-256 校验值 +} ota_meta_t; + +/** + * @brief GD32 OTA 固件 Flash 存储抽象层(纯静态工具类)。 + * + * 封装 gd32_ota 分区的读写、NVS 元数据持久化、SHA-256 校验。 + * 传输层通过 OtaCoordinator 或直接调用本类完成存储操作, + * OtaRelay 仅负责 SPI 中继状态机,不再承担存储职责。 + */ +class Gd32FlashStore { +public: + /** + * @brief 获取单例实例。 + * @return Gd32FlashStore 单例引用。 + */ + static Gd32FlashStore& instance(); + + // ── Flash 分区读写 ── + + /** + * @brief 向 gd32_ota 分区写入数据块(支持非 4 字节对齐写入)。 + * @param offset 分区内写入偏移(字节)。 + * @param data 待写入数据缓冲区。 + * @param len 数据长度(字节)。 + * @return ESP_OK 成功,其他错误码表示失败。 + */ + esp_err_t flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len); + + /** + * @brief 从 gd32_ota 分区读取数据块。 + * @param offset 分区内读取偏移(字节)。 + * @param data 输出数据缓冲区。 + * @param len 读取长度(字节)。 + * @return ESP_OK 成功,其他错误码表示失败。 + */ + esp_err_t flashReadChunk(uint32_t offset, uint8_t *data, size_t len); + + /** + * @brief 擦除 gd32_ota 分区指定范围。 + * @param size 擦除大小(字节),从偏移 0 开始。 + * @return ESP_OK 成功,其他错误码表示失败。 + */ + esp_err_t erasePartition(uint32_t size); + + // ── NVS 元数据持久化 ── + + /** + * @brief 从 NVS 加载 OTA 元数据(命名空间不存在时返回全零结构)。 + * @param meta 输出元数据结构体指针。 + * @return ESP_OK 成功。 + */ + esp_err_t metaLoad(ota_meta_t *meta); + + /** + * @brief 将 OTA 元数据写入 NVS。 + * @param meta 待保存的元数据结构体指针。 + * @return ESP_OK 成功,NVS 写入失败时返回错误码。 + */ + esp_err_t metaSave(const ota_meta_t *meta); + + /** + * @brief 传输过程中更新元数据的 stored_size 和 last_seq。 + * @param stored_size 已存储大小(字节)。 + * @param last_seq 最后成功写入的包序号。 + * @return ESP_OK 成功,其他错误码表示失败。 + */ + esp_err_t metaUpdate(uint32_t stored_size, uint32_t last_seq); + + /** + * @brief OTA 完成或中止时清除元数据(全零写入)。 + * @return ESP_OK 成功。 + */ + esp_err_t metaClear(); + + // ── SHA-256 校验 ── + + /** + * @brief 计算 gd32_ota 分区中已存储固件的 SHA-256。 + * @param out_sha256 输出 32 字节 SHA-256 哈希。 + * @return ESP_OK 成功,ESP_ERR_INVALID_STATE 元数据无效。 + */ + esp_err_t computeFlashSha256(uint8_t out_sha256[32]); + + /** + * @brief 固件接收完毕后计算 SHA-256 并写入元数据。 + * @return ESP_OK 成功,ESP_ERR_INVALID_STATE 固件不完整。 + */ + esp_err_t metaFinalizeChecksum(); + + /** + * @brief 校验固件完整性(重新计算 SHA-256 并与 meta.checksum 比对)。 + * @return ESP_OK 校验通过,ESP_ERR_INVALID_CRC 校验失败。 + */ + esp_err_t metaVerifyChecksum(); + + /** + * @brief 校验前端提供的 SHA-256 是否与设备端计算的一致。 + * @param frontend_sha32 前端 32 字节原始 SHA(非 hex)。 + * @param out_device_sha 可为 nullptr;非空时输出设备端 32 字节 SHA。 + * @return ESP_OK 匹配;ESP_ERR_INVALID_CRC 不匹配;其他 = 元数据/计算失败。 + */ + esp_err_t verifyFrontendSha(const uint8_t frontend_sha32[32], + uint8_t out_device_sha[32]); + + /** + * @brief 获取分区指针(懒初始化)。 + * @return 分区指针,找不到时返回 nullptr。 + */ + const esp_partition_t* getPartition(); + +private: + Gd32FlashStore() = default; + ~Gd32FlashStore() = default; + Gd32FlashStore(const Gd32FlashStore&) = delete; + Gd32FlashStore& operator=(const Gd32FlashStore&) = delete; + + const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化) +}; + +#endif // GD32_FLASH_STORE_H diff --git a/main/transport/protocol.cpp b/main/transport/protocol.cpp index b075b6d..3fe27e8 100644 --- a/main/transport/protocol.cpp +++ b/main/transport/protocol.cpp @@ -55,6 +55,10 @@ void Protocol::init() /** * @brief 根据时间戳、地址和随机种子生成 16 字节 nonce。 + * @param timestamp 当前时间戳(tick)。 + * @param mem_addr 内存地址(增加随机性)。 + * @param rand_seed 随机种子。 + * @param nonce_out 输出 16 字节 nonce 缓冲区。 */ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, uint32_t rand_seed, uint8_t *nonce_out) @@ -84,7 +88,10 @@ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, } /** - * @brief 会话密钥初始化 + * @brief 会话密钥初始化。 + * @param sess 会话上下文指针。 + * @param k_session 16 字节会话密钥。 + * @return 0 成功,-1 参数无效。 */ int Protocol::sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session) { @@ -100,6 +107,7 @@ int Protocol::sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session) /** * @brief 清除会话上下文中的密钥和状态。 + * @param sess 会话上下文指针。 */ void Protocol::sessionKeyClear(session_ctx_t *sess) { diff --git a/main/transport/protocol.h b/main/transport/protocol.h index a3ca412..6d14ce0 100644 --- a/main/transport/protocol.h +++ b/main/transport/protocol.h @@ -18,8 +18,8 @@ #define FRAME_OVERHEAD (6) // SOF + CMD + LEN(2) + CRC16(2) // 安全常量(定义在 app_config.h 中) -inline constexpr size_t MASTER_KEY_HERE_LEN = 16; -inline constexpr size_t CHIP_UID_LEN = 12; +inline constexpr size_t MASTER_KEY_HERE_LEN = 16; ///< AES-128 主密钥长度(字节) +inline constexpr size_t CHIP_UID_LEN = 12; ///< GD32 芯片 UID 长度(字节) // AES 会话上下文 typedef struct { diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index df92843..990c829 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -16,8 +16,8 @@ #define SPI_MASTER_CS_GPIO (4) // CS 片选引脚 #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz #define SPI_MODE (0) // CPOL=0, CPHA=0 -#define SPI_QUEUE_SIZE (1) -#define SPI_OTA_FRAME_MAX (96) +#define SPI_QUEUE_SIZE (1) ///< SPI 事务队列深度 +#define SPI_OTA_FRAME_MAX (96) ///< SPI OTA 单帧最大字节数 // GD32 SPI OTA 帧类型 #define SPI_OTA_SOF (0xA5) diff --git a/ota_upload_test.py b/ota_upload_test.py index c18bf64..958e6b8 100644 --- a/ota_upload_test.py +++ b/ota_upload_test.py @@ -1,6 +1,14 @@ -import urllib.request, json, time, sys +#!/usr/bin/env python3 +""" +ota_upload_test.py - ESP32 OTA 上传集成测试脚本 -ESP_IP = "192.168.31.127" +用法: python ota_upload_test.py +前提: 已编译 build/ESP32_COMM.bin,ESP32 已连接网络。 +步骤: 上传固件 → 触发更新 → 等待重启并验证版本。 +""" +import urllib.request, json, time, sys + +ESP_IP = "192.168.31.127" # 目标 ESP32 IP 地址(根据实际网络修改) # Step 1: Upload ESP32 firmware bin_path = r"G:\Robot_v2\DCU\ESP32_COMM\build\ESP32_COMM.bin" diff --git a/serial_monitor.py b/serial_monitor.py index f410418..358c234 100644 --- a/serial_monitor.py +++ b/serial_monitor.py @@ -1,6 +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') +#!/usr/bin/env python3 +""" +serial_monitor.py - ESP32 串口日志采集工具 + +用法: python serial_monitor.py +依赖: pip install pyserial +功能: 监听 COM15 串口 (115200 波特率),将输出记录到 serial_log.txt, + 最多运行 5 分钟。 +""" +import serial, sys, time +ser = serial.Serial('COM15', 115200, timeout=1) # COM 口和波特率根据实际修改 +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() diff --git a/tools/app.js b/tools/app.js index a18bdc3..6a83abd 100644 --- a/tools/app.js +++ b/tools/app.js @@ -1,4 +1,16 @@ -// ==================== 全局状态 ==================== +/** + * app.js - ESP32 OTA 中继控制台主脚本(WiFi OTA 通道) + * + * 职责: + * - 固件文件选择 / HEX→BIN 转换 / SHA-256 计算 + * - WiFi OTA 上传流程(ESP32 本机 + GD32 中继) + * - 系统状态轮询(/status + /logs) + * - CAN/SPI 调试工具 + * - UI 状态管理(进度条、阶段流程、Toast、日志) + * + * 依赖:index.html 中定义的 DOM 元素、style.css 样式 + */ +// ==================== 全局状态 ==================== let esp32File = null; let gd32File = null; let esp32FileHandle = null; // FileSystemFileHandle for hot reload @@ -104,6 +116,7 @@ function showVerifyActions(type) { void type; } +/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */ function setActivePhase(phase) { if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; activePhase = phase; @@ -118,6 +131,7 @@ function setActivePhase(phase) { var fill = document.getElementById('progressFill'); if (fill) fill.classList.remove('done'); } +/** @description 标记指定阶段为完成,更新进度条颜色。 */ function markPhaseDone(phase) { if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' }; @@ -129,6 +143,7 @@ function markPhaseDone(phase) { if (fill) fill.classList.add('done'); } } +/** @description 更新进度条百分比和文本显示。 */ function setProgress(pct, text) { var fillEl = document.getElementById('progressFill'); var txtEl = document.getElementById('progressTxt'); @@ -137,6 +152,7 @@ function setProgress(pct, text) { if (txtEl) txtEl.textContent = text; if (pctEl) pctEl.textContent = pct + '%'; } +/** @description 重置进度条和阶段状态到初始值。 */ function resetProgress() { setProgress(0, '等待操作...'); activePhase = null; @@ -150,11 +166,13 @@ function resetProgress() { if (fill) fill.classList.remove('done'); } // ==================== 工具函数 ==================== +/** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */ function formatBytes(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; } +/** @description 格式化 Date 对象为 HH:MM:SS.mmm 时间字符串。 */ function formatTime(date) { var h = String(date.getHours()).padStart(2, '0'); var m = String(date.getMinutes()).padStart(2, '0'); @@ -162,6 +180,7 @@ function formatTime(date) { var ms = String(date.getMilliseconds()).padStart(3, '0'); return h + ':' + m + ':' + s + '.' + ms; } +/** @description 格式化秒数为人类可读运行时间(d/h/m/s)。 */ function formatUptime(sec) { if (!Number.isFinite(sec) || sec < 0) return '--'; const d = Math.floor(sec / 86400); @@ -173,6 +192,7 @@ function formatUptime(sec) { if (m > 0) return m + 'm ' + s + 's'; return s + 's'; } +/** @description 转义 HTML 特殊字符,防止 XSS 注入。 */ function escapeHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&') @@ -190,6 +210,7 @@ function formatHttpErrorDetail(text) { if (detail.length > 180) detail = detail.slice(0, 177) + '...'; return detail; } +/** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */ function showToast(message, type) { type = type || 'success'; const toast = document.getElementById('toast'); @@ -210,6 +231,7 @@ function getBaseUrl() { function makeLogHash(level, tag, message) { return level + '|' + tag + '|' + message; } +/** @description 添加一条日志条目到环形缓冲区和日志面板。 */ function addLog(level, tag, message) { var scroll = document.getElementById('logScroll'); var time = formatTime(new Date()); @@ -666,6 +688,7 @@ function updateSystemStatusUi(data) { setOtaProgressBadge(storageProgress); } } +/** @description 测试与 ESP32 的连接(GET /status)。 */ async function testConnection() { // 如果在降速模式,先恢复正常轮询 if (pollSlowMode) { @@ -1016,6 +1039,7 @@ async function convertHexFileToBin(file) { return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr }; } // ==================== 文件处理 ==================== +/** @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 */ function processFile(file, chip) { var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; var isHexByName = file.name.toLowerCase().endsWith('.hex'); @@ -1083,6 +1107,7 @@ function processFile(file, chip) { } } } +/** @description 清除已选固件文件并重置对应 UI 状态。 */ function clearFile(chip) { var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; if (chip === 'esp32') { esp32File = null; esp32FileHandle = null; window._esp32FileSha256 = null; } @@ -1099,6 +1124,7 @@ function clearFile(chip) { // 但前端只用一个 `isUploading` bool 锁同一通道。这里在每次 startUpload // 入口主动查询 /system/status,若发现另一通道已经进入 OTA 活跃态 // (ESP32 三个状态或 GD32 存储/中继),直接拒绝并提示用户。 +/** @description 启动 ESP32 本机 OTA 上传流程(互斥获取→分块上传→SHA验证→重启)。 */ async function startUpload(chip) { var file = chip === 'esp32' ? esp32File : gd32File; if (!file || isUploading) return; @@ -1317,6 +1343,7 @@ async function startUpload(chip) { } // ==================== ESP32 OTA 完成升级 ==================== // 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮) +/** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */ async function verifyEsp32Firmware() { updateEsp32StagesFromPhase(3); // 校验中 var sha256 = window._esp32FileSha256; @@ -1463,6 +1490,7 @@ async function restartEsp32() { } } // ==================== GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)==================== +/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */ async function triggerGd32Relay() { var sha256 = window._gd32FileSha256; if (!sha256) { @@ -1507,6 +1535,7 @@ async function triggerGd32Relay() { } // ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ==================== +/** @description 触发 GD32 OTA 中继(封装函数,调用实际 API)。 */ async function triggerRelay() { // 转发到统一入口:自动使用最近一次上传的 GD32 文件 SHA await triggerGd32Relay(); @@ -1558,6 +1587,7 @@ async function diagnoseNetwork() { diagResult = result.join(';'); return diagResult; } +/** @description 启动定时轮询(状态 + 日志)。 */ function startPolling() { consecutivePollFailures = 0; pollSlowMode = false; @@ -1606,6 +1636,7 @@ function resumePollingAfterUpload() { pollStatus(); pollLogs(); } +/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */ async function pollStatus() { if (pollingPausedForUpload) return; try { @@ -1684,6 +1715,7 @@ document.addEventListener('visibilitychange', function () { }); // ==================== ESP32 实时日志拉取 (增量) ==================== var lastLogTimestamp = 0; +/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */ async function pollLogs() { if (pollingPausedForUpload) return; try { diff --git a/tools/ble_ota.js b/tools/ble_ota.js index d38a44b..7a67fbe 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -419,6 +419,7 @@ bleLog('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`); } + /** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */ async function connectBle() { if (!navigator.bluetooth) { bleLog('BLE', '当前浏览器不支持 Web Bluetooth', 'E'); @@ -494,6 +495,7 @@ } } + /** @description 断开 BLE 连接并清理资源。 */ async function disconnectBle() { // 用户主动断开:取消任何进行中的重连 / expectedReboot state.expectedReboot = false; @@ -534,6 +536,7 @@ } } + /** @description 处理 BLE Notify 事件(解析 ACK/ERROR/PROGRESS 等响应类型)。 */ function handleNotify(event) { const view = event.target.value; const bytes = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); @@ -1100,6 +1103,7 @@ bleLog('FILE', '文件已清除'); } + /** @description 构建 BLE OTA 数据包(seq + totalSize + payload + cmd + CRC)。 */ function buildPacket(seq, totalSize, payload, cmd) { const packet = new Uint8Array(7 + payload.length); packet[0] = cmd; @@ -1118,6 +1122,7 @@ } + /** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */ async function uploadFirmware() { // 每次刷写前尝试重新从本地读取文件 if (state.selectedFileHandle) { @@ -1454,6 +1459,7 @@ } } + /** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */ async function autoFlash() { // 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择) if (state.selectedFileHandle) { @@ -1507,6 +1513,7 @@ } } + /** @description 中止当前 BLE OTA 任务并清理状态。 */ function abortTask() { state.abortRequested = true; bleLog('OTA', '已请求中止,等待当前分包返回'); diff --git a/tools/index.html b/tools/index.html index 0e3e22f..2fb1fa6 100644 --- a/tools/index.html +++ b/tools/index.html @@ -1,4 +1,9 @@ - + +