diff --git a/README.md b/README.md index 4db1c67..f42d7b8 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器) | IP 分配 | DHCP(可切换静态 IP 192.168.31.32)| | 国家码 | CN(允许信道 1-13)| -**冷启动优化**: 复位原因为 POWERON/BROWNOUT 时:电源轨稳定等待 + 仅清 WiFi 连接缓存;**仅 BROWNOUT 后**才清 PHY 强制重校准(避免每次上电全量 RF 校准与 GD32 抢电流导致起不来、需再插拔)。`wifi_start` 前另有短延迟。BOD 阈值略放宽(LVL5)。 +**冷启动**: POWERON/BROWNOUT 时做短延时后初始化;清 WiFi 连接缓存;欠压复位后清 PHY 重校准;BOD 档位 LVL5。 ### mDNS 发现 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 839199a..858e3ce 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -2,6 +2,7 @@ idf_component_register( SRCS "core/main.cpp" # 程序入口 (app_main) + "core/system_boot.cpp" # 启动模块位图与汇总 "ota/esp32_ota_core.cpp" # ESP32 本机 OTA 核心(分区写入/SHA验证) "ota/ota_coordinator.cpp" # OTA 多通道协调器(状态机/互斥) "relay/ota_relay.cpp" # GD32 SPI OTA 中继状态机 diff --git a/main/core/main.cpp b/main/core/main.cpp index 6b72a49..74a492e 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -1,17 +1,27 @@ /* * ESP32-C3FH4 OTA 中继 - 程序入口 - * FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT / CAN 反馈 * - * 使用各模块单例初始化并创建任务;入口只做装配,不实现协议。 + * 初始化顺序(每位成功后 SystemBoot::mark=1,末尾统一 report): + * 1. 冷启动延时 + * 2. netif / event_loop / debug_log + * 3. wifi earlyInit + * 4. health_mon + task + * 5. cpu_stats(可选) + * 6. protocol / ota_coord + * 7. spi init + L0 本机自检 → mark spi;HELLO 对端轻检 → mark can_link(非关键) + * 8. ota_relay / wifi_ota / ble_ota 任务 + * 9. can_fb 任务(依赖 spi L0) + * 10. ota_mark_valid(可选) + * 11. SystemBoot::report() */ #include "freertos/FreeRTOS.h" #include "freertos/task.h" -#include "esp_chip_info.h" #include "esp_system.h" #include "esp_netif.h" #include "esp_event.h" #include "esp_ota_ops.h" +#include "system_boot.h" #include "monitor/health_monitor.h" #include "monitor/cpu_stats.h" #include "relay/ota_relay.h" @@ -25,31 +35,21 @@ #include "ota/esp32_ota_core.h" #include "monitor/can_feedback_task.h" -/** - * @brief 创建关键任务;失败则打 ERR 并 ESP_ERROR_CHECK 触发重启。 - */ -static void createCriticalTask(TaskFunction_t entry, const char *name, +/** @brief 创建关键任务;成功返回 true。 */ +static bool createCriticalTask(TaskFunction_t entry, const char *name, uint32_t stack, UBaseType_t prio) { - BaseType_t xRet = xTaskCreatePinnedToCore( - entry, name, stack, NULL, prio, NULL, 0); - if (xRet != pdPASS) { - DEBUG_ERR("MAIN", "创建关键任务 %s 失败", name); - ESP_ERROR_CHECK(ESP_FAIL); - } - DEBUG_INFO("MAIN", "任务已创建: %s (prio=%u stack=%lu)", - name, (unsigned)prio, (unsigned long)stack); + return xTaskCreatePinnedToCore(entry, name, stack, NULL, prio, NULL, 0) == pdPASS; } /** - * @brief 程序主入口:初始化组件并创建 FreeRTOS 任务。 + * @brief 程序主入口:按模块初始化并汇总结果。 */ extern "C" void app_main(void) { - esp_chip_info_t chip_info; - esp_chip_info(&chip_info); + SystemBoot::reset(); - /* 上电/欠压复位:先等电源与 GD32 侧负载稳定,再进 WiFi/SPI 重活 */ + /* 上电/欠压复位后短延时再初始化子系统 */ { esp_reset_reason_t rr = esp_reset_reason(); if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) { @@ -59,79 +59,86 @@ extern "C" void app_main(void) Esp32OtaCore::initMutex(); - ESP_ERROR_CHECK(esp_netif_init()); - ESP_ERROR_CHECK(esp_event_loop_create_default()); + if (esp_netif_init() == ESP_OK) { + SystemBoot::mark(BOOT_NETIF); + } + + if (esp_event_loop_create_default() == ESP_OK) { + SystemBoot::mark(BOOT_EVENT_LOOP); + } DebugLog::instance().init(); + SystemBoot::mark(BOOT_DEBUG_LOG); - /* earlyInit:true=已启动 STA 连接流程;false=将走 AP(或 CONFIG 直选 AP) */ + /* earlyInit:true=已启动 STA 连接流程;false=将走 AP */ const bool wifi_early_started_sta = WifiOta::instance().earlyInit(); + SystemBoot::mark(BOOT_WIFI_EARLY); HealthMonitor::instance().init(); - DEBUG_INFO("MAIN", "健康监控状态已初始化"); - DEBUG_INFO("MAIN", "========================================"); - DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动", FW_VERSION); - DEBUG_INFO("MAIN", "Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip_info.revision); - DEBUG_INFO("MAIN", "Free heap: %lu bytes", (unsigned long)esp_get_free_heap_size()); - DEBUG_INFO("MAIN", "CPU Freq: %d MHz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ); - DEBUG_INFO("MAIN", "WiFi early: %s(最终 STA/AP 由 wifi_ota 任务确认)", - wifi_early_started_sta ? "STA 连接中" : "非 STA 优先/将切 AP"); - DEBUG_INFO("MAIN", "========================================"); + SystemBoot::mark(BOOT_HEALTH_MON); - /* 健康监控:温度/RSSI;缺失则运维盲区 → 关键 */ - createCriticalTask(HealthMonitor::taskEntry, "health_mon", - TASK_STACK_HEALTH_MON, configMAX_PRIORITIES - 4); + if (createCriticalTask(HealthMonitor::taskEntry, "health_mon", + TASK_STACK_HEALTH_MON, configMAX_PRIORITIES - 4)) { + SystemBoot::mark(BOOT_HEALTH_TASK); + } - /* CPU 1Hz 采样:非致命 */ CpuStats::instance().start(); + SystemBoot::mark(BOOT_CPU_STATS); Protocol::instance().init(); - DEBUG_INFO("MAIN", "协议层初始化完成"); + SystemBoot::mark(BOOT_PROTOCOL); OtaCoordinator::instance().init(); OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance()); - DEBUG_INFO("MAIN", "OTA 全局协调器已初始化"); + SystemBoot::mark(BOOT_OTA_COORD); - /* SPI 是中继/CAN 反馈根基:失败则无法完成核心职责,按关键失败处理 */ + bool spi_l0_ok = false; { esp_err_t ret = SpiMaster::instance().init(); - if (ret != ESP_OK) { - DEBUG_ERR("MAIN", "SPI 总线初始化失败: %s", esp_err_to_name(ret)); - ESP_ERROR_CHECK(ret); - } - DEBUG_INFO("MAIN", "SPI 总线初始化完成"); - } - - createCriticalTask(OtaRelay::taskEntry, "ota_relay", - TASK_STACK_OTA_RELAY, configMAX_PRIORITIES - 2); - - createCriticalTask(WifiOta::taskEntry, "wifi_ota", - TASK_STACK_WIFI_OTA, configMAX_PRIORITIES - 3); - - createCriticalTask(BleOta::taskEntry, "ble_ota", - TASK_STACK_BLE_OTA, configMAX_PRIORITIES - 3); - - if (startCanFeedbackTask()) { - DEBUG_INFO("MAIN", "CAN 反馈轮询任务已创建"); - } else { - DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)"); - } - - /* 任务就绪后尽快标记 app 有效,避免后续探测拖太久导致回滚窗口拉长 */ - { - esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback(); - if (ota_valid_err == ESP_OK) { - DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效"); - } else { - DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动", - esp_err_to_name(ota_valid_err)); + if (ret == ESP_OK && SpiMaster::instance().selfTestL0()) { + SystemBoot::mark(BOOT_SPI); + spi_l0_ok = true; + /* 对端轻检:GD32 可能仍在启动延时,失败不拉垮关键启动 */ + if (SpiMaster::instance().selfTestPeerL0()) { + SystemBoot::mark(BOOT_CAN_LINK); + } } } - DEBUG_INFO("MAIN", "========================================"); - DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中"); - DEBUG_INFO("MAIN", "GD32 版本探测由 ota_relay 任务负责(hello+probe)"); - DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)", - DEBUG_LOG_IP, DEBUG_LOG_PORT); - DEBUG_INFO("MAIN", "========================================"); + if (createCriticalTask(OtaRelay::taskEntry, "ota_relay", + TASK_STACK_OTA_RELAY, configMAX_PRIORITIES - 2)) { + SystemBoot::mark(BOOT_OTA_RELAY_TASK); + } + + if (createCriticalTask(WifiOta::taskEntry, "wifi_ota", + TASK_STACK_WIFI_OTA, configMAX_PRIORITIES - 3)) { + SystemBoot::mark(BOOT_WIFI_TASK); + } + + if (createCriticalTask(BleOta::taskEntry, "ble_ota", + TASK_STACK_BLE_OTA, configMAX_PRIORITIES - 3)) { + SystemBoot::mark(BOOT_BLE_TASK); + } + + /* CAN 反馈依赖 SPI 本机 L0 */ + if (spi_l0_ok && startCanFeedbackTask()) { + SystemBoot::mark(BOOT_CAN_FB_TASK); + } + + if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) { + SystemBoot::mark(BOOT_OTA_VALID); + } + + DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s, heap=%lu, wifi_early=%s", + FW_VERSION, + (unsigned long)esp_get_free_heap_size(), + wifi_early_started_sta ? "STA" : "AP/other"); + + const bool critical_ok = SystemBoot::report(); + + if (!critical_ok) { + DEBUG_ERR("MAIN", "critical init FAIL, restart in 3s"); + vTaskDelay(pdMS_TO_TICKS(3000)); + esp_restart(); + } } diff --git a/main/core/system_boot.cpp b/main/core/system_boot.cpp new file mode 100644 index 0000000..d33820e --- /dev/null +++ b/main/core/system_boot.cpp @@ -0,0 +1,171 @@ +/** + * @file system_boot.cpp + * @brief 启动模块位图实现。 + */ +#include "system_boot.h" +#include "monitor/debug_log.h" + +#include +#include + +namespace { + +struct BootEntry { + uint32_t bit; + const char *name; + bool critical; +}; + +static const BootEntry kEntries[] = { + { BOOT_NETIF, "netif", true }, + { BOOT_EVENT_LOOP, "event_loop", true }, + { BOOT_DEBUG_LOG, "debug_log", true }, + { BOOT_WIFI_EARLY, "wifi_early", true }, + { BOOT_HEALTH_MON, "health_mon", true }, + { BOOT_HEALTH_TASK, "health_task", true }, + { BOOT_CPU_STATS, "cpu_stats", false }, + { BOOT_PROTOCOL, "protocol", true }, + { BOOT_OTA_COORD, "ota_coord", true }, + { BOOT_SPI, "spi", true }, + { BOOT_OTA_RELAY_TASK, "ota_relay_task", true }, + { BOOT_WIFI_TASK, "wifi_task", true }, + { BOOT_BLE_TASK, "ble_task", true }, + { BOOT_CAN_LINK, "can_link", false }, + { BOOT_CAN_FB_TASK, "can_fb_task", false }, + { BOOT_OTA_VALID, "ota_mark_valid", false }, +}; + +static uint32_t s_mask = 0U; + +static void evaluate(bool *critical_ok, bool *all_ok, char *fail_list, size_t fail_cap) +{ + *critical_ok = true; + *all_ok = true; + if (fail_list && fail_cap > 0U) { + fail_list[0] = '\0'; + } + size_t fail_len = 0U; + + for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) { + const BootEntry &e = kEntries[i]; + const bool ok = (s_mask & e.bit) != 0U; + if (ok) { + continue; + } + *all_ok = false; + if (e.critical) { + *critical_ok = false; + } + if (!fail_list || fail_cap == 0U) { + continue; + } + if (fail_len + 2U < fail_cap) { + if (fail_len > 0U) { + fail_list[fail_len++] = ','; + } + const char *p = e.name; + while (*p != '\0' && fail_len + 1U < fail_cap) { + fail_list[fail_len++] = *p++; + } + fail_list[fail_len] = '\0'; + } + } +} + +} // namespace + +void SystemBoot::reset() +{ + s_mask = 0U; +} + +void SystemBoot::mark(SystemBootBit bit) +{ + s_mask |= static_cast(bit); +} + +bool SystemBoot::report() +{ + bool critical_ok = true; + bool all_ok = true; + char fail_list[192] = {0}; + evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list)); + + if (critical_ok && all_ok) { + DEBUG_INFO("BOOT", "init: OK (all modules)"); + } else if (critical_ok) { + DEBUG_WARN("BOOT", "init: OK (critical), skipped/failed: %s", + fail_list[0] != '\0' ? fail_list : "?"); + } else { + DEBUG_ERR("BOOT", "init: FAIL, modules: %s", + fail_list[0] != '\0' ? fail_list : "?"); + } + return critical_ok; +} + +int SystemBoot::appendStatusJson(char *buf, size_t cap, int offset) +{ + if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) { + return offset; + } + + bool critical_ok = true; + bool all_ok = true; + char fail_list[192] = {0}; + evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list)); + + const char *summary; + if (critical_ok && all_ok) { + summary = "OK (all modules)"; + } else if (critical_ok) { + summary = "OK (critical)"; + } else { + summary = "FAIL"; + } + + int n = snprintf(buf + offset, cap - (size_t)offset, + ",\"boot\":{\"critical_ok\":%s,\"all_ok\":%s,\"mask\":%lu," + "\"summary\":\"%s\",\"failed\":\"%s\",\"modules\":[", + critical_ok ? "true" : "false", + all_ok ? "true" : "false", + (unsigned long)s_mask, + summary, + fail_list); + if (n < 0) { + return offset; + } + if (offset + n >= (int)cap) { + buf[cap - 1] = '\0'; + return (int)(cap - 1); + } + offset += n; + + for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) { + const BootEntry &e = kEntries[i]; + const bool ok = (s_mask & e.bit) != 0U; + n = snprintf(buf + offset, cap - (size_t)offset, + "%s{\"name\":\"%s\",\"ok\":%s,\"critical\":%s}", + i == 0 ? "" : ",", + e.name, + ok ? "true" : "false", + e.critical ? "true" : "false"); + if (n < 0) { + return offset; + } + if (offset + n >= (int)cap) { + buf[cap - 1] = '\0'; + return (int)(cap - 1); + } + offset += n; + } + + n = snprintf(buf + offset, cap - (size_t)offset, "]}"); + if (n < 0) { + return offset; + } + if (offset + n >= (int)cap) { + buf[cap - 1] = '\0'; + return (int)(cap - 1); + } + return offset + n; +} diff --git a/main/core/system_boot.h b/main/core/system_boot.h new file mode 100644 index 0000000..bcfb127 --- /dev/null +++ b/main/core/system_boot.h @@ -0,0 +1,50 @@ +/** + * @file system_boot.h + * @brief ESP32 启动阶段模块初始化位图与汇总报告。 + * + * 每个模块 init/建任务成功后 mark(bit)=1,启动末尾 report() 打印整体结果。 + * /system/status 通过 appendStatusJson() 把同一份结果暴露给网页。 + */ +#ifndef SYSTEM_BOOT_H +#define SYSTEM_BOOT_H + +#include +#include +#include + +/** 启动模块位(成功置 1) */ +enum SystemBootBit : uint32_t { + BOOT_NETIF = 1u << 0, + BOOT_EVENT_LOOP = 1u << 1, + BOOT_DEBUG_LOG = 1u << 2, + BOOT_WIFI_EARLY = 1u << 3, + BOOT_HEALTH_MON = 1u << 4, + BOOT_HEALTH_TASK = 1u << 5, + BOOT_CPU_STATS = 1u << 6, + BOOT_PROTOCOL = 1u << 7, + BOOT_OTA_COORD = 1u << 8, + BOOT_SPI = 1u << 9, ///< SPI 主机 L0(本机就绪) + BOOT_OTA_RELAY_TASK = 1u << 10, + BOOT_WIFI_TASK = 1u << 11, + BOOT_BLE_TASK = 1u << 12, + BOOT_CAN_LINK = 1u << 13, ///< SPI 对端 L0(HELLO 可达 GD32,非关键) + BOOT_CAN_FB_TASK = 1u << 14, ///< CAN 反馈任务(依赖 SPI L0) + BOOT_OTA_VALID = 1u << 15, +}; + +class SystemBoot { +public: + static void reset(); + static void mark(SystemBootBit bit); + + /** 串口汇总;返回关键模块是否全部成功 */ + static bool report(); + + /** + * @brief 追加 boot JSON 字段(含前导逗号)到 status 缓冲。 + * @return 新的写入偏移 + */ + static int appendStatusJson(char *buf, size_t cap, int offset); +}; + +#endif /* SYSTEM_BOOT_H */ diff --git a/main/include/app_config.h b/main/include/app_config.h index a4de9a3..cfb79a4 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -56,7 +56,7 @@ inline constexpr uint32_t TASK_STACK_WIFI_OTA = 4096; ///< wifi_ota 主循 inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(轻载 peak~1KB;转发时看 /system/status.task_stack) inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:5KB(帧缓冲热路径;已纳入 task_stack 巡检) inline constexpr uint32_t TASK_STACK_BLE_OTA = 3072; ///< ble_ota 主任务:peak~1KB -inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker:peak~1.1KB(负载峰值曾~1.9KB) +inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.2KB inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~2KB inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:peak~1KB @@ -134,7 +134,7 @@ inline constexpr size_t BLE_OTA_ACK_WINDOW = 32; // Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW) inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32; // 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批 -inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 8); +inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 16); // ============================================================ // 安全常量(需与 GD32 固件团队确认) diff --git a/main/include/version.h b/main/include/version.h index 67b491d..9a4110d 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 49 -#define FW_VERSION_STRING "0.3.49" +#define FW_VERSION_PATCH 50 +#define FW_VERSION_STRING "0.3.50" // 编译时间 -#define BUILD_TIME "2026-07-17 00:10:21" +#define BUILD_TIME "2026-07-17 02:02:16" // Git 信息 -#define GIT_HASH "5db3f66" +#define GIT_HASH "6364eac" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index 6e12cbe..f78fdab 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -21,6 +21,7 @@ #include "transport/spi_master.h" #include "monitor/debug_log.h" #include "monitor/can_feedback_task.h" +#include "esp_system.h" #include @@ -56,6 +57,17 @@ static void taskLoop(void *) { DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)"); + /* 冷启动延后开始监听,错开上电初始化窗口 */ + { + esp_reset_reason_t rr = esp_reset_reason(); + if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) { + constexpr uint32_t kCanFbDeferMs = 1500U; + DEBUG_INFO(kTag, "冷启动: %lu ms 后开始 CAN 反馈监听", + (unsigned long)kCanFbDeferMs); + vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs)); + } + } + uint8_t ring_type = 0U; uint8_t ring_payload[kRingPayloadMax] = {}; size_t ring_len = 0U; diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 9e7d6d2..d9c3d50 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -220,14 +220,12 @@ void HealthMonitor::taskEntry(void *pvParameters) uint32_t free_heap = esp_get_free_heap_size(); if (free_heap < HEAP_WARNING_MIN) { - /* WiFi OTA 收流时 TCP 窗口 + httpd 会把 free 压到 5~10KB,属预期,勿当故障刷屏。 */ + /* 堆告警节流:OTA 期间阈值更低、间隔更长 */ static TickType_t s_last_heap_warn = 0; TickType_t now = xTaskGetTickCount(); const bool ota_busy = !OtaCoordinator::instance().isIdle(); - /* 堆越紧越要少打日志(日志本身还要占堆/UDP) */ const uint32_t interval_ms = ota_busy ? 15000U : (free_heap < 8192U ? 10000U : 3000U); - /* OTA 中只在 free 极低(<4KB)时告警;空闲时维持 10KB 阈值 */ const uint32_t warn_floor = ota_busy ? 4096U : (uint32_t)HEAP_WARNING_MIN; if (free_heap < warn_floor && (now - s_last_heap_warn) >= pdMS_TO_TICKS(interval_ms)) { diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index a223139..4c11d8b 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -1,9 +1,7 @@ /* - * esp32_ota_core.cpp - ESP32 本机 OTA 核心流程实现(WiFi / BLE 通道共用) + * esp32_ota_core.cpp - ESP32 本机 OTA 核心流程(WiFi / BLE 通道共用) * - * 设计依据 WiFi 原流程(wifi_ota.cpp 旧实现),修复已知 bug: - * - 旧 wifi_ota.cpp:625 在 esp_ota_end 成功后又调 esp_ota_abort → 用 m_ended 标志避免 - * - SHA 统一从分区读回计算,不在传输过程中做增量 SHA + * SHA-256 在写入完成后从 OTA 分区读回计算;finalize 用 m_ended 区分 end 与 abort。 */ #include #include diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 09d38fd..846b35d 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -49,10 +49,7 @@ OtaCoordinator& OtaCoordinator::instance() // ============================================================ bool OtaCoordinator::lock() const { - // ★ 修复: 返回 take 结果,由调用方传回 unlock()。 - // 旧实现用成员 bool m_locked 记录 take 结果,多任务并发 lock() 时 - // 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give, - // mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。 + /* 返回 take 是否成功;调用方须把该值传给 unlock() */ if (!m_mutex) return false; bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE); if (!ok) { @@ -64,7 +61,7 @@ bool OtaCoordinator::lock() const void OtaCoordinator::unlock(bool handle) const { - // ★ 修复: 仅在对应的 lock() 成功 take 时才 give。 + /* 仅当对应 lock() 成功时 give */ if (handle && m_mutex) { xSemaphoreGive(m_mutex); } @@ -97,11 +94,7 @@ void OtaCoordinator::watchdogCheckLocked() return; // 未超时 } - // ★ 超时自愈:完整中止到 IDLE(释放 Esp32OtaCore / 中继 / GD32 meta 等)。 - // 旧逻辑只标 ERROR 不清理子系统:若卡在 FINALIZING 且 core 已半释放, - // 前端仍长时间判“占用”;若 core 仍持互斥,ERROR 后 begin 还要靠 forceCleanup。 - // REBOOTING 本应已 esp_restart;超时则同样中止,避免永久卡死。 - DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制中止 → IDLE", + DEBUG_ERR(TAG, "忙状态 %d 超过 %" PRIu32 " ms,强制中止 → IDLE", (int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks)); doAbortLocked(); } @@ -580,9 +573,7 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h if (r.err == ESP_OK) { notifyEsp32FinalizeComplete(); } else { - // ★ SHA 不匹配 / end 失败:Esp32OtaCore 已 abort+释放互斥,但协调器若仍停在 - // ESP32_FINALIZING,WiFi 前端会一直被“设备 OTA 占用”拒绝(BLE 路径曾单独 - // 补 abortCurrent;统一放到这里,WiFi/BLE 共用)。 + /* finalize 失败:复位协调器到 IDLE */ DEBUG_ERR(TAG, "ESP32 finalize 失败: %s,复位协调器 → IDLE", esp_err_to_name(r.err)); abortCurrent(); @@ -758,17 +749,12 @@ OtaSnapshot OtaCoordinator::getSnapshot() const { OtaSnapshot snap{}; - // ── coordinator 自身状态(一次 mutex,避免二次加锁导致快照不一致)── - // 持锁时一次性取 m_sysState,并调不加锁的 stateToString() - // 就地转字符串。旧实现释放锁后再调 stateToString(getSystemState()) 二次加锁, - // 两次取值之间状态可能变化,导致 snap.sys_state 与 snap.sys_state_str - // 描述不同时刻状态。 + /* 持锁一次读取状态并生成字符串,保证快照字段一致 */ bool h = lock(); if (h) { snap.sys_state = m_sysState; snap.channel = m_otaChannel; snap.storage_done = m_gd32StorageDone; - // 就地把状态枚举转字符串,与 sys_state 来自同一时刻 snap.sys_state_str = stateToString(snap.sys_state); unlock(h); } else { diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index 285a0de..38b0892 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -469,20 +469,14 @@ private: OtaCoordinator(const OtaCoordinator&) = delete; OtaCoordinator& operator=(const OtaCoordinator&) = delete; - // 线程安全辅助 - // ★ lock() 返回是否成功 take;unlock(handle) 据此决定是否 give。 - // 旧实现用共享 bool m_locked 记录 take 结果,多任务并发 lock() 时 - // 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give, - // mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。 + // 线程安全:lock() 返回 take 是否成功;unlock(handle) 仅在成功时 give bool lock() const; void unlock(bool handle) const; - // ★ 卡死自愈检查:忙状态超时则 doAbortLocked → IDLE(不含 REBOOTING)。 - // 必须在已持锁状态下调用。 + // 忙状态超时检查(须已持锁);超时则 doAbortLocked → IDLE void watchdogCheckLocked(); - // ★ 已持锁版本的中止序列:根据当前 m_sysState 调用对应子模块的清理, - // 并把状态机复位为 IDLE。abortCurrent / abortCurrentIfEpoch 共享此实现。 + // 已持锁中止:清理子系统并将状态复位为 IDLE void doAbortLocked(); // ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。 diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 6d1e8da..7d6f208 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -1445,26 +1445,22 @@ ota_result_t OtaRelay::doSendFinishOta() return OTA_RESULT_OK; } -/** @brief 协议子步骤:轮询等待 GD32 APP 存活(10 次重试)。 */ +/** @brief 协议子步骤:轮询等待 GD32 APP 存活。 */ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms) { - // ★ APP 存活验证 setGd32AppAlive(false); - // GD32 刚跳转到新 APP,SPI v2 session_id 已重置为 0。 - // 必须先 hello() 建立新 session,否则 appPing 的帧被 otaFindFrame - // 以 SESSION mismatch 拒绝(旧 session_id vs 新 APP session_id=0)。 - bool session_established = false; + /* FINISH 后短暂安静,避免 jump/APP SPI arm 窗口被 HELLO 打乱(对齐既有 300ms 节拍)。 */ + vTaskDelay(pdMS_TO_TICKS(500)); - /* APP 完整初始化(含 EthercatSlave::init)需 5-6 秒,偶发 >10 秒。 - * 增加重试次数到 20、缩短延迟到 300ms,总窗口 ~26 秒, - * APP 启动后最快 300ms 内检测到(原 500ms)。 */ + /* APP 起机约数秒;HELLO 须 role=APP 再建 session,再 appPing。 */ + bool session_established = false; for (uint32_t attempt = 0; attempt < 20; ++attempt) { vTaskDelay(pdMS_TO_TICKS(300)); if (!session_established) { esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP); - if (hr == ESP_OK) { + if (hr == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_APP) { session_established = true; } else { DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1); @@ -1475,6 +1471,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms) if (probeAndCacheAppAlive(timeout_ms)) { break; } + /* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */ + session_established = false; DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1); } @@ -1484,7 +1482,6 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms) return OTA_RESULT_ERR_GD32; } - // 全流程唯一成功总结(版本来自 probe 写入的快照) gd32_version_t ver = getSnapshot().version; DEBUG_INFO("OTA", "GD32 OTA 完成: APP=v%d.%d.%d.%d, BOOT=v%d.%d.%d.%d", ver.app_major, ver.app_minor, ver.app_patch, ver.app_build, @@ -1531,8 +1528,7 @@ void OtaRelay::taskEntry(void *pvParameters) return; } - /* 启动 GD32 探测(原 main.cpp 与此处双份;现统一在本任务): - * APP 完整 init 约 5–6s;先 hello 建 v2 session,再 appPing。 */ + /* 启动 GD32 探测:先 hello 建 v2 session,再 appPing。 */ { DEBUG_INFO("OTA", "启动初始 GD32 探测 (hello+probe)..."); bool session_ok = false; @@ -1540,7 +1536,8 @@ void OtaRelay::taskEntry(void *pvParameters) for (uint32_t attempt = 0; attempt < 20U; ++attempt) { vTaskDelay(pdMS_TO_TICKS(300)); if (!session_ok) { - if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK) { + if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK && + SpiMaster::instance().gd32Role() == V2_ROLE_APP) { session_ok = true; } else { DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U); @@ -1551,6 +1548,7 @@ void OtaRelay::taskEntry(void *pvParameters) alive = true; break; } + session_ok = false; DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/20)", attempt + 1U); } if (!alive) { diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 92e9b36..17731d8 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -238,13 +238,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) } // ============================================================ -// WiFi 共存策略:BLE OTA 传输期间的处理 -// -// 不切换 WiFi 省电模式(保持 WIFI_PS_NONE),让 STA 全程可用, -// 协调器照常以 403 拒绝并发 OTA。ESP32-C3 coex 默认调度已能保证 BLE OTA 可用。 -// (曾尝试切到 WIFI_PS_MAX_MODEM 让出空中时间,但 STA 监听间隔被拉到极大, -// HTTP server 的 TCP 握手/数据包大量丢失或超时,前端 XHR 长时间无响应。) -// 仍保留 setBleOtaActive() 暂停 RSSI 轮询(避免 get_ap_info 的射频活动干扰 BLE)。 +// BLE OTA 期间 WiFi 共存:保持 WIFI_PS_NONE;暂停 RSSI 轮询 // ============================================================ static bool s_wifi_ps_boosted = false; @@ -365,24 +359,28 @@ static int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compat { struct ble_gap_upd_params params = {}; if (windows_compatible) { + /* 15–20 ms:多数 Windows 主机可接受 */ params.itvl_min = 12; params.itvl_max = 16; params.min_ce_len = 0; params.max_ce_len = 0; } else { + /* 7.5–15 ms:勿锁死 7.5,否则 Windows 常拒然后掉到更慢默认值 */ params.itvl_min = 6; - params.itvl_max = 6; + params.itvl_max = 12; params.min_ce_len = 12; - params.max_ce_len = 12; + params.max_ce_len = 24; } params.latency = 0; params.supervision_timeout = 200; int rc = ble_gap_update_params(conn_handle, ¶ms); - DEBUG_INFO(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms rc=%d", + DEBUG_INFO(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms ce=%u..%u rc=%d", windows_compatible ? "Windows兼容" : "高速", params.itvl_min * 1.25f, params.itvl_max * 1.25f, + (unsigned)params.min_ce_len, + (unsigned)params.max_ce_len, rc); return rc; } @@ -646,6 +644,11 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size) esp32_reorder_alloc(); // ★ BLE OTA 开始:降低 WiFi 射频占用 ble_ota_boost_wifi_ps(); + /* 刷写前再要一次高速连接参数(连接时若未谈妥,此处补刀) */ + if (BleOta::instance().m_bleConnected && + BleOta::instance().m_connHandle != 0xFFFFU) { + (void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); + } return ESP_OK; } @@ -1317,6 +1320,10 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, s_ble_gd32_meta.total_size = total_size; s_ble_gd32_meta_active = true; ble_ota_boost_wifi_ps(); + if (BleOta::instance().m_bleConnected && + BleOta::instance().m_connHandle != 0xFFFFU) { + (void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); + } } } @@ -2017,11 +2024,8 @@ esp_err_t BleOta::serverStart() esp_err_t BleOta::suspendStack() { /* - * ★ 软挂起 only(ESP32-C3 WiFi/BLE 共用射频)。 - * 禁止在此调用 nimble_port_stop / deinit / controller_disable: - * - 会从 httpd 任务阻塞甚至打挂 WiFi,表现为「一点击上传就失联」; - * - C3 上 deinit BT 控制器与 WiFi 共存状态机冲突。 - * 软挂起:停广播、断连、拒收新数据,NimBLE host 仍跑、WiFi 不受影响。 + * ESP32-C3 WiFi/BLE 共用射频:此处仅软挂起(停广播、断连、拒收新数据), + * 不调用 nimble_port_stop / deinit / controller_disable。 */ if (m_suspendedForWifiOta) { DEBUG_DBG(TAG, "suspendStack: 已挂起,跳过"); @@ -2146,7 +2150,7 @@ void BleOta::taskEntry(void *pvParameters) "ble_ota_worker", TASK_STACK_BLE_WORKER, // 实测 peak~1.1KB,负载峰值曾~1.9KB NULL, - 10, // 中等优先级,避免被 NimBLE host task 过度抢占 + 12, // 略提高:尽快排空队列,减少 withoutResponse 连发丢包 NULL); if (wk_ok != pdPASS) { DEBUG_ERR(TAG, "OTA 数据工作线程创建失败,BLE OTA 数据传输不可用"); diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index e8a4d0f..502cb0d 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -34,6 +34,7 @@ #include "monitor/can_feedback_task.h" #include "monitor/task_stack_check.h" #include "ble_ota.h" +#include "system_boot.h" static size_t parseHexToBytes(const char *hex, uint8_t *out, size_t max_out) { @@ -641,7 +642,7 @@ esp_err_t can_feedback_handler(httpd_req_t *req) valid[i] = getLatestCanFeedback(i, &results[i]); } - /* ★ 修复:改用静态缓冲区,避免每次请求 malloc 8KB 加剧堆碎片化 */ + /* 静态响应缓冲,减少频繁大块堆分配 */ static char s_feedback_json[8192]; char *resp = s_feedback_json; memset(resp, 0, sizeof(s_feedback_json)); @@ -837,17 +838,15 @@ esp_err_t can_send_handler(httpd_req_t *req) * @brief POST /can/send_batch — 同通道连续发送多帧,单次 HTTP 完成。 * * JSON: { "ch": 0, "frames": [ { "id": n, "dlc": n, "data": "HEX" }, ... ] } - * 返回: { "status":"ok", "results":[ { "status":"ok"|"error", ... }, ... ] } - * 帧间 5ms;暂停 CAN 反馈,避免与批量 SPI 竞态导致偶发崩溃。 + * 返回: { "status":"ok", "results":[ ... ], 可选 skipped/abort_* } + * 帧间间隔 kInterFrameDelayMs;批量期间暂停 CAN 反馈任务。 */ esp_err_t can_send_batch_handler(httpd_req_t *req) { http_set_cors_headers(req); - /* 单次尽量装下整批,避免前端分块导致“一阵一阵” */ constexpr int kMaxBody = 49152; constexpr int kMaxFrames = 128; - /* 成功路径通常 <20ms;失败路径不再拖到数百 ms */ constexpr uint32_t kPerFrameTimeoutMs = 80U; constexpr uint32_t kInterFrameDelayMs = 5U; @@ -906,7 +905,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) cJSON_AddStringToObject(out_root, "status", "ok"); cJSON_AddItemToObject(out_root, "results", results); - /* 暂停 0xC1 反馈抢 SPI,降低 worker 竞态 / 长时间占线导致的 WDT 风险 */ + /* 批量发送期间暂停 CAN 反馈,独占 SPI 业务路径 */ pauseCanFeedbackTask(true); auto fill_err = [](cJSON *item, int code, uint8_t gd32_err) { @@ -918,11 +917,6 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) cJSON_AddNumberToObject(item, "gd32_err", gd32_err); }; - /* 中途致命错误后不再逐帧 SPI 重试(否则每帧再等一整段超时) */ - bool abort_rest = false; - int abort_code = -2; - uint8_t abort_gd32 = 0U; - for (int i = 0; i < frame_count; i++) { (void)esp_task_wdt_reset(); @@ -932,11 +926,6 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) } cJSON_AddItemToArray(results, item); - if (abort_rest) { - fill_err(item, abort_code, abort_gd32); - continue; /* 无 SPI、无帧间延时 */ - } - cJSON *fr = cJSON_GetArrayItem(frames, i); if (!cJSON_IsObject(fr)) { fill_err(item, -1, 0); @@ -987,25 +976,22 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) cJSON_AddStringToObject(item, "recv", resp_hex); } else { fill_err(item, resp_len, gd32_err); - /* SPI 失败 / BUS_OFF / TEC 过高:后面不再逐帧 SPI,也不再为每帧分配 cJSON */ + /* SPI 失败 / BUS_OFF / TEC 过高:中止剩余帧,由 skipped 字段告知前端 */ const bool fatal_spi = (resp_len == -2); const bool fatal_bus = (resp_len == -3) && (gd32_err == 0xE2U || gd32_err == 0xE3U); if (fatal_spi || fatal_bus) { - abort_rest = true; - abort_code = resp_len; - abort_gd32 = gd32_err; const int skipped = frame_count - i - 1; if (skipped > 0) { cJSON_AddNumberToObject(out_root, "skipped", skipped); - cJSON_AddNumberToObject(out_root, "abort_code", abort_code); - cJSON_AddNumberToObject(out_root, "abort_gd32_err", abort_gd32); + cJSON_AddNumberToObject(out_root, "abort_code", resp_len); + cJSON_AddNumberToObject(out_root, "abort_gd32_err", gd32_err); } - break; /* 不再为剩余帧 CreateObject,避免堆被打穿 */ + break; } } - if (i + 1 < frame_count && !abort_rest) { + if (i + 1 < frame_count) { vTaskDelay(pdMS_TO_TICKS(kInterFrameDelayMs)); } } @@ -1018,9 +1004,8 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) cJSON_Delete(out_root); out_root = nullptr; if (!printed) { - /* 堆已紧时勿再走大段错误路径分配;给前端最小 JSON */ static const char kOom[] = - "{\"status\":\"error\",\"code\":\"json_print\",\"message\":\"JSON输出失败(堆不足)\"}"; + "{\"status\":\"error\",\"code\":\"json_print\",\"message\":\"JSON输出失败\"}"; httpd_resp_set_status(req, "500 Internal Server Error"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, kOom, HTTPD_RESP_USE_STRLEN); @@ -1104,7 +1089,8 @@ esp_err_t spi_send_handler(httpd_req_t *req) static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; /* 状态 JSON 用静态缓冲:OTA 后 largest free 常 <2KB,calloc(8KB) 会 500 "no memory"。 */ -static constexpr size_t SYSTEM_STATUS_JSON_CAP = 6144; +/* 含 boot 模块列表与 CPU 任务;OTA 后 largest free 常偏紧,仍用静态缓冲避免 calloc */ +static constexpr size_t SYSTEM_STATUS_JSON_CAP = 7168; static char s_status_json[SYSTEM_STATUS_JSON_CAP]; static SemaphoreHandle_t s_status_json_mux = nullptr; static portMUX_TYPE s_status_json_init_mux = portMUX_INITIALIZER_UNLOCKED; @@ -1344,6 +1330,9 @@ esp_err_t system_status_handler(httpd_req_t *req) (unsigned long)ls.stats.max_resp_latency_us); } + /* 启动自检位图(与串口 BOOT report 同源) */ + len = SystemBoot::appendStatusJson(resp, SYSTEM_STATUS_JSON_CAP, len); + // 前导逗号与前一块分隔;之前因 append_cpu_status_json 尾随逗号叠加 // 形成 "],," 导致前端 JSON.parse 报 "Expected double-quoted property name" len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, ",\"task_stack\": {"); diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 65cc9ed..5ac7cc9 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -313,12 +313,9 @@ esp_err_t WifiOta::initSta() ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); - // ★ 冷启动时在 wifi_start 前增加延迟 - // 长时间断电后首次上电,WiFi PHY 校准需要额外时间 - // esp_wifi_start() 内部会触发 PHY 初始化和校准 - // 如果校准未完成就启动射频,可能导致连接失败 + /* 冷启动时在 wifi_start 前短延时 */ if (WifiOta::instance().m_coldBoot) { - DEBUG_INFO("WIFI", "冷启动: wifi_start 前再等 300ms..."); + DEBUG_INFO("WIFI", "冷启动: wifi_start 前等待 300ms"); vTaskDelay(pdMS_TO_TICKS(300)); } @@ -400,16 +397,11 @@ esp_err_t WifiOta::httpServerStart() config.server_port = HTTP_SERVER_PORT; config.stack_size = HTTP_SERVER_STACK_SIZE; config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检 - /* LWIP_MAX_SOCKETS=12;HTTP 内部约 3 + UDP 日志 1 → 业务 socket 不宜过大。 - * 上传期间前端必须停 status/logs(见 app.js pausePollingForUpload)。 - * ★ 修复:增加到 7,防止浏览器超时 abort 后旧连接占满所有槽位。 */ + /* LWIP_MAX_SOCKETS=12;HTTP 内部约占 3 + UDP 日志 1;业务连接上限 7 */ config.max_open_sockets = 7; - /* ★ 修复:启用 LRU 淘汰,当所有 socket 被占满时主动关闭最旧空闲连接。 - * OTA 上传期间前端已暂停轮询(pausePollingForUpload),不会踢掉 upload 连接。 - * 若仍担心极端情况,可在 ota_upload_handler 入口设置该 session 的更长超时。 */ + /* 满连接时淘汰最久未用的空闲 socket */ config.lru_purge_enable = true; - /* ★ 修复:缩短超时,加速释放废弃连接。 - * OTA 写 Flash 期间 handler 本身持有连接不会被超时踢(httpd 只在 idle 时检测)。 */ + /* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉 */ config.recv_wait_timeout = 10; config.send_wait_timeout = 10; @@ -551,10 +543,9 @@ bool WifiOta::earlyInit() "OTHER", WifiOta::instance().m_coldBoot ? "是" : "否"); - /* 与 GD32/CAN 收发器同板上电时,电源上升沿慢 + 负载大。 - * 若立刻清 PHY/开 WiFi 全量校准,易触发 brownout,表现为「上电起不来,拔插才好」。 */ + /* 冷启动:电源与外设上电完成后再初始化 NVS/WiFi */ if (WifiOta::instance().m_coldBoot) { - DEBUG_INFO("WIFI", "冷启动: 电源轨稳定等待 400ms(再做 NVS/WiFi)..."); + DEBUG_INFO("WIFI", "冷启动: 等待 400ms 后初始化 NVS/WiFi"); vTaskDelay(pdMS_TO_TICKS(400)); } @@ -576,9 +567,7 @@ bool WifiOta::earlyInit() DEBUG_INFO("WIFI", "NVS 初始化成功"); } - // ★ 冷启动:只清 WiFi 连接缓存;勿在每次 POWERON 都清 PHY。 - // 全量 PHY 校准电流大,与 GD32 同时上电易 brownout → 必须再插拔才能起来。 - // 仅在欠压复位后清 PHY,避免校准脏数据死循环。 + /* 冷启动清理 WiFi 连接缓存;欠压复位后再清 PHY 强制重校准 */ if (WifiOta::instance().m_coldBoot) { err = clearNvsNamespace("nvs.net80211"); if (err == ESP_OK) { @@ -589,7 +578,7 @@ bool WifiOta::earlyInit() if (reset_reason == ESP_RST_BROWNOUT) { err = clearNvsNamespace("phy"); if (err == ESP_OK) { - DEBUG_WARN("WIFI", "BROWNOUT 后: 已清除 PHY 校准,将重新校准"); + DEBUG_WARN("WIFI", "欠压复位后: 已清除 PHY 校准数据"); } } } diff --git a/main/storage/gd32_flash_store.h b/main/storage/gd32_flash_store.h index 10e7f9a..2762d92 100644 --- a/main/storage/gd32_flash_store.h +++ b/main/storage/gd32_flash_store.h @@ -158,15 +158,13 @@ private: uint32_t m_erasedUpTo = 0; esp_err_t ensureErasedForWrite(uint32_t offset, size_t len); - // ── NVS 互斥锁(保护元数据读-改-写原子性)── - // lockNvs 返回是否成功 take;unlockNvs(handle) 据此决定是否 give, - // 参考 OtaCoordinator::lock/unlock 模式(避免共享 bool 被并发覆盖导致死锁)。 + // NVS 互斥:lockNvs 返回 take 是否成功;unlockNvs(handle) 仅成功时 give SemaphoreHandle_t m_nvsMutex = nullptr; bool lockNvs(uint32_t timeout_ms); void unlockNvs(bool handle); static constexpr uint32_t kNvsLockTimeoutMs = 100; ///< NVS 加锁超时(ms) - // 已持锁版本:由持锁的公开方法或 metaUpdate 调用,不再自行加锁,避免递归 take 死锁 + // 已持锁版本(调用方已持 m_nvsMutex) esp_err_t metaLoadLocked(ota_meta_t *meta); esp_err_t metaSaveLocked(const ota_meta_t *meta); }; diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 1e92236..0b44c0e 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -64,7 +64,7 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, &resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms); if (ret != ESP_OK) { - /* 批量失败时勿每帧打日志:UDP/格式化会把堆打穿 */ + /* 同类错误节流日志,降低高频失败时的日志开销 */ static uint32_t s_spi_fail_n = 0U; static int s_last_spi_err = 0; const int err_i = static_cast(ret); diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 799db14..456139d 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -295,7 +295,7 @@ esp_err_t SpiMaster::submitJob(Job &job) return job.result; } - /* 堆 Job + 动态 binary sem:避免调用方超时返回后栈上 Job/done 被 worker UAF。 */ + /* 跨任务作业:堆上 Job + binary 完成信号量 */ Job *heap_job = static_cast(calloc(1, sizeof(Job))); if (heap_job == nullptr) { DEBUG_ERR("SPI", "submitJob: calloc failed kind=%u", (unsigned)job.kind); @@ -519,8 +519,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len, t.tx_buffer = tx_ptr; t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL; - /* 禁止 portMAX_DELAY:SPI 硬件/从机异常时 worker 会永久卡死, - * 表现即 OTA「转发进度 1/1117 后完全沉默」。所有等待必须有上界。 */ + /* SPI 事务等待使用有界超时 */ const uint32_t effective_ms = (timeout_ms == 0U) ? 500U : (timeout_ms < 50U ? 50U : timeout_ms); const TickType_t queue_wait = pdMS_TO_TICKS(effective_ms); @@ -544,7 +543,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len, ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, result_wait); if (ret != ESP_OK) { /* 再给一次有界抢救(最多 1s),清掉可能残留的 in-flight 事务。 - * 旧代码用 portMAX_DELAY —— GD32 不响应时 SPI worker 永久死锁。 */ + * 使用有界超时,避免对端无响应时 worker 永久阻塞。 */ DEBUG_WARN("SPI", "spi_device_get_trans_result timeout: %s len=%u, bounded retry 1s", esp_err_to_name(ret), (unsigned)(total_len * 8U)); ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, pdMS_TO_TICKS(1000)); @@ -740,10 +739,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, */ esp_err_t SpiMaster::init() { - // ★ 修复:先创建 mutex,再使用 LockGuard。 - // 旧代码在 LockGuard 构造时 m_mutex 仍为 NULL, - // 虽然 LockGuard 会跳过 NULL take/give,但在极端情况下 - // 其他任务可能在 mutex 创建后立即尝试 take,产生竞态。 + /* 先创建互斥锁,再进入临界区 */ if (!m_mutex) { m_mutex = xSemaphoreCreateMutex(); if (m_mutex == NULL) { @@ -840,9 +836,7 @@ esp_err_t SpiMaster::init() m_stats = SpiLinkStats{}; m_eventHead = m_eventTail = m_eventCount = 0; - /* job 队列 + 事件信号量 + SPI worker。 - * worker 启动后可能立刻抢 m_mutex;init 持锁时 worker 会阻塞在 LockGuard, - * init 返回释放锁后 worker 继续 —— 不会死锁。 */ + /* 创建 job 队列、事件信号量与 SPI worker(init 仍持 m_mutex) */ if (m_jobQueue == nullptr) { m_jobQueue = xQueueCreate(kJobQueueLen, sizeof(Job *)); if (m_jobQueue == nullptr) { @@ -871,6 +865,24 @@ esp_err_t SpiMaster::init() return ESP_OK; } +bool SpiMaster::selfTestL0() const +{ + return (m_spiDevice != nullptr) && (m_mutex != nullptr) && + (m_workerTask != nullptr) && (m_jobQueue != nullptr); +} + +bool SpiMaster::selfTestPeerL0() +{ + /* 短超时、最多 2 次;不拉长整体启动 */ + for (uint32_t attempt = 0U; attempt < 2U; ++attempt) { + if (hello(600U, 0U) == ESP_OK) { + return true; + } + vTaskDelay(pdMS_TO_TICKS(50)); + } + return false; +} + // ============================================================ // SPI OTA 协议 // ============================================================ @@ -1228,12 +1240,11 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t * ESP_OK - 读到过期帧(正常,忽略) * ESP_ERR_INVALID_RESPONSE - 读到 0xFF(正常,延迟一拍) * 其他 - SPI 真实错误 */ - /* 将总超时切成 send + poll,禁止 poll 单独再给 500ms(短超时批量时会「失败卡很久」) */ + /* 总超时在 send 与 poll 之间分配,二者之和不超过 timeout_ms */ uint32_t send_timeout = (timeout_ms > 500U) ? 500U : timeout_ms; if (send_timeout == 0U) { send_timeout = 1U; } - /* 至少给 poll 留一点时间;总超时很短时 send/poll 各半 */ uint32_t poll_timeout; if (timeout_ms > send_timeout) { poll_timeout = timeout_ms - send_timeout; @@ -1244,7 +1255,7 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t } poll_timeout = timeout_ms - send_timeout; } else { - poll_timeout = timeout_ms; /* 极短超时:发完立刻 poll,总预算仍受 deadline 约束 */ + poll_timeout = timeout_ms; } if (poll_timeout == 0U) { poll_timeout = 1U; @@ -1257,7 +1268,7 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t return send_ret; } - /* 等 GD32 完成 onCsRelease 再 poll;5ms 兼顾「能发出」与批量节奏 */ + /* CS 释放后短延时,再 poll 应答 */ vTaskDelay(pdMS_TO_TICKS(5)); /* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */ @@ -1707,18 +1718,11 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) } m_gd32Role = pload[4]; - m_localCredit = pload[5]; - m_remoteCredit = hdr.credit; + /* payload[5]/hdr.credit 均为对端 credit_max → 写入 m_remoteCredit。 + * 勿写入 m_localCredit(本端 RX 窗口,init 为 8)。 */ + m_remoteCredit = (pload[5] != 0U) ? pload[5] : hdr.credit; m_v2Active = true; m_stats.resyncs++; - if (role_hint == 0U || m_gd32Role == role_hint || m_gd32Role == V2_ROLE_BOOT) { - DEBUG_INFO("SPI", "HELLO success: session=0x%08lX role=%u credit=%u attempt=%lu", - (unsigned long)m_sessionId, m_gd32Role, m_localCredit, - (unsigned long)attempt); - } else { - DEBUG_DBG("SPI", "HELLO ok role=%u (want hint=%u) attempt=%lu", - m_gd32Role, role_hint, (unsigned long)attempt); - } return ESP_OK; } diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 9c896f9..702a781 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -100,17 +100,13 @@ public: TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; } /** - * @brief OTA 中继期间禁止 worker 按铃读。 - * - * PB6 在 OTA 下也会因 WAIT/ACK 拉低。若 worker 在 job 间隙把 RESPONSE - * 当 EVENT 偷读,业务 poll 只能空等 → 约 10s 后 DATA 重发 → GD32 DUP-SEQ, - * 表现为刷写断断续续。中继开始设 true,结束/复位设 false。 + * @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。 + * 中继开始设 true,结束/复位设 false。 */ void setSuppressRingService(bool suppress); /** - * @brief OTA 业务层确认当前请求已成功(结构化 ACK 校验通过)。 - * 在 SPI 头 ack_seq 偶发失配但仍接受业务 ACK 时补齐 m_txSeq,避免后续包卡死。 + * @brief 业务层确认当前请求已成功时推进/对齐 m_txSeq。 */ void confirmTxSeqAccepted(); @@ -190,6 +186,17 @@ public: /** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */ esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr); + /** + * @brief L0 本机自检(不依赖 GD32):SPI 设备/互斥/worker/队列已就绪。 + */ + bool selfTestL0() const; + + /** + * @brief L0 对端轻检:短超时 HELLO(role=any),确认 SPI 链路可达 GD32。 + * @note GD32 仍在启动延时中时可能失败,属非关键项。 + */ + bool selfTestPeerL0(); + private: /** * @brief 私有默认构造函数。 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 1a63beb..4c27c1a 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -84,6 +84,6 @@ CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=16 CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0 CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200 -# 与 GD32 同板上电:略放宽欠压阈值,减少上电瞬间复位(仍保留 BOD) +# Brownout detector threshold (LVL5) CONFIG_ESP_BROWNOUT_DET=y CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5=y diff --git a/tools/can.html b/tools/can.html index 90e054a..4b56e0b 100644 --- a/tools/can.html +++ b/tools/can.html @@ -4,11 +4,11 @@ ESP32 CAN 通信控制台 - - - - - + + + + +
@@ -318,6 +318,6 @@ 操作成功
- + diff --git a/tools/css/components.css b/tools/css/components.css index 71bbcb4..c17dd5b 100644 --- a/tools/css/components.css +++ b/tools/css/components.css @@ -122,6 +122,9 @@ font-size: 20px; font-weight: 700; word-break: break-word; } .metric .v.highlight { color: var(--accent); } +.metric .v.ok { color: var(--green); } +.metric .v.warn { color: var(--amber); } +.metric .v.err { color: var(--red); } .status-kv-grid { display: grid; @@ -150,7 +153,134 @@ 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; } + +/* === 健康状态 → 启动自检(点击展开,非常驻) === */ +.badge-stat-click { + cursor: pointer; + user-select: none; + transition: border-color .15s ease, background .15s ease; +} +.badge-stat-click:hover, +.badge-stat-click:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated)); +} +.badge-stat-click.is-open { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); +} +.health-boot-hint { + font-size: 9px; + letter-spacing: 0; + text-transform: none; + color: var(--text-dim); + font-weight: 500; + margin-left: 2px; +} +.badge-stat-click.is-open .health-boot-hint { + color: var(--accent); +} +.health-boot-panel { + margin-top: 12px; + padding: 12px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); +} +.health-boot-panel.collapsed, +.health-boot-panel[hidden] { + display: none; +} +.health-boot-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} +.health-boot-title { + font-size: 11px; + color: var(--text-label); + text-transform: uppercase; + letter-spacing: .08em; + font-weight: 600; +} +.health-boot-summary { + font-size: 13px; + font-weight: 700; + color: var(--text); + text-align: right; +} +.health-boot-summary.ok { color: var(--green); } +.health-boot-summary.warn { color: var(--amber); } +.health-boot-summary.err { color: var(--red); } +.health-boot-failed { + font-size: 12px; + color: var(--text-dim); + margin-bottom: 8px; +} +.health-boot-failed b { + color: var(--text); + font-weight: 600; +} +.health-boot-failed b.ok { color: var(--green); } +.health-boot-failed b.warn { color: var(--amber); } +.health-boot-failed b.err { color: var(--red); } + +/* === BOOT MODULE CHIPS (/system/status boot) === */ +.boot-module-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; + min-height: 8px; +} +.boot-mod { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-root); + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + line-height: 1.2; +} +.boot-mod .boot-mod-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); + flex-shrink: 0; +} +.boot-mod.ok { + color: var(--green); + border-color: color-mix(in srgb, var(--green) 35%, var(--border)); +} +.boot-mod.ok .boot-mod-dot { background: var(--green); } +.boot-mod.fail { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 35%, var(--border)); +} +.boot-mod.fail .boot-mod-dot { background: var(--red); } +.boot-mod.fail.optional { + color: var(--amber); + border-color: color-mix(in srgb, var(--amber) 35%, var(--border)); +} +.boot-mod.fail.optional .boot-mod-dot { background: var(--amber); } + +.status-section-title { + margin-top: 14px; + margin-bottom: 8px; + font-size: 11px; + color: var(--text-label); + text-transform: uppercase; + letter-spacing: .08em; + font-family: var(--font); + font-weight: 600; +} /* ===== 堆概况 ===== */ .heap-group { @@ -179,6 +309,7 @@ text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; + font-family: var(--font); } .heap-bar-row { @@ -196,6 +327,7 @@ font-weight: 600; white-space: nowrap; color: var(--text); + font-family: var(--font); } /* 百分比数值与文案同一行;进度条单独占下一行 */ @@ -208,6 +340,7 @@ text-align: right; font-variant-numeric: tabular-nums; color: var(--text-dim); + font-family: var(--font-mono); } .heap-bar-track { @@ -257,6 +390,39 @@ font-size: 11px; } +/* 抽屉内数值/任务名/JSON 强制等宽,避免被其它样式冲掉 */ +.sys-drawer-body .heap-bar-pct, +.sys-drawer-body .heap-detail-row b, +.sys-drawer-body .status-task-name, +.sys-drawer-body .status-task-ratio, +.sys-drawer-body .status-task-meta b, +.sys-drawer-body .status-json { + font-family: var(--font-mono); +} +.sys-drawer-body .status-section-title, +.sys-drawer-body .heap-group-title, +.sys-drawer-body .heap-bar-text { + font-family: var(--font); + color: inherit; +} +.sys-drawer-body .status-section-title { + color: var(--text-label); + font-size: 11px; + font-weight: 600; +} +.sys-drawer-body .heap-bar-text { + color: var(--text); + font-size: 13px; + font-weight: 600; +} +.sys-drawer-body .status-json { + color: var(--text-dim); + font-size: 11px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + .status-task-list { display: grid; gap: 8px; diff --git a/tools/index.html b/tools/index.html index 0c4c716..44da766 100644 --- a/tools/index.html +++ b/tools/index.html @@ -9,11 +9,11 @@ ESP32 OTA 中继控制台 - - - - - + + + + +
@@ -244,9 +244,11 @@
离线
连接状态
-
+
offline
@@ -257,6 +259,15 @@
GATT 状态
+ + @@ -425,8 +436,8 @@ 操作成功 - - - + + + diff --git a/tools/js/ble/constants.js b/tools/js/ble/constants.js index 9f18d64..b1c7c0c 100644 --- a/tools/js/ble/constants.js +++ b/tools/js/ble/constants.js @@ -10,7 +10,7 @@ export const UUIDS = { version: 0x0004, }; -export const UI_BUILD = '2026-07-16-ble-begin-ready'; +export const UI_BUILD = '2026-07-18-ble-fix'; export const BLE_OTA_NOTIFY_ACK = 0xA1; export const BLE_OTA_NOTIFY_FINISH = 0xB1; export const BLE_OTA_NOTIFY_SHA256 = 0xC1; diff --git a/tools/js/ble/gatt.js b/tools/js/ble/gatt.js index e04f4eb..8dd4fcb 100644 --- a/tools/js/ble/gatt.js +++ b/tools/js/ble/gatt.js @@ -199,8 +199,10 @@ export function handleNotify(event) { updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes); } - const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`; - bleLog('ACK', ackText); + /* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程 */ + if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) { + bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`); + } // ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK // 不再要求 ackSeq >= expectedSeq,因为 ESP32 可能用较低的 seq 发送 ACK diff --git a/tools/js/ble/transfer.js b/tools/js/ble/transfer.js index 14f8eb7..65c0288 100644 --- a/tools/js/ble/transfer.js +++ b/tools/js/ble/transfer.js @@ -238,6 +238,7 @@ export async function uploadFirmware() { continue; } + /* Windows WebBT 同时只允许一个 GATT 操作,必须逐包 await,禁止 Promise.all 并发写 */ await state.dataInChar.writeValueWithoutResponse(packet); } diff --git a/tools/js/can.js b/tools/js/can.js index 1e50645..249fbf9 100644 --- a/tools/js/can.js +++ b/tools/js/can.js @@ -20,7 +20,7 @@ import { state, el, getBaseUrl, getDeviceIp, setDeviceIp, parseDbc, readDbcText, buildSummary, rebuildMergedDbc, makeDbcSourceId -} from './can/dbc.js?v=20260717w'; +} from './can/dbc.js?v=20260717x'; import { updateDbcFileInfo, filterMessages, @@ -30,12 +30,11 @@ import { recomputeTransmitterFilterEmpty, updateNodeFilterLabel, updateBatchUi -} from './can/messages.js?v=20260717w'; +} from './can/messages.js?v=20260717x'; import { ensureMessageDefaults, - renderSelectedMessage, updateEncodedPreview -} from './can/signals.js?v=20260717w'; +} from './can/signals.js?v=20260717x'; import { renderHistory, sendSelectedMessage, @@ -44,15 +43,15 @@ import { sendManualFrame, resetManualFields, loadPreviewToManual -} from './can/send.js?v=20260717w'; +} from './can/send.js?v=20260717x'; import { scheduleCanPersist, loadCanSession, bindCanPersistLifecycle -} from './can/persist.js?v=20260717w'; -import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717w'; -import { showToast } from './common/toast.js?v=20260717w'; -import { initCanMonitor } from './can_monitor.js?v=20260717w'; +} from './can/persist.js?v=20260717x'; +import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717x'; +import { showToast } from './common/toast.js?v=20260717x'; +import { initCanMonitor } from './can_monitor.js?v=20260717x'; // ==================== 本地工具 ==================== // 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h), @@ -63,10 +62,10 @@ import { initCanMonitor } from './can_monitor.js?v=20260717w'; function cacheElements() { [ 'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData', - 'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount', + 'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcMessageCount', 'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcFileCount', 'dbcFileChips', 'dbcSearch', 'dbcHint', 'clearDbcBtn', - 'importDbcBtn', 'replaceDbcBtn', + 'importDbcBtn', 'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint', 'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn', 'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco', @@ -149,11 +148,10 @@ function afterDbcSourcesChanged(opts) { if (opts.resetSelection || !state.selectedMessageKey) { selectMessage(state.dbc.messages[0].key); } else { - renderSelectedMessage(); updateEncodedPreview(); } } else { - renderSelectedMessage(); + updateEncodedPreview(); } if (el.dbcHint) { el.dbcHint.hidden = true; @@ -268,7 +266,6 @@ function bindEvents() { if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value; if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; renderMessageList(); - renderSelectedMessage(); updateEncodedPreview(); scheduleCanPersist(); }); @@ -277,7 +274,6 @@ function bindEvents() { if (el.dbcFileInput) el.dbcFileInput.click(); } if (el.importDbcBtn) el.importDbcBtn.addEventListener('click', openDbcPicker); - if (el.replaceDbcBtn) el.replaceDbcBtn.addEventListener('click', openDbcPicker); if (el.dbcDropZone) { el.dbcDropZone.addEventListener('dragover', function (event) { event.preventDefault(); @@ -389,7 +385,7 @@ function init() { selectMessage(state.dbc.messages[0].key); } } else { - renderSelectedMessage(); + updateEncodedPreview(); } updateBatchUi(); refreshManualHint(); diff --git a/tools/js/can/dbc.js b/tools/js/can/dbc.js index 68d3f96..686eb80 100644 --- a/tools/js/can/dbc.js +++ b/tools/js/can/dbc.js @@ -4,7 +4,7 @@ * 包含:共享 state/el、基础工具、DBC 解析、cssEscape、readDbcText。 * * 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出, - * 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。 + * 供 messages/signals/send 共享(ES Module 的 live binding 保证引用一致)。 */ // ==================== 共享状态 ==================== diff --git a/tools/js/can/feedback.js b/tools/js/can/feedback.js deleted file mode 100644 index ede4f76..0000000 --- a/tools/js/can/feedback.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * feedback.js - 1Hz 反馈轮询(预留模块) - * - * 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。 - * 1Hz 轮询 /can/feedback 或 ID 快照功能可在本模块按需实现。 - */ -import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js'; - -// 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。 -export { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow }; diff --git a/tools/js/can/messages.js b/tools/js/can/messages.js index 5413264..d01fd4c 100644 --- a/tools/js/can/messages.js +++ b/tools/js/can/messages.js @@ -5,12 +5,12 @@ * * 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、 * common/utils.js(escapeHtml/formatBytes)、 - * signals.js(renderInlineSignalGrid/ensureMessageDefaults/renderSelectedMessage/updateEncodedPreview)。 + * signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。 */ -import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717v'; -import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717v'; -import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js?v=20260717v'; -import { scheduleCanPersist } from './persist.js?v=20260717v'; +import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717x'; +import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717x'; +import { renderInlineSignalGrid, ensureMessageDefaults, updateEncodedPreview } from './signals.js?v=20260717x'; +import { scheduleCanPersist } from './persist.js?v=20260717x'; // ==================== DBC 文件信息 ==================== /** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */ @@ -395,7 +395,6 @@ export function selectMessage(key) { state.selectedMessageKey = key; ensureMessageDefaults(msg); renderMessageList(); - renderSelectedMessage(); updateEncodedPreview(); scheduleCanPersist(); } diff --git a/tools/js/can/persist.js b/tools/js/can/persist.js index 22d9a69..02209f7 100644 --- a/tools/js/can/persist.js +++ b/tools/js/can/persist.js @@ -6,7 +6,7 @@ * * 依赖:dbc.js(state/el/parseDbc/rebuildMergedDbc) */ -import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717v'; +import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717x'; var LS_KEY = 'dcu_can_session_v1'; var MAX_HISTORY = 200; @@ -135,17 +135,6 @@ export function scheduleCanPersist() { }, SAVE_DEBOUNCE_MS); } -/** @description 清空持久化(全部清除 DBC 时)。 */ -export function clearCanSessionPersist() { - if (saveTimer) { - clearTimeout(saveTimer); - saveTimer = null; - } - try { - localStorage.removeItem(LS_KEY); - } catch (_) {} -} - /** * @description 从 localStorage 恢复会话到 state + 表单。 * @returns {{ restored: boolean, dbcCount: number, historyCount: number }} diff --git a/tools/js/can/send.js b/tools/js/can/send.js index 770afbc..494bac5 100644 --- a/tools/js/can/send.js +++ b/tools/js/can/send.js @@ -12,12 +12,12 @@ * 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。 */ -import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717w'; -import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717w'; -import { showToast } from '../common/toast.js?v=20260717w'; -import { updateBatchUi } from './messages.js?v=20260717w'; -import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717w'; -import { scheduleCanPersist } from './persist.js?v=20260717w'; +import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717x'; +import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x'; +import { showToast } from '../common/toast.js?v=20260717x'; +import { updateBatchUi } from './messages.js?v=20260717x'; +import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717x'; +import { scheduleCanPersist } from './persist.js?v=20260717x'; // ==================== 本地工具(保持与原 can.js 一致) ==================== function formatTime(date) { @@ -109,7 +109,7 @@ async function sendCanBatchRequest(channel, frames) { if (!json.results || !Array.isArray(json.results)) { throw new Error('批量响应缺少 results'); } - /* 设备中途 fatal 会 skipped 剩余帧(少分配堆);前端补齐等长 results */ + /* 设备可能返回 skipped;补齐与 frames 等长的 results */ var results = json.results.slice(); var need = frames.length - results.length; if (need > 0) { @@ -172,12 +172,12 @@ export function renderHistory() { } // ==================== 批量发送 ==================== -/** 单批最多帧数(与设备 /can/send_batch 上限 128 对齐;一次 HTTP 连发,避免分块成“一阵一阵”) */ +/** 单批最多帧数(与设备 /can/send_batch 上限一致) */ var BATCH_CHUNK = 128; /** * @description 批量发送所有勾选的报文。 - * 优先一次 HTTP /can/send_batch(设备侧帧间 5ms);失败则回退单帧(无额外间隔)。 + * 优先 POST /can/send_batch;不支持时回退逐帧 /can/send。 * 批量过程中不重绘历史,结束后统一刷新。 */ export async function sendCheckedMessages() { diff --git a/tools/js/can/signals.js b/tools/js/can/signals.js index 111897e..e6840ea 100644 --- a/tools/js/can/signals.js +++ b/tools/js/can/signals.js @@ -1,14 +1,13 @@ /** * signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical) * - * 包含:信号行渲染、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作、 - * renderSelectedMessage shim。 + * 包含:行内信号表格、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作。 * * 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。 */ -import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717v'; -import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717v'; -import { scheduleCanPersist } from './persist.js?v=20260717v'; +import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717x'; +import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x'; +import { scheduleCanPersist } from './persist.js?v=20260717x'; // ==================== 信号值存取 ==================== /** @description 确保报文有 signalValues bag,并为每个信号填充默认值。返回 bag。 */ @@ -81,15 +80,6 @@ export function getActiveSignals(message) { }); } -// ==================== 旧版 shim ==================== -/** - * @description 旧版"顶部独立信号编辑面板"的兼容 shim。 - * 该面板已移除,信号编辑现在通过报文行展开完成; - * 仍被 selectMessage、handleDbcFile、clearDbc、handleSignalInputEvent 调用, - * 保留空函数以避免修改所有调用方。 - */ -export function renderSelectedMessage() {} - // ==================== 信号行渲染 ==================== /** @description 在报文展开行内渲染信号表格(inline grid)。 */ export function renderInlineSignalGrid(message, host) { @@ -178,7 +168,6 @@ function handleSignalInputEvent(evt) { setSignalValue(message, name, input.value); var signal = findSignal(message, name); if (signal && signal.multiplexRole === 'mux') { - if (key === state.selectedMessageKey) renderSelectedMessage(); var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host'); if (inlineGrid) renderInlineSignalGrid(message, inlineGrid); } diff --git a/tools/js/can_monitor.js b/tools/js/can_monitor.js index cb8cf98..c30808f 100644 --- a/tools/js/can_monitor.js +++ b/tools/js/can_monitor.js @@ -1,7 +1,7 @@ /** * can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用) */ -import { showToast } from './common/toast.js?v=20260717v'; +import { showToast } from './common/toast.js?v=20260717x'; var state = { fbTimer: null, fbAbortCtrl: null }; diff --git a/tools/js/common/connection.js b/tools/js/common/connection.js index 124e547..ff81368 100644 --- a/tools/js/common/connection.js +++ b/tools/js/common/connection.js @@ -49,11 +49,105 @@ export function updateHealthUi(healthCode, freeHeap) { mh.textContent = health; mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err'); } + ensureHealthBootToggleBound(); } -/** - * 每次 pollStatus 成功后刷新堆/栈/CPU(固定周期约 1000ms,中继期 400ms; - * sample_ms 由设备两次采样墙钟差给出;WiFi 上传 ESP32 期间 pause 不刷)。 - */ + +/** 健康状态 badge 点击展开/收起启动自检面板(只绑一次)。 */ +function ensureHealthBootToggleBound() { + var badge = document.getElementById('healthBadge'); + var panel = document.getElementById('healthBootPanel'); + if (!badge || !panel || badge.dataset.bootToggleBound === '1') return; + badge.dataset.bootToggleBound = '1'; + + function toggle() { + var open = panel.classList.contains('collapsed') || panel.hasAttribute('hidden'); + if (open) { + panel.hidden = false; + panel.classList.remove('collapsed'); + badge.classList.add('is-open'); + badge.setAttribute('aria-expanded', 'true'); + } else { + panel.hidden = true; + panel.classList.add('collapsed'); + badge.classList.remove('is-open'); + badge.setAttribute('aria-expanded', 'false'); + } + } + badge.addEventListener('click', function (e) { + e.preventDefault(); + toggle(); + }); + badge.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }); +} + +/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */ +function updateBootInitUi(data) { + var boot = data && data.boot ? data.boot : null; + var resultEl = document.getElementById('mBootResult'); + var failedEl = document.getElementById('mBootFailed'); + var listEl = document.getElementById('bootModuleList'); + if (!boot) { + if (resultEl) { + resultEl.textContent = '--'; + resultEl.className = 'health-boot-summary'; + } + if (failedEl) { + failedEl.textContent = '--'; + failedEl.className = ''; + } + if (listEl) listEl.innerHTML = ''; + return; + } + + var criticalOk = !!boot.critical_ok; + var allOk = !!boot.all_ok; + var summary = boot.summary || (allOk ? 'OK (all modules)' : (criticalOk ? 'OK (critical)' : 'FAIL')); + var failed = (boot.failed && String(boot.failed).trim()) ? String(boot.failed) : ''; + var tone = allOk ? 'ok' : (criticalOk ? 'warn' : 'err'); + + if (resultEl) { + resultEl.textContent = summary; + resultEl.className = 'health-boot-summary ' + tone; + resultEl.title = 'mask=0x' + (Number(boot.mask) || 0).toString(16); + } + if (failedEl) { + failedEl.textContent = failed || (allOk ? '无' : '--'); + failedEl.className = failed ? tone : 'ok'; + } + + if (listEl) { + var modules = Array.isArray(boot.modules) ? boot.modules : []; + if (!modules.length) { + listEl.innerHTML = ''; + } else { + listEl.innerHTML = modules.map(function (m) { + var ok = !!m.ok; + var critical = !!m.critical; + var cls = 'boot-mod ' + (ok ? 'ok' : (critical ? 'fail' : 'fail optional')); + var tip = (m.name || '') + (ok ? ' 成功' : ' 失败') + (critical ? ' · 关键' : ' · 非关键'); + return '' + + '' + + escapeHtml(m.name || '?') + + ''; + }).join(''); + } + } + + if (!window._bootInitLogged) { + window._bootInitLogged = true; + var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : ''); + try { + addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine); + } catch (_) {} + } + ensureHealthBootToggleBound(); +} + export function updateHeapAndTaskStatusUi(data) { if (!data) return; var ota = data.ota || {}; @@ -62,6 +156,8 @@ export function updateHeapAndTaskStatusUi(data) { if (el) el.textContent = value; }; + updateBootInitUi(data); + // ---- 堆概况:已用 / 总计 + 占用条 ---- function setFill(id, pct) { var el = document.getElementById(id); diff --git a/tools/js/ota/stages.js b/tools/js/ota/stages.js index 9c40e35..21951ee 100644 --- a/tools/js/ota/stages.js +++ b/tools/js/ota/stages.js @@ -208,7 +208,13 @@ export function updateSystemStatusUi(data) { var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs); if (window.currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); } - if (isGd32Error && (window.currentOtaTarget === 'gd32' || window.otaSessionTarget === 'gd32')) { + /* 本会话正在 GD32 路径(含仅 RELAYING 而 target 被清的情况) */ + var gd32Session = + window.otaSessionState === OtaSessionState.RELAYING || + window.currentOtaTarget === 'gd32' || + window.otaSessionTarget === 'gd32'; + + if (isGd32Error && gd32Session) { var failDetail = 'GD32 OTA 失败'; if (relayTotal > 0) { failDetail += '(已转发 ' + relaySeq + '/' + relayTotal + @@ -218,6 +224,7 @@ export function updateSystemStatusUi(data) { } window.cleanupAfterOtaFailure(failDetail); window.pollLogs(); + return; } else if (isGd32Error) { // 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理 updateStagesFromRelayState(255); @@ -233,6 +240,23 @@ export function updateSystemStatusUi(data) { if (isGd32Relaying || (rs >= 1 && rs <= 4)) { window.gd32RelaySessionLive = true; } + /* + * 设备侧 ERROR 常被 status 软清为 IDLE,前端收不到 code=8。 + * 已见真实中继、设备已回 IDLE、且未宣布完成 → 按失败收尾,释放会话。 + */ + var deviceIdleAfterRelay = + (sysStateCode === 0 || sysStateCode === 2) && + (rs === 0 || rs === 5) && + window.gd32RelaySessionLive && + !window.gd32CompleteAnnounced; + if (deviceIdleAfterRelay) { + window.cleanupAfterOtaFailure( + 'GD32 中继已结束但未成功(设备已回 IDLE' + + (relayTotal > 0 ? ',已转发 ' + relaySeq + '/' + relayTotal : '') + ')' + ); + window.pollLogs(); + return; + } /* 包进度:进入 FINISHING/COMPLETE 时先补 100%,再走完成/收尾 UI */ if (window.gd32RelaySessionLive && relayTotal > 0 && (relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) { diff --git a/tools/js/ota/upload-esp32.js b/tools/js/ota/upload-esp32.js index 0a3368a..fad4c2c 100644 --- a/tools/js/ota/upload-esp32.js +++ b/tools/js/ota/upload-esp32.js @@ -58,6 +58,31 @@ export async function startUpload(chip) { if (!file) return; // ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。 // FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。 + // RELAYING 可能因 ERROR 被设备软清为 IDLE 而前端未收尾,先探测再决定。 + if (otaSessionActive()) { + var busyState = window.otaSessionState; + if (busyState === OtaSessionState.RELAYING || busyState === OtaSessionState.VERIFYING) { + try { + var staleProbe = await fetch(window.getBaseUrl() + '/system/status', { + signal: AbortSignal.timeout(2000) + }); + if (staleProbe.ok) { + var staleSt = await staleProbe.json(); + var so = staleSt.ota || {}; + var ssc0 = Number.isFinite(so.sys_state_code) ? so.sys_state_code : -1; + var rs0 = Number.isFinite(so.relay_state) ? so.relay_state : -1; + /* 设备已空闲/完成/错误残留:前端会话是陈旧活跃态,强制清掉 */ + var deviceFree = (ssc0 === 0 || ssc0 === 2 || ssc0 === 4 || ssc0 === 8) && + (rs0 === 0 || rs0 === 5 || rs0 === 255 || rs0 < 0); + if (deviceFree) { + addLog('W', '上传', '检测到陈旧会话 ' + busyState + + '(设备已 ' + (so.sys_state || ssc0) + '),自动清理后继续'); + otaSessionReset(); + } + } + } catch (_) { /* 探测失败则仍按占用拒绝 */ } + } + } if (otaSessionActive()) { var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase(); addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传'); @@ -70,11 +95,7 @@ export async function startUpload(chip) { window.otaSessionTarget = chip; window.pausePollingForUpload(); - // ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。 - // 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间 - // 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send, - // 设备端互斥被绕过、403 路径走漏。 - // ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true) + /* 先进入 UPLOADING(同步 isUploading),再 await 读文件,防止重复提交 */ otaSessionTransition(OtaSessionState.UPLOADING); window.activeUploadChip = chip; /* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */ diff --git a/tools/js/ota/upload-gd32.js b/tools/js/ota/upload-gd32.js index a11eb2f..1e1067a 100644 --- a/tools/js/ota/upload-gd32.js +++ b/tools/js/ota/upload-gd32.js @@ -159,9 +159,39 @@ export function startRelayProgressWatch() { if (rstate >= 1 && rstate <= 4) { window.gd32RelaySessionLive = true; } + /* 中继失败:必须把前端会话从 RELAYING 拉到 FAILED,否则无法新开上传 */ if (code === 8 || rstate === 255) { stopRelayProgressWatch(); window.pollLogs(); + if (window.otaSessionState === OtaSessionState.RELAYING || + window.otaSessionState === OtaSessionState.UPLOADING) { + var failMsg = 'GD32 中继失败(relay_state=' + rstate + + ', sys=' + (o.sys_state || code) + ')'; + if (typeof window.cleanupAfterOtaFailure === 'function') { + window.cleanupAfterOtaFailure(failMsg); + } else { + otaSessionTransition(OtaSessionState.FAILED, failMsg); + } + } + return; + } + /* + * ERROR 可能在 status 路径被软清为 IDLE,前端收不到 code=8。 + * 本会话已见过真实中继,设备却已回 IDLE 且未宣布完成 → 按失败清理。 + */ + var idleAfterRelay = (code === 0 || code === 2) && + (rstate === 0 || rstate === 5) && + window.gd32RelaySessionLive && + !window.gd32CompleteAnnounced && + window.otaSessionState === OtaSessionState.RELAYING; + if (idleAfterRelay) { + stopRelayProgressWatch(); + window.pollLogs(); + if (typeof window.cleanupAfterOtaFailure === 'function') { + window.cleanupAfterOtaFailure('GD32 中继已结束但未成功(设备已回 IDLE)'); + } else { + otaSessionTransition(OtaSessionState.FAILED, 'GD32 中继已结束但未成功'); + } return; } /*