refactor(debug): 重构调试日志环形缓冲区管理

- 将日志环形缓冲区从静态数组改为动态堆分配
- 实现启动时大容量缓冲区和运行时小容量缓冲区的切换机制
- 添加日志环满时优先保留错误和警告信息的清理策略
- 优化内存使用,在日志为空时避免不必要的内存分配
- 移除调试日志中的冗余字段显示

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模式配置位置
This commit is contained in:
小龙虾
2026-07-20 03:10:42 +08:00
parent b807645a20
commit 00b716c8db
66 changed files with 4803 additions and 1074 deletions
+4 -4
View File
@@ -327,7 +327,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器)
| POST | `/ota/relay` | 触发 GD32 中继 | - | `{"status":"ok"}` | | POST | `/ota/relay` | 触发 GD32 中继 | - | `{"status":"ok"}` |
| GET | `/system/status` | 系统状态 JSON | - | 含版本/WiFi/OTA/温度/堆/GD32 版本及**OtaCoordinator 系统级状态**等 | | GET | `/system/status` | 系统状态 JSON | - | 含版本/WiFi/OTA/温度/堆/GD32 版本及**OtaCoordinator 系统级状态**等 |
| GET | `/logs` | 拉取调试日志 | - | JSON 数组字符串 | | 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 | `/spi/send` | SPI 直接发送 | `{"type":"01","data":"00AC","timeout_ms":1000}` | `{"status":"ok","resp_type":".."}` |
| POST | `/restart` | 重启 ESP32 | - | 500ms 后 `esp_restart()` | | 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 curl http://192.168.31.1/system/status | jq
# 发送 CAN 帧 # 发送 CAN 帧(单帧也走 send_batch)
curl -X POST http://192.168.31.1/can/send \ curl -X POST http://192.168.31.1/can/send_batch \
-H "Content-Type: application/json" \ -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 直接发送 # SPI 直接发送
curl -X POST http://192.168.31.1/spi/send \ curl -X POST http://192.168.31.1/spi/send \
+3 -3
View File
@@ -14,10 +14,10 @@ idf_component_register(
"server/ble_ota_host.cpp" # BLE OTA GATT/GAP/广播/Host "server/ble_ota_host.cpp" # BLE OTA GATT/GAP/广播/Host
"monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检) "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检)
"monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务 "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/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
"monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享) "monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子
"monitor/stack_overflow_hook.cpp" # FreeRTOS 栈溢出钩子(canary 触发时 UART 硬写告警)
INCLUDE_DIRS INCLUDE_DIRS
"." "."
"core" "core"
+23 -22
View File
@@ -10,6 +10,12 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "version.h" #include "version.h"
// ============================================================
// 功能开关(ESP32 本机;GD32 侧开关随 HELLO/PING 身份块上报,无需镜像)
// ============================================================
// 0 = AP 热点,1 = STA 连路由器
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
// ============================================================ // ============================================================
// 固件版本(由 version.h 自动生成,此处提供 C++ constexpr 访问) // 固件版本(由 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_BUILD_TIME = BUILD_TIME; ///< 编译时间字符串(自动生成)
inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(自动生成) 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 模式配置(连接路由器) // WiFi STA 模式配置(连接路由器)
// ============================================================ // ============================================================
@@ -47,19 +46,21 @@ inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服
// ============================================================ // ============================================================
// 任务栈大小(字节)— 2026-07 按板端 task_stack 水位收敛 // 任务栈大小(字节)— 2026-07 按板端 task_stack 水位收敛
// 规则:peak_used 实测 + 至少 ~1.5–2KB 余量;httpd 路径最重单独放宽。 // 规则:S ≈ peak_used + 1.5~2KB;偏松的收、偏紧的不砍。
// 实测(空闲/常规):http peak~5KB, wifi_ota~1.4KB, ota_relay~1KB, // 板测峰值量级(常规/OTA):
// health~1.2KB, cpu_stats~2KB, ble*~1KB, reconnect~1KB。 // 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 HTTP_SERVER_STACK_SIZE = 7168; ///< httpd:peak~5.2KB + ~2KB → 7KB(原 8/10KB)
inline constexpr uint32_t TASK_STACK_WIFI_OTA = 4096; ///< wifi_ota 主循环:原 8KB,peak~1.4KB 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~1KB;转发时看 /system/status.task_stack) inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(轻载 peak~1.3KB;转发突发不砍)
inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:5KB(帧缓冲热路径;已纳入 task_stack 巡检) 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 = 3072; ///< ble_ota 主任务:peak~1KB 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 = 3072; ///< ble_ota_worker 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.2KB 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~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:peak~1KB 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_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_FINISH = 4096; ///< 按需:SHA finalize(保持)
inline constexpr uint32_t TASK_STACK_BLE_GD32_WAIT = 3072; ///< 按需:GD32 中继等待 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 配置 // OTA 配置
// ============================================================ // ============================================================
inline constexpr size_t OTA_BLOCK_SIZE = 2048; ///< 上传接收块:2KB 降峰值 BSS/堆(s_ota_upload_recv 静态占用) 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 回退省 DRAM)。 // OTA_BLOCK_SIZE 演进:8KB→2KB(压堆)→4KB(试吞吐)→2KB(2026-07 回退)→4KB(2026-07-20 再提吞吐)。
// 4KB 时 s_ota_upload_recv BSS +2KB,开机 free/largest 更紧;回退 2KB 优先保内存。
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB = 221184B = 0x35F90,固件大小上限 inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB = 221184B = 0x35F90,固件大小上限
inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷 inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷
@@ -88,6 +88,7 @@ inline constexpr size_t HEAP_WARNING_MIN = 10240; ///< 堆内
// ============================================================ // ============================================================
// 调试配置(UDP 日志) // 调试配置(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 const char* DEBUG_LOG_IP = "192.168.31.3"; ///< UDP 日志目标 IP
inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口 inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口
+4 -4
View File
@@ -4,11 +4,11 @@
// 固件版本 // 固件版本
#define FW_VERSION_MAJOR 0 #define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3 #define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 51 #define FW_VERSION_PATCH 52
#define FW_VERSION_STRING "0.3.51" #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 信息 // Git 信息
#define GIT_HASH "1b10f89" #define GIT_HASH "b807645"
+23 -4
View File
@@ -21,6 +21,8 @@
#include "transport/spi_master.h" #include "transport/spi_master.h"
#include "monitor/debug_log.h" #include "monitor/debug_log.h"
#include "monitor/can_feedback_task.h" #include "monitor/can_feedback_task.h"
#include "monitor/gd32_adc_feedback.h"
#include "app_config.h"
#include "esp_system.h" #include "esp_system.h"
#include <string.h> #include <string.h>
@@ -30,6 +32,7 @@ namespace {
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时 constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔 constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiCanFeedbackType = 0xC1U; constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断 constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB"; 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 uint8_t s_workIdx[CAN_CHANNEL_MAX];
static uint32_t s_lastLoggedError[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) static void publish(uint8_t ch, const can_feedback_result_t &fb)
{ {
if (s_mutex == nullptr) return; 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 *) static void taskLoop(void *)
{ {
DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)"); DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)");
@@ -85,6 +96,12 @@ static void taskLoop(void *)
continue; continue;
} }
/* 0xC4 ADC 调试反馈:GD32 关开关则不推;ESP32 始终可解析(无本地开关) */
if (ring_type == kSpiAdcFeedbackType) {
(void)onGd32AdcFeedbackEvent(ring_payload, ring_len);
continue;
}
if (ring_type != kSpiCanFeedbackType || ring_len < 28U) { if (ring_type != kSpiCanFeedbackType || ring_len < 28U) {
continue; continue;
} }
@@ -103,13 +120,11 @@ static void taskLoop(void *)
if (s_work[ch].stats.error_count != 0U if (s_work[ch].stats.error_count != 0U
&& s_work[ch].stats.error_count != s_lastLoggedError[ch]) { && s_work[ch].stats.error_count != s_lastLoggedError[ch]) {
DEBUG_WARN(kTag, 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<unsigned>(ch), static_cast<unsigned>(ch),
static_cast<unsigned long>(s_work[ch].stats.snapshot_seq),
static_cast<unsigned long>(s_work[ch].stats.error_count), static_cast<unsigned long>(s_work[ch].stats.error_count),
static_cast<unsigned>(s_work[ch].stats.tec), static_cast<unsigned>(s_work[ch].stats.tec),
static_cast<unsigned>(s_work[ch].stats.rec), static_cast<unsigned>(s_work[ch].stats.rec));
static_cast<unsigned>(s_work[ch].stats.bus_state));
s_lastLoggedError[ch] = s_work[ch].stats.error_count; s_lastLoggedError[ch] = s_work[ch].stats.error_count;
} }
} }
@@ -156,6 +171,10 @@ bool startCanFeedbackTask()
return true; return true;
} }
/**
* @brief 暂停/恢复 CAN(及同任务分发的 ADC)事件监听。
* @param pause true=OTA 期间暂停;false=恢复
*/
void pauseCanFeedbackTask(bool pause) void pauseCanFeedbackTask(bool pause)
{ {
if (s_paused == pause) return; // 幂等:状态未变时不重复日志 if (s_paused == pause) return; // 幂等:状态未变时不重复日志
+1 -1
View File
@@ -95,7 +95,7 @@ void CpuStats::sampleOnce()
(now_tick - m_lastTruncWarnTick) >= cpuStatsWarnIntervalTicks()) { (now_tick - m_lastTruncWarnTick) >= cpuStatsWarnIntervalTicks()) {
m_lastTruncWarnTick = now_tick; m_lastTruncWarnTick = now_tick;
DEBUG_WARN("CPU", DEBUG_WARN("CPU",
"任务数 %lu > 快照上限 %u,本拍仅统计前 %u 个任务", "任务数%lu>上限%u,只计前%u",
(unsigned long)live, (unsigned long)live,
(unsigned)CPU_TASK_SNAPSHOT_MAX, (unsigned)CPU_TASK_SNAPSHOT_MAX,
(unsigned)CPU_TASK_SNAPSHOT_MAX); (unsigned)CPU_TASK_SNAPSHOT_MAX);
+139 -19
View File
@@ -18,6 +18,7 @@
*/ */
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <unistd.h> #include <unistd.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <arpa/inet.h> #include <arpa/inet.h>
@@ -69,17 +70,13 @@ bool DebugLog::isKeepAliveTag(const char *tag)
char *DebugLog::allocEmptyJson(int *out_len) char *DebugLog::allocEmptyJson(int *out_len)
{ {
char *empty = (char *)malloc(3); /* 返回 nullptr 让 caller 走"[]"字面量分支,避免空环时每秒 malloc(3)/free(3)。
if (!empty) { * ringDrainJson 的所有"返空"路径都调本函数,caller logs_handler 见 nullptr
return nullptr; * 后直接发 "[]" 字面量,零分配。 */
}
empty[0] = '[';
empty[1] = ']';
empty[2] = '\0';
if (out_len) { 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<char *>(calloc((size_t)slots, (size_t)LOG_RING_SIZE));
int *lens = static_cast<int *>(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<char *>(calloc((size_t)new_slots, (size_t)LOG_RING_SIZE));
int *lens = static_cast<int *>(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() int DebugLog::ringCount()
{ {
if (!m_logRingMutex) { if (!m_logRingMutex) {
@@ -166,11 +259,11 @@ bool DebugLog::ringPopOldestLocked(char *out, size_t out_size, int *out_len)
if (out_len) { if (out_len) {
*out_len = 0; *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; 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]; int len = m_logRingLens[idx];
if (len < 0) { if (len < 0) {
len = 0; len = 0;
@@ -182,7 +275,7 @@ bool DebugLog::ringPopOldestLocked(char *out, size_t out_size, int *out_len)
} }
if (copy_len > 0) { 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'; out[copy_len] = '\0';
if (out_len) { if (out_len) {
@@ -215,6 +308,9 @@ int DebugLog::ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const
if (total_raw_len) { if (total_raw_len) {
*total_raw_len = 0; *total_raw_len = 0;
} }
if (!m_logRing || m_logRingSlots <= 0) {
return 0;
}
int n = m_logRingCount; int n = m_logRingCount;
if (n > max_n) { if (n > max_n) {
n = max_n; n = max_n;
@@ -225,7 +321,7 @@ int DebugLog::ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const
int sum = 0; int sum = 0;
for (int i = 0; i < n; ++i) { 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]; int len = m_logRingLens[idx];
if (len < 0) { if (len < 0) {
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) void DebugLog::ringPushLocked(const char *data, int len)
{ {
if (!data || len <= 0) { if (!data || len <= 0 || !m_logRing || !m_logRingLens || m_logRingSlots <= 0) {
return; return;
} }
@@ -252,12 +348,27 @@ void DebugLog::ringPushLocked(const char *data, int len)
copy_len = LOG_RING_SIZE - 1; copy_len = LOG_RING_SIZE - 1;
} }
memcpy(m_logRing[m_logRingHead], data, (size_t)copy_len); /* 满环时优先覆盖 INFO/DBG,保护 ERR/WARN。 */
m_logRing[m_logRingHead][copy_len] = '\0'; 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_logRingLens[m_logRingHead] = copy_len;
m_logRingHead = (m_logRingHead + 1) % LOG_RING_SLOTS; m_logRingHead = (m_logRingHead + 1) % m_logRingSlots;
if (m_logRingCount < LOG_RING_SLOTS) { if (m_logRingCount < m_logRingSlots) {
m_logRingCount++; m_logRingCount++;
} }
} }
@@ -475,6 +586,15 @@ esp_err_t DebugLog::init()
return ESP_ERR_NO_MEM; 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)); memset(&m_debugAddr, 0, sizeof(m_debugAddr));
m_debugAddr.sin_family = AF_INET; m_debugAddr.sin_family = AF_INET;
m_debugAddr.sin_port = htons(DEBUG_LOG_PORT); m_debugAddr.sin_port = htons(DEBUG_LOG_PORT);
+25 -8
View File
@@ -51,9 +51,15 @@ class DebugLog {
public: public:
static DebugLog& instance(); static DebugLog& instance();
/** @brief 初始化日志系统(UART + ring;UDP socket 延迟创建)。 */ /** @brief 初始化日志系统(UART + 开机大环;UDP socket 延迟创建)。 */
esp_err_t init(); esp_err_t init();
/**
* @brief 启动 burst 结束后把环从开机容量缩到稳态小环,释放大块堆。
* 幂等;失败则保留大环。宜在 WiFi/HTTP 就绪后、日志变少时调用。
*/
void shrinkRingAfterBoot();
/** @brief 初始化 UART0 调试串口。 */ /** @brief 初始化 UART0 调试串口。 */
esp_err_t uartInit(); esp_err_t uartInit();
@@ -96,17 +102,28 @@ private:
bool m_uartAvailable = false; bool m_uartAvailable = false;
debug_log_level_t m_logLevel = DEBUG_LOG_INFO; debug_log_level_t m_logLevel = DEBUG_LOG_INFO;
/* 环形日志:OTA 中继期需保留关键 INFO,64×160≈10KB BSS。 */ /* 环形日志(堆上分配,非 .data):
static constexpr int LOG_RING_SLOTS = 64; * 开机 LOG_RING_SLOTS_BOOT×128 容纳启动刷屏;
static constexpr int LOG_RING_SIZE = 160; * shrinkRingAfterBoot() 后改为 LOG_RING_SLOTS_STEADY×128,释放大环。
/** 单次 /logs drain 上限(与 SLOTS 更接近,减 burst 滞后) */ * 业务 DEBUG_* 目标:整行([L][TAG]+正文+\\n)≤128B;槽 128 含 \\0 → 有效≤127。 */
static constexpr int LOG_DRAIN_MAX = 48; static constexpr int LOG_RING_SLOTS_BOOT = 64; ///< 开机 burst:~8.4KB 堆
char m_logRing[LOG_RING_SLOTS][LOG_RING_SIZE] = {}; static constexpr int LOG_RING_SLOTS_STEADY = 16; ///< 稳态:~2.1KB 堆(对齐 drain)
int m_logRingLens[LOG_RING_SLOTS] = {}; 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_logRingHead = 0;
int m_logRingCount = 0; int m_logRingCount = 0;
bool m_logRingShrunk = false;
SemaphoreHandle_t m_logRingMutex = nullptr; 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 堆): /* 堆门控(防 OTA 时 UDP/格式化抢 LWIP 堆):
* ERR/WARN 永不因堆门控丢弃 * ERR/WARN 永不因堆门控丢弃
* free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO * free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO
+150
View File
@@ -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 <string.h>
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<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8));
}
/** @brief 读小端 u32。 */
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(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<int16_t>(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<float>(temp_x100) / 100.0f;
fb.vdda_v = static_cast<float>(vdda_mV) / 1000.0f;
fb.vin_adc_v = static_cast<float>(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<int64_t>(UINT32_MAX)) {
return UINT32_MAX;
}
return static_cast<uint32_t>(age);
}
+53
View File
@@ -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 <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#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 */
+399 -10
View File
@@ -8,6 +8,8 @@
*/ */
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <stdarg.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "freertos/semphr.h" #include "freertos/semphr.h"
@@ -20,9 +22,107 @@
#include "health_monitor.h" #include "health_monitor.h"
#include "debug_log.h" #include "debug_log.h"
#include "task_stack_check.h" #include "task_stack.h"
#include "stack_overflow_hook.h"
#include "ota/ota_coordinator.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) { if (m_mutex == NULL) {
DEBUG_ERR("HEALTH", "创建健康状态互斥锁失败"); 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) { if (temp >= TEMP_CRITICAL) {
self.setState(SYS_SAFE_SHUTDOWN); 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"); DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN");
} else if (temp >= TEMP_WARNING) { } else if (temp >= TEMP_WARNING) {
health_state_t cur = self.getState(); health_state_t cur = self.getState();
@@ -213,12 +325,19 @@ void HealthMonitor::taskEntry(void *pvParameters)
// 在 health_mon 完好栈上用 DEBUG_ERR 经 UDP/UART 发详细告警。 // 在 health_mon 完好栈上用 DEBUG_ERR 经 UDP/UART 发详细告警。
// 标志只在置位后告警一次,不自动清除(canary 触发即不可逆,需重启恢复)。 // 标志只在置位后告警一次,不自动清除(canary 触发即不可逆,需重启恢复)。
if (g_stackOverflowDetected) { if (g_stackOverflowDetected) {
DEBUG_ERR("MON", "!!! 栈溢出已被内核 canary 检测到,请检查任务栈大小 !!!"); DEBUG_ERR("MON", "栈溢出(canary),检查任务栈");
// 告警一次后清标志,避免 5 秒刷屏;后续若再次触发会重新置位 // 告警一次后清标志,避免 5 秒刷屏;后续若再次触发会重新置位
g_stackOverflowDetected = false; g_stackOverflowDetected = false;
} }
uint32_t free_heap = esp_get_free_heap_size(); 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) { if (free_heap < HEAP_WARNING_MIN) {
/* 堆告警节流:OTA 期间阈值更低、间隔更长 */ /* 堆告警节流:OTA 期间阈值更低、间隔更长 */
static TickType_t s_last_heap_warn = 0; 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)) { (now - s_last_heap_warn) >= pdMS_TO_TICKS(interval_ms)) {
s_last_heap_warn = now; s_last_heap_warn = now;
uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); 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中)" : "", ota_busy ? "(OTA中)" : "",
(unsigned long)free_heap, (unsigned long)free_heap,
(unsigned long)esp_get_minimum_free_heap_size(), (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); size_t warn_bytes = (size_t)(metrics[i].stack_size_bytes * STACK_WARN_RATIO);
if (hw < (UBaseType_t)warn_bytes) { if (hw < (UBaseType_t)warn_bytes) {
if (!s_stackWarned[i]) { 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, metrics[i].name,
(unsigned long)hw, (unsigned long)hw,
(unsigned long)warn_bytes, (unsigned long)warn_bytes,
STACK_WARN_RATIO * 100.0f,
(unsigned long)metrics[i].stack_size_bytes); (unsigned long)metrics[i].stack_size_bytes);
s_stackWarned[i] = true; s_stackWarned[i] = true;
} }
@@ -313,14 +431,14 @@ health_status_t HealthMonitor::checkBeforeOta()
if (getLastWifiRssi(&last_rssi, &age_ms)) { if (getLastWifiRssi(&last_rssi, &age_ms)) {
if (age_ms <= OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS) { if (age_ms <= OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS) {
if (last_rssi < OTA_WIFI_RSSI_CRITICAL) { 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); last_rssi, OTA_WIFI_RSSI_CRITICAL, (unsigned long)age_ms);
return HEALTH_MON_CRITICAL; return HEALTH_MON_CRITICAL;
} else if (last_rssi < WIFI_RSSI_NORMAL) { } else if (last_rssi < WIFI_RSSI_NORMAL) {
if (status < HEALTH_MON_WARNING) { if (status < HEALTH_MON_WARNING) {
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); 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); DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
} else { // next == SYS_NORMAL,从 WARNING/CRITICAL 恢复 } else { // next == SYS_NORMAL,从 WARNING/CRITICAL 恢复
setState(SYS_NORMAL); 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; 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;
}
+70
View File
@@ -32,6 +32,62 @@ typedef enum {
// WiFi RSSI 阈值(来自 app_config.h) // WiFi RSSI 阈值(来自 app_config.h)
#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING ///< WiFi 信号正常阈值(别名) #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 系统健康监控类(单例) * @brief 系统健康监控类(单例)
* *
@@ -73,6 +129,15 @@ public:
/** @brief 获取健康监控任务句柄。 */ /** @brief 获取健康监控任务句柄。 */
TaskHandle_t getTaskHandle() const { return m_taskHandle; } 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 依赖)。 * @brief 由 WiFi 事件回调推送连接状态(避免 HealthMonitor → WifiOta 依赖)。
* WiFi 连接/断开时由 wifi_ota 事件处理器主动调用, * WiFi 连接/断开时由 wifi_ota 事件处理器主动调用,
@@ -106,6 +171,11 @@ private:
// m_wifiConnected 由 m_mutex 保护(setWifiConnected 写 / checkBeforeOta 读)。 // m_wifiConnected 由 m_mutex 保护(setWifiConnected 写 / checkBeforeOta 读)。
bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送) 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); static void tempMonitorCallback(void *arg);
// RAII 锁守卫(与 SpiMaster::LockGuard 风格一致) // RAII 锁守卫(与 SpiMaster::LockGuard 风格一致)
-41
View File
@@ -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 <cstring>
#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");
}
-18
View File
@@ -1,18 +0,0 @@
/*
* stack_overflow_hook.h - FreeRTOS 栈溢出钩子标志声明
*/
#pragma once
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/* canary 触发时由 vApplicationStackOverflowHook 置位;
* HealthMonitor 巡检循环检测此标志,置位后用 DEBUG_ERR 发详细告警。 */
extern volatile bool g_stackOverflowDetected;
#ifdef __cplusplus
}
#endif
+82
View File
@@ -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 <cstring>
#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;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* task_stack.h - 任务栈:水位收集 + 溢出钩子
*
* 1) collectTaskStackMetrics:供 health_mon 巡检与 /system/status 共用任务表
* 2) vApplicationStackOverflowHook / g_stackOverflowDetected:canary 溢出
*/
#pragma once
#include <stddef.h>
#include <stdbool.h>
#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 */
-55
View File
@@ -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;
}
-32
View File
@@ -1,32 +0,0 @@
/*
* task_stack_check.h - 任务栈水位指标收集(共享)
*
* 将受监控任务的任务名/句柄/栈大小统一收集,供:
* - health_monitor 巡检主动告警(低水位时 DEBUG_ERR,突破 OTA 门控)
* - http_handlers /system/status 被动报告(前端每秒轮询展示)
* 两处复用同一份任务表,避免散落维护。
*
* 表内含转发关键:ota_relay、spi_worker(帧缓冲热路径)。
*/
#pragma once
#include <stddef.h>
#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);
+10 -9
View File
@@ -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); esp_err_t err = esp_ota_begin(m_partition, OTA_WITH_SEQUENTIAL_WRITES, &m_handle);
const int64_t t1 = esp_timer_get_time(); const int64_t t1 = esp_timer_get_time();
if (err != ESP_OK) { 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)); esp_err_to_name(err), (long long)((t1 - t0) / 1000));
return beginCleanupAndReturn(err); return beginCleanupAndReturn(err);
} }
@@ -201,7 +201,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
m_ended = false; m_ended = false;
memset(m_sha256, 0, sizeof(m_sha256)); 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); m_partition->label, (unsigned)total_size);
return ESP_OK; return ESP_OK;
} }
@@ -224,9 +224,8 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len)
if (err == ESP_OK) { if (err == ESP_OK) {
m_received += len; m_received += len;
} else { } else {
DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u offset=%u free=%lu)", DEBUG_ERR(TAG, "ota_write %s n=%u off=%u",
esp_err_to_name(err), (unsigned)len, (unsigned)m_received, esp_err_to_name(err), (unsigned)len, (unsigned)m_received);
(unsigned long)esp_get_free_heap_size());
} }
return err; 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); 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); esp_err_t rd_err = esp_partition_read(m_partition, offset, s_sha_read_buf, to_read);
if (rd_err != ESP_OK) { 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)); (unsigned)offset, esp_err_to_name(rd_err));
mbedtls_sha256_free(&s_sha_ctx); mbedtls_sha256_free(&s_sha_ctx);
return rd_err; 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..."); DEBUG_DBG(TAG, "ESP32 OTA 校验 SHA-256...");
esp_err_t sha_err = computeShaFromPartition(); esp_err_t sha_err = computeShaFromPartition();
if (sha_err != ESP_OK) { 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); return finalizeCleanupAndReturn(sha_err);
} }
@@ -353,8 +352,10 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
if (sha_match) { if (sha_match) {
DEBUG_DBG(TAG, "SHA-256 校验通过"); DEBUG_DBG(TAG, "SHA-256 校验通过");
} else { } else {
DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s", /* 与正常刷写失败一致:先报异常,再分 2 行打两个 SHA-256 */
frontend_sha_hex, device_hex); 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); return finalizeCleanupAndReturn(ESP_ERR_INVALID_VERSION);
} }
} else if (out_device_sha_hex) { } else if (out_device_sha_hex) {
+14 -16
View File
@@ -148,12 +148,12 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
// RELAYING 是中继阶段,不可中断,也拒绝。 // RELAYING 是中继阶段,不可中断,也拒绝。
if (h && m_sysState == OTA_SYS_GD32_STORING) { if (h && m_sysState == OTA_SYS_GD32_STORING) {
if (m_otaChannel != channel) { if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "GD32 存储中(通道=%d),拒绝跨通道请求(通道=%d)", DEBUG_WARN(TAG, "GD32存中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel); (int)m_otaChannel, (int)channel);
unlock(h); unlock(h);
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
DEBUG_WARN(TAG, "GD32 存储仍在进行中(同通道),中止旧存储并重新开始"); DEBUG_WARN(TAG, "GD32同通道重传:中止旧存储");
doAbortLocked(); // 清 meta + IDLE,与 abort 路径一致 doAbortLocked(); // 清 meta + IDLE,与 abort 路径一致
// epoch 将在下方设置 GD32_STORING 后递增 // 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_sysState = OTA_SYS_GD32_STORING;
m_gd32StorageDone = false; m_gd32StorageDone = false;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻 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); total_size, m_otaEpoch, (int)channel);
} }
unlock(h); unlock(h);
@@ -242,20 +242,18 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t
void OtaCoordinator::completeGd32Storage() void OtaCoordinator::completeGd32Storage()
{ {
bool h = lock(); bool h = lock();
bool resume_ble = false;
if (h && m_sysState == OTA_SYS_GD32_STORING) { if (h && m_sysState == OTA_SYS_GD32_STORING) {
m_sysState = OTA_SYS_GD32_STORED; m_sysState = OTA_SYS_GD32_STORED;
m_gd32StorageDone = true; m_gd32StorageDone = true;
m_busySinceTick = 0; // ★ STORED 是非忙状态 m_busySinceTick = 0; // ★ STORED 是非忙状态
resume_ble = (m_otaChannel == OTA_CHANNEL_WIFI);
DEBUG_DBG(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED"); DEBUG_DBG(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED");
/* WiFi 路径不在此处恢复 BLE:
* 旧逻辑 HTTP 一收完就 resume,55KB 极快时日志紧贴 raw upload start,
* 像「一刷新就共存恢复」。挂起到中继结束/中止(notifyRelayComplete、abort)。 */
} else if (h) { } else if (h) {
DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState); DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState);
} }
unlock(h); unlock(h);
if (resume_ble) {
wifiOtaResumeBleIfNeeded();
}
} }
esp_err_t OtaCoordinator::finishGd32StorageAfterReceive() esp_err_t OtaCoordinator::finishGd32StorageAfterReceive()
@@ -281,7 +279,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay()
if (m_sysState != OTA_SYS_GD32_STORED && if (m_sysState != OTA_SYS_GD32_STORED &&
m_sysState != OTA_SYS_IDLE && m_sysState != OTA_SYS_IDLE &&
m_sysState != OTA_SYS_GD32_COMPLETE) { m_sysState != OTA_SYS_GD32_COMPLETE) {
DEBUG_WARN(TAG, "拒绝触发中继: 当前状态 %s (%d) 不在白名单", DEBUG_WARN(TAG, "拒中继 state=%s(%d)",
stateToString(m_sysState), (int)m_sysState); stateToString(m_sysState), (int)m_sysState);
unlock(h); unlock(h);
return ESP_ERR_INVALID_STATE; 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)仍拒绝,保持互斥。 // 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) { if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_otaChannel != channel) { if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "ESP32 OTA 接收中(通道=%d),拒绝跨通道请求(通道=%d)", DEBUG_WARN(TAG, "ESP32收中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel); (int)m_otaChannel, (int)channel);
unlock(h); unlock(h);
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
DEBUG_WARN(TAG, "ESP32 OTA 仍在接收中(同通道),中止旧会话并重新开始"); DEBUG_WARN(TAG, "ESP32同通道重传:中止旧会话");
doAbortLocked(); // abort core + IDLE + 恢复 CAN 反馈 doAbortLocked(); // abort core + IDLE + 恢复 CAN 反馈
// epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效 // epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效
} }
@@ -434,7 +432,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。 // core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) { if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (!Esp32OtaCore::instance().isActive()) { if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "ESP32_FINALIZING 残留(core 已空闲),强制复位后允许新会话"); DEBUG_WARN(TAG, "FINALIZING残留(core空闲),强制复位");
doAbortLocked(); doAbortLocked();
} else { } else {
DEBUG_WARN(TAG, "系统忙 (ESP32_FINALIZING, core 活跃),拒绝 ESP32 OTA"); 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 || if (h && (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING || m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_REBOOTING)) { 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)); (int)m_sysState, stateToString(m_sysState));
unlock(h); unlock(h);
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
@@ -488,7 +486,7 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe
// 避免连续 isIdle() + getSystemState() 各加锁一次(共 2 次锁)。 // 避免连续 isIdle() + getSystemState() 各加锁一次(共 2 次锁)。
ota_system_state_t cur = getSystemState(); ota_system_state_t cur = getSystemState();
if (!isIdleState(cur) && cur != OTA_SYS_ERROR) { if (!isIdleState(cur) && cur != OTA_SYS_ERROR) {
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝且系统非空闲 (state=%d),不强制清理,直接失败", DEBUG_WARN(TAG, "ESP32拒且非空闲 state=%d,不强制清",
(int)cur); (int)cur);
if (channel == OTA_CHANNEL_WIFI) { if (channel == OTA_CHANNEL_WIFI) {
wifiOtaResumeBleIfNeeded(); wifiOtaResumeBleIfNeeded();
@@ -497,9 +495,9 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe
} }
if (cur == OTA_SYS_ERROR) { if (cur == OTA_SYS_ERROR) {
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但协调器处于 ERROR,清理底层残留句柄后重试"); DEBUG_WARN(TAG, "ESP32拒但ERROR,清残留后重试");
} else { } else {
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但系统空闲,判定为锁残留,强制清理状态机后重试"); DEBUG_WARN(TAG, "ESP32拒但空闲,判锁残留,强制清后重试");
} }
Esp32OtaCore::instance().forceCleanup(); Esp32OtaCore::instance().forceCleanup();
err = beginEsp32Ota(total_size, channel); err = beginEsp32Ota(total_size, channel);
+45 -37
View File
@@ -596,18 +596,23 @@ void OtaRelay::logRespPayload(const char *stage, uint8_t resp_type, const uint8_
if (DebugLog::instance().getLogLevel() < DEBUG_LOG_DBG) { if (DebugLog::instance().getLogLevel() < DEBUG_LOG_DBG) {
return; return;
} }
char hex[193] = {0}; char hx[17] = {0};
size_t n = (len > 32U) ? 32U : len; size_t n = (len > 8U) ? 8U : len;
if (payload) { if (payload) {
for (size_t i = 0; i < n; ++i) { 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 { } else {
n = 0; n = 0;
} }
DEBUG_DBG("OTA", "%s resp: type=0x%02X len=%u payload[%u]=%s%s", /* stage 名截断,避免整行超 128B */
stage, resp_type, (unsigned)len, (unsigned)n, char stg[12];
n > 0 ? hex : "<empty>", len > n ? "..." : ""); 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)); 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); esp_err_t wait_err = SpiMaster::instance().waitForBootloaderReady(12000);
if (wait_err == ESP_OK) { if (wait_err == ESP_OK) {
DEBUG_INFO("OTA", "Bootloader 已就绪 (HELLO role=boot)");
return ESP_OK; return ESP_OK;
} }
@@ -882,7 +886,7 @@ esp_err_t OtaRelay::requestGd32AppEnterBootloader()
DEBUG_ERR("OTA", "Bootloader HELLO 就绪失败(重试后): %s", esp_err_to_name(wait_err)); DEBUG_ERR("OTA", "Bootloader HELLO 就绪失败(重试后): %s", esp_err_to_name(wait_err));
return wait_err; return wait_err;
} }
DEBUG_INFO("OTA", "Bootloader 已就绪 (HELLO role=boot, 二次)"); /* 成功日志由 SPI waitForBootloaderReady 统一输出 */
return ESP_OK; 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", DEBUG_WARN("OTA", "HANDSHAKE重试 %" PRIu32 "/%" PRIu32 " err=%d t=0x%02X",
attempt + 1U, attempts, last_err, last_resp_type, (unsigned)last_resp_len); attempt + 1U, attempts, last_err, last_resp_type);
vTaskDelay(pdMS_TO_TICKS(kHandshakeRetryGapMs)); 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, err = pollUntilExpectedAckShape(stage, &resp_type, resp_payload, resp_max, resp_len,
poll_timeout_ms, wait_rounds, max_extra_polls, matcher); poll_timeout_ms, wait_rounds, max_extra_polls, matcher);
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !matcher(resp_payload, *resp_len)) { 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); stage, err, resp_type, *resp_len);
return OTA_RESULT_ERR_TIMEOUT; return OTA_RESULT_ERR_TIMEOUT;
} }
@@ -1019,14 +1023,7 @@ ota_result_t OtaRelay::doChallengeAndAuth()
OtaRelay::isConfirmPlainAck); OtaRelay::isConfirmPlainAck);
if (res != OTA_RESULT_OK) return res; if (res != OTA_RESULT_OK) return res;
{ DEBUG_INFO("OTA", "CONFIRM ok, resp_len=%zu", resp_len);
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);
}
Protocol::sessionKeyInit(&m_sessionCtx, m_kSession); Protocol::sessionKeyInit(&m_sessionCtx, m_kSession);
return OTA_RESULT_OK; 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); tx_buf[59] = (uint8_t)((if_end >> 24) & 0xFFU);
calculateCrc32WithKey(m_kSession, plain48, &tx_buf[60]); calculateCrc32WithKey(m_kSession, plain48, &tx_buf[60]);
DEBUG_DBG("OTA", "包 %" PRIu32 ": chunk=%u if_end=0x%08" PRIX32 " crc32=%02X%02X%02X%02X", DEBUG_DBG("OTA", "包%" PRIu32 " n=%u end=%08" PRIX32,
seq, (unsigned)chunk_size, if_end, seq, (unsigned)chunk_size, if_end);
tx_buf[60], tx_buf[61], tx_buf[62], tx_buf[63]);
/* 不在此写 m_transferCurrentSeq:预组下一包时不应污染“当前发送序号” */ /* 不在此写 m_transferCurrentSeq:预组下一包时不应污染“当前发送序号” */
if (out_chunk_size) { 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(); TickType_t last_hb = xTaskGetTickCount();
while (xTaskGetTickCount() < poll_deadline) { while (xTaskGetTickCount() < poll_deadline) {
if ((xTaskGetTickCount() - last_hb) >= pdMS_TO_TICKS(1000)) { if ((xTaskGetTickCount() - last_hb) >= pdMS_TO_TICKS(1000)) {
DEBUG_INFO("OTA", "包 %" PRIu32 " 等 ACK… send=%" PRIu32 " type=0x%02X wait=%d credit=%u", DEBUG_INFO("OTA", "包%" PRIu32 "等ACK send=%" PRIu32 " t=0x%02X cr=%u",
seq, send_i, resp_type, (int)saw_wait, seq, send_i, resp_type,
(unsigned)SpiMaster::instance().remoteCredit()); (unsigned)SpiMaster::instance().remoteCredit());
last_hb = xTaskGetTickCount(); 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, seq,
(uint32_t)((xTaskGetTickCount() - pkt_start) * portTICK_PERIOD_MS), (uint32_t)((xTaskGetTickCount() - pkt_start) * portTICK_PERIOD_MS),
(unsigned)SpiMaster::instance().remoteCredit(), resp_type,
resp_type, (int)saw_wait); (unsigned)SpiMaster::instance().remoteCredit());
*out_result = OTA_RESULT_ERR_TIMEOUT; *out_result = OTA_RESULT_ERR_TIMEOUT;
return true; return true;
} }
@@ -1369,10 +1365,9 @@ bool OtaRelay::probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime)
gd32Version = ver; gd32Version = ver;
if (out_uptime) *out_uptime = uptime; if (out_uptime) *out_uptime = uptime;
// 启动探测等场景用 DBG;OTA 成功路径由 doWaitForAppAlive 统一 INFO 总结 // 启动探测等场景用 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, uptime,
ver.app_major, ver.app_minor, ver.app_patch, ver.app_build, ver.app_major, ver.app_minor, ver.app_patch, ver.app_build);
ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build);
return true; return true;
} }
if (out_uptime) *out_uptime = 0; if (out_uptime) *out_uptime = 0;
@@ -1432,7 +1427,7 @@ ota_result_t OtaRelay::doSendFinishOta()
continue; 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); resp_type, resp_len, retry + 1);
vTaskDelay(pdMS_TO_TICKS(10)); vTaskDelay(pdMS_TO_TICKS(10));
} }
@@ -1482,10 +1477,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
return OTA_RESULT_ERR_GD32; return OTA_RESULT_ERR_GD32;
} }
gd32_version_t ver = getSnapshot().version; /* 版本已在 HELLO/PING 身份(SPI ID APP/BOOT)打印,此处不重复 */
DEBUG_INFO("OTA", "GD32 OTA 完成: APP=v%d.%d.%d.%d, BOOT=v%d.%d.%d.%d", DEBUG_INFO("OTA", "GD32 OTA完成");
ver.app_major, ver.app_minor, ver.app_patch, ver.app_build,
ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build);
return OTA_RESULT_OK; return OTA_RESULT_OK;
} }
@@ -1539,11 +1532,26 @@ void OtaRelay::taskEntry(void *pvParameters)
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK && if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
SpiMaster::instance().gd32Role() == V2_ROLE_APP) { SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_ok = true; 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 { } else {
DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U); DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U);
continue; continue;
} }
} }
/* 旧固件无 HELLO 身份:回退 APP PING */
if (probeAndCacheAppAlive(2000)) { if (probeAndCacheAppAlive(2000)) {
alive = true; alive = true;
break; break;
@@ -1576,7 +1584,7 @@ void OtaRelay::taskEntry(void *pvParameters)
// 成功日志已在 doWaitForAppAlive 输出,此处不再重复 // 成功日志已在 doWaitForAppAlive 输出,此处不再重复
vTaskDelay(pdMS_TO_TICKS(3000)); vTaskDelay(pdMS_TO_TICKS(3000));
} else if (result == OTA_RESULT_ERR_TIMEOUT) { } 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) { } else if (result == OTA_RESULT_ERR_USER_ABORT) {
DEBUG_ERR("OTA", "OTA 用户中止 (USER_ABORT=%d)", (int)result); DEBUG_ERR("OTA", "OTA 用户中止 (USER_ABORT=%d)", (int)result);
} else { } else {
+15 -10
View File
@@ -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() BleOta& BleOta::instance()
{ {
static BleOta inst; static BleOta inst;
@@ -145,7 +150,7 @@ void ble_ota_boost_wifi_ps()
s_wifi_ps_boosted = true; s_wifi_ps_boosted = true;
// 通过协调器通知 WiFi 层 // 通过协调器通知 WiFi 层
OtaCoordinator::instance().notifyBleCoexStart(); 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; s_wifi_ps_boosted = false;
// 通过协调器通知 WiFi 层 // 通过协调器通知 WiFi 层
OtaCoordinator::instance().notifyBleCoexEnd(); 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) { if (!m_stackRunning) {
/* 仅当曾 hard-deinit 过才需要完整 serverStart */ /* 仅当曾 hard-deinit 过才需要完整 serverStart */
DEBUG_INFO(TAG, "WiFi OTA 结束:BLE 栈未运行,尝试 serverStart free=%lu", DEBUG_DBG(TAG, "WiFi共存:BLE栈未运行,serverStart free=%lu",
(unsigned long)esp_get_free_heap_size()); (unsigned long)esp_get_free_heap_size());
esp_err_t err = serverStart(); esp_err_t err = serverStart();
if (err != ESP_OK) { if (err != ESP_OK) {
DEBUG_ERR(TAG, "resumeStack/serverStart 失败: %s", esp_err_to_name(err)); DEBUG_ERR(TAG, "resumeStack/serverStart 失败: %s", esp_err_to_name(err));
@@ -297,9 +302,9 @@ esp_err_t BleOta::resumeStack()
return ESP_OK; return ESP_OK;
} }
/* 软挂起恢复:重新开广播即可 */ /* 软挂起恢复:重新开广播即可(非 BLE OTA 会话,仅射频共存;默认 INFO 不刷) */
DEBUG_INFO(TAG, "WiFi OTA 结束:恢复 BLE 广播 free=%lu", DEBUG_DBG(TAG, "WiFi共存:恢复BLE广播 free=%lu",
(unsigned long)esp_get_free_heap_size()); (unsigned long)esp_get_free_heap_size());
m_bleReady = true; m_bleReady = true;
ble_ota_start_advertising(); ble_ota_start_advertising();
return ESP_OK; return ESP_OK;
@@ -375,17 +380,17 @@ void BleOta::taskEntry(void *pvParameters)
12, // 略提高:尽快排空队列,减少 withoutResponse 连发丢包 12, // 略提高:尽快排空队列,减少 withoutResponse 连发丢包
NULL); NULL);
if (wk_ok != pdPASS) { if (wk_ok != pdPASS) {
DEBUG_ERR(TAG, "OTA 数据工作线程创建失败,BLE OTA 数据传输不可用"); DEBUG_ERR(TAG, "OTA工作线程创建失败,BLE不可用");
vQueueDelete(s_otaDataQueue); vQueueDelete(s_otaDataQueue);
s_otaDataQueue = nullptr; s_otaDataQueue = nullptr;
} }
} else { } else {
DEBUG_ERR(TAG, "OTA 数据队列创建失败,BLE OTA 数据传输不可用"); DEBUG_ERR(TAG, "OTA队列创建失败,BLE不可用");
} }
esp_err_t err = BleOta::instance().serverStart(); esp_err_t err = BleOta::instance().serverStart();
if (err != ESP_OK) { if (err != ESP_OK) {
DEBUG_ERR(TAG, "BLE 服务器启动失败: %s → BLE OTA 不可用", DEBUG_ERR(TAG, "BLE serverStart失败:%s",
esp_err_to_name(err)); esp_err_to_name(err));
} }
+8
View File
@@ -81,4 +81,12 @@ public:
bool m_suspendedForWifiOta = false; ///< 因 WiFi OTA 主动挂起 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 #endif // BLE_OTA_H
+43 -40
View File
@@ -62,15 +62,14 @@ uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase)
struct ble_gap_conn_desc desc = {}; struct ble_gap_conn_desc desc = {};
int rc = ble_gap_conn_find(conn_handle, &desc); int rc = ble_gap_conn_find(conn_handle, &desc);
if (rc != 0) { if (rc != 0) {
DEBUG_WARN(TAG, "%s读取 BLE 连接参数失败: rc=%d", phase, rc); DEBUG_WARN(TAG, "%sBLE param读失败 rc=%d", phase, rc);
return 0; return 0;
} }
DEBUG_DBG(TAG, "%sBLE 连接参数: interval=%.2fms latency=%u supervision=%ums", DEBUG_DBG(TAG, "%sitv=%.2f lat=%u",
phase, phase,
desc.conn_itvl * 1.25f, desc.conn_itvl * 1.25f,
(unsigned)desc.conn_latency, (unsigned)desc.conn_latency);
(unsigned)desc.supervision_timeout * 10U);
return desc.conn_itvl; 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; params.supervision_timeout = 200;
int rc = ble_gap_update_params(conn_handle, &params); int rc = ble_gap_update_params(conn_handle, &params);
DEBUG_DBG(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms ce=%u..%u rc=%d", DEBUG_DBG(TAG, "BLEp(%s) %.1f-%.1f rc=%d",
windows_compatible ? "Windows兼容" : "高速", windows_compatible ? "Win" : "Hi",
params.itvl_min * 1.25f, params.itvl_min * 1.25f,
params.itvl_max * 1.25f, params.itvl_max * 1.25f,
(unsigned)params.min_ce_len,
(unsigned)params.max_ce_len,
rc); rc);
return rc; return rc;
} }
@@ -129,11 +126,8 @@ void esp32_reorder_alloc()
s_fallback_ooo_count = 0; s_fallback_ooo_count = 0;
if (!s_reorder.slots || !s_reorder.lens) { if (!s_reorder.slots || !s_reorder.lens) {
DEBUG_WARN(TAG, "Reorder buffer 分配失败 (slots=%p lens=%p),回退到丢弃策略。" DEBUG_WARN(TAG, "Reorder分配失败,回退丢弃 free=%u",
"heap: free=%u min=%u", (unsigned)esp_get_free_heap_size());
s_reorder.slots, s_reorder.lens,
(unsigned)esp_get_free_heap_size(),
(unsigned)esp_get_minimum_free_heap_size());
// 释放可能部分成功的分配 // 释放可能部分成功的分配
free(s_reorder.slots); s_reorder.slots = nullptr; free(s_reorder.slots); s_reorder.slots = nullptr;
free(s_reorder.lens); s_reorder.lens = nullptr; free(s_reorder.lens); s_reorder.lens = nullptr;
@@ -143,7 +137,7 @@ void esp32_reorder_alloc()
} }
s_reorder.active = true; s_reorder.active = true;
s_reorder.fallback = false; 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)s_reorder.windowSize, (unsigned)s_reorder.payloadCap,
(unsigned)esp_get_free_heap_size()); (unsigned)esp_get_free_heap_size());
} }
@@ -178,6 +172,16 @@ void esp32_reorder_free()
s_fallback_ooo_count = 0; 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。 * @brief flush expected 对应的连续缓存包到 flash,并释放对应 slot。
* 由 esp32_write_ordered 在每次 expected 推进后调用。 * 由 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); ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U);
if (shouldAck) { if (shouldAck) {
uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); 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, seq, (unsigned)s_reorder.expected, (unsigned)curReceived,
(unsigned)s_fallback_ooo_count); (unsigned)s_fallback_ooo_count);
sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0,
curReceived); curReceived);
s_fallback_ooo_count = 0; s_fallback_ooo_count = 0;
} else { } 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, seq, (unsigned)s_reorder.expected,
(unsigned)s_fallback_ooo_count); (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++; s_reorder.expected++;
uint32_t flushed = esp32_reorder_flush(); uint32_t flushed = esp32_reorder_flush();
if (flushed > 0) { 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); seq, (unsigned)flushed, (unsigned)s_reorder.expected);
} }
return ESP_OK; 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 (idx < s_reorder.windowSize) {
if (s_reorder.slots[idx]) { if (s_reorder.slots[idx]) {
// 该 slot 已被占用(重复的乱序包),跳过 // 该 slot 已被占用(重复的乱序包),跳过
DEBUG_DBG(TAG, "Reorder slot 已占: seq=%u idx=%u,跳过重复包", DEBUG_DBG(TAG, "Reorder占 s=%u i=%u 跳过",
seq, (unsigned)idx); seq, (unsigned)idx);
return ESP_OK; 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); uint8_t *copy = (uint8_t *)malloc(data_len);
if (!copy) { if (!copy) {
uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived();
DEBUG_ERR(TAG, "Reorder slot malloc 失败: seq=%u expected=%u bytes=%u," DEBUG_ERR(TAG, "Reorder OOM s=%u e=%u n=%u ACK+abort",
"ACK 报告缺口并 abort",
seq, (unsigned)s_reorder.expected, (unsigned)curReceived); seq, (unsigned)s_reorder.expected, (unsigned)curReceived);
sendWindowAck(cmd, sendWindowAck(cmd,
s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, 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.lens[idx] = data_len;
s_reorder.cachedCount++; s_reorder.cachedCount++;
if (s_reorder.cachedCount == 1U) { 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); (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); seq, (unsigned)idx, (unsigned)s_reorder.expected);
return ESP_OK; return ESP_OK;
} }
// seq 超出窗口范围(防御性,理论不会发生) // seq 超出窗口范围(防御性,理论不会发生)
uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived();
DEBUG_WARN(TAG, "ESP32 包超出 reorder 窗口: seq=%u (expected=%u base=%u win=%u),ACK bytes=%u", DEBUG_WARN(TAG, "超窗 s=%u e=%u w=%u ACK=%u",
seq, (unsigned)s_reorder.expected, (unsigned)s_reorder.windowBase, seq, (unsigned)s_reorder.expected,
(unsigned)s_reorder.windowSize, (unsigned)curReceived); (unsigned)s_reorder.windowSize, (unsigned)curReceived);
sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0,
curReceived); 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)); esp_err_t err = BleOta::instance().notifyResponse(ack, sizeof(ack));
if (err != ESP_OK) { 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)); cmd, seq, esp_err_to_name(err));
} else { } else {
DEBUG_DBG(TAG, "已发送 ACK: cmd=0x%02X seq=%u bytes=%lu", 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); ota_result_t result = (ota_result_t)((int32_t)result_msg);
if (result == OTA_RESULT_OK) { 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, sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_DONE,
packBleFinishValue(session_id, 0)); packBleFinishValue(session_id, 0));
} else { } 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); memcpy(heap_buf, buf, (size_t)recv_len);
BleOtaDataBatch item = { heap_buf, (size_t)recv_len }; BleOtaDataBatch item = { heap_buf, (size_t)recv_len };
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) { 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); free(heap_buf);
} }
} else { } else {
@@ -626,12 +629,12 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
if (Esp32OtaCore::instance().isActive()) { if (Esp32OtaCore::instance().isActive()) {
ota_channel_t curCh = OtaCoordinator::instance().getOtaChannel(); ota_channel_t curCh = OtaCoordinator::instance().getOtaChannel();
if (curCh != OTA_CHANNEL_BLE) { if (curCh != OTA_CHANNEL_BLE) {
DEBUG_WARN(TAG, "ESP32 OTA 被其他通道占用(ch=%d),BLE 拒绝抢占", DEBUG_WARN(TAG, "ESP32被ch=%d占用,BLE拒抢",
(int)curCh); (int)curCh);
sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0);
return true; return true;
} }
DEBUG_INFO(TAG, "ESP32 OTA 会话已激活,seq=0 视为重传,保留 reorder 状态"); DEBUG_INFO(TAG, "ESP32已激活 seq=0重传,保留reorder");
} else { } else {
// 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成 // 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成
esp_err_t err = ble_esp32_ota_begin(total_size); 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 || isLastPacket ||
firstPacketReady)) { firstPacketReady)) {
if (isLastPacket || transferComplete) { 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); seq, data_len, (unsigned int)afterReceived, total_size);
} else if (firstPacketReady) { } else if (firstPacketReady) {
DEBUG_DBG(TAG, "ESP32 begin 就绪 ACK: seq=0 received=%u total=%" PRIu32, 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) { 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); (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1);
OtaCoordinator::instance().notifyEsp32ReceiveComplete(); OtaCoordinator::instance().notifyEsp32ReceiveComplete();
BleOta::instance().m_esp32LastCmd = cmd; 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,毁掉已写入进度)。 // (否则会 erase 分区、清 meta,毁掉已写入进度)。
if (s_ble_gd32_meta_active && s_ble_gd32_meta.total_size == total_size) { if (s_ble_gd32_meta_active && s_ble_gd32_meta.total_size == total_size) {
if (s_ble_gd32_meta.stored_size > 0) { 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); s_ble_gd32_meta.stored_size);
sendWindowAck(cmd, 0, s_ble_gd32_meta.stored_size); sendWindowAck(cmd, 0, s_ble_gd32_meta.stored_size);
return false; return false;
} }
DEBUG_INFO(TAG, "GD32 会话已激活且未落盘,seq=0 重传首块(不重新 prepare)"); DEBUG_INFO(TAG, "GD32未落盘 seq=0重传首块(不prepare)");
// fall through:stored_size==0 时重写 offset 0 // fall through:stored_size==0 时重写 offset 0
} else { } else {
// 与 WiFi 共用 prepareGd32Storage:validate + begin + erase + clearMeta + saveMeta // 与 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) || (s_gd32_ooo_count >= BLE_OTA_ACK_WINDOW) ||
((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U); ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U);
if (shouldAck) { 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); seq, meta.last_seq, meta.stored_size, (unsigned)s_gd32_ooo_count);
sendWindowAck(cmd, (uint16_t)meta.last_seq, meta.stored_size); sendWindowAck(cmd, (uint16_t)meta.last_seq, meta.stored_size);
s_gd32_ooo_count = 0; s_gd32_ooo_count = 0;
} else { } 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); seq, meta.last_seq, (unsigned)s_gd32_ooo_count);
} }
return false; // 外层推进 offset,继续下一包 return false; // 外层推进 offset,继续下一包
@@ -881,14 +884,14 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
// --- 检查 GD32 固件是否接收完毕 --- // --- 检查 GD32 固件是否接收完毕 ---
if (transfer_complete) { 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); meta.stored_size, meta.total_size, seq + 1);
s_ble_gd32_meta_active = false; s_ble_gd32_meta_active = false;
ble_ota_restore_wifi_ps(); ble_ota_restore_wifi_ps();
// 与 WiFi 共用 finishGd32StorageAfterReceive:complete + SHA // 与 WiFi 共用 finishGd32StorageAfterReceive:complete + SHA
esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive();
if (sha_err != ESP_OK) { 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; 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) // 当前包总长度: header(7) + payload(data_len)
size_t packet_len = 7 + 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", DEBUG_DBG(TAG, "DATA_IN c=0x%02X s=%u tot=%" PRIu32 " n=%zu",
cmd, seq, total_size, data_len, offset); cmd, seq, total_size, data_len);
// 分发到对应通道的包处理器 // 分发到对应通道的包处理器
bool abort_batch = false; bool abort_batch = false;
@@ -985,7 +988,7 @@ void ble_ota_data_worker_task(void *pvParameters)
// 若 BLE 已断开或 WiFi OTA 挂起,丢弃队列中的残留数据 // 若 BLE 已断开或 WiFi OTA 挂起,丢弃队列中的残留数据
if (!BleOta::instance().isConnected() || if (!BleOta::instance().isConnected() ||
BleOta::instance().isSuspendedForWifiOta()) { BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_DBG(TAG, "BLE 不可用/挂起,丢弃 %zu 字节队列数据", item.len); DEBUG_DBG(TAG, "BLE不可用 丢%zuB队列", item.len);
free(item.data); free(item.data);
continue; continue;
} }
+21 -21
View File
@@ -38,7 +38,7 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
} }
if (len < 35) { 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, sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
packBleFinishValue(session_id, 1)); packBleFinishValue(session_id, 1));
return 0; 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); err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha);
shaBytesToHexUpper(device_sha, device_sha_hex); shaBytesToHexUpper(device_sha, device_sha_hex);
sendSha256Notify(device_sha, session_id); 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) { 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, sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
packBleFinishValue(session_id, 0)); packBleFinishValue(session_id, 0));
return 0; return 0;
} else if (err != ESP_OK) { } 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, sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 3)); packBleFinishValue(session_id, 3));
return 0; return 0;
} }
DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继", DEBUG_INFO(TAG, "GD32就绪 SHA匹配 %" PRIu32 "/%" PRIu32 ",触发中继",
meta.stored_size, meta.total_size); meta.stored_size, meta.total_size);
if (OtaRelay::getResultQueue()) { if (OtaRelay::getResultQueue()) {
uint32_t stale = 0; 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, sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START,
packBleFinishValue(session_id, 0)); 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)); BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx));
if (!ctx) { if (!ctx) {
@@ -142,7 +141,7 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
// 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对, // 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对,
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。
if (len < 33) { 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); sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG);
return 0; 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->sha_hex[64] = '\0';
finish_ctx->session_id = session_id; 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; BleOta::instance().m_esp32RebootPending = true;
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, 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) { } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {
// 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host // 重启 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) { if (BleOta::instance().m_esp32RebootPending) {
DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL"); DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL");
return 0; 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(); 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); OtaCoordinator::stateToString(cur_state), session_id);
if (cur_state == OTA_SYS_GD32_RELAYING) { 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, sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 2)); packBleFinishValue(session_id, 2));
return 0; 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(); ota_system_state_t cur = OtaCoordinator::instance().getSystemState();
if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) { if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "BLE 断开但 %s 进行中,保留状态等待自然结束", DEBUG_WARN(TAG, "BLE断但%s进行中,保留状态",
OtaCoordinator::stateToString(cur)); OtaCoordinator::stateToString(cur));
} else if (cur == OTA_SYS_ESP32_RECEIVING || } else if (cur == OTA_SYS_ESP32_RECEIVING ||
cur == OTA_SYS_ESP32_FINALIZING || cur == OTA_SYS_ESP32_FINALIZING ||
@@ -479,7 +478,7 @@ int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
ch = snap.channel; ch = snap.channel;
} }
if (ch == OTA_CHANNEL_BLE) { if (ch == OTA_CHANNEL_BLE) {
DEBUG_WARN(TAG, "BLE 断开,中止 BLE 通道 OTA (state=%s)", DEBUG_WARN(TAG, "BLE断,中止OTA state=%s",
OtaCoordinator::stateToString(cur)); OtaCoordinator::stateToString(cur));
ble_ota_session_reset(); ble_ota_session_reset();
} else { } else {
@@ -502,7 +501,7 @@ int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
return 0; return 0;
case BLE_GAP_EVENT_SUBSCRIBE: 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.conn_handle,
event->subscribe.attr_handle, event->subscribe.attr_handle,
event->subscribe.cur_notify, 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" : const char *tx_phy = (tx == BLE_GAP_LE_PHY_2M) ? "2M" :
(tx == BLE_GAP_LE_PHY_1M) ? "1M" : (tx == BLE_GAP_LE_PHY_1M) ? "1M" :
(tx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?"; (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); event->phy_updated.status, rx_phy, tx_phy);
return 0; return 0;
} }
@@ -573,18 +572,18 @@ void ble_ota_start_advertising(void) // kept as free function for BLE callback
uint8_t own_addr_type; uint8_t own_addr_type;
rc = ble_hs_id_infer_auto(0, &own_addr_type); rc = ble_hs_id_infer_auto(0, &own_addr_type);
if (rc != 0) { if (rc != 0) {
DEBUG_ERR(TAG, "推断地址类型失败: rc=%d, 尝试生成随机地址", rc); DEBUG_ERR(TAG, "推断地址失败 rc=%d,试随机地址", rc);
// 生成不可解析随机地址作为 fallback // 生成不可解析随机地址作为 fallback
ble_addr_t rnd_addr; ble_addr_t rnd_addr;
rc = ble_hs_id_gen_rnd(0, &rnd_addr); rc = ble_hs_id_gen_rnd(0, &rnd_addr);
if (rc != 0) { if (rc != 0) {
DEBUG_ERR(TAG, "生成随机地址也失败: rc=%d, BLE 广播无法启动", rc); DEBUG_ERR(TAG, "随机地址失败 rc=%d", rc);
return; return;
} }
own_addr_type = rnd_addr.type; 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}; 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) { if (rc != 0) {
DEBUG_ERR(TAG, "启动广播失败: %d", rc); DEBUG_ERR(TAG, "启动广播失败: %d", rc);
} else { } else {
DEBUG_INFO(TAG, "BLE 广播已启动: \"%s\"", name); /* 冷启动与 WiFi 共存恢复都会走这里;常态 INFO 不刷,避免上传日志夹杂 BLE */
DEBUG_DBG(TAG, "BLE广播就绪: \"%s\"", name);
} }
} }
+455 -167
View File
@@ -3,6 +3,7 @@
*/ */
#include <string.h> #include <string.h>
#include <stdarg.h> #include <stdarg.h>
#include <stdint.h>
#include <inttypes.h> #include <inttypes.h>
#include <algorithm> #include <algorithm>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
@@ -17,6 +18,7 @@
#include "esp_task_wdt.h" #include "esp_task_wdt.h"
#include "esp_heap_caps.h" #include "esp_heap_caps.h"
#include "esp_rom_sys.h" #include "esp_rom_sys.h"
#include "soc/soc.h"
#include "cJSON.h" #include "cJSON.h"
#include "app_config.h" #include "app_config.h"
@@ -32,7 +34,8 @@
#include "spi_master.h" #include "spi_master.h"
#include "canfd_bridge.h" #include "canfd_bridge.h"
#include "monitor/can_feedback_task.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 "ble_ota.h"
#include "system_boot.h" #include "system_boot.h"
@@ -235,7 +238,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req)
http_set_cors_headers(req); http_set_cors_headers(req);
uint8_t *recv_buf = s_ota_upload_recv; 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, req->content_len,
(unsigned long)esp_get_free_heap_size(), (unsigned long)esp_get_free_heap_size(),
(unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); (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_firmware_written = 0;
size_t total_received = json_header_len; 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) (req->content_len > json_header_len)
? (req->content_len - json_header_len) : 0); ? (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); int ret = httpd_req_recv(req, (char *)fw_buf, to_read);
if (ret <= 0) { 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, ret, (unsigned)total_received, req->content_len,
(unsigned long)esp_get_free_heap_size()); (unsigned long)esp_get_free_heap_size());
OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); 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"); httpd_resp_set_hdr(req, "Connection", "close");
esp_err_t send_err = httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); esp_err_t send_err = httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
if (send_err != ESP_OK) { 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), esp_err_to_name(send_err),
(unsigned long)esp_get_free_heap_size(), (unsigned long)esp_get_free_heap_size());
(unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT));
/* 再试一次极简回包 */ /* 再试一次极简回包 */
send_err = httpd_resp_send(req, "{\"status\":\"ok\",\"target\":\"esp32\"}", send_err = httpd_resp_send(req, "{\"status\":\"ok\",\"target\":\"esp32\"}",
HTTPD_RESP_USE_STRLEN); 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)); DEBUG_ERR("WIFI", "ESP32 OTA 简短响应仍失败: %s", esp_err_to_name(send_err));
} }
} else { } else {
DEBUG_INFO("WIFI", DEBUG_INFO("WIFI", "ESP32存完 %uB %lldms free=%lu",
"ESP32 OTA 存储+响应完成: %u B elapsed=%lld ms free=%lu",
(unsigned int)core.getReceived(), (unsigned int)core.getReceived(),
(long long)((esp_timer_get_time() - t_recv0) / 1000), (long long)((esp_timer_get_time() - t_recv0) / 1000),
(unsigned long)esp_get_free_heap_size()); (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) { if (sha_err != ESP_OK) {
/* 存盘已完成,仍回 status=ok;relay 阶段会再比前端 SHA。 /* 存盘已完成,仍回 status=ok;relay 阶段会再比前端 SHA。
* 此处必须醒目告警,避免只当 INFO 被忽略。 */ * 此处必须醒目告警,避免只当 INFO 被忽略。 */
DEBUG_WARN("WIFI", DEBUG_WARN("WIFI", "GD32 SHA预算失败:%s (relay再校)",
"GD32 上传完成但 SHA-256 预计算失败: %s(仍返回 stored ok,/ota/relay 将再校验)",
esp_err_to_name(sha_err)); 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"); httpd_resp_set_hdr(req, "Connection", "close");
esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp)); esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp));
if (send_err != ESP_OK) { 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), esp_err_to_name(send_err),
(unsigned long)esp_get_free_heap_size(), (unsigned long)esp_get_free_heap_size());
(unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT));
} else { } else {
DEBUG_INFO("WIFI", "GD32 OTA 存储+响应完成: %u / %u free=%lu", DEBUG_INFO("WIFI", "GD32 OTA 存储+响应完成: %u / %u free=%lu",
(unsigned)gd32_offset, expected_fw_size_raw, (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) { 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]; char err_resp[256];
snprintf(err_resp, sizeof(err_resp), snprintf(err_resp, sizeof(err_resp),
"{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", " "{\"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)); httpd_resp_send(req, err_resp, strlen(err_resp));
return ESP_FAIL; return ESP_FAIL;
} else if (verify_err != ESP_OK) { } else if (verify_err != ESP_OK) {
(void)BleOta::instance().resumeStack();
return http_send_json_error(req, "500 Internal Server Error", return http_send_json_error(req, "500 Internal Server Error",
"sha_compute_failed", "SHA compute failed"); "sha_compute_failed", "SHA compute failed");
} }
esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay();
if (trigger_err != ESP_OK) { if (trigger_err != ESP_OK) {
(void)BleOta::instance().resumeStack();
return http_send_json_error(req, "400 Bad Request", return http_send_json_error(req, "400 Bad Request",
"relay_trigger_failed", "relay_trigger_failed",
"relay trigger failed - system busy or firmware not ready"); "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) esp_err_t can_feedback_handler(httpd_req_t *req)
{ {
http_set_cors_headers(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]); valid[i] = getLatestCanFeedback(i, &results[i]);
} }
/* 静态响应缓冲,减少频繁大块堆分配 */ const bool feat_known = SpiMaster::instance().gd32FeatKnown();
static char s_feedback_json[8192]; const uint32_t feat = SpiMaster::instance().gd32FeatFlags();
char *resp = s_feedback_json; const bool ch_en[CAN_CHANNEL_MAX] = {
memset(resp, 0, sizeof(s_feedback_json)); !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) { for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) {
const can_feedback_result_t &r = results[i]; const can_feedback_result_t &r = results[i];
if (i > 0) p += sprintf(p, ","); int off = 0;
p += sprintf(p, "{\"ch\":%u,\"valid\":%s", (unsigned)i, off = feedback_append(work, HTTP_JSON_WORK_CAP, off,
valid[i] ? "true" : "false"); "%s{\"ch\":%u,\"enabled\":%s,\"valid\":%s",
if (valid[i]) { i == 0 ? "" : ",",
p += sprintf(p, (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," ",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu,"
"\"drop\":%lu,\"err\":%lu,\"tec\":%u,\"rec\":%u," "\"drop\":%lu,\"err\":%lu,\"tec\":%u,\"rec\":%u,"
"\"bus_state\":%u,\"count\":%u,\"entries\":[", "\"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.rec,
(unsigned)r.stats.bus_state, (unsigned)r.stats.bus_state,
(unsigned)r.total_count); (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) { for (uint8_t j = 0; j < r.total_count && j < CANFD_ID_SNAPSHOT_MAX; ++j) {
const id_snapshot_entry_t &e = r.entries[j]; const id_snapshot_entry_t &e = r.entries[j];
if (j > 0) p += sprintf(p, ","); uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX)
p += sprintf(p, ? e.dlc : (uint8_t)CANFD_ID_SNAPSHOT_DATA_MAX;
"{\"id\":\"0x%08lX\",\"dlc\":%u,\"fd\":%u," 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\":\"", "\"brs\":%u,\"esi\":%u,\"data\":\"",
emitted ? "," : "",
(unsigned long)e.id, (unsigned long)e.id,
(unsigned)e.dlc, (unsigned)e.dlc,
(unsigned)e.fd_flag, (unsigned)e.fd_flag,
(unsigned)e.brs, (unsigned)e.brs,
(unsigned)e.esi); (unsigned)e.esi);
const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX) if (elen < 0 || (size_t)elen >= sizeof(entry_tmp)) {
? e.dlc : CANFD_ID_SNAPSHOT_DATA_MAX; break;
for (uint8_t d = 0; d < dataLen; ++d) {
p += sprintf(p, "%02X", e.data[d]);
} }
p += sprintf(p, "\"}"); for (uint8_t d = 0; d < dataLen; ++d) {
} int hn = snprintf(entry_tmp + elen, sizeof(entry_tmp) - (size_t)elen,
p += sprintf(p, "]"); "%02X", e.data[d]);
} if (hn != 2) {
p += sprintf(p, "}"); break;
} }
p += sprintf(p, "]}"); 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"); if (off + elen + 2 >= (int)HTTP_JSON_WORK_CAP) {
httpd_resp_send(req, resp, (int)(p - resp)); break; /* 本通道 entries 截断 */
return ESP_OK; }
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); httpd_resp_send(req, resp, len);
return ESP_OK; 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" }, ... ] } * JSON: { "ch": 0, "frames": [ { "id": n, "dlc": n, "data": "HEX" }, ... ] }
* 返回: { "status":"ok", "results":[ ... ], 可选 skipped/abort_* } * 返回: { "status":"ok", "results":[ ... ], 可选 skipped/abort_* }
* 帧间间隔 kInterFrameDelayMs;批量期间暂停 CAN 反馈任务。 * 单帧 canFdSend 超时 2000ms(等应答);多帧 80ms/帧 + 帧间隔。
* 发送期间暂停 CAN 反馈任务。
*/ */
esp_err_t can_send_batch_handler(httpd_req_t *req) 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 kMaxBody = 49152;
constexpr int kMaxFrames = 128; constexpr int kMaxFrames = 128;
constexpr uint32_t kPerFrameTimeoutMs = 80U; constexpr uint32_t kPerFrameTimeoutMs = 80U;
constexpr uint32_t kSingleFrameTimeoutMs = 2000U;
constexpr uint32_t kInterFrameDelayMs = 5U; constexpr uint32_t kInterFrameDelayMs = 5U;
int content_len = req->content_len; 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", return http_send_json_error(req, "400 Bad Request", "bad_frames",
"frames 数量无效(1~128)"); "frames 数量无效(1~128)");
} }
const uint32_t frame_timeout_ms =
(frame_count == 1) ? kSingleFrameTimeoutMs : kPerFrameTimeoutMs;
cJSON *out_root = cJSON_CreateObject(); cJSON *out_root = cJSON_CreateObject();
cJSON *results = cJSON_CreateArray(); 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_AddStringToObject(out_root, "status", "ok");
cJSON_AddItemToObject(out_root, "results", results); cJSON_AddItemToObject(out_root, "results", results);
/* 批量发送期间暂停 CAN 反馈,独占 SPI 业务路径 */ /* 发送期间暂停 CAN 反馈,独占 SPI 业务路径 */
pauseCanFeedbackTask(true); pauseCanFeedbackTask(true);
auto fill_err = [](cJSON *item, int code, uint8_t gd32_err) { 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)); memset(resp_data, 0, sizeof(resp_data));
uint8_t gd32_err = 0U; uint8_t gd32_err = 0U;
int resp_len = canFdSend(ch, ext_id, tx_data, tx_dlc, 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) { if (resp_len >= 0) {
char resp_hex[CAN_MAX_DLC * 2 + 1]; 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), snprintf(resp, sizeof(resp),
"{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}", "{\"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); frame_type, payload_len, resp_type, resp_len, resp_hex);
DEBUG_INFO("SPI", "WEB SPI 响应: type=0x%02X len=%zu payload=%s", /* 日志只记 type/len,完整 payload 仅在 HTTP JSON 返回给前端 */
resp_type, resp_len, resp_hex); DEBUG_INFO("SPI", "WEB SPI ok: type=0x%02X len=%zu",
resp_type, resp_len);
} else { } else {
snprintf(resp, sizeof(resp), snprintf(resp, sizeof(resp),
"{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}", "{\"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; 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) { if (!req || !buf || !len || *len <= 0) {
portENTER_CRITICAL(&s_status_json_init_mux); return true;
if (s_status_json_mux == nullptr) {
s_status_json_mux = xSemaphoreCreateMutex();
}
portEXIT_CRITICAL(&s_status_json_init_mux);
} }
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) static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle)
{ {
if (handle == NULL) return 0; 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; 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 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, offset = append_jsonf(buf, cap, offset,
"\"cpu\":{\"available\":%s,\"valid\":%s,\"usage_pct\":%.2f,\"idle_pct\":%.2f,\"sample_ms\":%lu,\"tasks\":[", "\"cpu\":{\"available\":%s,\"valid\":%s,\"usage_pct\":%.2f,\"idle_pct\":%.2f,\"sample_ms\":%lu,\"tasks\":[",
cpu.available ? "true" : "false", 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, cpu.valid ? cpu.idle_pct : 0.0,
(unsigned long)(cpu.valid ? cpu.sample_ms : 0U)); (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]; const CpuTaskMetric& task = cpu.tasks[i];
offset = append_jsonf(buf, cap, offset, offset = append_jsonf(buf, cap, offset,
"%s{\"name\":\"%s\",\"task_number\":%lu,\"runtime_delta\":%" PRIu64 ",\"usage_pct\":%.2f}", "%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); 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(前端轮询会刷红)。 */ /* 堆极紧时仍返回精简 JSON,禁止 500(前端轮询会刷红)。 */
const size_t largest_now = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); const size_t largest_now = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT);
if (largest_now < 512U) { if (largest_now < 512U) {
@@ -1184,10 +1272,10 @@ esp_err_t system_status_handler(httpd_req_t *req)
return ESP_OK; return ESP_OK;
} }
SemaphoreHandle_t mux = statusJsonMutex(); SemaphoreHandle_t mux = httpJsonWorkMutex();
if (mux) { if (mux) {
if (xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { if (xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) {
/* 并发 status 冲突:仍回简包,避免 200 */ /* 与 feedback/heap_low 争用工作区:仍回简包,避免 500 刷红 */
char busy[160]; char busy[160];
snprintf(busy, sizeof(busy), snprintf(busy, sizeof(busy),
"{\"version\":\"%s\",\"ota\":{\"sys_state\":\"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_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 internal_heap_total = heap_caps_get_total_size(INTERNAL_HEAP_CAPS);
size_t default_heap_total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT); 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<uintptr_t>(a);
const uintptr_t ub = reinterpret_cast<uintptr_t>(b);
return (ub >= ua) ? static_cast<size_t>(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<uintptr_t>(_heap_start);
const size_t static_to_heap =
(heap_start_addr >= static_cast<uintptr_t>(SOC_DRAM_LOW))
? static_cast<size_t>(heap_start_addr - static_cast<uintptr_t>(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), // 任务栈指标复用 health_monitor 共享的任务表(collectTaskStackMetrics),
// 避免与巡检逻辑两地维护;http_server 在本 handler 上下文中,句柄即当前任务。 // 避免与巡检逻辑两地维护;http_server 在本 handler 上下文中,句柄即当前任务。
TaskStackMetric task_metrics[12]; TaskStackMetric task_metrics[12];
size_t task_metrics_count = collectTaskStackMetrics(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 线程中取当前任务更准确 // http_server 行的句柄在 httpd 线程中取当前任务更准确
for (size_t i = 0; i < task_metrics_count; ++i) { for (size_t i = 0; i < task_metrics_count; ++i) {
if (strcmp(task_metrics[i].name, "http_server") == 0) { 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 */ /* CPU:只读 cpu_stats 任务的 1Hz 快照,禁止在 httpd 里 uxTaskGetSystemState */
CpuUsageSnapshot cpu_local = {}; CpuUsageSnapshot *cpu = &s_cpu_snapshot; // 用静态缓冲,不再每秒 calloc(1.2KB)
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;
}
}
CpuStats::instance().getSnapshot(*cpu); CpuStats::instance().getSnapshot(*cpu);
char *resp = s_status_json; char *resp = s_http_json_work;
memset(resp, 0, SYSTEM_STATUS_JSON_CAP); 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, " "{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, "
"\"free_heap\": %lu, \"min_free_heap\": %lu, \"largest_free_block\": %lu, " "\"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_free_heap\": %lu, \"internal_min_free_heap\": %lu, \"internal_largest_free_block\": %lu, "
"\"internal_heap_total\": %lu, \"default_heap_total\": %lu, " "\"internal_heap_total\": %lu, \"default_heap_total\": %lu, "
"\"temperature\": %.1f, \"uptime_sec\": %lu, " "\"temperature\": %.1f, \"uptime_sec\": %lu, "
"\"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, " "\"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, " "\"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\"}, ", "\"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(), 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.sys_state,
(int)snap.channel, (int)snap.channel,
snap.channel == OTA_CHANNEL_BLE ? "BLE" : "WIFI", 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, snap.relay.state,
(unsigned long)snap.relay.seq, (unsigned long)snap.relay.seq,
(unsigned long)snap.relay.total_packets, (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.app_patch, snap.relay.version.app_build,
snap.relay.version.boot_major, snap.relay.version.boot_minor, snap.relay.version.boot_major, snap.relay.version.boot_minor,
snap.relay.version.boot_patch, snap.relay.version.boot_build); 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 锁, /* ── 分块 3:SPI / ADC / features / boot / memory ── */
* OTA 中继期间拿不到锁时返回零值统计,绝不阻塞 /system/status 响应。 */
{ {
SpiMaster::SpiLinkStatus ls = SpiMaster::instance().linkStatus(); SpiMaster::SpiLinkStatus ls = SpiMaster::instance().linkStatus();
const char *role_str = "unknown"; 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; case 2: role_str = "boot"; break;
default: 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\"," ",\"spi_link\":{\"version\":%u,\"gd32_role\":\"%s\",\"session_id\":\"0x%08lX\","
"\"v2_active\":%s,\"local_credit\":%u,\"remote_credit\":%u," "\"v2_active\":%s,\"local_credit\":%u,\"remote_credit\":%u,"
"\"crc_errors\":%lu,\"duplicate_requests\":%lu,\"seq_errors\":%lu," "\"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); (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 尾随逗号叠加 len = SystemBoot::appendStatusJson(resp, HTTP_JSON_WORK_CAP, len);
// 形成 "],," 导致前端 JSON.parse 报 "Expected double-quoted property name"
len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, ",\"task_stack\": {"); /* 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) { 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}", "%s\"%s\":{\"stack_size_bytes\":%lu,\"high_watermark_bytes\":%lu,\"peak_used_bytes\":%lu}",
i == 0 ? "" : ",", i == 0 ? "" : ",",
task_metrics[i].name, 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_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)); (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"); if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) {
httpd_resp_send(req, resp, len > 0 ? len : 0); if (mux) {
if (cpu_heap) { xSemaphoreGive(mux);
free(cpu_heap); }
return ESP_FAIL;
} }
/* 结束 chunked body */
(void)httpd_resp_send_chunk(req, NULL, 0);
if (mux) { if (mux) {
xSemaphoreGive(mux); xSemaphoreGive(mux);
} }
@@ -1386,3 +1636,41 @@ esp_err_t system_probe_handler(httpd_req_t *req)
httpd_resp_send(req, resp, len); httpd_resp_send(req, resp, len);
return ESP_OK; 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;
}
+4 -2
View File
@@ -15,13 +15,15 @@ esp_err_t restart_handler(httpd_req_t *req);
// Utility handlers // Utility handlers
esp_err_t logs_handler(httpd_req_t *req); esp_err_t logs_handler(httpd_req_t *req);
esp_err_t can_send_handler(httpd_req_t *req); /** @brief CAN 发送(唯一入口):同通道 1~N 帧,单帧即 frames 长度为 1。 */
/** @brief 批量 CAN 发送:一次 HTTP 连续发多帧,减少帧间 HTTP 往返延迟。 */
esp_err_t can_send_batch_handler(httpd_req_t *req); esp_err_t can_send_batch_handler(httpd_req_t *req);
esp_err_t spi_send_handler(httpd_req_t *req); esp_err_t spi_send_handler(httpd_req_t *req);
esp_err_t system_status_handler(httpd_req_t *req); esp_err_t system_status_handler(httpd_req_t *req);
esp_err_t system_probe_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)与接收模式 // CAN 反馈快照(被动 0xC1)与接收模式
esp_err_t can_feedback_handler(httpd_req_t *req); esp_err_t can_feedback_handler(httpd_req_t *req);
esp_err_t can_mode_handler(httpd_req_t *req); esp_err_t can_mode_handler(httpd_req_t *req);
+56 -27
View File
@@ -397,9 +397,26 @@ esp_err_t WifiOta::httpServerStart()
config.server_port = HTTP_SERVER_PORT; config.server_port = HTTP_SERVER_PORT;
config.stack_size = HTTP_SERVER_STACK_SIZE; config.stack_size = HTTP_SERVER_STACK_SIZE;
config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检 config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检
/* LWIP_MAX_SOCKETS=12;HTTP 内部约占 3 + UDP 日志 1;业务连接上限 7 */ /* Socket 预算(无法“合并” httpd 内部 3 个 fd:listen + ctrl 对):
config.max_open_sockets = 7; *
/* 满连接时淘汰最久未用的空闲 socket */ * 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; config.lru_purge_enable = true;
/* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉 */ /* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉 */
config.recv_wait_timeout = 10; config.recv_wait_timeout = 10;
@@ -437,7 +454,7 @@ esp_err_t WifiOta::httpServerStart()
{"/logs", HTTP_GET, logs_handler}, {"/logs", HTTP_GET, logs_handler},
{"/system/status", HTTP_GET, system_status_handler}, {"/system/status", HTTP_GET, system_status_handler},
{"/system/probe", HTTP_GET, system_probe_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/send_batch", HTTP_POST, can_send_batch_handler},
{"/can/feedback", HTTP_GET, can_feedback_handler}, {"/can/feedback", HTTP_GET, can_feedback_handler},
{"/can/mode", HTTP_GET, can_mode_handler}, {"/can/mode", HTTP_GET, can_mode_handler},
@@ -445,7 +462,10 @@ esp_err_t WifiOta::httpServerStart()
{"/spi/send", HTTP_POST, spi_send_handler}, {"/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 = {}; httpd_uri_t main_uri = {};
main_uri.uri = r.uri; main_uri.uri = r.uri;
main_uri.method = r.method; main_uri.method = r.method;
@@ -453,15 +473,24 @@ esp_err_t WifiOta::httpServerStart()
main_uri.user_ctx = NULL; main_uri.user_ctx = NULL;
httpd_register_uri_handler(WifiOta::instance().m_httpServer, &main_uri); httpd_register_uri_handler(WifiOta::instance().m_httpServer, &main_uri);
httpd_uri_t opts_uri = {}; bool need_opts = true;
opts_uri.uri = r.uri; for (size_t j = 0; j < i; ++j) {
opts_uri.method = HTTP_OPTIONS; if (strcmp(kHttpRoutes[j].uri, r.uri) == 0) {
opts_uri.handler = http_options_handler; need_opts = false;
opts_uri.user_ctx = NULL; break;
httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri); }
}
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; return ESP_OK;
} }
@@ -562,7 +591,7 @@ bool WifiOta::earlyInit()
DEBUG_ERR("WIFI", "NVS 初始化失败: %s", esp_err_to_name(err)); DEBUG_ERR("WIFI", "NVS 初始化失败: %s", esp_err_to_name(err));
} else { } else {
if (nvs_erased) { if (nvs_erased) {
DEBUG_WARN("WIFI", "NVS 已擦除重建,WiFi PHY 校准数据已清除,将重新校准"); DEBUG_WARN("WIFI", "NVS重建,PHY校准已清,将重校");
} }
DEBUG_INFO("WIFI", "NVS 初始化成功"); DEBUG_INFO("WIFI", "NVS 初始化成功");
} }
@@ -721,7 +750,7 @@ void WifiOta::taskEntry(void *pvParameters)
ota_meta_t boot_meta; ota_meta_t boot_meta;
if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK && if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK &&
boot_meta.total_size > 0 && boot_meta.stored_size < boot_meta.total_size) { 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); (unsigned)boot_meta.stored_size, (unsigned)boot_meta.total_size);
OtaCoordinator::instance().clearGd32Meta(); OtaCoordinator::instance().clearGd32Meta();
OtaCoordinator::instance().abortCurrent(); OtaCoordinator::instance().abortCurrent();
@@ -742,21 +771,21 @@ void WifiOta::taskEntry(void *pvParameters)
// 等待网络栈稳定(ARP 表建立、路由就绪),避免首批 UDP 包丢失 // 等待网络栈稳定(ARP 表建立、路由就绪),避免首批 UDP 包丢失
vTaskDelay(pdMS_TO_TICKS(500)); vTaskDelay(pdMS_TO_TICKS(500));
// 启动摘要,发两遍确保上位机收到(第一包可能触发 ARP 被丢) // 启动摘要(单次;前面已 delay 等网络/ARP 稳定)
esp_chip_info_t chip; esp_chip_info_t chip;
esp_chip_info(&chip); esp_chip_info(&chip);
for (int i = 0; i < 2; i++) { DEBUG_INFO("WIFI", "========================================");
DEBUG_INFO("WIFI", "========================================"); DEBUG_INFO("WIFI", "[启动摘要] ESP32-C3 OTA Relay v%s", FW_VERSION);
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", "[启动摘要] 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", "[启动摘要] 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", "[启动摘要] WiFi: %s", WifiOta::getMode() == OTA_WIFI_MODE_STA ? "STA" : "AP"); DEBUG_INFO("WIFI", "[启动摘要] NVS + WiFi driver + netif + event handler 初始化完成");
DEBUG_INFO("WIFI", "[启动摘要] NVS + WiFi driver + netif + event handler 初始化完成"); DEBUG_INFO("WIFI", "[启动摘要] HTTP 服务器端口: %d", HTTP_SERVER_PORT);
DEBUG_INFO("WIFI", "[启动摘要] HTTP 服务器端口: %d", HTTP_SERVER_PORT); DEBUG_INFO("WIFI", "========================================");
DEBUG_INFO("WIFI", "========================================"); DEBUG_INFO("WIFI", "=== WiFi OTA 服务器就绪 ===");
DEBUG_INFO("WIFI", "=== WiFi OTA 服务器就绪 ===");
if (i == 0) vTaskDelay(pdMS_TO_TICKS(200)); /* 启动日志 burst 已过:环 64→16 槽,释放大约 7.5KB 给业务堆 */
} DebugLog::instance().shrinkRingAfterBoot();
// WiFi 状态监控 // WiFi 状态监控
// 每秒一次循环:每 10 秒检查 RSSI // 每秒一次循环:每 10 秒检查 RSSI
+17 -11
View File
@@ -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; size_t aligned_len = (total + 3) & ~(size_t)3;
if (aligned_offset + aligned_len > part->size) { if (aligned_offset + aligned_len > part->size) {
DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu aligned_off=%" PRIu32 " aligned_len=%zu part=%" PRIu32, DEBUG_ERR("OTA", "flashWrite越界 off=%" PRIu32 " n=%zu part=%" PRIu32,
offset, len, aligned_offset, aligned_len, part->size); offset, len, part->size);
return ESP_ERR_INVALID_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) { if (head_pad > 0 || len % 4 != 0) {
DEBUG_WARN("OTA", "flashWriteChunk 非对齐: offset=%" PRIu32 " len=%zu head=%zu aligned_len=%zu", DEBUG_WARN("OTA", "flashWrite非对齐 off=%" PRIu32 " n=%zu head=%zu",
offset, len, head_pad, aligned_len); offset, len, head_pad);
} }
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len); uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
@@ -485,7 +485,7 @@ esp_err_t Gd32FlashStore::metaVerifyChecksum()
// 若直接返回 OK 跳过校验,meta 被意外清零时会掩盖固件损坏。 // 若直接返回 OK 跳过校验,meta 被意外清零时会掩盖固件损坏。
static const uint8_t kZeroSha[32] = {0}; static const uint8_t kZeroSha[32] = {0};
if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 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; 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; if (!frontend_sha32) return ESP_ERR_INVALID_ARG;
// 1) 计算设备端 SHA 并写入 meta.checksum /* 上传收尾 finishGd32StorageAfterReceive 通常已 metaFinalizeChecksum(并打过 SHA 日志)。
esp_err_t err = metaFinalizeChecksum(); * relay 触发时若 checksum 已有,直接比对,避免整盘重算 + 再打一遍「固件 SHA-256」。 */
ota_meta_t meta;
esp_err_t err = metaLoad(&meta);
if (err != ESP_OK) return err; if (err != ESP_OK) return err;
// 2) 读回 meta 拿到 checksum static const uint8_t kZeroSha[32] = {0};
ota_meta_t meta; if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) {
err = metaLoad(&meta); err = metaFinalizeChecksum();
if (err != ESP_OK) return err; 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); if (out_device_sha) memcpy(out_device_sha, meta.checksum, 32);
// 3) 比对前端 SHA 与设备端 SHA // 3) 比对前端 SHA 与设备端 SHA
+3 -3
View File
@@ -69,7 +69,7 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
static int s_last_spi_err = 0; static int s_last_spi_err = 0;
const int err_i = static_cast<int>(ret); const int err_i = static_cast<int>(ret);
if (s_spi_fail_n == 0U || err_i != s_last_spi_err || (s_spi_fail_n % 32U) == 0U) { 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)channel, (unsigned long)ext_id, esp_err_to_name(ret),
(unsigned long)(s_spi_fail_n + 1U)); (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 uint32_t s_rej_n = 0U;
static uint8_t s_last_rej = 0xFFU; static uint8_t s_last_rej = 0xFFU;
if (s_rej_n == 0U || gd32_err != s_last_rej || (s_rej_n % 32U) == 0U) { 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)channel, (unsigned long)ext_id, resp_type, gd32_err,
(unsigned long)(s_rej_n + 1U)); (unsigned long)(s_rej_n + 1U));
} }
@@ -280,7 +280,7 @@ int canFdSetRxMode(can_rx_mode_t mode,
return 0; 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, (unsigned)mode,
esp_err_to_name(ret), esp_err_to_name(ret),
(unsigned)resp_type, (unsigned)resp_type,
+165 -32
View File
@@ -97,11 +97,19 @@ SpiMaster& SpiMaster::instance()
// SPI Worker:唯一持 SPI 的任务;ISR 只通知本任务 // SPI Worker:唯一持 SPI 的任务;ISR 只通知本任务
// ============================================================ // ============================================================
/** @brief 当前任务是否为 SPI worker(仅 worker 可直接走无锁路径)。 */
bool SpiMaster::onWorker() const bool SpiMaster::onWorker() const
{ {
return m_workerTask != nullptr && xTaskGetCurrentTaskHandle() == m_workerTask; 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) bool SpiMaster::pushEvent(uint8_t type, const uint8_t *payload, size_t len)
{ {
if (m_eventCount >= kEventSlots) { if (m_eventCount >= kEventSlots) {
@@ -126,6 +134,14 @@ bool SpiMaster::pushEvent(uint8_t type, const uint8_t *payload, size_t len)
return true; 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) bool SpiMaster::popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len)
{ {
if (m_eventCount == 0U) { 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; return true;
} }
/**
* @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理。
* @param suppress true=OTA 等关键路径禁止偷读 PB6;false=恢复
*/
void SpiMaster::setSuppressRingService(bool suppress) void SpiMaster::setSuppressRingService(bool suppress)
{ {
m_suppressRingService = suppress; m_suppressRingService = suppress;
@@ -159,6 +179,10 @@ void SpiMaster::setSuppressRingService(bool suppress)
} }
} }
/**
* @brief 业务侧确认本端 TX 序号已被对端接受,推进 m_txSeq。
* @note 在收到明确业务 ACK 后调用,避免重发与序号失配。
*/
void SpiMaster::confirmTxSeqAccepted() void SpiMaster::confirmTxSeqAccepted()
{ {
/* 业务 ACK 已确认:保证下一包使用 lastSentSeq+1,即使头 ack_seq 曾失配 */ /* 业务 ACK 已确认:保证下一包使用 lastSentSeq+1,即使头 ack_seq 曾失配 */
@@ -167,6 +191,10 @@ void SpiMaster::confirmTxSeqAccepted()
m_lastRxAckMatched = true; m_lastRxAckMatched = true;
} }
/**
* @brief worker 单次服务 GD32 按铃:读一帧并入 EVENT 队列。
* @return ESP_OK 入队成功;ESP_ERR_NOT_FOUND 无线/被抑制;其它为读失败
*/
esp_err_t SpiMaster::serviceRingOnce() esp_err_t SpiMaster::serviceRingOnce()
{ {
/* OTA 中继期间禁止按铃读:OTA ACK/WAIT 也拉低 PB6,偷读会导致 poll 空等。 */ /* OTA 中继期间禁止按铃读:OTA ACK/WAIT 也拉低 PB6,偷读会导致 poll 空等。 */
@@ -230,6 +258,10 @@ esp_err_t SpiMaster::serviceRingOnce()
return ESP_OK; return ESP_OK;
} }
/**
* @brief worker 执行一条排队 Job(Transact/Hello/Ping/EnterBoot 等)。
* @param job 入出参:写入 job.result
*/
void SpiMaster::executeJob(Job &job) void SpiMaster::executeJob(Job &job)
{ {
/* 已在 worker 上下文;下列 public API 见 onWorker() 后走实现体,不再二次入队。 */ /* 已在 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) void SpiMaster::finishHeapJob(Job *job)
{ {
if (job == nullptr) { 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) esp_err_t SpiMaster::submitJob(Job &job)
{ {
if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) { if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) {
@@ -363,11 +404,16 @@ esp_err_t SpiMaster::submitJob(Job &job)
return ESP_ERR_TIMEOUT; return ESP_ERR_TIMEOUT;
} }
/** @brief FreeRTOS 任务入口,转到 workerLoop。 */
void SpiMaster::workerEntry(void *arg) void SpiMaster::workerEntry(void *arg)
{ {
static_cast<SpiMaster *>(arg)->workerLoop(); static_cast<SpiMaster *>(arg)->workerLoop();
} }
/**
* @brief SPI worker 主循环:处理按铃 EVENT + 执行 job 队列。
* @note 全栈唯一允许直接访问 SPI 总线的任务上下文。
*/
void SpiMaster::workerLoop() void SpiMaster::workerLoop()
{ {
m_workerTask = xTaskGetCurrentTaskHandle(); 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)) { 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); (unsigned)tx_len, tx_buf, (unsigned)rx_len, rx_buf, m_spiDevice);
return ESP_ERR_INVALID_ARG; 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)); esp_err_to_name(ret), (unsigned)(total_len * 8U));
ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, pdMS_TO_TICKS(1000)); ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, pdMS_TO_TICKS(1000));
if (ret != ESP_OK) { 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)); esp_err_to_name(ret));
/* 尽力排空 ret_queue,避免后续 queue_trans 永久堵死 */ /* 尽力排空 ret_queue,避免后续 queue_trans 永久堵死 */
spi_transaction_t *junk = nullptr; spi_transaction_t *junk = nullptr;
@@ -690,7 +736,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
if (!is_event) { if (!is_event) {
static uint32_t s_sess_mm_logs = 0U; static uint32_t s_sess_mm_logs = 0U;
if ((++s_sess_mm_logs % 32U) == 1U) { 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, (unsigned long)hdr.session_id, (unsigned long)self.m_sessionId,
hdr.type, (unsigned long)s_sess_mm_logs); 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) { if (!self.m_lastRxAckMatched) {
static uint32_t s_stale_logs = 0U; static uint32_t s_stale_logs = 0U;
if ((++s_stale_logs % 32U) == 1U) { 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, hdr.type, hdr.ack_seq, self.m_lastSentSeq,
(unsigned long)s_stale_logs); (unsigned long)s_stale_logs);
} }
@@ -791,7 +837,7 @@ esp_err_t SpiMaster::init()
return ret; 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_MASTER_CS_GPIO, SPI_CLOCK_HZ);
/* 配置 SPI Ready 握手引脚(GPIO10),v2 DATA_READY 模式:仅输入。 /* 配置 SPI Ready 握手引脚(GPIO10),v2 DATA_READY 模式:仅输入。
@@ -873,12 +919,20 @@ esp_err_t SpiMaster::init()
return ESP_OK; return ESP_OK;
} }
/**
* @brief L0 本机自检:SPI 设备、互斥、worker、job 队列是否就绪。
* @return true 本机 SPI 栈可用
*/
bool SpiMaster::selfTestL0() const bool SpiMaster::selfTestL0() const
{ {
return (m_spiDevice != nullptr) && (m_mutex != nullptr) && return (m_spiDevice != nullptr) && (m_mutex != nullptr) &&
(m_workerTask != nullptr) && (m_jobQueue != nullptr); (m_workerTask != nullptr) && (m_jobQueue != nullptr);
} }
/**
* @brief L0 对端轻检:短超时 HELLO,确认链路可达 GD32。
* @return true 至少一次 HELLO 成功
*/
bool SpiMaster::selfTestPeerL0() bool SpiMaster::selfTestPeerL0()
{ {
/* 短超时、最多 2 次;不拉长整体启动 */ /* 短超时、最多 2 次;不拉长整体启动 */
@@ -1415,7 +1469,7 @@ esp_err_t SpiMaster::appRequestBootloader()
} else if (hdr.type == V2_TYPE_LINK_ERROR) { } else if (hdr.type == V2_TYPE_LINK_ERROR) {
const uint8_t errc = (hdr.payload_len > 0U && pload) ? pload[0] : 0xFFU; const uint8_t errc = (hdr.payload_len > 0U && pload) ? pload[0] : 0xFFU;
const uint8_t exp = (hdr.payload_len > 1U && pload) ? pload[1] : 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); errc, exp, (unsigned)m_lastSentSeq);
break; break;
} else if (hdr.type == SPI_OTA_RSP_ERROR) { } else if (hdr.type == SPI_OTA_RSP_ERROR) {
@@ -1441,7 +1495,7 @@ esp_err_t SpiMaster::appRequestBootloader()
/* 静默:APP 擦配置页(可达数十 ms) + 软复位 + BOOT 起机。 /* 静默:APP 擦配置页(可达数十 ms) + 软复位 + BOOT 起机。
* 此间禁止 HELLO,否则 role 会一直像卡在 APP。 */ * 此间禁止 HELLO,否则 role 会一直像卡在 APP。 */
const uint32_t kQuietMs = got_ack ? 100U : 30U; 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); got_ack ? "yes" : "no", (unsigned long)kQuietMs);
vTaskDelay(pdMS_TO_TICKS(kQuietMs)); vTaskDelay(pdMS_TO_TICKS(kQuietMs));
return got_ack ? ESP_OK : ESP_ERR_INVALID_RESPONSE; 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); 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); (unsigned long)timeout_ms, (unsigned long)rounds, (unsigned long)app_hits);
return ESP_ERR_TIMEOUT; 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 变长读)。 * @brief 向 GD32 APP 发送存活探测(帧长发送 + DATA_READY 变长读)。
* *
* 两步法(持锁):otaTransferUnlocked(expect_stale) + otaPollResponseUnlocked。 * 两步法(持锁):otaTransferUnlocked(expect_stale) + otaPollResponseUnlocked。
* GD32 App PING 响应 type=kRspAck(0x80), * 成功时 body 为身份 16B,与 HELLO_ACK 附加块同布局,经 applyAppIdentity16 入库。
* payload 16B = APP ver(4) + BOOT ver(4) + uptime_ms(4) + build_hash(4)。
*/ */
esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version) 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; 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)); memset(version, 0, sizeof(gd32_version_t));
version->app_major = pload[0]; version->app_major = pload[0];
version->app_minor = pload[1]; version->app_minor = pload[1];
version->app_patch = pload[2]; version->app_patch = pload[2];
version->app_build = pload[3]; 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) { DEBUG_DBG("SPI", "APP PING 成功 len=%u", (unsigned)resp_len);
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 成功");
return ESP_OK; return ESP_OK;
} }
// ============================================================ // ============================================================
// SPI Link v2 链路状态 // SPI Link v2 链路状态
// ============================================================ // ============================================================
/**
* @brief 拷贝链路统计快照(有界取锁,失败返回零值)。
* @return 统计结构副本
*/
SpiMaster::SpiLinkStats SpiMaster::linkStats() SpiMaster::SpiLinkStats SpiMaster::linkStats()
{ {
/* OTA 中继期间 SPI worker 在 otaSendAndPoll 全程持有 m_mutex(发送+轮询 ACK, /* OTA 中继期间 SPI worker 在 otaSendAndPoll 全程持有 m_mutex(发送+轮询 ACK,
@@ -1618,6 +1739,10 @@ SpiMaster::SpiLinkStats SpiMaster::linkStats()
return ret; return ret;
} }
/**
* @brief 完整链路状态(session/role/credit + 统计),供 /system/status。
* @return 状态快照
*/
SpiMaster::SpiLinkStatus SpiMaster::linkStatus() SpiMaster::SpiLinkStatus SpiMaster::linkStatus()
{ {
/* 状态字段为 volatile 单字节/双字读,原子性足够;统计复用 linkStats() 的有界取锁。 */ /* 状态字段为 volatile 单字节/双字读,原子性足够;统计复用 linkStats() 的有界取锁。 */
@@ -1631,6 +1756,12 @@ SpiMaster::SpiLinkStatus SpiMaster::linkStatus()
return st; 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) esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint)
{ {
if (!onWorker() && m_workerTask != nullptr) { 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。 /* payload[5]/hdr.credit 均为对端 credit_max → 写入 m_remoteCredit。
* 勿写入 m_localCredit(本端 RX 窗口,init 为 8)。 */ * 勿写入 m_localCredit(本端 RX 窗口,init 为 8)。 */
m_remoteCredit = (pload[5] != 0U) ? pload[5] : hdr.credit; 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_v2Active = true;
m_stats.resyncs++; m_stats.resyncs++;
return ESP_OK; return ESP_OK;
} }
if (!saw_any_frame) { if (!saw_any_frame) {
/* 不打全包 hex:只记 attempts + 前 4 字节便于判 SOF */
DEBUG_WARN("SPI", DEBUG_WARN("SPI",
"HELLO: no valid v2 frame in response (attempts=%lu) rx[0..15]=" "HELLO no v2 frame att=%lu rx0-3=%02X %02X %02X %02X",
"%02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X",
(unsigned long)attempt, (unsigned long)attempt,
last_rx_head[0], last_rx_head[1], last_rx_head[2], last_rx_head[3], 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]);
} else { } else {
DEBUG_WARN("SPI", "HELLO: bad/lag response type=0x%02X flags=0x%02X attempts=%lu", DEBUG_WARN("SPI", "HELLO: bad/lag response type=0x%02X flags=0x%02X attempts=%lu",
last_type, last_flags, (unsigned long)attempt); last_type, last_flags, (unsigned long)attempt);
+41
View File
@@ -99,6 +99,10 @@ public:
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */ /** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; } 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 读取。 * @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。
* 中继开始设 true,结束/复位设 false。 * 中继开始设 true,结束/复位设 false。
@@ -160,6 +164,26 @@ public:
/** @brief 最近一次 HELLO 得到的 GD32 角色(0=unknown, 1=app, 2=boot)。 */ /** @brief 最近一次 HELLO 得到的 GD32 角色(0=unknown, 1=app, 2=boot)。 */
uint8_t gd32Role() const { return m_gd32Role; } 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 获取链路统计快照(线程安全,持锁读取)。 */ /** @brief 获取链路统计快照(线程安全,持锁读取)。 */
SpiLinkStats linkStats(); SpiLinkStats linkStats();
@@ -315,11 +339,24 @@ private:
void executeJob(Job &job); void executeJob(Job &job);
/** @brief worker 完成堆 Job 后的生命周期收尾(give done + 可能 free)。 */ /** @brief worker 完成堆 Job 后的生命周期收尾(give done + 可能 free)。 */
void finishHeapJob(Job *job); void finishHeapJob(Job *job);
/** @brief FreeRTOS 任务入口,转到 workerLoop。 */
static void workerEntry(void *arg); static void workerEntry(void *arg);
/** @brief SPI worker 主循环:EVENT 按铃 + Job 队列。 */
void workerLoop(); void workerLoop();
/** @brief 单次服务 GD32 按铃读帧并入 EVENT 队列。 */
esp_err_t serviceRingOnce(); esp_err_t serviceRingOnce();
/** @brief EVENT 入队(队列满丢最旧)。 */
bool pushEvent(uint8_t type, const uint8_t *payload, size_t len); 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); 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; ///< 本端剩余可接收帧数(信用窗口) volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口)
@@ -332,6 +369,10 @@ private:
volatile uint8_t m_rxAckedSeq = 0; ///< 已确认的对端最新序号 volatile uint8_t m_rxAckedSeq = 0; ///< 已确认的对端最新序号
volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号 volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号
volatile uint8_t m_gd32Role = 0; ///< GD32 角色(0=unknown, 1=app, 2=boot) 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 是否激活 volatile bool m_v2Active = false; ///< v2 session 是否激活
/** 最近一次 otaFindFrame 解析到的 RESPONSE 是否与 m_lastSentSeq 归属匹配 */ /** 最近一次 otaFindFrame 解析到的 RESPONSE 是否与 m_lastSentSeq 归属匹配 */
volatile bool m_lastRxAckMatched = false; volatile bool m_lastRxAckMatched = false;
+12 -4
View File
@@ -22,11 +22,10 @@ CONFIG_DCU_SPI_CLOCK_HZ=4000000
# 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE) # 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE)
# 提到 12 留足缓冲,配合 can_feedback_task 退避从根本上避免 socket 饥荒 # 提到 12 留足缓冲,配合 can_feedback_task 退避从根本上避免 socket 饥荒
CONFIG_LWIP_MAX_SOCKETS=12 CONFIG_LWIP_MAX_SOCKETS=12
# C3 堆紧:WiFi+BLE 双栈时 TCP 窗口过大 → OTA 收流 free 压到 ~5KB → 卡半/堆爆。 # C3 堆紧:窗口过大 OTA 峰值易顶;8KB≈5*MSS,在 4KB OTA 块下吞吐更好,仍比 12KB+ 保守。
# 6144=6KB≈4*MSS:优先压峰值堆;OTA 瓶颈多在 Flash 擦写,吞吐损失通常可接受。
# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。 # 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。
CONFIG_LWIP_TCP_WND_DEFAULT=6144 CONFIG_LWIP_TCP_WND_DEFAULT=8192
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=6144 CONFIG_LWIP_TCP_SND_BUF_DEFAULT=8192
# 乱序队列 pbuf:4→2,少占堆;HTTP 顺序上传基本不需要大 OOSEQ # 乱序队列 pbuf:4→2,少占堆;HTTP 顺序上传基本不需要大 OOSEQ
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=2 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_RX_BUFFER_NUM=16
CONFIG_ESP_WIFI_DYNAMIC_TX_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 # FreeRTOS runtime stats -- /system/status CPU usage
CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_USE_TRACE_FACILITY=y
CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y
CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y
+81
View File
@@ -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]}")
+21
View File
@@ -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
+37
View File
@@ -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}")
+207
View File
@@ -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())
+130
View File
@@ -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"
1 Archive File Total Size DRAM .bss .data .text Flash Code .text Flash Data .tdata .rodata .appdesc RTC SLOW .rtc_reserved .force_slow
2 libnet80211.a 154301 12686 7678 936 4072 128198 128198 13417 0 13417 0 0 0 0
3 libesp_stdio.a 114154 16 16 0 0 402 402 113736 0 113736 0 0 0 0
4 libbtdm_app.a 98094 20783 684 481 19618 72152 72152 5159 0 5159 0 0 0 0
5 liblwip.a 88946 2602 2590 12 0 82916 82916 3428 0 3428 0
6 0 0 0
7 libmain.a 85209 26097 22673 3264 160 57804 57804 1308 0 130 8 0 0 0 0
8 libpp.a 68522 19794 1262 2644 15888 44814 44814 3914 0 3914 0 0 0 0
9 libmbed-builtin.a 57570 60 12 48 0 45710 45710 11800 0 1180 0 0 0 0 0
10 libbt.a 56078 1060 139 233 688 51916 51916 3102 0 3102 0
11 0 0 0
12 libwpa_supplicant.a 37603 1138 1138 0 0 36390 36390 75 0 75 0 0 0 0
13 libphy.a 36923 5801 87 1312 4402 31122 31122 0 0 0 0 0
14 0 0
15 libtfpsacrypto.a 24703 562 400 80 82 23844 23844 297 0 297
16 0 0 0 0
17 libfreertos.a 20942 17688 2560 16 15112 1690 1690 1564 0 15 64 0 0 0 0
18 libesp_hw_support.a 20186 6251 145 160 5946 13170 13170 705 0 705 0 60 24 36
19 libspi_flash.a 17438 15081 10 5867 9204 1322 1322 1035 0 10 35 0 0 0 0
20 libc.a 14702 4 0 4 0 13610 13610 1088 0 1088 0 0 0 0
21 libnvs_flash.a 14264 24 24 0 0 14108 14108 132 0 132 0 0 0 0
22 libtransport.a 14237 1271 50 1145 76 11942 11942 1024 0 102 4 0 0 0 0
23 libhttp_parser.a 12694 0 0 0 0 11980 11980 714 0 714 0 0 0 0
24 libesp_driver_uart.a 11468 225 25 200 0 10782 10782 461 0 4 61 0 0 0 0
25 libesp_system.a 11009 1595 142 49 1404 8866 8866 548 0 548
26 0 0 0 0
27 libesp_http_server.a 10867 0 0 0 0 10528 10528 339 0 339 0 0 0 0
28 libheap.a 10856 6894 8 4 6882 3240 3240 722 0 722 0 0
29 0 0
30 libesp_driver_spi.a 10004 3507 8 131 3368 6068 6068 429 0 4 29 0 0 0 0
31 libesp_netif.a 7572 37 29 8 0 7304 7304 231 0 231 0 0
32 0 0
33 libcoexist.a 6051 461 6 301 154 4316 4316 1274 0 1274 0
34 0 0 0
35 libesp_driver_gpio.a 6008 216 0 36 180 5238 5238 554 0 554
36 0 0 0 0
37 libesp_libc.a 5358 3584 564 324 2696 1622 1622 152 16 136
38 0 0 0 0
39 libesp_wifi.a 5144 703 43 480 180 3618 3618 823 0 823 0
40 0 0 0
41 libcjson.a 5116 20 8 12 0 5096 5096 0 0 0 0 0 0 0
42 libvfs.a 5051 236 44 192 0 4676 4676 139 0 139 0 0 0
43 0
44 libesp_ringbuf.a 4860 2894 0 0 2894 1492 1492 474 0 474 0 0 0 0
45 libesp_hal_mspi.a 4366 3792 0 36 3756 574 574 0 0 0 0 0 0 0
46 libbootloader_support.a 4284 686 8 0 678 3510 3510 88 0 88
47 0 0 0 0
48 libesp_mm.a 4183 1325 48 103 1174 2644 2644 214 0 214 0
49 0 0 0
50 libesp_event.a 3691 4 4 0 0 3546 3546 141 0 141 0 0 0 0
51 libesp_phy.a 3455 248 51 9 188 2908 2908 299 0 299 0 0
52 0 0
53 libesp_timer.a 3035 1364 36 16 1312 1580 1580 91 0 91 0
54 0 0 0
55 libapp_update.a 2996 12 12 0 0 2922 2922 62 0 62 0 0 0 0
56 libbtbb.a 2868 0 0 0 0 2868 2868 0 0 0 0 0 0 0
57 libesp_partition.a 2796 8 8 0 0 2620 2620 168 0 168 0 0 0 0
58 libesp_driver_dma.a 2774 140 0 12 128 2532 2532 102 0 102
59 0 0 0 0
60 libhal.a 2564 2212 5 235 1972 352 352 0 0 0 0 0 0 0
61 libesp_hal_gpspi.a 2550 2194 0 20 2174 284 284 72 0 72 0
62 0 0 0
63 libefuse.a 2461 320 4 316 0 1654 1654 487 0 487 0 0 0 0
64 libesp_driver_usb_serial_jtag.a 2172 157 17 48 92 1874 1874 141 0 141 0 0 0 0
65 libstdc++.a 2056 33 25 8 0 1664 1664 359 0 359 0 0 0
66 0
67 libesp_driver_tsens.a 1942 8 4 4 0 1714 1714 220 0 220 0
68 0 0 0
69 liblog.a 1894 700 276 8 416 1146 1146 48 0 48 0 0 0
70 0
71 libesp_common.a 1774 0 0 0 0 48 48 1726 0 1726 0 0 0
72 0
73 libesp_hal_ana_conv.a 1559 9 0 9 0 1450 1450 100 0 100 0
74 0 0 0
75 libriscv.a 1294 1112 256 0 856 132 132 50 0 50 0 0 0
76 0
77 libesp_hal_security.a 1260 0 0 0 0 1260 1260 0 0 0 0 0
78 0 0
79 libpthread.a 1223 4 4 0 0 1130 1130 89 0 89 0 0 0 0
80 libesp_hal_dma.a 1022 366 0 4 362 656 656 0 0 0 0 0 0 0
81 libesp_hal_wdt.a 1005 1005 0 21 984 0 0 0 0 0 0 0 0
82 0
83 libesp_security.a 922 12 12 0 0 902 902 8 0 8 0 0 0
84 0
85 libesp_app_format.a 770 10 10 0 0 496 496 264 0 8 256 0 0 0
86 libcxx.a 740 16 16 0 0 724 724 0 0 0 0 0 0 0
87 libesp_hal_uart.a 674 0 0 0 0 618 618 56 0 56 0 0 0
88 0
89 libesp_hal_gpio.a 468 0 0 0 0 440 440 28 0 28 0 0 0
90 0
91 libesp_coex.a 428 154 0 80 74 274 274 0 0 0 0 0 0 0
92 libesp_hal_clock.a 404 0 0 0 0 404 404 0 0 0 0 0 0 0
93 libcore.a 366 9 9 0 0 314 314 43 0 43 0 0 0 0
94 libsoc.a 364 0 0 0 0 0 0 364 0 364 0 0 0 0
95 libmesh.a 64 4 4 0 0 60 60 0 0 0 0 0 0 0
96 libmbedtls.a 32 0 0 0 0 24 24 8 0 8 0 0 0 0
97 libespnow.a 3 3 0 3 0 0 0 0 0 0 0 0 0 0
98 libnvs_sec_provider.a 2 0 0 0 0 2 2 0 0 0 0 0 0 0
View File
View File
+217
View File
@@ -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())
+294
View File
@@ -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())
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="css/base.css?v=20260717x"> <link rel="stylesheet" href="css/base.css?v=20260717x">
<link rel="stylesheet" href="css/layout.css?v=20260717x"> <link rel="stylesheet" href="css/layout.css?v=20260717x">
<link rel="stylesheet" href="css/components.css?v=20260717x"> <link rel="stylesheet" href="css/components.css?v=20260717x">
<link rel="stylesheet" href="css/can.css?v=20260717x"> <link rel="stylesheet" href="css/can.css?v=20260719s">
<link rel="stylesheet" href="css/responsive.css?v=20260717x"> <link rel="stylesheet" href="css/responsive.css?v=20260717x">
</head> </head>
<body class="can-page"> <body class="can-page">
@@ -318,6 +318,6 @@
<span id="toastMsg">操作成功</span> <span id="toastMsg">操作成功</span>
</div> </div>
<script type="module" src="js/can.js?v=20260719b"></script> <script type="module" src="js/can.js?v=20260720b"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="css/base.css?v=20260716s"> <link rel="stylesheet" href="css/base.css?v=20260716s">
<link rel="stylesheet" href="css/layout.css?v=20260716s"> <link rel="stylesheet" href="css/layout.css?v=20260716s">
<link rel="stylesheet" href="css/components.css?v=20260716s"> <link rel="stylesheet" href="css/components.css?v=20260716s">
<link rel="stylesheet" href="css/can.css?v=20260716s"> <link rel="stylesheet" href="css/can.css?v=20260719s">
<link rel="stylesheet" href="css/responsive.css?v=20260716s"> <link rel="stylesheet" href="css/responsive.css?v=20260716s">
</head> </head>
<body class="can-page"> <body class="can-page">
@@ -182,6 +182,6 @@
<span id="toastMsg">操作成功</span> <span id="toastMsg">操作成功</span>
</div> </div>
<script type="module" src="js/can_monitor.js?v=20260716v"></script> <script type="module" src="js/can_monitor.js?v=20260719s"></script>
</body> </body>
</html> </html>
+7
View File
@@ -1039,6 +1039,13 @@ body.can-page {
background: transparent; background: transparent;
border: none; border: none;
} }
.feedback-channel.ch-disabled {
opacity: 0.45;
filter: grayscale(0.35);
}
.feedback-channel.ch-disabled .feedback-ch-head {
cursor: default;
}
.feedback-channel { .feedback-channel {
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07); background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07);
border-radius: 10px; padding: 14px 16px; border-radius: 10px; padding: 14px 16px;
+292
View File
@@ -390,6 +390,298 @@
font-size: 11px; 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 强制等宽,避免被其它样式冲掉 */ /* 抽屉内数值/任务名/JSON 强制等宽,避免被其它样式冲掉 */
.sys-drawer-body .heap-bar-pct, .sys-drawer-body .heap-bar-pct,
.sys-drawer-body .heap-detail-row b, .sys-drawer-body .heap-detail-row b,
+50 -17
View File
@@ -11,8 +11,8 @@
<title>ESP32 OTA 中继控制台</title> <title>ESP32 OTA 中继控制台</title>
<link rel="stylesheet" href="css/base.css?v=20260718b"> <link rel="stylesheet" href="css/base.css?v=20260718b">
<link rel="stylesheet" href="css/layout.css?v=20260718b"> <link rel="stylesheet" href="css/layout.css?v=20260718b">
<link rel="stylesheet" href="css/components.css?v=20260718b"> <link rel="stylesheet" href="css/components.css?v=20260719x">
<link rel="stylesheet" href="css/can.css?v=20260718f"> <link rel="stylesheet" href="css/can.css?v=20260719s">
<link rel="stylesheet" href="css/responsive.css?v=20260718b"> <link rel="stylesheet" href="css/responsive.css?v=20260718b">
</head> </head>
<body> <body>
@@ -65,10 +65,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="conn-indicator err" id="connStatus" style="margin-top:12px;"> <!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 -->
<span class="light"></span>
<span id="connStatusTxt">等待连接</span>
</div>
</div> </div>
</div> </div>
@@ -119,6 +116,10 @@
<div class="file-meta-item"><span>类型</span><b id="fileTypeGd32">-</b></div> <div class="file-meta-item"><span>类型</span><b id="fileTypeGd32">-</b></div>
<div class="file-meta-item"><span>大小</span><b id="fileSizeGd32">-</b></div> <div class="file-meta-item"><span>大小</span><b id="fileSizeGd32">-</b></div>
</div> </div>
<div class="file-meta-item" style="margin-top:6px;">
<span>SHA-256</span>
<b id="fileSha256Gd32" style="font-size:10px;word-break:break-all;font-family:monospace;">-</b>
</div>
</div> </div>
</div> </div>
@@ -276,10 +277,12 @@
<div class="card-head"><span class="ico">📊</span> 运行指标</div> <div class="card-head"><span class="ico">📊</span> 运行指标</div>
<div class="card-body"> <div class="card-body">
<div class="metric-grid"> <div class="metric-grid">
<div class="metric"><div class="k">温度</div><div class="v" id="mTemp">--</div></div> <div class="metric"><div class="k">ESP32 温度</div><div class="v" id="mTemp">--</div></div>
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App" style="font-size:14px;">--</div></div> <div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温度">--</div></div>
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot" style="font-size:14px;">--</div></div> <div class="metric"><div class="k">输入 VCC</div><div class="v" id="mVcc" title="PA6 分压反推:VCC—47k—ADC—4.7k—GND">--</div></div>
<div class="metric"><div class="k">OTA 缓存</div><div class="v" id="mOtaCache" style="font-size:13px;">--</div></div> <div class="metric"><div class="k">VDDA</div><div class="v" id="mVdda" title="片内 VREFINT 反推的 MCU 模拟供电">--</div></div>
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App">--</div></div>
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot">--</div></div>
</div> </div>
</div> </div>
</div> </div>
@@ -293,7 +296,6 @@
<div class="status-kv-grid"> <div class="status-kv-grid">
<div class="status-kv"><span class="k">OTA 状态</span><b id="sOtaState">--</b></div> <div class="status-kv"><span class="k">OTA 状态</span><b id="sOtaState">--</b></div>
<div class="status-kv"><span class="k">Relay 状态</span><b id="sRelayState">--</b></div> <div class="status-kv"><span class="k">Relay 状态</span><b id="sRelayState">--</b></div>
<div class="status-kv"><span class="k">缓存进度</span><b id="sOtaStored">--</b></div>
<div class="status-kv"><span class="k">刷新时间</span><b id="sStatusUpdated">--</b></div> <div class="status-kv"><span class="k">刷新时间</span><b id="sStatusUpdated">--</b></div>
</div> </div>
</div> </div>
@@ -315,7 +317,7 @@
</div> </div>
<span class="can-launch-badge">DBC / HEX</span> <span class="can-launch-badge">DBC / HEX</span>
</div> </div>
<p class="can-launch-desc">导入 DBC 后自动读取报文和信号,按物理值实时组帧,并复用现有 <code>/can/send</code> 接口直接发送。</p> <p class="can-launch-desc">导入 DBC 后自动读取报文和信号,按物理值实时组帧,经 <code>/can/send_batch</code> 发送(单帧或多帧)。</p>
<div class="can-launch-points"> <div class="can-launch-points">
<span class="can-launch-point">自动解析 DBC</span> <span class="can-launch-point">自动解析 DBC</span>
<span class="can-launch-point">信号实时组帧</span> <span class="can-launch-point">信号实时组帧</span>
@@ -396,9 +398,35 @@
<div class="status-empty">等待下一次状态刷新...</div> <div class="status-empty">等待下一次状态刷新...</div>
</div> </div>
<div class="status-section-title">堆概况</div> <div class="status-section-title">内存概况</div>
<div class="heap-group"> <div class="heap-group">
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div> <div class="heap-group-title">芯片 SRAM 总览 <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(ESP32-C3 · 静态段可拆分)</span></div>
<div class="heap-bar-row">
<span class="heap-bar-text" id="memSramUsage">--</span>
<span class="heap-bar-track mem-sram-track" id="memSramTrack" title="紫=IRAM · 橙=.data · 灰=.bss · 青=堆已用 · 暗=堆空闲">
<span class="mem-seg mem-seg-iram" id="memSegIram"></span>
<span class="mem-seg mem-seg-data" id="memSegData"></span>
<span class="mem-seg mem-seg-bss" id="memSegBss"></span>
<span class="mem-seg mem-seg-used" id="memSegUsed"></span>
<span class="mem-seg mem-seg-free" id="memSegFree"></span>
</span>
<span class="heap-bar-pct" id="memSramPct">--</span>
</div>
<div class="heap-legend">
<span class="heap-legend-item"><i class="mem-dot mem-dot-iram"></i>IRAM <b id="mIram">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-data"></i>.data <b id="mData">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-bss"></i>.bss <b id="mBss">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-used"></i>堆已用 <b id="mHeapUsed">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-free"></i>堆空闲 <b id="mHeapFree">--</b></span>
</div>
<div class="heap-detail-row">
<span>SOC 窗口: <b id="mSramSoc">--</b></span>
<span>尾部保留: <b id="mReservedTail">--</b></span>
<span>任务栈合计: <b id="mTaskStackTotal">--</b></span>
</div>
</div>
<div class="heap-group">
<div class="heap-group-title">内部 DRAM 堆 <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(malloc 数据堆)</span></div>
<div class="heap-bar-row"> <div class="heap-bar-row">
<span class="heap-bar-text" id="memInternalUsage">--</span> <span class="heap-bar-text" id="memInternalUsage">--</span>
<span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span> <span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span>
@@ -409,6 +437,11 @@
<span>最低空闲: <b id="mInternalMinHeap">--</b></span> <span>最低空闲: <b id="mInternalMinHeap">--</b></span>
<span>最大块: <b id="mInternalLargestBlock">--</b></span> <span>最大块: <b id="mInternalLargestBlock">--</b></span>
</div> </div>
<div class="heap-detail-row">
<span>DMA 空闲: <b id="mDmaFree">--</b></span>
<span>RTC 堆空闲: <b id="mRtcFree">--</b></span>
<span>分配块数: <b id="mAllocBlocks">--</b></span>
</div>
</div> </div>
<div class="status-section-title">任务栈水位 <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim);">(峰值占用 Top,下滑查看)</span></div> <div class="status-section-title">任务栈水位 <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim);">(峰值占用 Top,下滑查看)</span></div>
@@ -436,8 +469,8 @@
<span id="toastMsg">操作成功</span> <span id="toastMsg">操作成功</span>
</div> </div>
<script type="module" src="js/app.js?v=20260719b"></script> <script type="module" src="js/app.js?v=20260720ae"></script>
<script type="module" src="js/ota/console-mode.js?v=20260719b"></script> <script type="module" src="js/ota/console-mode.js?v=20260720ae"></script>
<script type="module" src="js/ble_ota.js?v=20260719b"></script> <script type="module" src="js/ble_ota.js?v=20260720ae"></script>
</body> </body>
</html> </html>
+3 -15
View File
@@ -12,7 +12,7 @@ import { showToast } from './common/toast.js';
import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js'; import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js';
import { import {
updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi,
testConnection, refreshSystemStatusNow, diagnoseNetwork testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl
} from './common/connection.js'; } from './common/connection.js';
import { import {
scheduleStatusPoll, startPolling, pausePollingForUpload, scheduleStatusPoll, startPolling, pausePollingForUpload,
@@ -103,10 +103,6 @@ function applyRebootingRuntimeState() {
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's'; 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'); var bc = document.getElementById('bConn');
if (bc) bc.textContent = '重启中'; if (bc) bc.textContent = '重启中';
if (bc) bc.className = 'v warn'; if (bc) bc.className = 'v warn';
@@ -122,25 +118,16 @@ function applyUploadingRuntimeState() {
var pct = document.getElementById('pillConnTxt'); var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing'; if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '上传中'; 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'); var bc = document.getElementById('bConn');
if (bc) bc.textContent = '上传中'; if (bc) bc.textContent = '上传中';
if (bc) bc.className = 'v warn'; if (bc) bc.className = 'v warn';
} }
/** 设备忙(OTA/中继/重启期间不响应轮询)的连接胶囊/状态条运行时态。 /** 设备忙:只更新顶栏连接胶囊 + 系统状态徽章,不写下方重复文案。 */
* 与上面三兄弟同模式,区别是不改进度/阶段,只标记连接胶囊为 processing + warn。 */
function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) { function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) {
var pc = document.getElementById('pillConn'); var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt'); var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing'; if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = pillText || '设备忙'; 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'); var bc = document.getElementById('bConn');
if (bc) bc.textContent = badgeText || '设备忙'; if (bc) bc.textContent = badgeText || '设备忙';
if (bc) bc.className = 'v warn'; if (bc) bc.className = 'v warn';
@@ -205,6 +192,7 @@ window.updateHealthUi = updateHealthUi;
window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi; window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi;
window.testConnection = testConnection; window.testConnection = testConnection;
window.refreshSystemStatusNow = refreshSystemStatusNow; window.refreshSystemStatusNow = refreshSystemStatusNow;
window.systemStatusUrl = systemStatusUrl;
window.scheduleStatusPoll = scheduleStatusPoll; window.scheduleStatusPoll = scheduleStatusPoll;
window.setStatusPollIntervalMs = setStatusPollIntervalMs; window.setStatusPollIntervalMs = setStatusPollIntervalMs;
window.startPolling = startPolling; window.startPolling = startPolling;
+32 -32
View File
@@ -9,8 +9,9 @@ import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionSta
import { setBleStage, setBleStageText, resetBleStages } from './stages.js'; import { setBleStage, setBleStageText, resetBleStages } from './stages.js';
import { setProgress } from './transfer.js'; import { setProgress } from './transfer.js';
import { hideBleVerifyInfo } from './verify.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 { showToast } from '../common/toast.js';
import { computeFileSha } from '../ota/file-handle.js';
/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */ /** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */
function isAnyChannelUploading() { function isAnyChannelUploading() {
@@ -18,11 +19,8 @@ function isAnyChannelUploading() {
} }
export function updateFileInfo(file) { export function updateFileInfo(file) {
// ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget // ★ 上传进行中禁止重置进度/阶段
// 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...)
// 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。
if (isBleUploading() && file && state.selectedFile) { if (isBleUploading() && file && state.selectedFile) {
// 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段
bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D'); bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D');
return Promise.resolve(); return Promise.resolve();
} }
@@ -43,9 +41,9 @@ export function updateFileInfo(file) {
else els.fileInfo.classList.remove('show'); 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') { 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) { if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W'); bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeAndHash(file); finalizeAndHash(file);
@@ -55,7 +53,6 @@ export function updateFileInfo(file) {
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); 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) + ')'); bleLog('FILE', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name + ' (基地址 ' + baseHex + ', ' + formatBytes(result.file.size) + ')');
showToast('HEX 已自动转换为 BIN', 'success'); showToast('HEX 已自动转换为 BIN', 'success');
// 更新 UI 显示转换后的信息
els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
els.fileType.textContent = 'Binary (HEX转换)'; els.fileType.textContent = 'Binary (HEX转换)';
els.fileSize.textContent = formatBytes(result.file.size); els.fileSize.textContent = formatBytes(result.file.size);
@@ -72,20 +69,16 @@ export function updateFileInfo(file) {
}); });
} }
// 非 HEX 文件直接完成
finalizeAndHash(file); finalizeAndHash(file);
return Promise.resolve(); return Promise.resolve();
} }
/** finalizeFileInfo + computeFileSha256 的统一出口:HEX 转换完成分支与非 HEX 分支共用。 */
function finalizeAndHash(file) { function finalizeAndHash(file) {
finalizeFileInfo(file); finalizeFileInfo(file);
computeFileSha256(file); computeBleFileSha(file);
} }
function finalizeFileInfo(file) { function finalizeFileInfo(file) {
// ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间
// 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。
if (isBleUploading()) { if (isBleUploading()) {
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D'); bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
return; return;
@@ -105,40 +98,43 @@ function finalizeFileInfo(file) {
} }
/** /**
* 计算文件 SHA-256 并推进 BLE stage 流。 * BLE 侧:读对应芯片槽;槽里已有则直接用,没有才算一次。
* stage 推进逻辑收敛在此函数内,避免与 finalizeFileInfo 隐式耦合: * 不跨芯片、不互切。
* - 已连接且 file 非空:进入「计算中」态(stage 3 active + 状态栏文案)
* - 哈希算完:stage 3 done + 推进 stage 4 + 状态栏「文件就绪」
* - file 为空 / 未连接:不碰 stage(由 finalizeFileInfo 的 else 分支 + clearSelectedFile 负责)
*/ */
function computeFileSha256(file) { function computeBleFileSha(file) {
var shaElem = document.getElementById('fileSha256'); var shaElem = document.getElementById('fileSha256');
if (!file || !shaElem) { if (!file) {
if (shaElem) shaElem.textContent = '-'; if (shaElem) shaElem.textContent = '-';
state.fileSha256 = null; state.fileSha256 = null;
return; 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) { if (state.server) {
setBleStage(3, '计算哈希...', 'active'); setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...'); setBleStageText('正在计算文件 SHA-256...');
} }
shaElem.textContent = '计算中...'; computeFileSha(file, chip).then(function(hashHex) {
file.arrayBuffer().then(function(buf) { if (!hashHex) {
return crypto.subtle.digest('SHA-256', buf); state.fileSha256 = null;
}).then(function(hashBuffer) { return;
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase(); }
shaElem.textContent = hashHex;
state.fileSha256 = hashHex; state.fileSha256 = hashHex;
bleLog('FILE', 'SHA-256: ' + hashHex);
if (state.server) { if (state.server) {
setBleStage(3, 'SHA-256 已计算', null); setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null); setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写'); setBleStageText('文件就绪,可以开始刷写');
} }
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
state.fileSha256 = null;
}); });
} }
@@ -219,7 +215,11 @@ export async function notifyFileChange(file, handle) {
return; return;
} }
if (file) { if (file) {
if (handle) state.selectedFileHandle = handle; /* 始终更新 handle(含 null),避免切目标后仍热重载到上一芯片的文件 */
state.selectedFileHandle = handle || null;
await updateFileInfo(file); await updateFileInfo(file);
} else {
state.selectedFileHandle = null;
await updateFileInfo(null);
} }
} }
+16 -14
View File
@@ -3,8 +3,6 @@ import { TARGETS } from './constants.js';
import { addLog } from '../common/log.js'; import { addLog } from '../common/log.js';
import { updateBleStageLabels, resetBleStages } from './stages.js'; import { updateBleStageLabels, resetBleStages } from './stages.js';
import { hideBleVerifyInfo } from './verify.js'; import { hideBleVerifyInfo } from './verify.js';
import { updateFileInfo } from './file.js';
export const state = { export const state = {
device: null, device: null,
server: null, server: null,
@@ -28,7 +26,7 @@ export const state = {
otaResultWaiter: null, otaResultWaiter: null,
otaRunId: 0, otaRunId: 0,
lastEsp32DoneSession: null, lastEsp32DoneSession: null,
fileSha256: null, fileSha256: null, // 当前 BLE 会话用的 SHA(与对应芯片槽同步)
deviceSha256: null, deviceSha256: null,
expectedReboot: false, // 发送 0xFA 后置 true,重连后清 expectedReboot: false, // 发送 0xFA 后置 true,重连后清
reconnecting: false, // 自动重连中 reconnecting: false, // 自动重连中
@@ -46,8 +44,6 @@ export const els = {
abortBtn: $('abortBtn'), abortBtn: $('abortBtn'),
uploadZone: $('uploadZone'), uploadZone: $('uploadZone'),
firmwareFile: $('firmwareFile'), firmwareFile: $('firmwareFile'),
connStatus: $('connStatus'), // 共享连接指示器
connStatusTxt: $('connStatusTxt'), // 共享连接指示器文本
connState: $('connState'), connState: $('connState'),
connBadge: $('connBadge'), connBadge: $('connBadge'),
deviceName: $('deviceName'), deviceName: $('deviceName'),
@@ -115,10 +111,12 @@ export function setReadyHint(html) {
export function setConnectionState(connected) { export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading; const idle = connected && state.selectedFile && !state.uploading;
// 仅在 BLE 模式下更新共享连接指示器与 BLE 专用徽章,避免覆盖 WiFi 轮询的状态 // BLE 模式:连接状态只写顶栏胶囊,避免下方重复文案
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; var pc = document.getElementById('pillConn');
if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接'; var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
if (els.connState) { if (els.connState) {
els.connState.textContent = connected ? '已连接' : '未连接'; els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) { if (els.connState.parentElement) {
@@ -174,17 +172,21 @@ export function resetUiAfterDisconnect() {
export function refreshTargetUi() { export function refreshTargetUi() {
const cfg = currentTarget(); const cfg = currentTarget();
updateBleStageLabels(cfg); updateBleStageLabels(cfg);
// ★ 上传进行中不调用 updateFileInfo:避免目标切换时进度条被重置。 /* 与 WiFi 一致:文件由 syncBleFileFromSelectedTarget 按 flashTarget 绑定;
// 阶段标签更新(updateBleStageLabels)安全,不碰进度条。 * 此处只刷新标签/包数/阶段说明。 */
if (state.selectedFile && !state.uploading && !state.autoFlashing) { if (state.selectedFile) {
updateFileInfo(state.selectedFile); const payloadSize = currentPayloadSize();
const packets = Math.ceil(state.selectedFile.size / payloadSize);
if (els.packetCount) els.packetCount.textContent = String(packets);
} }
setConnectionState(!!state.server); setConnectionState(!!state.server);
// 同步目标胶囊标签
var espTab = document.getElementById('targetEsp32Tab'); var espTab = document.getElementById('targetEsp32Tab');
var gdTab = document.getElementById('targetGd32Tab'); var gdTab = document.getElementById('targetGd32Tab');
var isEsp = cfg.label === 'ESP32'; var isEsp = cfg.label === 'ESP32';
if (espTab) espTab.classList.toggle('active', isEsp); if (espTab) espTab.classList.toggle('active', isEsp);
if (gdTab) gdTab.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';
}
} }
+13 -36
View File
@@ -6,6 +6,7 @@ import { showBleVerifyInfo, hideBleVerifyInfo } from './verify.js';
import { clearOtaResultWaiter, waitForOtaResult, nextOtaSessionId } from './session.js'; import { clearOtaResultWaiter, waitForOtaResult, nextOtaSessionId } from './session.js';
import { enableNotifications } from './gatt.js'; import { enableNotifications } from './gatt.js';
import { syncSelectedFileFromInput, updateFileInfo } from './file.js'; import { syncSelectedFileFromInput, updateFileInfo } from './file.js';
import { computeFileSha } from '../ota/file-handle.js';
import { formatBytes } from '../common/utils.js'; import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js'; import { showToast } from '../common/toast.js';
@@ -105,17 +106,15 @@ export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
/** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */ /** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */
export async function uploadFirmware() { export async function uploadFirmware() {
// 每次刷写前尝试重新从本地读取文件 // 热重载:有 handle 则重新读盘(本地编译会改同路径固件);否则用已选/input
if (state.selectedFileHandle) { if (state.selectedFileHandle) {
try { try {
const freshFile = await state.selectedFileHandle.getFile(); const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile); await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
} catch (e) { } catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
} }
} else { } else if (!state.selectedFile) {
await syncSelectedFileFromInput(); await syncSelectedFileFromInput();
} }
if (!state.selectedFile || !state.dataInChar) return false; 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'); bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
} }
// SHA-256 已在文件选择时(updateFileInfo)计算并存入 state.fileSha256。 /* 上传前始终按本次读到的内容重算 SHA,不沿用选文件时的旧值 */
// 此处不再重复打日志(避免与 [FILE] SHA-256 重复),仅保证 fileSha256 变量有值。 var chipKey = (cfg.label === 'GD32') ? 'gd32' : 'esp32';
var fileSha256; var shaHex = await computeFileSha(file, chipKey, fileBytes.buffer);
if (state.fileSha256) { if (!shaHex) throw new Error('SHA-256 计算失败');
fileSha256 = state.fileSha256.toLowerCase(); state.fileSha256 = shaHex;
} else { var fileSha256 = shaHex.toLowerCase();
// 极端兜底: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
var shaElem = document.getElementById('fileSha256'); var shaElem = document.getElementById('fileSha256');
if (shaElem) shaElem.textContent = fileSha256.toUpperCase(); if (shaElem) shaElem.textContent = shaHex.toUpperCase();
setBleStage(4, '写入中...', null); setBleStage(4, '写入中...', null);
setBleStageText(`正在写入 ${packetTotal} 包数据...`); setBleStageText(`正在写入 ${packetTotal} 包数据...`);
@@ -464,29 +455,15 @@ export async function uploadFirmware() {
/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */ /** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */
export async function autoFlash() { export async function autoFlash() {
// 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择) /* 与 uploadFirmware 一致:有 handle 则重新读盘,SHA 在 upload 内按内容重算 */
if (state.selectedFileHandle) { if (state.selectedFileHandle) {
try { try {
const freshFile = await state.selectedFileHandle.getFile(); const freshFile = await state.selectedFileHandle.getFile();
// 仅在文件变化时才刷新元数据并重算 SHA-256 await updateFileInfo(freshFile);
// (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');
}
} catch (e) { } catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
} }
} else { } else if (!state.selectedFile) {
await syncSelectedFileFromInput(); await syncSelectedFileFromInput();
} }
if (!state.selectedFile || !state.server) return; if (!state.selectedFile || !state.server) return;
+1
View File
@@ -24,6 +24,7 @@ window.bleOta = {
resetBleStages(); resetBleStages();
bleLog('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`); bleLog('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`);
}, },
state: state,
disconnect: disconnectBle, disconnect: disconnectBle,
showToast: showToast, showToast: showToast,
notifyFileChange: notifyFileChange, notifyFileChange: notifyFileChange,
+2 -2
View File
@@ -43,7 +43,7 @@ import {
sendManualFrame, sendManualFrame,
resetManualFields, resetManualFields,
loadPreviewToManual loadPreviewToManual
} from './can/send.js?v=20260717x'; } from './can/send.js?v=20260720b';
import { import {
scheduleCanPersist, scheduleCanPersist,
loadCanSession, loadCanSession,
@@ -51,7 +51,7 @@ import {
} from './can/persist.js?v=20260717x'; } from './can/persist.js?v=20260717x';
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717x'; import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717x';
import { showToast } from './common/toast.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), // 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h),
+49 -84
View File
@@ -1,8 +1,8 @@
/** /**
* send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史) * send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史)
* *
* 包含:sendCanRequest、响应框、历史、批量/选中/手动发送、 * 包含:sendCanBatchRequest / sendCanOneFrame(仅 /can/send_batch)、响应框、历史、
* 手动帧校验/重置/载入预览、CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText。 * 批量/选中/手动发送、手动帧校验/重置/载入预览、错误码说明。
* *
* 依赖:dbc.js(state/el/getBaseUrl/padHex/bytesToHex/parseNumericId)、 * 依赖:dbc.js(state/el/getBaseUrl/padHex/bytesToHex/parseNumericId)、
* common/toast.js(showToast)、common/utils.js(escapeHtml)、 * common/toast.js(showToast)、common/utils.js(escapeHtml)、
@@ -28,6 +28,9 @@ function formatTime(date) {
return hh + ':' + mm + ':' + ss; return hh + ':' + mm + ':' + ss;
} }
/** 简单延时 helper,用于批次间间隔 */
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// ==================== 发送错误码描述 ==================== // ==================== 发送错误码描述 ====================
var CAN_SEND_ERR_DESC = { var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)', '-1': '参数错误(通道/ID/DLC 无效)',
@@ -64,54 +67,36 @@ function buildSendResponseText(result) {
return '发送失败: ' + desc + ' (code=' + result.code + ')'; return '发送失败: ' + desc + ' (code=' + result.code + ')';
} }
// ==================== HTTP 请求 ==================== // ==================== HTTP 请求(唯一入口 /can/send_batch)====================
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;
}
/** /**
* @description 一次 HTTP 连续发送多帧(设备 /can/send_batch)。 * @description 同通道发送 1~N 帧。单帧也走 batch(frames 长度 1)。
* @returns {Promise<object[]>} 与 frames 等长的 results * @returns {Promise<object[]>} 与 frames 等长的 results
*/ */
async function sendCanBatchRequest(channel, frames) { 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', { var resp = await fetch(getBaseUrl() + '/can/send_batch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ch: channel, frames: frames }), 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 text = await resp.text();
var json = {}; var json = {};
try { try {
json = text ? JSON.parse(text) : {}; json = text ? JSON.parse(text) : {};
} catch (err) { } catch (err) {
throw new Error('批量响应不是合法 JSON: ' + text); throw new Error('CAN 响应不是合法 JSON: ' + text);
} }
if (!resp.ok) { 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)) { if (!json.results || !Array.isArray(json.results)) {
throw new Error('批量响应缺少 results'); throw new Error('响应缺少 results');
} }
/* 设备可能返回 skipped;补齐与 frames 等长的 results */
var results = json.results.slice(); var results = json.results.slice();
var need = frames.length - results.length; var need = n - results.length;
if (need > 0) { if (need > 0) {
var pad = { var pad = {
status: 'error', status: 'error',
@@ -123,6 +108,12 @@ async function sendCanBatchRequest(channel, frames) {
return results; 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) { function updateResponseBox(box, textEl, result, mode) {
if (!box || !textEl) return; 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 批量发送所有勾选的报文。 * @description 批量发送所有勾选的报文(仅 /can/send_batch)。
* 优先 POST /can/send_batch;不支持时回退逐帧 /can/send。
* 批量过程中不重绘历史,结束后统一刷新。 * 批量过程中不重绘历史,结束后统一刷新。
*/ */
export async function sendCheckedMessages() { export async function sendCheckedMessages() {
@@ -246,28 +241,12 @@ export async function sendCheckedMessages() {
}); });
} }
/* 分块批量接口;不支持时整段回退为无间隔单帧 */
var useBatchApi = true;
for (var off = 0; off < prepared.length; ) { for (var off = 0; off < prepared.length; ) {
var chunk = prepared.slice(off, off + BATCH_CHUNK); var chunk = prepared.slice(off, off + BATCH_CHUNK);
off += chunk.length; off += chunk.length;
var frames = chunk.map(function (p) { return p.frame; }); var frames = chunk.map(function (p) { return p.frame; });
var results = null; try {
var results = await sendCanBatchRequest(channel, frames);
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) {
for (var r = 0; r < chunk.length; r++) { for (var r = 0; r < chunk.length; r++) {
var item = chunk[r]; var item = chunk[r];
var result = results[r] || { status: 'error', code: -3 }; var result = results[r] || { status: 'error', code: -3 };
@@ -278,27 +257,10 @@ export async function sendCheckedMessages() {
resultText: item.msg.name + ' · ' + buildSendResponseText(result) resultText: item.msg.name + ' · ' + buildSendResponseText(result)
}), silent); }), silent);
} }
continue; } catch (err) {
} for (var s = 0; s < chunk.length; s++) {
/* 回退:逐帧 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) {
fail++; fail++;
var one = chunk[s];
addHistoryEntry(makeHistoryEntry({ addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: false, channel: channel, mode: 'BATCH', ok: false, channel: channel,
idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex, idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex,
@@ -306,6 +268,11 @@ export async function sendCheckedMessages() {
}), silent); }), silent);
} }
} }
/* 批次间间隔:还有下一批时等待,给 CAN/SPI 总线喘息时间 */
if (off < prepared.length) {
await sleep(BATCH_INTER_CHUNK_DELAY_MS);
}
} }
renderHistory(); 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() { export async function sendSelectedMessage() {
var msg = getSelectedMessage(); var msg = getSelectedMessage();
if (!msg) { if (!msg) {
@@ -332,19 +299,18 @@ export async function sendSelectedMessage() {
return; return;
} }
var channel = parseInt(el.defaultChannel.value, 10) || 0; var channel = parseInt(el.defaultChannel.value, 10) || 0;
var payload = { var dataHex = bytesToHex(state.generatedData);
ch: channel,
id: msg.id,
dlc: msg.dlc,
data: bytesToHex(state.generatedData)
};
try { 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'); updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc');
addHistoryEntry(makeHistoryEntry({ addHistoryEntry(makeHistoryEntry({
mode: 'DBC', ok: result.status === 'ok', channel: channel, mode: 'DBC', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dataHex: bytesToHex(state.generatedData), dlc: msg.dlc, dataHex: dataHex,
resultText: buildSendResponseText(result) resultText: buildSendResponseText(result)
})); }));
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
@@ -357,7 +323,7 @@ export async function sendSelectedMessage() {
addHistoryEntry(makeHistoryEntry({ addHistoryEntry(makeHistoryEntry({
mode: 'DBC', ok: false, channel: channel, mode: 'DBC', ok: false, channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dataHex: bytesToHex(state.generatedData), dlc: msg.dlc, dataHex: dataHex,
resultText: err.message resultText: err.message
})); }));
showToast('发送失败: ' + err.message, 'error'); showToast('发送失败: ' + err.message, 'error');
@@ -415,8 +381,7 @@ export async function sendManualFrame() {
} }
try { try {
var result = await sendCanRequest({ var result = await sendCanOneFrame(channel, {
ch: channel,
id: id, id: id,
dlc: dlc, dlc: dlc,
data: dataHex data: dataHex
+25
View File
@@ -74,6 +74,17 @@ async function pollFeedback() {
for (var i = 0; i < 3; i++) { for (var i = 0; i < 3; i++) {
var ch = channels[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) { if (!ch || !ch.valid) {
renderChannelEmpty(i); renderChannelEmpty(i);
continue; 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'; } 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) { function renderIdTable(entries) {
var tbody = el.idsnapBody; var tbody = el.idsnapBody;
if (!tbody) return; if (!tbody) return;
+616 -20
View File
@@ -1,7 +1,7 @@
/** /**
* connection.js - 连接管理 + 系统状态显示 * connection.js - 连接管理 + 系统状态显示
* *
* 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。 * 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。
* *
* 共享状态(通过 window.* 引用): * 共享状态(通过 window.* 引用):
* window.latestSystemStatus — 最近一次 /system/status 响应 * window.latestSystemStatus — 最近一次 /system/status 响应
@@ -15,23 +15,21 @@
import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js'; import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js';
import { addLog } from './log.js'; import { addLog } from './log.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import {
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
} from './heap-low-history.js';
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) { export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接'); var label = text || (connected ? '已连接' : '未连接');
// header pill // 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = document.getElementById('pillConn'); var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt'); var pct = document.getElementById('pillConnTxt');
if (pc) { if (pc) {
pc.className = connected ? 'pill online' : 'pill offline'; pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = label; 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'); var bc = document.getElementById('bConn');
if (bc) { if (bc) {
bc.textContent = connected ? '在线' : '离线'; bc.textContent = connected ? '在线' : '离线';
@@ -41,10 +39,7 @@ export function updateConnectionUi(connected, text) {
export function updateHealthUi(healthCode, freeHeap) { export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机']; var healthNames = ['正常', '警告', '危险', '关机'];
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知'; var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
var connStatusTxt = document.getElementById('connStatusTxt'); /* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */
if (connStatusTxt && window.latestSystemStatus && window.latestSystemStatus.wifi) {
connStatusTxt.textContent = (window.latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
}
if (freeHeap !== undefined) { if (freeHeap !== undefined) {
var mHeapEl = document.getElementById('mHeap'); var mHeapEl = document.getElementById('mHeap');
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap); if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
@@ -252,22 +247,167 @@ export function updateHeapAndTaskStatusUi(data) {
} }
} }
updateCpuUsageUi(data.cpu); 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('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('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) : '--'); 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); 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) : '--'); 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('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) : '--'); 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.sys_state_code)) window.lastSysStateCode = ota.sys_state_code;
if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state; 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())); setText('sStatusUpdated', formatTime(new Date()));
var taskList = document.getElementById('taskStackList'); var taskList = document.getElementById('taskStackList');
@@ -355,16 +495,28 @@ export async function diagnoseNetwork() {
return result.join(';'); 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,超时后中断, * 统一的 /system/status 抓取器:独立 AbortController,超时后中断,
* 避免与递归调度的 pollStatus 互相干扰。 * 避免与递归调度的 pollStatus 互相干扰。
* 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。 * 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。
*/ */
async function fetchSystemStatus(timeoutMs) { async function fetchSystemStatus(timeoutMs, forceLayout) {
var ac = new AbortController(); var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000); var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
try { try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal }); var resp = await fetch(systemStatusUrl(!!forceLayout), { signal: ac.signal });
if (!resp.ok) { if (!resp.ok) {
var err = new Error('HTTP ' + resp.status); var err = new Error('HTTP ' + resp.status);
err.status = resp.status; err.status = resp.status;
@@ -407,7 +559,8 @@ export async function refreshSystemStatusNow(event) {
event.stopPropagation(); event.stopPropagation();
} }
try { try {
var data = await fetchSystemStatus(3000); /* 手动刷新强制带 layout,便于换固件后更新静态段 */
var data = await fetchSystemStatus(3000, true);
window.latestSystemStatus = data; window.latestSystemStatus = data;
window.updateSystemStatusUi(data); window.updateSystemStatusUi(data);
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
@@ -422,3 +575,446 @@ export async function refreshSystemStatusNow(event) {
showToast('状态刷新失败', 'error'); 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 '<tr class="heap-low-sub-row ' + cls + '"><td colspan="8" class="heap-low-sub-cell">' +
nm + '<span class="heap-low-stack-rem">' + rem + '</span></td></tr>';
}).join('');
var stackSummary = stacks.length
? (stacks[0].name + ' ' + formatBytes(stacks[0].remaining) + ' <span class="heap-low-expand-hint">▾' + stacks.length + '</span>')
: '--';
// ★ 行 id 用 segTag 前缀避免设备当前段与历史段 idx 冲突
var rowId = segTag + '_' + idx;
return '<tr class="heap-low-main-row" data-idx="' + rowId + '" data-seg="' + segTag + '">' +
'<td>' + ts + '</td>' +
'<td>' + formatBytes(r.min_free_heap) + '</td>' +
'<td>' + formatBytes(r.free_heap) + '</td>' +
'<td>' + formatBytes(r.largest_free_block) + '</td>' +
'<td>' + state + '</td>' +
'<td>' + conn + '</td>' +
'<td>' + otaProgress + '</td>' +
'<td>' + formatBytes(r.default_free_heap) + '</td>' +
'<td class="' + diagCls + '">' + diag + '</td>' +
'<td>' + stackSummary + '</td>' +
'</tr>' +
'<tr class="heap-low-stack-detail" data-idx="' + rowId + '" hidden><td colspan="10">' +
'<div class="heap-low-detail-meta">' +
'<span>采样任务: <b>' + curTask + '</b> <span class="heap-low-caller-hint">(巡检发现者,不是 alloc 触发者)</span></span>' +
'<span>内部堆分配数: <b>' + (Number.isFinite(r.alloc_count) ? r.alloc_count : '--') + '</b></span>' +
'<span>IP事件计数: <b>' + (Number.isFinite(r.ip_event_count) ? r.ip_event_count : '--') + '</b></span>' +
(r.saved_at_ms ? '<span>保存于: <b>' + formatTime(new Date(r.saved_at_ms)) + '</b></span>' : '') +
'</div>' +
'<div class="heap-low-hook-meta">' +
'<div class="heap-low-hook-title">heap hook:破新低时的「最后一击」alloc' +
(Number(r.hook_fired) === 1
? ''
: ' <span class="heap-low-caller-hint">(hook 未命中)</span>') +
'</div>' +
'<span>分配类别: <b>' + escapeHtml(r.hook_task || '--') + '</b> <span class="heap-low-caller-hint">net?=小中块/疑网络 · bulk=大块 · tiny=更小</span></span>' +
'<span>本次alloc: <b>' + (Number.isFinite(r.hook_alloc_size) && Number(r.hook_alloc_size) > 0 ? formatBytes(r.hook_alloc_size) : '--') + '</b></span>' +
'<span>alloc后est空闲: <b>' + (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--') + '</b></span>' +
'<span>调用者地址: <b class="heap-low-caller">0x' + (Number.isFinite(r.hook_caller_addr) && Number(r.hook_caller_addr) > 0 ? r.hook_caller_addr.toString(16).toUpperCase() : '--') + '</b>' +
' <span class="heap-low-caller-hint">(addr2line)</span></span>' +
'</div>' +
'<table class="heap-low-stack-table"><thead><tr><th>任务</th><th>剩余栈</th></tr></thead>' +
'<tbody>' + (stackRows || '<tr><td colspan="2">无</td></tr>') + '</tbody>' +
'</table>' +
'</td></tr>';
}
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 '<div class="heap-low-section heap-low-section-' + segTag + '">' +
'<div class="heap-low-section-title">' + title +
' <span class="heap-low-sub">' + records.length + ' 条' +
(totalCount !== undefined && totalCount > records.length ? ' / 共 ' + totalCount : '') +
'</span>' +
'</div>' +
'<table class="heap-low-table">' +
'<thead><tr>' +
'<th>启动后</th><th>最低空闲</th><th>当时空闲</th><th>最大块</th>' +
'<th>OTA状态</th><th>连接</th><th>OTA进度</th><th>默认堆</th>' +
'<th>诊断</th><th>最危险栈 ▾</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>';
}
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 = '<div class="heap-low-empty">尚无"最低空闲创新低"记录</div>';
} 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 '' +
'<div class="heap-low-title">堆"最低空闲"创新低记录' +
'<span class="heap-low-sub">设备 ' + live.length + ' 条 + 历史 ' + hist.length + ' 条</span>' +
'<button class="heap-low-clear" type="button" title="清空浏览器历史(不访问设备)">清空</button>' +
'<button class="heap-low-close" type="button" aria-label="关闭" title="关闭">&times;</button>' +
'</div>' +
'<div class="heap-low-table-wrap">' + bodyHtml + '</div>' +
'<div class="heap-low-tip">设备只保留最新 1 条;多条历史在浏览器 IndexedDB(最多 '
+ HEAP_HISTORY_MAX
+ ' 条,约 5s 拉取 + status 新低时立即拉取)。「清空」只清本地历史,不访问设备。状态栏「最低空闲」需重启才清。</div>';
}
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);
}
+196
View File
@@ -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<number>:本次实际新增的条数。 */
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<Array>。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<boolean>。 */
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<number>。 */
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;
}
+32 -9
View File
@@ -156,6 +156,8 @@ function markEsp32RebootComplete(source) {
if (!window.isEsp32Rebooting) return; if (!window.isEsp32Rebooting) return;
window.isEsp32Rebooting = false; window.isEsp32Rebooting = false;
window.rebootObservedDown = false; window.rebootObservedDown = false;
/* 重启后重新拉 memory.layout(换固件后静态段可能变化) */
window._memLayout = null;
window.updateEsp32StagesFromPhase(6); // 启动完成 window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线'); addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success'); showToast('ESP32 已恢复在线', 'success');
@@ -187,7 +189,11 @@ export async function pollStatus() {
statusAbortCtrl = new AbortController(); statusAbortCtrl = new AbortController();
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000); var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000);
try { 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 signal: statusAbortCtrl.signal
}); });
if (resp.ok) { if (resp.ok) {
@@ -229,24 +235,41 @@ export async function pollStatus() {
clearTimeout(pollTimer); clearTimeout(pollTimer);
// 自身 AbortController 触发的取消不算失败,静默返回 // 自身 AbortController 触发的取消不算失败,静默返回
if (e && e.name === "AbortError") return; 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 分钟/次, // 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。 // 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
if (pageVisible) window.consecutivePollFailures++; // OTA 期间也不递增:设备忙/重启无法响应是正常的,不该计入"诊断触发计数"。
var lastDeviceBusy = isLastDeviceBusy(); if (pageVisible && !otaOngoing0) window.consecutivePollFailures++;
var lastDeviceBusy = lastDeviceBusy0;
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的) // OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈 // 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。 // ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
// FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。 // FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。
var sessionActive = window.otaSessionActive(); var sessionActive = sessionActive0;
var sessionFailed = window.otaSessionState === window.OtaSessionState.FAILED; var sessionFailed = sessionFailed0;
if (pageVisible && window.consecutivePollFailures >= 5 && !sessionActive && !window.isEsp32Rebooting && !window.currentOtaTarget && !lastDeviceBusy && !sessionFailed) { // ★ 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 次无响应,正在诊断网络...'); addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork(); var diag = await diagnoseNetwork();
addLog('W', '诊断', diag); addLog('W', '诊断', diag);
updateConnectionUi(false, '未连接'); updateConnectionUi(false, '诊断中');
window.applyDisconnectedRuntimeState(); window.applyDisconnectedRuntimeState();
var cst = document.getElementById('connStatusTxt'); addLog('W', '诊断', '诊断结果: ' + diag);
if (cst) cst.textContent = '诊断中: ' + diag;
} }
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接" // ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
if (sessionFailed) { if (sessionFailed) {
+111 -71
View File
@@ -94,33 +94,41 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32'; window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget); persistFlashTarget(window.flashTarget);
applySharedModeUi(); applySharedModeUi();
syncBleFileFromSelectedTarget(); /* 与 WiFi 一致:两路文件独立;切换只换「当前目标」面板 + 绑定该路文件到会话 */
if (window.connectionMode === 'ble') { if (window.connectionMode === 'ble') {
window.switchStageFlow('ble'); window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') { 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 { } else {
window.switchStageFlow(window.flashTarget); window.switchStageFlow(window.flashTarget);
} }
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
window.bleOta.refreshTargetUi();
}
}; };
window.handleSharedFileSelect = function (event, chip) { 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'; window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget); persistFlashTarget(window.flashTarget);
applySharedModeUi(); 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) { window.pickSharedFirmware = function (chip) {
@@ -137,22 +145,25 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
}); });
}; };
Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () { Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () {
window.showOpenFilePicker = nativePicker; /* 用 processFile 后的结果(GD32 HEX→BIN 后的 prepared),不要再塞回原始 .hex */
if (!capturedHandle || typeof capturedHandle.getFile !== 'function') { var prepared = chip === 'gd32' ? window.gd32File : window.esp32File;
syncBleFileFromSelectedTarget(); var handle = (chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle) || capturedHandle;
return; 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) { syncBleFileFromSelectedTarget();
window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle };
syncBleFileFromSelectedTarget();
}).catch(function () {
syncBleFileFromSelectedTarget();
});
}).catch(function (err) { }).catch(function (err) {
window.showOpenFilePicker = nativePicker;
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') { 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 { } else {
var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32'); 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 () { window.syncBleFileFromSelectedTarget = function () {
if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') { if (window.connectionMode !== 'ble' || !window.bleOta) {
return; return;
} }
var isGd32 = window.flashTarget === 'gd32'; if (window.bleOta.isUploading && window.bleOta.isUploading()) {
var chip = isGd32 ? 'gd32' : 'esp32'; return;
var suffix = isGd32 ? 'Gd32' : 'Esp32'; }
var stored = window.sharedFirmwareFiles[chip]; var chip = window.flashTarget === 'gd32' ? 'gd32' : 'esp32';
if (stored && stored.handle && typeof stored.handle.getFile === 'function') { var file = chip === 'gd32' ? window.gd32File : window.esp32File;
stored.handle.getFile().then(function (file) { var handle = chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle;
stored.file = file; var stored = window.sharedFirmwareFiles && window.sharedFirmwareFiles[chip]
setBleFirmwareInputFile(file); ? window.sharedFirmwareFiles[chip]
window.bleOta.notifyFileChange(file, stored.handle); : null;
}).catch(function () { if (!handle && stored && stored.handle) handle = stored.handle;
if (stored.file) { if (!file && stored && stored.file) file = stored.file;
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle); 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; return;
} }
if (stored && stored.file) { var suffix = chip === 'gd32' ? 'Gd32' : 'Esp32';
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle || null);
return;
}
var input = document.getElementById('fileInput' + suffix); var input = document.getElementById('fileInput' + suffix);
var info = document.getElementById('fileInfo' + suffix); var inputFile = input && input.files && input.files[0] ? input.files[0] : null;
var hasInfo = info && info.classList.contains('show'); if (inputFile) {
var file = input && input.files && input.files[0] ? input.files[0] : null; if (window.sharedFirmwareFiles) {
if (file) { window.sharedFirmwareFiles[chip] = { file: inputFile, handle: null };
window.sharedFirmwareFiles[chip] = { file: file, handle: null }; }
setBleFirmwareInputFile(file); bindBle(inputFile, null);
window.bleOta.notifyFileChange(file, null);
return; return;
} }
if (!hasInfo) { clearBleSession();
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';
}; };
window.applySharedModeUi = function () { window.applySharedModeUi = function () {
@@ -260,9 +294,15 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
if (el) el.style.display = ids[id] ? '' : 'none'; if (el) el.style.display = ids[id] ? '' : 'none';
}); });
var hint = document.getElementById('readyHint'); var hint = document.getElementById('readyHint');
var autoBtn = document.getElementById('autoFlashBtn');
if (autoBtn && isBle) {
autoBtn.textContent = isGd32 ? '一键刷写 GD32' : '一键刷写 ESP32';
}
if (hint) { if (hint) {
if (isBle) { if (isBle) {
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。'; hint.innerHTML = isGd32
? 'BLE 模式 · 目标 GD32:连接设备 → 选择 GD32 固件 → 一键刷写。'
: 'BLE 模式 · 目标 ESP32:连接设备 → 选择 ESP32 固件 → 一键刷写。';
} else if (isGd32) { } else if (isGd32) {
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。'; hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
} else { } else {
+111 -103
View File
@@ -20,6 +20,9 @@ window.esp32File = null;
window.gd32File = null; window.gd32File = null;
window.esp32FileHandle = null; // FileSystemFileHandle for hot reload window.esp32FileHandle = null; // FileSystemFileHandle for hot reload
window.gd32FileHandle = null; // FileSystemFileHandle for hot reload window.gd32FileHandle = null; // FileSystemFileHandle for hot reload
/** 两路各自一份 SHA:选文件时写入,上传时读取;互不切换、不共享 */
window._esp32FileSha256 = null;
window._gd32FileSha256 = null;
/** localStorage:升级目标等轻量偏好(刷新后恢复) */ /** localStorage:升级目标等轻量偏好(刷新后恢复) */
var LS_FLASH_TARGET = 'dcu_ota_flash_target'; var LS_FLASH_TARGET = 'dcu_ota_flash_target';
@@ -196,7 +199,7 @@ export async function resumePersistedFirmwareWithGesture(chip) {
return false; return false;
} }
var file = await handle.getFile(); var file = await handle.getFile();
applyRestoredFile(chip, handle, file, { silent: true }); await applyRestoredFile(chip, handle, file, { silent: true });
window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name); addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success'); showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
@@ -218,11 +221,14 @@ function applyRestoredFile(chip, handle, file, options) {
if (window.sharedFirmwareFiles) { if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: file, handle: handle }; window.sharedFirmwareFiles[chip] = { file: file, handle: handle };
} }
processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})); /* 等 SHA 写入对应槽后再 sync BLE,避免二次重算 */
if (typeof window.syncBleFileFromSelectedTarget === 'function') { return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})))
window.syncBleFileFromSelectedTarget(); .then(function () {
} if (typeof window.syncBleFileFromSelectedTarget === 'function') {
safeUpdateUploadButtonLabel(); window.syncBleFileFromSelectedTarget();
}
safeUpdateUploadButtonLabel();
});
} }
/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */ /** 无权限时仍展示上次文件名,提示点上传区一键恢复 */
@@ -269,7 +275,7 @@ export async function restorePersistedFirmwareSelection() {
var perm = await queryHandlePermission(handle); var perm = await queryHandlePermission(handle);
if (perm === 'granted') { if (perm === 'granted') {
var file = await handle.getFile(); 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; window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name); addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
continue; continue;
@@ -296,13 +302,13 @@ export async function restorePersistedFirmwareSelection() {
export function handleFileSelect(event, chip) { export function handleFileSelect(event, chip) {
var file = event.target.files[0]; var file = event.target.files[0];
if (!file) return; if (!file) return Promise.resolve();
/* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */ /* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */
if (chip === 'esp32') window.esp32FileHandle = null; if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null; else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null); persistFileHandle(chip, null);
processFile(file, chip); return processFile(file, chip);
} }
// 通过 File System Access API 选择文件(支持热重载) // 通过 File System Access API 选择文件(支持热重载)
@@ -325,9 +331,14 @@ export async function pickFileViaFSAPI(chip) {
return false; return false;
} }
try { try {
/* GD32 常为 .hex:同时声明 octet-stream / text,避免对话框里灰掉选不了 */
var acceptMap = chip === 'esp32' var acceptMap = chip === 'esp32'
? { 'application/octet-stream': ['.bin'] } ? { '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; var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (!prevHandle) { if (!prevHandle) {
prevHandle = window._pendingFwHandles[chip] || null; prevHandle = window._pendingFwHandles[chip] || null;
@@ -341,7 +352,18 @@ export async function pickFileViaFSAPI(chip) {
if (prevHandle) { if (prevHandle) {
opts.startIn = 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) { if (handles && handles.length > 0) {
var handle = handles[0]; var handle = handles[0];
var file = await handle.getFile(); var file = await handle.getFile();
@@ -356,8 +378,8 @@ export async function pickFileViaFSAPI(chip) {
return true; return true;
} }
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') { if (e && e.name !== 'AbortError') {
addLog('W', '文件', 'FSAPI 选择失败: ' + e.message); addLog('W', '文件', 'FSAPI 选择失败: ' + (e.message || e));
} }
} }
return false; return false;
@@ -484,81 +506,58 @@ export async function convertHexFileToBin(file) {
return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr }; return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr };
} }
// ==================== SHA-256 缓存 ==================== // ==================== SHA-256:ESP32 / GD32 各一份,互不共用、不互切 ====================
// 选文件时就把 SHA-256 算好并缓存(同时记录 file meta),上传前 function paintShaUi(chip, hashHex, statusText) {
// 若 handle.getFile() 拿回的 fresh file meta 未变就直接复用, var t = statusText != null ? statusText : (hashHex || '-');
// 避免每次点上传都重算 30–80ms 的 crypto.subtle.digest。 if (chip === 'esp32') {
// 缓存 key:size + lastModified + name 三项均一致(lastModified 在 var e = document.getElementById('fileSha256Esp32');
// 文件 rebuild 后必变,是浏览器给的最可靠 rebuild 信号)。 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<string|null>} hex 大写 SHA,失败返回 null
*/ */
function _computeAndCacheFileSha(file, chip, optArrayBuffer) { export function computeFileSha(file, chip, optArrayBuffer) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; if (!file || (chip !== 'esp32' && chip !== 'gd32')) {
var shaElem = document.getElementById('fileSha256' + suffix); // GD32 可能为 null return Promise.resolve(null);
if (shaElem) shaElem.textContent = '计算中...'; }
paintShaUi(chip, null, '计算中...');
var bufPromise = optArrayBuffer ? Promise.resolve(optArrayBuffer) : file.arrayBuffer(); 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); return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) { }).then(function (hashBuffer) {
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase(); var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
if (shaElem) shaElem.textContent = hashHex; if (chip === 'esp32') window._esp32FileSha256 = hashHex;
var meta = { size: file.size, lastModified: file.lastModified, name: file.name }; else window._gd32FileSha256 = hashHex;
if (chip === 'esp32') { paintShaUi(chip, hashHex);
window._esp32FileSha256 = hashHex;
window._esp32FileShaMeta = meta;
} else {
window._gd32FileSha256 = hashHex;
window._gd32FileShaMeta = meta;
}
addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex); addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex);
return hashHex; return hashHex;
}).catch(function(err) { }).catch(function (err) {
if (shaElem) shaElem.textContent = '计算失败: ' + err.message; paintShaUi(chip, null, '计算失败: ' + err.message);
if (chip === 'esp32') window._esp32FileSha256 = null;
else window._gd32FileSha256 = null;
addLog('W', '文件', chip.toUpperCase() + ' SHA-256 计算失败: ' + err.message); 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; return null;
}); });
} }
/**
* @description 确保 file 的 SHA-256 已在缓存中:命中直接返回,不命中则重算并缓存。
* @param {File} file 目标文件
* @param {'esp32'|'gd32'} chip 通道
* @param {ArrayBuffer=} optArrayBuffer 命中时不用,未命中时传入可避免重复读文件
* @returns {Promise<string|null>} 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 计算、文件校验)。 * @description 处理用户选择的固件文件(HEX→BIN、算 SHA 写入对应芯片槽)。
* 对 GD32 .hex 走异步转换;返回 Promise 以便调用方 await
* (避免上游用 setTimeout 硬等,见 upload-esp32.js)。
* @param {File} file * @param {File} file
* @param {'esp32'|'gd32'} chip * @param {'esp32'|'gd32'} chip
* @param {{silent?: boolean, skipHandlePersist?: boolean}=} options * @param {{silent?: boolean, skipHandlePersist?: boolean}=} options
* silent: 恢复场景不弹 toast;skipHandlePersist: 已从 IDB 读出时不再写回
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export function processFile(file, chip, options) { export function processFile(file, chip, options) {
@@ -569,52 +568,61 @@ export function processFile(file, chip, options) {
if (!opts.skipHandlePersist) { if (!opts.skipHandlePersist) {
persistFlashTarget(chip); persistFlashTarget(chip);
} }
/* 元数据始终更新,便于刷新后展示「上次文件名」 */
if (file && file.name) { if (file && file.name) {
persistFileMeta(chip, file); 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) { if (chip === 'gd32' && isHexByName) {
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象 return convertHexFileToBin(file).then(function (result) {
return convertHexFileToBin(file).then(function(result) {
if (!result.converted) { if (!result.converted) {
// 文件名 .hex 但内容不像,直接用原始文件
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理'); addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
window.gd32File = file; window.gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show'); paintFileInfo(file.name, 'Binary(?)', formatBytes(file.size));
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
safeUpdateUploadButtonLabel(); safeUpdateUploadButtonLabel();
return ensureFileShaCached(file, 'gd32'); return computeFileSha(file, 'gd32');
} }
window.gd32File = result.file; window.gd32File = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name); addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name);
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size)); addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size));
if (!silent) showToast('HEX 已自动转换为 BIN', 'success'); if (!silent) showToast('HEX 已自动转换为 BIN', 'success');
document.getElementById('fileInfo' + suffix).classList.add('show'); paintFileInfo(
document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; result.file.name + ' (由 ' + file.name + ' 转换)',
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; 'Binary (HEX转换)',
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size); formatBytes(result.file.size)
);
safeUpdateUploadButtonLabel(); safeUpdateUploadButtonLabel();
return ensureFileShaCached(result.file, 'gd32'); return computeFileSha(result.file, 'gd32');
}).catch(function(err) { }).catch(function (err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message); addLog('E', '文件', 'HEX 解析失败: ' + (err && err.message ? err.message : err));
if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error'); if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error');
window.gd32File = null; 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(); safeUpdateUploadButtonLabel();
throw err; // 让上游 await 感知失败 throw err;
}); });
} }
// .bin 文件或 ESP32 文件: 直接使用
if (chip === 'esp32') window.esp32File = file; if (chip === 'esp32') window.esp32File = file;
else window.gd32File = file; else window.gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show'); paintFileInfo(
document.getElementById('fileName' + suffix).textContent = file.name; file.name,
document.getElementById('fileType' + suffix).textContent = isHexByName ? 'Intel HEX' : 'Binary',
isHexByName ? 'Intel HEX' : 'Binary'; formatBytes(file.size)
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); );
safeUpdateUploadButtonLabel(); safeUpdateUploadButtonLabel();
if (silent) { if (silent) {
addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')'); 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) + ')'); addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
showToast(chip.toUpperCase() + ' 文件已选择', 'success'); showToast(chip.toUpperCase() + ' 文件已选择', 'success');
} }
// 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算), return Promise.resolve(computeFileSha(file, chip));
// 上传时若文件未变 ensureFileShaCached 命中直接复用。
return Promise.resolve(ensureFileShaCached(file, chip));
} }
/** @description 清除已选固件文件并重置对应 UI 状态。 */ /** @description 清除已选固件文件并重置对应 UI 状态。 */
export function clearFile(chip) { export function clearFile(chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
if (chip === 'esp32') { if (chip === 'esp32') {
window.esp32File = null; window.esp32FileHandle = null; window.esp32File = null;
window._esp32FileSha256 = null; window._esp32FileShaMeta = null; window.esp32FileHandle = null;
window._esp32FileSha256 = null;
} else { } else {
window.gd32File = null; window.gd32FileHandle = null; window.gd32File = null;
window._gd32FileSha256 = null; window._gd32FileShaMeta = null; window.gd32FileHandle = null;
window._gd32FileSha256 = null;
} }
window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null); persistFileHandle(chip, null);
+1 -2
View File
@@ -156,8 +156,7 @@ export function cleanupAfterOtaFailure(reason) {
window.clearOtaSessionResiduals({ window.clearOtaSessionResiduals({
keepErrorStages: true, keepErrorStages: true,
statusText: reason || '失败', statusText: reason || '失败',
errorStyle: true, errorStyle: true
clearCacheDisplay: true
}); });
// 2. 阶段指示器标错 // 2. 阶段指示器标错
+1 -12
View File
@@ -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(用户发起新上传或设备空闲时调用)。 */ /** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */
@@ -245,8 +235,7 @@ export function otaSessionReset() {
clearOtaSessionResiduals({ clearOtaSessionResiduals({
keepErrorStages: false, keepErrorStages: false,
statusText: '待机', statusText: '待机',
errorStyle: false, errorStyle: false
clearCacheDisplay: false
}); });
if (otaSessionState !== OtaSessionState.IDLE) { if (otaSessionState !== OtaSessionState.IDLE) {
otaSessionState = OtaSessionState.IDLE; otaSessionState = OtaSessionState.IDLE;
+85 -29
View File
@@ -19,7 +19,7 @@
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset * window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress * 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 { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { setActivePhase, setProgress } from './progress.js'; import { setActivePhase, setProgress } from './progress.js';
import { otaSessionReset, OtaSessionState } from './session.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} data /system/status JSON
* @param {object} ota data.ota * @param {object} ota data.ota
* @param {object} wifi data.wifi * @param {object} wifi data.wifi
@@ -150,8 +150,87 @@ function applyDashboardUi(data, ota, wifi) {
var mTempEl = document.getElementById('mTemp'); var mTempEl = document.getElementById('mTemp');
if (mTempEl) { if (mTempEl) {
mTempEl.textContent = /* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--'; 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'); var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec); 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 rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI'); var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) pillRSSIEl.textContent = rssi; 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 rs = ota.relay_state;
var sysState = ota.sys_state || ''; var sysState = ota.sys_state || '';
var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; 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 isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4; var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255; var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
var total = ota.total_size || 0; /* 进度以 relay_seq/relay_total 为准;不再用 stored_size 做「OTA 缓存」展示 */
var stored = ota.stored_size || 0;
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
var relaySeq = ota.relay_seq || 0; var relaySeq = ota.relay_seq || 0;
var relayTotal = ota.relay_total_packets || 0; var relayTotal = ota.relay_total_packets || 0;
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0; var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
@@ -321,15 +385,7 @@ function applyGd32OtaSessionUi(ota) {
return; return;
} }
if (isGd32Storing || (total > 0 && stored < total && !window.isUploading && /* 不再用 stored_size 做「OTA 缓存」展示;上传走 XHR,中继走 relay_seq。 */
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));
}
}
} }
/** /**
+17 -23
View File
@@ -30,7 +30,7 @@ import {
startUploadWaitHeartbeat startUploadWaitHeartbeat
} from './session.js'; } from './session.js';
import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.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) { function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase(); var text = (detail || '').toLowerCase();
@@ -151,40 +151,30 @@ export async function startUpload(chip) {
break; break;
} }
} }
// 每次上传前尝试重新从本地读取文件 // 热重载:有 FSAPI handle 则重新读盘(本地编译会改同路径固件)
var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle; var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (handle) { if (handle) {
// 有 FileSystemFileHandle,调用 getFile() 获取最新内容
try { try {
var freshFile = await handle.getFile(); var freshFile = await handle.getFile();
if (chip === 'esp32') { if (chip === 'gd32' && freshFile.name.toLowerCase().endsWith('.hex')) {
window.esp32File = freshFile; /* HEX 必须按最新源文件再转 BIN,不能用选文件时的旧 prepared */
file = freshFile; await window.processFile(freshFile, 'gd32', { silent: true, skipHandlePersist: true });
file = window.gd32File;
} else { } else {
if (freshFile.name.toLowerCase().endsWith('.hex')) { if (chip === 'esp32') window.esp32File = freshFile;
// processFile 现已返回 Promise,await 即可拿到转换后的 gd32File, else window.gd32File = freshFile;
// 不再用 setTimeout 硬等(避免 HEX 稍大时拿到 null)。 file = freshFile;
await window.processFile(freshFile, 'gd32');
file = window.gd32File;
} else {
window.gd32File = freshFile;
file = freshFile;
}
} }
addLog('I', '文件', '已重新读取本地文件: ' + file.name + ' (' + formatBytes(file.size) + ')');
} catch (e) { } catch (e) {
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本'); addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
} }
} else { } else {
// 没有 handle,尝试直接读取当前 File 对象
try { try {
await file.arrayBuffer(); await file.arrayBuffer();
} catch (e) { } catch (e) {
// File 引用已失效,提示用户重新选择
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件'); addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
showToast('文件已变更,请重新选择', 'error'); showToast('文件已变更,请重新选择', 'error');
window.clearFile(chip); window.clearFile(chip);
// ★ 状态机:文件失效属于会话失败
cleanupAfterOtaFailure('本地文件失效'); cleanupAfterOtaFailure('本地文件失效');
return; return;
} }
@@ -209,10 +199,14 @@ export async function startUpload(chip) {
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success'); showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
try { try {
var arrayBuffer = await file.arrayBuffer(); var arrayBuffer = await file.arrayBuffer();
// SHA-256:processFile 阶段已算好并缓存;这里若 handle.getFile() 拿回的 /* 上传前始终按本次内容重算 SHA(本地固件会改,不能沿用选文件时的旧值) */
// fresh file meta 未变就直接复用(命中 30–100ms/MB 的计算); var savedSha = await computeFileSha(file, chip, arrayBuffer);
// 用户 rebuild 使 lastModified 变化时 ensureFileShaCached 自动 fallback 到重算。 if (!savedSha) {
await ensureFileShaCached(file, chip, arrayBuffer); addLog('E', '文件', chip.toUpperCase() + ' SHA-256 计算失败');
showToast('SHA-256 计算失败', 'error');
cleanupAfterOtaFailure('SHA-256 计算失败');
return;
}
var fileSize = file.size; var fileSize = file.size;
var body, url = window.getBaseUrl() + '/ota/upload'; var body, url = window.getBaseUrl() + '/ota/upload';
if (chip === 'esp32') { if (chip === 'esp32') {
+1 -1
View File
@@ -105,7 +105,7 @@ export function markGd32OtaUiComplete(msg) {
if (typeof window.updateUploadButtonLabel === 'function') { if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel(); window.updateUploadButtonLabel();
} }
addLog('I', 'OTA', msg || 'GD32 刷写完成'); /* 完成文案以设备 [I][OTA] GD32 OTA完成 为准,前端只 toast/进度,避免日志双份 */
showToast(msg || 'GD32 刷写完成', 'success'); showToast(msg || 'GD32 刷写完成', 'success');
} }
+226
View File
@@ -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())