Compare commits
4
Commits
59c729d77a
...
7a41eac526
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a41eac526 | ||
|
|
1491e9a3de | ||
|
|
00ce8dbbb5 | ||
|
|
3fff959a2b |
+4
-2
@@ -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 通信模块
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# transport 组件 - SPI 总线驱动、CANFD 桥接、协议层
|
||||
# 提供底层硬件通信能力,被 main 组件依赖。
|
||||
#
|
||||
# PRIV_INCLUDE_DIRS 仅对本组件编译可见,
|
||||
# 不会将 main/ 的子目录路径泄漏给依赖本组件的其他组件,
|
||||
# 从而在编译层面强制模块边界。
|
||||
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"../../main/transport/spi_master.cpp"
|
||||
"../../main/transport/canfd_bridge.cpp"
|
||||
"../../main/transport/protocol.cpp"
|
||||
INCLUDE_DIRS
|
||||
"../../main/transport" # spi_master.h, canfd_bridge.h, protocol.h
|
||||
PRIV_INCLUDE_DIRS
|
||||
"../../main" # monitor/debug_log.h(仅本组件编译时使用)
|
||||
"../../main/include" # app_config.h
|
||||
REQUIRES
|
||||
esp_driver_spi # SPI 主设备驱动
|
||||
freertos # FreeRTOS 内核
|
||||
mbedtls # AES 加密
|
||||
esp_timer # 高精度定时器
|
||||
)
|
||||
@@ -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,
|
||||
|
@@ -35,6 +35,34 @@ CONFIG_BTDM_BLE_MAX_CONN_EFF=1
|
||||
CONFIG_BTDM_CTRL_BLE_MAX_CONN_EFF=1
|
||||
CONFIG_BTDM_BLE_MAX_CONN_EFF_EFF=1
|
||||
|
||||
# BLE 连接事件长度优化(允许单 CE 内传多包,提升 OTA 吞吐)
|
||||
CONFIG_BT_CTRL_CE_LENGTH_TYPE_CE=y
|
||||
|
||||
# NimBLE mbuf 池(单连接 OTA 场景缩减,省 ~7 KB)
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=16
|
||||
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=8
|
||||
|
||||
# NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service,省 ~8-15 KB)
|
||||
CONFIG_BT_NIMBLE_PROX_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_ANS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_CTS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HTP_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IPSS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_TPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_LLS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_SPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HR_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_BAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_DIS_SERVICE=n
|
||||
|
||||
# NimBLE: 传输缓冲区缩减
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
|
||||
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
|
||||
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
|
||||
|
||||
# mbedTLS
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT=y
|
||||
|
||||
+30
-29
@@ -1,41 +1,42 @@
|
||||
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 提取)
|
||||
"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"
|
||||
"transport"
|
||||
"storage" # GD32 Flash 存储抽象层头文件
|
||||
"server"
|
||||
"monitor"
|
||||
"include"
|
||||
"include" # 公共头文件(app_config.h, version.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
|
||||
transport # M5: 传输层组件(SPI/CANFD/协议)
|
||||
app_update # ESP32 OTA API
|
||||
cjson # JSON 解析/生成
|
||||
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)
|
||||
|
||||
+7
-10
@@ -22,23 +22,18 @@
|
||||
#include "transport/protocol.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "app_config.h"
|
||||
#include "ota_mutex.h"
|
||||
#include "ota/esp32_ota_core.h"
|
||||
|
||||
/**
|
||||
* @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。
|
||||
*/
|
||||
// 全局 ESP32 OTA 互斥变量(声明在 ota_mutex.h)
|
||||
SemaphoreHandle_t g_esp32OtaMutex = NULL;
|
||||
volatile bool g_esp32OtaOccupied = false;
|
||||
|
||||
extern "C" void app_main(void)
|
||||
{
|
||||
esp_chip_info_t chip_info;
|
||||
esp_chip_info(&chip_info);
|
||||
|
||||
// 创建 ESP32 OTA 全局互斥 Mutex
|
||||
g_esp32OtaMutex = xSemaphoreCreateMutex();
|
||||
g_esp32OtaOccupied = false;
|
||||
// ★ M1: ESP32 OTA 分区互斥锁内聚到 Esp32OtaCore(替代旧的全局变量)
|
||||
Esp32OtaCore::initMutex();
|
||||
|
||||
// 初始化网络接口 + 事件循环(必须最早)
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
@@ -82,6 +77,8 @@ extern "C" void app_main(void)
|
||||
|
||||
// ★ 全局 OTA 协调器初始化(在 OTA task 创建前,确保 mutex 就绪)
|
||||
OtaCoordinator::instance().init();
|
||||
// ★ L1: 注册 BLE 射频共存监听器(WifiOta 实现 IBleCoexListener)
|
||||
OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance());
|
||||
DEBUG_INFO("MAIN", "OTA 全局协调器已初始化");
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().init();
|
||||
@@ -95,7 +92,7 @@ extern "C" void app_main(void)
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
OtaRelay::taskEntry,
|
||||
"ota_relay",
|
||||
12288,
|
||||
6144,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 2,
|
||||
NULL,
|
||||
@@ -127,7 +124,7 @@ extern "C" void app_main(void)
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
BleOta::taskEntry,
|
||||
"ble_ota",
|
||||
8192,
|
||||
4096,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 3,
|
||||
NULL,
|
||||
|
||||
+41
-41
@@ -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,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* ota_mutex.h - ESP32 OTA 全局互斥(BLE / WiFi 通道共享)
|
||||
*
|
||||
* 同一时刻只允许一个 OTA 源(BLE 或 WiFi)操作 ESP32 OTA 分区。
|
||||
* 变量定义 + Mutex 创建在 main.cpp app_main()。
|
||||
*/
|
||||
#pragma once
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <cstdint>
|
||||
|
||||
extern SemaphoreHandle_t g_esp32OtaMutex;
|
||||
extern volatile bool g_esp32OtaOccupied;
|
||||
|
||||
// 获取全局 OTA 占用标志(带超时),成功返回 true
|
||||
// 内部: Take Mutex → 检查标志 → 若空闲则置 true → Give Mutex
|
||||
/** @brief 获取全局 OTA 占用标志(带超时),成功返回 true。 */
|
||||
static inline bool esp32_ota_mutex_acquire(uint32_t timeout_ms) {
|
||||
if (!g_esp32OtaMutex) return false;
|
||||
if (xSemaphoreTake(g_esp32OtaMutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) return false;
|
||||
if (g_esp32OtaOccupied) {
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
return false;
|
||||
}
|
||||
g_esp32OtaOccupied = true;
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 释放全局 OTA 占用标志
|
||||
// 内部: Take Mutex → 清除标志 → Give Mutex
|
||||
/** @brief 释放全局 OTA 占用标志。 */
|
||||
static inline void esp32_ota_mutex_release(void) {
|
||||
if (!g_esp32OtaMutex) return;
|
||||
xSemaphoreTake(g_esp32OtaMutex, portMAX_DELAY);
|
||||
g_esp32OtaOccupied = false;
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
}
|
||||
@@ -4,11 +4,11 @@
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 3
|
||||
#define FW_VERSION_PATCH 28
|
||||
#define FW_VERSION_STRING "0.3.28"
|
||||
#define FW_VERSION_PATCH 32
|
||||
#define FW_VERSION_STRING "0.3.32"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-06-19 03:24:28"
|
||||
#define BUILD_TIME "2026-06-23 00:04:33"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "7feef70"
|
||||
#define GIT_HASH "1491e9a"
|
||||
|
||||
+70
-50
@@ -60,74 +60,94 @@ int DebugLog::ringCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 读取并清空环形日志缓冲区,返回 JSON 数组字符串。
|
||||
* @param out_len 输出参数,返回 JSON 字符串长度。
|
||||
* @return 指向 JSON 字符串的指针,需调用者 free。
|
||||
* @brief 弹出一条最旧日志并从环形缓冲区移除。
|
||||
* @param out 输出缓冲区。
|
||||
* @param out_size 输出缓冲区大小。
|
||||
* @param out_len 输出日志长度。
|
||||
* @return true 表示成功弹出一条日志,false 表示缓冲区为空或参数无效。
|
||||
*/
|
||||
char *DebugLog::ringDrainJson(int *out_len)
|
||||
bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len)
|
||||
{
|
||||
if (!m_logRingMutex) {
|
||||
if (out_len) {
|
||||
*out_len = 0;
|
||||
return NULL;
|
||||
}
|
||||
if (!m_logRingMutex || !out || out_size == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
|
||||
|
||||
int count = m_logRingCount;
|
||||
if (count == 0) {
|
||||
if (m_logRingCount <= 0) {
|
||||
xSemaphoreGive(m_logRingMutex);
|
||||
*out_len = 0;
|
||||
return NULL;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 估算 JSON 大小:每条 ~280 字节(转义 + JSON 包装)
|
||||
size_t buf_size = (size_t)count * 280 + 16;
|
||||
char *json = (char *)malloc(buf_size);
|
||||
if (!json) {
|
||||
xSemaphoreGive(m_logRingMutex);
|
||||
*out_len = 0;
|
||||
return NULL;
|
||||
int idx = (m_logRingHead - m_logRingCount + LOG_RING_SLOTS) % LOG_RING_SLOTS;
|
||||
int len = m_logRingLens[idx];
|
||||
if (len < 0) {
|
||||
len = 0;
|
||||
}
|
||||
|
||||
int pos = 0;
|
||||
json[pos++] = '[';
|
||||
|
||||
// 从最旧的开始读
|
||||
int start = (count < LOG_RING_SLOTS) ? 0 : m_logRingHead;
|
||||
for (int i = 0; i < count && pos < (int)buf_size - 200; i++) {
|
||||
int idx = (start + i) % LOG_RING_SLOTS;
|
||||
int len = m_logRingLens[idx];
|
||||
if (len <= 0) continue;
|
||||
|
||||
if (i > 0) json[pos++] = ',';
|
||||
|
||||
pos += snprintf(json + pos, buf_size - pos, "\"");
|
||||
for (int j = 0; j < len && pos < (int)buf_size - 10; j++) {
|
||||
char c = m_logRing[idx][j];
|
||||
if (c == '"' || c == '\\') {
|
||||
json[pos++] = '\\';
|
||||
json[pos++] = c;
|
||||
} else if (c == '\n') {
|
||||
json[pos++] = '\\';
|
||||
json[pos++] = 'n';
|
||||
} else if (c >= 32) {
|
||||
json[pos++] = c;
|
||||
}
|
||||
}
|
||||
pos += snprintf(json + pos, buf_size - pos, "\"");
|
||||
int copy_len = len;
|
||||
if ((size_t)copy_len >= out_size) {
|
||||
copy_len = (int)out_size - 1;
|
||||
}
|
||||
|
||||
json[pos++] = ']';
|
||||
json[pos] = '\0';
|
||||
if (copy_len > 0) {
|
||||
memcpy(out, m_logRing[idx], (size_t)copy_len);
|
||||
}
|
||||
out[copy_len] = '\0';
|
||||
if (out_len) {
|
||||
*out_len = copy_len;
|
||||
}
|
||||
|
||||
// 清空缓冲区
|
||||
m_logRingHead = 0;
|
||||
m_logRingCount = 0;
|
||||
m_logRingLens[idx] = 0;
|
||||
m_logRingCount--;
|
||||
|
||||
xSemaphoreGive(m_logRingMutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。
|
||||
* @param out_len 输出 JSON 字符串长度。
|
||||
* @return 动态分配的 JSON 字符串,调用者须 free()。
|
||||
*/
|
||||
char* DebugLog::ringDrainJson(int *out_len)
|
||||
{
|
||||
if (!out_len) return NULL;
|
||||
*out_len = 0;
|
||||
int count = ringCount();
|
||||
int capacity = count * (LOG_RING_SIZE * 2) + 16;
|
||||
if (capacity < 16) capacity = 16;
|
||||
char *buf = (char*)malloc(capacity);
|
||||
if (!buf) return NULL;
|
||||
|
||||
int pos = 0;
|
||||
buf[pos++] = '[';
|
||||
bool first = true;
|
||||
char entry[LOG_RING_SIZE];
|
||||
int entryLen = 0;
|
||||
|
||||
while (ringPopOldest(entry, sizeof(entry), &entryLen) && entryLen > 0) {
|
||||
if (!first) buf[pos++] = ',';
|
||||
first = false;
|
||||
buf[pos++] = '"';
|
||||
for (int i = 0; i < entryLen && pos < capacity - 4; i++) {
|
||||
char c = entry[i];
|
||||
if (c == '"') { buf[pos++] = '\\'; buf[pos++] = '"'; }
|
||||
else if (c == '\\') { buf[pos++] = '\\'; buf[pos++] = '\\'; }
|
||||
else if (c == '\n') { buf[pos++] = '\\'; buf[pos++] = 'n'; }
|
||||
else if (c == '\r') { buf[pos++] = '\\'; buf[pos++] = 'r'; }
|
||||
else if (c == '\t') { buf[pos++] = '\\'; buf[pos++] = 't'; }
|
||||
else if ((unsigned char)c >= 0x20) { buf[pos++] = c; }
|
||||
}
|
||||
buf[pos++] = '"';
|
||||
}
|
||||
buf[pos++] = ']';
|
||||
buf[pos] = '\0';
|
||||
*out_len = pos;
|
||||
return json;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,4 +370,4 @@ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, ..
|
||||
int total = snprintf(log_buf, sizeof(log_buf), "%s %s\n", prefix, msg);
|
||||
|
||||
send((const uint8_t *)log_buf, total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -72,7 +72,14 @@ public:
|
||||
/** @brief 获取当前环形日志缓冲区中的日志条数。 */
|
||||
int ringCount();
|
||||
|
||||
/** @brief 读取并清空环形日志缓冲区,返回 JSON 数组字符串(需调用者 free)。 */
|
||||
/** @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 当前日志级别(运行时可改)。 */
|
||||
@@ -102,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
|
||||
#endif // DEBUG_LOG_H
|
||||
|
||||
@@ -16,10 +16,8 @@
|
||||
#include "esp_err.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "health_monitor.h"
|
||||
#include "debug_log.h"
|
||||
#include "server/wifi_ota.h"
|
||||
|
||||
// ============================================================
|
||||
// 单例
|
||||
@@ -167,7 +165,9 @@ void HealthMonitor::tempMonitorCallback(void *arg)
|
||||
*/
|
||||
void HealthMonitor::taskEntry(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
auto& self = instance();
|
||||
self.m_taskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO("MON", "=== 健康监控任务启动 ===");
|
||||
|
||||
temperature_sensor_config_t temp_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 80);
|
||||
@@ -246,7 +246,7 @@ health_status_t HealthMonitor::checkBeforeOta()
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意");
|
||||
}
|
||||
|
||||
if (!WifiOta::isConnected()) {
|
||||
if (!m_wifiConnected) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi 未连接");
|
||||
return HEALTH_MON_CRITICAL;
|
||||
}
|
||||
@@ -311,6 +311,18 @@ health_status_t HealthMonitor::checkBeforeOtaRelay()
|
||||
return status;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WiFi 连接状态推送(由 WiFi 事件回调调用)
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 由 WiFi 事件回调推送连接状态。
|
||||
* @param connected true=已连接,false=断开。
|
||||
*/
|
||||
void HealthMonitor::setWifiConnected(bool connected)
|
||||
{
|
||||
m_wifiConnected = connected;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WiFi RSSI 更新
|
||||
// ============================================================
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "app_config.h"
|
||||
|
||||
@@ -19,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 系统健康监控类(单例)
|
||||
@@ -70,6 +72,17 @@ public:
|
||||
/** @brief 获取最近一次 WiFi RSSI 及其年龄。 */
|
||||
bool getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms);
|
||||
|
||||
/** @brief 获取健康监控任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
/**
|
||||
* @brief 由 WiFi 事件回调推送连接状态(解耦 HealthMonitor → WifiOta 依赖)。
|
||||
* WiFi 连接/断开时由 wifi_ota 事件处理器主动调用,
|
||||
* HealthMonitor 不再主动查询 WifiOta::isConnected()。
|
||||
* @param connected true=已连接,false=断开。
|
||||
*/
|
||||
void setWifiConnected(bool connected);
|
||||
|
||||
private:
|
||||
/** @brief 加锁读取健康状态。 */
|
||||
health_state_t getState();
|
||||
@@ -88,6 +101,8 @@ private:
|
||||
float m_lastTemperature = 0.0f;
|
||||
int8_t m_lastWifiRssi = -127;
|
||||
int64_t m_lastWifiRssiMs = 0;
|
||||
TaskHandle_t m_taskHandle = NULL;
|
||||
volatile bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送)
|
||||
|
||||
static void tempMonitorCallback(void *arg);
|
||||
};
|
||||
};
|
||||
|
||||
+54
-20
@@ -17,12 +17,46 @@
|
||||
#include "mbedtls/private/sha256.h"
|
||||
|
||||
#include "esp32_ota_core.h"
|
||||
#include "ota_mutex.h"
|
||||
#include "app_config.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
#define TAG "OTA_CORE"
|
||||
|
||||
// ============================================================
|
||||
// 分区互斥锁(M1: 内聚自 ota_mutex.h 全局变量)
|
||||
// ============================================================
|
||||
SemaphoreHandle_t Esp32OtaCore::s_mutex = NULL;
|
||||
volatile bool Esp32OtaCore::s_occupied = false;
|
||||
|
||||
void Esp32OtaCore::initMutex()
|
||||
{
|
||||
if (!s_mutex) {
|
||||
s_mutex = xSemaphoreCreateMutex();
|
||||
s_occupied = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Esp32OtaCore::acquireMutex(uint32_t timeout_ms)
|
||||
{
|
||||
if (!s_mutex) return false;
|
||||
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) return false;
|
||||
if (s_occupied) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return false;
|
||||
}
|
||||
s_occupied = true;
|
||||
xSemaphoreGive(s_mutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Esp32OtaCore::releaseMutex()
|
||||
{
|
||||
if (!s_mutex) return;
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
s_occupied = false;
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 获取 Esp32OtaCore 单例实例。
|
||||
* @return Esp32OtaCore 引用。
|
||||
@@ -56,7 +90,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
{
|
||||
// ★ 全局互斥:必须在清理旧会话之前检查,
|
||||
// 否则 WiFi 的 begin() 会先释放 BLE 持有的锁,再自己获取,导致互斥失效
|
||||
if (!esp32_ota_mutex_acquire(1000)) {
|
||||
if (!acquireMutex(1000)) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
@@ -67,10 +101,10 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
if (!m_ended) {
|
||||
esp_ota_abort(m_handle);
|
||||
}
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
resetState();
|
||||
// 重新获取全局互斥(刚刚释放了)
|
||||
if (!esp32_ota_mutex_acquire(1000)) {
|
||||
if (!acquireMutex(1000)) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 重新获取互斥失败");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
@@ -91,7 +125,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
|
||||
if (m_partition == NULL) {
|
||||
DEBUG_ERR(TAG, "找不到可用的 OTA 分区");
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -102,7 +136,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
DEBUG_ERR(TAG, "固件太大: %u > 分区大小: %u",
|
||||
(unsigned)total_size, (unsigned)m_partition->size);
|
||||
m_partition = nullptr;
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -119,7 +153,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err));
|
||||
m_partition = nullptr;
|
||||
m_handle = 0;
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -248,7 +282,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err));
|
||||
esp_ota_abort(m_handle);
|
||||
m_ended = false;
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
resetState();
|
||||
return sha_err;
|
||||
}
|
||||
@@ -279,7 +313,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
frontend_sha_hex, device_hex);
|
||||
esp_ota_abort(m_handle);
|
||||
m_ended = false;
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
resetState();
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
@@ -300,7 +334,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
// end 失败时仍可 abort(未真正 end)
|
||||
esp_ota_abort(m_handle);
|
||||
m_ended = false;
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
resetState();
|
||||
return err;
|
||||
}
|
||||
@@ -310,12 +344,12 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
err = esp_ota_set_boot_partition(m_partition);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
resetState();
|
||||
return err;
|
||||
}
|
||||
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received);
|
||||
|
||||
// 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理
|
||||
@@ -341,7 +375,7 @@ void Esp32OtaCore::abort()
|
||||
if (!m_ended) {
|
||||
esp_ota_abort(m_handle);
|
||||
}
|
||||
esp32_ota_mutex_release();
|
||||
releaseMutex();
|
||||
resetState();
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
|
||||
}
|
||||
@@ -351,7 +385,7 @@ void Esp32OtaCore::abort()
|
||||
*
|
||||
* 用于新通道(WiFi/BLE)发现锁被残留占用时,主动走完整的状态机退出流程:
|
||||
* 1. 若会话活跃(m_active)→ 走 abort() 完整清理(esp_ota_abort + 释放锁 + 复位状态)
|
||||
* 2. 若会话不活跃但锁被占(g_esp32OtaOccupied)→ 仅释放残留锁
|
||||
* 2. 若会话不活跃但锁被占(s_occupied)→ 仅释放残留锁
|
||||
* 3. 否则无需操作
|
||||
*
|
||||
* @return true 执行了清理(调用方可重试 begin),false 无需清理。
|
||||
@@ -366,16 +400,16 @@ bool Esp32OtaCore::forceCleanup()
|
||||
}
|
||||
|
||||
// 会话不活跃,但锁可能被残留占用
|
||||
if (!g_esp32OtaMutex) return false;
|
||||
if (!s_mutex) return false;
|
||||
|
||||
// 在互斥保护下检查 g_esp32OtaOccupied
|
||||
if (xSemaphoreTake(g_esp32OtaMutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false;
|
||||
// 在互斥保护下检查 s_occupied
|
||||
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false;
|
||||
|
||||
bool was_occupied = g_esp32OtaOccupied;
|
||||
bool was_occupied = s_occupied;
|
||||
if (was_occupied) {
|
||||
g_esp32OtaOccupied = false;
|
||||
s_occupied = false;
|
||||
}
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
xSemaphoreGive(s_mutex);
|
||||
|
||||
if (was_occupied) {
|
||||
DEBUG_WARN(TAG, "强制清理: 释放残留 OTA 锁(无活跃会话)");
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
#include "esp_err.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_partition.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
class Esp32OtaCore {
|
||||
public:
|
||||
@@ -99,9 +101,22 @@ public:
|
||||
bool isActive() const { return m_active; }
|
||||
/** @brief 获取已接收的字节数。 */
|
||||
size_t getReceived() const { return m_received; }
|
||||
|
||||
// ── 分区互斥锁管理(M1: 从 ota_mutex.h 全局变量内聚到此类)──
|
||||
/**
|
||||
* @brief 初始化 ESP32 OTA 分区互斥锁(在 app_main 中调用)。
|
||||
* 替代旧的全局 g_esp32OtaMutex / g_esp32OtaOccupied。
|
||||
*/
|
||||
static void initMutex();
|
||||
private:
|
||||
Esp32OtaCore() = default;
|
||||
|
||||
// ── 分区互斥锁(M1: 内聚自 ota_mutex.h)──
|
||||
static SemaphoreHandle_t s_mutex; ///< ESP32 OTA 分区互斥锁
|
||||
static volatile bool s_occupied; ///< 分区占用标志
|
||||
static bool acquireMutex(uint32_t timeout_ms);
|
||||
static void releaseMutex();
|
||||
|
||||
// 从 OTA 分区读回计算 SHA-256(WiFi 风格),结果写入 m_sha256,置 m_shaComputed=true
|
||||
/**
|
||||
* @brief 从 OTA 分区读回计算 SHA-256。
|
||||
|
||||
+167
-14
@@ -7,10 +7,13 @@
|
||||
#include "ota_coordinator.h"
|
||||
#include "esp32_ota_core.h"
|
||||
#include "relay/ota_relay.h"
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include <inttypes.h>
|
||||
|
||||
#define TAG "OTA_COORD"
|
||||
|
||||
@@ -26,7 +29,7 @@ OtaCoordinator& OtaCoordinator::instance()
|
||||
// ============================================================
|
||||
// 线程安全辅助
|
||||
// ============================================================
|
||||
bool OtaCoordinator::lock()
|
||||
bool OtaCoordinator::lock() const
|
||||
{
|
||||
// ★ 修复: 返回 take 结果,由调用方传回 unlock()。
|
||||
// 旧实现用成员 bool m_locked 记录 take 结果,多任务并发 lock() 时
|
||||
@@ -41,7 +44,7 @@ bool OtaCoordinator::lock()
|
||||
return ok;
|
||||
}
|
||||
|
||||
void OtaCoordinator::unlock(bool handle)
|
||||
void OtaCoordinator::unlock(bool handle) const
|
||||
{
|
||||
// ★ 修复: 仅在对应的 lock() 成功 take 时才 give。
|
||||
if (handle && m_mutex) {
|
||||
@@ -102,10 +105,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 +146,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 +173,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 +250,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 固件未完整存储,拒绝触发中继");
|
||||
@@ -218,7 +268,8 @@ esp_err_t OtaCoordinator::triggerGd32Relay()
|
||||
}
|
||||
|
||||
// 向 OtaRelay task 发送启动消息
|
||||
if (h && !OtaRelay::dispatchQueue) {
|
||||
QueueHandle_t dq = OtaRelay::getDispatchQueue();
|
||||
if (h && !dq) {
|
||||
DEBUG_ERR(TAG, "OtaRelay dispatchQueue 未就绪");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
@@ -234,7 +285,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay()
|
||||
DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING");
|
||||
|
||||
uint32_t cmd = 1;
|
||||
BaseType_t ret = xQueueSend(OtaRelay::dispatchQueue, &cmd, pdMS_TO_TICKS(200));
|
||||
BaseType_t ret = xQueueSend(dq, &cmd, pdMS_TO_TICKS(200));
|
||||
if (ret != pdTRUE) {
|
||||
DEBUG_ERR(TAG, "向 OtaRelay 发送启动消息失败");
|
||||
m_sysState = OTA_SYS_GD32_STORED;
|
||||
@@ -247,6 +298,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay()
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:GD32 中继已开始,更新系统状态为 GD32_RELAYING。 */
|
||||
void OtaCoordinator::notifyRelayStarted()
|
||||
{
|
||||
bool h = lock();
|
||||
@@ -258,6 +310,7 @@ void OtaCoordinator::notifyRelayStarted()
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:GD32 中继已完成,根据结果转为 COMPLETE 或 ERROR。 */
|
||||
void OtaCoordinator::notifyRelayComplete(bool success)
|
||||
{
|
||||
bool h = lock();
|
||||
@@ -276,6 +329,43 @@ 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);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BLE 射频共存管理(L1: 消除 ble_ota → wifi_ota 直接依赖)
|
||||
// ============================================================
|
||||
void OtaCoordinator::setBleCoexListener(IBleCoexListener *listener)
|
||||
{
|
||||
m_bleCoexListener = listener;
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyBleCoexStart()
|
||||
{
|
||||
if (m_bleCoexListener) {
|
||||
m_bleCoexListener->onBleOtaStart();
|
||||
}
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyBleCoexEnd()
|
||||
{
|
||||
if (m_bleCoexListener) {
|
||||
m_bleCoexListener->onBleOtaEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ESP32 OTA 阶段
|
||||
// ============================================================
|
||||
@@ -342,6 +432,7 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size)
|
||||
return err;
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */
|
||||
void OtaCoordinator::notifyEsp32ReceiveComplete()
|
||||
{
|
||||
bool h = lock();
|
||||
@@ -352,6 +443,7 @@ void OtaCoordinator::notifyEsp32ReceiveComplete()
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:ESP32 OTA 收尾完成(SHA验证+标记启动),状态转为 REBOOTING。 */
|
||||
void OtaCoordinator::notifyEsp32FinalizeComplete()
|
||||
{
|
||||
bool h = lock();
|
||||
@@ -362,6 +454,7 @@ void OtaCoordinator::notifyEsp32FinalizeComplete()
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:ESP32 即将重启。 */
|
||||
void OtaCoordinator::notifyEsp32Rebooting()
|
||||
{
|
||||
bool h = lock();
|
||||
@@ -399,6 +492,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 +514,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 ||
|
||||
@@ -438,13 +536,14 @@ void OtaCoordinator::abortCurrent()
|
||||
ota_system_state_t OtaCoordinator::getSystemState() const
|
||||
{
|
||||
// 对于纯读取,短暂加锁保证一致性
|
||||
OtaCoordinator* self = const_cast<OtaCoordinator*>(this);
|
||||
bool h = self->lock();
|
||||
// ★ M1/L3: m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
|
||||
bool h = lock();
|
||||
ota_system_state_t s = m_sysState;
|
||||
self->unlock(h);
|
||||
unlock(h);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** @brief 判断系统是否处于空闲状态(IDLE/STORED/COMPLETE)。 */
|
||||
bool OtaCoordinator::isIdle() const
|
||||
{
|
||||
return (getSystemState() == OTA_SYS_IDLE ||
|
||||
@@ -452,6 +551,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 +559,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 +568,7 @@ bool OtaCoordinator::isEsp32Busy() const
|
||||
s == OTA_SYS_ESP32_REBOOTING);
|
||||
}
|
||||
|
||||
/** @brief 获取系统状态的字符串名称(用于日志和前端显示)。 */
|
||||
const char* OtaCoordinator::getSystemStateString() const
|
||||
{
|
||||
switch (getSystemState()) {
|
||||
@@ -483,20 +585,71 @@ 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<OtaCoordinator*>(this);
|
||||
bool h = self->lock();
|
||||
// ★ M1/L3: m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
|
||||
bool h = lock();
|
||||
bool done = m_gd32StorageDone;
|
||||
self->unlock(h);
|
||||
unlock(h);
|
||||
return done;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OTA 状态快照(PR1: 收口状态读取)
|
||||
// ============================================================
|
||||
OtaSnapshot OtaCoordinator::getSnapshot() const
|
||||
{
|
||||
OtaSnapshot snap{};
|
||||
|
||||
// ── coordinator 自身状态(一次 mutex)──
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
snap.sys_state = m_sysState;
|
||||
snap.storage_done = m_gd32StorageDone;
|
||||
unlock(h);
|
||||
}
|
||||
snap.sys_state_str = getSystemStateString();
|
||||
|
||||
// ── GD32 NVS 元数据 ──
|
||||
Gd32FlashStore::instance().metaLoad(&snap.meta);
|
||||
|
||||
// ── relay 进度(一次 relay mutex)──
|
||||
snap.relay = OtaRelay::getSnapshot();
|
||||
|
||||
return snap;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GD32 APP 探测(PR2: 从 /system/status 移出)
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::probeGd32AppOnce(gd32_version_t *out_version, uint32_t *out_uptime)
|
||||
{
|
||||
uint32_t uptime = 0;
|
||||
gd32_version_t ver = {};
|
||||
|
||||
esp_err_t err = SpiMaster::instance().appPing(500, &uptime, &ver);
|
||||
if (err == ESP_OK) {
|
||||
OtaRelay::setGd32AppAlive(true);
|
||||
OtaRelay::setGd32Version(ver);
|
||||
if (out_version) *out_version = ver;
|
||||
if (out_uptime) *out_uptime = uptime;
|
||||
DEBUG_INFO(TAG, "GD32 APP 探测成功: alive, uptime=%" PRIu32 "s", uptime);
|
||||
} else {
|
||||
OtaRelay::setGd32AppAlive(false);
|
||||
if (out_version) *out_version = ver;
|
||||
if (out_uptime) *out_uptime = 0;
|
||||
DEBUG_WARN(TAG, "GD32 APP 探测失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 完全复位
|
||||
// ============================================================
|
||||
|
||||
+164
-5
@@ -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 接口定义 + RelaySnapshot(H4 解耦)
|
||||
|
||||
// ============================================================
|
||||
// 系统级 OTA 状态枚举
|
||||
@@ -35,6 +37,41 @@ typedef enum {
|
||||
OTA_SYS_ERROR // 错误状态
|
||||
} ota_system_state_t;
|
||||
|
||||
// ============================================================
|
||||
// OTA 系统全量只读快照(PR1: 收口状态读取)
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief OTA 系统全量只读快照。
|
||||
* 由 OtaCoordinator::getSnapshot() 一次性填充,
|
||||
* 供 /system/status 等只读接口消费,避免散读各模块状态。
|
||||
*/
|
||||
struct OtaSnapshot {
|
||||
ota_system_state_t sys_state = OTA_SYS_IDLE; ///< 系统级 OTA 状态
|
||||
const char *sys_state_str = "IDLE"; ///< 状态字符串
|
||||
bool storage_done = false; ///< GD32 固件存储完成标志
|
||||
ota_meta_t meta = {}; ///< GD32 NVS 元数据
|
||||
RelaySnapshot relay; ///< 中继进度快照
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// IBleCoexListener - BLE 射频共存通知接口(解决 L1 横向耦合)
|
||||
// ============================================================
|
||||
// WifiOta 实现此接口并注册到 OtaCoordinator,
|
||||
// BLE OTA 开始/结束时通过协调器通知 WiFi 层,
|
||||
// 消除 ble_ota → wifi_ota 的直接依赖。
|
||||
/**
|
||||
* @brief BLE 射频共存监听器接口。
|
||||
* BLE OTA 开始/结束时,协调器通过此接口通知 WiFi 层暂停/恢复射频活动。
|
||||
*/
|
||||
class IBleCoexListener {
|
||||
public:
|
||||
virtual ~IBleCoexListener() = default;
|
||||
/** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询等射频活动。 */
|
||||
virtual void onBleOtaStart() = 0;
|
||||
/** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */
|
||||
virtual void onBleOtaEnd() = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA 收尾结果(由 finalizeForReboot 返回)。
|
||||
* err 为 ESP_OK 时 device_sha_hex 有效;SHA 不匹配时也填充 device_sha_hex 供前端核对。
|
||||
@@ -51,10 +88,11 @@ typedef struct {
|
||||
* 传输层(WiFi/BLE)通过本协调器发起所有 OTA 操作,
|
||||
* 不再直接调用 OtaRelay / Esp32OtaCore 的底层接口。
|
||||
*/
|
||||
class OtaCoordinator {
|
||||
class OtaCoordinator : public IRelayListener {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 OtaCoordinator 单例实例。
|
||||
* @return OtaCoordinator 单例引用。
|
||||
*/
|
||||
static OtaCoordinator& instance();
|
||||
|
||||
@@ -80,6 +118,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 中继转发。
|
||||
@@ -159,39 +258,64 @@ public:
|
||||
void abortCurrent();
|
||||
|
||||
// ── 状态查询 ──
|
||||
/**
|
||||
* @brief 一次性读取系统 OTA 全量状态快照(PR1: 收口状态读取)。
|
||||
* coordinator mutex + relay mutex 各取一次,
|
||||
* 供 /system/status 等只读接口消费。
|
||||
* @return 包含 sys_state、meta、relay 进度的完整快照。
|
||||
*/
|
||||
OtaSnapshot getSnapshot() const;
|
||||
|
||||
/**
|
||||
* @brief 单次 SPI 探测 GD32 APP 是否存活(PR2: 从 status 移出)。
|
||||
* 成功时更新 OtaRelay 缓存的 alive/version,
|
||||
* 失败时清除 alive 标志。
|
||||
* @param out_version 输出版本信息,可为 nullptr。
|
||||
* @param out_uptime 输出 GD32 运行时间(秒),可为 nullptr。
|
||||
* @return ESP_OK 存活,其他失败。
|
||||
*/
|
||||
esp_err_t probeGd32AppOnce(gd32_version_t *out_version, uint32_t *out_uptime);
|
||||
|
||||
/**
|
||||
* @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 +329,42 @@ 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;
|
||||
|
||||
// ── BLE 射频共存管理(L1: 替代 ble_ota → wifi_ota 直接调用)──
|
||||
/**
|
||||
* @brief 注册 BLE 共存监听器(由 main 在初始化时调用)。
|
||||
* @param listener WifiOta 实现的监听器指针,nullptr 取消注册。
|
||||
*/
|
||||
void setBleCoexListener(IBleCoexListener *listener);
|
||||
|
||||
/**
|
||||
* @brief BLE OTA 开始时调用,通知 WiFi 层暂停 RSSI 轮询。
|
||||
*/
|
||||
void notifyBleCoexStart();
|
||||
|
||||
/**
|
||||
* @brief BLE OTA 结束时调用,通知 WiFi 层恢复射频活动。
|
||||
*/
|
||||
void notifyBleCoexEnd();
|
||||
|
||||
private:
|
||||
OtaCoordinator() = default;
|
||||
~OtaCoordinator() = default;
|
||||
@@ -220,8 +376,8 @@ private:
|
||||
// 旧实现用共享 bool m_locked 记录 take 结果,多任务并发 lock() 时
|
||||
// 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give,
|
||||
// mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。
|
||||
bool lock();
|
||||
void unlock(bool handle);
|
||||
bool lock() const;
|
||||
void unlock(bool handle) const;
|
||||
|
||||
// ★ 卡死自愈检查:若忙状态停留超过 watchdog 超时,强制转为 ERROR。
|
||||
// 用于在中继 task 因任何原因(崩溃/SPI 死锁/看门狗复位残留)未能回调
|
||||
@@ -238,8 +394,11 @@ private:
|
||||
// GD32 存储跟踪
|
||||
bool m_gd32StorageDone = false;
|
||||
|
||||
// 互斥锁
|
||||
SemaphoreHandle_t m_mutex = nullptr;
|
||||
// 互斥锁(M1/L3: mutable 允许在 const 方法中加锁,消除 const_cast)
|
||||
mutable SemaphoreHandle_t m_mutex = nullptr;
|
||||
|
||||
// BLE 共存监听器(L1: 由 WifiOta 实现并注册)
|
||||
IBleCoexListener *m_bleCoexListener = nullptr;
|
||||
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100);
|
||||
|
||||
// ★ 忙状态 watchdog 超时。GD32 中继理论最大耗时 ≈ 60s(全局超时) + 15s(APP存活轮询) + 1s(bootloader) ≈ 76s,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* ota_snapshot.h - OTA 状态只读快照(便捷聚合头文件)
|
||||
*
|
||||
* 本文件本身不包含结构体定义,仅聚合两个快照来源:
|
||||
* - RelaySnapshot → 定义在 relay/ota_relay.h(一次锁保护读取 relay 进度)
|
||||
* - OtaSnapshot → 定义在 ota/ota_coordinator.h(coordinator + meta + relay 快照)
|
||||
*
|
||||
* HTTP handler 等消费方只需 #include "ota/ota_snapshot.h" 即可使用两种快照。
|
||||
*/
|
||||
#ifndef OTA_SNAPSHOT_H
|
||||
#define OTA_SNAPSHOT_H
|
||||
|
||||
#include "relay/ota_relay.h" // RelaySnapshot
|
||||
#include "ota/ota_coordinator.h" // OtaSnapshot
|
||||
|
||||
#endif // OTA_SNAPSHOT_H
|
||||
+176
-309
@@ -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 接口回调通知协调器,依赖方向变为单向。
|
||||
|
||||
// ============================================================
|
||||
// 静态成员定义
|
||||
@@ -41,6 +38,30 @@ gd32_version_t OtaRelay::gd32Version = {};
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 队列访问器(M3: 封装 public static 成员)──
|
||||
QueueHandle_t OtaRelay::getDispatchQueue()
|
||||
{
|
||||
return dispatchQueue;
|
||||
}
|
||||
|
||||
QueueHandle_t OtaRelay::getResultQueue()
|
||||
{
|
||||
return resultQueue;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态表:将每个 relay_state_t 映射到对应的 handler
|
||||
@@ -102,6 +123,18 @@ void OtaRelay::getGd32Version(gd32_version_t *out)
|
||||
*out = gd32Version;
|
||||
}
|
||||
|
||||
RelaySnapshot OtaRelay::getSnapshot()
|
||||
{
|
||||
StateLockGuard lock;
|
||||
RelaySnapshot s;
|
||||
s.state = relayState;
|
||||
s.seq = relaySeq;
|
||||
s.total_packets = relayTotalPackets;
|
||||
s.gd32_app_alive = gd32AppAlive;
|
||||
s.version = gd32Version;
|
||||
return s;
|
||||
}
|
||||
|
||||
void OtaRelay::setState(relay_state_t s)
|
||||
{
|
||||
StateLockGuard lock;
|
||||
@@ -134,6 +167,11 @@ void OtaRelay::setGd32Version(const gd32_version_t &ver)
|
||||
gd32Version = ver;
|
||||
}
|
||||
|
||||
TaskHandle_t OtaRelay::getTaskHandle()
|
||||
{
|
||||
return taskHandle;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 事件分发核心:processEvent()
|
||||
// ============================================================
|
||||
@@ -267,7 +305,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;
|
||||
@@ -403,7 +441,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;
|
||||
}
|
||||
|
||||
@@ -411,7 +449,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();
|
||||
@@ -486,6 +524,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;
|
||||
@@ -495,6 +540,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;
|
||||
@@ -505,6 +557,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};
|
||||
@@ -516,6 +575,17 @@ void OtaRelay::logRespPayload(const char *stage, uint8_t resp_type, const uint8_
|
||||
n > 0 ? hex : "<empty>", 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)
|
||||
@@ -533,6 +603,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;
|
||||
@@ -542,6 +619,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;
|
||||
@@ -552,18 +637,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;
|
||||
@@ -571,6 +661,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,
|
||||
@@ -603,6 +706,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)
|
||||
{
|
||||
@@ -635,6 +746,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)
|
||||
@@ -642,6 +760,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;
|
||||
@@ -652,301 +776,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 窗口");
|
||||
@@ -960,6 +793,7 @@ esp_err_t OtaRelay::requestGd32AppEnterBootloader()
|
||||
return err;
|
||||
}
|
||||
|
||||
/** @brief 协议子步骤:发送握手请求(0x00 0xAC)并等待 GD32 回显 ACK。 */
|
||||
ota_result_t OtaRelay::doHandshakeReq()
|
||||
{
|
||||
DEBUG_INFO("OTA", "--- 握手请求 ---");
|
||||
@@ -1010,6 +844,7 @@ ota_result_t OtaRelay::doHandshakeReq()
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
/** @brief 协议子步骤:执行挑战认证(发送 nonce、验证响应、派生会话密钥、发送确认)。 */
|
||||
ota_result_t OtaRelay::doChallengeAndAuth()
|
||||
{
|
||||
uint8_t resp_type = 0;
|
||||
@@ -1080,6 +915,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;
|
||||
@@ -1110,12 +946,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;
|
||||
}
|
||||
@@ -1148,7 +989,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;
|
||||
@@ -1291,6 +1132,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");
|
||||
@@ -1359,7 +1201,7 @@ ota_result_t OtaRelay::doFinishAndVerify()
|
||||
|
||||
DEBUG_INFO("OTA", "=== OTA 完成,等待 GD32 APP 启动 ===");
|
||||
|
||||
OtaRelay::metaClear();
|
||||
Gd32FlashStore::instance().metaClear();
|
||||
|
||||
// ★ APP 存活验证
|
||||
gd32AppAlive = false;
|
||||
@@ -1397,6 +1239,7 @@ ota_result_t OtaRelay::doFinishAndVerify()
|
||||
void OtaRelay::taskEntry(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
taskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO("OTA", "OTA 中继任务启动");
|
||||
|
||||
initMutex();
|
||||
@@ -1410,6 +1253,31 @@ void OtaRelay::taskEntry(void *pvParameters)
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 启动时初始 GD32 探测:读取 GD32 APP 版本号与存活状态
|
||||
// GD32 APP 启动需要一定时间,给其 3 秒准备窗口后发起探测
|
||||
{
|
||||
DEBUG_INFO("OTA", "启动初始 GD32 探测...");
|
||||
vTaskDelay(pdMS_TO_TICKS(3000)); // 等待 GD32 APP 就绪
|
||||
uint32_t uptime = 0;
|
||||
gd32_version_t ping_ver = {};
|
||||
esp_err_t ping_err = SpiMaster::instance().appPing(2000, &uptime, &ping_ver);
|
||||
if (ping_err == ESP_OK) {
|
||||
StateLockGuard lock;
|
||||
gd32AppAlive = true;
|
||||
gd32Version = ping_ver;
|
||||
DEBUG_INFO("OTA", "启动 GD32 探测成功: uptime=%" PRIu32 "s, "
|
||||
"APP=v%d.%d.%d.%d, BOOT=v%d.%d.%d.%d",
|
||||
uptime,
|
||||
ping_ver.app_major, ping_ver.app_minor,
|
||||
ping_ver.app_patch, ping_ver.app_build,
|
||||
ping_ver.boot_major, ping_ver.boot_minor,
|
||||
ping_ver.boot_patch, ping_ver.boot_build);
|
||||
} else {
|
||||
DEBUG_WARN("OTA", "启动 GD32 探测失败: %s (GD32 可能未就绪)",
|
||||
esp_err_to_name(ping_err));
|
||||
}
|
||||
}
|
||||
|
||||
while (1) {
|
||||
uint32_t msg = 0;
|
||||
BaseType_t ret = xQueueReceive(dispatchQueue, &msg, portMAX_DELAY);
|
||||
@@ -1418,8 +1286,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;
|
||||
@@ -1434,8 +1302,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);
|
||||
}
|
||||
}
|
||||
|
||||
+116
-71
@@ -19,19 +19,12 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#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 个"微状态"合并为阶段)
|
||||
// ============================================================
|
||||
@@ -57,26 +50,65 @@ 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;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 中继进度快照(一次锁保护读取,消除逐 getter 不一致)
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief GD32 中继进度只读快照。
|
||||
* 由 OtaRelay::getSnapshot() 在单次 StateLockGuard 下填充,
|
||||
* 供 OtaCoordinator::getSnapshot() 组装完整系统快照。
|
||||
*/
|
||||
struct RelaySnapshot {
|
||||
relay_state_t state = RELAY_IDLE; ///< 当前中继状态机状态
|
||||
uint32_t seq = 0; ///< 当前中继包序号
|
||||
uint32_t total_packets = 0; ///< 本次中继总包数
|
||||
bool gd32_app_alive = false; ///< GD32 APP 存活标志
|
||||
gd32_version_t version = {}; ///< GD32 固件版本
|
||||
};
|
||||
|
||||
class OtaRelay {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取OtaRelay单例引用.
|
||||
* @return OtaRelay 单例引用。
|
||||
*/
|
||||
static OtaRelay& instance();
|
||||
|
||||
/**
|
||||
* @brief OTA中继FreeRTOS任务入口.
|
||||
* @param pvParameters FreeRTOS 任务参数(未使用)。
|
||||
*/
|
||||
static void taskEntry(void *pvParameters);
|
||||
|
||||
@@ -99,112 +131,114 @@ 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 一次性读取所有中继状态字段(单次锁保护)。
|
||||
* 消除逐 getter 调用之间 relay task 更新状态导致的不一致。
|
||||
* @return 中继进度快照。
|
||||
*/
|
||||
static RelaySnapshot getSnapshot();
|
||||
/**
|
||||
* @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();
|
||||
|
||||
/**
|
||||
* @brief 重置状态机到 IDLE.
|
||||
*/
|
||||
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);
|
||||
static IRelayListener* getRelayListener();
|
||||
|
||||
// ── 队列访问器(解决 M3: 封装 public static 成员)──
|
||||
/**
|
||||
* @brief 从Flash加载元数据.
|
||||
* @brief 获取事件分发队列句柄。
|
||||
* 外部模块(OtaCoordinator / BLE)通过 getter 访问,不再直接引用静态成员。
|
||||
* @return dispatchQueue 句柄,未初始化时为 NULL。
|
||||
*/
|
||||
static esp_err_t metaLoad(ota_meta_t *meta);
|
||||
static QueueHandle_t getDispatchQueue();
|
||||
|
||||
/**
|
||||
* @brief 保存元数据到Flash.
|
||||
* @brief 获取结果回传队列句柄。
|
||||
* @return resultQueue 句柄,未初始化时为 NULL。
|
||||
*/
|
||||
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 QueueHandle_t getResultQueue();
|
||||
|
||||
// ── 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 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 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) {
|
||||
@@ -221,16 +255,19 @@ 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;
|
||||
|
||||
private:
|
||||
// ── 队列(M3: 由 getter 访问,外部不再直接引用)──
|
||||
static QueueHandle_t dispatchQueue; ///< 事件分发队列
|
||||
static QueueHandle_t resultQueue; ///< 结果回传队列
|
||||
|
||||
OtaRelay() = default;
|
||||
~OtaRelay() = default;
|
||||
OtaRelay(const OtaRelay&) = delete;
|
||||
@@ -265,26 +302,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();
|
||||
|
||||
|
||||
+50
-52
@@ -38,12 +38,11 @@
|
||||
// SHA-256 计算已抽离到 Esp32OtaCore,本文件不再直接引用 mbedtls
|
||||
|
||||
#include "app_config.h"
|
||||
#include "ota_mutex.h"
|
||||
#include "ble_ota.h"
|
||||
#include "wifi_ota.h"
|
||||
#include "esp32_ota_core.h"
|
||||
#include "ota_coordinator.h"
|
||||
#include "ota_relay.h"
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
@@ -233,7 +232,8 @@ static void ble_ota_boost_wifi_ps()
|
||||
{
|
||||
if (s_wifi_ps_boosted) return;
|
||||
s_wifi_ps_boosted = true;
|
||||
WifiOta::instance().setBleOtaActive(true); // ★ 暂停 WiFi RSSI 轮询
|
||||
// ★ L1: 通过协调器通知 WiFi 层(替代直接调用 WifiOta)
|
||||
OtaCoordinator::instance().notifyBleCoexStart();
|
||||
DEBUG_INFO(TAG, "BLE OTA 进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)");
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@ static void ble_ota_restore_wifi_ps()
|
||||
{
|
||||
if (!s_wifi_ps_boosted) return;
|
||||
s_wifi_ps_boosted = false;
|
||||
WifiOta::instance().setBleOtaActive(false); // ★ 恢复 WiFi RSSI 轮询
|
||||
// ★ L1: 通过协调器通知 WiFi 层(替代直接调用 WifiOta)
|
||||
OtaCoordinator::instance().notifyBleCoexEnd();
|
||||
DEBUG_INFO(TAG, "BLE OTA 结束:恢复 WiFi RSSI 轮询");
|
||||
}
|
||||
|
||||
@@ -570,12 +571,10 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
*/
|
||||
static void ble_esp32_ota_abort()
|
||||
{
|
||||
// ★ 通知协调器释放 ESP32 通道占用(m_sysState → IDLE),
|
||||
// 否则后续 begin() 会因协调器仍记为 ESP32_RECEIVING 而假拒绝。
|
||||
ota_system_state_t cur = OtaCoordinator::instance().getSystemState();
|
||||
if (cur == OTA_SYS_ESP32_RECEIVING || cur == OTA_SYS_ESP32_FINALIZING) {
|
||||
OtaCoordinator::instance().abortCurrent();
|
||||
}
|
||||
// ★ 无条件通知协调器释放通道占用(m_sysState → IDLE)。
|
||||
// abortCurrent() 对 IDLE 状态是空操作,对其他状态均复位到 IDLE,
|
||||
// 确保后续 begin() 不会被残留的 ESP32_RECEIVING / ERROR 等状态假拒绝。
|
||||
OtaCoordinator::instance().abortCurrent();
|
||||
Esp32OtaCore::instance().abort();
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
// ★ 释放 reorder buffer
|
||||
@@ -633,11 +632,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 +648,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 +661,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;
|
||||
@@ -667,8 +670,9 @@ static void ble_gd32_relay_wait_task(void *pvParameters)
|
||||
|
||||
uint32_t result_msg = (uint32_t)OTA_RESULT_ERR_TIMEOUT;
|
||||
bool got_result = false;
|
||||
if (OtaRelay::resultQueue) {
|
||||
got_result = (xQueueReceive(OtaRelay::resultQueue, &result_msg, pdMS_TO_TICKS(120000)) == pdTRUE);
|
||||
QueueHandle_t rq = OtaRelay::getResultQueue();
|
||||
if (rq) {
|
||||
got_result = (xQueueReceive(rq, &result_msg, pdMS_TO_TICKS(120000)) == pdTRUE);
|
||||
}
|
||||
|
||||
if (!got_result) {
|
||||
@@ -747,7 +751,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 +762,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);
|
||||
@@ -778,9 +782,9 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
|
||||
DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继",
|
||||
meta.stored_size, meta.total_size);
|
||||
if (OtaRelay::resultQueue) {
|
||||
if (OtaRelay::getResultQueue()) {
|
||||
uint32_t stale = 0;
|
||||
while (xQueueReceive(OtaRelay::resultQueue, &stale, 0) == pdTRUE) {
|
||||
while (xQueueReceive(OtaRelay::getResultQueue(), &stale, 0) == pdTRUE) {
|
||||
DEBUG_WARN(TAG, "清理旧 GD32 中继结果: %" PRIu32, stale);
|
||||
}
|
||||
}
|
||||
@@ -1015,13 +1019,28 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len)
|
||||
DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size);
|
||||
return;
|
||||
}
|
||||
// ★ 幂等保护:若已有活跃 ESP32 OTA 会话,说明这是客户端因 ACK 超时
|
||||
// 重传的 seq=0(同一会话续传)。若再次走 begin,会被协调器以
|
||||
// "OTA 已 ESP32_RECEIVING" 拒绝 → 误报"被其他通道占用"并中止整条
|
||||
// OTA 流程。此处跳过 begin 复用现有会话,后续 reorder buffer 会
|
||||
// 按 seq 去重(seq=0 < expected 时作为重复包丢弃)。
|
||||
// ★ 会话判定:若已有活跃 ESP32 OTA 会话,根据已接收字节数区分两种情况:
|
||||
// ① m_received == 0:seq=0 尚未写入 flash,说明是客户端 ACK 超时后
|
||||
// 重传的 seq=0(同一会话续传),跳过 begin。
|
||||
// ② m_received > 0:seq=0 已写入且有后续数据,说明这是新的上传会话
|
||||
// (前端 abort 后 BLE 断开未完成就发起了新传输),需 abort 旧会话
|
||||
// 再 begin,否则 reorder buffer 以旧 expected 去重导致全部丢弃。
|
||||
if (Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 会话已激活,seq=0 视为重传,跳过 begin");
|
||||
if (Esp32OtaCore::instance().getReceived() > 0) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 新会话检测: received=%u > 0, "
|
||||
"中止旧会话并重新开始",
|
||||
(unsigned)Esp32OtaCore::instance().getReceived());
|
||||
ble_esp32_ota_abort();
|
||||
esp_err_t err = ble_esp32_ota_begin(total_size);
|
||||
if (err != ESP_OK) {
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 会话已激活,seq=0 视为重传,跳过 begin");
|
||||
}
|
||||
} else {
|
||||
// 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成
|
||||
esp_err_t err = ble_esp32_ota_begin(total_size);
|
||||
@@ -1111,7 +1130,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 +1143,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 +1170,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 +1178,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 +1193,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));
|
||||
}
|
||||
@@ -1205,6 +1224,7 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len)
|
||||
static void ble_ota_data_worker_task(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
BleOta::instance().m_workerTaskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO(TAG, "OTA 数据工作线程已启动");
|
||||
|
||||
while (1) {
|
||||
@@ -1387,7 +1407,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;
|
||||
@@ -1553,7 +1572,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;
|
||||
}
|
||||
|
||||
@@ -1677,30 +1695,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 任务入口
|
||||
//
|
||||
@@ -1715,6 +1712,7 @@ void BleOta::signalRelay()
|
||||
void BleOta::taskEntry(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
BleOta::instance().m_taskHandle = xTaskGetCurrentTaskHandle();
|
||||
DEBUG_INFO(TAG, "=== BLE OTA 任务启动 ===");
|
||||
|
||||
// 创建 OTA 数据队列和工作线程(在 NimBLE 初始化之前完成)
|
||||
@@ -1722,7 +1720,7 @@ void BleOta::taskEntry(void *pvParameters)
|
||||
if (s_otaDataQueue) {
|
||||
BaseType_t wk_ok = xTaskCreate(ble_ota_data_worker_task,
|
||||
"ble_ota_worker",
|
||||
8192, // 6KB→8KB,flash 操作需要足够栈空间
|
||||
4096, // 峰值仅 ~1.9KB,4KB 留有充足余量
|
||||
NULL,
|
||||
10, // 中等优先级,避免被 NimBLE host task 过度抢占
|
||||
NULL);
|
||||
|
||||
+14
-10
@@ -11,6 +11,7 @@
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
class BleOta {
|
||||
public:
|
||||
@@ -42,20 +43,23 @@ public:
|
||||
* @return 已连接返回 true,未连接返回 false。
|
||||
*/
|
||||
bool isConnected();
|
||||
/**
|
||||
* @brief 触发中继模块向 GD32 转发固件。
|
||||
*/
|
||||
void signalRelay();
|
||||
/** @brief 获取 BLE OTA 守护任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
/** @brief 获取 BLE OTA 数据工作线程句柄。 */
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
/*
|
||||
* http_handlers.cpp - HTTP 请求处理器实现
|
||||
* 从 wifi_ota.cpp 提取的 HTTP handler 函数
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <stdarg.h>
|
||||
#include <inttypes.h>
|
||||
#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 "ota_snapshot.h"
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "spi_master.h"
|
||||
#include "canfd_bridge.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);
|
||||
// ★ L1: WiFi OTA 开始时恢复 BLE 共存状态(通过协调器)
|
||||
OtaCoordinator::instance().notifyBleCoexEnd();
|
||||
|
||||
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 = canFdSend(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;
|
||||
}
|
||||
|
||||
// ★ PR1: 一次性快照,替代散读 OtaCoordinator / OtaRelay 多个 getter
|
||||
OtaSnapshot snap = OtaCoordinator::instance().getSnapshot();
|
||||
|
||||
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",
|
||||
snap.sys_state_str,
|
||||
(int)snap.sys_state,
|
||||
(unsigned long)snap.meta.total_size,
|
||||
(unsigned long)snap.meta.stored_size,
|
||||
snap.storage_done ? "true" : "false",
|
||||
snap.relay.state,
|
||||
(unsigned long)snap.relay.seq,
|
||||
(unsigned long)snap.relay.total_packets,
|
||||
snap.relay.gd32_app_alive ? "true" : "false",
|
||||
snap.relay.version.app_major, snap.relay.version.app_minor,
|
||||
snap.relay.version.app_patch, snap.relay.version.app_build,
|
||||
snap.relay.version.boot_major, snap.relay.version.boot_minor,
|
||||
snap.relay.version.boot_patch, snap.relay.version.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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /system/probe — GD32 APP 单次 SPI 探测(PR2)
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 显式触发一次 GD32 APP 存活探测。
|
||||
* 从 system_status_handler 移出的副作用,改为独立端点。
|
||||
* 成功时更新 OtaRelay 缓存的 alive/version 并返回版本信息。
|
||||
*/
|
||||
esp_err_t system_probe_handler(httpd_req_t *req)
|
||||
{
|
||||
http_set_cors_headers(req);
|
||||
|
||||
gd32_version_t ver = {};
|
||||
uint32_t uptime = 0;
|
||||
esp_err_t err = OtaCoordinator::instance().probeGd32AppOnce(&ver, &uptime);
|
||||
|
||||
char resp[256];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"alive\": %s, \"uptime_sec\": %" PRIu32 ", "
|
||||
"\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}",
|
||||
(err == ESP_OK) ? "true" : "false",
|
||||
uptime,
|
||||
ver.app_major, ver.app_minor, ver.app_patch, ver.app_build,
|
||||
ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#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);
|
||||
esp_err_t system_probe_handler(httpd_req_t *req);
|
||||
|
||||
#endif // HTTP_HANDLERS_H
|
||||
+45
-901
File diff suppressed because it is too large
Load Diff
+23
-21
@@ -11,15 +11,18 @@
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_event.h"
|
||||
#include "freertos/task.h"
|
||||
#include <atomic>
|
||||
|
||||
// SHA-256 验证所需类型前向声明
|
||||
#include "ota/ota_coordinator.h" // IBleCoexListener 接口定义
|
||||
|
||||
/** @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 {
|
||||
class WifiOta : public IBleCoexListener {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 WifiOta 单例实例。
|
||||
@@ -37,11 +40,6 @@ public:
|
||||
* @param pvParameters FreeRTOS 任务参数。
|
||||
*/
|
||||
static void taskEntry(void *pvParameters);
|
||||
/**
|
||||
* @brief 通知 OTA 中继开始向 GD32 转发固件。
|
||||
*/
|
||||
void signalRelay();
|
||||
|
||||
/**
|
||||
* @brief 获取当前 WiFi 工作模式。
|
||||
* @return 当前 WiFi 模式(AP 或 STA)。
|
||||
@@ -75,11 +73,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; }
|
||||
@@ -89,27 +82,36 @@ 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<bool> m_bleOtaActive{false};
|
||||
/** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */
|
||||
void setBleOtaActive(bool v) { m_bleOtaActive.store(v); }
|
||||
|
||||
// ── IBleCoexListener 接口实现(L1: 通过协调器接收 BLE 共存通知)──
|
||||
/** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询。 */
|
||||
void onBleOtaStart() override;
|
||||
/** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */
|
||||
void onBleOtaEnd() override;
|
||||
|
||||
/** @brief 获取 WiFi OTA 任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
private:
|
||||
// ── 跨任务共享状态(atomic + m_stateMutex 保护)──
|
||||
std::atomic<bool> m_staConnected{false};
|
||||
std::atomic<int> m_staRetryCount{0};
|
||||
std::atomic<bool> m_staRetryExhausted{false};
|
||||
std::atomic<bool> m_gd32StorageDone{false};
|
||||
std::atomic<bool> m_relayTriggered{false};
|
||||
SemaphoreHandle_t m_stateMutex = NULL;
|
||||
TaskHandle_t m_taskHandle = nullptr;
|
||||
|
||||
/**
|
||||
* @brief 初始化 SoftAP 模式,供局域网 OTA 使用。
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* gd32_flash_store.cpp - GD32 OTA 固件 Flash 存储抽象层实现
|
||||
*
|
||||
* 从 OtaRelay 剥离的 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验。
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <inttypes.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
#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
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取)
|
||||
*/
|
||||
#include <string.h>
|
||||
#include "canfd_bridge.h"
|
||||
#include "spi_master.h"
|
||||
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应。
|
||||
* 通过 SpiMaster 的公共 syncTransfer() 完成 SPI 传输。
|
||||
* 注意: syncTransfer 内部会加 SPI 互斥锁,FreeRTOS mutex 为递归锁故安全。
|
||||
*/
|
||||
int canFdSend(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms)
|
||||
{
|
||||
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t tx_buf[72];
|
||||
size_t tx_len = 0;
|
||||
|
||||
tx_buf[tx_len++] = (uint8_t)(channel & CAN_CHANNEL_MASK);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 24) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 16) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 8) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)(ext_id & 0xFF);
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().syncTransfer(
|
||||
0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms);
|
||||
if (ret != ESP_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return (int)recv_max;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 发送一帧无需响应的 CANFD 数据。
|
||||
*/
|
||||
int canFdSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc)
|
||||
{
|
||||
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t tx_buf[72];
|
||||
size_t tx_len = 0;
|
||||
|
||||
tx_buf[tx_len++] = (uint8_t)(channel & CAN_CHANNEL_MASK);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 24) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 16) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 8) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)(ext_id & 0xFF);
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().send(0, tx_buf, tx_len);
|
||||
if (ret != ESP_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* canfd_bridge.h - CANFD 3通道通信桥接(M4: 从 SpiMaster 提取)
|
||||
*
|
||||
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责解耦。
|
||||
* 通过 SpiMaster 的公共 SPI 接口完成底层传输。
|
||||
*/
|
||||
#ifndef CANFD_BRIDGE_H
|
||||
#define CANFD_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// ============================================================
|
||||
// CANFD 帧格式(3通道)
|
||||
// [通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]
|
||||
// ============================================================
|
||||
#define CAN_EXTENDED_ID_BITS (29) ///< CAN 扩展帧 ID 位数
|
||||
#define CAN_MAX_DLC (64) ///< 最大数据长度
|
||||
#define CAN_CHANNEL_MASK (0x03) ///< 通道号掩码 (0~2)
|
||||
|
||||
// 通道定义
|
||||
typedef enum {
|
||||
CAN_CHANNEL_0 = 0,
|
||||
CAN_CHANNEL_1 = 1,
|
||||
CAN_CHANNEL_2 = 2,
|
||||
CAN_CHANNEL_MAX = 3,
|
||||
} can_channel_t;
|
||||
|
||||
// CANFD 帧结构
|
||||
typedef struct {
|
||||
can_channel_t channel; ///< CAN 通道 (0~2)
|
||||
uint32_t ext_id; ///< 扩展帧 ID (29bit, Big-Endian 存储)
|
||||
uint8_t data[CAN_MAX_DLC]; ///< CAN 数据负载 (最大 64 字节)
|
||||
uint8_t dlc; ///< 数据长度 (1~64)
|
||||
} can_frame_t;
|
||||
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应。
|
||||
* 内部通过 SpiMaster 的公共 SPI 接口完成传输。
|
||||
* @param channel CAN 通道编号(0~2)。
|
||||
* @param ext_id 扩展帧 ID(29 位)。
|
||||
* @param data CAN 数据负载。
|
||||
* @param dlc 数据长度。
|
||||
* @param recv_buf 接收数据缓冲区(可为 nullptr)。
|
||||
* @param recv_max 接收缓冲区最大长度。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @return 成功时返回接收数据长度,失败返回 -1。
|
||||
*/
|
||||
int canFdSend(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms);
|
||||
|
||||
/**
|
||||
* @brief 发送一帧无需响应的 CANFD 数据。
|
||||
* @param channel CAN 通道编号(0~2)。
|
||||
* @param ext_id 扩展帧 ID(29 位)。
|
||||
* @param data CAN 数据负载。
|
||||
* @param dlc 数据长度。
|
||||
* @return 成功返回 0,失败返回 -1。
|
||||
*/
|
||||
int canFdSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc);
|
||||
|
||||
#endif // CANFD_BRIDGE_H
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
#include "driver/spi_master.h"
|
||||
#include "soc/spi_struct.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "spi_master.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
|
||||
static constexpr size_t kSpiTransferMaxBytes = 256U;
|
||||
|
||||
// ============================================================
|
||||
@@ -724,83 +726,7 @@ esp_err_t SpiMaster::transferWithRetry(uint8_t cmd, const uint8_t *payload,
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CANFD 接口
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应内容。
|
||||
* @param channel CAN 通道编号(0~2)。
|
||||
* @param ext_id 扩展帧 ID(29 位)。
|
||||
* @param data CAN 数据负载。
|
||||
* @param dlc 数据长度。
|
||||
* @param recv_buf 接收数据缓冲区。
|
||||
* @param recv_max 接收缓冲区最大长度。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @return 成功时返回接收数据长度,失败返回 -1。
|
||||
*/
|
||||
int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t tx_buf[72];
|
||||
size_t tx_len = 0;
|
||||
|
||||
tx_buf[tx_len++] = (uint8_t)(channel & CAN_CHANNEL_MASK);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 24) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 16) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 8) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)(ext_id & 0xFF);
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
|
||||
esp_err_t ret = syncTransferUnlocked(0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms);
|
||||
if (ret != ESP_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return (int)recv_max;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 发送一帧无需响应的 CANFD 数据。
|
||||
* @param channel CAN 通道编号(0~2)。
|
||||
* @param ext_id 扩展帧 ID(29 位)。
|
||||
* @param data CAN 数据负载。
|
||||
* @param dlc 数据长度。
|
||||
* @return 成功返回 0,失败返回 -1。
|
||||
*/
|
||||
int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t tx_buf[72];
|
||||
size_t tx_len = 0;
|
||||
|
||||
tx_buf[tx_len++] = (uint8_t)(channel & CAN_CHANNEL_MASK);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 24) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 16) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)((ext_id >> 8) & 0xFF);
|
||||
tx_buf[tx_len++] = (uint8_t)(ext_id & 0xFF);
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
|
||||
esp_err_t ret = sendUnlocked(0, tx_buf, tx_len);
|
||||
if (ret != ESP_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
// CANFD 接口已迁移至 transport/canfd_bridge.cpp(M4 解耦)
|
||||
|
||||
// ============================================================
|
||||
// 无锁内部版本实现
|
||||
|
||||
@@ -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)
|
||||
@@ -30,30 +30,7 @@
|
||||
#define SPI_OTA_RSP_WAIT (0x81)
|
||||
#define SPI_OTA_RSP_ERROR (0x82)
|
||||
|
||||
// ============================================================
|
||||
// CANFD 帧格式(3通道)
|
||||
// [通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]
|
||||
// 长度自动识别,无需发送 DLC
|
||||
// ============================================================
|
||||
#define CAN_EXTENDED_ID_BITS (29) // CAN 扩展帧 ID 位数
|
||||
#define CAN_MAX_DLC (64) // 最大数据长度
|
||||
#define CAN_CHANNEL_MASK (0x03) // 通道号掩码 (0~2)
|
||||
|
||||
// 通道定义
|
||||
typedef enum {
|
||||
CAN_CHANNEL_0 = 0,
|
||||
CAN_CHANNEL_1 = 1,
|
||||
CAN_CHANNEL_2 = 2,
|
||||
CAN_CHANNEL_MAX = 3,
|
||||
} can_channel_t;
|
||||
|
||||
// CANFD 帧结构
|
||||
typedef struct {
|
||||
can_channel_t channel; // CAN 通道 (0~2)
|
||||
uint32_t ext_id; // 扩展帧 ID (29bit, Big-Endian 存储)
|
||||
uint8_t data[CAN_MAX_DLC]; // CAN 数据负载 (最大 64 字节)
|
||||
uint8_t dlc; // 数据长度 (1~64)
|
||||
} can_frame_t;
|
||||
// CANFD 类型已迁移至 transport/canfd_bridge.h(M4 解耦)
|
||||
|
||||
// GD32 版本信息(从 APP PING 响应解析)
|
||||
typedef struct {
|
||||
@@ -122,19 +99,7 @@ public:
|
||||
size_t payload_len, uint8_t *recv_buf,
|
||||
size_t recv_len, int max_retries);
|
||||
|
||||
// ============================================================
|
||||
// CANFD 接口
|
||||
// ============================================================
|
||||
|
||||
/** @brief 发送一帧 CANFD 数据,可选等待并读取响应内容。 */
|
||||
int canSend(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms);
|
||||
|
||||
/** @brief 发送一帧无需响应的 CANFD 数据。 */
|
||||
int canSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc);
|
||||
// ── CANFD 接口已迁移至 transport/canfd_bridge.h(M4 解耦)──
|
||||
|
||||
/** @brief 获取 SPI 设备句柄(供外部状态查询)。 */
|
||||
spi_device_handle_t deviceHandle() const { return m_spiDevice; }
|
||||
|
||||
+10
-2
@@ -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"
|
||||
|
||||
+32
-1
@@ -1,6 +1,37 @@
|
||||
# WiFi perf -- OTA acceleration
|
||||
CONFIG_ESP_WIFI_RX_BA_WIN=6
|
||||
CONFIG_ESP_WIFI_TX_BA_WIN=6
|
||||
CONFIG_LWIP_MAX_SOCKETS=10
|
||||
|
||||
# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 = 7 够用
|
||||
CONFIG_LWIP_MAX_SOCKETS=7
|
||||
CONFIG_LWIP_TCP_WND_DEFAULT=11480
|
||||
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
|
||||
|
||||
# ── NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service)──
|
||||
# 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB
|
||||
CONFIG_BT_NIMBLE_PROX_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_ANS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_CTS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HTP_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IPSS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_TPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_LLS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_SPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HR_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_BAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_DIS_SERVICE=n
|
||||
|
||||
# ── NimBLE: 缩减内存池(单连接 OTA 场景,不需要大池子)──
|
||||
# MSYS pool 1: 256B blocks, 16→8 (省 ~2 KB)
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8
|
||||
# MSYS pool 2: 320B blocks, 32→16 (省 ~5 KB)
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=16
|
||||
# ACL transport buffers: 12→6 (省 ~1.5 KB)
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
|
||||
# Event buffers: 30→16 (省 ~1 KB)
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
|
||||
# Prepared write entries: 64→16 (省 ~1 KB)
|
||||
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
|
||||
# GATT procedures: 4→2 (单连接够用)
|
||||
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
|
||||
|
||||
+12
-3
@@ -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()
|
||||
|
||||
+347
-82
@@ -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
|
||||
@@ -14,6 +26,10 @@ let pageVisible = !document.hidden; // 页面可见性:切到后台/最小
|
||||
let latestSystemStatus = null;
|
||||
let isEsp32Rebooting = false;
|
||||
let rebootStartTime = 0;
|
||||
let pollingPausedForUpload = false;
|
||||
let activeXhr = null; // 当前上传 XHR,用于 abort
|
||||
let uploadBtnOrigText = ''; // 上传按钮原始文字
|
||||
let activeUploadChip = null; // 当前上传目标芯片 ('esp32' | 'gd32' | null)
|
||||
|
||||
// ==================== 折叠面板 ====================
|
||||
function toggleCollapse(headEl) {
|
||||
@@ -98,11 +114,73 @@ function updateEsp32StagesError() {
|
||||
if (statusText) statusText.textContent = '校验/应用失败';
|
||||
}
|
||||
|
||||
/**
|
||||
* @description OTA 失败后的统一清理函数。
|
||||
* 所有上传失败、校验失败、中继触发失败等异常路径都应调用此函数,
|
||||
* 确保 UI 状态完整重置,不会残留"上传中"或中间态。
|
||||
* @param {string} reason 失败原因描述
|
||||
*/
|
||||
function cleanupAfterOtaFailure(reason) {
|
||||
// 1. 重置上传锁
|
||||
isUploading = false;
|
||||
|
||||
// 2. 中止正在进行的 XHR(如有)
|
||||
if (activeXhr) {
|
||||
try { activeXhr.abort(); } catch (_) {}
|
||||
activeXhr = null;
|
||||
}
|
||||
|
||||
// 3. 标记阶段指示器为错误态
|
||||
if (currentOtaTarget === 'esp32') {
|
||||
updateEsp32StagesError();
|
||||
} else if (currentOtaTarget === 'gd32') {
|
||||
updateStagesFromRelayState(255);
|
||||
}
|
||||
|
||||
// 4. 状态栏标红
|
||||
var sb = document.getElementById('stageStatusBar');
|
||||
var st = document.getElementById('stageStatusText');
|
||||
if (sb) sb.className = 'stage-status-bar err';
|
||||
if (st) st.textContent = reason || '失败';
|
||||
|
||||
// 5. 进度条显示失败信息
|
||||
setProgress(0, reason || '刷写失败');
|
||||
|
||||
// 6. 重置活跃阶段
|
||||
setActivePhase(null);
|
||||
|
||||
// 7. 清除 OTA 目标(下次操作时重新设置)
|
||||
currentOtaTarget = null;
|
||||
|
||||
// 8. 隐藏校验面板
|
||||
var verifyInfo = document.getElementById('stageVerifyInfo');
|
||||
if (verifyInfo) verifyInfo.style.display = 'none';
|
||||
|
||||
// 9. 恢复上传按钮(基于实际上传的芯片,而非当前 UI 选中的标签)
|
||||
var chip = activeUploadChip || window.flashTarget || 'esp32';
|
||||
var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn';
|
||||
var btn = document.getElementById(btnId);
|
||||
if (btn && uploadBtnOrigText) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = uploadBtnOrigText;
|
||||
}
|
||||
activeUploadChip = null;
|
||||
|
||||
updateUploadButtonLabel();
|
||||
|
||||
// 10. 恢复轮询
|
||||
resumePollingAfterUpload();
|
||||
|
||||
// 11. 日志
|
||||
addLog('E', 'OTA', '刷写流程已中止: ' + (reason || '未知原因'));
|
||||
}
|
||||
|
||||
function showVerifyActions(type) {
|
||||
// 已移除确认/重试/取消按钮:SHA-256 校验通过后自动重启,校验失败仅记录日志
|
||||
void type;
|
||||
}
|
||||
|
||||
/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */
|
||||
function setActivePhase(phase) {
|
||||
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
|
||||
activePhase = phase;
|
||||
@@ -117,6 +195,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' };
|
||||
@@ -128,6 +207,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');
|
||||
@@ -136,6 +216,7 @@ function setProgress(pct, text) {
|
||||
if (txtEl) txtEl.textContent = text;
|
||||
if (pctEl) pctEl.textContent = pct + '%';
|
||||
}
|
||||
/** @description 重置进度条和阶段状态到初始值。 */
|
||||
function resetProgress() {
|
||||
setProgress(0, '等待操作...');
|
||||
activePhase = null;
|
||||
@@ -149,11 +230,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');
|
||||
@@ -161,6 +244,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);
|
||||
@@ -172,11 +256,25 @@ 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
function formatPercent(value) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
return value.toFixed(1).replace(/\.0$/, '') + '%';
|
||||
}
|
||||
function formatHttpErrorDetail(text) {
|
||||
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
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');
|
||||
@@ -197,6 +295,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());
|
||||
@@ -299,6 +398,7 @@ function resetStageFlow() {
|
||||
function applyDisconnectedRuntimeState() {
|
||||
document.getElementById('pillIP').textContent = '--';
|
||||
document.getElementById('pillRSSI').textContent = '--';
|
||||
if (isUploading || currentOtaTarget) return;
|
||||
resetProgress();
|
||||
resetStageFlow();
|
||||
}
|
||||
@@ -354,7 +454,92 @@ function updateHealthUi(healthCode, freeHeap) {
|
||||
connStatusTxt.textContent = (latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
|
||||
}
|
||||
if (freeHeap !== undefined) {
|
||||
document.getElementById('mHeap').textContent = formatBytes(freeHeap);
|
||||
var mHeapEl = document.getElementById('mHeap');
|
||||
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
|
||||
}
|
||||
var mh = document.getElementById('mHealth');
|
||||
if (mh) {
|
||||
mh.textContent = health;
|
||||
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
|
||||
}
|
||||
}
|
||||
function updateHeapAndTaskStatusUi(data) {
|
||||
if (!data) return;
|
||||
var ota = data.ota || {};
|
||||
var wifi = data.wifi || {};
|
||||
var targetHost = getBaseUrl().replace(/^http:\/\//, '');
|
||||
var setText = function (id, value) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
// ---- 堆概况:已用 / 总计 + 占用条 ----
|
||||
function setFill(id, pct) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
|
||||
el.className = 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : '');
|
||||
}
|
||||
function setBar(usageId, fillId, pctId, free, total) {
|
||||
if (free === undefined || total === undefined) {
|
||||
setText(usageId, '--'); setFill(fillId, 0); setText(pctId, '--');
|
||||
return;
|
||||
}
|
||||
var used = total - free;
|
||||
var pct = total > 0 ? (used * 100 / total) : 0;
|
||||
setText(usageId, '已用 ' + formatBytes(used) + ' / 总计 ' + formatBytes(total));
|
||||
setFill(fillId, pct);
|
||||
setText(pctId, pct.toFixed(1) + '%');
|
||||
}
|
||||
setText('mInternalHeap', data.internal_free_heap !== undefined ? formatBytes(data.internal_free_heap) : '--');
|
||||
setText('mInternalMinHeap', data.internal_min_free_heap !== undefined ? formatBytes(data.internal_min_free_heap) : '--');
|
||||
setText('mInternalLargestBlock', data.internal_largest_free_block !== undefined ? formatBytes(data.internal_largest_free_block) : '--');
|
||||
setBar('memInternalUsage', 'memInternalFill', 'memInternalPct', data.internal_free_heap, data.internal_heap_total);
|
||||
|
||||
setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--');
|
||||
|
||||
setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : ''));
|
||||
setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--');
|
||||
var storedText = '--';
|
||||
if (ota.total_size || ota.stored_size) {
|
||||
storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0);
|
||||
}
|
||||
setText('sOtaStored', storedText);
|
||||
setText('sStatusUpdated', formatTime(new Date()));
|
||||
|
||||
var taskList = document.getElementById('taskStackList');
|
||||
if (taskList) {
|
||||
var taskStack = data.task_stack || {};
|
||||
var names = Object.keys(taskStack);
|
||||
if (!names.length) {
|
||||
taskList.innerHTML = '<div class="status-empty">当前状态未包含任务栈信息</div>';
|
||||
} else {
|
||||
taskList.innerHTML = names.map(function (name) {
|
||||
var item = taskStack[name] || {};
|
||||
var stackSize = Number(item.stack_size_bytes) || 0;
|
||||
var watermark = Number(item.high_watermark_bytes) || 0;
|
||||
var peakUsed = Number(item.peak_used_bytes) || 0;
|
||||
var usagePct = stackSize > 0 ? (peakUsed * 100 / stackSize) : 0;
|
||||
var fillClass = usagePct >= 85 ? 'err' : usagePct >= 70 ? 'warn' : '';
|
||||
return '' +
|
||||
'<div class="status-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>栈大小</span><b>' + escapeHtml(formatBytes(stackSize)) + '</b></div>' +
|
||||
'<div><span>剩余水位</span><b>' + escapeHtml(formatBytes(watermark)) + '</b></div>' +
|
||||
'<div><span>峰值占用</span><b>' + escapeHtml(formatBytes(peakUsed)) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
var statusJson = document.getElementById('statusJson');
|
||||
if (statusJson) {
|
||||
statusJson.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
// ==================== OTA 阶段驱动 (真实状态) ====================
|
||||
@@ -425,6 +610,7 @@ function updateStagesFromRelayState(rs) {
|
||||
function updateSystemStatusUi(data) {
|
||||
if (!data) return;
|
||||
latestSystemStatus = data;
|
||||
updateHeapAndTaskStatusUi(data);
|
||||
var wifi = data.wifi || {};
|
||||
var ota = data.ota || {};
|
||||
var healthCode = data.health;
|
||||
@@ -433,10 +619,12 @@ function updateSystemStatusUi(data) {
|
||||
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
|
||||
updateConnectionUi(connected, connText);
|
||||
updateHealthUi(healthCode, data.free_heap);
|
||||
// version
|
||||
if (data.version) {
|
||||
document.getElementById('pillVer').textContent = 'v' + data.version;
|
||||
document.getElementById('pillVer').dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
|
||||
var pillVerEl = document.getElementById('pillVer');
|
||||
if (pillVerEl) {
|
||||
pillVerEl.textContent = 'v' + data.version;
|
||||
pillVerEl.dataset.tooltip = data.build_time ? ("编译: " + data.build_time) : '';
|
||||
}
|
||||
// GD32 版本
|
||||
var mGd32App = document.getElementById('mGd32App');
|
||||
var mGd32Boot = document.getElementById('mGd32Boot');
|
||||
@@ -460,23 +648,20 @@ function updateSystemStatusUi(data) {
|
||||
}
|
||||
}
|
||||
// temperature
|
||||
document.getElementById('mTemp').textContent =
|
||||
var mTempEl = document.getElementById('mTemp');
|
||||
if (mTempEl) mTempEl.textContent =
|
||||
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
|
||||
// uptime
|
||||
var uptimeEl = document.getElementById('pillUptime');
|
||||
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
|
||||
// wifi / signal
|
||||
document.getElementById('pillIP').textContent = wifi.ip || '--';
|
||||
var pillIPEl = document.getElementById('pillIP');
|
||||
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
|
||||
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
|
||||
document.getElementById('pillRSSI').textContent = rssi;
|
||||
// heap
|
||||
document.getElementById('mHeap').textContent =
|
||||
data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
|
||||
document.getElementById('mMinHeap').textContent =
|
||||
data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
|
||||
// ota cache
|
||||
var otaStored = ota.stored_size || 0;
|
||||
var pillRSSIEl = document.getElementById('pillRSSI');
|
||||
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
|
||||
var mOtaCache = document.getElementById('mOtaCache');
|
||||
var otaStored = ota.stored_size || 0;
|
||||
if (mOtaCache) {
|
||||
if (otaStored > 0) {
|
||||
mOtaCache.textContent = formatBytes(otaStored) + ' / 216 KB';
|
||||
@@ -501,14 +686,7 @@ function updateSystemStatusUi(data) {
|
||||
var total = ota.total_size || 0;
|
||||
var stored = ota.stored_size || 0;
|
||||
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
||||
var bp = document.getElementById('bOTAProgress');
|
||||
function setOtaProgressBadge(pct) {
|
||||
var safePct = Math.max(0, Math.min(100, Math.round(pct || 0)));
|
||||
if (bp) {
|
||||
bp.textContent = safePct + '%';
|
||||
bp.className = 'v ' + (safePct >= 100 ? 'ok' : safePct > 0 ? 'warn' : '');
|
||||
}
|
||||
}
|
||||
function setOtaProgressBadge() {} // OTA 进度卡片已移除,保留空壳避免调用报错
|
||||
// GD32 relay forwarding progress
|
||||
var relaySeq = ota.relay_seq || 0;
|
||||
var relayTotal = ota.relay_total_packets || 0;
|
||||
@@ -517,10 +695,15 @@ function updateSystemStatusUi(data) {
|
||||
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
|
||||
if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); }
|
||||
|
||||
if (isGd32Error) {
|
||||
setActivePhase('gd32Ota');
|
||||
setOtaProgressBadge(0);
|
||||
setProgress(0, 'GD32 OTA 失败');
|
||||
if (isGd32Error && currentOtaTarget === 'gd32') {
|
||||
cleanupAfterOtaFailure('GD32 OTA 失败');
|
||||
} else if (isGd32Error) {
|
||||
// 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理
|
||||
updateStagesFromRelayState(255);
|
||||
var sbErr = document.getElementById('stageStatusBar');
|
||||
var stErr = document.getElementById('stageStatusText');
|
||||
if (sbErr) sbErr.className = 'stage-status-bar err';
|
||||
if (stErr) stErr.textContent = 'GD32 错误(待机)';
|
||||
} else if (isGd32Complete || rs === 5 || (gd32AppAlive && sysState === 'GD32_COMPLETE')) {
|
||||
markPhaseDone('gd32Ota');
|
||||
setOtaProgressBadge(100);
|
||||
@@ -574,6 +757,7 @@ function updateSystemStatusUi(data) {
|
||||
setOtaProgressBadge(storageProgress);
|
||||
}
|
||||
}
|
||||
/** @description 测试与 ESP32 的连接(GET /status)。 */
|
||||
async function testConnection() {
|
||||
// 如果在降速模式,先恢复正常轮询
|
||||
if (pollSlowMode) {
|
||||
@@ -607,6 +791,32 @@ async function testConnection() {
|
||||
showToast('无法连接: ' + diag, 'error');
|
||||
}
|
||||
}
|
||||
async function refreshSystemStatusNow(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
|
||||
showToast('状态刷新失败: HTTP ' + resp.status, 'error');
|
||||
return;
|
||||
}
|
||||
var data = await resp.json();
|
||||
latestSystemStatus = data;
|
||||
updateSystemStatusUi(data);
|
||||
consecutivePollFailures = 0;
|
||||
if (pollSlowMode) restorePolling();
|
||||
addLog('I', '状态', '已手动刷新 /system/status');
|
||||
} catch (e) {
|
||||
addLog('E', '状态', '刷新失败: ' + e.message);
|
||||
showToast('状态刷新失败', 'error');
|
||||
}
|
||||
}
|
||||
// ==================== CAN 帧发送 ====================
|
||||
async function sendCanFrame() {
|
||||
var ch = parseInt(document.getElementById('canChannel').value);
|
||||
@@ -898,6 +1108,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');
|
||||
@@ -965,6 +1176,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; }
|
||||
@@ -981,45 +1193,56 @@ 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;
|
||||
pausePollingForUpload();
|
||||
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
|
||||
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
|
||||
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
|
||||
// 设备端互斥被绕过、403 路径走漏。
|
||||
isUploading = true;
|
||||
// 跨通道互斥前置检查
|
||||
try {
|
||||
var probe = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
if (probe.ok) {
|
||||
var st = await probe.json();
|
||||
var ota = st.ota || {};
|
||||
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
|
||||
// ESP32 三种活跃态(5=RECEIVING, 6=FINALIZING, 7=REBOOTING)
|
||||
var esp32Busy = (ssc === 5 || ssc === 6 || ssc === 7);
|
||||
// GD32 存储/中继(1=STORING, 3=RELAYING)→ 阻止新上传,避免 NVS 与 Flash 互踩
|
||||
var gd32Busy = (ssc === 1 || ssc === 3);
|
||||
// 同通道(chip=esp32)已在进行中:本通道会重入;不做阻拦
|
||||
// 另一通道活跃:拒绝
|
||||
if (chip === 'esp32' && (esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 ESP32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
return;
|
||||
activeUploadChip = chip;
|
||||
// 跨通道互斥前置检查(含 BLE 取消后的瞬态重试)
|
||||
var _probeReady = false;
|
||||
for (var _pi = 0; _pi < 4; _pi++) {
|
||||
if (_pi > 0) {
|
||||
var _pd = [1500, 3000, 3000][_pi - 1];
|
||||
addLog('I', '上传', '等待设备清理 OTA 状态 (' + _pd + 'ms)...');
|
||||
await new Promise(function(r) { setTimeout(r, _pd); });
|
||||
}
|
||||
try {
|
||||
var probe = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
if (probe.ok) {
|
||||
var st = await probe.json();
|
||||
var ota = st.ota || {};
|
||||
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
|
||||
// ★ RECEIVING (5) 可能是刚取消的 BLE OTA 残留态(设备端 GAP disconnect
|
||||
// 处理需 3-4 秒),等它清理完;FINALIZING(6)/REBOOTING(7) 是真实忙,直接拒绝。
|
||||
if (ssc === 5 && _pi < 3) continue;
|
||||
// ESP32 活跃态(5=RECEIVING, 6=FINALIZING, 7=REBOOTING)
|
||||
var esp32Busy = (ssc === 5 || ssc === 6 || ssc === 7);
|
||||
// GD32 存储/中继(1=STORING, 3=RELAYING)→ 阻止新上传,避免 NVS 与 Flash 互踩
|
||||
var gd32Busy = (ssc === 1 || ssc === 3);
|
||||
if ((esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 ' + chip.toUpperCase() + ' 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (chip === 'gd32' && (esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 GD32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
return;
|
||||
}
|
||||
_probeReady = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
// 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。
|
||||
addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)');
|
||||
_probeReady = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。
|
||||
addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)');
|
||||
}
|
||||
// 每次上传前尝试重新从本地读取文件
|
||||
var handle = chip === 'esp32' ? esp32FileHandle : gd32FileHandle;
|
||||
@@ -1054,11 +1277,12 @@ async function startUpload(chip) {
|
||||
showToast('文件已变更,请重新选择', 'error');
|
||||
clearFile(chip);
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
|
||||
var origText = uploadBtn.textContent;
|
||||
uploadBtnOrigText = uploadBtn.textContent;
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = '上传中...';
|
||||
switchStageFlow(chip);
|
||||
@@ -1127,7 +1351,8 @@ async function startUpload(chip) {
|
||||
combined.set(new Uint8Array(payload), headerBytes.length);
|
||||
body = combined.buffer;
|
||||
}
|
||||
var xhr = new XMLHttpRequest();
|
||||
activeXhr = new XMLHttpRequest();
|
||||
var xhr = activeXhr;
|
||||
xhr.upload.addEventListener('progress', function (e) {
|
||||
if (e.lengthComputable) {
|
||||
var pct = Math.round((e.loaded / e.total) * 100);
|
||||
@@ -1135,19 +1360,17 @@ async function startUpload(chip) {
|
||||
if (chip === 'gd32') {
|
||||
var overallPct = Math.min(45, Math.round(pct * 0.45));
|
||||
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
var el = document.getElementById('bOTAProgress');
|
||||
if (el) el.textContent = overallPct + '%';
|
||||
} else {
|
||||
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
var el = document.getElementById('bOTAProgress');
|
||||
if (el) el.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', function () {
|
||||
isUploading = false;
|
||||
activeXhr = null;
|
||||
resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
uploadBtn.textContent = uploadBtnOrigText;
|
||||
updateUploadButtonLabel();
|
||||
if (xhr.status === 200) {
|
||||
addLog('I', '上传', '固件上传完成!');
|
||||
@@ -1167,35 +1390,38 @@ async function startUpload(chip) {
|
||||
} else if (xhr.status === 403) {
|
||||
addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试');
|
||||
showToast('ESP32 OTA 被其他通道占用,请稍后重试', 'error');
|
||||
setProgress(0, 'OTA 被其他通道占用');
|
||||
cleanupAfterOtaFailure('通道被占用');
|
||||
} else {
|
||||
var detail = formatHttpErrorDetail(xhr.responseText);
|
||||
var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : '');
|
||||
addLog('E', '上传', message);
|
||||
showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error');
|
||||
cleanupAfterOtaFailure('HTTP ' + xhr.status);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', function () {
|
||||
isUploading = false;
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
addLog('E', '上传', '上传过程发生错误');
|
||||
showToast('上传出错', 'error');
|
||||
if (activeXhr) {
|
||||
cleanupAfterOtaFailure('网络错误');
|
||||
showToast('上传过程发生网络错误', 'error');
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('abort', function () {
|
||||
if (activeXhr) {
|
||||
cleanupAfterOtaFailure('已中止');
|
||||
showToast('上传已中止', 'error');
|
||||
}
|
||||
});
|
||||
xhr.open('POST', url);
|
||||
xhr.send(body);
|
||||
} catch (e) {
|
||||
addLog('E', '上传', '上传失败: ' + e.message);
|
||||
showToast('上传失败', 'error');
|
||||
isUploading = false;
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = origText;
|
||||
updateUploadButtonLabel();
|
||||
showToast('上传失败: ' + e.message, 'error');
|
||||
cleanupAfterOtaFailure('上传异常');
|
||||
}
|
||||
}
|
||||
// ==================== ESP32 OTA 完成升级 ====================
|
||||
// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮)
|
||||
/** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */
|
||||
async function verifyEsp32Firmware() {
|
||||
updateEsp32StagesFromPhase(3); // 校验中
|
||||
var sha256 = window._esp32FileSha256;
|
||||
@@ -1253,9 +1479,9 @@ async function verifyEsp32Firmware() {
|
||||
addLog('E', 'OTA', '校验失败: ' + detail);
|
||||
showToast('校验失败: ' + detail, 'error');
|
||||
var resultEl = document.getElementById('sviResult');
|
||||
if (resultEl) { resultEl.textContent = '❌ ' + detail; resultEl.className = 'svi-val err'; }
|
||||
updateEsp32StagesError();
|
||||
if (resultEl) { resultEl.textContent = '✗ ' + detail; resultEl.className = 'svi-val err'; }
|
||||
showVerifyActions('retry');
|
||||
cleanupAfterOtaFailure('校验失败');
|
||||
}
|
||||
} catch (e) {
|
||||
addLog('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message);
|
||||
@@ -1282,10 +1508,15 @@ async function retryEsp32Upload() {
|
||||
}
|
||||
|
||||
async function cancelEsp32Ota() {
|
||||
addLog('I', 'OTA', '已取消 OTA 刷写');
|
||||
addLog('I', 'OTA', '用户取消 OTA 刷写');
|
||||
showToast('已取消 OTA', 'error');
|
||||
showVerifyActions('none');
|
||||
resetProgress();
|
||||
var xhr = activeXhr;
|
||||
activeXhr = null;
|
||||
if (xhr) {
|
||||
try { xhr.abort(); } catch (_) {}
|
||||
}
|
||||
cleanupAfterOtaFailure('用户取消');
|
||||
}
|
||||
|
||||
async function applyEsp32Firmware() {
|
||||
@@ -1342,11 +1573,13 @@ async function restartEsp32() {
|
||||
}
|
||||
}
|
||||
// ==================== GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)====================
|
||||
/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */
|
||||
async function triggerGd32Relay() {
|
||||
var sha256 = window._gd32FileSha256;
|
||||
if (!sha256) {
|
||||
addLog('E', 'OTA', '缺少 GD32 文件 SHA-256,请先上传文件');
|
||||
showToast('SHA-256 缺失', 'error');
|
||||
cleanupAfterOtaFailure('SHA-256 缺失');
|
||||
return;
|
||||
}
|
||||
addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...');
|
||||
@@ -1373,19 +1606,23 @@ async function triggerGd32Relay() {
|
||||
if (err.device) {
|
||||
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device + ' 前端=' + sha256);
|
||||
showToast('SHA-256 不匹配', 'error');
|
||||
cleanupAfterOtaFailure('SHA-256 不匹配');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
addLog('E', 'OTA', 'GD32 触发失败: HTTP ' + resp.status + ' ' + text);
|
||||
showToast('GD32 触发失败', 'error');
|
||||
cleanupAfterOtaFailure('触发失败 HTTP ' + resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
addLog('E', 'OTA', 'GD32 触发失败: ' + e.message);
|
||||
showToast('GD32 触发失败', 'error');
|
||||
cleanupAfterOtaFailure('触发异常');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ====================
|
||||
/** @description 触发 GD32 OTA 中继(封装函数,调用实际 API)。 */
|
||||
async function triggerRelay() {
|
||||
// 转发到统一入口:自动使用最近一次上传的 GD32 文件 SHA
|
||||
await triggerGd32Relay();
|
||||
@@ -1437,6 +1674,7 @@ async function diagnoseNetwork() {
|
||||
diagResult = result.join(';');
|
||||
return diagResult;
|
||||
}
|
||||
/** @description 启动定时轮询(状态 + 日志)。 */
|
||||
function startPolling() {
|
||||
consecutivePollFailures = 0;
|
||||
pollSlowMode = false;
|
||||
@@ -1462,7 +1700,32 @@ function restorePolling() {
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
}
|
||||
function pausePollingForUpload() {
|
||||
if (pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = true;
|
||||
consecutivePollFailures = 0;
|
||||
if (statusTimerId) clearInterval(statusTimerId);
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
statusTimerId = null;
|
||||
logsTimerId = null;
|
||||
}
|
||||
function resumePollingAfterUpload() {
|
||||
if (!pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = false;
|
||||
consecutivePollFailures = 0;
|
||||
if (pollSlowMode) {
|
||||
statusTimerId = setInterval(pollStatus, 10000);
|
||||
logsTimerId = setInterval(pollLogs, 10000);
|
||||
} else {
|
||||
statusTimerId = setInterval(pollStatus, 1000);
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
pollStatus();
|
||||
pollLogs();
|
||||
}
|
||||
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
|
||||
async function pollStatus() {
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
@@ -1485,14 +1748,14 @@ async function pollStatus() {
|
||||
addLog('I', '系统', 'ESP32 已恢复在线');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
}
|
||||
updateSystemStatusUi(data);
|
||||
try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
|
||||
}
|
||||
} catch (e) {
|
||||
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
|
||||
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
|
||||
if (pageVisible) consecutivePollFailures++;
|
||||
// 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的)
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) {
|
||||
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget) {
|
||||
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
@@ -1539,7 +1802,9 @@ document.addEventListener('visibilitychange', function () {
|
||||
});
|
||||
// ==================== ESP32 实时日志拉取 (增量) ====================
|
||||
var lastLogTimestamp = 0;
|
||||
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
|
||||
async function pollLogs() {
|
||||
if (pollingPausedForUpload) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/logs', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
|
||||
+31
-1
@@ -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) {
|
||||
@@ -1289,7 +1294,7 @@
|
||||
// ★ 等待设备端 reorder buffer flush + ACK 回传。
|
||||
// 设备端可能正在 flush 缺口包(getReceived 跳跃增长),
|
||||
// 留足时间让最新的 ACK 到达,避免重发已 flush 的包。
|
||||
await sleep(150);
|
||||
await sleep(10);
|
||||
|
||||
for (let seq = resumeSeq; seq <= endSeq; seq++) {
|
||||
const start = seq * payloadSize;
|
||||
@@ -1439,6 +1444,23 @@
|
||||
bleLog('OTA', `写入失败: ${err.message}`, 'E');
|
||||
setProgress(0, `写入失败: ${err.message}`);
|
||||
setBleStageError(err.message);
|
||||
// ★ BLE OTA 失败/中止后强制断开 BLE 连接,并等待实际断连完成:
|
||||
// 触发 ESP32 后端的 GAP disconnect 处理器 → ble_esp32_ota_abort(),
|
||||
// 清理 OTA 会话(Esp32OtaCore::abort + reorder buffer 释放 + 协调器状态复位)。
|
||||
// 等待 gattserverdisconnected 事件确保断连真正生效,避免用户快速重试时
|
||||
// 旧会话尚未清理导致 seq=0 被误判为重传。
|
||||
if (state.server && state.device && state.device.gatt && state.device.gatt.connected) {
|
||||
bleLog('OTA', '刷写失败,断开 BLE 以清理 ESP32 OTA 会话...', 'W');
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => { if (!done) { done = true; resolve(); } };
|
||||
state.device.addEventListener('gattserverdisconnected', finish, { once: true });
|
||||
setTimeout(finish, 1000); // 1 秒兜底超时
|
||||
state.device.gatt.disconnect();
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (state.ackWaiter) {
|
||||
@@ -1454,6 +1476,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */
|
||||
async function autoFlash() {
|
||||
// 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择)
|
||||
if (state.selectedFileHandle) {
|
||||
@@ -1489,6 +1512,12 @@
|
||||
const ok = await uploadFirmware();
|
||||
if (!ok || state.abortRequested) {
|
||||
bleLog('AUTO', '一键刷写中止', 'W');
|
||||
// ★ 确保 BLE 已断开(触发 ESP32 OTA 会话清理)
|
||||
// uploadFirmware catch 块已尝试断开,这里做安全网兜底
|
||||
if (state.device && state.device.gatt && state.device.gatt.connected) {
|
||||
bleLog('AUTO', '断开 BLE 以清理 ESP32 OTA 会话...', 'W');
|
||||
try { state.device.gatt.disconnect(); } catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cfg.label === 'ESP32') {
|
||||
@@ -1507,6 +1536,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 中止当前 BLE OTA 任务并清理状态。 */
|
||||
function abortTask() {
|
||||
state.abortRequested = true;
|
||||
bleLog('OTA', '已请求中止,等待当前分包返回');
|
||||
|
||||
+56
-7
@@ -1,10 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.html - ESP32 OTA 中继控制台主页面
|
||||
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
|
||||
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA), style.css (UI 样式)
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 OTA 中继控制台</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260617a">
|
||||
<link rel="stylesheet" href="style.css?v=20260619c">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -248,8 +253,8 @@
|
||||
<div class="k">连接状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v warn" id="bOTAProgress">0%</div>
|
||||
<div class="k">OTA 进度</div>
|
||||
<div class="v" id="mHealth">--</div>
|
||||
<div class="k">健康状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v err" id="connBadge">offline</div>
|
||||
@@ -269,8 +274,6 @@
|
||||
<div class="card-body">
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><div class="k">温度</div><div class="v" id="mTemp">--</div></div>
|
||||
<div class="metric"><div class="k">空闲堆</div><div class="v" id="mHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">最低空闲堆</div><div class="v" id="mMinHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot" style="font-size:14px;">--</div></div>
|
||||
<div class="metric"><div class="k">OTA 缓存</div><div class="v" id="mOtaCache" style="font-size:13px;">--</div></div>
|
||||
@@ -278,6 +281,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="display:none">
|
||||
<div class="card-head">
|
||||
<span class="ico">🧾</span> 系统状态 (/system/status)
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-kv-grid">
|
||||
<div class="status-kv"><span class="k">OTA 状态</span><b id="sOtaState">--</b></div>
|
||||
<div class="status-kv"><span class="k">Relay 状态</span><b id="sRelayState">--</b></div>
|
||||
<div class="status-kv"><span class="k">缓存进度</span><b id="sOtaStored">--</b></div>
|
||||
<div class="status-kv"><span class="k">刷新时间</span><b id="sStatusUpdated">--</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">🧠</span> 堆与任务栈
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-section-title">堆概况</div>
|
||||
<div class="heap-group">
|
||||
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="memInternalUsage">--</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span>
|
||||
<span class="heap-bar-pct" id="memInternalPct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="mInternalHeap">--</b></span>
|
||||
<span>最低空闲: <b id="mInternalMinHeap">--</b></span>
|
||||
<span>最大块: <b id="mInternalLargestBlock">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">任务栈水位</div>
|
||||
<div class="status-task-list" id="taskStackList">
|
||||
<div class="status-empty">等待状态刷新...</div>
|
||||
</div>
|
||||
<div class="status-section-title">原始 JSON</div>
|
||||
<pre class="status-json" id="statusJson">--</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAN panel -->
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
@@ -369,7 +418,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260619c"></script>
|
||||
<script src="app.js?v=20260619e"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
|
||||
+212
@@ -116,6 +116,12 @@ body::before {
|
||||
text-transform: uppercase; letter-spacing: .06em;
|
||||
}
|
||||
.card-head .ico { font-size: 15px; }
|
||||
.card-head-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card.collapsed .card-body { display: none; }
|
||||
.card-head { cursor: pointer; }
|
||||
@@ -326,6 +332,211 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
}
|
||||
.metric .v.highlight { color: var(--accent); }
|
||||
|
||||
.status-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.status-kv {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.status-kv .k {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.status-kv b {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
/* ===== 堆概况 ===== */
|
||||
.heap-group {
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.heap-group:last-child { margin-bottom: 0; }
|
||||
|
||||
.heap-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.heap-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.heap-bar-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
min-width: 165px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.heap-bar-track {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heap-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(90deg, var(--accent), #5eead4);
|
||||
transition: width .4s ease;
|
||||
.heap-bar-fill.warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #d97706);
|
||||
}
|
||||
.heap-bar-fill.danger {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.heap-bar-pct {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.heap-detail-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heap-detail-row span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.heap-detail-row b {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-task-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-task {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.status-task-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-task-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-task-ratio {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-task-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-task-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #22d3ee, #06b6d4);
|
||||
}
|
||||
.status-task-bar-fill.warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #d97706);
|
||||
}
|
||||
.status-task-bar-fill.err {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
.status-task-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.status-task-meta span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.status-task-meta b {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-json {
|
||||
margin-top: 0;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-empty {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* === PRIMARY STATUS BADGES (large) === */
|
||||
.badge-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
|
||||
@@ -662,6 +873,7 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
.container { padding: 16px; }
|
||||
.header-bar { flex-direction: column; align-items: flex-start; }
|
||||
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
|
||||
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
|
||||
.can-grid { grid-template-columns: 1fr 1fr; }
|
||||
.btn-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user