From 00b716c8db8a10cf92d0cf64c99844ed059c07f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Mon, 20 Jul 2026 03:10:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor(debug):=20=E9=87=8D=E6=9E=84=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E6=97=A5=E5=BF=97=E7=8E=AF=E5=BD=A2=E7=BC=93=E5=86=B2?= =?UTF-8?q?=E5=8C=BA=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将日志环形缓冲区从静态数组改为动态堆分配 - 实现启动时大容量缓冲区和运行时小容量缓冲区的切换机制 - 添加日志环满时优先保留错误和警告信息的清理策略 - 优化内存使用,在日志为空时避免不必要的内存分配 - 移除调试日志中的冗余字段显示 feat(api): 更新CAN接口为批量发送模式 - 将 `/can/send` 接口替换为 `/can/send_batch`,支持1到N帧的批量发送 - 更新API文档中的接口描述和使用示例 - 修改请求参数格式为 `{"ch":0,"frames":[{...}]}` 返回格式为 `{"status":"ok","results":[...]}` - 单帧发送也需要通过send_batch接口实现 feat(monitor): 新增GD32 ADC调试反馈处理功能 - 添加gd32_adc_feedback组件用于解析0xC4类型的SPI反馈 - 实现ADC数据解析包括温度、电压等参数的计算和转换 - 在can_feedback_task中集成ADC反馈分发逻辑 - 添加线程安全的数据获取和年龄检查接口 refactor(config): 优化任务栈大小配置并调整OTA块大小 - 重新评估各任务栈使用情况,优化HTTP、WiFi、SPI等工作任务的栈大小 - 将HTTP服务器栈从8KB调整至7KB,WiFi OTA任务栈从4KB调整至3KB - OTA块大小从2KB调整回4KB以提升传输吞吐性能 - 重新整理配置文件结构,移动WiFi模式配置位置 --- README.md | 8 +- main/CMakeLists.txt | 6 +- main/include/app_config.h | 45 +- main/include/version.h | 8 +- main/monitor/can_feedback_task.cpp | 27 +- main/monitor/cpu_stats.cpp | 2 +- main/monitor/debug_log.cpp | 158 ++++++- main/monitor/debug_log.h | 33 +- main/monitor/gd32_adc_feedback.cpp | 150 +++++++ main/monitor/gd32_adc_feedback.h | 53 +++ main/monitor/health_monitor.cpp | 409 ++++++++++++++++- main/monitor/health_monitor.h | 70 +++ main/monitor/stack_overflow_hook.cpp | 41 -- main/monitor/stack_overflow_hook.h | 18 - main/monitor/task_stack.cpp | 82 ++++ main/monitor/task_stack.h | 40 ++ main/monitor/task_stack_check.cpp | 55 --- main/monitor/task_stack_check.h | 32 -- main/ota/esp32_ota_core.cpp | 19 +- main/ota/ota_coordinator.cpp | 30 +- main/relay/ota_relay.cpp | 82 ++-- main/server/ble_ota.cpp | 25 +- main/server/ble_ota.h | 8 + main/server/ble_ota_data.cpp | 83 ++-- main/server/ble_ota_host.cpp | 42 +- main/server/http_handlers.cpp | 622 +++++++++++++++++++------- main/server/http_handlers.h | 6 +- main/server/wifi_ota.cpp | 83 ++-- main/storage/gd32_flash_store.cpp | 28 +- main/transport/canfd_bridge.cpp | 6 +- main/transport/spi_master.cpp | 197 +++++++-- main/transport/spi_master.h | 41 ++ sdkconfig.defaults | 16 +- tools/_bss_breakdown.py | 81 ++++ tools/_legacy5.txt | 21 + tools/_scan_concat_fmt.py | 37 ++ tools/_scan_real_over128.py | 207 +++++++++ tools/_size_arch.csv | 130 ++++++ tools/_size_arch.json | 0 tools/_size_out.json | 0 tools/analyze_debug_log_len.py | 217 +++++++++ tools/analyze_debug_over128.py | 294 +++++++++++++ tools/can.html | 4 +- tools/can_monitor.html | 4 +- tools/css/can.css | 7 + tools/css/components.css | 292 ++++++++++++ tools/index.html | 67 ++- tools/js/app.js | 18 +- tools/js/ble/file.js | 64 +-- tools/js/ble/state.js | 30 +- tools/js/ble/transfer.js | 49 +-- tools/js/ble_ota.js | 1 + tools/js/can.js | 4 +- tools/js/can/send.js | 133 +++--- tools/js/can_monitor.js | 25 ++ tools/js/common/connection.js | 636 ++++++++++++++++++++++++++- tools/js/common/heap-low-history.js | 196 +++++++++ tools/js/common/polling.js | 41 +- tools/js/ota/console-mode.js | 182 +++++--- tools/js/ota/file-handle.js | 214 ++++----- tools/js/ota/progress.js | 3 +- tools/js/ota/session.js | 13 +- tools/js/ota/stages.js | 114 +++-- tools/js/ota/upload-esp32.js | 40 +- tools/js/ota/upload-gd32.js | 2 +- tools/parse_map_static_mem.py | 226 ++++++++++ 66 files changed, 4803 insertions(+), 1074 deletions(-) create mode 100644 main/monitor/gd32_adc_feedback.cpp create mode 100644 main/monitor/gd32_adc_feedback.h delete mode 100644 main/monitor/stack_overflow_hook.cpp delete mode 100644 main/monitor/stack_overflow_hook.h create mode 100644 main/monitor/task_stack.cpp create mode 100644 main/monitor/task_stack.h delete mode 100644 main/monitor/task_stack_check.cpp delete mode 100644 main/monitor/task_stack_check.h create mode 100644 tools/_bss_breakdown.py create mode 100644 tools/_legacy5.txt create mode 100644 tools/_scan_concat_fmt.py create mode 100644 tools/_scan_real_over128.py create mode 100644 tools/_size_arch.csv create mode 100644 tools/_size_arch.json create mode 100644 tools/_size_out.json create mode 100644 tools/analyze_debug_log_len.py create mode 100644 tools/analyze_debug_over128.py create mode 100644 tools/js/common/heap-low-history.js create mode 100644 tools/parse_map_static_mem.py diff --git a/README.md b/README.md index f42d7b8..b3e8c75 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器) | POST | `/ota/relay` | 触发 GD32 中继 | - | `{"status":"ok"}` | | GET | `/system/status` | 系统状态 JSON | - | 含版本/WiFi/OTA/温度/堆/GD32 版本及**OtaCoordinator 系统级状态**等 | | GET | `/logs` | 拉取调试日志 | - | JSON 数组字符串 | -| POST | `/can/send` | 发送 CAN 帧 | `{"ch":0,"id":123,"dlc":8,"data":"0102..."}` | `{"status":"ok","recv":"..."}` | +| POST | `/can/send_batch` | CAN 发送(1~N 帧,单帧即 length=1) | `{"ch":0,"frames":[{"id":123,"dlc":8,"data":"0102..."}]}` | `{"status":"ok","results":[...]}` | | POST | `/spi/send` | SPI 直接发送 | `{"type":"01","data":"00AC","timeout_ms":1000}` | `{"status":"ok","resp_type":".."}` | | POST | `/restart` | 重启 ESP32 | - | 500ms 后 `esp_restart()` | @@ -354,10 +354,10 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器) # 查询系统状态 curl http://192.168.31.1/system/status | jq -# 发送 CAN 帧 -curl -X POST http://192.168.31.1/can/send \ +# 发送 CAN 帧(单帧也走 send_batch) +curl -X POST http://192.168.31.1/can/send_batch \ -H "Content-Type: application/json" \ - -d '{"ch":0,"id":0x18DA00F1,"dlc":8,"data":"0102030405060708"}' + -d '{"ch":0,"frames":[{"id":0x18DA00F1,"dlc":8,"data":"0102030405060708"}]}' # SPI 直接发送 curl -X POST http://192.168.31.1/spi/send \ diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 023bad5..947958e 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -14,10 +14,10 @@ idf_component_register( "server/ble_ota_host.cpp" # BLE OTA GATT/GAP/广播/Host "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检) "monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务 - "monitor/can_feedback_task.cpp" # CAN 反馈被动监听(0xC1 takeEvent) + "monitor/can_feedback_task.cpp" # CAN 反馈被动监听(0xC1 takeEvent)+ 分发 0xC4 + "monitor/gd32_adc_feedback.cpp" # GD32 ADC 调试反馈缓存(0xC4) "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) - "monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享) - "monitor/stack_overflow_hook.cpp" # FreeRTOS 栈溢出钩子(canary 触发时 UART 硬写告警) + "monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子 INCLUDE_DIRS "." "core" diff --git a/main/include/app_config.h b/main/include/app_config.h index 00699a1..0909e8f 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -10,6 +10,12 @@ #include "freertos/FreeRTOS.h" #include "version.h" +// ============================================================ +// 功能开关(ESP32 本机;GD32 侧开关随 HELLO/PING 身份块上报,无需镜像) +// ============================================================ +// 0 = AP 热点,1 = STA 连路由器 +inline constexpr uint8_t CONFIG_WIFI_MODE = 1; + // ============================================================ // 固件版本(由 version.h 自动生成,此处提供 C++ constexpr 访问) // ============================================================ @@ -17,13 +23,6 @@ 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 工作模式选择 -// ============================================================ -// 0 = AP 模式(设备当热点) -// 1 = STA 模式(连接路由器) -inline constexpr uint8_t CONFIG_WIFI_MODE = 1; - // ============================================================ // WiFi STA 模式配置(连接路由器) // ============================================================ @@ -47,19 +46,21 @@ inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服 // ============================================================ // 任务栈大小(字节)— 2026-07 按板端 task_stack 水位收敛 -// 规则:peak_used 实测 + 至少 ~1.5–2KB 余量;httpd 路径最重单独放宽。 -// 实测(空闲/常规):http peak~5KB, wifi_ota~1.4KB, ota_relay~1KB, -// health~1.2KB, cpu_stats~2KB, ble*~1KB, reconnect~1KB。 +// 规则:S ≈ peak_used + 1.5~2KB;偏松的收、偏紧的不砍。 +// 板测峰值量级(常规/OTA): +// http~5.2KB, spi~1.9KB, wifi_ota~1.2KB, ble_ota~0.9KB, +// ble_worker 1004B, health/cpu~1.8KB, ota_relay 轻载~1.3KB。 +// 2026-07 余量优化:收 http/spi/wifi/ble* 偏松项,health/cpu/relay 保持。 // ============================================================ -inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; ///< httpd:原 10KB,peak~5KB → 8KB(余量~3KB) -inline constexpr uint32_t TASK_STACK_WIFI_OTA = 4096; ///< wifi_ota 主循环:原 8KB,peak~1.4KB -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 -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 +inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 7168; ///< httpd:peak~5.2KB + ~2KB → 7KB(原 8/10KB) +inline constexpr uint32_t TASK_STACK_WIFI_OTA = 3072; ///< wifi_ota:peak~1.2KB + ~1.8KB → 3KB(原 4/8KB) +inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(轻载 peak~1.3KB;转发突发不砍) +inline constexpr uint32_t TASK_STACK_SPI_WORKER = 4096; ///< spi_worker:peak~1.9KB + ~2KB → 4KB(原 5KB) +inline constexpr uint32_t TASK_STACK_BLE_OTA = 2560; ///< ble_ota:peak~0.9KB + ~1.5KB → 2.5KB(原 3KB) +inline constexpr uint32_t TASK_STACK_BLE_WORKER = 2560; ///< ble_ota_worker:peak 1004B + ~1.5KB → 2.5KB(原 3KB) +inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.8KB,余量~1.2KB,不砍 +inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~1.8KB,余量~1.2KB,不砍 +inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:轻载,2.5KB 保持 inline constexpr uint32_t TASK_STACK_CAN_FB = 3072; ///< can_fb:被动 takeEvent(0xC1) inline constexpr uint32_t TASK_STACK_BLE_FINISH = 4096; ///< 按需:SHA finalize(保持) inline constexpr uint32_t TASK_STACK_BLE_GD32_WAIT = 3072; ///< 按需:GD32 中继等待 @@ -68,9 +69,8 @@ inline constexpr uint32_t TASK_STACK_BLE_RESTART = 2048; ///< 按需:延时 // ============================================================ // OTA 配置 // ============================================================ -inline constexpr size_t OTA_BLOCK_SIZE = 2048; ///< 上传接收块:2KB 降峰值 BSS/堆(s_ota_upload_recv 静态占用) -// OTA_BLOCK_SIZE 演进:8KB(原)→2KB(压堆)→4KB(试吞吐)→2KB(2026-07 回退省 DRAM)。 -// 4KB 时 s_ota_upload_recv BSS +2KB,开机 free/largest 更紧;回退 2KB 优先保内存。 +inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< 上传接收块:4KB(吞吐优先;s_ota_upload_recv 静态 +2KB vs 2KB 块) +// OTA_BLOCK_SIZE 演进:8KB→2KB(压堆)→4KB(试吞吐)→2KB(2026-07 回退)→4KB(2026-07-20 再提吞吐)。 inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB = 221184B = 0x35F90,固件大小上限 inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷 @@ -88,6 +88,7 @@ inline constexpr size_t HEAP_WARNING_MIN = 10240; ///< 堆内 // ============================================================ // 调试配置(UDP 日志) +// GD32 ADC/CAN 等开关:看 HELLO_ACK/PING 身份块 feat 位(GD32 config 顶部) // ============================================================ inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; ///< UDP 日志目标 IP inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口 diff --git a/main/include/version.h b/main/include/version.h index e246c81..81b6311 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 51 -#define FW_VERSION_STRING "0.3.51" +#define FW_VERSION_PATCH 52 +#define FW_VERSION_STRING "0.3.52" // 编译时间 -#define BUILD_TIME "2026-07-18 00:40:04" +#define BUILD_TIME "2026-07-20 02:50:38" // Git 信息 -#define GIT_HASH "1b10f89" +#define GIT_HASH "b807645" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index 43db032..e92322e 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -21,6 +21,8 @@ #include "transport/spi_master.h" #include "monitor/debug_log.h" #include "monitor/can_feedback_task.h" +#include "monitor/gd32_adc_feedback.h" +#include "app_config.h" #include "esp_system.h" #include @@ -30,6 +32,7 @@ namespace { constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时 constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔 constexpr uint8_t kSpiCanFeedbackType = 0xC1U; +constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈 constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断 constexpr const char *kTag = "CAN_FB"; @@ -43,6 +46,11 @@ static can_feedback_result_t s_work [CAN_CHANNEL_MAX]; static uint8_t s_workIdx[CAN_CHANNEL_MAX]; static uint32_t s_lastLoggedError[CAN_CHANNEL_MAX]; +/** + * @brief 将某通道完整反馈发布到 s_latest(线程安全)。 + * @param ch 通道 0..2 + * @param fb 解析后的快照 + */ static void publish(uint8_t ch, const can_feedback_result_t &fb) { if (s_mutex == nullptr) return; @@ -53,6 +61,9 @@ static void publish(uint8_t ch, const can_feedback_result_t &fb) } } +/** + * @brief can_fb 任务主循环:takeEvent 分发 0xC1 CAN / 0xC4 ADC。 + */ static void taskLoop(void *) { DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)"); @@ -85,6 +96,12 @@ static void taskLoop(void *) continue; } + /* 0xC4 ADC 调试反馈:GD32 关开关则不推;ESP32 始终可解析(无本地开关) */ + if (ring_type == kSpiAdcFeedbackType) { + (void)onGd32AdcFeedbackEvent(ring_payload, ring_len); + continue; + } + if (ring_type != kSpiCanFeedbackType || ring_len < 28U) { continue; } @@ -103,13 +120,11 @@ static void taskLoop(void *) if (s_work[ch].stats.error_count != 0U && s_work[ch].stats.error_count != s_lastLoggedError[ch]) { DEBUG_WARN(kTag, - "CH%u seq=%lu err=%lu TEC=%u REC=%u state=0x%02X", + "CH%u err=%lu TEC=%u REC=%u", static_cast(ch), - static_cast(s_work[ch].stats.snapshot_seq), static_cast(s_work[ch].stats.error_count), static_cast(s_work[ch].stats.tec), - static_cast(s_work[ch].stats.rec), - static_cast(s_work[ch].stats.bus_state)); + static_cast(s_work[ch].stats.rec)); s_lastLoggedError[ch] = s_work[ch].stats.error_count; } } @@ -156,6 +171,10 @@ bool startCanFeedbackTask() return true; } +/** + * @brief 暂停/恢复 CAN(及同任务分发的 ADC)事件监听。 + * @param pause true=OTA 期间暂停;false=恢复 + */ void pauseCanFeedbackTask(bool pause) { if (s_paused == pause) return; // 幂等:状态未变时不重复日志 diff --git a/main/monitor/cpu_stats.cpp b/main/monitor/cpu_stats.cpp index 2202328..3b56cae 100644 --- a/main/monitor/cpu_stats.cpp +++ b/main/monitor/cpu_stats.cpp @@ -95,7 +95,7 @@ void CpuStats::sampleOnce() (now_tick - m_lastTruncWarnTick) >= cpuStatsWarnIntervalTicks()) { m_lastTruncWarnTick = now_tick; DEBUG_WARN("CPU", - "任务数 %lu > 快照上限 %u,本拍仅统计前 %u 个任务", + "任务数%lu>上限%u,只计前%u", (unsigned long)live, (unsigned)CPU_TASK_SNAPSHOT_MAX, (unsigned)CPU_TASK_SNAPSHOT_MAX); diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index 235e8cb..f37c222 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -18,6 +18,7 @@ */ #include #include +#include #include #include #include @@ -69,17 +70,13 @@ bool DebugLog::isKeepAliveTag(const char *tag) char *DebugLog::allocEmptyJson(int *out_len) { - char *empty = (char *)malloc(3); - if (!empty) { - return nullptr; - } - empty[0] = '['; - empty[1] = ']'; - empty[2] = '\0'; + /* 返回 nullptr 让 caller 走"[]"字面量分支,避免空环时每秒 malloc(3)/free(3)。 + * ringDrainJson 的所有"返空"路径都调本函数,caller logs_handler 见 nullptr + * 后直接发 "[]" 字面量,零分配。 */ if (out_len) { - *out_len = 2; + *out_len = 0; } - return empty; + return nullptr; } /** @@ -148,8 +145,104 @@ int DebugLog::jsonEscapeAppend(char *buf, int pos, int capacity, const char *src } // ============================================================ -// 环形缓冲(Locked 变体假定已持 m_logRingMutex) +// 环形缓冲(堆上;Locked 变体假定已持 m_logRingMutex) // ============================================================ +bool DebugLog::allocRing(int slots) +{ + if (slots <= 0) { + return false; + } + char *rows = static_cast(calloc((size_t)slots, (size_t)LOG_RING_SIZE)); + int *lens = static_cast(calloc((size_t)slots, sizeof(int))); + if (!rows || !lens) { + free(rows); + free(lens); + return false; + } + free(m_logRing); + free(m_logRingLens); + m_logRing = rows; + m_logRingLens = lens; + m_logRingSlots = slots; + m_logRingHead = 0; + m_logRingCount = 0; + return true; +} + +void DebugLog::shrinkRingAfterBoot() +{ + if (m_logRingShrunk || !m_logRingMutex) { + return; + } + if (m_logRingSlots > 0 && m_logRingSlots <= LOG_RING_SLOTS_STEADY) { + m_logRingShrunk = true; + return; + } + + xSemaphoreTake(m_logRingMutex, portMAX_DELAY); + + const int old_slots = m_logRingSlots; + const int old_count = m_logRingCount; + const int old_head = m_logRingHead; + char *old_ring = m_logRing; + int *old_lens = m_logRingLens; + + const int new_slots = LOG_RING_SLOTS_STEADY; + char *rows = static_cast(calloc((size_t)new_slots, (size_t)LOG_RING_SIZE)); + int *lens = static_cast(calloc((size_t)new_slots, sizeof(int))); + if (!rows || !lens) { + free(rows); + free(lens); + xSemaphoreGive(m_logRingMutex); + ESP_LOGW(TAG, "log ring shrink failed, keep boot ring (%d slots)", old_slots); + return; + } + + /* 保留时间序上最新的 min(count, new_slots) 条 */ + int keep = old_count; + if (keep > new_slots) { + keep = new_slots; + } + int new_head = 0; + if (old_ring && old_lens && keep > 0 && old_slots > 0) { + for (int i = 0; i < keep; ++i) { + /* 从最旧保留起点:跳过被挤掉的旧条 */ + const int src_i = old_count - keep + i; + const int src = (old_head - old_count + src_i + old_slots * 4) % old_slots; + int len = old_lens[src]; + if (len < 0) { + len = 0; + } + if (len >= LOG_RING_SIZE) { + len = LOG_RING_SIZE - 1; + } + char *dst = rows + (size_t)new_head * (size_t)LOG_RING_SIZE; + if (len > 0) { + memcpy(dst, old_ring + (size_t)src * (size_t)LOG_RING_SIZE, (size_t)len); + } + dst[len] = '\0'; + lens[new_head] = len; + new_head = (new_head + 1) % new_slots; + } + } + + m_logRing = rows; + m_logRingLens = lens; + m_logRingSlots = new_slots; + m_logRingCount = keep; + m_logRingHead = (keep >= new_slots) ? new_head : keep; + m_logRingShrunk = true; + + xSemaphoreGive(m_logRingMutex); + + free(old_ring); + free(old_lens); + + ESP_LOGI(TAG, "log ring shrunk: %d → %d slots (free ~%d B)", + old_slots, new_slots, + (old_slots - new_slots) * (LOG_RING_SIZE + (int)sizeof(int))); +} + int DebugLog::ringCount() { if (!m_logRingMutex) { @@ -166,11 +259,11 @@ bool DebugLog::ringPopOldestLocked(char *out, size_t out_size, int *out_len) if (out_len) { *out_len = 0; } - if (!out || out_size == 0 || m_logRingCount <= 0) { + if (!out || out_size == 0 || m_logRingCount <= 0 || !m_logRing || m_logRingSlots <= 0) { return false; } - const int idx = (m_logRingHead - m_logRingCount + LOG_RING_SLOTS) % LOG_RING_SLOTS; + const int idx = (m_logRingHead - m_logRingCount + m_logRingSlots) % m_logRingSlots; int len = m_logRingLens[idx]; if (len < 0) { len = 0; @@ -182,7 +275,7 @@ bool DebugLog::ringPopOldestLocked(char *out, size_t out_size, int *out_len) } if (copy_len > 0) { - memcpy(out, m_logRing[idx], (size_t)copy_len); + memcpy(out, ringSlot(idx), (size_t)copy_len); } out[copy_len] = '\0'; if (out_len) { @@ -215,6 +308,9 @@ int DebugLog::ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const if (total_raw_len) { *total_raw_len = 0; } + if (!m_logRing || m_logRingSlots <= 0) { + return 0; + } int n = m_logRingCount; if (n > max_n) { n = max_n; @@ -225,7 +321,7 @@ int DebugLog::ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const int sum = 0; for (int i = 0; i < n; ++i) { - const int idx = (m_logRingHead - m_logRingCount + i + LOG_RING_SLOTS) % LOG_RING_SLOTS; + const int idx = (m_logRingHead - m_logRingCount + i + m_logRingSlots) % m_logRingSlots; int len = m_logRingLens[idx]; if (len < 0) { len = 0; @@ -243,7 +339,7 @@ int DebugLog::ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const void DebugLog::ringPushLocked(const char *data, int len) { - if (!data || len <= 0) { + if (!data || len <= 0 || !m_logRing || !m_logRingLens || m_logRingSlots <= 0) { return; } @@ -252,12 +348,27 @@ void DebugLog::ringPushLocked(const char *data, int len) copy_len = LOG_RING_SIZE - 1; } - memcpy(m_logRing[m_logRingHead], data, (size_t)copy_len); - m_logRing[m_logRingHead][copy_len] = '\0'; + /* 满环时优先覆盖 INFO/DBG,保护 ERR/WARN。 */ + if (m_logRingCount >= m_logRingSlots) { + int victim_idx = m_logRingHead; + for (int i = 0; i < m_logRingSlots; ++i) { + const int idx = (m_logRingHead + i) % m_logRingSlots; + const char *s = ringSlot(idx); + if (s[0] == '[' && (s[1] == 'I' || s[1] == 'D')) { + victim_idx = idx; + break; + } + } + m_logRingHead = victim_idx; + } + + char *slot = ringSlot(m_logRingHead); + memcpy(slot, data, (size_t)copy_len); + slot[copy_len] = '\0'; m_logRingLens[m_logRingHead] = copy_len; - m_logRingHead = (m_logRingHead + 1) % LOG_RING_SLOTS; - if (m_logRingCount < LOG_RING_SLOTS) { + m_logRingHead = (m_logRingHead + 1) % m_logRingSlots; + if (m_logRingCount < m_logRingSlots) { m_logRingCount++; } } @@ -475,6 +586,15 @@ esp_err_t DebugLog::init() return ESP_ERR_NO_MEM; } + if (!allocRing(LOG_RING_SLOTS_BOOT)) { + ESP_LOGE(TAG, "boot log ring alloc 失败 (%d slots),/logs 不可用", LOG_RING_SLOTS_BOOT); + /* UART/UDP 仍可用 */ + } else { + ESP_LOGI(TAG, "boot log ring %d×%d (~%d B heap)", + LOG_RING_SLOTS_BOOT, LOG_RING_SIZE, + LOG_RING_SLOTS_BOOT * (LOG_RING_SIZE + (int)sizeof(int))); + } + memset(&m_debugAddr, 0, sizeof(m_debugAddr)); m_debugAddr.sin_family = AF_INET; m_debugAddr.sin_port = htons(DEBUG_LOG_PORT); diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index a9c0581..92f375f 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -51,9 +51,15 @@ class DebugLog { public: static DebugLog& instance(); - /** @brief 初始化日志系统(UART + ring;UDP socket 延迟创建)。 */ + /** @brief 初始化日志系统(UART + 开机大环;UDP socket 延迟创建)。 */ esp_err_t init(); + /** + * @brief 启动 burst 结束后把环从开机容量缩到稳态小环,释放大块堆。 + * 幂等;失败则保留大环。宜在 WiFi/HTTP 就绪后、日志变少时调用。 + */ + void shrinkRingAfterBoot(); + /** @brief 初始化 UART0 调试串口。 */ esp_err_t uartInit(); @@ -96,17 +102,28 @@ private: bool m_uartAvailable = false; debug_log_level_t m_logLevel = DEBUG_LOG_INFO; - /* 环形日志:OTA 中继期需保留关键 INFO,64×160≈10KB BSS。 */ - static constexpr int LOG_RING_SLOTS = 64; - static constexpr int LOG_RING_SIZE = 160; - /** 单次 /logs drain 上限(与 SLOTS 更接近,减 burst 滞后) */ - static constexpr int LOG_DRAIN_MAX = 48; - char m_logRing[LOG_RING_SLOTS][LOG_RING_SIZE] = {}; - int m_logRingLens[LOG_RING_SLOTS] = {}; + /* 环形日志(堆上分配,非 .data): + * 开机 LOG_RING_SLOTS_BOOT×128 容纳启动刷屏; + * shrinkRingAfterBoot() 后改为 LOG_RING_SLOTS_STEADY×128,释放大环。 + * 业务 DEBUG_* 目标:整行([L][TAG]+正文+\\n)≤128B;槽 128 含 \\0 → 有效≤127。 */ + static constexpr int LOG_RING_SLOTS_BOOT = 64; ///< 开机 burst:~8.4KB 堆 + static constexpr int LOG_RING_SLOTS_STEADY = 16; ///< 稳态:~2.1KB 堆(对齐 drain) + static constexpr int LOG_RING_SIZE = 128; ///< 单条槽(含 \\0);有效≤127(含换行) + static constexpr int LOG_DRAIN_MAX = 16; ///< 单次 /logs 最多条数 + char *m_logRing = nullptr; ///< [slots * LOG_RING_SIZE] 扁平行 + int *m_logRingLens = nullptr; ///< [slots] + int m_logRingSlots = 0; ///< 当前容量 int m_logRingHead = 0; int m_logRingCount = 0; + bool m_logRingShrunk = false; SemaphoreHandle_t m_logRingMutex = nullptr; + char *ringSlot(int idx) { return m_logRing + (size_t)idx * (size_t)LOG_RING_SIZE; } + const char *ringSlot(int idx) const { + return m_logRing + (size_t)idx * (size_t)LOG_RING_SIZE; + } + bool allocRing(int slots); + /* 堆门控(防 OTA 时 UDP/格式化抢 LWIP 堆): * ERR/WARN 永不因堆门控丢弃 * free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO diff --git a/main/monitor/gd32_adc_feedback.cpp b/main/monitor/gd32_adc_feedback.cpp new file mode 100644 index 0000000..bc2dde5 --- /dev/null +++ b/main/monitor/gd32_adc_feedback.cpp @@ -0,0 +1,150 @@ +/* + * gd32_adc_feedback.cpp - GD32 ADC 调试反馈解析与缓存 + */ +#include "monitor/gd32_adc_feedback.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include + +namespace { + +constexpr uint8_t kPayloadVer = 1U; +constexpr size_t kPayloadLen = 20U; + +static SemaphoreHandle_t s_mux = nullptr; +static gd32_adc_feedback_t s_latest = {}; +static bool s_have = false; + +/** @brief 读小端 u16。 */ +static uint16_t rdU16Le(const uint8_t *p) +{ + return static_cast(p[0] | (static_cast(p[1]) << 8)); +} + +/** @brief 读小端 u32。 */ +static uint32_t rdU32Le(const uint8_t *p) +{ + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +/** @brief 懒创建快照互斥量。 */ +static void ensureMux() +{ + if (s_mux == nullptr) { + s_mux = xSemaphoreCreateMutex(); + } +} + +} // namespace + +/** + * @brief 处理一帧 0xC4 ADC 调试反馈并更新最新快照。 + * @param payload 固定 20B 载荷 + * @param len 实际长度 + * @return true 解析并写入成功 + */ +bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len) +{ + if (payload == nullptr || len < kPayloadLen) { + return false; + } + if (payload[0] != kPayloadVer) { + return false; + } + + const uint8_t flags = payload[1]; + const int16_t temp_x100 = static_cast(rdU16Le(&payload[2])); + const uint16_t vdda_mV = rdU16Le(&payload[4]); + const uint16_t vin_mV = rdU16Le(&payload[6]); + const uint16_t temp_raw = rdU16Le(&payload[8]); + const uint16_t vref_raw = rdU16Le(&payload[10]); + const uint32_t seq = rdU32Le(&payload[12]); + const uint32_t uptime = rdU32Le(&payload[16]); + + gd32_adc_feedback_t fb = {}; + fb.valid = (flags & 0x01U) != 0U; + fb.temperature_c = static_cast(temp_x100) / 100.0f; + fb.vdda_v = static_cast(vdda_mV) / 1000.0f; + fb.vin_adc_v = static_cast(vin_mV) / 1000.0f; + /* 原理图:VCC — R18 47k — VIN_ADC — R13 4.7k — GND + * VCC = V_adc * (R18+R13)/R13 = V_adc * 11 */ + fb.vcc_v = fb.vin_adc_v * ((47.0f + 4.7f) / 4.7f); + fb.temp_raw = temp_raw; + fb.vref_raw = vref_raw; + fb.seq = seq; + fb.uptime_sec = uptime; + fb.recv_ms = esp_timer_get_time() / 1000; + + ensureMux(); + if (s_mux == nullptr) { + return false; + } + if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) { + return false; + } + s_latest = fb; + s_have = true; + xSemaphoreGive(s_mux); + return true; +} + +/** + * @brief 线程安全拷贝最近一次 ADC 反馈。 + * @param[out] out 输出缓冲 + * @return true 已有至少一帧有效数据 + */ +bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out) +{ + if (out == nullptr) { + return false; + } + ensureMux(); + if (s_mux == nullptr) { + return false; + } + bool ok = false; + if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) { + if (s_have) { + *out = s_latest; + ok = true; + } + xSemaphoreGive(s_mux); + } + return ok; +} + +/** + * @brief 距上次收到 0xC4 的毫秒数。 + * @return 年龄 ms;尚无数据返回 UINT32_MAX + */ +uint32_t gd32AdcFeedbackAgeMs(void) +{ + ensureMux(); + if (s_mux == nullptr) { + return UINT32_MAX; + } + int64_t recv = 0; + bool have = false; + if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) { + have = s_have; + recv = s_latest.recv_ms; + xSemaphoreGive(s_mux); + } + if (!have || recv <= 0) { + return UINT32_MAX; + } + const int64_t now = esp_timer_get_time() / 1000; + if (now < recv) { + return 0U; + } + const int64_t age = now - recv; + if (age > static_cast(UINT32_MAX)) { + return UINT32_MAX; + } + return static_cast(age); +} diff --git a/main/monitor/gd32_adc_feedback.h b/main/monitor/gd32_adc_feedback.h new file mode 100644 index 0000000..fb95e58 --- /dev/null +++ b/main/monitor/gd32_adc_feedback.h @@ -0,0 +1,53 @@ +/* + * gd32_adc_feedback.h - 被动接收 GD32 ADC 调试反馈(SPI TYPE 0xC4) + * + * 与 CAN 0xC1 同模型:GD32 1Hz 主动按铃推送,ESP32 takeEvent 侧解析入库。 + * 是否推送由 GD32 CONFIG_SPI_ADC_FEEDBACK_ENABLE 决定,经 HELLO/PING 身份块 + * (V2_FEAT_ADC_FEEDBACK)上报;ESP32 无本地开关,有帧即解析。 + */ +#ifndef GD32_ADC_FEEDBACK_H +#define GD32_ADC_FEEDBACK_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief 最近一次 GD32 ADC 调试快照(工程量已换算)。 */ +typedef struct { + bool valid; ///< GD32 sample.valid + float temperature_c; ///< ℃ + float vdda_v; ///< V(MCU 模拟供电,约 3.3) + float vin_adc_v; ///< V(分压后 ADC 引脚 PA6) + float vcc_v; ///< V(输入母线 VCC,由 vin 分压反推) + uint16_t temp_raw; + uint16_t vref_raw; + uint32_t seq; + uint32_t uptime_sec; ///< GD32 侧 uptime + int64_t recv_ms; ///< ESP32 收到时 esp_timer ms +} gd32_adc_feedback_t; + +/** + * @brief 处理一帧 0xC4 EVENT payload(由 can_fb 任务 takeEvent 分发)。 + * @return true 表示已接受并更新快照。 + */ +bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len); + +/** + * @brief 拷贝最新快照。 + * @param[out] out 输出缓冲 + * @return true 表示至少收到过一帧 + */ +bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out); + +/** @brief 距离上次收到的毫秒数;无数据返回 UINT32_MAX。 */ +uint32_t gd32AdcFeedbackAgeMs(void); + +#ifdef __cplusplus +} +#endif + +#endif /* GD32_ADC_FEEDBACK_H */ diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index d9c3d50..e446746 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -8,6 +8,8 @@ */ #include #include +#include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" @@ -20,9 +22,107 @@ #include "health_monitor.h" #include "debug_log.h" -#include "task_stack_check.h" -#include "stack_overflow_hook.h" +#include "task_stack.h" #include "ota/ota_coordinator.h" +#include "ble_ota.h" +#include "wifi_ota.h" +#include "spi_master.h" +#include "esp_event.h" +#include "esp_netif_types.h" + +// ============================================================ +// heap hook:捕获"破新低时的最后一次 alloc"(最后一击,未必是主因) +// ============================================================ +// CONFIG_HEAP_USE_HOOKS=y 时,每次 malloc 成功后进入本弱符号。 +// +// ★ 为何不能记任务名(用户看到"每次都是 WiFi"的根因): +// 1) WiFi IRAM 路径禁止在 hook 里调 FreeRTOS/pcTaskGetName(会搞挂 STA)。 +// 2) 即便能取任务名:HTTP/UDP **发送前** lwip 仍会为 pbuf/TX 再 malloc, +// 芯片 minimum free 往往在这条"最后一击"上被压低,任务名就会是 wifi/tiT/httpd, +// 看起来像"WiFi 吃堆",实际只是发送路径的末包分配。 +// 因此 hook **永不写任务名**;只记 size + caller 地址(addr2line)。 +// 前端诊断应标明「最后一击 / 可能网络发送」,勿把连接列的 WiFi 当成触发任务。 +// +// ★ 记录策略:health_mon 周期把真实 free 同步进 g_est_free; +// hook 内仅做 g_est_free -= size(C3 单核,无锁可接受漂移)。 +// 当 est 创新低时更新 hook 快照。free hook 不知 size,靠周期 resync 纠偏。 +namespace { +volatile uint32_t g_hook_min_est = 0xFFFFFFFFu; ///< hook 见过的最低 est free +volatile uint32_t g_hook_alloc_size = 0; ///< 触发 est 新低的那次 alloc 大小 +volatile uint32_t g_hook_caller_addr = 0; ///< 调用者返回地址 +volatile uint32_t g_hook_free_after = 0; ///< 那次后的 est free(非精确实测) +volatile uint32_t g_hook_fired = 0; +volatile uint32_t g_hook_armed = 0; +volatile uint32_t g_est_free = 0; ///< 近似空闲,任务上下文 resync +volatile uint32_t g_ip_event_count = 0; + +/** 最近若干次「值得关注」的 alloc(size≥阈值),供破新低时对照。 */ +struct RecentAllocSnap { + uint32_t size; + uint32_t caller; +}; +static constexpr uint32_t kHookInterestingMin = 48; ///< 忽略极小碎片分配 +static constexpr size_t kRecentAllocCap = 4; +RecentAllocSnap g_recent_allocs[kRecentAllocCap] = {}; +volatile uint32_t g_recent_alloc_w = 0; +volatile uint32_t g_recent_alloc_n = 0; + +void hookResyncEstFree(uint32_t real_free) +{ + g_est_free = real_free; +} +} // namespace + +// ESP-IDF heap alloc hook:仅 DRAM 标量 + 内联 return address,禁止 FreeRTOS/flash 长函数。 +extern "C" +void esp_heap_trace_alloc_hook(void *ptr, size_t size, uint32_t caps) +{ + (void)caps; + if (!ptr || size == 0 || g_hook_armed == 0) { + return; + } + const uint32_t sz = (uint32_t)size; + + /* 近似空闲:只减不增(free 路径不在此修正),health_mon 周期 resync。 */ + uint32_t est = g_est_free; + if (est >= sz) { + est -= sz; + } else { + est = 0; + } + g_est_free = est; + + /* 环形记下近期「有意义」分配,破新低时可读到发送路径小包之前的大块。 */ + if (sz >= kHookInterestingMin) { + const uint32_t w = g_recent_alloc_w % (uint32_t)kRecentAllocCap; + g_recent_allocs[w].size = sz; + g_recent_allocs[w].caller = (uint32_t)(uintptr_t)__builtin_return_address(0); + g_recent_alloc_w = w + 1U; + if (g_recent_alloc_n < (uint32_t)kRecentAllocCap) { + g_recent_alloc_n = g_recent_alloc_n + 1U; + } + } + + /* est 创新低 → 记「最后一击」alloc(常为网络 pbuf,任务若可读会是 wifi) */ + if (est < g_hook_min_est) { + g_hook_min_est = est; + g_hook_alloc_size = sz; + g_hook_caller_addr = (uint32_t)(uintptr_t)__builtin_return_address(0); + g_hook_free_after = est; + g_hook_fired = 1; + /* 不采任务名(WiFi IRAM 路径禁止 FreeRTOS 调用) */ + } +} + +// IP 事件 handler:STA 获得 IP(含 DHCP 续约)时计数 +1 +void ipEventHandler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) +{ + (void)arg; (void)event_base; (void)event_id; (void)event_data; + if (event_base == IP_EVENT && (event_id == IP_EVENT_STA_GOT_IP || event_id == IP_EVENT_ETH_GOT_IP)) { + uint32_t cur = g_ip_event_count; + g_ip_event_count = cur + 1; + } +} // ============================================================ // 栈水位主动巡检 @@ -60,6 +160,18 @@ void HealthMonitor::init() if (m_mutex == NULL) { DEBUG_ERR("HEALTH", "创建健康状态互斥锁失败"); } + + // 注册 IP 事件 handler,统计 STA 获得 IP(含 DHCP 续约)次数。 + // 配合 heap_low_log 快照的 ip_event_count 字段,可验证"低谷是不是 DHCP 续约引起"。 + // 失败仅告警,不致命(计数器保持 0,快照里 ip_event_count 永远 0)。 + esp_err_t ev = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, ipEventHandler, nullptr); + if (ev != ESP_OK) { + DEBUG_WARN("HEALTH", "IP 事件 handler 注册失败: %s", esp_err_to_name(ev)); + } + + // 武装 heap alloc hook:init() 在 app_main 之后调用。armed=0 时 hook 直接 return。 + // hook 本身禁止任何 FreeRTOS/flash 调用(见 esp_heap_trace_alloc_hook 注释)。 + g_hook_armed = 1; } // ============================================================ @@ -159,7 +271,7 @@ void HealthMonitor::tempMonitorCallback(void *arg) if (temp >= TEMP_CRITICAL) { self.setState(SYS_SAFE_SHUTDOWN); - DEBUG_ERR("TEMP", "!!! 危险 !!! 温度 %.1f°C >= %d°C,触发安全关机", temp, TEMP_CRITICAL); + DEBUG_ERR("TEMP", "过热 %.1fC>=%dC 安全关机", temp, TEMP_CRITICAL); DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN"); } else if (temp >= TEMP_WARNING) { health_state_t cur = self.getState(); @@ -213,12 +325,19 @@ void HealthMonitor::taskEntry(void *pvParameters) // 在 health_mon 完好栈上用 DEBUG_ERR 经 UDP/UART 发详细告警。 // 标志只在置位后告警一次,不自动清除(canary 触发即不可逆,需重启恢复)。 if (g_stackOverflowDetected) { - DEBUG_ERR("MON", "!!! 栈溢出已被内核 canary 检测到,请检查任务栈大小 !!!"); + DEBUG_ERR("MON", "栈溢出(canary),检查任务栈"); // 告警一次后清标志,避免 5 秒刷屏;后续若再次触发会重新置位 g_stackOverflowDetected = false; } uint32_t free_heap = esp_get_free_heap_size(); + /* 同步 hook 近似空闲:纠正 free 路径未记账造成的向下漂移。 + * 必须在 maybeRecord 之前,否则 est 长期偏低会误触发 hook 快照。 */ + hookResyncEstFree(free_heap); + + // 堆低水位快照:仅在"最低空闲"创新低时记录一条上下文(5s 检测足够) + self.maybeRecordHeapLowWater(); + if (free_heap < HEAP_WARNING_MIN) { /* 堆告警节流:OTA 期间阈值更低、间隔更长 */ static TickType_t s_last_heap_warn = 0; @@ -231,7 +350,7 @@ void HealthMonitor::taskEntry(void *pvParameters) (now - s_last_heap_warn) >= pdMS_TO_TICKS(interval_ms)) { s_last_heap_warn = now; uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - DEBUG_WARN("MON", "堆内存偏紧%s: free=%lu min=%lu largest_blk=%lu", + DEBUG_WARN("MON", "堆紧%s free=%lu min=%lu big=%lu", ota_busy ? "(OTA中)" : "", (unsigned long)free_heap, (unsigned long)esp_get_minimum_free_heap_size(), @@ -255,11 +374,10 @@ void HealthMonitor::taskEntry(void *pvParameters) size_t warn_bytes = (size_t)(metrics[i].stack_size_bytes * STACK_WARN_RATIO); if (hw < (UBaseType_t)warn_bytes) { if (!s_stackWarned[i]) { - DEBUG_ERR("MON", "!!! 栈水位过低 !!! 任务=%s 剩余=%luB 阈值=%luB(%.0f%%) 栈大小=%luB", + DEBUG_ERR("MON", "栈低 task=%s left=%lu thr=%lu sz=%lu", metrics[i].name, (unsigned long)hw, (unsigned long)warn_bytes, - STACK_WARN_RATIO * 100.0f, (unsigned long)metrics[i].stack_size_bytes); s_stackWarned[i] = true; } @@ -313,14 +431,14 @@ health_status_t HealthMonitor::checkBeforeOta() if (getLastWifiRssi(&last_rssi, &age_ms)) { if (age_ms <= OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS) { if (last_rssi < OTA_WIFI_RSSI_CRITICAL) { - DEBUG_WARN("HEALTH", "OTA 前检查: 最近 RSSI %d < %d (样本年龄 %lu ms)", + DEBUG_WARN("HEALTH", "OTA前 RSSI %d<%d age=%lums", last_rssi, OTA_WIFI_RSSI_CRITICAL, (unsigned long)age_ms); return HEALTH_MON_CRITICAL; } else if (last_rssi < WIFI_RSSI_NORMAL) { if (status < HEALTH_MON_WARNING) { status = HEALTH_MON_WARNING; } - DEBUG_WARN("HEALTH", "OTA 前检查: 最近 RSSI %d < %d (样本年龄 %lu ms)", + DEBUG_WARN("HEALTH", "OTA前 RSSI %d<%d age=%lums", last_rssi, WIFI_RSSI_NORMAL, (unsigned long)age_ms); } } @@ -409,7 +527,7 @@ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi) DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL); } else { // next == SYS_NORMAL,从 WARNING/CRITICAL 恢复 setState(SYS_NORMAL); - DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm (从 %d 降级)", rssi, WIFI_RSSI_NORMAL, (int)cur); + DEBUG_INFO("HEALTH", "RSSI恢复 %d>=%d (lv%d)", rssi, WIFI_RSSI_NORMAL, (int)cur); } } @@ -418,3 +536,274 @@ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi) return HEALTH_MON_OK; } + +// ============================================================ +// 堆低水位快照("最低空闲"创新低时记录上下文) +// ============================================================ +namespace { +/** @brief snprintf 追加到 buf 的安全封装:截断但不越界,返回新 offset。 */ +int appendJsonfSafe(char *buf, size_t cap, int offset, const char *fmt, ...) +{ + if (offset < 0 || (size_t)offset >= cap) return offset; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap); + va_end(ap); + if (n < 0) return offset; + if ((size_t)n >= cap - (size_t)offset) { + // 被截断:vsnprintf 返回的是"本应写入"的长度,按截断后修正 offset + return (int)cap - 1; + } + return offset + n; +} +} // namespace + +/** + * @brief 检测"内部堆最低空闲"是否创新低,是则覆盖写入唯一快照槽。 + * + * 调用方:health_mon 5s 巡检任务。仅在"新低"时写一条,避免每 5s 刷一条无意义记录。 + * 上下文字段:OTA 系统状态、通道、RSSI、温度、栈剩余最少的任务名+剩余字节。 + * 所有外部读取(OtaCoordinator::getSnapshot / collectTaskStackMetrics / rssi / temp) + * 都先在锁外取,避免持锁期间嵌套调用其它模块的锁;持锁后只做字段拷贝。 + */ +void HealthMonitor::maybeRecordHeapLowWater() +{ + // 与 http_handlers.cpp 同口径:内部 DRAM 数据堆 + static constexpr uint32_t CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + + const uint32_t cur_min = (uint32_t)heap_caps_get_minimum_free_size(CAPS); + + // 不创新低则直接返回(不持锁) + if (cur_min >= m_heapLowWaterLastMin) { + return; + } + + // ── 锁外收集上下文 ── + const uint32_t free_heap = (uint32_t)esp_get_free_heap_size(); + const uint32_t largest_block = (uint32_t)heap_caps_get_largest_free_block(CAPS); + + OtaSnapshot snap = OtaCoordinator::instance().getSnapshot(); + const uint8_t sys_state = (uint8_t)snap.sys_state; + const uint8_t ota_channel = (uint8_t)snap.channel; + const uint32_t ota_stored = (uint32_t)snap.meta.stored_size; + const uint32_t ota_total = (uint32_t)snap.meta.total_size; + + int8_t rssi = -127; + { + int8_t r; + uint32_t age_ms; + if (getLastWifiRssi(&r, &age_ms)) { + rssi = r; + } + } + const float temperature = getLastTemperature(); + + /* current_task = 采样者,不是触发者。固定写 health_mon,避免误解为“谁 alloc”。 */ + char current_task[16] = "health_mon"; + + // 连接状态 + const uint8_t ble_connected = BleOta::instance().isConnected() ? 1 : 0; + const uint8_t wifi_connected = WifiOta::instance().getStaConnected() ? 1 : 0; + + // 嫌疑资源水位:定位"哪个子系统吃堆" + const uint16_t ble_queue_depth = (uint16_t)ble_ota_get_data_queue_depth(); + const uint16_t ble_reorder = (uint16_t)ble_ota_get_reorder_cached_count(); + const uint16_t spi_pending = (uint16_t)SpiMaster::instance().getPendingJobCount(); + + // 内存全景:default 含 PSRAM,与 internal 对比可判断"是内部碎片 vs 系统级紧" + const uint32_t default_free = (uint32_t)heap_caps_get_free_size(MALLOC_CAP_DEFAULT); + uint32_t alloc_count = 0; + { + multi_heap_info_t info = {}; + heap_caps_get_info(&info, CAPS); + alloc_count = (uint32_t)info.allocated_blocks; + } + + // 收集所有受监控任务栈水位,按"剩余升序"取前 8 个 + HeapLowTaskStack stacks[8]; + uint8_t stack_count = 0; + { + TaskStackMetric metrics[STACK_TASK_MAX]; + size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX); + // 先全部采集 + struct Item { char name[16]; uint32_t rem; }; + Item items[STACK_TASK_MAX]; + size_t item_n = 0; + for (size_t i = 0; i < count && i < STACK_TASK_MAX; ++i) { + if (metrics[i].handle == NULL || metrics[i].stack_size_bytes == 0) { + continue; + } + UBaseType_t hw = uxTaskGetStackHighWaterMark(metrics[i].handle); + Item &it = items[item_n++]; + it.rem = (uint32_t)hw * sizeof(StackType_t); + strncpy(it.name, metrics[i].name ? metrics[i].name : "?", sizeof(it.name) - 1); + it.name[sizeof(it.name) - 1] = '\0'; + } + // 冒泡排序按 rem 升序(item_n ≤ 9,开销小) + for (size_t i = 0; i + 1 < item_n; ++i) { + for (size_t j = i + 1; j < item_n; ++j) { + if (items[j].rem < items[i].rem) { + Item t = items[i]; items[i] = items[j]; items[j] = t; + } + } + } + // 取前 8 + for (size_t i = 0; i < item_n && stack_count < 8; ++i) { + strncpy(stacks[stack_count].name, items[i].name, sizeof(stacks[stack_count].name) - 1); + stacks[stack_count].name[sizeof(stacks[stack_count].name) - 1] = '\0'; + stacks[stack_count].remaining = items[i].rem; + ++stack_count; + } + } + + // ── 持锁覆盖写唯一槽 ── + LockGuard lock(m_mutex); + + HeapLowWaterRecord &rec = m_heapLowWater; + rec = {}; + rec.ts_ms = (uint64_t)(esp_timer_get_time() / 1000); + rec.free_heap = free_heap; + rec.min_free_heap = cur_min; + rec.largest_free_block = largest_block; + rec.sys_state = sys_state; + rec.ota_channel = ota_channel; + rec.rssi = rssi; + rec.temperature = temperature; + rec.task_stack_count = stack_count; + for (uint8_t i = 0; i < stack_count && i < 8; ++i) { + rec.task_stacks[i] = stacks[i]; + } + strncpy(rec.current_task, current_task, sizeof(rec.current_task) - 1); + rec.current_task[sizeof(rec.current_task) - 1] = '\0'; + rec.ble_connected = ble_connected; + rec.wifi_connected = wifi_connected; + rec.ota_stored_size = ota_stored; + rec.ota_total_size = ota_total; + rec.ble_data_queue_depth = ble_queue_depth; + rec.ble_reorder_cached = ble_reorder; + rec.spi_pending_jobs = spi_pending; + rec.reserved = 0; + rec.default_free_heap = default_free; + rec.alloc_count = alloc_count; + // heap hook:「最后一击」alloc(无任务名;见文件头注释) + { + uint32_t hook_size = g_hook_alloc_size; + uint32_t hook_caller = g_hook_caller_addr; + uint32_t hook_after = g_hook_free_after; + uint32_t hook_fired = g_hook_fired; + + /* 窗口内更大块(若存在):发送前小 pbuf 压低水位时,主因更可能是此前 bulk */ + uint32_t big_size = 0; + uint32_t big_caller = 0; + const uint32_t n = g_recent_alloc_n; + for (uint32_t i = 0; i < n && i < (uint32_t)kRecentAllocCap; ++i) { + if (g_recent_allocs[i].size > big_size) { + big_size = g_recent_allocs[i].size; + big_caller = g_recent_allocs[i].caller; + } + } + + rec.hook_alloc_size = hook_size; + rec.hook_caller_addr = hook_caller; + rec.hook_free_after = hook_after; + rec.hook_fired = hook_fired; + /* hook_task 存类别:net? / net?+big / bulk / tiny(非 FreeRTOS 任务名) */ + rec.hook_task[0] = '\0'; + if (hook_fired) { + const char *cls = "tiny"; + if (hook_size >= 2048U) { + cls = "bulk"; + } else if (hook_size >= kHookInterestingMin) { + if (big_size > hook_size && big_size >= 2048U) { + cls = "net?+big"; + rec.hook_caller_addr = big_caller; + } else { + cls = "net?"; + } + } + strncpy(rec.hook_task, cls, sizeof(rec.hook_task) - 1); + rec.hook_task[sizeof(rec.hook_task) - 1] = '\0'; + } + + g_hook_min_est = g_est_free; + g_hook_fired = 0; + } + rec.ip_event_count = g_ip_event_count; + + m_heapLowWaterValid = true; + m_heapLowWaterLastMin = cur_min; +} + +/** + * @brief 输出设备唯一快照槽:{"count":0|1,"records":[...]}。 + * cap 不够装下整条时退回 count=0,避免半截 JSON。 + */ +int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const +{ + LockGuard lock(m_mutex); + + if (!m_heapLowWaterValid) { + return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}"); + } + + const HeapLowWaterRecord &r = m_heapLowWater; + int pos = appendJsonfSafe(buf, cap, offset, + "{\"count\":1,\"records\":[{" + "\"ts_ms\":%llu,\"free_heap\":%lu,\"min_free_heap\":%lu," + "\"largest_free_block\":%lu,\"sys_state\":%u,\"ota_channel\":%u," + "\"rssi\":%d,\"temperature\":%.1f," + "\"current_task\":\"%s\",\"ble_connected\":%u,\"wifi_connected\":%u," + "\"ota_stored_size\":%lu,\"ota_total_size\":%lu," + "\"ble_data_queue_depth\":%u,\"ble_reorder_cached\":%u," + "\"spi_pending_jobs\":%u,\"default_free_heap\":%lu,\"alloc_count\":%lu," + "\"hook_task\":\"%s\",\"hook_alloc_size\":%lu,\"hook_caller_addr\":%lu," + "\"hook_free_after\":%lu,\"hook_fired\":%lu,\"ip_event_count\":%lu," + "\"task_stacks\":[", + (unsigned long long)r.ts_ms, + (unsigned long)r.free_heap, + (unsigned long)r.min_free_heap, + (unsigned long)r.largest_free_block, + (unsigned)r.sys_state, + (unsigned)r.ota_channel, + (int)r.rssi, + (double)r.temperature, + r.current_task, + (unsigned)r.ble_connected, + (unsigned)r.wifi_connected, + (unsigned long)r.ota_stored_size, + (unsigned long)r.ota_total_size, + (unsigned)r.ble_data_queue_depth, + (unsigned)r.ble_reorder_cached, + (unsigned)r.spi_pending_jobs, + (unsigned long)r.default_free_heap, + (unsigned long)r.alloc_count, + r.hook_task, + (unsigned long)r.hook_alloc_size, + (unsigned long)r.hook_caller_addr, + (unsigned long)r.hook_free_after, + (unsigned long)r.hook_fired, + (unsigned long)r.ip_event_count); + if (pos == offset) { + return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}"); + } + + for (uint8_t k = 0; k < r.task_stack_count && k < 8; ++k) { + int after = appendJsonfSafe(buf, cap, pos, + "%s{\"name\":\"%s\",\"remaining\":%lu}", + k == 0 ? "" : ",", + r.task_stacks[k].name, + (unsigned long)r.task_stacks[k].remaining); + if (after == pos) { + return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}"); + } + pos = after; + } + + int closed = appendJsonfSafe(buf, cap, pos, "]}]}"); + if (closed == pos) { + return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}"); + } + return closed; +} + + diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index 25bdf73..473390f 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -32,6 +32,62 @@ typedef enum { // WiFi RSSI 阈值(来自 app_config.h) #define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING ///< WiFi 信号正常阈值(别名) +// ============================================================ +// 堆低水位快照("最低空闲"创新低时记录上下文,供 /system/heap_low_log 查询) +// ============================================================ +/** @brief 单条任务栈水位(用于低水位快照内嵌的任务栈表)。 */ +struct HeapLowTaskStack { + char name[16]; ///< 任务名(截断) + uint32_t remaining; ///< 剩余栈字节(uxTaskGetStackHighWaterMark × sizeof(StackType_t)) +}; + +/** @brief 单条"堆低水位"快照记录。仅当 internal_min_free 出现新低时写入。 */ +struct HeapLowWaterRecord { + uint64_t ts_ms; ///< 启动后毫秒(esp_timer_get_time / 1000) + uint32_t free_heap; ///< 当时的总空闲堆(esp_get_free_heap_size) + uint32_t min_free_heap; ///< 当时记录到的内部堆最低水位 + uint32_t largest_free_block; ///< 最大连续空闲块 + uint8_t sys_state; ///< OTA 系统状态码(ota_system_state_t) + uint8_t ota_channel; ///< OTA 通道(0=WIFI, 1=BLE) + int8_t rssi; ///< 最近 WiFi RSSI(dBm,-127=未知) + float temperature; ///< 最近芯片温度(°C) + /* 多任务栈水位表:按"剩余升序"取前 8 个,定位"是谁的栈突然涨了"。 + * task_stack_count=0 表示未采集到任务信息。 */ + HeapLowTaskStack task_stacks[8]; + uint8_t task_stack_count; + /* 触发低水位的"当前任务":取当前 TaskHandle 的名字(taskENTER_CRITICAL 后取), + * 用于推断是谁在分配堆。空串表示未知。 */ + char current_task[16]; + /* 连接/OTA 进度上下文:区分"无连接 vs 中继期 vs 收尾期" */ + uint8_t ble_connected; ///< 0/1 + uint8_t wifi_connected; ///< 0/1 + uint32_t ota_stored_size; ///< OTA 已存储字节 + uint32_t ota_total_size; ///< OTA 总字节 + /* 嫌疑资源水位:定位"OTA/IDLE 期低谷由哪个子系统吃堆"。 + * 非 OTA 期这些应为 0;非 0 时即对应子系统持有堆缓冲。 */ + uint16_t ble_data_queue_depth; ///< BLE OTA 数据队列当前条数 + uint16_t ble_reorder_cached; ///< BLE reorder 已缓存乱序包数 + uint16_t spi_pending_jobs; ///< SPI master 待处理 job 数 + uint16_t reserved; ///< 对齐保留 + /* 内存全景:区分"内部碎片"vs"系统级紧"。 + * default_free 含 PSRAM;internal_free = free_heap(含 SPIRAM 时与 default 区分)。 + * alloc_count = 当前活跃 alloc 数,看"是不是小块大量未释放"。 */ + uint32_t default_free_heap; ///< MALLOC_CAP_DEFAULT free(含 PSRAM) + uint32_t alloc_count; ///< 内部堆当前 allocated_blocks(heap_caps_get_info) + /* heap hook:「破新低时的最后一击 alloc」。 + * 不记任务名(WiFi IRAM 禁止;且发送路径末包常把任务刷成 wifi,误导主因)。 + * hook_task 现为类别串:net? / bulk / tiny(非 FreeRTOS 任务名)。 + * hook_caller_addr 用 addr2line 解析。 */ + char hook_task[16]; ///< 分配类别 net?/bulk/tiny,不是任务名 + uint32_t hook_alloc_size; ///< 最后一击 alloc 字节数 + uint32_t hook_caller_addr; ///< 调用者返回地址(addr2line) + uint32_t hook_free_after; ///< 该次后的近似 free(est,非精确实测) + uint32_t hook_fired; ///< 0=本窗口 hook 未命中 + /* DHCP/IP 事件计数:每次 STA GOT_IP(含续约)+1。 + * 对比两次低谷记录的计数差,确认低谷是不是 DHCP 续约引起。 */ + uint32_t ip_event_count; ///< 累计 IP 获得/续约次数 +}; + /** * @brief 系统健康监控类(单例) * @@ -73,6 +129,15 @@ public: /** @brief 获取健康监控任务句柄。 */ TaskHandle_t getTaskHandle() const { return m_taskHandle; } + // ── 堆低水位快照 ── + /** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。 + * 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。 + * 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。 */ + void maybeRecordHeapLowWater(); + + /** @brief 将唯一快照序列化为 JSON:{"count":0|1,"records":[...]}。线程安全。 */ + int dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const; + /** * @brief 由 WiFi 事件回调推送连接状态(避免 HealthMonitor → WifiOta 依赖)。 * WiFi 连接/断开时由 wifi_ota 事件处理器主动调用, @@ -106,6 +171,11 @@ private: // m_wifiConnected 由 m_mutex 保护(setWifiConnected 写 / checkBeforeOta 读)。 bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送) + // 堆低水位:设备仅 1 槽(覆盖写)。m_mutex 保护。 + HeapLowWaterRecord m_heapLowWater = {}; + bool m_heapLowWaterValid = false; ///< 是否有一条有效快照 + uint32_t m_heapLowWaterLastMin = 0xFFFFFFFFu; ///< 上次记下的最低值 + static void tempMonitorCallback(void *arg); // RAII 锁守卫(与 SpiMaster::LockGuard 风格一致) diff --git a/main/monitor/stack_overflow_hook.cpp b/main/monitor/stack_overflow_hook.cpp deleted file mode 100644 index 822b233..0000000 --- a/main/monitor/stack_overflow_hook.cpp +++ /dev/null @@ -1,41 +0,0 @@ -/* - * stack_overflow_hook.cpp - FreeRTOS 栈溢出钩子 - * - * CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY 开启后,内核在任务切换时检查 - * 栈 canary,若被踩则回调 vApplicationStackOverflowHook。 - * - * 钩子在被踩栈的任务上下文中执行,此时该任务栈已损坏 —— 禁止任何依赖栈 / - * mutex / 网络栈的调用(printf 格式化、UDP sendto、xSemaphoreTake 等都可能 - * 二次崩溃)。唯一安全做法:用 uart_write_bytes 往 UART0 硬写预写好的常量 - * 字符串(物理外设直写,不需要栈空间),并置一个 volatile 标志,由 - * HealthMonitor 巡检循环(运行在完好的 health_mon 栈上)后续用 DEBUG_ERR - * 经 UDP/UART 发详细告警。 - * - * UART0 直写不受 OTA 门控抑制,是 OTA 末尾断网阶段唯一可靠的物理通道。 - */ -#include -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "driver/uart.h" -#include "esp_system.h" - -// 供 HealthMonitor 巡检检测:canary 触发后置位,由巡检发详细告警。 -volatile bool g_stackOverflowDetected = false; - -extern "C" void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) -{ - (void)xTask; - g_stackOverflowDetected = true; - - // 栈已损坏,只能做不依赖栈的最小操作。UART0 硬写常量字符串。 - static const char prefix[] = "\r\n!!! STACK OVERFLOW in task: "; - uart_write_bytes(UART_NUM_0, prefix, sizeof(prefix) - 1); - // 任务名最多 configMAX_TASK_NAME_LEN,strlen 对小已知 buffer 安全 - uart_write_bytes(UART_NUM_0, pcTaskName, strnlen(pcTaskName, configMAX_TASK_NAME_LEN)); - static const char suffix[] = " !!!\r\n"; - uart_write_bytes(UART_NUM_0, suffix, sizeof(suffix) - 1); - - /* 立刻 abort:OTA 上传 100% 后若 httpd 栈溢出却不 abort, - * 前端会一直停在 100% 像“卡住”,且 TCB 已损坏无法恢复。 */ - esp_system_abort("stack overflow"); -} \ No newline at end of file diff --git a/main/monitor/stack_overflow_hook.h b/main/monitor/stack_overflow_hook.h deleted file mode 100644 index 8faef6c..0000000 --- a/main/monitor/stack_overflow_hook.h +++ /dev/null @@ -1,18 +0,0 @@ -/* - * stack_overflow_hook.h - FreeRTOS 栈溢出钩子标志声明 - */ -#pragma once - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* canary 触发时由 vApplicationStackOverflowHook 置位; - * HealthMonitor 巡检循环检测此标志,置位后用 DEBUG_ERR 发详细告警。 */ -extern volatile bool g_stackOverflowDetected; - -#ifdef __cplusplus -} -#endif \ No newline at end of file diff --git a/main/monitor/task_stack.cpp b/main/monitor/task_stack.cpp new file mode 100644 index 0000000..0c765dd --- /dev/null +++ b/main/monitor/task_stack.cpp @@ -0,0 +1,82 @@ +/* + * task_stack.cpp - 任务栈水位收集 + FreeRTOS 栈溢出钩子 + * + * 水位表:health_mon 巡检 + /system/status 共用。 + * 溢出钩子:CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY 下内核回调; + * 被踩栈上下文禁止 mutex/网络/格式化,仅 UART0 硬写 + abort。 + */ +#include "task_stack.h" + +#include +#include "app_config.h" +#include "health_monitor.h" +#include "cpu_stats.h" +#include "ota_relay.h" +#include "wifi_ota.h" +#include "ble_ota.h" +#include "spi_master.h" +#include "driver/uart.h" +#include "esp_system.h" + +// ============================================================ +// 栈溢出钩子 +// ============================================================ +volatile bool g_stackOverflowDetected = false; + +extern "C" void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) +{ + (void)xTask; + g_stackOverflowDetected = true; + + static const char prefix[] = "\r\n!!! STACK OVERFLOW in task: "; + uart_write_bytes(UART_NUM_0, prefix, sizeof(prefix) - 1); + uart_write_bytes(UART_NUM_0, pcTaskName, strnlen(pcTaskName, configMAX_TASK_NAME_LEN)); + static const char suffix[] = " !!!\r\n"; + uart_write_bytes(UART_NUM_0, suffix, sizeof(suffix) - 1); + + /* OTA 末尾若 httpd 栈溢出却不 abort,前端会一直停在 100%。 */ + esp_system_abort("stack overflow"); +} + +// ============================================================ +// 任务栈水位表 +// ============================================================ +size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max) +{ + if (!out || max == 0) { + return 0; + } + + size_t n = 0; + + // httpd 无公开句柄;未 httpd_start 时 xTaskGetHandle 为 NULL + if (n < max) { + out[n++] = { "http_server", xTaskGetHandle("httpd"), HTTP_SERVER_STACK_SIZE }; + } + if (n < max) { + out[n++] = { "health_mon", HealthMonitor::instance().getTaskHandle(), TASK_STACK_HEALTH_MON }; + } + if (n < max) { + out[n++] = { "cpu_stats", CpuStats::instance().getTaskHandle(), TASK_STACK_CPU_STATS }; + } + if (n < max) { + out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), TASK_STACK_OTA_RELAY }; + } + if (n < max) { + out[n++] = { "spi_worker", SpiMaster::instance().getWorkerTaskHandle(), TASK_STACK_SPI_WORKER }; + } + if (n < max) { + out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), TASK_STACK_WIFI_OTA }; + } + if (n < max) { + out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, TASK_STACK_WIFI_RECONNECT }; + } + if (n < max) { + out[n++] = { "ble_ota", BleOta::instance().getTaskHandle(), TASK_STACK_BLE_OTA }; + } + if (n < max) { + out[n++] = { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), TASK_STACK_BLE_WORKER }; + } + + return n; +} diff --git a/main/monitor/task_stack.h b/main/monitor/task_stack.h new file mode 100644 index 0000000..bed3324 --- /dev/null +++ b/main/monitor/task_stack.h @@ -0,0 +1,40 @@ +/* + * task_stack.h - 任务栈:水位收集 + 溢出钩子 + * + * 1) collectTaskStackMetrics:供 health_mon 巡检与 /system/status 共用任务表 + * 2) vApplicationStackOverflowHook / g_stackOverflowDetected:canary 溢出 + */ +#pragma once + +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** canary 触发时由 vApplicationStackOverflowHook 置位(随后 abort)。 */ +extern volatile bool g_stackOverflowDetected; + +#ifdef __cplusplus +} +#endif + +#ifdef __cplusplus + +/** @brief 单个任务的栈指标描述。 */ +struct TaskStackMetric { + const char* name; ///< 任务名(展示用) + TaskHandle_t handle; ///< 句柄(可能为 NULL:任务尚未创建) + size_t stack_size_bytes; ///< 创建时分配的栈大小(字节) +}; + +/** + * @brief 收集受监控任务的栈指标。 + * @return 实际写入条数(<= max)。handle==NULL 的条目由调用方跳过。 + */ +size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max); + +#endif /* __cplusplus */ diff --git a/main/monitor/task_stack_check.cpp b/main/monitor/task_stack_check.cpp deleted file mode 100644 index ac02cde..0000000 --- a/main/monitor/task_stack_check.cpp +++ /dev/null @@ -1,55 +0,0 @@ -/* - * task_stack_check.cpp - 任务栈水位指标收集(共享) - * - * 实现 collectTaskStackMetrics():复用 http_handlers 旧版 task_metrics[] 任务表, - * 供 health_monitor 主动巡检与 /system/status 被动报告共用。 - */ -#include "task_stack_check.h" - -#include "app_config.h" -#include "health_monitor.h" -#include "cpu_stats.h" -#include "ota_relay.h" -#include "wifi_ota.h" -#include "ble_ota.h" -#include "spi_master.h" - -size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max) -{ - if (!out || max == 0) return 0; - - size_t n = 0; - - // httpd 由 esp_http_server 创建,无公开句柄;按任务名取。 - // httpd 任务在 httpd_start() 后才存在,未启动时 xTaskGetHandle 返回 NULL。 - if (n < max) { - out[n++] = { "http_server", xTaskGetHandle("httpd"), HTTP_SERVER_STACK_SIZE }; - } - if (n < max) { - out[n++] = { "health_mon", HealthMonitor::instance().getTaskHandle(), TASK_STACK_HEALTH_MON }; - } - if (n < max) { - out[n++] = { "cpu_stats", CpuStats::instance().getTaskHandle(), TASK_STACK_CPU_STATS }; - } - /* 转发热路径:ota_relay(业务)+ spi_worker(帧缓冲,此前监控缺口) */ - if (n < max) { - out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), TASK_STACK_OTA_RELAY }; - } - if (n < max) { - out[n++] = { "spi_worker", SpiMaster::instance().getWorkerTaskHandle(), TASK_STACK_SPI_WORKER }; - } - if (n < max) { - out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), TASK_STACK_WIFI_OTA }; - } - if (n < max) { - out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, TASK_STACK_WIFI_RECONNECT }; - } - if (n < max) { - out[n++] = { "ble_ota", BleOta::instance().getTaskHandle(), TASK_STACK_BLE_OTA }; - } - if (n < max) { - out[n++] = { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), TASK_STACK_BLE_WORKER }; - } - - return n; -} \ No newline at end of file diff --git a/main/monitor/task_stack_check.h b/main/monitor/task_stack_check.h deleted file mode 100644 index db379fd..0000000 --- a/main/monitor/task_stack_check.h +++ /dev/null @@ -1,32 +0,0 @@ -/* - * task_stack_check.h - 任务栈水位指标收集(共享) - * - * 将受监控任务的任务名/句柄/栈大小统一收集,供: - * - health_monitor 巡检主动告警(低水位时 DEBUG_ERR,突破 OTA 门控) - * - http_handlers /system/status 被动报告(前端每秒轮询展示) - * 两处复用同一份任务表,避免散落维护。 - * - * 表内含转发关键:ota_relay、spi_worker(帧缓冲热路径)。 - */ -#pragma once - -#include -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" - -/** @brief 单个任务的栈指标描述。 */ -struct TaskStackMetric { - const char* name; ///< 任务名(展示用) - TaskHandle_t handle; ///< FreeRTOS 任务句柄(可能为 NULL:任务尚未创建) - size_t stack_size_bytes; ///< 创建该任务时分配的栈大小(字节) -}; - -/** - * @brief 收集所有受监控任务的栈指标。 - * @param out 输出数组。 - * @param max out 数组容量。 - * @return 实际写入条数(<= max)。 - * - * 注意:部分任务句柄在任务创建前为 NULL,调用方应跳过 handle==NULL 的条目。 - */ -size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max); \ No newline at end of file diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index 2a5e568..d7b905f 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -187,7 +187,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) esp_err_t err = esp_ota_begin(m_partition, OTA_WITH_SEQUENTIAL_WRITES, &m_handle); const int64_t t1 = esp_timer_get_time(); if (err != ESP_OK) { - DEBUG_ERR(TAG, "esp_ota_begin(SEQUENTIAL) 失败: %s (耗时 %lld ms)", + DEBUG_ERR(TAG, "ota_begin fail:%s %lldms", esp_err_to_name(err), (long long)((t1 - t0) / 1000)); return beginCleanupAndReturn(err); } @@ -201,7 +201,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) m_ended = false; memset(m_sha256, 0, sizeof(m_sha256)); - DEBUG_DBG(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase-on-write)", + DEBUG_DBG(TAG, "ESP32 OTA begin part=%s size=%u (erase-on-write)", m_partition->label, (unsigned)total_size); return ESP_OK; } @@ -224,9 +224,8 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len) if (err == ESP_OK) { m_received += len; } else { - DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u offset=%u free=%lu)", - esp_err_to_name(err), (unsigned)len, (unsigned)m_received, - (unsigned long)esp_get_free_heap_size()); + DEBUG_ERR(TAG, "ota_write %s n=%u off=%u", + esp_err_to_name(err), (unsigned)len, (unsigned)m_received); } return err; } @@ -257,7 +256,7 @@ esp_err_t Esp32OtaCore::computeShaFromPartition() if (to_read > sizeof(s_sha_read_buf)) to_read = sizeof(s_sha_read_buf); esp_err_t rd_err = esp_partition_read(m_partition, offset, s_sha_read_buf, to_read); if (rd_err != ESP_OK) { - DEBUG_ERR(TAG, "SHA 计算: 分区读失败 @offset %u: %s", + DEBUG_ERR(TAG, "SHA读失败 off=%u %s", (unsigned)offset, esp_err_to_name(rd_err)); mbedtls_sha256_free(&s_sha_ctx); return rd_err; @@ -327,7 +326,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, DEBUG_DBG(TAG, "ESP32 OTA 校验 SHA-256..."); esp_err_t sha_err = computeShaFromPartition(); if (sha_err != ESP_OK) { - DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err)); + DEBUG_ERR(TAG, "SHA失败:%s 中止", esp_err_to_name(sha_err)); return finalizeCleanupAndReturn(sha_err); } @@ -353,8 +352,10 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, if (sha_match) { DEBUG_DBG(TAG, "SHA-256 校验通过"); } else { - DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s", - frontend_sha_hex, device_hex); + /* 与正常刷写失败一致:先报异常,再分 2 行打两个 SHA-256 */ + DEBUG_ERR(TAG, "SHA-256 不匹配"); + DEBUG_ERR(TAG, "frontend=%s", frontend_sha_hex); + DEBUG_ERR(TAG, "device=%s", device_hex); return finalizeCleanupAndReturn(ESP_ERR_INVALID_VERSION); } } else if (out_device_sha_hex) { diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 765ea27..881daf7 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -148,12 +148,12 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch // RELAYING 是中继阶段,不可中断,也拒绝。 if (h && m_sysState == OTA_SYS_GD32_STORING) { if (m_otaChannel != channel) { - DEBUG_WARN(TAG, "GD32 存储中(通道=%d),拒绝跨通道请求(通道=%d)", + DEBUG_WARN(TAG, "GD32存中 ch=%d 拒跨通道 ch=%d", (int)m_otaChannel, (int)channel); unlock(h); return ESP_ERR_INVALID_STATE; } - DEBUG_WARN(TAG, "GD32 存储仍在进行中(同通道),中止旧存储并重新开始"); + DEBUG_WARN(TAG, "GD32同通道重传:中止旧存储"); doAbortLocked(); // 清 meta + IDLE,与 abort 路径一致 // epoch 将在下方设置 GD32_STORING 后递增 } @@ -182,7 +182,7 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch m_sysState = OTA_SYS_GD32_STORING; m_gd32StorageDone = false; m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻 - DEBUG_DBG(TAG, "GD32 固件存储开始: %" PRIu32 " 字节, 系统状态 → GD32_STORING (epoch=%u, channel=%d)", + DEBUG_DBG(TAG, "GD32存开始 %" PRIu32 "B →STORING ep=%u ch=%d", total_size, m_otaEpoch, (int)channel); } unlock(h); @@ -242,20 +242,18 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t void OtaCoordinator::completeGd32Storage() { bool h = lock(); - bool resume_ble = false; if (h && m_sysState == OTA_SYS_GD32_STORING) { m_sysState = OTA_SYS_GD32_STORED; m_gd32StorageDone = true; m_busySinceTick = 0; // ★ STORED 是非忙状态 - resume_ble = (m_otaChannel == OTA_CHANNEL_WIFI); DEBUG_DBG(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED"); + /* WiFi 路径不在此处恢复 BLE: + * 旧逻辑 HTTP 一收完就 resume,55KB 极快时日志紧贴 raw upload start, + * 像「一刷新就共存恢复」。挂起到中继结束/中止(notifyRelayComplete、abort)。 */ } else if (h) { DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState); } unlock(h); - if (resume_ble) { - wifiOtaResumeBleIfNeeded(); - } } esp_err_t OtaCoordinator::finishGd32StorageAfterReceive() @@ -281,7 +279,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay() if (m_sysState != OTA_SYS_GD32_STORED && m_sysState != OTA_SYS_IDLE && m_sysState != OTA_SYS_GD32_COMPLETE) { - DEBUG_WARN(TAG, "拒绝触发中继: 当前状态 %s (%d) 不在白名单", + DEBUG_WARN(TAG, "拒中继 state=%s(%d)", stateToString(m_sysState), (int)m_sysState); unlock(h); return ESP_ERR_INVALID_STATE; @@ -419,12 +417,12 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann // 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。 if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) { if (m_otaChannel != channel) { - DEBUG_WARN(TAG, "ESP32 OTA 接收中(通道=%d),拒绝跨通道请求(通道=%d)", + DEBUG_WARN(TAG, "ESP32收中 ch=%d 拒跨通道 ch=%d", (int)m_otaChannel, (int)channel); unlock(h); return ESP_ERR_INVALID_STATE; } - DEBUG_WARN(TAG, "ESP32 OTA 仍在接收中(同通道),中止旧会话并重新开始"); + DEBUG_WARN(TAG, "ESP32同通道重传:中止旧会话"); doAbortLocked(); // abort core + IDLE + 恢复 CAN 反馈 // epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效 } @@ -434,7 +432,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann // core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。 if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) { if (!Esp32OtaCore::instance().isActive()) { - DEBUG_WARN(TAG, "ESP32_FINALIZING 残留(core 已空闲),强制复位后允许新会话"); + DEBUG_WARN(TAG, "FINALIZING残留(core空闲),强制复位"); doAbortLocked(); } else { DEBUG_WARN(TAG, "系统忙 (ESP32_FINALIZING, core 活跃),拒绝 ESP32 OTA"); @@ -447,7 +445,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann if (h && (m_sysState == OTA_SYS_GD32_STORING || m_sysState == OTA_SYS_GD32_RELAYING || m_sysState == OTA_SYS_ESP32_REBOOTING)) { - DEBUG_WARN(TAG, "系统忙 (state=%d, %s),拒绝 ESP32 OTA", + DEBUG_WARN(TAG, "忙 state=%d(%s) 拒ESP32 OTA", (int)m_sysState, stateToString(m_sysState)); unlock(h); return ESP_ERR_INVALID_STATE; @@ -488,7 +486,7 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe // 避免连续 isIdle() + getSystemState() 各加锁一次(共 2 次锁)。 ota_system_state_t cur = getSystemState(); if (!isIdleState(cur) && cur != OTA_SYS_ERROR) { - DEBUG_WARN(TAG, "ESP32 OTA 被拒绝且系统非空闲 (state=%d),不强制清理,直接失败", + DEBUG_WARN(TAG, "ESP32拒且非空闲 state=%d,不强制清", (int)cur); if (channel == OTA_CHANNEL_WIFI) { wifiOtaResumeBleIfNeeded(); @@ -497,9 +495,9 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe } if (cur == OTA_SYS_ERROR) { - DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但协调器处于 ERROR,清理底层残留句柄后重试"); + DEBUG_WARN(TAG, "ESP32拒但ERROR,清残留后重试"); } else { - DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但系统空闲,判定为锁残留,强制清理状态机后重试"); + DEBUG_WARN(TAG, "ESP32拒但空闲,判锁残留,强制清后重试"); } Esp32OtaCore::instance().forceCleanup(); err = beginEsp32Ota(total_size, channel); diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 7d6f208..f947386 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -596,18 +596,23 @@ void OtaRelay::logRespPayload(const char *stage, uint8_t resp_type, const uint8_ if (DebugLog::instance().getLogLevel() < DEBUG_LOG_DBG) { return; } - char hex[193] = {0}; - size_t n = (len > 32U) ? 32U : len; + char hx[17] = {0}; + size_t n = (len > 8U) ? 8U : len; if (payload) { for (size_t i = 0; i < n; ++i) { - snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", payload[i]); + snprintf(&hx[i * 2], sizeof(hx) - i * 2, "%02X", payload[i]); } } else { n = 0; } - DEBUG_DBG("OTA", "%s resp: type=0x%02X len=%u payload[%u]=%s%s", - stage, resp_type, (unsigned)len, (unsigned)n, - n > 0 ? hex : "", len > n ? "..." : ""); + /* stage 名截断,避免整行超 128B */ + char stg[12]; + snprintf(stg, sizeof(stg), "%.11s", stage ? stage : "?"); + DEBUG_DBG("OTA", "%s rsp t=%02X n=%u", + stg, resp_type, (unsigned)len); + if (n > 0) { + DEBUG_DBG("OTA", " hx=%s%s", hx, len > n ? ".." : ""); + } } /** @@ -858,10 +863,9 @@ esp_err_t OtaRelay::requestGd32AppEnterBootloader() DEBUG_WARN("OTA", "ENTER_BOOT 发送失败: %s", esp_err_to_name(boot_err)); } - /* 3) 硬门控:HELLO role=boot(给 APP 擦页+复位+BOOT 起机更长时间) */ + /* 3) 硬门控:HELLO role=boot(成功时 SPI waitForBootloaderReady 已打就绪日志) */ esp_err_t wait_err = SpiMaster::instance().waitForBootloaderReady(12000); if (wait_err == ESP_OK) { - DEBUG_INFO("OTA", "Bootloader 已就绪 (HELLO role=boot)"); return ESP_OK; } @@ -882,7 +886,7 @@ esp_err_t OtaRelay::requestGd32AppEnterBootloader() DEBUG_ERR("OTA", "Bootloader HELLO 就绪失败(重试后): %s", esp_err_to_name(wait_err)); return wait_err; } - DEBUG_INFO("OTA", "Bootloader 已就绪 (HELLO role=boot, 二次)"); + /* 成功日志由 SPI waitForBootloaderReady 统一输出 */ return ESP_OK; } @@ -936,8 +940,8 @@ ota_result_t OtaRelay::doHandshakeReq(uint32_t attempts) } } - DEBUG_WARN("OTA", "HANDSHAKE_REQ 未就绪,重试 %" PRIu32 "/%" PRIu32 ": err=%d resp_type=0x%02X resp_len=%u", - attempt + 1U, attempts, last_err, last_resp_type, (unsigned)last_resp_len); + DEBUG_WARN("OTA", "HANDSHAKE重试 %" PRIu32 "/%" PRIu32 " err=%d t=0x%02X", + attempt + 1U, attempts, last_err, last_resp_type); vTaskDelay(pdMS_TO_TICKS(kHandshakeRetryGapMs)); } @@ -964,7 +968,7 @@ ota_result_t OtaRelay::sendAndWaitAckShape(const char *stage, uint8_t cmd, err = pollUntilExpectedAckShape(stage, &resp_type, resp_payload, resp_max, resp_len, poll_timeout_ms, wait_rounds, max_extra_polls, matcher); if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !matcher(resp_payload, *resp_len)) { - DEBUG_ERR("OTA", "%s 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", + DEBUG_ERR("OTA", "%s fail err=%d t=0x%02X n=%zu", stage, err, resp_type, *resp_len); return OTA_RESULT_ERR_TIMEOUT; } @@ -1019,14 +1023,7 @@ ota_result_t OtaRelay::doChallengeAndAuth() OtaRelay::isConfirmPlainAck); if (res != OTA_RESULT_OK) return res; - { - char hex[73] = {0}; - size_t n = (resp_len > 36U) ? 36U : resp_len; - for (size_t i = 0; i < n; ++i) { - snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", resp_payload[i]); - } - DEBUG_INFO("OTA", "CONFIRM 验证通过, resp_len=%zu, payload=%s", resp_len, hex); - } + DEBUG_INFO("OTA", "CONFIRM ok, resp_len=%zu", resp_len); Protocol::sessionKeyInit(&m_sessionCtx, m_kSession); return OTA_RESULT_OK; @@ -1110,9 +1107,8 @@ ota_result_t OtaRelay::prepareRelayPacket(uint32_t seq, uint32_t flash_offset, c tx_buf[59] = (uint8_t)((if_end >> 24) & 0xFFU); calculateCrc32WithKey(m_kSession, plain48, &tx_buf[60]); - DEBUG_DBG("OTA", "包 %" PRIu32 ": chunk=%u if_end=0x%08" PRIX32 " crc32=%02X%02X%02X%02X", - seq, (unsigned)chunk_size, if_end, - tx_buf[60], tx_buf[61], tx_buf[62], tx_buf[63]); + DEBUG_DBG("OTA", "包%" PRIu32 " n=%u end=%08" PRIX32, + seq, (unsigned)chunk_size, if_end); /* 不在此写 m_transferCurrentSeq:预组下一包时不应污染“当前发送序号” */ if (out_chunk_size) { @@ -1188,8 +1184,8 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], TickType_t last_hb = xTaskGetTickCount(); while (xTaskGetTickCount() < poll_deadline) { if ((xTaskGetTickCount() - last_hb) >= pdMS_TO_TICKS(1000)) { - DEBUG_INFO("OTA", "包 %" PRIu32 " 等 ACK… send=%" PRIu32 " type=0x%02X wait=%d credit=%u", - seq, send_i, resp_type, (int)saw_wait, + DEBUG_INFO("OTA", "包%" PRIu32 "等ACK send=%" PRIu32 " t=0x%02X cr=%u", + seq, send_i, resp_type, (unsigned)SpiMaster::instance().remoteCredit()); last_hb = xTaskGetTickCount(); } @@ -1244,11 +1240,11 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], } } - DEBUG_ERR("OTA", "包 %" PRIu32 " 失败 (elapsed=%" PRIu32 " ms credit=%u type=0x%02X saw_wait=%d)", + DEBUG_ERR("OTA", "包%" PRIu32 "失败 el=%" PRIu32 "ms t=0x%02X cr=%u", seq, (uint32_t)((xTaskGetTickCount() - pkt_start) * portTICK_PERIOD_MS), - (unsigned)SpiMaster::instance().remoteCredit(), - resp_type, (int)saw_wait); + resp_type, + (unsigned)SpiMaster::instance().remoteCredit()); *out_result = OTA_RESULT_ERR_TIMEOUT; return true; } @@ -1369,10 +1365,9 @@ bool OtaRelay::probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime) gd32Version = ver; if (out_uptime) *out_uptime = uptime; // 启动探测等场景用 DBG;OTA 成功路径由 doWaitForAppAlive 统一 INFO 总结 - DEBUG_DBG("OTA", "GD32 APP 存活: uptime=%" PRIu32 "s APP=v%d.%d.%d.%d BOOT=v%d.%d.%d.%d", + DEBUG_DBG("OTA", "GD32 APP up=%" PRIu32 "s APP=v%d.%d.%d.%d", 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); + ver.app_major, ver.app_minor, ver.app_patch, ver.app_build); return true; } if (out_uptime) *out_uptime = 0; @@ -1432,7 +1427,7 @@ ota_result_t OtaRelay::doSendFinishOta() continue; } - DEBUG_WARN("OTA", "FINISH_OTA 响应异常, resp_type=0x%02X, resp_len=%zu, retry=%" PRIu32 "/10", + DEBUG_WARN("OTA", "FINISH_OTA异常 t=0x%02X n=%zu r=%" PRIu32, resp_type, resp_len, retry + 1); vTaskDelay(pdMS_TO_TICKS(10)); } @@ -1482,10 +1477,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms) return OTA_RESULT_ERR_GD32; } - 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, - ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build); + /* 版本已在 HELLO/PING 身份(SPI ID APP/BOOT)打印,此处不重复 */ + DEBUG_INFO("OTA", "GD32 OTA完成"); return OTA_RESULT_OK; } @@ -1539,11 +1532,26 @@ void OtaRelay::taskEntry(void *pvParameters) if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_APP) { session_ok = true; + /* HELLO 已带身份时直接缓存版本/开关,无需再等 PING 才亮版本 */ + if (SpiMaster::instance().gd32IdKnown()) { + StateLockGuard lock; + gd32Version = SpiMaster::instance().gd32VersionCached(); + gd32AppAlive = true; + alive = true; + DEBUG_INFO("OTA", "HELLO就绪 APP=v%d.%d.%d.%d", + gd32Version.app_major, gd32Version.app_minor, + gd32Version.app_patch, gd32Version.app_build); + DEBUG_INFO("OTA", "HELLO就绪 BOOT=v%d.%d.%d.%d", + gd32Version.boot_major, gd32Version.boot_minor, + gd32Version.boot_patch, gd32Version.boot_build); + break; + } } else { DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U); continue; } } + /* 旧固件无 HELLO 身份:回退 APP PING */ if (probeAndCacheAppAlive(2000)) { alive = true; break; @@ -1576,7 +1584,7 @@ void OtaRelay::taskEntry(void *pvParameters) // 成功日志已在 doWaitForAppAlive 输出,此处不再重复 vTaskDelay(pdMS_TO_TICKS(3000)); } else if (result == OTA_RESULT_ERR_TIMEOUT) { - DEBUG_ERR("OTA", "OTA 超时中止 (TIMEOUT=%d,含全局预算/停刷/单包)", (int)result); + DEBUG_ERR("OTA", "OTA超时中止 TIMEOUT=%d", (int)result); } else if (result == OTA_RESULT_ERR_USER_ABORT) { DEBUG_ERR("OTA", "OTA 用户中止 (USER_ABORT=%d)", (int)result); } else { diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 02c7581..be05286 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -15,6 +15,11 @@ void drainOtaDataQueue() } } +uint32_t ble_ota_get_data_queue_depth() +{ + return s_otaDataQueue ? (uint32_t)uxQueueMessagesWaiting(s_otaDataQueue) : 0; +} + BleOta& BleOta::instance() { static BleOta inst; @@ -145,7 +150,7 @@ void ble_ota_boost_wifi_ps() s_wifi_ps_boosted = true; // 通过协调器通知 WiFi 层 OtaCoordinator::instance().notifyBleCoexStart(); - DEBUG_DBG(TAG, "BLE OTA 数据传输进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)"); + DEBUG_DBG(TAG, "BLE OTA中:暂停RSSI,保持PS_NONE"); } /** @@ -157,7 +162,7 @@ void ble_ota_restore_wifi_ps() s_wifi_ps_boosted = false; // 通过协调器通知 WiFi 层 OtaCoordinator::instance().notifyBleCoexEnd(); - DEBUG_DBG(TAG, "BLE OTA 数据传输结束:恢复 WiFi RSSI 轮询(GD32 中继仍可能进行中)"); + DEBUG_DBG(TAG, "BLE OTA数据结束:恢复RSSI轮询"); } // ============================================================ @@ -286,8 +291,8 @@ esp_err_t BleOta::resumeStack() if (!m_stackRunning) { /* 仅当曾 hard-deinit 过才需要完整 serverStart */ - DEBUG_INFO(TAG, "WiFi OTA 结束:BLE 栈未运行,尝试 serverStart free=%lu", - (unsigned long)esp_get_free_heap_size()); + DEBUG_DBG(TAG, "WiFi共存:BLE栈未运行,serverStart free=%lu", + (unsigned long)esp_get_free_heap_size()); esp_err_t err = serverStart(); if (err != ESP_OK) { DEBUG_ERR(TAG, "resumeStack/serverStart 失败: %s", esp_err_to_name(err)); @@ -297,9 +302,9 @@ esp_err_t BleOta::resumeStack() return ESP_OK; } - /* 软挂起恢复:重新开广播即可 */ - DEBUG_INFO(TAG, "WiFi OTA 结束:恢复 BLE 广播 free=%lu", - (unsigned long)esp_get_free_heap_size()); + /* 软挂起恢复:重新开广播即可(非 BLE OTA 会话,仅射频共存;默认 INFO 不刷) */ + DEBUG_DBG(TAG, "WiFi共存:恢复BLE广播 free=%lu", + (unsigned long)esp_get_free_heap_size()); m_bleReady = true; ble_ota_start_advertising(); return ESP_OK; @@ -375,17 +380,17 @@ void BleOta::taskEntry(void *pvParameters) 12, // 略提高:尽快排空队列,减少 withoutResponse 连发丢包 NULL); if (wk_ok != pdPASS) { - DEBUG_ERR(TAG, "OTA 数据工作线程创建失败,BLE OTA 数据传输不可用"); + DEBUG_ERR(TAG, "OTA工作线程创建失败,BLE不可用"); vQueueDelete(s_otaDataQueue); s_otaDataQueue = nullptr; } } else { - DEBUG_ERR(TAG, "OTA 数据队列创建失败,BLE OTA 数据传输不可用"); + DEBUG_ERR(TAG, "OTA队列创建失败,BLE不可用"); } esp_err_t err = BleOta::instance().serverStart(); if (err != ESP_OK) { - DEBUG_ERR(TAG, "BLE 服务器启动失败: %s → BLE OTA 不可用", + DEBUG_ERR(TAG, "BLE serverStart失败:%s", esp_err_to_name(err)); } diff --git a/main/server/ble_ota.h b/main/server/ble_ota.h index 21f1373..9710767 100644 --- a/main/server/ble_ota.h +++ b/main/server/ble_ota.h @@ -81,4 +81,12 @@ public: bool m_suspendedForWifiOta = false; ///< 因 WiFi OTA 主动挂起 }; +// ── 堆低水位快照辅助 getter(不在 BleOta 类内,file-static 资源访问) ── +/** @brief BLE OTA 数据队列当前深度(每个 item 持有 malloc 的 data 缓冲)。 + * 供 /system/heap_low_log 采样。非 OTA 期返回 0。 */ +uint32_t ble_ota_get_data_queue_depth(); +/** @brief BLE reorder buffer 已缓存的乱序包数(每个 slot 持有 malloc 的 payload)。 + * 供 /system/heap_low_log 采样。非 ESP32 OTA 期返回 0。 */ +uint32_t ble_ota_get_reorder_cached_count(); + #endif // BLE_OTA_H diff --git a/main/server/ble_ota_data.cpp b/main/server/ble_ota_data.cpp index 95bb35c..61889be 100644 --- a/main/server/ble_ota_data.cpp +++ b/main/server/ble_ota_data.cpp @@ -62,15 +62,14 @@ uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase) struct ble_gap_conn_desc desc = {}; int rc = ble_gap_conn_find(conn_handle, &desc); if (rc != 0) { - DEBUG_WARN(TAG, "%s读取 BLE 连接参数失败: rc=%d", phase, rc); + DEBUG_WARN(TAG, "%sBLE param读失败 rc=%d", phase, rc); return 0; } - DEBUG_DBG(TAG, "%sBLE 连接参数: interval=%.2fms latency=%u supervision=%ums", + DEBUG_DBG(TAG, "%sitv=%.2f lat=%u", phase, desc.conn_itvl * 1.25f, - (unsigned)desc.conn_latency, - (unsigned)desc.supervision_timeout * 10U); + (unsigned)desc.conn_latency); return desc.conn_itvl; } @@ -97,12 +96,10 @@ int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compatible) params.supervision_timeout = 200; int rc = ble_gap_update_params(conn_handle, ¶ms); - DEBUG_DBG(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms ce=%u..%u rc=%d", - windows_compatible ? "Windows兼容" : "高速", + DEBUG_DBG(TAG, "BLEp(%s) %.1f-%.1f rc=%d", + windows_compatible ? "Win" : "Hi", params.itvl_min * 1.25f, params.itvl_max * 1.25f, - (unsigned)params.min_ce_len, - (unsigned)params.max_ce_len, rc); return rc; } @@ -129,11 +126,8 @@ void esp32_reorder_alloc() s_fallback_ooo_count = 0; if (!s_reorder.slots || !s_reorder.lens) { - DEBUG_WARN(TAG, "Reorder buffer 分配失败 (slots=%p lens=%p),回退到丢弃策略。" - "heap: free=%u min=%u", - s_reorder.slots, s_reorder.lens, - (unsigned)esp_get_free_heap_size(), - (unsigned)esp_get_minimum_free_heap_size()); + DEBUG_WARN(TAG, "Reorder分配失败,回退丢弃 free=%u", + (unsigned)esp_get_free_heap_size()); // 释放可能部分成功的分配 free(s_reorder.slots); s_reorder.slots = nullptr; free(s_reorder.lens); s_reorder.lens = nullptr; @@ -143,7 +137,7 @@ void esp32_reorder_alloc() } s_reorder.active = true; s_reorder.fallback = false; - DEBUG_DBG(TAG, "Reorder buffer 已启用: window=%u payloadCap=%u heap=%u", + DEBUG_DBG(TAG, "Reorder ok win=%u cap=%u heap=%u", (unsigned)s_reorder.windowSize, (unsigned)s_reorder.payloadCap, (unsigned)esp_get_free_heap_size()); } @@ -178,6 +172,16 @@ void esp32_reorder_free() s_fallback_ooo_count = 0; } +/** + * @brief 取当前 reorder buffer 已缓存的乱序包数(每个 slot 持有 malloc 的 payload)。 + * 供堆低水位快照采样,定位"OTA 期低谷是不是 BLE reorder 引起"。 + * 非 OTA 期 active=false,返回 0。 + */ +uint32_t ble_ota_get_reorder_cached_count() +{ + return s_reorder.active ? s_reorder.cachedCount : 0; +} + /** * @brief flush expected 对应的连续缓存包到 flash,并释放对应 slot。 * 由 esp32_write_ordered 在每次 expected 推进后调用。 @@ -249,14 +253,14 @@ static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd, ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U); if (shouldAck) { uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); - DEBUG_WARN(TAG, "ESP32 乱序包跳过(回退): seq=%u (expected=%u),节流 ACK bytes=%u (ooo=%u)", + DEBUG_WARN(TAG, "乱序 s=%u e=%u ACK=%u ooo=%u", seq, (unsigned)s_reorder.expected, (unsigned)curReceived, (unsigned)s_fallback_ooo_count); sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, curReceived); s_fallback_ooo_count = 0; } else { - DEBUG_DBG(TAG, "ESP32 乱序包跳过(回退): seq=%u (expected=%u),抑制 ACK (ooo=%u)", + DEBUG_DBG(TAG, "ESP32乱序 s=%u e=%u 抑ACK ooo=%u", seq, (unsigned)s_reorder.expected, (unsigned)s_fallback_ooo_count); } @@ -289,7 +293,7 @@ static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd, s_reorder.expected++; uint32_t flushed = esp32_reorder_flush(); if (flushed > 0) { - DEBUG_INFO(TAG, "Reorder 缺口已补齐: seq=%u,连续 flush=%u 包,expected→%u", + DEBUG_INFO(TAG, "Reorder补齐 seq=%u flush=%u exp→%u", seq, (unsigned)flushed, (unsigned)s_reorder.expected); } return ESP_OK; @@ -300,7 +304,7 @@ static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd, if (idx < s_reorder.windowSize) { if (s_reorder.slots[idx]) { // 该 slot 已被占用(重复的乱序包),跳过 - DEBUG_DBG(TAG, "Reorder slot 已占: seq=%u idx=%u,跳过重复包", + DEBUG_DBG(TAG, "Reorder占 s=%u i=%u 跳过", seq, (unsigned)idx); return ESP_OK; } @@ -309,8 +313,7 @@ static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd, uint8_t *copy = (uint8_t *)malloc(data_len); if (!copy) { uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); - DEBUG_ERR(TAG, "Reorder slot malloc 失败: seq=%u expected=%u bytes=%u," - "ACK 报告缺口并 abort", + DEBUG_ERR(TAG, "Reorder OOM s=%u e=%u n=%u ACK+abort", seq, (unsigned)s_reorder.expected, (unsigned)curReceived); sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, @@ -322,18 +325,18 @@ static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd, s_reorder.lens[idx] = data_len; s_reorder.cachedCount++; if (s_reorder.cachedCount == 1U) { - DEBUG_INFO(TAG, "Reorder 检测到缺口: expected=%u,开始缓存 seq=%u", + DEBUG_INFO(TAG, "Reorder缺口 exp=%u cache seq=%u", (unsigned)s_reorder.expected, seq); } - DEBUG_DBG(TAG, "Reorder 缓存乱序包: seq=%u idx=%u (expected=%u)", + DEBUG_DBG(TAG, "Reorder cache s=%u i=%u exp=%u", seq, (unsigned)idx, (unsigned)s_reorder.expected); return ESP_OK; } // seq 超出窗口范围(防御性,理论不会发生) uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); - DEBUG_WARN(TAG, "ESP32 包超出 reorder 窗口: seq=%u (expected=%u base=%u win=%u),ACK bytes=%u", - seq, (unsigned)s_reorder.expected, (unsigned)s_reorder.windowBase, + DEBUG_WARN(TAG, "超窗 s=%u e=%u w=%u ACK=%u", + seq, (unsigned)s_reorder.expected, (unsigned)s_reorder.windowSize, (unsigned)curReceived); sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, curReceived); @@ -448,7 +451,7 @@ void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes) esp_err_t err = BleOta::instance().notifyResponse(ack, sizeof(ack)); if (err != ESP_OK) { - DEBUG_WARN(TAG, "发送 ACK 失败: cmd=0x%02X seq=%u err=%s", + DEBUG_WARN(TAG, "ACK失败 cmd=0x%02X seq=%u %s", cmd, seq, esp_err_to_name(err)); } else { DEBUG_DBG(TAG, "已发送 ACK: cmd=0x%02X seq=%u bytes=%lu", @@ -530,7 +533,7 @@ void ble_gd32_relay_wait_task(void *pvParameters) ota_result_t result = (ota_result_t)((int32_t)result_msg); if (result == OTA_RESULT_OK) { - DEBUG_INFO(TAG, "GD32 BLE OTA 中继完成,通知前端 (stage=0x11, session=%u)", session_id); + DEBUG_INFO(TAG, "GD32中继完成 stage=0x11 sess=%u", session_id); sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_DONE, packBleFinishValue(session_id, 0)); } else { @@ -596,7 +599,7 @@ int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, memcpy(heap_buf, buf, (size_t)recv_len); BleOtaDataBatch item = { heap_buf, (size_t)recv_len }; if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) { - DEBUG_WARN(TAG, "DATA_IN 工作队列满,丢弃 %d 字节(等待客户端重传)", recv_len); + DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len); free(heap_buf); } } else { @@ -626,12 +629,12 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, if (Esp32OtaCore::instance().isActive()) { ota_channel_t curCh = OtaCoordinator::instance().getOtaChannel(); if (curCh != OTA_CHANNEL_BLE) { - DEBUG_WARN(TAG, "ESP32 OTA 被其他通道占用(ch=%d),BLE 拒绝抢占", + DEBUG_WARN(TAG, "ESP32被ch=%d占用,BLE拒抢", (int)curCh); sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); return true; } - DEBUG_INFO(TAG, "ESP32 OTA 会话已激活,seq=0 视为重传,保留 reorder 状态"); + DEBUG_INFO(TAG, "ESP32已激活 seq=0重传,保留reorder"); } else { // 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成 esp_err_t err = ble_esp32_ota_begin(total_size); @@ -696,7 +699,7 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, isLastPacket || firstPacketReady)) { if (isLastPacket || transferComplete) { - DEBUG_DBG(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32, + DEBUG_DBG(TAG, "ESP32尾包 s=%u n=%zu rx=%u tot=%" PRIu32, seq, data_len, (unsigned int)afterReceived, total_size); } else if (firstPacketReady) { DEBUG_DBG(TAG, "ESP32 begin 就绪 ACK: seq=0 received=%u total=%" PRIu32, @@ -709,7 +712,7 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, } if (transferComplete) { - DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)", + DEBUG_INFO(TAG, "ESP32收完 %u/%" PRIu32 "B chunks=%u", (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1); OtaCoordinator::instance().notifyEsp32ReceiveComplete(); BleOta::instance().m_esp32LastCmd = cmd; @@ -743,12 +746,12 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, // (否则会 erase 分区、清 meta,毁掉已写入进度)。 if (s_ble_gd32_meta_active && s_ble_gd32_meta.total_size == total_size) { if (s_ble_gd32_meta.stored_size > 0) { - DEBUG_INFO(TAG, "GD32 会话已激活,seq=0 视为重传,跳过写入 stored=%" PRIu32, + DEBUG_INFO(TAG, "GD32已激活 seq=0重传 skip stored=%" PRIu32, s_ble_gd32_meta.stored_size); sendWindowAck(cmd, 0, s_ble_gd32_meta.stored_size); return false; } - DEBUG_INFO(TAG, "GD32 会话已激活且未落盘,seq=0 重传首块(不重新 prepare)"); + DEBUG_INFO(TAG, "GD32未落盘 seq=0重传首块(不prepare)"); // fall through:stored_size==0 时重写 offset 0 } else { // 与 WiFi 共用 prepareGd32Storage:validate + begin + erase + clearMeta + saveMeta @@ -810,12 +813,12 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, (s_gd32_ooo_count >= BLE_OTA_ACK_WINDOW) || ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U); if (shouldAck) { - DEBUG_WARN(TAG, "GD32 乱序包跳过: seq=%u (last_seq=%" PRIu32 "),节流 ACK bytes=%" PRIu32 " (ooo=%u)", + DEBUG_WARN(TAG, "GD32乱序 s=%u last=%" PRIu32 " ACK=%" PRIu32 " ooo=%u", seq, meta.last_seq, meta.stored_size, (unsigned)s_gd32_ooo_count); sendWindowAck(cmd, (uint16_t)meta.last_seq, meta.stored_size); s_gd32_ooo_count = 0; } else { - DEBUG_DBG(TAG, "GD32 乱序包跳过: seq=%u (last_seq=%" PRIu32 "),抑制 ACK (ooo=%u)", + DEBUG_DBG(TAG, "GD32乱序 s=%u last=%" PRIu32 " 抑ACK ooo=%u", seq, meta.last_seq, (unsigned)s_gd32_ooo_count); } return false; // 外层推进 offset,继续下一包 @@ -881,14 +884,14 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, // --- 检查 GD32 固件是否接收完毕 --- if (transfer_complete) { - DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)", + DEBUG_INFO(TAG, "GD32收完 %" PRIu32 "/%" PRIu32 "B chunks=%u", meta.stored_size, meta.total_size, seq + 1); s_ble_gd32_meta_active = false; ble_ota_restore_wifi_ps(); // 与 WiFi 共用 finishGd32StorageAfterReceive:complete + SHA esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); if (sha_err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); + DEBUG_ERR(TAG, "GD32 SHA失败:%s", esp_err_to_name(sha_err)); } } return false; @@ -937,8 +940,8 @@ void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) // 当前包总长度: header(7) + payload(data_len) size_t packet_len = 7 + data_len; - DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu offset=%zu", - cmd, seq, total_size, data_len, offset); + DEBUG_DBG(TAG, "DATA_IN c=0x%02X s=%u tot=%" PRIu32 " n=%zu", + cmd, seq, total_size, data_len); // 分发到对应通道的包处理器 bool abort_batch = false; @@ -985,7 +988,7 @@ void ble_ota_data_worker_task(void *pvParameters) // 若 BLE 已断开或 WiFi OTA 挂起,丢弃队列中的残留数据 if (!BleOta::instance().isConnected() || BleOta::instance().isSuspendedForWifiOta()) { - DEBUG_DBG(TAG, "BLE 不可用/挂起,丢弃 %zu 字节队列数据", item.len); + DEBUG_DBG(TAG, "BLE不可用 丢%zuB队列", item.len); free(item.data); continue; } diff --git a/main/server/ble_ota_host.cpp b/main/server/ble_ota_host.cpp index 103df5c..f6452e0 100644 --- a/main/server/ble_ota_host.cpp +++ b/main/server/ble_ota_host.cpp @@ -38,7 +38,7 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, } if (len < 35) { - DEBUG_WARN(TAG, "CTRL 0xF7 数据长度不足 35(前端 SHA-256 必传),拒绝触发 GD32 中继"); + DEBUG_WARN(TAG, "CTRL 0xF7 len<35 缺SHA,拒GD32中继"); sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, packBleFinishValue(session_id, 1)); return 0; @@ -60,22 +60,21 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha); shaBytesToHexUpper(device_sha, device_sha_hex); sendSha256Notify(device_sha, session_id); - DEBUG_DBG(TAG, "GD32 前端 SHA-256: %s, session=%u", frontend_sha_hex, session_id); - DEBUG_DBG(TAG, "GD32 缓存 SHA-256: %s", device_sha_hex); - if (err == ESP_ERR_INVALID_CRC) { - DEBUG_ERR(TAG, "GD32 缓存 SHA-256 与前端文件不匹配,拒绝触发 SPI 中继"); + DEBUG_ERR(TAG, "GD32 SHA-256 不匹配"); + DEBUG_ERR(TAG, "frontend=%s", frontend_sha_hex); + DEBUG_ERR(TAG, "device=%s", device_sha_hex); sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, packBleFinishValue(session_id, 0)); return 0; } else if (err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 SHA-256 计算/读取失败: %s", esp_err_to_name(err)); + DEBUG_ERR(TAG, "GD32 SHA 失败: %s", esp_err_to_name(err)); sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, packBleFinishValue(session_id, 3)); return 0; } - DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继", + DEBUG_INFO(TAG, "GD32就绪 SHA匹配 %" PRIu32 "/%" PRIu32 ",触发中继", meta.stored_size, meta.total_size); if (OtaRelay::getResultQueue()) { uint32_t stale = 0; @@ -93,7 +92,7 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START, packBleFinishValue(session_id, 0)); - DEBUG_INFO(TAG, "已通知前端: GD32 中继已开始 (stage=0x10, session=%u)", session_id); + DEBUG_INFO(TAG, "GD32中继开始 stage=0x10 sess=%u", session_id); BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx)); if (!ctx) { @@ -142,7 +141,7 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, // 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对, // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 if (len < 33) { - DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发"); + DEBUG_WARN(TAG, "CTRL 0xF8 len<33 缺SHA,拒绝"); sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG); return 0; } @@ -159,7 +158,7 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, } finish_ctx->sha_hex[64] = '\0'; finish_ctx->session_id = session_id; - DEBUG_DBG(TAG, "前端 SHA-256: %s, session=%u", finish_ctx->sha_hex, session_id); + DEBUG_DBG(TAG, "feSHA=%s sess=%u", finish_ctx->sha_hex, session_id); BleOta::instance().m_esp32RebootPending = true; BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, @@ -179,7 +178,7 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, } } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) { // 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host - DEBUG_INFO(TAG, "收到 0xFA 重启请求,创建 restart task(500ms 后 esp_restart())"); + DEBUG_INFO(TAG, "0xFA重启,建restart task(500ms)"); if (BleOta::instance().m_esp32RebootPending) { DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL"); return 0; @@ -207,11 +206,11 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, } ota_system_state_t cur_state = OtaCoordinator::instance().getSystemState(); - DEBUG_WARN(TAG, "收到 0xFB 中止请求: state=%s session=%u", + DEBUG_WARN(TAG, "0xFB abort state=%s sess=%u", OtaCoordinator::stateToString(cur_state), session_id); if (cur_state == OTA_SYS_GD32_RELAYING) { - DEBUG_WARN(TAG, "GD32 SPI 中继进行中,无法中止(请等待完成或断开 BLE)"); + DEBUG_WARN(TAG, "GD32 SPI中继中,无法中止,请等待"); sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, packBleFinishValue(session_id, 2)); return 0; @@ -466,7 +465,7 @@ int ble_ota_gap_event(struct ble_gap_event *event, void *arg) { ota_system_state_t cur = OtaCoordinator::instance().getSystemState(); if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) { - DEBUG_WARN(TAG, "BLE 断开但 %s 进行中,保留状态等待自然结束", + DEBUG_WARN(TAG, "BLE断但%s进行中,保留状态", OtaCoordinator::stateToString(cur)); } else if (cur == OTA_SYS_ESP32_RECEIVING || cur == OTA_SYS_ESP32_FINALIZING || @@ -479,7 +478,7 @@ int ble_ota_gap_event(struct ble_gap_event *event, void *arg) ch = snap.channel; } if (ch == OTA_CHANNEL_BLE) { - DEBUG_WARN(TAG, "BLE 断开,中止 BLE 通道 OTA (state=%s)", + DEBUG_WARN(TAG, "BLE断,中止OTA state=%s", OtaCoordinator::stateToString(cur)); ble_ota_session_reset(); } else { @@ -502,7 +501,7 @@ int ble_ota_gap_event(struct ble_gap_event *event, void *arg) return 0; case BLE_GAP_EVENT_SUBSCRIBE: - DEBUG_INFO(TAG, "CCC 描述符更新: conn=%d attr=%d notify=%d indicate=%d", + DEBUG_INFO(TAG, "CCC upd conn=%d attr=%d n=%d i=%d", event->subscribe.conn_handle, event->subscribe.attr_handle, event->subscribe.cur_notify, @@ -543,7 +542,7 @@ int ble_ota_gap_event(struct ble_gap_event *event, void *arg) const char *tx_phy = (tx == BLE_GAP_LE_PHY_2M) ? "2M" : (tx == BLE_GAP_LE_PHY_1M) ? "1M" : (tx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?"; - DEBUG_INFO(TAG, "PHY 更新: status=%d rx=%s tx=%s", + DEBUG_INFO(TAG, "PHY %d %s/%s", event->phy_updated.status, rx_phy, tx_phy); return 0; } @@ -573,18 +572,18 @@ void ble_ota_start_advertising(void) // kept as free function for BLE callback uint8_t own_addr_type; rc = ble_hs_id_infer_auto(0, &own_addr_type); if (rc != 0) { - DEBUG_ERR(TAG, "推断地址类型失败: rc=%d, 尝试生成随机地址", rc); + DEBUG_ERR(TAG, "推断地址失败 rc=%d,试随机地址", rc); // 生成不可解析随机地址作为 fallback ble_addr_t rnd_addr; rc = ble_hs_id_gen_rnd(0, &rnd_addr); if (rc != 0) { - DEBUG_ERR(TAG, "生成随机地址也失败: rc=%d, BLE 广播无法启动", rc); + DEBUG_ERR(TAG, "随机地址失败 rc=%d", rc); return; } own_addr_type = rnd_addr.type; } - DEBUG_INFO(TAG, "使用地址类型: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random"); + DEBUG_DBG(TAG, "BLE addr type: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random"); // --- 构建广播数据 --- uint8_t adv_data[31] = {0}; @@ -640,7 +639,8 @@ void ble_ota_start_advertising(void) // kept as free function for BLE callback if (rc != 0) { DEBUG_ERR(TAG, "启动广播失败: %d", rc); } else { - DEBUG_INFO(TAG, "BLE 广播已启动: \"%s\"", name); + /* 冷启动与 WiFi 共存恢复都会走这里;常态 INFO 不刷,避免上传日志夹杂 BLE */ + DEBUG_DBG(TAG, "BLE广播就绪: \"%s\"", name); } } diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index c633cee..bb236a9 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -3,6 +3,7 @@ */ #include #include +#include #include #include #include "freertos/FreeRTOS.h" @@ -17,6 +18,7 @@ #include "esp_task_wdt.h" #include "esp_heap_caps.h" #include "esp_rom_sys.h" +#include "soc/soc.h" #include "cJSON.h" #include "app_config.h" @@ -32,7 +34,8 @@ #include "spi_master.h" #include "canfd_bridge.h" #include "monitor/can_feedback_task.h" -#include "monitor/task_stack_check.h" +#include "monitor/gd32_adc_feedback.h" +#include "monitor/task_stack.h" #include "ble_ota.h" #include "system_boot.h" @@ -235,7 +238,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) http_set_cors_headers(req); uint8_t *recv_buf = s_ota_upload_recv; - DEBUG_WARN("WIFI", "=== HTTP: POST /ota/upload === content_len=%d free=%lu largest=%lu", + DEBUG_WARN("WIFI", "POST /ota/upload clen=%d free=%lu big=%lu", req->content_len, (unsigned long)esp_get_free_heap_size(), (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); @@ -318,7 +321,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) size_t total_firmware_written = 0; size_t total_received = json_header_len; - DEBUG_INFO("WIFI", "ESP32 OTA 开始收流: content_len=%d json_hdr=%d fw_expect=%d", + DEBUG_INFO("WIFI", "ESP32收流 clen=%d hdr=%d exp=%d", req->content_len, json_header_len, (req->content_len > json_header_len) ? (req->content_len - json_header_len) : 0); @@ -338,7 +341,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) int ret = httpd_req_recv(req, (char *)fw_buf, to_read); if (ret <= 0) { - DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA recv=%u/%d free=%lu", + DEBUG_ERR("WIFI", "HTTP recv fail ret=%d OTA %u/%d free=%lu", ret, (unsigned)total_received, req->content_len, (unsigned long)esp_get_free_heap_size()); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); @@ -377,10 +380,9 @@ esp_err_t ota_upload_handler(httpd_req_t *req) httpd_resp_set_hdr(req, "Connection", "close"); esp_err_t send_err = httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); if (send_err != ESP_OK) { - DEBUG_ERR("WIFI", "ESP32 OTA 响应发送失败: %s (free=%lu largest=%lu)", + DEBUG_ERR("WIFI", "ESP32 OTA回包失败: %s free=%lu", esp_err_to_name(send_err), - (unsigned long)esp_get_free_heap_size(), - (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); + (unsigned long)esp_get_free_heap_size()); /* 再试一次极简回包 */ send_err = httpd_resp_send(req, "{\"status\":\"ok\",\"target\":\"esp32\"}", HTTPD_RESP_USE_STRLEN); @@ -388,8 +390,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) DEBUG_ERR("WIFI", "ESP32 OTA 简短响应仍失败: %s", esp_err_to_name(send_err)); } } else { - DEBUG_INFO("WIFI", - "ESP32 OTA 存储+响应完成: %u B elapsed=%lld ms free=%lu", + DEBUG_INFO("WIFI", "ESP32存完 %uB %lldms free=%lu", (unsigned int)core.getReceived(), (long long)((esp_timer_get_time() - t_recv0) / 1000), (unsigned long)esp_get_free_heap_size()); @@ -505,11 +506,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req) if (sha_err != ESP_OK) { /* 存盘已完成,仍回 status=ok;relay 阶段会再比前端 SHA。 * 此处必须醒目告警,避免只当 INFO 被忽略。 */ - DEBUG_WARN("WIFI", - "GD32 上传完成但 SHA-256 预计算失败: %s(仍返回 stored ok,/ota/relay 将再校验)", + DEBUG_WARN("WIFI", "GD32 SHA预算失败:%s (relay再校)", esp_err_to_name(sha_err)); - DEBUG_ERR("WIFI", "GD32 finishGd32StorageAfterReceive failed: %s", - esp_err_to_name(sha_err)); } } @@ -519,10 +517,9 @@ esp_err_t ota_upload_handler(httpd_req_t *req) httpd_resp_set_hdr(req, "Connection", "close"); esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp)); if (send_err != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 OTA 响应发送失败: %s (free=%lu largest=%lu)", + DEBUG_ERR("WIFI", "GD32 OTA回包失败: %s free=%lu", esp_err_to_name(send_err), - (unsigned long)esp_get_free_heap_size(), - (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); + (unsigned long)esp_get_free_heap_size()); } else { DEBUG_INFO("WIFI", "GD32 OTA 存储+响应完成: %u / %u free=%lu", (unsigned)gd32_offset, expected_fw_size_raw, @@ -581,6 +578,11 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req) } if (verify_err == ESP_ERR_INVALID_CRC) { + DEBUG_ERR("OTA", "GD32 SHA-256 不匹配"); + DEBUG_ERR("OTA", "frontend=%s", frontend_sha_hex); + DEBUG_ERR("OTA", "device=%s", device_sha_hex); + /* 未进中继:须恢复 WiFi 上传期挂起的 BLE */ + (void)BleOta::instance().resumeStack(); char err_resp[256]; snprintf(err_resp, sizeof(err_resp), "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", " @@ -589,12 +591,14 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req) httpd_resp_send(req, err_resp, strlen(err_resp)); return ESP_FAIL; } else if (verify_err != ESP_OK) { + (void)BleOta::instance().resumeStack(); return http_send_json_error(req, "500 Internal Server Error", "sha_compute_failed", "SHA compute failed"); } esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); if (trigger_err != ESP_OK) { + (void)BleOta::instance().resumeStack(); return http_send_json_error(req, "400 Bad Request", "relay_trigger_failed", "relay trigger failed - system busy or firmware not ready"); @@ -630,8 +634,53 @@ esp_err_t logs_handler(httpd_req_t *req) // ============================================================ -// GET /can/feedback — 三路 CAN 反馈快照(GD32 推送,ESP32 缓存) +// HTTP JSON 共享工作区(.bss 单份 ~3KB) +// 供 /system/status、/can/feedback、/system/heap_low_log 串行复用; +// 互斥保护,禁止并发写同一缓冲(httpd 多 worker 时会排队)。 // ============================================================ +static constexpr size_t HTTP_JSON_WORK_CAP = 3072; +static char s_http_json_work[HTTP_JSON_WORK_CAP]; +static SemaphoreHandle_t s_http_json_mux = nullptr; +static portMUX_TYPE s_http_json_init_mux = portMUX_INITIALIZER_UNLOCKED; + +static SemaphoreHandle_t httpJsonWorkMutex(void) +{ + if (s_http_json_mux == nullptr) { + portENTER_CRITICAL(&s_http_json_init_mux); + if (s_http_json_mux == nullptr) { + s_http_json_mux = xSemaphoreCreateMutex(); + } + portEXIT_CRITICAL(&s_http_json_init_mux); + } + return s_http_json_mux; +} + +// ============================================================ +// GET /can/feedback — 三路 CAN 反馈快照(GD32 推送,ESP32 缓存) +// 分块发送:复用 s_http_json_work,一次 HTTP 内按通道 send_chunk; +// 前端仍一次 fetch 收完整 JSON。 +// ============================================================ +/** 监控预览:data hex 最多 8 字节(16 字符),避免 FD 64B 撑爆分块 */ +static constexpr uint8_t FEEDBACK_DATA_HEX_MAX = 8; + +static int feedback_append(char *buf, size_t cap, int off, const char *fmt, ...) +{ + if (!buf || cap == 0 || off < 0 || (size_t)off >= cap) { + return -1; + } + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf + off, cap - (size_t)off, fmt, ap); + va_end(ap); + if (n < 0) { + return -1; + } + if (off + n >= (int)cap) { + return -1; /* 分块装不下:调用方截断 entries */ + } + return off + n; +} + esp_err_t can_feedback_handler(httpd_req_t *req) { http_set_cors_headers(req); @@ -642,20 +691,61 @@ esp_err_t can_feedback_handler(httpd_req_t *req) valid[i] = getLatestCanFeedback(i, &results[i]); } - /* 静态响应缓冲,减少频繁大块堆分配 */ - static char s_feedback_json[8192]; - char *resp = s_feedback_json; - memset(resp, 0, sizeof(s_feedback_json)); + const bool feat_known = SpiMaster::instance().gd32FeatKnown(); + const uint32_t feat = SpiMaster::instance().gd32FeatFlags(); + const bool ch_en[CAN_CHANNEL_MAX] = { + !feat_known || (feat & V2_FEAT_CAN_CH0) != 0U, + !feat_known || (feat & V2_FEAT_CAN_CH1) != 0U, + !feat_known || (feat & V2_FEAT_CAN_CH2) != 0U, + }; + const unsigned ch_mask = feat_known + ? (unsigned)((feat & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) + : 0x7U; + + SemaphoreHandle_t mux = httpJsonWorkMutex(); + if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb busy"); + return ESP_FAIL; + } + + char *work = s_http_json_work; + httpd_resp_set_type(req, "application/json"); + + auto fail_unlock = [&](esp_err_t err) -> esp_err_t { + if (mux) { + xSemaphoreGive(mux); + } + return err; + }; + + int n = snprintf(work, HTTP_JSON_WORK_CAP, + "{\"feat_known\":%s,\"can_ch_mask\":%u,\"can_brs\":%s,\"channels\":[", + feat_known ? "true" : "false", + ch_mask, + (feat_known && (feat & V2_FEAT_CAN_BRS)) ? "true" : "false"); + if (n < 0 || (size_t)n >= HTTP_JSON_WORK_CAP) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb hdr"); + return fail_unlock(ESP_FAIL); + } + if (httpd_resp_send_chunk(req, work, n) != ESP_OK) { + return fail_unlock(ESP_FAIL); + } - char *p = resp; - p += sprintf(p, "{\"channels\":["); for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) { const can_feedback_result_t &r = results[i]; - if (i > 0) p += sprintf(p, ","); - p += sprintf(p, "{\"ch\":%u,\"valid\":%s", (unsigned)i, - valid[i] ? "true" : "false"); - if (valid[i]) { - p += sprintf(p, + int off = 0; + off = feedback_append(work, HTTP_JSON_WORK_CAP, off, + "%s{\"ch\":%u,\"enabled\":%s,\"valid\":%s", + i == 0 ? "" : ",", + (unsigned)i, + ch_en[i] ? "true" : "false", + valid[i] ? "true" : "false"); + if (off < 0) { + break; + } + + if (valid[i] && ch_en[i]) { + off = feedback_append(work, HTTP_JSON_WORK_CAP, off, ",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu," "\"drop\":%lu,\"err\":%lu,\"tec\":%u,\"rec\":%u," "\"bus_state\":%u,\"count\":%u,\"entries\":[", @@ -669,33 +759,74 @@ esp_err_t can_feedback_handler(httpd_req_t *req) (unsigned)r.stats.rec, (unsigned)r.stats.bus_state, (unsigned)r.total_count); + if (off < 0) { + break; + } + + uint8_t emitted = 0; for (uint8_t j = 0; j < r.total_count && j < CANFD_ID_SNAPSHOT_MAX; ++j) { const id_snapshot_entry_t &e = r.entries[j]; - if (j > 0) p += sprintf(p, ","); - p += sprintf(p, - "{\"id\":\"0x%08lX\",\"dlc\":%u,\"fd\":%u," + uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX) + ? e.dlc : (uint8_t)CANFD_ID_SNAPSHOT_DATA_MAX; + if (dataLen > FEEDBACK_DATA_HEX_MAX) { + dataLen = FEEDBACK_DATA_HEX_MAX; + } + + /* 先估长度:装不下则截断 entries(count 仍报真实 total) */ + char entry_tmp[96 + FEEDBACK_DATA_HEX_MAX * 2]; + int elen = snprintf(entry_tmp, sizeof(entry_tmp), + "%s{\"id\":\"0x%08lX\",\"dlc\":%u,\"fd\":%u," "\"brs\":%u,\"esi\":%u,\"data\":\"", + emitted ? "," : "", (unsigned long)e.id, (unsigned)e.dlc, (unsigned)e.fd_flag, (unsigned)e.brs, (unsigned)e.esi); - const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX) - ? e.dlc : CANFD_ID_SNAPSHOT_DATA_MAX; - for (uint8_t d = 0; d < dataLen; ++d) { - p += sprintf(p, "%02X", e.data[d]); + if (elen < 0 || (size_t)elen >= sizeof(entry_tmp)) { + break; } - p += sprintf(p, "\"}"); - } - p += sprintf(p, "]"); - } - p += sprintf(p, "}"); - } - p += sprintf(p, "]}"); + for (uint8_t d = 0; d < dataLen; ++d) { + int hn = snprintf(entry_tmp + elen, sizeof(entry_tmp) - (size_t)elen, + "%02X", e.data[d]); + if (hn != 2) { + break; + } + elen += hn; + } + if (elen + 2 >= (int)sizeof(entry_tmp)) { + break; + } + entry_tmp[elen++] = '"'; + entry_tmp[elen++] = '}'; + entry_tmp[elen] = '\0'; - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, (int)(p - resp)); - return ESP_OK; + if (off + elen + 2 >= (int)HTTP_JSON_WORK_CAP) { + break; /* 本通道 entries 截断 */ + } + memcpy(work + off, entry_tmp, (size_t)elen); + off += elen; + emitted++; + } + off = feedback_append(work, HTTP_JSON_WORK_CAP, off, "]"); + if (off < 0) { + break; + } + } + off = feedback_append(work, HTTP_JSON_WORK_CAP, off, "}"); + if (off < 0) { + break; + } + if (httpd_resp_send_chunk(req, work, off) != ESP_OK) { + return fail_unlock(ESP_FAIL); + } + } + + if (httpd_resp_send_chunk(req, "]}", 2) != ESP_OK) { + return fail_unlock(ESP_FAIL); + } + esp_err_t end_err = httpd_resp_send_chunk(req, NULL, 0); + return fail_unlock(end_err == ESP_OK ? ESP_OK : ESP_FAIL); } // ============================================================ @@ -767,79 +898,13 @@ esp_err_t can_mode_handler(httpd_req_t *req) httpd_resp_send(req, resp, len); 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)) { - tx_dlc = (uint8_t)parseHexToBytes(cJSON_GetStringValue(data_item), tx_data, CAN_MAX_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}; - uint8_t gd32_err = 0U; - int resp_len = canFdSend(ch, ext_id, tx_data, tx_dlc, - resp_data, sizeof(resp_data), 2000, &gd32_err); - - char resp[512]; - if (resp_len >= 0) { - char resp_hex[CAN_MAX_DLC * 2 + 1] = {0}; - bytesToHexUpper(resp_data, (size_t)resp_len, resp_hex); - 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, \"gd32_err\": %u}", - resp_len, (unsigned)gd32_err); - } - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; -} - /** - * @brief POST /can/send_batch — 同通道连续发送多帧,单次 HTTP 完成。 + * @brief POST /can/send_batch — CAN 发送唯一入口(单帧 = frames 长度 1)。 * * JSON: { "ch": 0, "frames": [ { "id": n, "dlc": n, "data": "HEX" }, ... ] } * 返回: { "status":"ok", "results":[ ... ], 可选 skipped/abort_* } - * 帧间间隔 kInterFrameDelayMs;批量期间暂停 CAN 反馈任务。 + * 单帧 canFdSend 超时 2000ms(等应答);多帧 80ms/帧 + 帧间隔。 + * 发送期间暂停 CAN 反馈任务。 */ esp_err_t can_send_batch_handler(httpd_req_t *req) { @@ -848,6 +913,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) constexpr int kMaxBody = 49152; constexpr int kMaxFrames = 128; constexpr uint32_t kPerFrameTimeoutMs = 80U; + constexpr uint32_t kSingleFrameTimeoutMs = 2000U; constexpr uint32_t kInterFrameDelayMs = 5U; int content_len = req->content_len; @@ -893,6 +959,8 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) return http_send_json_error(req, "400 Bad Request", "bad_frames", "frames 数量无效(1~128)"); } + const uint32_t frame_timeout_ms = + (frame_count == 1) ? kSingleFrameTimeoutMs : kPerFrameTimeoutMs; cJSON *out_root = cJSON_CreateObject(); cJSON *results = cJSON_CreateArray(); @@ -905,7 +973,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) cJSON_AddStringToObject(out_root, "status", "ok"); cJSON_AddItemToObject(out_root, "results", results); - /* 批量发送期间暂停 CAN 反馈,独占 SPI 业务路径 */ + /* 发送期间暂停 CAN 反馈,独占 SPI 业务路径 */ pauseCanFeedbackTask(true); auto fill_err = [](cJSON *item, int code, uint8_t gd32_err) { @@ -962,7 +1030,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) memset(resp_data, 0, sizeof(resp_data)); uint8_t gd32_err = 0U; int resp_len = canFdSend(ch, ext_id, tx_data, tx_dlc, - resp_data, sizeof(resp_data), kPerFrameTimeoutMs, &gd32_err); + resp_data, sizeof(resp_data), frame_timeout_ms, &gd32_err); if (resp_len >= 0) { char resp_hex[CAN_MAX_DLC * 2 + 1]; @@ -1073,8 +1141,9 @@ esp_err_t spi_send_handler(httpd_req_t *req) 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); + /* 日志只记 type/len,完整 payload 仅在 HTTP JSON 返回给前端 */ + DEBUG_INFO("SPI", "WEB SPI ok: type=0x%02X len=%zu", + resp_type, resp_len); } else { snprintf(resp, sizeof(resp), "{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}", @@ -1088,25 +1157,27 @@ 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"。 */ -/* 含 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; -static SemaphoreHandle_t statusJsonMutex(void) +/** 发送当前工作区并清空 offset(分块;失败返回 false) */ +static bool status_flush_chunk(httpd_req_t *req, char *buf, size_t cap, int *len) { - if (s_status_json_mux == nullptr) { - portENTER_CRITICAL(&s_status_json_init_mux); - if (s_status_json_mux == nullptr) { - s_status_json_mux = xSemaphoreCreateMutex(); - } - portEXIT_CRITICAL(&s_status_json_init_mux); + if (!req || !buf || !len || *len <= 0) { + return true; } - return s_status_json_mux; + if (httpd_resp_send_chunk(req, buf, *len) != ESP_OK) { + return false; + } + *len = 0; + if (cap > 0) { + buf[0] = '\0'; + } + return true; } +/* CPU 快照静态缓冲:替代每秒 calloc(1.2KB),消除 IDLE 期持续的堆抖动。 + * 与 s_http_json_work 同由 httpJsonWorkMutex 串行化。 */ +static CpuUsageSnapshot s_cpu_snapshot; + static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle) { if (handle == NULL) return 0; @@ -1136,9 +1207,10 @@ static int append_jsonf(char* buf, size_t cap, int offset, const char* fmt, ...) return offset + (int)written; } -/** 将 CpuStats 1Hz 快照写入 JSON(不在 HTTP 线程采样) */ +/** 将 CpuStats 1Hz 快照写入 JSON(不在 HTTP 线程采样)。最多 16 条任务以免撑爆 3KB 分块。 */ static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUsageSnapshot& cpu) { + static constexpr size_t kCpuTaskJsonMax = 16; offset = append_jsonf(buf, cap, offset, "\"cpu\":{\"available\":%s,\"valid\":%s,\"usage_pct\":%.2f,\"idle_pct\":%.2f,\"sample_ms\":%lu,\"tasks\":[", cpu.available ? "true" : "false", @@ -1147,7 +1219,9 @@ static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUs cpu.valid ? cpu.idle_pct : 0.0, (unsigned long)(cpu.valid ? cpu.sample_ms : 0U)); - for (size_t i = 0; i < cpu.task_count; ++i) { + const size_t ntask = + (cpu.task_count > kCpuTaskJsonMax) ? kCpuTaskJsonMax : cpu.task_count; + for (size_t i = 0; i < ntask; ++i) { const CpuTaskMetric& task = cpu.tasks[i]; offset = append_jsonf(buf, cap, offset, "%s{\"name\":\"%s\",\"task_number\":%lu,\"runtime_delta\":%" PRIu64 ",\"usage_pct\":%.2f}", @@ -1166,6 +1240,20 @@ esp_err_t system_status_handler(httpd_req_t *req) { http_set_cors_headers(req); + /* layout=1:附带链接期静态布局(IRAM/.data/.bss/堆容量等),前端缓存后轮询不再要。 + * 默认只下发 memory 动态字段,减 status JSON 体积。 */ + bool want_mem_layout = false; + { + char query[48]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { + char val[8] = {}; + if (httpd_query_key_value(query, "layout", val, sizeof(val)) == ESP_OK) { + want_mem_layout = (val[0] == '1' || val[0] == 'y' || val[0] == 'Y' || + val[0] == 't' || val[0] == 'T'); + } + } + } + /* 堆极紧时仍返回精简 JSON,禁止 500(前端轮询会刷红)。 */ const size_t largest_now = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); if (largest_now < 512U) { @@ -1184,10 +1272,10 @@ esp_err_t system_status_handler(httpd_req_t *req) return ESP_OK; } - SemaphoreHandle_t mux = statusJsonMutex(); + SemaphoreHandle_t mux = httpJsonWorkMutex(); if (mux) { if (xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { - /* 并发 status 冲突:仍回简包,避免 200 */ + /* 与 feedback/heap_low 争用工作区:仍回简包,避免 500 刷红 */ char busy[160]; snprintf(busy, sizeof(busy), "{\"version\":\"%s\",\"ota\":{\"sys_state\":\"busy\"}," @@ -1229,10 +1317,58 @@ esp_err_t system_status_handler(httpd_req_t *req) 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); + + /* 内存全景:堆只是 SRAM 中可 malloc 的部分。 + * 静态段用链接符号拆分(与 map 文件一致): + * [IRAM code | .data | .bss | heap ...] (C3 上 IRAM/DRAM 同一物理 SRAM) + * 手册 400KB = DRAM 窗口 + 约 16KB 指令 cache(cache 不在 SOC_DRAM_* 窗口内)。 */ + multi_heap_info_t internal_info = {}; + heap_caps_get_info(&internal_info, INTERNAL_HEAP_CAPS); + + extern char _iram_start[]; + extern char _iram_end[]; + extern char _data_start[]; + extern char _data_end[]; + extern char _bss_start[]; + extern char _bss_end[]; + extern char _heap_start[]; + auto sym_span = [](const char *a, const char *b) -> size_t { + const uintptr_t ua = reinterpret_cast(a); + const uintptr_t ub = reinterpret_cast(b); + return (ub >= ua) ? static_cast(ub - ua) : 0U; + }; + const size_t iram_bytes = sym_span(_iram_start, _iram_end); + const size_t data_bytes = sym_span(_data_start, _data_end); + const size_t bss_bytes = sym_span(_bss_start, _bss_end); + /* C3 DIRAM:DRAM 基址起至 _heap_start 即为 IRAM+.data+.bss 占位 */ + const uintptr_t heap_start_addr = reinterpret_cast(_heap_start); + const size_t static_to_heap = + (heap_start_addr >= static_cast(SOC_DRAM_LOW)) + ? static_cast(heap_start_addr - static_cast(SOC_DRAM_LOW)) + : (iram_bytes + data_bytes + bss_bytes); + + const size_t sram_soc = + (SOC_DRAM_HIGH > SOC_DRAM_LOW) ? (size_t)(SOC_DRAM_HIGH - SOC_DRAM_LOW) : 0U; + /* 应用实际接管:静态链接区 + 内部堆(比整窗更贴近“可用 DRAM”) */ + const size_t sram_app = static_to_heap + internal_heap_total; + const size_t sram_total = (sram_app > 0) ? sram_app : sram_soc; + const size_t internal_heap_used = internal_info.total_allocated_bytes; + const size_t static_reserved = static_to_heap; + const size_t reserved_tail = + (sram_soc > sram_app) ? (sram_soc - sram_app) : 0U; + const size_t dma_total = heap_caps_get_total_size(MALLOC_CAP_DMA); + const size_t dma_free = heap_caps_get_free_size(MALLOC_CAP_DMA); + const size_t rtc_total = heap_caps_get_total_size(MALLOC_CAP_RTCRAM); + const size_t rtc_free = heap_caps_get_free_size(MALLOC_CAP_RTCRAM); + // 任务栈指标复用 health_monitor 共享的任务表(collectTaskStackMetrics), // 避免与巡检逻辑两地维护;http_server 在本 handler 上下文中,句柄即当前任务。 TaskStackMetric task_metrics[12]; size_t task_metrics_count = collectTaskStackMetrics(task_metrics, 12); + size_t task_stack_total_bytes = 0; + for (size_t i = 0; i < task_metrics_count; ++i) { + task_stack_total_bytes += task_metrics[i].stack_size_bytes; + } // http_server 行的句柄在 httpd 线程中取当前任务更准确 for (size_t i = 0; i < task_metrics_count; ++i) { if (strcmp(task_metrics[i].name, "http_server") == 0) { @@ -1241,28 +1377,22 @@ esp_err_t system_status_handler(httpd_req_t *req) } /* CPU:只读 cpu_stats 任务的 1Hz 快照,禁止在 httpd 里 uxTaskGetSystemState */ - CpuUsageSnapshot cpu_local = {}; - CpuUsageSnapshot *cpu = &cpu_local; - CpuUsageSnapshot *cpu_heap = nullptr; - if (largest_free_block >= (sizeof(CpuUsageSnapshot) + 512U)) { - cpu_heap = (CpuUsageSnapshot *)calloc(1, sizeof(CpuUsageSnapshot)); - if (cpu_heap) { - cpu = cpu_heap; - } - } + CpuUsageSnapshot *cpu = &s_cpu_snapshot; // 用静态缓冲,不再每秒 calloc(1.2KB) CpuStats::instance().getSnapshot(*cpu); - char *resp = s_status_json; - memset(resp, 0, SYSTEM_STATUS_JSON_CAP); + char *resp = s_http_json_work; + memset(resp, 0, HTTP_JSON_WORK_CAP); + httpd_resp_set_type(req, "application/json"); - int len = snprintf(resp, SYSTEM_STATUS_JSON_CAP, + /* ── 分块 1:核心状态 + OTA/WiFi ── */ + int len = snprintf(resp, HTTP_JSON_WORK_CAP, "{\"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, \"channel\": %d, \"channel_name\": \"%s\", \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, " + "\"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"channel\": %d, \"channel_name\": \"%s\", " "\"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, " "\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}, ", FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(), @@ -1284,9 +1414,6 @@ esp_err_t system_status_handler(httpd_req_t *req) (int)snap.sys_state, (int)snap.channel, snap.channel == OTA_CHANNEL_BLE ? "BLE" : "WIFI", - (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, @@ -1295,11 +1422,30 @@ esp_err_t system_status_handler(httpd_req_t *req) 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); + if (len < 0 || (size_t)len >= HTTP_JSON_WORK_CAP) { + if (mux) { + xSemaphoreGive(mux); + } + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "status hdr"); + return ESP_FAIL; + } + if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { + if (mux) { + xSemaphoreGive(mux); + } + return ESP_FAIL; + } - len = append_cpu_status_json(resp, SYSTEM_STATUS_JSON_CAP, len, *cpu); + /* ── 分块 2:CPU ── */ + len = append_cpu_status_json(resp, HTTP_JSON_WORK_CAP, 0, *cpu); + if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { + if (mux) { + xSemaphoreGive(mux); + } + return ESP_FAIL; + } - /* SPI Link v2 统计(SPEC §12.1)— linkStatus 内部用 5ms 有界超时取 SPI 锁, - * OTA 中继期间拿不到锁时返回零值统计,绝不阻塞 /system/status 响应。 */ + /* ── 分块 3:SPI / ADC / features / boot / memory ── */ { SpiMaster::SpiLinkStatus ls = SpiMaster::instance().linkStatus(); const char *role_str = "unknown"; @@ -1308,7 +1454,7 @@ esp_err_t system_status_handler(httpd_req_t *req) case 2: role_str = "boot"; break; default: break; } - len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, 0, ",\"spi_link\":{\"version\":%u,\"gd32_role\":\"%s\",\"session_id\":\"0x%08lX\"," "\"v2_active\":%s,\"local_credit\":%u,\"remote_credit\":%u," "\"crc_errors\":%lu,\"duplicate_requests\":%lu,\"seq_errors\":%lu," @@ -1330,14 +1476,114 @@ 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); + { + gd32_adc_feedback_t ga = {}; + const bool have = getLatestGd32AdcFeedback(&ga); + const uint32_t age = gd32AdcFeedbackAgeMs(); + const bool fresh = have && age != UINT32_MAX && age < 5000U; + const bool feat_known = SpiMaster::instance().gd32FeatKnown(); + const bool feat_adc = SpiMaster::instance().gd32FeatAdcFeedback(); + const char *en = feat_known ? (feat_adc ? "true" : "false") + : (have ? "true" : "null"); + if (have) { + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, len, + ",\"gd32_adc\":{\"enabled\":%s,\"feat_known\":%s,\"fresh\":%s,\"valid\":%s," + "\"temperature\":%.2f,\"vdda\":%.3f,\"vin_adc\":%.3f,\"vcc\":%.3f," + "\"temp_raw\":%u,\"vref_raw\":%u,\"seq\":%lu," + "\"uptime_sec\":%lu,\"age_ms\":%lu}", + en, feat_known ? "true" : "false", + fresh ? "true" : "false", ga.valid ? "true" : "false", + (double)ga.temperature_c, (double)ga.vdda_v, (double)ga.vin_adc_v, + (double)ga.vcc_v, + (unsigned)ga.temp_raw, (unsigned)ga.vref_raw, + (unsigned long)ga.seq, (unsigned long)ga.uptime_sec, + (unsigned long)(age == UINT32_MAX ? 0UL : (unsigned long)age)); + } else { + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, len, + ",\"gd32_adc\":{\"enabled\":%s,\"feat_known\":%s,\"fresh\":false,\"valid\":false}", + en, feat_known ? "true" : "false"); + } + const uint32_t fl = SpiMaster::instance().gd32FeatFlags(); + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, len, + ",\"gd32_features\":{\"known\":%s,\"flags\":%lu," + "\"adc_feedback\":%s,\"uart_temp_print\":%s," + "\"spi_app_log\":%s,\"can_brs\":%s," + "\"can_ch0\":%s,\"can_ch1\":%s,\"can_ch2\":%s,\"can_ch_mask\":%u}", + feat_known ? "true" : "false", + (unsigned long)fl, + (feat_known && (fl & V2_FEAT_ADC_FEEDBACK)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_UART_TEMP_PRINT)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_SPI_APP_LOG)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_CAN_BRS)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_CAN_CH0)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_CAN_CH1)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_CAN_CH2)) ? "true" : "false", + feat_known ? (unsigned)((fl & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) : 7U); + } - // 前导逗号与前一块分隔;之前因 append_cpu_status_json 尾随逗号叠加 - // 形成 "],," 导致前端 JSON.parse 报 "Expected double-quoted property name" - len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, ",\"task_stack\": {"); + len = SystemBoot::appendStatusJson(resp, HTTP_JSON_WORK_CAP, len); + + /* memory 动态字段:每次轮询;layout 仅 ?layout=1 时附带(链接期常量)。 */ + if (want_mem_layout) { + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, len, + ",\"memory\":{\"layout\":{\"chip\":\"esp32c3\",\"sram_total\":%lu,\"sram_soc\":%lu," + "\"static_reserved\":%lu,\"reserved_tail\":%lu," + "\"iram\":%lu,\"data\":%lu,\"bss\":%lu," + "\"heap_total\":%lu,\"dma_total\":%lu,\"rtc_total\":%lu," + "\"task_stack_total\":%lu,\"default_heap_total\":%lu}," + "\"heap_used\":%lu,\"heap_free\":%lu," + "\"heap_min_free\":%lu,\"heap_largest_free\":%lu," + "\"alloc_blocks\":%lu,\"free_blocks\":%lu," + "\"dma_free\":%lu,\"rtc_free\":%lu,\"default_heap_free\":%lu}", + (unsigned long)sram_total, + (unsigned long)sram_soc, + (unsigned long)static_reserved, + (unsigned long)reserved_tail, + (unsigned long)iram_bytes, + (unsigned long)data_bytes, + (unsigned long)bss_bytes, + (unsigned long)internal_heap_total, + (unsigned long)dma_total, + (unsigned long)rtc_total, + (unsigned long)task_stack_total_bytes, + (unsigned long)default_heap_total, + (unsigned long)internal_heap_used, + (unsigned long)internal_info.total_free_bytes, + (unsigned long)internal_info.minimum_free_bytes, + (unsigned long)internal_info.largest_free_block, + (unsigned long)internal_info.allocated_blocks, + (unsigned long)internal_info.free_blocks, + (unsigned long)dma_free, + (unsigned long)rtc_free, + (unsigned long)free_heap); + } else { + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, len, + ",\"memory\":{\"heap_used\":%lu,\"heap_free\":%lu," + "\"heap_min_free\":%lu,\"heap_largest_free\":%lu," + "\"alloc_blocks\":%lu,\"free_blocks\":%lu," + "\"dma_free\":%lu,\"rtc_free\":%lu,\"default_heap_free\":%lu}", + (unsigned long)internal_heap_used, + (unsigned long)internal_info.total_free_bytes, + (unsigned long)internal_info.minimum_free_bytes, + (unsigned long)internal_info.largest_free_block, + (unsigned long)internal_info.allocated_blocks, + (unsigned long)internal_info.free_blocks, + (unsigned long)dma_free, + (unsigned long)rtc_free, + (unsigned long)free_heap); + } + + if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { + if (mux) { + xSemaphoreGive(mux); + } + return ESP_FAIL; + } + + /* ── 分块 4:task_stack + 闭合 ── */ + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, 0, ",\"task_stack\": {"); for (size_t i = 0; i < task_metrics_count; ++i) { - len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, len, "%s\"%s\":{\"stack_size_bytes\":%lu,\"high_watermark_bytes\":%lu,\"peak_used_bytes\":%lu}", i == 0 ? "" : ",", task_metrics[i].name, @@ -1345,13 +1591,17 @@ esp_err_t system_status_handler(httpd_req_t *req) (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, SYSTEM_STATUS_JSON_CAP, len, "}}"); + len = append_jsonf(resp, HTTP_JSON_WORK_CAP, len, "}}"); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, len > 0 ? len : 0); - if (cpu_heap) { - free(cpu_heap); + if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { + if (mux) { + xSemaphoreGive(mux); + } + return ESP_FAIL; } + /* 结束 chunked body */ + (void)httpd_resp_send_chunk(req, NULL, 0); + if (mux) { xSemaphoreGive(mux); } @@ -1386,3 +1636,41 @@ esp_err_t system_probe_handler(httpd_req_t *req) httpd_resp_send(req, resp, len); return ESP_OK; } + +// ============================================================ +// GET /system/heap_low_log — 设备唯一快照槽(0 或 1 条) +// ============================================================ +/** + * @brief 返回设备侧最新一条"最低空闲创新低"上下文。 + * 多条历史由网页 IndexedDB 累积;JSON 复用 s_http_json_work(无独立 .bss)。 + */ +esp_err_t system_heap_low_log_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + SemaphoreHandle_t mux = httpJsonWorkMutex(); + if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"records\":[]}", HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + + char *resp = s_http_json_work; + int len = HealthMonitor::instance().dumpHeapLowWaterJson(resp, HTTP_JSON_WORK_CAP, 0); + if (len < 0) { + len = 0; + } + if ((size_t)len >= HTTP_JSON_WORK_CAP) { + len = (int)HTTP_JSON_WORK_CAP - 1; + } + resp[len] = '\0'; + + httpd_resp_set_type(req, "application/json"); + esp_err_t err = httpd_resp_send(req, resp, len); + if (mux) { + xSemaphoreGive(mux); + } + return err; +} + + diff --git a/main/server/http_handlers.h b/main/server/http_handlers.h index febb67d..aeb3b88 100644 --- a/main/server/http_handlers.h +++ b/main/server/http_handlers.h @@ -15,13 +15,15 @@ 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); -/** @brief 批量 CAN 发送:一次 HTTP 连续发多帧,减少帧间 HTTP 往返延迟。 */ +/** @brief CAN 发送(唯一入口):同通道 1~N 帧,单帧即 frames 长度为 1。 */ esp_err_t can_send_batch_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); +/** @brief GET /system/heap_low_log — 返回设备侧最新 1 条创新低上下文。 */ +esp_err_t system_heap_low_log_handler(httpd_req_t *req); + // CAN 反馈快照(被动 0xC1)与接收模式 esp_err_t can_feedback_handler(httpd_req_t *req); esp_err_t can_mode_handler(httpd_req_t *req); diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 5ac7cc9..db870c8 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -397,9 +397,26 @@ 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;业务连接上限 7 */ - config.max_open_sockets = 7; - /* 满连接时淘汰最久未用的空闲 socket */ + /* Socket 预算(无法“合并” httpd 内部 3 个 fd:listen + ctrl 对): + * + * LWIP 总池 CONFIG_LWIP_MAX_SOCKETS + * ├─ httpd 内部固定 3 + * ├─ max_open_sockets = 业务并发(下面 want) + * └─ 余量:UDP 调试日志 1 + 偶发其它 + * + * 约束:max_open_sockets <= LWIP_MAX_SOCKETS - 3 + * + * 业务并发压到 4(tools 单浏览器已足够): + * 1× /system/status 轮询 + 1× /logs 轮询 + 1× 操作(OTA/CAN) + 1× 余量 + * OTA 大上传期间前端 pausePolling,只占 1 条长连接,不与轮询抢槽。 + * lru_purge:满槽时踢最久空闲连接,避免 EMFILE 卡死。 + * 硬编码 7 在 LWIP=8 时会 ESP_ERR_INVALID_ARG → HTTP 起不来。 */ + { + const int lwip_cap = CONFIG_LWIP_MAX_SOCKETS - 3; // httpd 硬上限 + const int want = 4; // 合并并发需求后的业务槽位(原 7 偏松) + config.max_open_sockets = (lwip_cap < want) ? ((lwip_cap > 1) ? lwip_cap : 1) : want; + } + /* 满连接时淘汰最久未用的空闲 socket(相当于“复用/腾挪”槽位) */ config.lru_purge_enable = true; /* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉 */ config.recv_wait_timeout = 10; @@ -437,7 +454,7 @@ esp_err_t WifiOta::httpServerStart() {"/logs", HTTP_GET, logs_handler}, {"/system/status", HTTP_GET, system_status_handler}, {"/system/probe", HTTP_GET, system_probe_handler}, - {"/can/send", HTTP_POST, can_send_handler}, + {"/system/heap_low_log", HTTP_GET, system_heap_low_log_handler}, {"/can/send_batch", HTTP_POST, can_send_batch_handler}, {"/can/feedback", HTTP_GET, can_feedback_handler}, {"/can/mode", HTTP_GET, can_mode_handler}, @@ -445,7 +462,10 @@ esp_err_t WifiOta::httpServerStart() {"/spi/send", HTTP_POST, spi_send_handler}, }; - for (const auto &r : kHttpRoutes) { + /* 同一 path 可有多 method(如 GET+POST);OPTIONS 每个 path 只注册一次, + * 否则 httpd 报 “handler ... method 6 already registered”。 */ + for (size_t i = 0; i < sizeof(kHttpRoutes) / sizeof(kHttpRoutes[0]); ++i) { + const UriEntry &r = kHttpRoutes[i]; httpd_uri_t main_uri = {}; main_uri.uri = r.uri; main_uri.method = r.method; @@ -453,15 +473,24 @@ esp_err_t WifiOta::httpServerStart() main_uri.user_ctx = NULL; httpd_register_uri_handler(WifiOta::instance().m_httpServer, &main_uri); - httpd_uri_t opts_uri = {}; - opts_uri.uri = r.uri; - opts_uri.method = HTTP_OPTIONS; - opts_uri.handler = http_options_handler; - opts_uri.user_ctx = NULL; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri); + bool need_opts = true; + for (size_t j = 0; j < i; ++j) { + if (strcmp(kHttpRoutes[j].uri, r.uri) == 0) { + need_opts = false; + break; + } + } + if (need_opts) { + httpd_uri_t opts_uri = {}; + opts_uri.uri = r.uri; + opts_uri.method = HTTP_OPTIONS; + opts_uri.handler = http_options_handler; + opts_uri.user_ctx = NULL; + httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri); + } } - DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /restart, /logs, /system/status, /system/probe, /can/send, /can/send_batch, /spi/send, /can/feedback, /can/mode"); + DEBUG_INFO("WIFI", "HTTP端点已注册 (ota/can/spi/logs/status)"); return ESP_OK; } @@ -562,7 +591,7 @@ bool WifiOta::earlyInit() DEBUG_ERR("WIFI", "NVS 初始化失败: %s", esp_err_to_name(err)); } else { if (nvs_erased) { - DEBUG_WARN("WIFI", "NVS 已擦除重建,WiFi PHY 校准数据已清除,将重新校准"); + DEBUG_WARN("WIFI", "NVS重建,PHY校准已清,将重校"); } DEBUG_INFO("WIFI", "NVS 初始化成功"); } @@ -721,7 +750,7 @@ void WifiOta::taskEntry(void *pvParameters) ota_meta_t boot_meta; if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK && boot_meta.total_size > 0 && boot_meta.stored_size < boot_meta.total_size) { - DEBUG_WARN("WIFI", "检测到不完整 OTA 缓存 (stored=%u total=%u),清除残留状态", + DEBUG_WARN("WIFI", "不完整OTA缓存 stored=%u total=%u,清残留", (unsigned)boot_meta.stored_size, (unsigned)boot_meta.total_size); OtaCoordinator::instance().clearGd32Meta(); OtaCoordinator::instance().abortCurrent(); @@ -742,21 +771,21 @@ void WifiOta::taskEntry(void *pvParameters) // 等待网络栈稳定(ARP 表建立、路由就绪),避免首批 UDP 包丢失 vTaskDelay(pdMS_TO_TICKS(500)); - // 启动摘要,发两遍确保上位机收到(第一包可能触发 ARP 被丢) + // 启动摘要(单次;前面已 delay 等网络/ARP 稳定) esp_chip_info_t chip; esp_chip_info(&chip); - for (int i = 0; i < 2; i++) { - DEBUG_INFO("WIFI", "========================================"); - DEBUG_INFO("WIFI", "[启动摘要] ESP32-C3 OTA Relay v%s", FW_VERSION); - DEBUG_INFO("WIFI", "[启动摘要] Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip.revision); - DEBUG_INFO("WIFI", "[启动摘要] Free heap: %lu bytes", (unsigned long)esp_get_free_heap_size()); - DEBUG_INFO("WIFI", "[启动摘要] WiFi: %s", WifiOta::getMode() == OTA_WIFI_MODE_STA ? "STA" : "AP"); - DEBUG_INFO("WIFI", "[启动摘要] NVS + WiFi driver + netif + event handler 初始化完成"); - DEBUG_INFO("WIFI", "[启动摘要] HTTP 服务器端口: %d", HTTP_SERVER_PORT); - DEBUG_INFO("WIFI", "========================================"); - DEBUG_INFO("WIFI", "=== WiFi OTA 服务器就绪 ==="); - if (i == 0) vTaskDelay(pdMS_TO_TICKS(200)); - } + DEBUG_INFO("WIFI", "========================================"); + DEBUG_INFO("WIFI", "[启动摘要] ESP32-C3 OTA Relay v%s", FW_VERSION); + DEBUG_INFO("WIFI", "[启动摘要] Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip.revision); + DEBUG_INFO("WIFI", "[启动摘要] Free heap: %lu bytes", (unsigned long)esp_get_free_heap_size()); + DEBUG_INFO("WIFI", "[启动摘要] WiFi: %s", WifiOta::getMode() == OTA_WIFI_MODE_STA ? "STA" : "AP"); + DEBUG_INFO("WIFI", "[启动摘要] NVS + WiFi driver + netif + event handler 初始化完成"); + DEBUG_INFO("WIFI", "[启动摘要] HTTP 服务器端口: %d", HTTP_SERVER_PORT); + DEBUG_INFO("WIFI", "========================================"); + DEBUG_INFO("WIFI", "=== WiFi OTA 服务器就绪 ==="); + + /* 启动日志 burst 已过:环 64→16 槽,释放大约 7.5KB 给业务堆 */ + DebugLog::instance().shrinkRingAfterBoot(); // WiFi 状态监控 // 每秒一次循环:每 10 秒检查 RSSI diff --git a/main/storage/gd32_flash_store.cpp b/main/storage/gd32_flash_store.cpp index ac2d421..cb8f460 100644 --- a/main/storage/gd32_flash_store.cpp +++ b/main/storage/gd32_flash_store.cpp @@ -139,8 +139,8 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, 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); + DEBUG_ERR("OTA", "flashWrite越界 off=%" PRIu32 " n=%zu part=%" PRIu32, + offset, len, part->size); return ESP_ERR_INVALID_SIZE; } @@ -156,8 +156,8 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, } 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); + DEBUG_WARN("OTA", "flashWrite非对齐 off=%" PRIu32 " n=%zu head=%zu", + offset, len, head_pad); } uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len); @@ -485,7 +485,7 @@ esp_err_t Gd32FlashStore::metaVerifyChecksum() // 若直接返回 OK 跳过校验,meta 被意外清零时会掩盖固件损坏。 static const uint8_t kZeroSha[32] = {0}; if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) { - DEBUG_WARN("OTA", "metaVerify: checksum 未计算,返回 INVALID_STATE(调用方决定容忍或失败)"); + DEBUG_WARN("OTA", "metaVerify: checksum未算→INVALID_STATE"); return ESP_ERR_INVALID_STATE; } @@ -517,14 +517,20 @@ esp_err_t Gd32FlashStore::verifyFrontendSha(const uint8_t frontend_sha32[32], { if (!frontend_sha32) return ESP_ERR_INVALID_ARG; - // 1) 计算设备端 SHA 并写入 meta.checksum - esp_err_t err = metaFinalizeChecksum(); + /* 上传收尾 finishGd32StorageAfterReceive 通常已 metaFinalizeChecksum(并打过 SHA 日志)。 + * relay 触发时若 checksum 已有,直接比对,避免整盘重算 + 再打一遍「固件 SHA-256」。 */ + ota_meta_t meta; + esp_err_t err = metaLoad(&meta); if (err != ESP_OK) return err; - // 2) 读回 meta 拿到 checksum - ota_meta_t meta; - err = metaLoad(&meta); - if (err != ESP_OK) return err; + static const uint8_t kZeroSha[32] = {0}; + if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) { + err = metaFinalizeChecksum(); + if (err != ESP_OK) return err; + err = metaLoad(&meta); + if (err != ESP_OK) return err; + } + if (out_device_sha) memcpy(out_device_sha, meta.checksum, 32); // 3) 比对前端 SHA 与设备端 SHA diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 0b44c0e..105435f 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -69,7 +69,7 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, static int s_last_spi_err = 0; const int err_i = static_cast(ret); if (s_spi_fail_n == 0U || err_i != s_last_spi_err || (s_spi_fail_n % 32U) == 0U) { - DEBUG_WARN("CAN", "CAN_SEND SPI失败: ch=%u id=0x%08lX err=%s (x%lu)", + DEBUG_WARN("CAN", "SEND SPI fail ch=%u id=%08lX %s x%lu", (unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret), (unsigned long)(s_spi_fail_n + 1U)); } @@ -100,7 +100,7 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, static uint32_t s_rej_n = 0U; static uint8_t s_last_rej = 0xFFU; if (s_rej_n == 0U || gd32_err != s_last_rej || (s_rej_n % 32U) == 0U) { - DEBUG_WARN("CAN", "CAN_SEND GD32拒绝: ch=%u id=0x%08lX resp=0x%02X err=0x%02X (x%lu)", + DEBUG_WARN("CAN", "SEND拒 ch=%u id=%08lX r=%02X e=%02X x%lu", (unsigned)channel, (unsigned long)ext_id, resp_type, gd32_err, (unsigned long)(s_rej_n + 1U)); } @@ -280,7 +280,7 @@ int canFdSetRxMode(can_rx_mode_t mode, return 0; } - DEBUG_WARN("CAN", "SET_MODE 失败: want=%u spi=%s resp=0x%02X len=%u", + DEBUG_WARN("CAN", "SET_MODE fail want=%u %s r=0x%02X n=%u", (unsigned)mode, esp_err_to_name(ret), (unsigned)resp_type, diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 03cfcb7..87b24c4 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -97,11 +97,19 @@ SpiMaster& SpiMaster::instance() // SPI Worker:唯一持 SPI 的任务;ISR 只通知本任务 // ============================================================ +/** @brief 当前任务是否为 SPI worker(仅 worker 可直接走无锁路径)。 */ bool SpiMaster::onWorker() const { return m_workerTask != nullptr && xTaskGetCurrentTaskHandle() == m_workerTask; } +/** + * @brief 将 GD32 EVENT 帧入队(worker 上下文)。 + * @param type 业务 TYPE(如 0xC1/0xC4) + * @param payload 载荷指针,可为 nullptr + * @param len 载荷长度 + * @return 始终 true;队列满时丢最旧一帧再写入 + */ bool SpiMaster::pushEvent(uint8_t type, const uint8_t *payload, size_t len) { if (m_eventCount >= kEventSlots) { @@ -126,6 +134,14 @@ bool SpiMaster::pushEvent(uint8_t type, const uint8_t *payload, size_t len) return true; } +/** + * @brief 从事件队列弹出一帧(非阻塞)。 + * @param[out] type 业务 TYPE + * @param[out] payload 拷贝载荷;可为 nullptr + * @param max_len payload 缓冲容量 + * @param[out] len 实际拷贝长度 + * @return true 取到一帧;false 队列空 + */ bool SpiMaster::popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len) { if (m_eventCount == 0U) { @@ -150,6 +166,10 @@ bool SpiMaster::popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t return true; } +/** + * @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理。 + * @param suppress true=OTA 等关键路径禁止偷读 PB6;false=恢复 + */ void SpiMaster::setSuppressRingService(bool suppress) { m_suppressRingService = suppress; @@ -159,6 +179,10 @@ void SpiMaster::setSuppressRingService(bool suppress) } } +/** + * @brief 业务侧确认本端 TX 序号已被对端接受,推进 m_txSeq。 + * @note 在收到明确业务 ACK 后调用,避免重发与序号失配。 + */ void SpiMaster::confirmTxSeqAccepted() { /* 业务 ACK 已确认:保证下一包使用 lastSentSeq+1,即使头 ack_seq 曾失配 */ @@ -167,6 +191,10 @@ void SpiMaster::confirmTxSeqAccepted() m_lastRxAckMatched = true; } +/** + * @brief worker 单次服务 GD32 按铃:读一帧并入 EVENT 队列。 + * @return ESP_OK 入队成功;ESP_ERR_NOT_FOUND 无线/被抑制;其它为读失败 + */ esp_err_t SpiMaster::serviceRingOnce() { /* OTA 中继期间禁止按铃读:OTA ACK/WAIT 也拉低 PB6,偷读会导致 poll 空等。 */ @@ -230,6 +258,10 @@ esp_err_t SpiMaster::serviceRingOnce() return ESP_OK; } +/** + * @brief worker 执行一条排队 Job(Transact/Hello/Ping/EnterBoot 等)。 + * @param job 入出参:写入 job.result + */ void SpiMaster::executeJob(Job &job) { /* 已在 worker 上下文;下列 public API 见 onWorker() 后走实现体,不再二次入队。 */ @@ -263,6 +295,10 @@ void SpiMaster::executeJob(Job &job) } } +/** + * @brief worker 完成堆上 Job 后的收尾:释放完成信号量,超时放弃时由 worker free。 + * @param job 堆分配的 Job 指针 + */ void SpiMaster::finishHeapJob(Job *job) { if (job == nullptr) { @@ -288,6 +324,11 @@ void SpiMaster::finishHeapJob(Job *job) } } +/** + * @brief 将 Job 提交给 SPI worker 串行执行(非 worker 线程走队列)。 + * @param job 任务描述(栈上 Job 会拷到堆上再排队) + * @return 业务结果码;队列满/超时返回 ESP_ERR_TIMEOUT + */ esp_err_t SpiMaster::submitJob(Job &job) { if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) { @@ -363,11 +404,16 @@ esp_err_t SpiMaster::submitJob(Job &job) return ESP_ERR_TIMEOUT; } +/** @brief FreeRTOS 任务入口,转到 workerLoop。 */ void SpiMaster::workerEntry(void *arg) { static_cast(arg)->workerLoop(); } +/** + * @brief SPI worker 主循环:处理按铃 EVENT + 执行 job 队列。 + * @note 全栈唯一允许直接访问 SPI 总线的任务上下文。 + */ void SpiMaster::workerLoop() { m_workerTask = xTaskGetCurrentTaskHandle(); @@ -490,7 +536,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len, } if ((tx_len > 0U && tx_buf == NULL) || (rx_len > 0U && rx_buf == NULL)) { - DEBUG_WARN("SPI", "transferClocked INVALID_ARG: tx_len=%u tx_buf=%p rx_len=%u rx_buf=%p dev=%p", + DEBUG_WARN("SPI", "xfer INVALID_ARG tx=%u rx=%u dev=%p", (unsigned)tx_len, tx_buf, (unsigned)rx_len, rx_buf, m_spiDevice); return ESP_ERR_INVALID_ARG; } @@ -556,7 +602,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len, esp_err_to_name(ret), (unsigned)(total_len * 8U)); ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, pdMS_TO_TICKS(1000)); if (ret != ESP_OK) { - DEBUG_ERR("SPI", "spi_device_get_trans_result 放弃: %s(可能需复位 SPI)", + DEBUG_ERR("SPI", "trans_result放弃:%s 或复位SPI", esp_err_to_name(ret)); /* 尽力排空 ret_queue,避免后续 queue_trans 永久堵死 */ spi_transaction_t *junk = nullptr; @@ -690,7 +736,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, if (!is_event) { static uint32_t s_sess_mm_logs = 0U; if ((++s_sess_mm_logs % 32U) == 1U) { - DEBUG_WARN("SPI", "v2 SESSION mismatch: got=0x%08lX expected=0x%08lX type=0x%02X (log#%lu)", + DEBUG_WARN("SPI", "v2 SES mismatch got=%08lX exp=%08lX t=%02X #%lu", (unsigned long)hdr.session_id, (unsigned long)self.m_sessionId, hdr.type, (unsigned long)s_sess_mm_logs); } @@ -714,7 +760,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, if (!self.m_lastRxAckMatched) { static uint32_t s_stale_logs = 0U; if ((++s_stale_logs % 32U) == 1U) { - DEBUG_DBG("SPI", "v2 stale RESPONSE delivered: type=0x%02X ack_seq=%u expected=%u (log#%lu)", + DEBUG_DBG("SPI", "v2 stale rsp t=0x%02X ack=%u exp=%u #%lu", hdr.type, hdr.ack_seq, self.m_lastSentSeq, (unsigned long)s_stale_logs); } @@ -791,7 +837,7 @@ esp_err_t SpiMaster::init() return ret; } - DEBUG_INFO("SPI", "SPI2 总线初始化完成: MOSI=GPIO5, MISO=GPIO7, SCLK=GPIO6, CS=GPIO%d, CLK=%d Hz", + DEBUG_INFO("SPI", "SPI2 ok CS=GPIO%d CLK=%dHz", SPI_MASTER_CS_GPIO, SPI_CLOCK_HZ); /* 配置 SPI Ready 握手引脚(GPIO10),v2 DATA_READY 模式:仅输入。 @@ -873,12 +919,20 @@ esp_err_t SpiMaster::init() return ESP_OK; } +/** + * @brief L0 本机自检:SPI 设备、互斥、worker、job 队列是否就绪。 + * @return true 本机 SPI 栈可用 + */ bool SpiMaster::selfTestL0() const { return (m_spiDevice != nullptr) && (m_mutex != nullptr) && (m_workerTask != nullptr) && (m_jobQueue != nullptr); } +/** + * @brief L0 对端轻检:短超时 HELLO,确认链路可达 GD32。 + * @return true 至少一次 HELLO 成功 + */ bool SpiMaster::selfTestPeerL0() { /* 短超时、最多 2 次;不拉长整体启动 */ @@ -1415,7 +1469,7 @@ esp_err_t SpiMaster::appRequestBootloader() } else if (hdr.type == V2_TYPE_LINK_ERROR) { const uint8_t errc = (hdr.payload_len > 0U && pload) ? pload[0] : 0xFFU; const uint8_t exp = (hdr.payload_len > 1U && pload) ? pload[1] : 0xFFU; - DEBUG_WARN("SPI", "ENTER_BOOT 被拒 LINK_ERROR code=0x%02X expected_seq=%u last_sent=%u", + DEBUG_WARN("SPI", "ENTER_BOOT拒 code=0x%02X exp=%u last=%u", errc, exp, (unsigned)m_lastSentSeq); break; } else if (hdr.type == SPI_OTA_RSP_ERROR) { @@ -1441,7 +1495,7 @@ esp_err_t SpiMaster::appRequestBootloader() /* 静默:APP 擦配置页(可达数十 ms) + 软复位 + BOOT 起机。 * 此间禁止 HELLO,否则 role 会一直像卡在 APP。 */ const uint32_t kQuietMs = got_ack ? 100U : 30U; - DEBUG_INFO("SPI", "ENTER_BOOT 已上总线 (ack=%s),静默 %lums 等待 APP 复位...", + DEBUG_INFO("SPI", "ENTER_BOOT ok ack=%s waitAPP %lums", got_ack ? "yes" : "no", (unsigned long)kQuietMs); vTaskDelay(pdMS_TO_TICKS(kQuietMs)); return got_ack ? ESP_OK : ESP_ERR_INVALID_RESPONSE; @@ -1515,17 +1569,92 @@ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) vTaskDelay(miss_gap); } - DEBUG_WARN("SPI", "Bootloader 就绪超时 (%lums, rounds=%lu, app_hits=%lu)", + DEBUG_WARN("SPI", "BOOT就绪超时 %lums r=%lu app=%lu", (unsigned long)timeout_ms, (unsigned long)rounds, (unsigned long)app_hits); return ESP_ERR_TIMEOUT; } +/** + * @brief 解析 GD32 APP 身份 16B 并写入本端缓存。 + * + * 布局与 HELLO_ACK 附加块 / APP PING body 相同(见 spi_link_v2.h V2_APP_IDENTITY_LEN): + * [0..3] APP ver · [4..7] BOOT ver · [8..11] uptime_ms LE + * [12] feat_map_ver · [13..14] feature_flags LE · [15] rsv + * + * @param id 身份块指针(至少 16 字节) + * @param out_version 可选,输出解析后的版本结构 + * @param out_uptime_sec 可选,输出 uptime(秒) + */ +void SpiMaster::applyAppIdentity16(const uint8_t *id, gd32_version_t *out_version, + uint32_t *out_uptime_sec) +{ + if (!id) { + return; + } + gd32_version_t ver{}; + ver.app_major = id[0]; + ver.app_minor = id[1]; + ver.app_patch = id[2]; + ver.app_build = id[3]; + ver.boot_major = id[4]; + ver.boot_minor = id[5]; + ver.boot_patch = id[6]; + ver.boot_build = id[7]; + + const uint32_t uptime_ms = (uint32_t)id[8] + | ((uint32_t)id[9] << 8) + | ((uint32_t)id[10] << 16) + | ((uint32_t)id[11] << 24); + if (out_uptime_sec) { + *out_uptime_sec = uptime_ms / 1000U; + } + + uint16_t feat_flags = 0U; + bool feat_known = false; + if (id[12] == V2_FEAT_MAP_VER) { + feat_flags = v2_get_u16le(&id[13]); + feat_known = true; + } + + /* HELLO 与 PING 都会带同一份 16B 身份:仅版本/feat 变化时打 INFO,避免连打两遍 */ + const bool ver_changed = + !m_gd32IdKnown || + m_gd32Version.app_major != ver.app_major || + m_gd32Version.app_minor != ver.app_minor || + m_gd32Version.app_patch != ver.app_patch || + m_gd32Version.app_build != ver.app_build || + m_gd32Version.boot_major != ver.boot_major || + m_gd32Version.boot_minor != ver.boot_minor || + m_gd32Version.boot_patch != ver.boot_patch || + m_gd32Version.boot_build != ver.boot_build || + m_gd32FeatKnown != feat_known || + (feat_known && m_gd32FeatFlags != feat_flags); + + m_gd32Version = ver; + m_gd32IdKnown = true; + if (out_version) { + *out_version = ver; + } + m_gd32FeatKnown = feat_known; + m_gd32FeatFlags = feat_known ? feat_flags : 0U; + + if (ver_changed && feat_known) { + DEBUG_INFO("SPI", "ID APP=%u.%u.%u.%u feat=%04lX", + (unsigned)ver.app_major, (unsigned)ver.app_minor, + (unsigned)ver.app_patch, (unsigned)ver.app_build, + (unsigned long)m_gd32FeatFlags); + DEBUG_INFO("SPI", "ID BOOT=%u.%u.%u.%u ch=%u", + (unsigned)ver.boot_major, (unsigned)ver.boot_minor, + (unsigned)ver.boot_patch, (unsigned)ver.boot_build, + (unsigned)((m_gd32FeatFlags & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT)); + } +} + /** * @brief 向 GD32 APP 发送存活探测(帧长发送 + DATA_READY 变长读)。 * * 两步法(持锁):otaTransferUnlocked(expect_stale) + otaPollResponseUnlocked。 - * GD32 App PING 响应 type=kRspAck(0x80), - * payload 16B = APP ver(4) + BOOT ver(4) + uptime_ms(4) + build_hash(4)。 + * 成功时 body 为身份 16B,与 HELLO_ACK 附加块同布局,经 applyAppIdentity16 入库。 */ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version) { @@ -1576,34 +1705,26 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver } const uint8_t *pload = resp_payload; - if (version) { + if (resp_len >= V2_APP_IDENTITY_LEN) { + applyAppIdentity16(pload, version, uptime_sec); + } else if (version) { memset(version, 0, sizeof(gd32_version_t)); version->app_major = pload[0]; version->app_minor = pload[1]; version->app_patch = pload[2]; version->app_build = pload[3]; - if (resp_len >= 16U) { - version->boot_major = pload[4]; - version->boot_minor = pload[5]; - version->boot_patch = pload[6]; - version->boot_build = pload[7]; - } } - if (uptime_sec) { - const uint8_t *ut = (resp_len >= 16U) ? &pload[8] : &pload[4]; - const uint32_t uptime_ms = (uint32_t)ut[0] - | ((uint32_t)ut[1] << 8) - | ((uint32_t)ut[2] << 16) - | ((uint32_t)ut[3] << 24); - *uptime_sec = uptime_ms / 1000U; - } - DEBUG_DBG("SPI", "APP PING v2 成功"); + DEBUG_DBG("SPI", "APP PING 成功 len=%u", (unsigned)resp_len); return ESP_OK; } // ============================================================ // SPI Link v2 链路状态 // ============================================================ +/** + * @brief 拷贝链路统计快照(有界取锁,失败返回零值)。 + * @return 统计结构副本 + */ SpiMaster::SpiLinkStats SpiMaster::linkStats() { /* OTA 中继期间 SPI worker 在 otaSendAndPoll 全程持有 m_mutex(发送+轮询 ACK, @@ -1618,6 +1739,10 @@ SpiMaster::SpiLinkStats SpiMaster::linkStats() return ret; } +/** + * @brief 完整链路状态(session/role/credit + 统计),供 /system/status。 + * @return 状态快照 + */ SpiMaster::SpiLinkStatus SpiMaster::linkStatus() { /* 状态字段为 volatile 单字节/双字读,原子性足够;统计复用 linkStats() 的有界取锁。 */ @@ -1631,6 +1756,12 @@ SpiMaster::SpiLinkStatus SpiMaster::linkStatus() return st; } +/** + * @brief 发起 HELLO 建 session;APP 侧 ACK 可附带身份 16B(版本+开关)。 + * @param timeout_ms 总超时 + * @param role_hint 0=any,1=app,2=boot + * @return ESP_OK 成功;超时/坏响应见错误码 + */ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) { if (!onWorker() && m_workerTask != nullptr) { @@ -1729,20 +1860,22 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) /* payload[5]/hdr.credit 均为对端 credit_max → 写入 m_remoteCredit。 * 勿写入 m_localCredit(本端 RX 窗口,init 为 8)。 */ m_remoteCredit = (pload[5] != 0U) ? pload[5] : hdr.credit; + /* APP:HELLO_ACK 带 16B 身份 → 建连即完成开机身份(与 PING 同布局) */ + if (m_gd32Role == V2_ROLE_APP && + hdr.payload_len >= V2_HELLO_ACK_APP_LEN) { + applyAppIdentity16(&pload[V2_HELLO_ACK_BASE_LEN], nullptr, nullptr); + } m_v2Active = true; m_stats.resyncs++; return ESP_OK; } if (!saw_any_frame) { + /* 不打全包 hex:只记 attempts + 前 4 字节便于判 SOF */ DEBUG_WARN("SPI", - "HELLO: no valid v2 frame in response (attempts=%lu) rx[0..15]=" - "%02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", + "HELLO no v2 frame att=%lu rx0-3=%02X %02X %02X %02X", (unsigned long)attempt, - last_rx_head[0], last_rx_head[1], last_rx_head[2], last_rx_head[3], - last_rx_head[4], last_rx_head[5], last_rx_head[6], last_rx_head[7], - last_rx_head[8], last_rx_head[9], last_rx_head[10], last_rx_head[11], - last_rx_head[12], last_rx_head[13], last_rx_head[14], last_rx_head[15]); + last_rx_head[0], last_rx_head[1], last_rx_head[2], last_rx_head[3]); } else { DEBUG_WARN("SPI", "HELLO: bad/lag response type=0x%02X flags=0x%02X attempts=%lu", last_type, last_flags, (unsigned long)attempt); diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 702a781..a2eaf9c 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -99,6 +99,10 @@ public: /** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */ TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; } + /** @brief SPI 任务队列当前待处理 job 数(每个堆 Job 持有 calloc 的 Job 结构)。 + * 供 /system/heap_low_log 堆低水位快照采样,定位"低谷是不是 SPI 队列堆积引起"。 */ + UBaseType_t getPendingJobCount() const { return m_jobQueue ? uxQueueMessagesWaiting(m_jobQueue) : 0; } + /** * @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。 * 中继开始设 true,结束/复位设 false。 @@ -160,6 +164,26 @@ public: /** @brief 最近一次 HELLO 得到的 GD32 角色(0=unknown, 1=app, 2=boot)。 */ uint8_t gd32Role() const { return m_gd32Role; } + /** + * @brief 是否已从 HELLO_ACK/PING 身份块拿到功能位图。 + * @note false=尚未建连身份或旧固件。 + */ + bool gd32FeatKnown() const { return m_gd32FeatKnown; } + + /** @brief GD32 功能位图(V2_FEAT_*);未知时为 0。 */ + uint32_t gd32FeatFlags() const { return m_gd32FeatFlags; } + + /** @brief 便捷:GD32 是否开启 ADC 0xC4(需 gd32FeatKnown)。 */ + bool gd32FeatAdcFeedback() const { + return m_gd32FeatKnown && (m_gd32FeatFlags & V2_FEAT_ADC_FEEDBACK) != 0U; + } + + /** @brief 是否已从 HELLO_ACK/PING 拿到 APP/BOOT 版本。 */ + bool gd32IdKnown() const { return m_gd32IdKnown; } + + /** @brief 最近身份块中的版本(HELLO 或 PING)。 */ + gd32_version_t gd32VersionCached() const { return m_gd32Version; } + /** @brief 获取链路统计快照(线程安全,持锁读取)。 */ SpiLinkStats linkStats(); @@ -315,11 +339,24 @@ private: void executeJob(Job &job); /** @brief worker 完成堆 Job 后的生命周期收尾(give done + 可能 free)。 */ void finishHeapJob(Job *job); + /** @brief FreeRTOS 任务入口,转到 workerLoop。 */ static void workerEntry(void *arg); + /** @brief SPI worker 主循环:EVENT 按铃 + Job 队列。 */ void workerLoop(); + /** @brief 单次服务 GD32 按铃读帧并入 EVENT 队列。 */ esp_err_t serviceRingOnce(); + /** @brief EVENT 入队(队列满丢最旧)。 */ bool pushEvent(uint8_t type, const uint8_t *payload, size_t len); + /** @brief EVENT 出队(非阻塞)。 */ bool popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len); + /** + * @brief 解析并缓存 APP 身份 16B(HELLO_ACK 附加块 / PING body 同布局)。 + * @param id 身份块 + * @param out_version 可选输出版本 + * @param out_uptime_sec 可选输出 uptime 秒 + */ + void applyAppIdentity16(const uint8_t *id, gd32_version_t *out_version, + uint32_t *out_uptime_sec); // ── 帧内同步状态机 ── volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口) @@ -332,6 +369,10 @@ private: volatile uint8_t m_rxAckedSeq = 0; ///< 已确认的对端最新序号 volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号 volatile uint8_t m_gd32Role = 0; ///< GD32 角色(0=unknown, 1=app, 2=boot) + volatile bool m_gd32FeatKnown = false; ///< HELLO/PING 身份是否带 feat_map + volatile uint32_t m_gd32FeatFlags = 0; ///< V2_FEAT_* + volatile bool m_gd32IdKnown = false; ///< 是否已缓存版本号 + gd32_version_t m_gd32Version = {}; ///< HELLO/PING 缓存的版本 volatile bool m_v2Active = false; ///< v2 session 是否激活 /** 最近一次 otaFindFrame 解析到的 RESPONSE 是否与 m_lastSentSeq 归属匹配 */ volatile bool m_lastRxAckMatched = false; diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 4c27c1a..b33d28d 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -22,11 +22,10 @@ CONFIG_DCU_SPI_CLOCK_HZ=4000000 # 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE) # 提到 12 留足缓冲,配合 can_feedback_task 退避从根本上避免 socket 饥荒 CONFIG_LWIP_MAX_SOCKETS=12 -# C3 堆紧:WiFi+BLE 双栈时 TCP 窗口过大 → OTA 收流 free 压到 ~5KB → 卡半/堆爆。 -# 6144=6KB≈4*MSS:优先压峰值堆;OTA 瓶颈多在 Flash 擦写,吞吐损失通常可接受。 +# C3 堆紧:窗口过大 OTA 峰值易顶;8KB≈5*MSS,在 4KB OTA 块下吞吐更好,仍比 12KB+ 保守。 # 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。 -CONFIG_LWIP_TCP_WND_DEFAULT=6144 -CONFIG_LWIP_TCP_SND_BUF_DEFAULT=6144 +CONFIG_LWIP_TCP_WND_DEFAULT=8192 +CONFIG_LWIP_TCP_SND_BUF_DEFAULT=8192 # 乱序队列 pbuf:4→2,少占堆;HTTP 顺序上传基本不需要大 OOSEQ CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=2 @@ -36,8 +35,17 @@ CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6 CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16 CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16 +# ── 固件体积裁剪(P0,2026-07):仅三项,WPA2-PSK / 本地 HTTP / IPv4 OTA 不受影响 ── +# 关闭企业 WiFi(802.1X),家用 WPA2-PSK 不受影响 +CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT=n +# 无 HTTPS 出站客户端,本地 HTTP OTA 不需要 WiFi 内嵌 mbedTLS TLS client +CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT=n +# 仅 IPv4;关 IPv6 可去掉 nd6 等路径 +CONFIG_LWIP_IPV6=n + # FreeRTOS runtime stats -- /system/status CPU usage CONFIG_FREERTOS_USE_TRACE_FACILITY=y + CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y diff --git a/tools/_bss_breakdown.py b/tools/_bss_breakdown.py new file mode 100644 index 0000000..7dfc005 --- /dev/null +++ b/tools/_bss_breakdown.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re +from collections import defaultdict + +ROOT = Path(__file__).resolve().parents[1] +text = (ROOT / "build" / "ESP32_COMM.map").read_text(encoding="utf-8", errors="replace") +rows = [] +lines = text.splitlines() +i = 0 +while i < len(lines): + line = lines[i] + m = re.match(r"^\s*\.bss\.(\S+)\s*$", line) + m2 = re.match(r"^\s*\.bss\.(\S+)\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)", line) + if m2: + rows.append((int(m2.group(3), 16), m2.group(1), "")) + elif m: + name = m.group(1) + j = i + 1 + while j < len(lines) and j < i + 5: + l2 = lines[j] + mm = re.match(r"^\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s+(\S+)", l2) + if mm: + rows.append((int(mm.group(2), 16), name, mm.group(3))) + break + if re.match(r"^\s*\.", l2) and not l2.strip().startswith(".bss"): + break + j += 1 + i += 1 + + +def demangle(n: str) -> str: + n = n.replace("_ZN12_GLOBAL__N_1L", "") + n = n.replace("_ZL", "") + if n.startswith("_ZZ"): + n = "local:" + n[3:50] + return n[:55] + + +cats = defaultdict(int) +app = [] +for size, name, obj in rows: + o = (obj or "").lower() + if "libmain" in o: + cat = "app(main)" + app.append((size, name, obj)) + elif "libfreertos" in o: + cat = "freertos" + elif "liblwip" in o: + cat = "lwip" + elif any(x in o for x in ("libnet80211", "libpp", "libwpa", "libmesh")): + cat = "wifi/mesh" + elif any(x in o for x in ("libnimble", "libbt", "bt.", "ble_")): + cat = "ble" + elif any( + x in name + for x in ("g_cnx", "gWpa", "g_ic", "g_pm", "gChm", "gScan", "g_mesh", "mesh_") + ): + cat = "wifi/mesh(sym)" + else: + cat = "other/idf" + cats[cat] += size + +print("=== category ===") +for k, v in sorted(cats.items(), key=lambda x: -x[1]): + print(f"{v/1024:6.2f} KB {k}") +print(f"{sum(cats.values())/1024:6.2f} KB TOTAL") + +print("\n=== app(main) ===") +app.sort(reverse=True) +s = 0 +for size, name, obj in app: + s += size + print(f"{size:5d} {demangle(name)}") +print(f"app sum {s} ({s/1024:.2f} KB)") + +print("\n=== top 20 overall ===") +rows.sort(reverse=True) +for size, name, obj in rows[:20]: + short = obj.split("/")[-1] if obj else "?" + print(f"{size:5d} ({size/1024:5.2f}K) {demangle(name):45s} {short[:28]}") diff --git a/tools/_legacy5.txt b/tools/_legacy5.txt new file mode 100644 index 0000000..96f5930 --- /dev/null +++ b/tools/_legacy5.txt @@ -0,0 +1,21 @@ +Threshold: >128 bytes (prefix+body+newline, UTF-8) +Candidates: 5 + + # est body pre lv tag location +---------------------------------------------------------------------------------------------------- + 1 176 157 18 DBG TAG main/server/ble_ota_data.cpp:100 + fmt: BLEp(%s) %.1f-%.1f ce=%u-%u rc=%d + args: %s鈫?6:windows_compatible ? "Win" : "Hi" + 2 153 134 18 WARN kTag main/monitor/can_feedback_task.cpp:122 + fmt: CH%u s=%lu e=%lu T=%u R=%u st=%02X + 3 153 134 18 DBG TAG main/server/ble_ota_data.cpp:69 + fmt: %sitv=%.2f lat=%u sup=%u + args: %s鈫?8:phase + 4 141 122 18 INFO TAG main/server/ble_ota_host.cpp:545 + fmt: PHY %d %s/%s + args: %s鈫?8:rx_phy; %s鈫?8:tx_phy + 5 135 116 18 ERR TAG main/ota/esp32_ota_core.cpp:227 + fmt: ota_write %s n=%u off=%u free=%lu + args: %s鈫?2:esp_err_to_name(err) + +Total over 128B: 5 diff --git a/tools/_scan_concat_fmt.py b/tools/_scan_concat_fmt.py new file mode 100644 index 0000000..ce4f669 --- /dev/null +++ b/tools/_scan_concat_fmt.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] +# Match DEBUG_*(tag, "..." PRI... "..." ...) +pat = re.compile( + r'DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*' + r'((?:"(?:\\.|[^"\\])*"\s*(?:PRIu\d+|PRIx\d+|PRId\d+|PRIX\d+)?\s*)+)', + re.M, +) + +rows = [] +for path in (ROOT / "main").rglob("*.cpp"): + t = path.read_text(encoding="utf-8") + for m in pat.finditer(t): + raw = m.group(3) + parts = re.findall(r'"((?:\\.|[^"\\])*)"', raw) + if not parts: + continue + joined = "".join(parts) + # replace PRI holes with %u placeholder length 10 each + pri_count = len(re.findall(r"PRI[udxX]\d+", raw)) + # rough: joined has empty holes between lits around PRI + body = len(joined.encode()) + pri_count * 10 + # also % in joined + body += joined.count("%") * 8 # remaining % + if body < 100: + continue + ln = t.count("\n", 0, m.start()) + 1 + rows.append((body, path.relative_to(ROOT).as_posix(), ln, joined[:100], pri_count)) + +rows.sort(reverse=True) +print(f"Long concat fmt candidates: {len(rows)}") +for b, f, ln, j, pc in rows[:40]: + print(f"{b:4d} {f}:{ln} pri={pc}") + print(f" {j}") diff --git a/tools/_scan_real_over128.py b/tools/_scan_real_over128.py new file mode 100644 index 0000000..b7dc754 --- /dev/null +++ b/tools/_scan_real_over128.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Scan DEBUG_* with realistic arg widths (actual-ish max line size).""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +THRESHOLD = 128 + +PAT = re.compile( + r'DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*"((?:\\.|[^"\\])*)"\s*(?:,|\))', + re.M, +) + +# Known TAG macros +KNOWN_TAGS = { + "TAG": 7, # worst of BLE_OTA/OTA_CORE/OTA_COORD + "kTag": 8, +} + + +def unescape(fmt: str) -> str: + return fmt.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"') + + +def parse_args(text: str, fmt_end: int) -> list[str]: + i = fmt_end + while i < len(text) and text[i] in " \t\r\n": + i += 1 + if i < len(text) and text[i] == ")": + return [] + if i < len(text) and text[i] == ",": + i += 1 + args, depth, start = [], 0, i + in_str, esc = False, False + while i < len(text): + c = text[i] + if in_str: + if esc: + esc = False + elif c == "\\": + esc = True + elif c == '"': + in_str = False + i += 1 + continue + if c == '"': + in_str = True + i += 1 + continue + if c in "([{": + depth += 1 + elif c in ")]}": + if depth == 0 and c == ")": + part = text[start:i].strip() + if part: + args.append(part) + break + depth -= 1 + elif c == "," and depth == 0: + part = text[start:i].strip() + if part: + args.append(part) + start = i + 1 + i += 1 + if i - start > 500: + break + return args + + +def s_est(arg: str) -> int: + a = arg.strip() + if re.search(r"frontend_sha|device_hex|device_sha|sha_hex", a, re.I): + return 64 + if re.search(r"resp_hex", a, re.I): + return 64 # should not dump full anymore + if "esp_err_to_name" in a: + return 24 + if re.search(r"stateToString|phase|label|name|rx_phy|tx_phy", a, re.I): + return 16 + m = re.match(r'^"((?:\\.|[^"\\])*)"$', a) + if m: + return len(unescape(m.group(1)).encode()) + if "?" in a and ":" in a: + # ternary of string lits + parts = re.findall(r'"((?:\\.|[^"\\])*)"', a) + if parts: + return max(len(unescape(p).encode()) for p in parts) + return 8 + return 16 + + +def body_est(fmt: str, args: list[str]) -> int: + i, n, out, ai = 0, len(fmt), 0, 0 + + def nexta() -> str: + nonlocal ai + if ai < len(args): + a = args[ai] + ai += 1 + return a + return "" + + while i < n: + if fmt[i] != "%": + j = fmt.find("%", i) + if j < 0: + out += len(fmt[i:].encode()) + break + out += len(fmt[i:j].encode()) + i = j + continue + if i + 1 < n and fmt[i + 1] == "%": + out += 1 + i += 2 + continue + i += 1 + while i < n and fmt[i] in "-+ #0": + i += 1 + if i < n and fmt[i] == "*": + nexta() + i += 1 + else: + while i < n and fmt[i].isdigit(): + i += 1 + if i < n and fmt[i] == ".": + i += 1 + if i < n and fmt[i] == "*": + nexta() + i += 1 + else: + while i < n and fmt[i].isdigit(): + i += 1 + while i < n and fmt[i] in "hlLzjtq": + i += 1 + if i >= n: + break + conv = fmt[i] + i += 1 + arg = nexta() + if conv in "diouxX": + out += 10 # realistic + elif conv in "fFeEgG": + out += 8 + elif conv == "s": + out += s_est(arg) + elif conv == "c": + out += 1 + elif conv == "p": + out += 10 + else: + out += 8 + return out + + +def tag_len(expr: str) -> tuple[int, str]: + e = expr.strip() + m = re.match(r'^"([^"]*)"$', e) + if m: + return len(m.group(1).encode()), m.group(1) + if e in KNOWN_TAGS: + return KNOWN_TAGS[e], e + return 8, e + + +def main() -> int: + rows = [] + for path in ROOT.rglob("*"): + if path.suffix not in {".cpp", ".c", ".h"}: + continue + if "build" in path.parts or ".git" in path.parts: + continue + if "components" in path.parts: + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + rel = path.relative_to(ROOT).as_posix() + for m in PAT.finditer(text): + level, tag_expr, fmt_raw = m.group(1), m.group(2), m.group(3) + fmt = unescape(fmt_raw).rstrip("\n\r") + args = parse_args(text, m.end(3) + 1) + body = body_est(fmt, args) + tlen, tag = tag_len(tag_expr) + lc = {"ERR": "E", "WARN": "W", "INFO": "I", "DBG": "D"}[level] + if tag_expr.strip().startswith('"'): + prefix = len(f"[{lc}][{tag}] ".encode()) + else: + prefix = len(f"[{lc}][".encode()) + tlen + len("] ".encode()) + total = prefix + body + 1 + if total <= THRESHOLD: + continue + ln = text.count("\n", 0, m.start()) + 1 + rows.append((total, body, prefix, level, tag, rel, ln, fmt)) + rows.sort(reverse=True) + print(f"Realistic threshold >{THRESHOLD}B: {len(rows)} candidates\n") + for i, (total, body, pre, lv, tag, rel, ln, fmt) in enumerate(rows, 1): + fs = fmt if len(fmt) <= 90 else fmt[:87] + "..." + print(f"{i:3d} {total:4d} body={body:3d} pre={pre:2d} {lv:4s} {str(tag)[:10]:10s} {rel}:{ln}") + print(f" {fs}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/_size_arch.csv b/tools/_size_arch.csv new file mode 100644 index 0000000..8574637 --- /dev/null +++ b/tools/_size_arch.csv @@ -0,0 +1,130 @@ +"Archive File","Total Size","DRAM",".bss",".data",".text","Flash +Code",".text","Flash Data",".tdata",".rodata",".appdesc","RTC +SLOW",".rtc_reserved",".force_slow" +"libnet80211.a","154301","12686","7678","936","4072","128198","128198","13417"," +0","13417","0","0","0","0" +"libesp_stdio.a","114154","16","16","0","0","402","402","113736","0","113736","0 +","0","0","0" +"libbtdm_app.a","98094","20783","684","481","19618","72152","72152","5159","0"," +5159","0","0","0","0" +"liblwip.a","88946","2602","2590","12","0","82916","82916","3428","0","3428","0" +,"0","0","0" +"libmain.a","85209","26097","22673","3264","160","57804","57804","1308","0","130 +8","0","0","0","0" +"libpp.a","68522","19794","1262","2644","15888","44814","44814","3914","0","3914 +","0","0","0","0" +"libmbed-builtin.a","57570","60","12","48","0","45710","45710","11800","0","1180 +0","0","0","0","0" +"libbt.a","56078","1060","139","233","688","51916","51916","3102","0","3102","0" +,"0","0","0" +"libwpa_supplicant.a","37603","1138","1138","0","0","36390","36390","75","0","75 +","0","0","0","0" +"libphy.a","36923","5801","87","1312","4402","31122","31122","0","0","0","0","0" +,"0","0" +"libtfpsacrypto.a","24703","562","400","80","82","23844","23844","297","0","297" +,"0","0","0","0" +"libfreertos.a","20942","17688","2560","16","15112","1690","1690","1564","0","15 +64","0","0","0","0" +"libesp_hw_support.a","20186","6251","145","160","5946","13170","13170","705","0 +","705","0","60","24","36" +"libspi_flash.a","17438","15081","10","5867","9204","1322","1322","1035","0","10 +35","0","0","0","0" +"libc.a","14702","4","0","4","0","13610","13610","1088","0","1088","0","0","0"," +0" +"libnvs_flash.a","14264","24","24","0","0","14108","14108","132","0","132","0"," +0","0","0" +"libtransport.a","14237","1271","50","1145","76","11942","11942","1024","0","102 +4","0","0","0","0" +"libhttp_parser.a","12694","0","0","0","0","11980","11980","714","0","714","0"," +0","0","0" +"libesp_driver_uart.a","11468","225","25","200","0","10782","10782","461","0","4 +61","0","0","0","0" +"libesp_system.a","11009","1595","142","49","1404","8866","8866","548","0","548" +,"0","0","0","0" +"libesp_http_server.a","10867","0","0","0","0","10528","10528","339","0","339"," +0","0","0","0" +"libheap.a","10856","6894","8","4","6882","3240","3240","722","0","722","0","0", +"0","0" +"libesp_driver_spi.a","10004","3507","8","131","3368","6068","6068","429","0","4 +29","0","0","0","0" +"libesp_netif.a","7572","37","29","8","0","7304","7304","231","0","231","0","0", +"0","0" +"libcoexist.a","6051","461","6","301","154","4316","4316","1274","0","1274","0", +"0","0","0" +"libesp_driver_gpio.a","6008","216","0","36","180","5238","5238","554","0","554" +,"0","0","0","0" +"libesp_libc.a","5358","3584","564","324","2696","1622","1622","152","16","136", +"0","0","0","0" +"libesp_wifi.a","5144","703","43","480","180","3618","3618","823","0","823","0", +"0","0","0" +"libcjson.a","5116","20","8","12","0","5096","5096","0","0","0","0","0","0","0" +"libvfs.a","5051","236","44","192","0","4676","4676","139","0","139","0","0","0" +,"0" +"libesp_ringbuf.a","4860","2894","0","0","2894","1492","1492","474","0","474","0 +","0","0","0" +"libesp_hal_mspi.a","4366","3792","0","36","3756","574","574","0","0","0","0","0 +","0","0" +"libbootloader_support.a","4284","686","8","0","678","3510","3510","88","0","88" +,"0","0","0","0" +"libesp_mm.a","4183","1325","48","103","1174","2644","2644","214","0","214","0", +"0","0","0" +"libesp_event.a","3691","4","4","0","0","3546","3546","141","0","141","0","0","0 +","0" +"libesp_phy.a","3455","248","51","9","188","2908","2908","299","0","299","0","0" +,"0","0" +"libesp_timer.a","3035","1364","36","16","1312","1580","1580","91","0","91","0", +"0","0","0" +"libapp_update.a","2996","12","12","0","0","2922","2922","62","0","62","0","0"," +0","0" +"libbtbb.a","2868","0","0","0","0","2868","2868","0","0","0","0","0","0","0" +"libesp_partition.a","2796","8","8","0","0","2620","2620","168","0","168","0","0 +","0","0" +"libesp_driver_dma.a","2774","140","0","12","128","2532","2532","102","0","102", +"0","0","0","0" +"libhal.a","2564","2212","5","235","1972","352","352","0","0","0","0","0","0","0 +" +"libesp_hal_gpspi.a","2550","2194","0","20","2174","284","284","72","0","72","0" +,"0","0","0" +"libefuse.a","2461","320","4","316","0","1654","1654","487","0","487","0","0","0 +","0" +"libesp_driver_usb_serial_jtag.a","2172","157","17","48","92","1874","1874","141 +","0","141","0","0","0","0" +"libstdc++.a","2056","33","25","8","0","1664","1664","359","0","359","0","0","0" +,"0" +"libesp_driver_tsens.a","1942","8","4","4","0","1714","1714","220","0","220","0" +,"0","0","0" +"liblog.a","1894","700","276","8","416","1146","1146","48","0","48","0","0","0", +"0" +"libesp_common.a","1774","0","0","0","0","48","48","1726","0","1726","0","0","0" +,"0" +"libesp_hal_ana_conv.a","1559","9","0","9","0","1450","1450","100","0","100","0" +,"0","0","0" +"libriscv.a","1294","1112","256","0","856","132","132","50","0","50","0","0","0" +,"0" +"libesp_hal_security.a","1260","0","0","0","0","1260","1260","0","0","0","0","0" +,"0","0" +"libpthread.a","1223","4","4","0","0","1130","1130","89","0","89","0","0","0","0 +" +"libesp_hal_dma.a","1022","366","0","4","362","656","656","0","0","0","0","0","0 +","0" +"libesp_hal_wdt.a","1005","1005","0","21","984","0","0","0","0","0","0","0","0", +"0" +"libesp_security.a","922","12","12","0","0","902","902","8","0","8","0","0","0", +"0" +"libesp_app_format.a","770","10","10","0","0","496","496","264","0","8","256","0 +","0","0" +"libcxx.a","740","16","16","0","0","724","724","0","0","0","0","0","0","0" +"libesp_hal_uart.a","674","0","0","0","0","618","618","56","0","56","0","0","0", +"0" +"libesp_hal_gpio.a","468","0","0","0","0","440","440","28","0","28","0","0","0", +"0" +"libesp_coex.a","428","154","0","80","74","274","274","0","0","0","0","0","0","0 +" +"libesp_hal_clock.a","404","0","0","0","0","404","404","0","0","0","0","0","0"," +0" +"libcore.a","366","9","9","0","0","314","314","43","0","43","0","0","0","0" +"libsoc.a","364","0","0","0","0","0","0","364","0","364","0","0","0","0" +"libmesh.a","64","4","4","0","0","60","60","0","0","0","0","0","0","0" +"libmbedtls.a","32","0","0","0","0","24","24","8","0","8","0","0","0","0" +"libespnow.a","3","3","0","3","0","0","0","0","0","0","0","0","0","0" +"libnvs_sec_provider.a","2","0","0","0","0","2","2","0","0","0","0","0","0","0" diff --git a/tools/_size_arch.json b/tools/_size_arch.json new file mode 100644 index 0000000..e69de29 diff --git a/tools/_size_out.json b/tools/_size_out.json new file mode 100644 index 0000000..e69de29 diff --git a/tools/analyze_debug_log_len.py b/tools/analyze_debug_log_len.py new file mode 100644 index 0000000..fe0d6cd --- /dev/null +++ b/tools/analyze_debug_log_len.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Scan DEBUG_* format strings and estimate max expanded line length (not LOG_RING_SIZE).""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +PAT = re.compile( + r"DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*\"((?:\\.|[^\"\\])*)\"", + re.M, +) + + +def unescape_fmt(fmt: str) -> str: + try: + return bytes(fmt, "utf-8").decode("unicode_escape") + except Exception: + return ( + fmt.replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace('\\"', '"') + ) + + +def fmt_max_len(fmt: str) -> tuple[int, bool]: + """Return (max_len, unbounded_string) for fmt body (without our [L][tag] prefix).""" + i = 0 + n = len(fmt) + out = 0 + unbounded = False + while i < n: + if fmt[i] != "%": + j = fmt.find("%", i) + if j < 0: + out += n - i + break + out += j - i + i = j + continue + if i + 1 < n and fmt[i + 1] == "%": + out += 1 + i += 2 + continue + i += 1 + while i < n and fmt[i] in "-+ #0": + i += 1 + width = 0 + if i < n and fmt[i] == "*": + i += 1 + else: + while i < n and fmt[i].isdigit(): + width = width * 10 + int(fmt[i]) + i += 1 + prec = None + if i < n and fmt[i] == ".": + i += 1 + if i < n and fmt[i] == "*": + prec = 0 + i += 1 + else: + prec = 0 + while i < n and fmt[i].isdigit(): + prec = prec * 10 + int(fmt[i]) + i += 1 + while i < n and fmt[i] in "hlLzjtq": + i += 1 + if i >= n: + break + conv = fmt[i] + i += 1 + if conv in "diouxX": + est = 20 if conv in "diou" else 16 + if prec is not None: + est = max(est, prec) + if width: + est = max(est, width) + out += est + elif conv in "fFeEgG": + est = 48 # long double-ish + if prec is not None: + est = max(est, prec + 16) + if width: + est = max(est, width) + out += est + elif conv == "s": + if prec is not None: + est = prec + else: + # no precision: could be long; use 128 as aggressive scan bound + flag + est = 128 + unbounded = True + if width: + est = max(est, width) + out += est + elif conv == "c": + out += max(1, width or 1) + elif conv == "p": + out += 18 + elif conv == "PRIu32" or True: + # leftover / unknown after PRI macros already expanded in source rarely + if conv not in "diouxXfFeEgGscp%": + out += 12 + else: + out += 8 + else: + out += 8 + return out, unbounded + + +def tag_len(tag_expr: str) -> tuple[int, str]: + tag_expr = tag_expr.strip() + m = re.match(r'^"([^"]*)"$', tag_expr) + if m: + return len(m.group(1)), m.group(1) + # TAG macro / variable: measure common names + if tag_expr in ("TAG", "kTag", "tag"): + return 12, tag_expr + return max(len(tag_expr), 8), tag_expr + + +def main() -> int: + rows = [] + for path in ROOT.rglob("*"): + if path.suffix not in {".cpp", ".c", ".h"}: + continue + parts = path.parts + if "build" in parts or ".git" in parts: + continue + if "components" in parts and "cjson" in parts: + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + rel = path.relative_to(ROOT).as_posix() + for m in PAT.finditer(text): + level, tag_expr, fmt_raw = m.group(1), m.group(2), m.group(3) + fmt = unescape_fmt(fmt_raw) + # strip trailing newline in fmt for body math; we add +1 for forced \n + body_src = fmt.rstrip("\n\r") + body, unb = fmt_max_len(body_src) + tl, tag = tag_len(tag_expr) + prefix = 6 + tl # [L][tag]_ e.g. [I][WIFI] + total = prefix + body + 1 # + newline + # line number + line_no = text.count("\n", 0, m.start()) + 1 + rows.append( + { + "total": total, + "body": body, + "prefix": prefix, + "unbounded": unb, + "level": level, + "tag": tag, + "fmt": body_src, + "file": rel, + "line": line_no, + } + ) + + rows.sort(key=lambda r: (-r["total"], r["file"], r["line"])) + print(f"DEBUG_* sites: {len(rows)}") + print(f"design cap LOG_RING_SIZE=128 → usable max line = 127 bytes\n") + print("--- Top 50 by estimated max total ([L][tag]+body+\\n) ---") + print(f"{'est':>5} {'body':>5} {'pre':>3} {'>%159':>5} level tag file:line") + for r in rows[:50]: + over = "YES" if r["total"] > 159 else "" + unb = "*" if r["unbounded"] else " " + fmt_short = r["fmt"].replace("\n", "\\n") + if len(fmt_short) > 70: + fmt_short = fmt_short[:67] + "..." + print( + f"{r['total']:5d}{unb}{r['body']:5d} {r['prefix']:3d} {over:>5} " + f"{r['level']:4s} {r['tag'][:14]:14s} {r['file']}:{r['line']}" + ) + print(f" {fmt_short}") + + print("\n--- length histogram (estimated total) ---") + bins = [(0, 80), (81, 110), (111, 127), (128, 160), (161, 200), (201, 99999)] + for lo, hi in bins: + c = sum(1 for r in rows if lo <= r["total"] <= hi) + print(f" {lo:5d}-{hi:5d}: {c:4d}") + + over = [r for r in rows if r["total"] > 159] + unb = [r for r in rows if r["unbounded"]] + print(f"\nSites with est > 159 (would truncate in ring): {len(over)}") + print(f"Sites with unbounded %s (est used 128 for %s): {len(unb)}") + + # Also report pure fmt string lengths (no conversion expansion) + print("\n--- Top 20 by format string literal length only ---") + by_lit = sorted(rows, key=lambda r: (-len(r["fmt"]), r["file"])) + for r in by_lit[:20]: + print(f" lit={len(r['fmt']):3d} est={r['total']:4d} {r['file']}:{r['line']} {r['fmt'][:80]!r}") + + # max among sites without unbounded %s + bounded = [r for r in rows if not r["unbounded"]] + if bounded: + m = max(bounded, key=lambda r: r["total"]) + print( + f"\nMax est among bounded formats (no open %s): " + f"{m['total']} B @ {m['file']}:{m['line']} tag={m['tag']}" + ) + print(f" fmt={m['fmt'][:160]!r}") + + # For unbounded, try better estimate: common %s sources + print("\n--- Unbounded %s samples (need runtime arg max) ---") + for r in unb[:15]: + print(f" est~{r['total']} {r['file']}:{r['line']} {r['fmt'][:100]!r}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/analyze_debug_over128.py b/tools/analyze_debug_over128.py new file mode 100644 index 0000000..33869f8 --- /dev/null +++ b/tools/analyze_debug_over128.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""List DEBUG_* call sites that can exceed 128 bytes (UTF-8), longest first. + +Uses realistic bounds for known %s sources (SHA 64, SPI hex, esp_err_to_name, etc.). +""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +THRESHOLD = 128 # bytes, including [L][TAG] prefix + body + \\n + +PAT = re.compile( + r"DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*\"((?:\\.|[^\"\\])*)\"\s*(?:,|\))", + re.M, +) + +# When we can see following args in a window, classify %s +ARG_WIN = 200 + + +def unescape_fmt(fmt: str) -> str: + try: + return bytes(fmt, "utf-8").decode("unicode_escape") + except Exception: + return fmt.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t") + + +def tag_info(tag_expr: str) -> tuple[int, str]: + tag_expr = tag_expr.strip() + m = re.match(r'^"([^"]*)"$', tag_expr) + if m: + return len(m.group(1).encode("utf-8")), m.group(1) + # TAG / kTag variable — common values up to ~12 + return 12, tag_expr + + +def classify_s_arg(arg: str) -> int: + """Max bytes for a %s argument expression (UTF-8).""" + a = arg.strip() + # SHA hex buffers + if re.search(r"sha|hex|device_hex|frontend_sha", a, re.I): + if "64" in a or "sha" in a.lower() or "hex" in a.lower(): + # device_hex / frontend_sha_hex are 64-char C strings + if re.search(r"frontend_sha|device_hex|device_sha|out_device", a, re.I): + return 64 + if re.search(r"resp_hex|payload_hex|hex\[", a, re.I): + return 512 # worst unless capped in call site + return 64 + if re.search(r"resp_hex", a, re.I): + return 512 # SPI_OTA_FRAME_MAX*2 + if re.search(r"\bhex\b", a, re.I): + # local hex[] in ota_relay often 64–72 chars + return 72 + if "esp_err_to_name" in a: + return 32 + if re.search(r"FW_VERSION|GIT_HASH|BUILD_TIME|version", a, re.I): + return 16 + if re.search(r"partition|label|name|pcTaskName|task", a, re.I): + return 16 + if re.search(r"stage|TAG|tag", a, re.I): + return 24 + if re.search(r"ssid|ip_str|ip", a, re.I): + return 32 + # string literals + m = re.match(r'^"((?:\\.|[^"\\])*)"$', a) + if m: + return len(unescape_fmt(m.group(1)).encode("utf-8")) + # ternary "...":"..." + if "?" in a and ":" in a: + return 16 + return 48 # generic unknown %s + + +def parse_args_after_fmt(text: str, fmt_end: int) -> list[str]: + """Rough parse of C args after the format string until closing paren of DEBUG_*.""" + # fmt ends at quote; skip ", then args + i = fmt_end + # find the comma after format or ) + while i < len(text) and text[i] in " \t\r\n": + i += 1 + if i < len(text) and text[i] == ")": + return [] + if i < len(text) and text[i] == ",": + i += 1 + args: list[str] = [] + depth = 0 + start = i + in_str = False + esc = False + while i < len(text): + c = text[i] + if in_str: + if esc: + esc = False + elif c == "\\": + esc = True + elif c == '"': + in_str = False + i += 1 + continue + if c == '"': + in_str = True + i += 1 + continue + if c in "([{": + depth += 1 + elif c in ")]}": + if depth == 0 and c == ")": + part = text[start:i].strip() + if part: + args.append(part) + break + depth -= 1 + elif c == "," and depth == 0: + part = text[start:i].strip() + if part: + args.append(part) + start = i + 1 + i += 1 + if i - start > 500: + break + return args + + +def body_max_bytes(fmt: str, args: list[str]) -> tuple[int, list[str]]: + """Walk fmt and consume args for max expanded body length in UTF-8 bytes.""" + # Work on UTF-8: literal parts as utf-8 len + i = 0 + n = len(fmt) + out = 0 + arg_i = 0 + notes: list[str] = [] + + def next_arg() -> str: + nonlocal arg_i + if arg_i < len(args): + a = args[arg_i] + arg_i += 1 + return a + return "" + + while i < n: + if fmt[i] != "%": + j = fmt.find("%", i) + if j < 0: + out += len(fmt[i:].encode("utf-8")) + break + out += len(fmt[i:j].encode("utf-8")) + i = j + continue + if i + 1 < n and fmt[i + 1] == "%": + out += 1 + i += 2 + continue + i += 1 + while i < n and fmt[i] in "-+ #0": + i += 1 + width = 0 + if i < n and fmt[i] == "*": + next_arg() # width arg + i += 1 + else: + while i < n and fmt[i].isdigit(): + width = width * 10 + int(fmt[i]) + i += 1 + prec = None + if i < n and fmt[i] == ".": + i += 1 + if i < n and fmt[i] == "*": + next_arg() + prec = 0 + i += 1 + else: + prec = 0 + while i < n and fmt[i].isdigit(): + prec = prec * 10 + int(fmt[i]) + i += 1 + # length + while i < n and fmt[i] in "hlLzjtq": + i += 1 + # PRI macros might appear as broken sequence in source - rare + if i >= n: + break + conv = fmt[i] + i += 1 + arg = next_arg() + if conv in "diouxX": + est = 20 if conv in "diou" else 16 + if prec is not None: + est = max(est, prec) + if width: + est = max(est, width) + out += est + elif conv in "fFeEgG": + est = 32 + if prec is not None: + est = max(est, prec + 8) + if width: + est = max(est, width) + out += est + elif conv == "s": + if prec is not None: + est = prec + else: + est = classify_s_arg(arg) + notes.append(f"%s←{est}:{arg[:40]}") + if width: + est = max(est, width) + out += est + elif conv == "c": + out += max(1, width or 1) + elif conv == "p": + out += 18 + else: + # maybe start of PRIu32 in string - treat as literal leftover + out += 12 + return out, notes + + +def main() -> int: + rows = [] + for path in ROOT.rglob("*"): + if path.suffix not in {".cpp", ".c", ".h"}: + continue + if "build" in path.parts or ".git" in path.parts: + continue + if "components" in path.parts and "cjson" in path.parts: + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + rel = path.relative_to(ROOT).as_posix() + for m in PAT.finditer(text): + level, tag_expr, fmt_raw = m.group(1), m.group(2), m.group(3) + fmt = unescape_fmt(fmt_raw).rstrip("\n\r") + # end of format string in source: position after closing quote of fmt + # m.end() is after last "; use group 3 span + fmt_end = m.end(3) + 1 # past closing " + args = parse_args_after_fmt(text, fmt_end) + body, notes = body_max_bytes(fmt, args) + tlen, tag = tag_info(tag_expr) + prefix = len(f"[{level[0] if False else ''}]") # placeholder + # real prefix: [E]/[W]/[I]/[D] + [tag] + space + level_c = {"ERR": "E", "WARN": "W", "INFO": "I", "DBG": "D"}.get(level, "?") + prefix = len(f"[{level_c}][{tag}] ".encode("utf-8")) if tag_expr.strip().startswith('"') else (6 + tlen) + if not tag_expr.strip().startswith('"'): + # use tlen for tag byte len estimate (ASCII tags) + prefix = 6 + tlen + else: + prefix = len(f"[{level_c}][{tag}] ".encode("utf-8")) + total = prefix + body + 1 # + \\n + if total <= THRESHOLD: + continue + line_no = text.count("\n", 0, m.start()) + 1 + rows.append( + { + "total": total, + "body": body, + "prefix": prefix, + "level": level, + "tag": tag if tag_expr.strip().startswith('"') else tag_expr.strip(), + "fmt": fmt, + "file": rel, + "line": line_no, + "notes": notes, + "args": args, + } + ) + + rows.sort(key=lambda r: (-r["total"], r["file"], r["line"])) + print(f"Threshold: >{THRESHOLD} bytes (prefix+body+newline, UTF-8)") + print(f"Candidates: {len(rows)}\n") + print(f"{'#':>3} {'est':>5} {'body':>5} {'pre':>3} {'lv':4} {'tag':12} location") + print("-" * 100) + for i, r in enumerate(rows, 1): + print( + f"{i:3d} {r['total']:5d} {r['body']:5d} {r['prefix']:3d} " + f"{r['level']:4s} {str(r['tag'])[:12]:12s} {r['file']}:{r['line']}" + ) + fmt_show = r["fmt"].replace("\n", "\\n") + if len(fmt_show) > 100: + fmt_show = fmt_show[:97] + "..." + print(f" fmt: {fmt_show}") + if r["notes"]: + print(f" args: {'; '.join(r['notes'][:6])}") + print(f"\nTotal over {THRESHOLD}B: {len(rows)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/can.html b/tools/can.html index ffdcfd1..258b726 100644 --- a/tools/can.html +++ b/tools/can.html @@ -7,7 +7,7 @@ - + @@ -318,6 +318,6 @@ 操作成功 - + diff --git a/tools/can_monitor.html b/tools/can_monitor.html index da5fd50..0c4382a 100644 --- a/tools/can_monitor.html +++ b/tools/can_monitor.html @@ -7,7 +7,7 @@ - + @@ -182,6 +182,6 @@ 操作成功 - + \ No newline at end of file diff --git a/tools/css/can.css b/tools/css/can.css index 69a0612..0fdd363 100644 --- a/tools/css/can.css +++ b/tools/css/can.css @@ -1039,6 +1039,13 @@ body.can-page { background: transparent; border: none; } +.feedback-channel.ch-disabled { + opacity: 0.45; + filter: grayscale(0.35); +} +.feedback-channel.ch-disabled .feedback-ch-head { + cursor: default; +} .feedback-channel { background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07); border-radius: 10px; padding: 14px 16px; diff --git a/tools/css/components.css b/tools/css/components.css index c17dd5b..fc9e586 100644 --- a/tools/css/components.css +++ b/tools/css/components.css @@ -390,6 +390,298 @@ font-size: 11px; } +/* ===== 芯片 SRAM 分段条(IRAM / .data / .bss / 堆已用 / 堆空闲)===== */ +.mem-sram-track { + display: flex; + overflow: hidden; + gap: 0; + background: rgba(255,255,255,0.04); +} +.mem-seg { + display: block; + height: 100%; + min-width: 0; + transition: width .4s ease; +} +.mem-seg-iram { + background: linear-gradient(90deg, #a78bfa, #7c3aed); +} +.mem-seg-data { + background: linear-gradient(90deg, #fb923c, #ea580c); +} +.mem-seg-bss { + background: linear-gradient(90deg, #94a3b8, #64748b); +} +.mem-seg-static { + background: linear-gradient(90deg, #64748b, #475569); +} +.mem-seg-used { + background: linear-gradient(90deg, var(--accent), #5eead4); +} +.mem-seg-free { + background: rgba(255,255,255,0.08); +} +.heap-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin-top: 8px; + font-size: 11px; + color: var(--text-dim); +} +.heap-legend-item { + display: inline-flex; + align-items: center; + gap: 5px; +} +.heap-legend-item b { + color: var(--text); + font-weight: 500; + font-family: var(--font-mono); + font-size: 11px; +} +.mem-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + flex: 0 0 auto; +} +.mem-dot-iram { background: #8b5cf6; } +.mem-dot-data { background: #f97316; } +.mem-dot-bss { background: #64748b; } +.mem-dot-static { background: #64748b; } +.mem-dot-used { background: var(--accent, #2dd4bf); } +.mem-dot-free { background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.2); box-sizing: border-box; } +.mem-static-list { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: var(--text-dim); +} +.mem-static-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + align-items: center; + padding: 3px 0; + border-bottom: 1px solid rgba(255,255,255,0.03); +} +.mem-static-row:last-child { border-bottom: 0; } +.mem-static-name { + color: var(--text-label, var(--text-dim)); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mem-static-size { + font-family: var(--font-mono); + color: var(--text); + font-variant-numeric: tabular-nums; +} +.mem-static-pct { + font-family: var(--font-mono); + min-width: 42px; + text-align: right; + color: var(--text-dim); +} +.heap-hint { + margin-top: 8px; + padding-top: 6px; + border-top: 1px solid rgba(255,255,255,0.04); + font-size: 10px; + line-height: 1.45; + color: var(--text-dim); + opacity: 0.9; +} + +/* ============================================================ + 堆低水位 hover tooltip(鼠标移到 #mInternalMinHeap 上浮现) + ============================================================ */ +.heap-low-tooltip { + position: fixed; + z-index: 9999; + min-width: 480px; + max-width: 640px; + padding: 10px 12px 8px 12px; + background: var(--bg-elev, #1f2430); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.45); + font-size: 11px; + color: var(--text, #d4d4d4); + pointer-events: auto; + /* 初态不可见,由 JS 计算 left/top 后再切回 visible,避免闪到 (0,0) */ + visibility: hidden; +} +.heap-low-title { + font-weight: 600; + margin-bottom: 6px; + color: var(--text, #d4d4d4); + display: flex; + align-items: center; +} +.heap-low-sub { + font-weight: 400; + color: var(--text-dim, #888); + margin-left: 6px; +} +.heap-low-clear { + margin-left: auto; + margin-right: 6px; + height: 22px; + padding: 0 8px; + border: 1px solid rgba(255,107,107,0.45); + border-radius: 4px; + background: rgba(255,107,107,0.12); + color: #ff8a80; + font-size: 11px; + line-height: 20px; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.heap-low-clear:hover:not(:disabled) { + background: rgba(255,107,107,0.22); + border-color: rgba(255,107,107,0.7); + color: #ffcdd2; +} +.heap-low-clear:disabled { + opacity: 0.55; + cursor: wait; +} +.heap-low-close { + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-dim, #888); + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.heap-low-close:hover { + background: rgba(255,255,255,0.08); + color: var(--text, #d4d4d4); +} +.heap-low-table-wrap { + max-height: 360px; /* 留高一些容纳历史段 */ + overflow-y: auto; +} +.heap-low-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono, monospace); + font-size: 10.5px; +} +/* 两段分隔:设备当前段 vs 前端累积历史段 */ +.heap-low-section { margin-bottom: 6px; } +.heap-low-section + .heap-low-section { margin-top: 8px; padding-top: 6px; border-top: 1px dashed rgba(255,255,255,0.15); } +.heap-low-section-title { + font-size: 11px; + font-weight: 600; + color: var(--text-dim, #aaa); + padding: 4px 6px 2px; + background: rgba(255,255,255,0.03); + border-radius: 3px 3px 0 0; +} +.heap-low-section-hist .heap-low-section-title { color: #ffb74d; } +.heap-low-section-hist .heap-low-table { opacity: 0.92; } +.heap-low-table th, +.heap-low-table td { + padding: 3px 6px; + text-align: right; + white-space: nowrap; + border-bottom: 1px solid rgba(255,255,255,0.04); +} +.heap-low-table th { + color: var(--text-dim, #888); + font-weight: 500; + position: sticky; + top: 0; + background: var(--bg-elev, #1f2430); + text-align: right; +} +.heap-low-table td:nth-child(5), /* OTA 状态 */ +.heap-low-table td:nth-child(6), /* 连接 */ +.heap-low-table td:nth-child(7), /* OTA 进度 */ +.heap-low-table td:nth-child(9) { /* 嫌疑资源 */ + text-align: left; +} +.heap-low-table td:first-child { color: var(--text-dim, #888); } +.heap-low-table tbody tr:hover { background: rgba(255,255,255,0.03); } +/* 诊断列:OTA 资源有占用时标红(已锁定嫌疑);推断结论标黄(排除法推断) */ +.heap-low-table td.has-suspect { color: #ff6b6b; font-weight: 500; } +.heap-low-table td.idle-suspect { color: #ffb74d; } +.heap-low-detail-meta { + display: flex; + gap: 18px; + font-size: 10px; + color: var(--text-dim, #888); + margin-bottom: 6px; +} +.heap-low-detail-meta b { color: var(--text, #d4d4d4); font-family: var(--font-mono, monospace); } +/* heap hook 捕获区:突出显示"瞬时低谷触发者" */ +.heap-low-hook-meta { + margin: 8px 0 6px 0; + padding: 6px 8px; + background: rgba(255,107,107,0.08); + border-left: 2px solid #ff6b6b; + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 10px; + color: var(--text-dim, #888); +} +.heap-low-hook-title { + width: 100%; + color: #ff6b6b; + font-weight: 600; + margin-bottom: 2px; +} +.heap-low-hook-meta b { color: var(--text, #d4d4d4); font-family: var(--font-mono, monospace); } +.heap-low-caller { color: #ff6b6b; } +.heap-low-caller-hint { color: var(--text-dim, #888); font-size: 9px; } +.heap-low-main-row { cursor: pointer; } +.heap-low-expand-hint { + font-size: 9px; + color: var(--text-dim, #888); + margin-left: 4px; +} +/* 展开的任务栈子表 */ +.heap-low-stack-detail > td { padding: 6px 8px 8px 8px; background: rgba(0,0,0,0.15); } +.heap-low-stack-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono, monospace); + font-size: 10px; +} +.heap-low-stack-table th, +.heap-low-stack-table td { + padding: 2px 6px; + text-align: left; + border-bottom: 1px solid rgba(255,255,255,0.03); +} +.heap-low-stack-table th { color: var(--text-dim, #888); font-weight: 500; } +.heap-low-stack-table td:last-child { text-align: right; } +.heap-low-stack-table tr.crit td { color: #ff6b6b; } +.heap-low-stack-table tr.warn td { color: #ffb74d; } +.heap-low-empty { + padding: 14px 6px; + color: var(--text-dim, #888); + text-align: center; +} +.heap-low-tip { + margin-top: 6px; + font-size: 10px; + color: var(--text-dim, #888); +} + /* 抽屉内数值/任务名/JSON 强制等宽,避免被其它样式冲掉 */ .sys-drawer-body .heap-bar-pct, .sys-drawer-body .heap-detail-row b, diff --git a/tools/index.html b/tools/index.html index f59a5e1..6d6305c 100644 --- a/tools/index.html +++ b/tools/index.html @@ -11,8 +11,8 @@ ESP32 OTA 中继控制台 - - + + @@ -65,10 +65,7 @@ -
- - 等待连接 -
+ @@ -119,6 +116,10 @@
类型-
大小-
+
+ SHA-256 + - +
@@ -276,10 +277,12 @@
📊 运行指标
-
温度
--
-
GD32 APP
--
-
GD32 BOOT
--
-
OTA 缓存
--
+
ESP32 温度
--
+
GD32 温度
--
+
输入 VCC
--
+
VDDA
--
+
GD32 APP
--
+
GD32 BOOT
--
@@ -293,7 +296,6 @@
OTA 状态--
Relay 状态--
-
缓存进度--
刷新时间--
@@ -315,7 +317,7 @@ DBC / HEX -

导入 DBC 后自动读取报文和信号,按物理值实时组帧,并复用现有 /can/send 接口直接发送。

+

导入 DBC 后自动读取报文和信号,按物理值实时组帧,经 /can/send_batch 发送(单帧或多帧)。

自动解析 DBC 信号实时组帧 @@ -396,9 +398,35 @@
等待下一次状态刷新...
-
堆概况
+
内存概况
-
内部 DRAM (数据堆)
+
芯片 SRAM 总览 (ESP32-C3 · 静态段可拆分)
+
+ -- + + + + + + + + -- +
+
+ IRAM -- + .data -- + .bss -- + 堆已用 -- + 堆空闲 -- +
+
+ SOC 窗口: -- + 尾部保留: -- + 任务栈合计: -- +
+
+
+
内部 DRAM 堆 (malloc 数据堆)
-- @@ -409,6 +437,11 @@ 最低空闲: -- 最大块: --
+
+ DMA 空闲: -- + RTC 堆空闲: -- + 分配块数: -- +
任务栈水位 (峰值占用 Top,下滑查看)
@@ -436,8 +469,8 @@ 操作成功 - - - + + + diff --git a/tools/js/app.js b/tools/js/app.js index bad41de..e9908d3 100644 --- a/tools/js/app.js +++ b/tools/js/app.js @@ -12,7 +12,7 @@ import { showToast } from './common/toast.js'; import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js'; import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, - testConnection, refreshSystemStatusNow, diagnoseNetwork + testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl } from './common/connection.js'; import { scheduleStatusPoll, startPolling, pausePollingForUpload, @@ -103,10 +103,6 @@ function applyRebootingRuntimeState() { var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); pct.textContent = '重启中 ' + elapsed + 's'; } - var cs = document.getElementById('connStatus'); - var cst = document.getElementById('connStatusTxt'); - if (cs) cs.className = 'conn-indicator warn'; - if (cst) cst.textContent = 'ESP32 正在重启...'; var bc = document.getElementById('bConn'); if (bc) bc.textContent = '重启中'; if (bc) bc.className = 'v warn'; @@ -122,25 +118,16 @@ function applyUploadingRuntimeState() { var pct = document.getElementById('pillConnTxt'); if (pc) pc.className = 'pill processing'; if (pct) pct.textContent = '上传中'; - var cs = document.getElementById('connStatus'); - var cst = document.getElementById('connStatusTxt'); - if (cs) cs.className = 'conn-indicator warn'; - if (cst) cst.textContent = '固件上传中,设备忙...'; var bc = document.getElementById('bConn'); if (bc) bc.textContent = '上传中'; if (bc) bc.className = 'v warn'; } -/** 设备忙(OTA/中继/重启期间不响应轮询)的连接胶囊/状态条运行时态。 - * 与上面三兄弟同模式,区别是不改进度/阶段,只标记连接胶囊为 processing + warn。 */ +/** 设备忙:只更新顶栏连接胶囊 + 系统状态徽章,不写下方重复文案。 */ function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) { var pc = document.getElementById('pillConn'); var pct = document.getElementById('pillConnTxt'); if (pc) pc.className = 'pill processing'; if (pct) pct.textContent = pillText || '设备忙'; - var cs = document.getElementById('connStatus'); - var cst = document.getElementById('connStatusTxt'); - if (cs) cs.className = 'conn-indicator warn'; - if (cst) cst.textContent = statusText || '设备忙(OTA/重启中),保持 1s 轮询'; var bc = document.getElementById('bConn'); if (bc) bc.textContent = badgeText || '设备忙'; if (bc) bc.className = 'v warn'; @@ -205,6 +192,7 @@ window.updateHealthUi = updateHealthUi; window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi; window.testConnection = testConnection; window.refreshSystemStatusNow = refreshSystemStatusNow; +window.systemStatusUrl = systemStatusUrl; window.scheduleStatusPoll = scheduleStatusPoll; window.setStatusPollIntervalMs = setStatusPollIntervalMs; window.startPolling = startPolling; diff --git a/tools/js/ble/file.js b/tools/js/ble/file.js index b10b320..2fd0a10 100644 --- a/tools/js/ble/file.js +++ b/tools/js/ble/file.js @@ -9,8 +9,9 @@ import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionSta import { setBleStage, setBleStageText, resetBleStages } from './stages.js'; import { setProgress } from './transfer.js'; import { hideBleVerifyInfo } from './verify.js'; -import { formatBytes, bytesToHex } from '../common/utils.js'; +import { formatBytes } from '../common/utils.js'; import { showToast } from '../common/toast.js'; +import { computeFileSha } from '../ota/file-handle.js'; /** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */ function isAnyChannelUploading() { @@ -18,11 +19,8 @@ function isAnyChannelUploading() { } export function updateFileInfo(file) { - // ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget - // 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...) - // 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。 + // ★ 上传进行中禁止重置进度/阶段 if (isBleUploading() && file && state.selectedFile) { - // 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段 bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D'); return Promise.resolve(); } @@ -43,9 +41,9 @@ export function updateFileInfo(file) { else els.fileInfo.classList.remove('show'); } - // ★ HEX → BIN 自动转换:复用 app.js 公共函数 window.convertHexFileToBin + // ★ HEX → BIN:必须用 window.convertHexFileToBin(本模块未 import,裸名会 ReferenceError) if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') { - return convertHexFileToBin(file).then(function(result) { + return window.convertHexFileToBin(file).then(function(result) { if (!result.converted) { bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W'); finalizeAndHash(file); @@ -55,7 +53,6 @@ export function updateFileInfo(file) { var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); bleLog('FILE', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name + ' (基地址 ' + baseHex + ', ' + formatBytes(result.file.size) + ')'); showToast('HEX 已自动转换为 BIN', 'success'); - // 更新 UI 显示转换后的信息 els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; els.fileType.textContent = 'Binary (HEX转换)'; els.fileSize.textContent = formatBytes(result.file.size); @@ -72,20 +69,16 @@ export function updateFileInfo(file) { }); } - // 非 HEX 文件直接完成 finalizeAndHash(file); return Promise.resolve(); } -/** finalizeFileInfo + computeFileSha256 的统一出口:HEX 转换完成分支与非 HEX 分支共用。 */ function finalizeAndHash(file) { finalizeFileInfo(file); - computeFileSha256(file); + computeBleFileSha(file); } function finalizeFileInfo(file) { - // ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间 - // 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。 if (isBleUploading()) { bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D'); return; @@ -105,40 +98,43 @@ function finalizeFileInfo(file) { } /** - * 计算文件 SHA-256 并推进 BLE stage 流。 - * stage 推进逻辑收敛在此函数内,避免与 finalizeFileInfo 隐式耦合: - * - 已连接且 file 非空:进入「计算中」态(stage 3 active + 状态栏文案) - * - 哈希算完:stage 3 done + 推进 stage 4 + 状态栏「文件就绪」 - * - file 为空 / 未连接:不碰 stage(由 finalizeFileInfo 的 else 分支 + clearSelectedFile 负责) + * BLE 侧:读对应芯片槽;槽里已有则直接用,没有才算一次。 + * 不跨芯片、不互切。 */ -function computeFileSha256(file) { +function computeBleFileSha(file) { var shaElem = document.getElementById('fileSha256'); - if (!file || !shaElem) { + if (!file) { if (shaElem) shaElem.textContent = '-'; state.fileSha256 = null; return; } - // 已连接:进入「计算中」态(旧逻辑放在 finalizeFileInfo 里,现收敛至此) + var chip = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32'; + var existing = chip === 'gd32' ? window._gd32FileSha256 : window._esp32FileSha256; + if (existing) { + state.fileSha256 = existing; + if (shaElem) shaElem.textContent = existing; + if (state.server) { + setBleStage(3, 'SHA-256 已计算', null); + setBleStage(4, '', null); + setBleStageText('文件就绪,可以开始刷写'); + } + return; + } if (state.server) { setBleStage(3, '计算哈希...', 'active'); setBleStageText('正在计算文件 SHA-256...'); } - shaElem.textContent = '计算中...'; - file.arrayBuffer().then(function(buf) { - return crypto.subtle.digest('SHA-256', buf); - }).then(function(hashBuffer) { - var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase(); - shaElem.textContent = hashHex; + computeFileSha(file, chip).then(function(hashHex) { + if (!hashHex) { + state.fileSha256 = null; + return; + } state.fileSha256 = hashHex; - bleLog('FILE', 'SHA-256: ' + hashHex); if (state.server) { setBleStage(3, 'SHA-256 已计算', null); setBleStage(4, '', null); setBleStageText('文件就绪,可以开始刷写'); } - }).catch(function(err) { - shaElem.textContent = '计算失败: ' + err.message; - state.fileSha256 = null; }); } @@ -219,7 +215,11 @@ export async function notifyFileChange(file, handle) { return; } if (file) { - if (handle) state.selectedFileHandle = handle; + /* 始终更新 handle(含 null),避免切目标后仍热重载到上一芯片的文件 */ + state.selectedFileHandle = handle || null; await updateFileInfo(file); + } else { + state.selectedFileHandle = null; + await updateFileInfo(null); } } diff --git a/tools/js/ble/state.js b/tools/js/ble/state.js index 490d535..ded1be7 100644 --- a/tools/js/ble/state.js +++ b/tools/js/ble/state.js @@ -3,8 +3,6 @@ import { TARGETS } from './constants.js'; import { addLog } from '../common/log.js'; import { updateBleStageLabels, resetBleStages } from './stages.js'; import { hideBleVerifyInfo } from './verify.js'; -import { updateFileInfo } from './file.js'; - export const state = { device: null, server: null, @@ -28,7 +26,7 @@ export const state = { otaResultWaiter: null, otaRunId: 0, lastEsp32DoneSession: null, - fileSha256: null, + fileSha256: null, // 当前 BLE 会话用的 SHA(与对应芯片槽同步) deviceSha256: null, expectedReboot: false, // 发送 0xFA 后置 true,重连后清 reconnecting: false, // 自动重连中 @@ -46,8 +44,6 @@ export const els = { abortBtn: $('abortBtn'), uploadZone: $('uploadZone'), firmwareFile: $('firmwareFile'), - connStatus: $('connStatus'), // 共享连接指示器 - connStatusTxt: $('connStatusTxt'), // 共享连接指示器文本 connState: $('connState'), connBadge: $('connBadge'), deviceName: $('deviceName'), @@ -115,10 +111,12 @@ export function setReadyHint(html) { export function setConnectionState(connected) { const idle = connected && state.selectedFile && !state.uploading; - // 仅在 BLE 模式下更新共享连接指示器与 BLE 专用徽章,避免覆盖 WiFi 轮询的状态 + // BLE 模式:连接状态只写顶栏胶囊,避免下方重复文案 if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { - if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; - if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接'; + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + if (pc) pc.className = connected ? 'pill online' : 'pill offline'; + if (pct) pct.textContent = connected ? 'BLE 已连接' : 'BLE 未连接'; if (els.connState) { els.connState.textContent = connected ? '已连接' : '未连接'; if (els.connState.parentElement) { @@ -174,17 +172,21 @@ export function resetUiAfterDisconnect() { export function refreshTargetUi() { const cfg = currentTarget(); updateBleStageLabels(cfg); - // ★ 上传进行中不调用 updateFileInfo:避免目标切换时进度条被重置。 - // 阶段标签更新(updateBleStageLabels)安全,不碰进度条。 - if (state.selectedFile && !state.uploading && !state.autoFlashing) { - updateFileInfo(state.selectedFile); + /* 与 WiFi 一致:文件由 syncBleFileFromSelectedTarget 按 flashTarget 绑定; + * 此处只刷新标签/包数/阶段说明。 */ + if (state.selectedFile) { + const payloadSize = currentPayloadSize(); + const packets = Math.ceil(state.selectedFile.size / payloadSize); + if (els.packetCount) els.packetCount.textContent = String(packets); } setConnectionState(!!state.server); - // 同步目标胶囊标签 var espTab = document.getElementById('targetEsp32Tab'); var gdTab = document.getElementById('targetGd32Tab'); var isEsp = cfg.label === 'ESP32'; if (espTab) espTab.classList.toggle('active', isEsp); if (gdTab) gdTab.classList.toggle('active', !isEsp); - bleLog('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`); + var autoBtn = document.getElementById('autoFlashBtn'); + if (autoBtn && !state.uploading && !state.autoFlashing) { + autoBtn.textContent = isEsp ? '一键刷写 ESP32' : '一键刷写 GD32'; + } } diff --git a/tools/js/ble/transfer.js b/tools/js/ble/transfer.js index 7b9f1b0..dc46bc0 100644 --- a/tools/js/ble/transfer.js +++ b/tools/js/ble/transfer.js @@ -6,6 +6,7 @@ import { showBleVerifyInfo, hideBleVerifyInfo } from './verify.js'; import { clearOtaResultWaiter, waitForOtaResult, nextOtaSessionId } from './session.js'; import { enableNotifications } from './gatt.js'; import { syncSelectedFileFromInput, updateFileInfo } from './file.js'; +import { computeFileSha } from '../ota/file-handle.js'; import { formatBytes } from '../common/utils.js'; import { showToast } from '../common/toast.js'; @@ -105,17 +106,15 @@ export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) { /** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */ export async function uploadFirmware() { - // 每次刷写前尝试重新从本地读取文件 + // 热重载:有 handle 则重新读盘(本地编译会改同路径固件);否则用已选/input if (state.selectedFileHandle) { try { const freshFile = await state.selectedFileHandle.getFile(); await updateFileInfo(freshFile); - var actualFile = state.selectedFile || freshFile; - bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D'); } catch (e) { bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); } - } else { + } else if (!state.selectedFile) { await syncSelectedFileFromInput(); } if (!state.selectedFile || !state.dataInChar) return false; @@ -172,22 +171,14 @@ export async function uploadFirmware() { bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W'); } - // SHA-256 已在文件选择时(updateFileInfo)计算并存入 state.fileSha256。 - // 此处不再重复打日志(避免与 [FILE] SHA-256 重复),仅保证 fileSha256 变量有值。 - var fileSha256; - if (state.fileSha256) { - fileSha256 = state.fileSha256.toLowerCase(); - } else { - // 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置) - bleLog('OTA', '正在计算文件 SHA-256 哈希...', 'D'); - const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - state.fileSha256 = fileSha256.toUpperCase(); - } - // Update SHA display in file info + /* 上传前始终按本次读到的内容重算 SHA,不沿用选文件时的旧值 */ + var chipKey = (cfg.label === 'GD32') ? 'gd32' : 'esp32'; + var shaHex = await computeFileSha(file, chipKey, fileBytes.buffer); + if (!shaHex) throw new Error('SHA-256 计算失败'); + state.fileSha256 = shaHex; + var fileSha256 = shaHex.toLowerCase(); var shaElem = document.getElementById('fileSha256'); - if (shaElem) shaElem.textContent = fileSha256.toUpperCase(); + if (shaElem) shaElem.textContent = shaHex.toUpperCase(); setBleStage(4, '写入中...', null); setBleStageText(`正在写入 ${packetTotal} 包数据...`); @@ -464,29 +455,15 @@ export async function uploadFirmware() { /** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */ export async function autoFlash() { - // 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择) + /* 与 uploadFirmware 一致:有 handle 则重新读盘,SHA 在 upload 内按内容重算 */ if (state.selectedFileHandle) { try { const freshFile = await state.selectedFileHandle.getFile(); - // 仅在文件变化时才刷新元数据并重算 SHA-256 - // (name/size/lastModified 任一不同即视为变化) - // 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志 - const cached = state.selectedFile; - // HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex, - // 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换 - const isHex = freshFile.name.toLowerCase().endsWith('.hex'); - const changed = !cached || - (isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) || - cached.lastModified !== freshFile.lastModified; - if (changed) { - await updateFileInfo(freshFile); - var actualFile = state.selectedFile || freshFile; - bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D'); - } + await updateFileInfo(freshFile); } catch (e) { bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); } - } else { + } else if (!state.selectedFile) { await syncSelectedFileFromInput(); } if (!state.selectedFile || !state.server) return; diff --git a/tools/js/ble_ota.js b/tools/js/ble_ota.js index 6d93451..80ca292 100644 --- a/tools/js/ble_ota.js +++ b/tools/js/ble_ota.js @@ -24,6 +24,7 @@ window.bleOta = { resetBleStages(); bleLog('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`); }, + state: state, disconnect: disconnectBle, showToast: showToast, notifyFileChange: notifyFileChange, diff --git a/tools/js/can.js b/tools/js/can.js index 954dadf..acc4354 100644 --- a/tools/js/can.js +++ b/tools/js/can.js @@ -43,7 +43,7 @@ import { sendManualFrame, resetManualFields, loadPreviewToManual -} from './can/send.js?v=20260717x'; +} from './can/send.js?v=20260720b'; import { scheduleCanPersist, loadCanSession, @@ -51,7 +51,7 @@ import { } 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'; +import { initCanMonitor } from './can_monitor.js?v=20260719s'; // ==================== 本地工具 ==================== // 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h), diff --git a/tools/js/can/send.js b/tools/js/can/send.js index 02c2340..ccc66f7 100644 --- a/tools/js/can/send.js +++ b/tools/js/can/send.js @@ -1,8 +1,8 @@ /** * send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史) * - * 包含:sendCanRequest、响应框、历史、批量/选中/手动发送、 - * 手动帧校验/重置/载入预览、CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText。 + * 包含:sendCanBatchRequest / sendCanOneFrame(仅 /can/send_batch)、响应框、历史、 + * 批量/选中/手动发送、手动帧校验/重置/载入预览、错误码说明。 * * 依赖:dbc.js(state/el/getBaseUrl/padHex/bytesToHex/parseNumericId)、 * common/toast.js(showToast)、common/utils.js(escapeHtml)、 @@ -28,6 +28,9 @@ function formatTime(date) { return hh + ':' + mm + ':' + ss; } +/** 简单延时 helper,用于批次间间隔 */ +function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } + // ==================== 发送错误码描述 ==================== var CAN_SEND_ERR_DESC = { '-1': '参数错误(通道/ID/DLC 无效)', @@ -64,54 +67,36 @@ function buildSendResponseText(result) { return '发送失败: ' + desc + ' (code=' + result.code + ')'; } -// ==================== HTTP 请求 ==================== -async function sendCanRequest(payload) { - var resp = await fetch(getBaseUrl() + '/can/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(5000) - }); - var text = await resp.text(); - var json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch (err) { - throw new Error('响应不是合法 JSON: ' + text); - } - if (!resp.ok) { - throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '请求失败')); - } - return json; -} - +// ==================== HTTP 请求(唯一入口 /can/send_batch)==================== /** - * @description 一次 HTTP 连续发送多帧(设备 /can/send_batch)。 + * @description 同通道发送 1~N 帧。单帧也走 batch(frames 长度 1)。 * @returns {Promise} 与 frames 等长的 results */ async function sendCanBatchRequest(channel, frames) { + var n = frames && frames.length ? frames.length : 0; + /* 单帧设备侧 timeout 2s;多帧按帧数留余量 */ + var timeoutMs = (n <= 1) ? 8000 : Math.max(15000, n * 500); var resp = await fetch(getBaseUrl() + '/can/send_batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ch: channel, frames: frames }), - signal: AbortSignal.timeout(Math.max(15000, frames.length * 500)) + signal: AbortSignal.timeout(timeoutMs) }); var text = await resp.text(); var json = {}; try { json = text ? JSON.parse(text) : {}; } catch (err) { - throw new Error('批量响应不是合法 JSON: ' + text); + throw new Error('CAN 响应不是合法 JSON: ' + text); } if (!resp.ok) { - throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '批量请求失败')); + throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '发送失败')); } if (!json.results || !Array.isArray(json.results)) { - throw new Error('批量响应缺少 results'); + throw new Error('响应缺少 results'); } - /* 设备可能返回 skipped;补齐与 frames 等长的 results */ var results = json.results.slice(); - var need = frames.length - results.length; + var need = n - results.length; if (need > 0) { var pad = { status: 'error', @@ -123,6 +108,12 @@ async function sendCanBatchRequest(channel, frames) { return results; } +/** 单帧便捷封装:POST /can/send_batch,取 results[0] */ +async function sendCanOneFrame(channel, frame) { + var results = await sendCanBatchRequest(channel, [frame]); + return results[0] || { status: 'error', code: -3 }; +} + // ==================== 响应框 ==================== function updateResponseBox(box, textEl, result, mode) { if (!box || !textEl) return; @@ -188,12 +179,16 @@ export function renderHistory() { } // ==================== 批量发送 ==================== -/** 单批最多帧数(与设备 /can/send_batch 上限一致) */ -var BATCH_CHUNK = 128; +/** + * 单批最多帧数。实测一次发送 >9 帧失败率明显上升,故按 9 帧分批; + * 批次之间留 BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。 + */ +var BATCH_CHUNK = 32; +/** 批次间延时(ms),仅在还有后续批次时等待 */ +var BATCH_INTER_CHUNK_DELAY_MS = 200; /** - * @description 批量发送所有勾选的报文。 - * 优先 POST /can/send_batch;不支持时回退逐帧 /can/send。 + * @description 批量发送所有勾选的报文(仅 /can/send_batch)。 * 批量过程中不重绘历史,结束后统一刷新。 */ export async function sendCheckedMessages() { @@ -246,28 +241,12 @@ export async function sendCheckedMessages() { }); } - /* 分块批量接口;不支持时整段回退为无间隔单帧 */ - var useBatchApi = true; for (var off = 0; off < prepared.length; ) { var chunk = prepared.slice(off, off + BATCH_CHUNK); off += chunk.length; var frames = chunk.map(function (p) { return p.frame; }); - var results = null; - - if (useBatchApi) { - try { - results = await sendCanBatchRequest(channel, frames); - if (results.length !== chunk.length) { - throw new Error('results 长度不匹配'); - } - } catch (err) { - useBatchApi = false; - results = null; - /* 本 chunk 改单帧紧接 */ - } - } - - if (results) { + try { + var results = await sendCanBatchRequest(channel, frames); for (var r = 0; r < chunk.length; r++) { var item = chunk[r]; var result = results[r] || { status: 'error', code: -3 }; @@ -278,27 +257,10 @@ export async function sendCheckedMessages() { resultText: item.msg.name + ' · ' + buildSendResponseText(result) }), silent); } - continue; - } - - /* 回退:逐帧 HTTP,帧间无 sleep、无重试 */ - for (var s = 0; s < chunk.length; s++) { - var one = chunk[s]; - try { - var oneResult = await sendCanRequest({ - ch: channel, - id: one.frame.id, - dlc: one.frame.dlc, - data: one.frame.data - }); - if (oneResult.status === 'ok') ok++; else fail++; - addHistoryEntry(makeHistoryEntry({ - mode: 'BATCH', ok: oneResult.status === 'ok', channel: channel, - idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex, - resultText: one.msg.name + ' · ' + buildSendResponseText(oneResult) - }), silent); - } catch (err) { + } catch (err) { + for (var s = 0; s < chunk.length; s++) { fail++; + var one = chunk[s]; addHistoryEntry(makeHistoryEntry({ mode: 'BATCH', ok: false, channel: channel, idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex, @@ -306,6 +268,11 @@ export async function sendCheckedMessages() { }), silent); } } + + /* 批次间间隔:还有下一批时等待,给 CAN/SPI 总线喘息时间 */ + if (off < prepared.length) { + await sleep(BATCH_INTER_CHUNK_DELAY_MS); + } } renderHistory(); @@ -319,7 +286,7 @@ export async function sendCheckedMessages() { } // ==================== 选中报文发送 ==================== -/** @description 发送当前选中报文(DBC 编码后 POST /can/send)。 */ +/** @description 发送当前选中报文(DBC 编码后 POST /can/send_batch,1 帧)。 */ export async function sendSelectedMessage() { var msg = getSelectedMessage(); if (!msg) { @@ -332,19 +299,18 @@ export async function sendSelectedMessage() { return; } var channel = parseInt(el.defaultChannel.value, 10) || 0; - var payload = { - ch: channel, - id: msg.id, - dlc: msg.dlc, - data: bytesToHex(state.generatedData) - }; + var dataHex = bytesToHex(state.generatedData); try { - var result = await sendCanRequest(payload); + var result = await sendCanOneFrame(channel, { + id: msg.id, + dlc: msg.dlc, + data: dataHex + }); updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc'); addHistoryEntry(makeHistoryEntry({ mode: 'DBC', ok: result.status === 'ok', channel: channel, idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), - dlc: msg.dlc, dataHex: bytesToHex(state.generatedData), + dlc: msg.dlc, dataHex: dataHex, resultText: buildSendResponseText(result) })); showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); @@ -357,7 +323,7 @@ export async function sendSelectedMessage() { addHistoryEntry(makeHistoryEntry({ mode: 'DBC', ok: false, channel: channel, idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), - dlc: msg.dlc, dataHex: bytesToHex(state.generatedData), + dlc: msg.dlc, dataHex: dataHex, resultText: err.message })); showToast('发送失败: ' + err.message, 'error'); @@ -415,8 +381,7 @@ export async function sendManualFrame() { } try { - var result = await sendCanRequest({ - ch: channel, + var result = await sendCanOneFrame(channel, { id: id, dlc: dlc, data: dataHex diff --git a/tools/js/can_monitor.js b/tools/js/can_monitor.js index 7229bed..b68f780 100644 --- a/tools/js/can_monitor.js +++ b/tools/js/can_monitor.js @@ -74,6 +74,17 @@ async function pollFeedback() { for (var i = 0; i < 3; i++) { var ch = channels[i]; + var card = document.getElementById('fbCh' + i); + /* enabled 来自 GD32 HELLO/PING 身份;未知时默认按开启展示 */ + var enabled = !ch || ch.enabled === undefined || ch.enabled === null || !!ch.enabled; + if (card) { + card.classList.toggle('ch-disabled', !enabled); + card.title = enabled ? '' : 'GD32 CONFIG_CAN_CH' + i + '_ENABLE=false(HELLO/PING 身份)'; + } + if (!enabled) { + renderChannelDisabled(i); + continue; + } if (!ch || !ch.valid) { renderChannelEmpty(i); continue; @@ -176,6 +187,20 @@ function renderChannelEmpty(i) { if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } } +/** GD32 编译关闭该通道(APP PING feat can_chN=false) */ +function renderChannelDisabled(i) { + var p = 'fb' + i; + if (el[p + 'State']) { + el[p + 'State'].textContent = '固件未启用'; + el[p + 'State'].className = 'fb-val warn'; + } + ['Rx', 'Tx', 'Drop', 'Err', 'Tec', 'Rec', 'Bus', 'Seq', 'Delta', 'Dlc', 'Flags'].forEach(function (f) { + if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; } + }); + if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } + if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } +} + function renderIdTable(entries) { var tbody = el.idsnapBody; if (!tbody) return; diff --git a/tools/js/common/connection.js b/tools/js/common/connection.js index 09d96d9..4d7f142 100644 --- a/tools/js/common/connection.js +++ b/tools/js/common/connection.js @@ -1,7 +1,7 @@ /** * connection.js - 连接管理 + 系统状态显示 * - * 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。 + * 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。 * * 共享状态(通过 window.* 引用): * window.latestSystemStatus — 最近一次 /system/status 响应 @@ -15,23 +15,21 @@ import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js'; import { addLog } from './log.js'; import { showToast } from './toast.js'; +import { + appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX +} from './heap-low-history.js'; // ==================== 连接管理 ==================== export function updateConnectionUi(connected, text) { var label = text || (connected ? '已连接' : '未连接'); - // header pill + // 仅顶栏连接胶囊(下方连接卡不再重复显示文案) var pc = document.getElementById('pillConn'); var pct = document.getElementById('pillConnTxt'); if (pc) { pc.className = connected ? 'pill online' : 'pill offline'; if (pct) pct.textContent = label; } - // connection card indicator - var cs = document.getElementById('connStatus'); - var cst = document.getElementById('connStatusTxt'); - if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; - if (cst) cst.textContent = connected ? label : '未连接'; - // primary badge + // 系统状态卡徽章 var bc = document.getElementById('bConn'); if (bc) { bc.textContent = connected ? '在线' : '离线'; @@ -41,10 +39,7 @@ export function updateConnectionUi(connected, text) { export function updateHealthUi(healthCode, freeHeap) { var healthNames = ['正常', '警告', '危险', '关机']; var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知'; - var connStatusTxt = document.getElementById('connStatusTxt'); - if (connStatusTxt && window.latestSystemStatus && window.latestSystemStatus.wifi) { - connStatusTxt.textContent = (window.latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health; - } + /* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */ if (freeHeap !== undefined) { var mHeapEl = document.getElementById('mHeap'); if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap); @@ -252,22 +247,167 @@ export function updateHeapAndTaskStatusUi(data) { } } updateCpuUsageUi(data.cpu); + + /* 合并 memory:layout(?layout=1,缓存)+ 动态 heap_*(每次轮询) */ + function mergeMemoryView(rawMem) { + rawMem = rawMem || {}; + if (rawMem.layout && typeof rawMem.layout === 'object') { + window._memLayout = rawMem.layout; + } + var L = window._memLayout || {}; + /* 兼容旧固件:静态字段仍在 memory 根上 */ + function pick(key) { + if (rawMem[key] !== undefined) return rawMem[key]; + if (L[key] !== undefined) return L[key]; + return undefined; + } + return { + chip: pick('chip'), + sram_total: pick('sram_total'), + sram_soc: pick('sram_soc'), + static_reserved: pick('static_reserved'), + reserved_tail: pick('reserved_tail'), + iram: pick('iram'), + data: pick('data'), + bss: pick('bss'), + heap_total: pick('heap_total'), + dma_total: pick('dma_total'), + rtc_total: pick('rtc_total'), + task_stack_total: pick('task_stack_total'), + default_heap_total: pick('default_heap_total'), + heap_used: rawMem.heap_used, + heap_free: rawMem.heap_free, + heap_min_free: rawMem.heap_min_free, + heap_largest_free: rawMem.heap_largest_free, + alloc_blocks: rawMem.alloc_blocks, + free_blocks: rawMem.free_blocks, + dma_free: rawMem.dma_free, + rtc_free: rawMem.rtc_free, + default_heap_free: rawMem.default_heap_free + }; + } + + /* 芯片 SRAM 全景:IRAM + .data + .bss + 堆已用 + 堆空闲(链接符号拆分静态段) */ + function updateSramOverview(mem, fallback) { + mem = mergeMemoryView(mem); + var sramTotal = Number(mem.sram_total); + var sramSoc = Number(mem.sram_soc); + var staticRes = Number(mem.static_reserved); + var iram = Number(mem.iram); + var dataSz = Number(mem.data); + var bss = Number(mem.bss); + var heapTotal = Number(mem.heap_total); + var heapUsed = Number(mem.heap_used); + var heapFree = Number(mem.heap_free); + var reservedTail = Number(mem.reserved_tail); + var hasSections = Number.isFinite(iram) || Number.isFinite(dataSz) || Number.isFinite(bss); + + /* 旧固件无 memory 字段时,用 internal_* 兜底 */ + if (!Number.isFinite(sramTotal) || sramTotal <= 0) { + heapFree = Number(fallback.internal_free_heap); + heapTotal = Number(fallback.internal_heap_total); + if (!Number.isFinite(heapTotal) || heapTotal <= 0) { + setText('memSramUsage', '--'); + setText('memSramPct', '--'); + setText('mIram', '--'); + setText('mData', '--'); + setText('mBss', '--'); + setText('mHeapUsed', '--'); + setText('mHeapFree', '--'); + setText('mSramSoc', '--'); + setText('mReservedTail', '--'); + setText('mTaskStackTotal', '--'); + setText('mDmaFree', '--'); + setText('mRtcFree', '--'); + setText('mAllocBlocks', '--'); + ['memSegIram', 'memSegData', 'memSegBss', 'memSegUsed', 'memSegFree'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.style.width = '0%'; + }); + return; + } + heapUsed = heapTotal - heapFree; + sramTotal = heapTotal; + staticRes = 0; + iram = dataSz = bss = 0; + hasSections = false; + } + if (!Number.isFinite(heapUsed) || heapUsed < 0) { + heapUsed = Math.max(0, (Number.isFinite(heapTotal) ? heapTotal : 0) - (Number.isFinite(heapFree) ? heapFree : 0)); + } + if (!Number.isFinite(heapFree) || heapFree < 0) heapFree = 0; + if (!Number.isFinite(staticRes) || staticRes < 0) staticRes = 0; + if (!Number.isFinite(heapTotal) || heapTotal < 0) heapTotal = heapUsed + heapFree; + if (!Number.isFinite(iram) || iram < 0) iram = 0; + if (!Number.isFinite(dataSz) || dataSz < 0) dataSz = 0; + if (!Number.isFinite(bss) || bss < 0) bss = 0; + /* 无分段字段时,整块静态灰条仍可用 static_reserved */ + if (!hasSections && staticRes > 0) { + bss = staticRes; + } + + var usedAll = staticRes + heapUsed; + if (!Number.isFinite(staticRes) || staticRes <= 0) { + usedAll = iram + dataSz + bss + heapUsed; + staticRes = iram + dataSz + bss; + } + var pctAll = sramTotal > 0 ? (usedAll * 100 / sramTotal) : 0; + setText('memSramUsage', '已占 ' + formatBytes(usedAll) + ' / 应用 ' + formatBytes(sramTotal)); + setText('memSramPct', pctAll.toFixed(1) + '%'); + setText('mIram', formatBytes(iram)); + setText('mData', formatBytes(dataSz)); + setText('mBss', formatBytes(bss)); + setText('mHeapUsed', formatBytes(heapUsed)); + setText('mHeapFree', formatBytes(heapFree)); + setText('mSramSoc', Number.isFinite(sramSoc) && sramSoc > 0 ? formatBytes(sramSoc) : '--'); + setText('mReservedTail', Number.isFinite(reservedTail) ? formatBytes(reservedTail) : '--'); + setText('mTaskStackTotal', Number.isFinite(Number(mem.task_stack_total)) + ? formatBytes(Number(mem.task_stack_total)) : '--'); + + function setSeg(id, bytes) { + var el = document.getElementById(id); + if (!el) return; + var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0; + el.style.width = Math.max(0, Math.min(100, pct)) + '%'; + } + setSeg('memSegIram', iram); + setSeg('memSegData', dataSz); + setSeg('memSegBss', bss); + setSeg('memSegUsed', heapUsed); + setSeg('memSegFree', heapFree); + + if (Number.isFinite(Number(mem.dma_free)) && Number.isFinite(Number(mem.dma_total))) { + setText('mDmaFree', formatBytes(Number(mem.dma_free)) + ' / ' + formatBytes(Number(mem.dma_total))); + } else { + setText('mDmaFree', '--'); + } + if (Number.isFinite(Number(mem.rtc_free)) && Number.isFinite(Number(mem.rtc_total))) { + var rt = Number(mem.rtc_total); + setText('mRtcFree', rt > 0 + ? (formatBytes(Number(mem.rtc_free)) + ' / ' + formatBytes(rt)) + : '未并入堆'); + } else { + setText('mRtcFree', '--'); + } + setText('mAllocBlocks', Number.isFinite(Number(mem.alloc_blocks)) ? String(Number(mem.alloc_blocks)) : '--'); + } + updateSramOverview(data.memory, data); + 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); + /* 最低空闲再创新低 → 尽快把设备 1 槽快照拉进 IndexedDB(多条历史靠浏览器攒) */ + onStatusHeapMinMaybeChanged(data); setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--'); + bindHeapLowTooltip(); + 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) : '--'); if (Number.isFinite(ota.sys_state_code)) window.lastSysStateCode = ota.sys_state_code; if (Number.isFinite(ota.relay_state)) window.lastRelayState = 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'); @@ -355,16 +495,28 @@ export async function diagnoseNetwork() { return result.join(';'); } +/** + * /system/status URL:尚无缓存布局时带 ?layout=1(静态 IRAM/.data/.bss/堆容量只取一次)。 + * 设备重启/换固件后清 window._memLayout 再拉。 + */ +export function systemStatusUrl(forceLayout) { + var base = (window.getBaseUrl ? window.getBaseUrl() : '') + '/system/status'; + if (forceLayout || !window._memLayout) { + return base + (base.indexOf('?') >= 0 ? '&' : '?') + 'layout=1'; + } + return base; +} + /** * 统一的 /system/status 抓取器:独立 AbortController,超时后中断, * 避免与递归调度的 pollStatus 互相干扰。 * 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。 */ -async function fetchSystemStatus(timeoutMs) { +async function fetchSystemStatus(timeoutMs, forceLayout) { var ac = new AbortController(); var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000); try { - var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal }); + var resp = await fetch(systemStatusUrl(!!forceLayout), { signal: ac.signal }); if (!resp.ok) { var err = new Error('HTTP ' + resp.status); err.status = resp.status; @@ -407,7 +559,8 @@ export async function refreshSystemStatusNow(event) { event.stopPropagation(); } try { - var data = await fetchSystemStatus(3000); + /* 手动刷新强制带 layout,便于换固件后更新静态段 */ + var data = await fetchSystemStatus(3000, true); window.latestSystemStatus = data; window.updateSystemStatusUi(data); window.consecutivePollFailures = 0; @@ -422,3 +575,446 @@ export async function refreshSystemStatusNow(event) { showToast('状态刷新失败', 'error'); } } + +// ============================================================ +// 最低空闲 hover 弹窗:拉 /system/heap_low_log(设备 1 条)+ IndexedDB 历史 +// ============================================================ +// 行为:鼠标移到 #mInternalMinHeap 上时浮现 tooltip,离开时关闭。 +// 首次进入懒加载一次接口,1s 内复用缓存防鼠标抖动反复请求。 +// 弹窗固定在元素右下,越界时自动翻到左侧/上侧。 + +var heapLowLogCache = null; // null=未加载,[]/data=已加载 +var heapLowLogLoading = false; +var heapLowLogLoadAt = 0; // 上次成功加载时间戳(ms) +var heapLowTooltipEl = null; // 当前显示中的 tooltip 元素 + +var HEAP_LOW_STATE_NAMES = ['IDLE','GD32_STORING','GD32_STORED','GD32_RELAYING','GD32_COMPLETE','ESP32_RECEIVING','ESP32_FINALIZING','ESP32_REBOOTING','ERROR']; +var HEAP_LOW_CHANNEL_NAMES = ['WIFI','BLE']; + +function fmtUptime(ms) { + if (!Number.isFinite(ms) || ms <= 0) return '--'; + var s = Math.floor(ms / 1000); + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + var sec = s % 60; + if (h > 0) return h + 'h' + String(m).padStart(2,'0') + 'm' + String(sec).padStart(2,'0') + 's'; + if (m > 0) return m + 'm' + String(sec).padStart(2,'0') + 's'; + return sec + 's'; +} + +/** OTA 忙态:HTTP socket/堆紧张,勿后台拉 heap_low_log(与 status 抢缓冲/超时 abort)。 */ +function isOtaBusyForHeapLowPoll() { + if (window.pollingPausedForUpload) return true; + var c = Number(window.lastSysStateCode); + /* STORING=1 RELAYING=3 RECEIVING=5 FINALIZING=6 REBOOTING=7 */ + return c === 1 || c === 3 || c === 5 || c === 6 || c === 7; +} + +function isFetchAbortError(e) { + if (!e) return false; + if (e.name === 'AbortError') return true; + var msg = String(e.message || e); + return /aborted|AbortError/i.test(msg); +} + +/** 拉取 /system/heap_low_log。点击触发频率低,不缓存,保证每次看到最新数据 + * (与每秒轮询的 #mInternalMinHeap 表格保持一致)。仅对"请求中"去重避免重复并发。 + * 拉到后增量写入 IndexedDB 持久化,突破设备仅 1 条槽位的限制。 */ +async function loadHeapLowLog(force) { + if (heapLowLogLoading) return heapLowLogCache; + /* 用户点开 tooltip 时 force=true 仍允许;后台/创新低轮询勿在 OTA 中抢 HTTP */ + if (!force && isOtaBusyForHeapLowPoll()) { + return heapLowLogCache; + } + heapLowLogLoading = true; + try { + var ac = new AbortController(); + var timer = setTimeout(function(){ ac.abort(); }, 3000); + var resp; + try { + resp = await fetch(window.getBaseUrl() + '/system/heap_low_log', { signal: ac.signal }); + } finally { clearTimeout(timer); } + if (!resp.ok) throw new Error('HTTP ' + resp.status); + heapLowLogCache = await resp.json(); + heapLowLogLoadAt = Date.now(); + // 增量持久化:把设备当前记录与 IndexedDB 已有的去重后写入。 + // 失败/不可用静默降级(appendHeapLowRecords 内部已 catch)。 + if (heapLowLogCache && Array.isArray(heapLowLogCache.records) && heapLowLogCache.records.length) { + appendHeapLowRecords(heapLowLogCache.records).then(function (n) { + if (n > 0) { + addLog('I', '内存', '堆低水位历史已存 ' + n + ' 条新记录'); + } + }); + } + } catch (e) { + /* 超时 abort / 导航取消:OTA 或 socket 紧时常见,不刷 WARN */ + if (!isFetchAbortError(e)) { + addLog('W', '内存', '拉取 /system/heap_low_log 失败: ' + (e && e.message ? e.message : e)); + } + // 失败时不清缓存,避免下次再次进入就重打接口 + } finally { + heapLowLogLoading = false; + } + return heapLowLogCache; +} + +function buildHeapLowRowHtml(r, idx, segTag) { + var ts = fmtUptime(r.ts_ms); + var state = HEAP_LOW_STATE_NAMES[r.sys_state] || ('#' + r.sys_state); + var conn = ''; + if (r.ble_connected) conn += 'BLE '; + if (r.wifi_connected) conn += 'WiFi'; + if (!conn) conn = '--'; + var otaProgress = '--'; + if (r.ota_total_size > 0) { + otaProgress = formatBytes(r.ota_stored_size) + '/' + formatBytes(r.ota_total_size); + } + var curTask = escapeHtml(r.current_task || 'health_mon'); + // 排除法诊断:OTA 资源优先;否则按 hook 最后一击类别(勿把「连接=WiFi」当触发任务) + var diag = '--'; + var diagCls = ''; + var suspects = []; + if (Number(r.ble_data_queue_depth) > 0) suspects.push('BLE队列:' + r.ble_data_queue_depth); + if (Number(r.ble_reorder_cached) > 0) suspects.push('reorder:' + r.ble_reorder_cached); + if (Number(r.spi_pending_jobs) > 0) suspects.push('SPI队列:' + r.spi_pending_jobs); + var hookCls = (r.hook_task || '').toString(); + var hookSz = Number(r.hook_alloc_size) || 0; + if (suspects.length) { + diag = escapeHtml(suspects.join(' ')); + diagCls = 'has-suspect'; + } else if (Number(r.hook_fired) === 1 && (hookCls === 'net?' || (hookSz >= 48 && hookSz < 2048))) { + // 最后一击是小/中块:HTTP 回包 / UDP 日志 send 前 lwip 会再 alloc pbuf,常被误当成 WiFi 主因 + diag = '最后一击≈网络发送'; + diagCls = 'idle-suspect'; + } else if (Number(r.hook_fired) === 1 && (hookCls === 'bulk' || hookSz >= 2048)) { + diag = '最后一击≈大块alloc'; + diagCls = 'has-suspect'; + } else { + var stateName = HEAP_LOW_STATE_NAMES[r.sys_state] || ('#' + r.sys_state); + if (stateName === 'IDLE' || stateName === 'GD32_COMPLETE' || stateName === 'GD32_STORED' || stateName === 'ERROR') { + diag = '空闲期(主因未定位)'; + diagCls = 'idle-suspect'; + } else { + diag = '非数据路径'; + diagCls = 'idle-suspect'; + } + } + // 任务栈子表(默认隐藏,点击主行展开) + var stacks = Array.isArray(r.task_stacks) ? r.task_stacks : []; + var stackRows = stacks.map(function(s) { + var nm = escapeHtml(s.name || '?'); + var rem = Number.isFinite(s.remaining) ? formatBytes(s.remaining) : '--'; + var cls = (Number(s.remaining) < 1024) ? 'crit' : + (Number(s.remaining) < 2048) ? 'warn' : ''; + return '' + + nm + '' + rem + ''; + }).join(''); + var stackSummary = stacks.length + ? (stacks[0].name + ' ' + formatBytes(stacks[0].remaining) + ' ▾' + stacks.length + '') + : '--'; + // ★ 行 id 用 segTag 前缀避免设备当前段与历史段 idx 冲突 + var rowId = segTag + '_' + idx; + return '' + + '' + ts + '' + + '' + formatBytes(r.min_free_heap) + '' + + '' + formatBytes(r.free_heap) + '' + + '' + formatBytes(r.largest_free_block) + '' + + '' + state + '' + + '' + conn + '' + + '' + otaProgress + '' + + '' + formatBytes(r.default_free_heap) + '' + + '' + diag + '' + + '' + stackSummary + '' + + '' + + '' + + '
' + + '采样任务: ' + curTask + ' (巡检发现者,不是 alloc 触发者)' + + '内部堆分配数: ' + (Number.isFinite(r.alloc_count) ? r.alloc_count : '--') + '' + + 'IP事件计数: ' + (Number.isFinite(r.ip_event_count) ? r.ip_event_count : '--') + '' + + (r.saved_at_ms ? '保存于: ' + formatTime(new Date(r.saved_at_ms)) + '' : '') + + '
' + + '
' + + '
heap hook:破新低时的「最后一击」alloc' + + (Number(r.hook_fired) === 1 + ? '' + : ' (hook 未命中)') + + '
' + + '分配类别: ' + escapeHtml(r.hook_task || '--') + ' net?=小中块/疑网络 · bulk=大块 · tiny=更小' + + '本次alloc: ' + (Number.isFinite(r.hook_alloc_size) && Number(r.hook_alloc_size) > 0 ? formatBytes(r.hook_alloc_size) : '--') + '' + + 'alloc后est空闲: ' + (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--') + '' + + '调用者地址: 0x' + (Number.isFinite(r.hook_caller_addr) && Number(r.hook_caller_addr) > 0 ? r.hook_caller_addr.toString(16).toUpperCase() : '--') + '' + + ' (addr2line)' + + '
' + + '' + + '' + (stackRows || '') + '' + + '
任务剩余栈
无
' + + ''; +} + +function buildHeapLowSectionHtml(records, segTag, title, totalCount) { + if (!records || !records.length) return ''; + var rows = records.map(function (r, i) { return buildHeapLowRowHtml(r, i, segTag); }).join(''); + return '
' + + '
' + title + + ' ' + records.length + ' 条' + + (totalCount !== undefined && totalCount > records.length ? ' / 共 ' + totalCount : '') + + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rows + '' + + '
启动后最低空闲当时空闲最大块OTA状态连接OTA进度默认堆诊断最危险栈 ▾
' + + '
'; +} + +function buildHeapLowTooltipHtml(data, history) { + var live = (data && Array.isArray(data.records)) ? data.records : []; + var hist = Array.isArray(history) ? history : []; + var bodyHtml; + if (!live.length && !hist.length) { + bodyHtml = '
尚无"最低空闲创新低"记录
'; + } else { + // 设备当前段:live 已是最新在前 + var liveHtml = buildHeapLowSectionHtml(live, 'live', '设备当前(仅最新 1 条)', hist.length + live.length); + // 历史段:hist 升序(旧→新),显示时反转成新的在前 + var histDesc = hist.slice().reverse(); + var liveKeys = {}; + live.forEach(function (r) { + liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)] = true; + }); + var histOnly = histDesc.filter(function (r) { + return !liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)]; + }); + var histHtml = histOnly.length + ? buildHeapLowSectionHtml(histOnly, 'hist', '前端累积历史', hist.length) + : ''; + bodyHtml = liveHtml + histHtml; + } + return '' + + '
堆"最低空闲"创新低记录' + + '设备 ' + live.length + ' 条 + 历史 ' + hist.length + ' 条' + + '' + + '' + + '
' + + '
' + bodyHtml + '
' + + '
设备只保留最新 1 条;多条历史在浏览器 IndexedDB(最多 ' + + HEAP_HISTORY_MAX + + ' 条,约 5s 拉取 + status 新低时立即拉取)。「清空」只清本地历史,不访问设备。状态栏「最低空闲」需重启才清。
'; +} + +function positionHeapLowTooltip(anchorEl, tipEl) { + // 默认放右下;越界时翻左侧/上侧。tip 先 measure 再定位。 + tipEl.style.visibility = 'hidden'; + tipEl.style.left = '0px'; + tipEl.style.top = '0px'; + document.body.appendChild(tipEl); + var r = anchorEl.getBoundingClientRect(); + var tw = tipEl.offsetWidth; + var th = tipEl.offsetHeight; + var pad = 8; + var left = r.right + pad; + if (left + tw > window.innerWidth - 8) left = r.left - tw - pad; + if (left < 8) left = Math.max(8, r.right - tw); + var top = r.bottom + pad; + if (top + th > window.innerHeight - 8) top = r.top - th - pad; + if (top < 8) top = 8; + tipEl.style.left = left + 'px'; + tipEl.style.top = top + 'px'; + tipEl.style.visibility = 'visible'; +} + +function showHeapLowTooltip(anchorEl) { + // 并行加载:设备当前 1 条 + 浏览器 IndexedDB 累积历史 + // force=true:用户主动查看,OTA 期也允许试一次(失败静默) + Promise.all([ + loadHeapLowLog(true).catch(function () { return { records: [] }; }), + loadHeapLowHistory().catch(function () { return []; }) + ]).then(function (results) { + var data = results[0] || { records: [] }; + var history = results[1] || []; + if (heapLowTooltipEl) heapLowTooltipEl.remove(); + var el = document.createElement('div'); + el.className = 'heap-low-tooltip'; + el.innerHTML = buildHeapLowTooltipHtml(data, history); + heapLowTooltipEl = el; + positionHeapLowTooltip(anchorEl, el); + // 关闭按钮:显式关闭并阻止 click 冒泡触发"点击外部关闭" + var closeBtn = el.querySelector('.heap-low-close'); + if (closeBtn) { + closeBtn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + hideHeapLowTooltip(); + }); + } + var clearBtn = el.querySelector('.heap-low-clear'); + if (clearBtn) { + clearBtn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + clearHeapLowRecordsUi(clearBtn, anchorEl); + }); + } + // 点击主行展开/收起对应任务栈详情子表 + var mainRows = el.querySelectorAll('.heap-low-main-row'); + mainRows.forEach(function(row) { + row.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + var idx = row.getAttribute('data-idx'); + if (idx === null) return; + var detail = el.querySelector('.heap-low-stack-detail[data-idx="' + idx + '"]'); + if (detail) detail.hidden = !detail.hidden; + }); + }); + }); +} + +/** + * 清空浏览器侧堆低水位历史(IndexedDB)。 + * 不再 POST 设备:设备仅 1 槽,多条历史全在本地;状态栏 min_free 仍要重启才清。 + */ +async function clearHeapLowRecordsUi(btn, anchorEl) { + if (btn && btn.disabled) return; + if (!window.confirm( + '清空浏览器中的堆低水位历史(IndexedDB,最多 ' + + HEAP_HISTORY_MAX + + ' 条)?\n\n不会访问设备。状态栏「最低空闲」芯片累计需重启才清零。' + )) { + return; + } + if (btn) { + btn.disabled = true; + btn.textContent = '清空中…'; + } + var localOk = false; + try { + localOk = !!(await clearHeapLowHistory()); + } catch (e2) { + localOk = false; + } + heapLowLogCache = null; + heapLowLogLoadAt = 0; + lastSeenInternalMinHeap = null; + + if (localOk) { + addLog('I', '内存', '已清空堆低水位本地历史'); + showToast('已清空本地历史', 'success'); + } else { + addLog('E', '内存', '清空本地历史失败'); + showToast('清空失败', 'error'); + if (btn) { + btn.disabled = false; + btn.textContent = '清空'; + } + return; + } + if (anchorEl) { + showHeapLowTooltip(anchorEl); + } else { + hideHeapLowTooltip(); + } +} + +function hideHeapLowTooltip() { + if (heapLowTooltipEl) { heapLowTooltipEl.remove(); heapLowTooltipEl = null; } +} + +/** 绑定到 #mInternalMinHeap 元素(仅绑一次)。click 切换显示/隐藏。 */ +export function bindHeapLowTooltip() { + var el = document.getElementById('mInternalMinHeap'); + if (!el || el.dataset.heapLowBound === '1') return; + el.dataset.heapLowBound = '1'; + el.style.cursor = 'pointer'; + el.title = '点击查看堆低水位创新低记录'; + + // 点击触发:已显示则关闭,未显示则拉接口显示 + el.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (heapLowTooltipEl) { + hideHeapLowTooltip(); + } else { + showHeapLowTooltip(el); + } + }); + + // 点击 tooltip 外部关闭(点击 tooltip 自身或其子元素不关闭) + document.addEventListener('click', function(e) { + if (!heapLowTooltipEl) return; + if (e.target === el) return; // 触发元素自身已单独处理 + var node = e.target; + while (node && node !== document) { + if (node === heapLowTooltipEl) return; + node = node.parentNode; + } + hideHeapLowTooltip(); + }, true); + + // 页面整体滚动/resize 时关闭(tooltip 内部滚动不触发,由 e.target 父链判断) + window.addEventListener('scroll', function(e) { + if (!heapLowTooltipEl) return; + var node = e.target; + while (node && node !== document) { + if (node === heapLowTooltipEl) return; + node = node.parentNode; + } + hideHeapLowTooltip(); + }, true); + window.addEventListener('resize', function() { + if (heapLowTooltipEl) hideHeapLowTooltip(); + }); + + // 后台拉取 heap_low_log → IndexedDB 多条历史(设备仅 1 槽) + startHeapLowBackgroundPoll(); +} + +// ==================== 堆低水位历史:后台拉取 + 弹窗合并显示 ==================== +var heapLowBgTimerId = null; +/** 与 health_mon 5s 巡检对齐,尽量在下一次创新低覆盖前把当前槽拉进 IDB */ +var HEAP_LOW_BG_INTERVAL_MS = 5000; +/** 上次从 /system/status 见到的 min_free,用于检测新低并立刻拉 log */ +var lastSeenInternalMinHeap = null; + +/** + * status 轮询里调用:若 internal_min_free_heap 再创新低,立刻拉设备槽写入 IDB。 + * 这样不必等 5s 后台定时,连续创新低时也能多攒几条(每条仍须设备侧先记一次)。 + */ +export function onStatusHeapMinMaybeChanged(data) { + if (!data) return; + var minH = data.internal_min_free_heap; + if (minH === undefined) minH = data.min_free_heap; + minH = Number(minH); + if (!Number.isFinite(minH)) return; + if (lastSeenInternalMinHeap === null) { + lastSeenInternalMinHeap = minH; + return; + } + if (minH < lastSeenInternalMinHeap) { + lastSeenInternalMinHeap = minH; + /* force=false:OTA 忙态跳过,避免与上传/status 抢 socket 后 abort 刷屏 */ + if (!document.hidden && !isOtaBusyForHeapLowPoll()) { + loadHeapLowLog(false).catch(function () {}); + } + } else if (minH > lastSeenInternalMinHeap) { + /* 设备重启后 min 回升:同步基线,不强制拉 */ + lastSeenInternalMinHeap = minH; + } +} + +/** 启动后台拉取。多次调用幂等。 */ +function startHeapLowBackgroundPoll() { + if (heapLowBgTimerId) return; + function tick() { + heapLowBgTimerId = null; + if (!document.hidden && !isOtaBusyForHeapLowPoll()) { + loadHeapLowLog(false).catch(function () {}); + } + heapLowBgTimerId = setTimeout(tick, HEAP_LOW_BG_INTERVAL_MS); + } + heapLowBgTimerId = setTimeout(tick, 3000); +} diff --git a/tools/js/common/heap-low-history.js b/tools/js/common/heap-low-history.js new file mode 100644 index 0000000..f43549d --- /dev/null +++ b/tools/js/common/heap-low-history.js @@ -0,0 +1,196 @@ +/** + * heap-low-history.js - 堆"最低空闲创新低"记录的浏览器端持久化 + * + * 目的:设备 RAM 只保留最新 1 条创新低;长时间挂机早期记录会被覆盖。 + * 前端定时拉 /system/heap_low_log,发现新记录就追加到 IndexedDB, + * 弹窗里合并显示「设备当前 1 条 + 浏览器累积历史」,掉电/重启不丢本地史。 + * + * 存储设计: + * - 单 objectStore 'heap_low_records',keyPath 'id'(自增) + * - 每条记录附前端写入时的时间戳 saved_at_ms(wall clock) + * - 设备重启会重置 ts_ms(启动后毫秒)和 min_free_heap(水位清零), + * 所以去重 key 用 (ts_ms, min_free_heap, free_heap, alloc_count) 组合, + * 重启后第一条 min_free_heap 必然高于上一次会话最低值 → 自然不会误并入。 + * - 上限 HEAP_HISTORY_MAX 条,超出按 saved_at_ms 升序删最早的。 + */ + +var IDB_NAME = 'esp_dcu_heap_low'; +var IDB_STORE = 'heap_low_records'; +var IDB_VERSION = 1; // 独立库,与 file-handle.js 的 dcu_ota_fw_v1 不冲突 +/** 浏览器可保存的创新低条数上限(设备仅 1 槽;多条全靠这里)。 */ +export var HEAP_HISTORY_MAX = 500; + +var _dbPromise = null; + +function openDb() { + if (!_dbPromise) { + _dbPromise = new Promise(function (resolve, reject) { + if (!window.indexedDB) { + reject(new Error('indexedDB unavailable')); + return; + } + var req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = function () { + var db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + var store = db.createObjectStore(IDB_STORE, { keyPath: 'id', autoIncrement: true }); + // 去重查询索引:同一会话内 (ts_ms,min_free_heap,free_heap,alloc_count) 唯一 + store.createIndex('dedup', ['ts_ms', 'min_free_heap', 'free_heap', 'alloc_count'], { unique: false }); + store.createIndex('saved_at', 'saved_at_ms', { unique: false }); + } + }; + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error || new Error('idb open failed')); }; + }); + _dbPromise.catch(function () { _dbPromise = null; }); + } + return _dbPromise; +} + +/** 计算单条记录的去重 key 字符串(用于 in-memory 比对,避免每次都走 IDB 索引) */ +function dedupKey(r) { + return r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0); +} + +/** 把设备拉到的一批 records(最新在前)增量写入 IndexedDB,去重。 + * 返回 Promise:本次实际新增的条数。 */ +export function appendHeapLowRecords(records) { + if (!Array.isArray(records) || !records.length) return Promise.resolve(0); + return openDb().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(IDB_STORE, 'readwrite'); + var store = tx.objectStore(IDB_STORE); + var idx = store.index('dedup'); + var newCount = 0; + var savedAt = Date.now(); + + // 顺序处理:每条先查 dedup 索引,不存在则 put + var i = 0; + function next() { + if (i >= records.length) { + // 全部处理完后再裁剪到上限 + pruneToMax(store, function () { + tx.oncomplete ? null : null; + }); + return; + } + var r = records[i++]; + var key = [r.ts_ms, r.min_free_heap, r.free_heap, r.alloc_count || 0]; + var getReq = idx.get(key); + getReq.onsuccess = function () { + if (!getReq.result) { + // 新记录:写入 + var rec = Object.assign({}, r, { saved_at_ms: savedAt }); + var putReq = store.put(rec); + putReq.onsuccess = function () { newCount++; next(); }; + putReq.onerror = function () { next(); }; // 单条失败不阻断 + } else { + next(); // 已存在跳过 + } + }; + getReq.onerror = function () { next(); }; + } + next(); + + tx.oncomplete = function () { resolve(newCount); }; + tx.onerror = function () { reject(tx.error); }; + tx.onabort = function () { reject(tx.error || new Error('tx aborted')); }; + }); + }).catch(function (e) { + // IndexedDB 不可用(无痕模式等)静默降级 + return 0; + }); +} + +/** 删最早的记录直到条数 <= HEAP_HISTORY_MAX。需在 readwrite tx 内调用。 */ +function pruneToMax(store, done) { + var countReq = store.count(); + countReq.onsuccess = function () { + var total = countReq.result; + if (total <= HEAP_HISTORY_MAX) { done(); return; } + var toRemove = total - HEAP_HISTORY_MAX; + // 按 saved_at 索引升序游标删前 toRemove 条 + var idx = store.index('saved_at'); + var curReq = idx.openCursor(); + var removed = 0; + curReq.onsuccess = function (ev) { + var cursor = ev.target.result; + if (!cursor || removed >= toRemove) { done(); return; } + var delReq = store.delete(cursor.primaryKey); + delReq.onsuccess = function () { removed++; cursor.continue(); }; + delReq.onerror = function () { cursor.continue(); }; + }; + curReq.onerror = function () { done(); }; + }; + countReq.onerror = function () { done(); }; +} + +/** 读全部累积历史,按设备时间 ts_ms 升序(旧的在前),便于弹窗"历史段"显示。 + * 返回 Promise。IndexedDB 不可用时返回 []。 */ +export function loadHeapLowHistory() { + return openDb().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(IDB_STORE, 'readonly'); + var store = tx.objectStore(IDB_STORE); + var all = []; + var curReq = store.openCursor(); + curReq.onsuccess = function (ev) { + var cursor = ev.target.result; + if (!cursor) return; + all.push(cursor.value); + cursor.continue(); + }; + curReq.onerror = function () { reject(curReq.error); }; + tx.oncomplete = function () { + // 按 ts_ms 升序;同 ts_ms 时按 saved_at 升序(保证稳定) + all.sort(function (a, b) { + if (a.ts_ms !== b.ts_ms) return a.ts_ms - b.ts_ms; + return (a.saved_at_ms || 0) - (b.saved_at_ms || 0); + }); + resolve(all); + }; + tx.onerror = function () { reject(tx.error); }; + }); + }).catch(function () { return []; }); +} + +/** 清空全部历史(用于"清空"按钮,可选实现)。返回 Promise。 */ +export function clearHeapLowHistory() { + return openDb().then(function (db) { + return new Promise(function (resolve) { + var tx = db.transaction(IDB_STORE, 'readwrite'); + var req = tx.objectStore(IDB_STORE).clear(); + req.onsuccess = function () { resolve(true); }; + req.onerror = function () { resolve(false); }; + }); + }).catch(function () { return false; }); +} + +/** 返回当前累积条数(仅用于 UI 提示,不需要数据)。返回 Promise。 */ +export function countHeapLowHistory() { + return openDb().then(function (db) { + return new Promise(function (resolve) { + var tx = db.transaction(IDB_STORE, 'readonly'); + var req = tx.objectStore(IDB_STORE).count(); + req.onsuccess = function () { resolve(req.result || 0); }; + req.onerror = function () { resolve(0); }; + }); + }).catch(function () { return 0; }); +} + +/** 计算给定 records 数组(设备拉到的最新在前)与参考集合(已存历史)相比的新条数。 + * 纯内存比对,不写库,用于先判断是否需要写。 */ +export function diffNewCount(records, existingHistory) { + if (!Array.isArray(records) || !records.length) return 0; + var seen = {}; + if (Array.isArray(existingHistory)) { + for (var i = 0; i < existingHistory.length; ++i) { + seen[dedupKey(existingHistory[i])] = true; + } + } + var n = 0; + for (var j = 0; j < records.length; ++j) { + if (!seen[dedupKey(records[j])]) ++n; + } + return n; +} \ No newline at end of file diff --git a/tools/js/common/polling.js b/tools/js/common/polling.js index 69c3057..b80e8c7 100644 --- a/tools/js/common/polling.js +++ b/tools/js/common/polling.js @@ -156,6 +156,8 @@ function markEsp32RebootComplete(source) { if (!window.isEsp32Rebooting) return; window.isEsp32Rebooting = false; window.rebootObservedDown = false; + /* 重启后重新拉 memory.layout(换固件后静态段可能变化) */ + window._memLayout = null; window.updateEsp32StagesFromPhase(6); // 启动完成 addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线'); showToast('ESP32 已恢复在线', 'success'); @@ -187,7 +189,11 @@ export async function pollStatus() { statusAbortCtrl = new AbortController(); var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000); try { - var resp = await fetch(window.getBaseUrl() + '/system/status', { + /* 首次/无缓存:?layout=1 取静态内存布局;之后只轮询动态字段 */ + var statusUrl = (window.systemStatusUrl + ? window.systemStatusUrl(false) + : (window.getBaseUrl() + '/system/status' + (window._memLayout ? '' : '?layout=1'))); + var resp = await fetch(statusUrl, { signal: statusAbortCtrl.signal }); if (resp.ok) { @@ -229,24 +235,41 @@ export async function pollStatus() { clearTimeout(pollTimer); // 自身 AbortController 触发的取消不算失败,静默返回 if (e && e.name === "AbortError") return; + // 先判定 OTA 是否进行中:OTA 期间所有失败都不计入 consecutivePollFailures, + // 也不触发诊断。避免"上传完成→finalize→重启"窗口期失败计数累积, + // 切回 IDLE 瞬间立即触发 5-fail 诊断,干扰进度显示。 + var lastDeviceBusy0 = isLastDeviceBusy(); + var sessionActive0 = window.otaSessionActive(); + var sessionFailed0 = window.otaSessionState === window.OtaSessionState.FAILED; + var otaOngoing0 = window.pollingPausedForUpload || + sessionActive0 || sessionFailed0 || + window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy0; // 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次, // 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。 - if (pageVisible) window.consecutivePollFailures++; - var lastDeviceBusy = isLastDeviceBusy(); + // OTA 期间也不递增:设备忙/重启无法响应是正常的,不该计入"诊断触发计数"。 + if (pageVisible && !otaOngoing0) window.consecutivePollFailures++; + var lastDeviceBusy = lastDeviceBusy0; // OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的) // 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈 // ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。 // FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。 - var sessionActive = window.otaSessionActive(); - var sessionFailed = window.otaSessionState === window.OtaSessionState.FAILED; - if (pageVisible && window.consecutivePollFailures >= 5 && !sessionActive && !window.isEsp32Rebooting && !window.currentOtaTarget && !lastDeviceBusy && !sessionFailed) { + var sessionActive = sessionActive0; + var sessionFailed = sessionFailed0; + // ★ OTA 期间(含暂停轮询、会话活跃、设备忙、正在重启、FAILED、当前 OTA 目标存在) + // 一律不触发诊断日志/UI 覆盖。这些 W 级日志会刷在日志区,与"上传 xx/xx" "重启中 Xs" + // "中继 N/M" 等进度行混在一起,用户反馈干扰 OTA 进度显示。 + // 之前条件漏了 pollingPausedForUpload,且 sessionActive 可能在 finalize/reboot 短窗口 + // 翻 false,导致 5 次失败后误触发诊断。这里把所有 OTA 相关态统一兜底。 + var otaOngoing = window.pollingPausedForUpload || + sessionActive || sessionFailed || + window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy; + if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) { addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); var diag = await diagnoseNetwork(); addLog('W', '诊断', diag); - updateConnectionUi(false, '未连接'); + updateConnectionUi(false, '诊断中'); window.applyDisconnectedRuntimeState(); - var cst = document.getElementById('connStatusTxt'); - if (cst) cst.textContent = '诊断中: ' + diag; + addLog('W', '诊断', '诊断结果: ' + diag); } // ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接" if (sessionFailed) { diff --git a/tools/js/ota/console-mode.js b/tools/js/ota/console-mode.js index c902f5c..97c8432 100644 --- a/tools/js/ota/console-mode.js +++ b/tools/js/ota/console-mode.js @@ -94,33 +94,41 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32'; persistFlashTarget(window.flashTarget); applySharedModeUi(); - syncBleFileFromSelectedTarget(); + /* 与 WiFi 一致:两路文件独立;切换只换「当前目标」面板 + 绑定该路文件到会话 */ if (window.connectionMode === 'ble') { window.switchStageFlow('ble'); if (typeof setActivePhase === 'function') { - setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota'); + setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota'); } + if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') { + window.bleOta.refreshTargetUi(); + } + /* 把当前目标对应的 esp32/gd32 固件绑到 BLE 会话(无文件则清空,避免误刷另一芯片) */ + syncBleFileFromSelectedTarget(); } else { window.switchStageFlow(window.flashTarget); } - if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') { - window.bleOta.refreshTargetUi(); - } }; window.handleSharedFileSelect = function (event, chip) { - var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null; - if (file) { - /* 传统 input 无持久 handle,刷新后无法自动恢复该次选择 */ - window.sharedFirmwareFiles[chip] = { file: file, handle: null }; - } - if (typeof window.handleFileSelect === 'function') { - window.handleFileSelect(event, chip); - } window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32'; persistFlashTarget(window.flashTarget); applySharedModeUi(); - setTimeout(syncBleFileFromSelectedTarget, 0); + /* 等 processFile(含 HEX→BIN)完成后再写入 shared 并 sync */ + var done = (typeof window.handleFileSelect === 'function') + ? Promise.resolve(window.handleFileSelect(event, chip)) + : Promise.resolve(); + done.then(function () { + var prepared = chip === 'gd32' ? window.gd32File : window.esp32File; + if (prepared) { + window.sharedFirmwareFiles[chip] = { file: prepared, handle: null }; + } + syncBleFileFromSelectedTarget(); + }).catch(function (err) { + if (typeof window.addLog === 'function') { + window.addLog('W', '文件', chip.toUpperCase() + ' 选择失败: ' + (err && err.message ? err.message : err)); + } + }); }; window.pickSharedFirmware = function (chip) { @@ -137,22 +145,25 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { }); }; Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () { - window.showOpenFilePicker = nativePicker; - if (!capturedHandle || typeof capturedHandle.getFile !== 'function') { - syncBleFileFromSelectedTarget(); - return; + /* 用 processFile 后的结果(GD32 HEX→BIN 后的 prepared),不要再塞回原始 .hex */ + var prepared = chip === 'gd32' ? window.gd32File : window.esp32File; + var handle = (chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle) || capturedHandle; + if (prepared) { + window.sharedFirmwareFiles[chip] = { file: prepared, handle: handle || null }; + } else if (capturedHandle) { + window.sharedFirmwareFiles[chip] = { + file: window.sharedFirmwareFiles[chip] && window.sharedFirmwareFiles[chip].file, + handle: capturedHandle + }; } - capturedHandle.getFile().then(function (file) { - window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle }; - syncBleFileFromSelectedTarget(); - }).catch(function () { - syncBleFileFromSelectedTarget(); - }); + syncBleFileFromSelectedTarget(); }).catch(function (err) { - window.showOpenFilePicker = nativePicker; if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') { - window.addLog('W', '文件', 'FSAPI 选择失败: ' + err.message); + window.addLog('W', '文件', 'FSAPI 选择失败: ' + (err.message || err)); } + }).then(function () { + /* 无论成败都还原原生 picker,避免二次选择失效 */ + window.showOpenFilePicker = nativePicker; }); } else { var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32'); @@ -184,60 +195,83 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { } }; + /** + * 按 flashTarget 把「该芯片一路」固件绑到 BLE 会话(复刻 WiFi:两路独立、切换不串文件)。 + * 优先 window.esp32File/gd32File(含 HEX→BIN 结果),其次 sharedFirmwareFiles / input。 + * 当前目标无文件时清空 BLE 会话,禁止继续显示/上传另一芯片固件。 + */ window.syncBleFileFromSelectedTarget = function () { - if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') { + if (window.connectionMode !== 'ble' || !window.bleOta) { return; } - var isGd32 = window.flashTarget === 'gd32'; - var chip = isGd32 ? 'gd32' : 'esp32'; - var suffix = isGd32 ? 'Gd32' : 'Esp32'; - var stored = window.sharedFirmwareFiles[chip]; - if (stored && stored.handle && typeof stored.handle.getFile === 'function') { - stored.handle.getFile().then(function (file) { - stored.file = file; - setBleFirmwareInputFile(file); - window.bleOta.notifyFileChange(file, stored.handle); - }).catch(function () { - if (stored.file) { - setBleFirmwareInputFile(stored.file); - window.bleOta.notifyFileChange(stored.file, stored.handle); + if (window.bleOta.isUploading && window.bleOta.isUploading()) { + return; + } + var chip = window.flashTarget === 'gd32' ? 'gd32' : 'esp32'; + var file = chip === 'gd32' ? window.gd32File : window.esp32File; + var handle = chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle; + var stored = window.sharedFirmwareFiles && window.sharedFirmwareFiles[chip] + ? window.sharedFirmwareFiles[chip] + : null; + if (!handle && stored && stored.handle) handle = stored.handle; + if (!file && stored && stored.file) file = stored.file; + + function bindBle(f, h) { + setBleFirmwareInputFile(f); + if (typeof window.bleOta.notifyFileChange === 'function') { + window.bleOta.notifyFileChange(f, h || null); + } + /* 会话 SHA 与对应芯片槽对齐(不跨芯片) */ + var slot = chip === 'gd32' ? window._gd32FileSha256 : window._esp32FileSha256; + if (window.bleOta.state) { + if (slot) window.bleOta.state.fileSha256 = slot; + } + } + function clearBleSession() { + if (typeof window.bleOta.clearFile === 'function') { + window.bleOta.clearFile(); + } else if (typeof window.bleOta.notifyFileChange === 'function') { + window.bleOta.notifyFileChange(null, null); + } + } + + if (file) { + bindBle(file, handle); + return; + } + if (handle && typeof handle.getFile === 'function') { + handle.getFile().then(function (fresh) { + if (chip === 'gd32' && fresh.name.toLowerCase().endsWith('.hex') && + typeof window.processFile === 'function') { + return Promise.resolve(window.processFile(fresh, 'gd32', { + silent: true, + skipHandlePersist: true + })).then(function () { + if (window.gd32File) bindBle(window.gd32File, handle); + else bindBle(fresh, handle); + }); } + if (chip === 'esp32') window.esp32File = fresh; + else window.gd32File = fresh; + if (stored) stored.file = chip === 'gd32' ? window.gd32File : window.esp32File; + bindBle(chip === 'gd32' ? window.gd32File : window.esp32File, handle); + }).catch(function () { + if (stored && stored.file) bindBle(stored.file, handle); + else clearBleSession(); }); return; } - if (stored && stored.file) { - setBleFirmwareInputFile(stored.file); - window.bleOta.notifyFileChange(stored.file, stored.handle || null); - return; - } + var suffix = chip === 'gd32' ? 'Gd32' : 'Esp32'; var input = document.getElementById('fileInput' + suffix); - var info = document.getElementById('fileInfo' + suffix); - var hasInfo = info && info.classList.contains('show'); - var file = input && input.files && input.files[0] ? input.files[0] : null; - if (file) { - window.sharedFirmwareFiles[chip] = { file: file, handle: null }; - setBleFirmwareInputFile(file); - window.bleOta.notifyFileChange(file, null); + var inputFile = input && input.files && input.files[0] ? input.files[0] : null; + if (inputFile) { + if (window.sharedFirmwareFiles) { + window.sharedFirmwareFiles[chip] = { file: inputFile, handle: null }; + } + bindBle(inputFile, null); return; } - if (!hasInfo) { - return; - } - var nameEl = document.getElementById('fileName' + suffix); - var typeEl = document.getElementById('fileType' + suffix); - var sizeEl = document.getElementById('fileSize' + suffix); - var bleInfo = document.getElementById('fileInfo'); - var bleName = document.getElementById('fileName'); - var bleType = document.getElementById('fileType'); - var bleSize = document.getElementById('fileSize'); - if (bleInfo) bleInfo.classList.add('show'); - if (bleName && nameEl) bleName.textContent = nameEl.textContent; - if (bleType && typeEl) bleType.textContent = typeEl.textContent; - if (bleSize && sizeEl) bleSize.textContent = sizeEl.textContent; - var packetCount = document.getElementById('packetCount'); - var sentCount = document.getElementById('sentCount'); - if (packetCount) packetCount.textContent = '-'; - if (sentCount) sentCount.textContent = '0'; + clearBleSession(); }; window.applySharedModeUi = function () { @@ -260,9 +294,15 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { if (el) el.style.display = ids[id] ? '' : 'none'; }); var hint = document.getElementById('readyHint'); + var autoBtn = document.getElementById('autoFlashBtn'); + if (autoBtn && isBle) { + autoBtn.textContent = isGd32 ? '一键刷写 GD32' : '一键刷写 ESP32'; + } if (hint) { if (isBle) { - hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。'; + hint.innerHTML = isGd32 + ? 'BLE 模式 · 目标 GD32:连接设备 → 选择 GD32 固件 → 一键刷写。' + : 'BLE 模式 · 目标 ESP32:连接设备 → 选择 ESP32 固件 → 一键刷写。'; } else if (isGd32) { hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。'; } else { diff --git a/tools/js/ota/file-handle.js b/tools/js/ota/file-handle.js index 3da3b1c..d1e5d79 100644 --- a/tools/js/ota/file-handle.js +++ b/tools/js/ota/file-handle.js @@ -20,6 +20,9 @@ window.esp32File = null; window.gd32File = null; window.esp32FileHandle = null; // FileSystemFileHandle for hot reload window.gd32FileHandle = null; // FileSystemFileHandle for hot reload +/** 两路各自一份 SHA:选文件时写入,上传时读取;互不切换、不共享 */ +window._esp32FileSha256 = null; +window._gd32FileSha256 = null; /** localStorage:升级目标等轻量偏好(刷新后恢复) */ var LS_FLASH_TARGET = 'dcu_ota_flash_target'; @@ -196,7 +199,7 @@ export async function resumePersistedFirmwareWithGesture(chip) { return false; } var file = await handle.getFile(); - applyRestoredFile(chip, handle, file, { silent: true }); + await applyRestoredFile(chip, handle, file, { silent: true }); window._pendingFwHandles[chip] = null; addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name); showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success'); @@ -218,11 +221,14 @@ function applyRestoredFile(chip, handle, file, options) { if (window.sharedFirmwareFiles) { window.sharedFirmwareFiles[chip] = { file: file, handle: handle }; } - processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})); - if (typeof window.syncBleFileFromSelectedTarget === 'function') { - window.syncBleFileFromSelectedTarget(); - } - safeUpdateUploadButtonLabel(); + /* 等 SHA 写入对应槽后再 sync BLE,避免二次重算 */ + return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {}))) + .then(function () { + if (typeof window.syncBleFileFromSelectedTarget === 'function') { + window.syncBleFileFromSelectedTarget(); + } + safeUpdateUploadButtonLabel(); + }); } /** 无权限时仍展示上次文件名,提示点上传区一键恢复 */ @@ -269,7 +275,7 @@ export async function restorePersistedFirmwareSelection() { var perm = await queryHandlePermission(handle); if (perm === 'granted') { var file = await handle.getFile(); - applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true }); + await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true }); window._pendingFwHandles[chip] = null; addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name); continue; @@ -296,13 +302,13 @@ export async function restorePersistedFirmwareSelection() { export function handleFileSelect(event, chip) { var file = event.target.files[0]; - if (!file) return; + if (!file) return Promise.resolve(); /* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */ if (chip === 'esp32') window.esp32FileHandle = null; else window.gd32FileHandle = null; window._pendingFwHandles[chip] = null; persistFileHandle(chip, null); - processFile(file, chip); + return processFile(file, chip); } // 通过 File System Access API 选择文件(支持热重载) @@ -325,9 +331,14 @@ export async function pickFileViaFSAPI(chip) { return false; } try { + /* GD32 常为 .hex:同时声明 octet-stream / text,避免对话框里灰掉选不了 */ var acceptMap = chip === 'esp32' ? { 'application/octet-stream': ['.bin'] } - : { 'application/octet-stream': ['.bin', '.hex'] }; + : { + 'application/octet-stream': ['.bin', '.hex'], + 'text/plain': ['.hex'], + 'application/x-intel-hex': ['.hex'] + }; var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle; if (!prevHandle) { prevHandle = window._pendingFwHandles[chip] || null; @@ -341,7 +352,18 @@ export async function pickFileViaFSAPI(chip) { if (prevHandle) { opts.startIn = prevHandle; } - var handles = await window.showOpenFilePicker(opts); + var handles; + try { + handles = await window.showOpenFilePicker(opts); + } catch (pickErr) { + /* 失效 handle 作 startIn 时部分浏览器直接抛错,去掉后重试 */ + if (opts.startIn && pickErr && pickErr.name !== 'AbortError') { + delete opts.startIn; + handles = await window.showOpenFilePicker(opts); + } else { + throw pickErr; + } + } if (handles && handles.length > 0) { var handle = handles[0]; var file = await handle.getFile(); @@ -356,8 +378,8 @@ export async function pickFileViaFSAPI(chip) { return true; } } catch (e) { - if (e.name !== 'AbortError') { - addLog('W', '文件', 'FSAPI 选择失败: ' + e.message); + if (e && e.name !== 'AbortError') { + addLog('W', '文件', 'FSAPI 选择失败: ' + (e.message || e)); } } return false; @@ -484,81 +506,58 @@ export async function convertHexFileToBin(file) { return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr }; } -// ==================== SHA-256 缓存 ==================== -// 选文件时就把 SHA-256 算好并缓存(同时记录 file meta),上传前 -// 若 handle.getFile() 拿回的 fresh file meta 未变就直接复用, -// 避免每次点上传都重算 30–80ms 的 crypto.subtle.digest。 -// 缓存 key:size + lastModified + name 三项均一致(lastModified 在 -// 文件 rebuild 后必变,是浏览器给的最可靠 rebuild 信号)。 +// ==================== SHA-256:ESP32 / GD32 各一份,互不共用、不互切 ==================== +function paintShaUi(chip, hashHex, statusText) { + var t = statusText != null ? statusText : (hashHex || '-'); + if (chip === 'esp32') { + var e = document.getElementById('fileSha256Esp32'); + if (e) e.textContent = t; + } else if (chip === 'gd32') { + var g = document.getElementById('fileSha256Gd32'); + if (g) g.textContent = t; + } + /* BLE 面板只显示当前目标槽,避免算 ESP32 时盖掉 GD32 的展示 */ + var cur = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32'; + if (chip === cur) { + var ble = document.getElementById('fileSha256'); + if (ble) ble.textContent = t; + } +} + /** - * @description 计算 file 的 SHA-256 并写入缓存(sha + meta)。 - * GD32 UI 无 SHA 显示行;ESP32 同步更新 #fileSha256Esp32。 - * @param {File} file 目标文件 - * @param {'esp32'|'gd32'} chip 通道 - * @param {ArrayBuffer=} optArrayBuffer 已读的 arrayBuffer(避免重复读取) - * @returns {Promise} hex 大写 SHA,失败返回 null + * 选文件时算一次,写入对应芯片槽;上传时直接读槽。 + * 不做跨芯片复用、不与另一路互切。 */ -function _computeAndCacheFileSha(file, chip, optArrayBuffer) { - var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; - var shaElem = document.getElementById('fileSha256' + suffix); // GD32 可能为 null - if (shaElem) shaElem.textContent = '计算中...'; +export function computeFileSha(file, chip, optArrayBuffer) { + if (!file || (chip !== 'esp32' && chip !== 'gd32')) { + return Promise.resolve(null); + } + paintShaUi(chip, null, '计算中...'); var bufPromise = optArrayBuffer ? Promise.resolve(optArrayBuffer) : file.arrayBuffer(); - return bufPromise.then(function(buf) { + return bufPromise.then(function (buf) { return crypto.subtle.digest('SHA-256', buf); - }).then(function(hashBuffer) { + }).then(function (hashBuffer) { var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase(); - if (shaElem) shaElem.textContent = hashHex; - var meta = { size: file.size, lastModified: file.lastModified, name: file.name }; - if (chip === 'esp32') { - window._esp32FileSha256 = hashHex; - window._esp32FileShaMeta = meta; - } else { - window._gd32FileSha256 = hashHex; - window._gd32FileShaMeta = meta; - } + if (chip === 'esp32') window._esp32FileSha256 = hashHex; + else window._gd32FileSha256 = hashHex; + paintShaUi(chip, hashHex); addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex); return hashHex; - }).catch(function(err) { - if (shaElem) shaElem.textContent = '计算失败: ' + err.message; + }).catch(function (err) { + paintShaUi(chip, null, '计算失败: ' + err.message); + if (chip === 'esp32') window._esp32FileSha256 = null; + else window._gd32FileSha256 = null; addLog('W', '文件', chip.toUpperCase() + ' SHA-256 计算失败: ' + err.message); - if (chip === 'esp32') { window._esp32FileSha256 = null; window._esp32FileShaMeta = null; } - else { window._gd32FileSha256 = null; window._gd32FileShaMeta = null; } return null; }); } -/** - * @description 确保 file 的 SHA-256 已在缓存中:命中直接返回,不命中则重算并缓存。 - * @param {File} file 目标文件 - * @param {'esp32'|'gd32'} chip 通道 - * @param {ArrayBuffer=} optArrayBuffer 命中时不用,未命中时传入可避免重复读文件 - * @returns {Promise} hex 大写 SHA - */ -export function ensureFileShaCached(file, chip, optArrayBuffer) { - var cachedMeta = chip === 'esp32' ? window._esp32FileShaMeta : window._gd32FileShaMeta; - var cachedSha = chip === 'esp32' ? window._esp32FileSha256 : window._gd32FileSha256; - if (cachedSha && cachedMeta && - cachedMeta.size === file.size && - cachedMeta.lastModified === file.lastModified && - cachedMeta.name === file.name) { - // 命中:把 UI 上的 SHA 同步刷一下(幂等),避免其它代码误清 - var shaElem = document.getElementById(chip === 'esp32' ? 'fileSha256Esp32' : 'fileSha256Gd32'); - if (shaElem) shaElem.textContent = cachedSha; - addLog('I', '文件', chip.toUpperCase() + ' 复用已缓存 SHA-256(文件未变)'); - return Promise.resolve(cachedSha); - } - return _computeAndCacheFileSha(file, chip, optArrayBuffer); -} - // ==================== 文件处理 ==================== /** - * @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 - * 对 GD32 .hex 走异步转换;返回 Promise 以便调用方 await - * (避免上游用 setTimeout 硬等,见 upload-esp32.js)。 + * @description 处理用户选择的固件文件(HEX→BIN、算 SHA 写入对应芯片槽)。 * @param {File} file * @param {'esp32'|'gd32'} chip * @param {{silent?: boolean, skipHandlePersist?: boolean}=} options - * silent: 恢复场景不弹 toast;skipHandlePersist: 已从 IDB 读出时不再写回 * @returns {Promise} */ export function processFile(file, chip, options) { @@ -569,52 +568,61 @@ export function processFile(file, chip, options) { if (!opts.skipHandlePersist) { persistFlashTarget(chip); } - /* 元数据始终更新,便于刷新后展示「上次文件名」 */ if (file && file.name) { persistFileMeta(chip, file); } + + function paintFileInfo(name, type, sizeText) { + var info = document.getElementById('fileInfo' + suffix); + var nameEl = document.getElementById('fileName' + suffix); + var typeEl = document.getElementById('fileType' + suffix); + var sizeEl = document.getElementById('fileSize' + suffix); + if (info) info.classList.add('show'); + if (nameEl) nameEl.textContent = name; + if (typeEl) typeEl.textContent = type; + if (sizeEl) sizeEl.textContent = sizeText; + } + if (chip === 'gd32' && isHexByName) { - // GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象 - return convertHexFileToBin(file).then(function(result) { + return convertHexFileToBin(file).then(function (result) { if (!result.converted) { - // 文件名 .hex 但内容不像,直接用原始文件 addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理'); window.gd32File = file; - document.getElementById('fileInfo' + suffix).classList.add('show'); - document.getElementById('fileName' + suffix).textContent = file.name; - document.getElementById('fileType' + suffix).textContent = 'Binary(?)'; - document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); + paintFileInfo(file.name, 'Binary(?)', formatBytes(file.size)); safeUpdateUploadButtonLabel(); - return ensureFileShaCached(file, 'gd32'); + return computeFileSha(file, 'gd32'); } window.gd32File = result.file; var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name); addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size)); if (!silent) showToast('HEX 已自动转换为 BIN', 'success'); - document.getElementById('fileInfo' + suffix).classList.add('show'); - document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; - document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; - document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size); + paintFileInfo( + result.file.name + ' (由 ' + file.name + ' 转换)', + 'Binary (HEX转换)', + formatBytes(result.file.size) + ); safeUpdateUploadButtonLabel(); - return ensureFileShaCached(result.file, 'gd32'); - }).catch(function(err) { - addLog('E', '文件', 'HEX 解析失败: ' + err.message); - if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error'); + return computeFileSha(result.file, 'gd32'); + }).catch(function (err) { + addLog('E', '文件', 'HEX 解析失败: ' + (err && err.message ? err.message : err)); + if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error'); window.gd32File = null; - document.getElementById('fileInfo' + suffix).classList.remove('show'); + window._gd32FileSha256 = null; + var info = document.getElementById('fileInfo' + suffix); + if (info) info.classList.remove('show'); safeUpdateUploadButtonLabel(); - throw err; // 让上游 await 感知失败 + throw err; }); } - // .bin 文件或 ESP32 文件: 直接使用 + if (chip === 'esp32') window.esp32File = file; else window.gd32File = file; - document.getElementById('fileInfo' + suffix).classList.add('show'); - document.getElementById('fileName' + suffix).textContent = file.name; - document.getElementById('fileType' + suffix).textContent = - isHexByName ? 'Intel HEX' : 'Binary'; - document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); + paintFileInfo( + file.name, + isHexByName ? 'Intel HEX' : 'Binary', + formatBytes(file.size) + ); safeUpdateUploadButtonLabel(); if (silent) { addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')'); @@ -622,20 +630,20 @@ export function processFile(file, chip, options) { addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); showToast(chip.toUpperCase() + ' 文件已选择', 'success'); } - // 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算), - // 上传时若文件未变 ensureFileShaCached 命中直接复用。 - return Promise.resolve(ensureFileShaCached(file, chip)); + return Promise.resolve(computeFileSha(file, chip)); } /** @description 清除已选固件文件并重置对应 UI 状态。 */ export function clearFile(chip) { var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; if (chip === 'esp32') { - window.esp32File = null; window.esp32FileHandle = null; - window._esp32FileSha256 = null; window._esp32FileShaMeta = null; + window.esp32File = null; + window.esp32FileHandle = null; + window._esp32FileSha256 = null; } else { - window.gd32File = null; window.gd32FileHandle = null; - window._gd32FileSha256 = null; window._gd32FileShaMeta = null; + window.gd32File = null; + window.gd32FileHandle = null; + window._gd32FileSha256 = null; } window._pendingFwHandles[chip] = null; persistFileHandle(chip, null); diff --git a/tools/js/ota/progress.js b/tools/js/ota/progress.js index 4c903fa..45fb1c9 100644 --- a/tools/js/ota/progress.js +++ b/tools/js/ota/progress.js @@ -156,8 +156,7 @@ export function cleanupAfterOtaFailure(reason) { window.clearOtaSessionResiduals({ keepErrorStages: true, statusText: reason || '失败', - errorStyle: true, - clearCacheDisplay: true + errorStyle: true }); // 2. 阶段指示器标错 diff --git a/tools/js/ota/session.js b/tools/js/ota/session.js index 1510da3..ced4dba 100644 --- a/tools/js/ota/session.js +++ b/tools/js/ota/session.js @@ -228,16 +228,6 @@ export function clearOtaSessionResiduals(opts) { : '待机'; } - // 设备侧缓存展示:失败后不要继续显示“已满缓存像成功” - var mOtaCache = document.getElementById('mOtaCache'); - if (mOtaCache && opts.clearCacheDisplay) { - mOtaCache.textContent = '0 / 216 KB'; - mOtaCache.className = 'v'; - } - var sOtaStored = document.getElementById('sOtaStored'); - if (sOtaStored && opts.clearCacheDisplay) { - sOtaStored.textContent = '--'; - } } /** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */ @@ -245,8 +235,7 @@ export function otaSessionReset() { clearOtaSessionResiduals({ keepErrorStages: false, statusText: '待机', - errorStyle: false, - clearCacheDisplay: false + errorStyle: false }); if (otaSessionState !== OtaSessionState.IDLE) { otaSessionState = OtaSessionState.IDLE; diff --git a/tools/js/ota/stages.js b/tools/js/ota/stages.js index b2ce360..ca2d3c8 100644 --- a/tools/js/ota/stages.js +++ b/tools/js/ota/stages.js @@ -19,7 +19,7 @@ * window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset * window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress */ -import { formatBytes, formatUptime } from '../common/utils.js'; +import { formatUptime } from '../common/utils.js'; import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js'; import { setActivePhase, setProgress } from './progress.js'; import { otaSessionReset, OtaSessionState } from './session.js'; @@ -107,7 +107,7 @@ function setDisplayedRelayState(rs) { } /** - * 常驻仪表盘:堆、连接、健康、版本、温湿度、IP/RSSI、OTA cache。 + * 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。 * @param {object} data /system/status JSON * @param {object} ota data.ota * @param {object} wifi data.wifi @@ -150,8 +150,87 @@ function applyDashboardUi(data, ota, wifi) { var mTempEl = document.getElementById('mTemp'); if (mTempEl) { - mTempEl.textContent = - Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--'; + /* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */ + if (Number.isFinite(data.temperature)) { + var tC = data.temperature; + mTempEl.textContent = tC.toFixed(1) + '\u00B0C'; + if (tC >= 100) { + mTempEl.className = 'v err'; + } else if (tC >= 80) { + mTempEl.className = 'v warn'; + } else { + mTempEl.className = 'v ok'; + } + mTempEl.title = 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)'; + } else { + mTempEl.textContent = '--'; + mTempEl.className = 'v'; + mTempEl.title = '温度无效或未采样'; + } + } + /* GD32 ADC:enabled 来自 HELLO_ACK 功能位(GD32 编译开关),ESP32 无本地开关 */ + var ga = data.gd32_adc || null; + var feat = data.gd32_features || null; + var mGd32TempEl = document.getElementById('mGd32Temp'); + var mVccEl = document.getElementById('mVcc'); + var mVddaEl = document.getElementById('mVdda'); + + function setMetricUi(el, text, cls, title) { + if (!el) return; + el.textContent = text; + el.className = cls || 'v'; + if (title) el.title = title; + } + + if (mGd32TempEl || mVccEl || mVddaEl) { + if (ga && ga.enabled === false) { + var offTitle = 'GD32 CONFIG_SPI_ADC_FEEDBACK_ENABLE=0(HELLO/PING 身份)'; + setMetricUi(mGd32TempEl, '关闭', 'v', offTitle); + setMetricUi(mVccEl, '关闭', 'v', offTitle); + setMetricUi(mVddaEl, '关闭', 'v', offTitle); + } else if (ga && Number.isFinite(ga.temperature) && ga.fresh) { + setMetricUi(mGd32TempEl, ga.temperature.toFixed(1) + '\u00B0C', + ga.valid ? 'v ok' : 'v warn', + '片内温度 · seq=' + (ga.seq || 0) + ' age=' + (ga.age_ms || 0) + 'ms' + + (ga.valid ? '' : ' (valid=0)')); + /* VCC:status 已带 vcc;旧固件仅 vin_adc 时本地 *11(47k/4.7k) */ + var vcc = Number.isFinite(ga.vcc) ? ga.vcc + : (Number.isFinite(ga.vin_adc) ? ga.vin_adc * 11.0 : NaN); + if (Number.isFinite(vcc) && vcc > 0.5) { + setMetricUi(mVccEl, vcc.toFixed(1) + 'V', ga.valid ? 'v ok' : 'v warn', + '输入母线 VCC(PA6 分压反推)· vin_adc=' + + (Number.isFinite(ga.vin_adc) ? ga.vin_adc.toFixed(3) + 'V' : '--')); + } else { + setMetricUi(mVccEl, '--', 'v', '无 VIN 采样'); + } + if (Number.isFinite(ga.vdda) && ga.vdda > 0.5) { + setMetricUi(mVddaEl, ga.vdda.toFixed(2) + 'V', ga.valid ? 'v ok' : 'v warn', + 'MCU 模拟供电 VDDA(片内 VREFINT 反推)'); + } else { + setMetricUi(mVddaEl, '--', 'v', '无 VDDA 采样'); + } + } else if (ga && Number.isFinite(ga.temperature)) { + setMetricUi(mGd32TempEl, ga.temperature.toFixed(1) + '\u00B0C (旧)', 'v warn', '超过 5s 未更新'); + var vccOld = Number.isFinite(ga.vcc) ? ga.vcc + : (Number.isFinite(ga.vin_adc) ? ga.vin_adc * 11.0 : NaN); + setMetricUi(mVccEl, Number.isFinite(vccOld) ? (vccOld.toFixed(1) + 'V (旧)') : '--', + 'v warn', '超过 5s 未更新'); + setMetricUi(mVddaEl, + Number.isFinite(ga.vdda) ? (ga.vdda.toFixed(2) + 'V (旧)') : '--', + 'v warn', '超过 5s 未更新'); + } else if (ga && ga.enabled === true) { + var waitTitle = 'GD32 已开 0xC4,等待首帧'; + setMetricUi(mGd32TempEl, '等待…', 'v', waitTitle); + setMetricUi(mVccEl, '等待…', 'v', waitTitle); + setMetricUi(mVddaEl, '等待…', 'v', waitTitle); + } else { + var idleTitle = feat && feat.known === false + ? '尚未 HELLO 身份或旧 GD32(无 feat_map)' + : '等待 HELLO / 0xC4'; + setMetricUi(mGd32TempEl, '--', 'v', idleTitle); + setMetricUi(mVccEl, '--', 'v', idleTitle); + setMetricUi(mVddaEl, '--', 'v', idleTitle); + } } var uptimeEl = document.getElementById('pillUptime'); if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec); @@ -160,18 +239,6 @@ function applyDashboardUi(data, ota, wifi) { var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--'; 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'; - mOtaCache.className = 'v ok'; - } else { - mOtaCache.textContent = '0 / 216 KB'; - mOtaCache.className = 'v'; - } - } } /** @@ -183,14 +250,11 @@ function applyGd32OtaSessionUi(ota) { var rs = ota.relay_state; var sysState = ota.sys_state || ''; var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; - var isGd32Storing = sysState === 'GD32_STORING' || sysStateCode === 1; var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3; var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4; var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255; - 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; + /* 进度以 relay_seq/relay_total 为准;不再用 stored_size 做「OTA 缓存」展示 */ var relaySeq = ota.relay_seq || 0; var relayTotal = ota.relay_total_packets || 0; var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0; @@ -321,15 +385,7 @@ function applyGd32OtaSessionUi(ota) { return; } - if (isGd32Storing || (total > 0 && stored < total && !window.isUploading && - window.otaSessionState !== OtaSessionState.DONE && - window.otaSessionState !== OtaSessionState.IDLE)) { - setActivePhase('gd32Ota'); - var storeOverall = Math.min(45, Math.round(storageProgress * 0.45)); - if (!window.isUploading) { - setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total)); - } - } + /* 不再用 stored_size 做「OTA 缓存」展示;上传走 XHR,中继走 relay_seq。 */ } /** diff --git a/tools/js/ota/upload-esp32.js b/tools/js/ota/upload-esp32.js index 2c9abb9..a943734 100644 --- a/tools/js/ota/upload-esp32.js +++ b/tools/js/ota/upload-esp32.js @@ -30,7 +30,7 @@ import { startUploadWaitHeartbeat } from './session.js'; import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js'; -import { ensureFileShaCached } from './file-handle.js'; +import { computeFileSha } from './file-handle.js'; function isOtaBusyError(status, detail) { var text = (detail || '').toLowerCase(); @@ -151,40 +151,30 @@ export async function startUpload(chip) { break; } } - // 每次上传前尝试重新从本地读取文件 + // 热重载:有 FSAPI handle 则重新读盘(本地编译会改同路径固件) var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle; if (handle) { - // 有 FileSystemFileHandle,调用 getFile() 获取最新内容 try { var freshFile = await handle.getFile(); - if (chip === 'esp32') { - window.esp32File = freshFile; - file = freshFile; + if (chip === 'gd32' && freshFile.name.toLowerCase().endsWith('.hex')) { + /* HEX 必须按最新源文件再转 BIN,不能用选文件时的旧 prepared */ + await window.processFile(freshFile, 'gd32', { silent: true, skipHandlePersist: true }); + file = window.gd32File; } else { - if (freshFile.name.toLowerCase().endsWith('.hex')) { - // processFile 现已返回 Promise,await 即可拿到转换后的 gd32File, - // 不再用 setTimeout 硬等(避免 HEX 稍大时拿到 null)。 - await window.processFile(freshFile, 'gd32'); - file = window.gd32File; - } else { - window.gd32File = freshFile; - file = freshFile; - } + if (chip === 'esp32') window.esp32File = freshFile; + else window.gd32File = freshFile; + file = freshFile; } - addLog('I', '文件', '已重新读取本地文件: ' + file.name + ' (' + formatBytes(file.size) + ')'); } catch (e) { addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本'); } } else { - // 没有 handle,尝试直接读取当前 File 对象 try { await file.arrayBuffer(); } catch (e) { - // File 引用已失效,提示用户重新选择 addLog('E', '文件', '本地文件已变更或失效,请重新选择文件'); showToast('文件已变更,请重新选择', 'error'); window.clearFile(chip); - // ★ 状态机:文件失效属于会话失败 cleanupAfterOtaFailure('本地文件失效'); return; } @@ -209,10 +199,14 @@ export async function startUpload(chip) { showToast('开始上传固件到 ' + chip.toUpperCase(), 'success'); try { var arrayBuffer = await file.arrayBuffer(); - // SHA-256:processFile 阶段已算好并缓存;这里若 handle.getFile() 拿回的 - // fresh file meta 未变就直接复用(命中 30–100ms/MB 的计算); - // 用户 rebuild 使 lastModified 变化时 ensureFileShaCached 自动 fallback 到重算。 - await ensureFileShaCached(file, chip, arrayBuffer); + /* 上传前始终按本次内容重算 SHA(本地固件会改,不能沿用选文件时的旧值) */ + var savedSha = await computeFileSha(file, chip, arrayBuffer); + if (!savedSha) { + addLog('E', '文件', chip.toUpperCase() + ' SHA-256 计算失败'); + showToast('SHA-256 计算失败', 'error'); + cleanupAfterOtaFailure('SHA-256 计算失败'); + return; + } var fileSize = file.size; var body, url = window.getBaseUrl() + '/ota/upload'; if (chip === 'esp32') { diff --git a/tools/js/ota/upload-gd32.js b/tools/js/ota/upload-gd32.js index c0bfede..e72fbe6 100644 --- a/tools/js/ota/upload-gd32.js +++ b/tools/js/ota/upload-gd32.js @@ -105,7 +105,7 @@ export function markGd32OtaUiComplete(msg) { if (typeof window.updateUploadButtonLabel === 'function') { window.updateUploadButtonLabel(); } - addLog('I', 'OTA', msg || 'GD32 刷写完成'); + /* 完成文案以设备 [I][OTA] GD32 OTA完成 为准,前端只 toast/进度,避免日志双份 */ showToast(msg || 'GD32 刷写完成', 'success'); } diff --git a/tools/parse_map_static_mem.py b/tools/parse_map_static_mem.py new file mode 100644 index 0000000..aa9b39b --- /dev/null +++ b/tools/parse_map_static_mem.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Print ESP32-C3 static memory breakdown from build map + ELF (nm). + +Usage (from repo or any cwd): + python tools/parse_map_static_mem.py + python tools/parse_map_static_mem.py --elf build/ESP32_COMM.elf +""" +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_MAP = ROOT / "build" / "ESP32_COMM.map" +DEFAULT_ELF = ROOT / "build" / "ESP32_COMM.elf" + +# ESP32-C3 DRAM / IRAM windows used by app (after 16KB i-cache) +DRAM_LO, DRAM_HI = 0x3FC80000, 0x3FCE0000 +IRAM_LO, IRAM_HI = 0x40380000, 0x403E0000 + + +def demangle(name: str) -> str: + """Lightweight Itanium demangle for common ESP app symbols.""" + # _ZL13s_status_json -> s_status_json + m = re.match(r"^_ZL\d+(.+)$", name) + if m: + return m.group(1) + # _ZZ20can_feedback_handler...E15s_feedback_json + m = re.search(r"E\d+([A-Za-z_][A-Za-z0-9_]*)$", name) + if m and name.startswith("_ZZ"): + return m.group(1) + " (fn-static)" + # _ZN12_GLOBAL__N_1L6s_workE + m = re.match(r"^_ZN\d+_GLOBAL__N_\d+L\d+(.+)E$", name) + if m: + return m.group(1) + " (anon)" + # _ZZN12Esp32OtaCore23computeShaFromPartitionEvE14s_sha_read_buf + m = re.search(r"E\d+([A-Za-z_][A-Za-z0-9_]*)$", name) + if m and name.startswith("_ZZN"): + return m.group(1) + " (method-static)" + # singleton: _ZZN8DebugLog8instanceEvE4inst + m = re.match(r"^_ZZN(\d+)(.+)$", name) + if m and "instanceEvE" in name: + # extract class-ish + body = name + cm = re.search(r"_ZZN\d+([A-Za-z0-9_]+)\d+instance", body) + if cm: + return cm.group(1) + "::instance" + return "singleton inst" + return name + + +def find_nm() -> str | None: + which = shutil.which("riscv32-esp-elf-nm") + if which: + return which + roots = [ + Path(r"C:\Espressif\tools\riscv32-esp-elf"), + Path(r"F:\software\.espressif\tools\riscv32-esp-elf"), + ] + for root in roots: + if not root.exists(): + continue + matches = sorted(root.glob("**/riscv32-esp-elf-nm.exe")) + if matches: + return str(matches[-1]) + return None + + +def parse_syms(map_text: str) -> dict[str, int]: + out = {} + for name in ( + "_iram_start", + "_iram_end", + "_data_start", + "_data_end", + "_bss_start", + "_bss_end", + "_heap_start", + ): + m = re.search(rf"0x([0-9a-fA-F]+)\s+{name}\s*=", map_text) + if m: + out[name] = int(m.group(1), 16) + return out + + +def run_nm(nm: str, elf: Path) -> list[tuple[int, int, str, str]]: + """Return list of (addr, size, type, name).""" + # -S size, --size-sort, -t x hex + r = subprocess.run( + [nm, "-S", "--size-sort", "-t", "x", str(elf)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + rows = [] + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) < 4: + continue + try: + addr = int(parts[0], 16) + size = int(parts[1], 16) + except ValueError: + continue + typ = parts[2] + name = parts[3] + rows.append((addr, size, typ, name)) + return rows + + +def top_in_range( + rows: list[tuple[int, int, str, str]], + lo: int, + hi: int, + types: set[str], + n: int = 20, + min_size: int = 64, +) -> list[tuple[int, str, str]]: + picked = [] + for addr, size, typ, name in rows: + if typ not in types: + continue + if size < min_size: + continue + if not (lo <= addr < hi): + continue + # skip linker bookkeeping extremes + if name.startswith("_") and name.endswith(("_start", "_end", "_length")): + if size > 0x10000: + continue + picked.append((size, typ, name)) + picked.sort(key=lambda x: -x[0]) + return picked[:n] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--map", type=Path, default=DEFAULT_MAP) + ap.add_argument("--elf", type=Path, default=DEFAULT_ELF) + ap.add_argument("-n", type=int, default=18, help="top N symbols per section") + args = ap.parse_args() + + if not args.map.exists(): + print(f"map not found: {args.map}", file=sys.stderr) + return 1 + + text = args.map.read_text(encoding="utf-8", errors="replace") + syms = parse_syms(text) + + def span(a: str, b: str) -> int: + if a not in syms or b not in syms: + return 0 + return max(0, syms[b] - syms[a]) + + iram = span("_iram_start", "_iram_end") + data = span("_data_start", "_data_end") + bss = span("_bss_start", "_bss_end") + heap_start = syms.get("_heap_start", 0) + static_to_heap = max(0, heap_start - DRAM_LO) if heap_start else iram + data + bss + + print("=== Linker section totals (authoritative) ===") + print(f" IRAM : {iram:7d} ({iram/1024:6.1f} KB) code in RAM (WiFi/BT/ISR/tlsf…)") + print(f" .data : {data:7d} ({data/1024:6.1f} KB) initialized globals/singletons") + print(f" .bss : {bss:7d} ({bss/1024:6.1f} KB) zeroed static buffers") + print(f" static→heap : {static_to_heap:7d} ({static_to_heap/1024:6.1f} KB)") + if heap_start: + print(f" _heap_start : 0x{heap_start:08x}") + room = max(0, DRAM_HI - heap_start) + print(f" DRAM window after heap_start (to SOC_DRAM_HIGH): {room} ({room/1024:.1f} KB)") + print(f" sum check IRAM+data+bss = {iram+data+bss} (should ≈ static→heap on C3)") + + nm = find_nm() + if not args.elf.exists(): + print(f"\nELF not found: {args.elf} (skip symbol tops)") + return 0 + if not nm: + print("\nriscv32-esp-elf-nm not found (skip symbol tops)") + return 0 + + print(f"\nusing nm: {nm}") + rows = run_nm(nm, args.elf) + + sections = [ + ("IRAM code (largest functions)", IRAM_LO, IRAM_HI, set("tT")), + (".data (largest objects)", DRAM_LO, DRAM_HI, set("dD")), + (".bss (largest objects)", DRAM_LO, DRAM_HI, set("bB")), + ] + for title, lo, hi, types in sections: + print(f"\n=== Top {args.n} {title} ===") + tops = top_in_range(rows, lo, hi, types, n=args.n) + # for .bss prefer heap_start range if known + if "b" in {t.lower() for t in types} and heap_start: + bss_lo = syms.get("_bss_start", DRAM_LO) + bss_hi = heap_start + tops = top_in_range(rows, bss_lo, bss_hi, types, n=args.n) + if "d" in {t.lower() for t in types} and "_data_start" in syms: + tops = top_in_range( + rows, + syms["_data_start"], + syms.get("_data_end", DRAM_HI), + types, + n=args.n, + ) + total = sum(s for s, _, _ in tops) + for size, typ, name in tops: + print( + f" {size:6d} {size/1024:5.1f}KB {typ} " + f"{demangle(name):40s} {name}" + ) + print(f" (listed sum {total} / {total/1024:.1f} KB)") + + print( + "\nTip: runtime /system/status memory.{iram,data,bss} matches linker totals; " + "symbol tops are build-time only." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())