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:
@@ -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
@@ -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
@@ -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,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"
|
||||||
|
|||||||
@@ -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; // 幂等:状态未变时不重复日志
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 风格一致)
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ¶ms);
|
int rc = ble_gap_update_params(conn_handle, ¶ms);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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]}")
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
|
@@ -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())
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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="关闭">×</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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user