feat(ota): 实现GD32 OTA包间背压机制,优化传输节奏控制

移除固定的包间延迟配置 `OTA_PACKET_DELAY_MS`,采用基于SPI Link v2
CREDIT背压的动态节奏控制,在SpiMaster中通过remoteCredit==0时wait/拒发
实现流量控制,提高OTA传输稳定性。

refactor(stack): 统一任务栈大小配置并添加CPU统计功能

- 在app_config.h中集中定义所有任务栈大小常量,基于实测水位优化分配
- 新增monitor/cpu_stats.cpp组件,提供1Hz FreeRTOS CPU占用采样
- 更新main.cpp中的任务创建使用新的栈大小常量
- 调整health_monitor和ota_relay等任务的栈分配

perf(ota): 改进ESP32和GD32 OTA分区预擦策略

- ESP32 OTA从边写边擦改为整分区预擦模式,避免写入时的扇区擦除阻塞
- GD32 OTA在prepare阶段进行整分区预擦,提升后续写入吞吐量
- 优化OTA协调器状态管理,修复FINALIZING状态残留问题

fix(log): 改进UDP日志发送的堆内存管理

- 当堆内存不足时记录UDP发送跳过的告警信息到ring buffer
- 降低UDP发送跳过的内存阈值从10KB到6KB
- 降低日志排水最小内存阈值从8KB到4KB

docs(readme): 更新OTA配置参数文档说明

移除已废弃的OTA_PACKET_DELAY_MS参数,更新包间节奏控制说明为
基于CREDIT背压机制的描述。
This commit is contained in:
小龙虾
2026-07-16 01:24:12 +08:00
parent a47a027178
commit 51c89b3065
52 changed files with 1006 additions and 628 deletions
+1 -2
View File
@@ -433,9 +433,8 @@ UDP socket 延迟创建(首次 `send()` 时才分配),WiFi 未连接时 `s
| OTA | `OTA_BLOCK_SIZE` | 8192 | 读写块大小 | | OTA | `OTA_BLOCK_SIZE` | 8192 | 读写块大小 |
| OTA | `OTA_MAX_FIRMWARE_SIZE` | 216KB | GD32 固件上限 | | OTA | `OTA_MAX_FIRMWARE_SIZE` | 216KB | GD32 固件上限 |
| OTA | `GD32_OTA_PACKET_SIZE` | 48 | 每包载荷字节 | | OTA | `GD32_OTA_PACKET_SIZE` | 48 | 每包载荷字节 |
| OTA | `OTA_RESEND_RETRIES` | 10 | 单包最大重发次数 |
| OTA | `OTA_PACKET_HEALTH_CHECK_INTERVAL` | 32 | 每 N 包健康检查 | | OTA | `OTA_PACKET_HEALTH_CHECK_INTERVAL` | 32 | 每 N 包健康检查 |
| OTA | `OTA_PACKET_DELAY_MS` | 5 | 每包发送后延时(ms) | | OTA | 包间节奏 | CREDIT 背压 | `SpiMaster`:`remoteCredit==0` 时 wait/拒发,无固定包间 delay |
| OTA | `OTA_WIFI_RSSI_CRITICAL` | -88 dBm | OTA 期间 RSSI 危险阈值 | | OTA | `OTA_WIFI_RSSI_CRITICAL` | -88 dBm | OTA 期间 RSSI 危险阈值 |
| OTA | `OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS` | 15000 | OTA 前 RSSI 采样最大时效 | | OTA | `OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS` | 15000 | OTA 前 RSSI 采样最大时效 |
| 阈值 | `TEMP_WARNING` | 80°C | 温度警告阈值 | | 阈值 | `TEMP_WARNING` | 80°C | 温度警告阈值 |
+1
View File
@@ -10,6 +10,7 @@ idf_component_register(
"server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取) "server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取)
"server/ble_ota.cpp" # BLE GATT OTA 服务器 "server/ble_ota.cpp" # BLE GATT OTA 服务器
"monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检) "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检)
"monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务
"monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1) "monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1)
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
"monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享) "monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享)
+5 -13
View File
@@ -10,25 +10,17 @@ menu "DCU OTA Configuration"
validated against actual wiring length/quality and GD32 validated against actual wiring length/quality and GD32
SPI slave timing. SPI slave timing.
config DCU_OTA_PACKET_INTERVAL_MS
int "Delay between GD32 OTA packets (ms)"
default 1
range 0 50
help
Idle delay inserted after each successfully acknowledged
firmware packet before sending the next one.
Lower value speeds up OTA, but if GD32 cannot finish
flash write / prepare next ACK in time, set it back.
config DCU_OTA_POLL_BACKOFF_MS config DCU_OTA_POLL_BACKOFF_MS
int "Polling backoff when GD32 is not ready (ms)" int "Polling backoff when GD32 is not ready (ms)"
default 2 default 2
range 0 50 range 0 50
help help
Delay between SPI response polls when GD32 returns WAIT Delay between SPI response polls when GD32 returns WAIT
or does not drive MISO yet. Lower value reduces per-packet or does not drive MISO yet. Packet pacing uses SPI Link v2
idle time and can significantly speed up OTA, but if GD32 CREDIT backpressure in SpiMaster (remoteCredit==0 → wait/
cannot finish flash write before the next poll, increase it. refuse TX), not a fixed inter-packet delay. Lower value
reduces poll idle time; increase if GD32 needs more time
between polls.
config DCU_OTA_FINISH_TIMEOUT_MS config DCU_OTA_FINISH_TIMEOUT_MS
int "Timeout for FINISH_OTA command (ms)" int "Timeout for FINISH_OTA command (ms)"
+14 -11
View File
@@ -14,6 +14,7 @@
#include "esp_ota_ops.h" #include "esp_ota_ops.h"
#include "monitor/health_monitor.h" #include "monitor/health_monitor.h"
#include "monitor/cpu_stats.h"
#include "relay/ota_relay.h" #include "relay/ota_relay.h"
#include "ota/ota_coordinator.h" #include "ota/ota_coordinator.h"
#include "transport/spi_master.h" #include "transport/spi_master.h"
@@ -33,7 +34,7 @@ extern "C" void app_main(void)
esp_chip_info_t chip_info; esp_chip_info_t chip_info;
esp_chip_info(&chip_info); esp_chip_info(&chip_info);
// ★ M1: ESP32 OTA 分区互斥锁内聚到 Esp32OtaCore(替代旧的全局变量) // ESP32 OTA 分区互斥锁内聚到 Esp32OtaCore
Esp32OtaCore::initMutex(); Esp32OtaCore::initMutex();
// 初始化网络接口 + 事件循环(必须最早) // 初始化网络接口 + 事件循环(必须最早)
@@ -43,7 +44,7 @@ extern "C" void app_main(void)
// 初始化调试日志(UART0 先就绪,UDP socket 延迟到首次发送时创建) // 初始化调试日志(UART0 先就绪,UDP socket 延迟到首次发送时创建)
DebugLog::instance().init(); DebugLog::instance().init();
// ★ WiFi 提前初始化(调度器启动前) // WiFi 提前初始化(调度器启动前)
bool wifi_sta_ok = WifiOta::instance().earlyInit(); bool wifi_sta_ok = WifiOta::instance().earlyInit();
HealthMonitor::instance().init(); HealthMonitor::instance().init();
@@ -61,7 +62,7 @@ extern "C" void app_main(void)
xRet = xTaskCreatePinnedToCore( xRet = xTaskCreatePinnedToCore(
HealthMonitor::taskEntry, HealthMonitor::taskEntry,
"health_mon", "health_mon",
4096, TASK_STACK_HEALTH_MON,
NULL, NULL,
configMAX_PRIORITIES - 4, configMAX_PRIORITIES - 4,
NULL, NULL,
@@ -76,12 +77,15 @@ extern "C" void app_main(void)
DEBUG_INFO("MAIN", "健康监控任务已创建"); DEBUG_INFO("MAIN", "健康监控任务已创建");
} }
/* CPU 占用 1Hz 采样(非致命;失败时 /system/status 的 cpu.valid 保持 false) */
CpuStats::instance().start();
Protocol::instance().init(); Protocol::instance().init();
DEBUG_INFO("MAIN", "协议层初始化完成"); DEBUG_INFO("MAIN", "协议层初始化完成");
// ★ 全局 OTA 协调器初始化(在 OTA task 创建前,确保 mutex 就绪) // OTA 全局协调器初始化(在 OTA task 创建前,确保 mutex 就绪)
OtaCoordinator::instance().init(); OtaCoordinator::instance().init();
// ★ L1: 注册 BLE 射频共存监听器(WifiOta 实现 IBleCoexListener) // 注册 BLE 射频共存监听器(WifiOta 实现 IBleCoexListener)
OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance()); OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance());
DEBUG_INFO("MAIN", "OTA 全局协调器已初始化"); DEBUG_INFO("MAIN", "OTA 全局协调器已初始化");
@@ -96,7 +100,7 @@ extern "C" void app_main(void)
xRet = xTaskCreatePinnedToCore( xRet = xTaskCreatePinnedToCore(
OtaRelay::taskEntry, OtaRelay::taskEntry,
"ota_relay", "ota_relay",
6144, TASK_STACK_OTA_RELAY,
NULL, NULL,
configMAX_PRIORITIES - 2, configMAX_PRIORITIES - 2,
NULL, NULL,
@@ -115,7 +119,7 @@ extern "C" void app_main(void)
xRet = xTaskCreatePinnedToCore( xRet = xTaskCreatePinnedToCore(
WifiOta::taskEntry, WifiOta::taskEntry,
"wifi_ota", "wifi_ota",
8192, TASK_STACK_WIFI_OTA,
NULL, NULL,
configMAX_PRIORITIES - 3, configMAX_PRIORITIES - 3,
NULL, NULL,
@@ -134,7 +138,7 @@ extern "C" void app_main(void)
xRet = xTaskCreatePinnedToCore( xRet = xTaskCreatePinnedToCore(
BleOta::taskEntry, BleOta::taskEntry,
"ble_ota", "ble_ota",
4096, TASK_STACK_BLE_OTA,
NULL, NULL,
configMAX_PRIORITIES - 3, configMAX_PRIORITIES - 3,
NULL, NULL,
@@ -158,7 +162,7 @@ extern "C" void app_main(void)
DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)"); DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)");
} }
// ★ OTA 回滚保护:标记当前固件有效 // OTA 回滚保护:标记当前固件有效
// 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数 // 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数
// 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区 // 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区
esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback(); esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback();
@@ -171,8 +175,7 @@ extern "C" void app_main(void)
DEBUG_INFO("MAIN", "========================================"); DEBUG_INFO("MAIN", "========================================");
DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中"); DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中");
// ★ 启动最后步骤:读取 GD32 APP 版本号 // 启动最后步骤:读取 GD32 APP 版本号
// 参考 OtaRelay::doWaitForAppAlive 刷写后读取逻辑:
// 先 hello() 建立 SPI v2 会话,再 probeGd32AppOnce 读取版本 // 先 hello() 建立 SPI v2 会话,再 probeGd32AppOnce 读取版本
// GD32 APP 完整初始化需 5-6 秒,使用 20 次重试 × 300ms 窗口 // GD32 APP 完整初始化需 5-6 秒,使用 20 次重试 × 300ms 窗口
{ {
+23 -10
View File
@@ -44,22 +44,35 @@ inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; ///< AP 模
// HTTP 服务器配置 // HTTP 服务器配置
// ============================================================ // ============================================================
inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服务器监听端口 inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服务器监听端口
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 10240; ///< HTTP 服务器任务栈大小(字节)
// ★ 历史:6144 栈溢出;8192 在上传收尾仍紧。曾提到 12288 但 ESP32-C3 堆极紧 // ============================================================
// (OTA 后 free~5KB、largest~1KB)。SHA context 已改 static,httpd 栈 10KB 折中。 // 任务栈大小(字节)— 2026-07 按板端 task_stack 水位收敛
// 真·栈溢出钩子会 abort;卡 100% 更多是堆碎导致回包/后续分配失败。 // 规则:peak_used 实测 + 至少 ~1.5–2KB 余量;httpd 路径最重单独放宽。
// 实测(空闲/常规):http peak~5KB, wifi_ota~1.4KB, ota_relay~1KB,
// health~1.2KB, cpu_stats~2KB, ble*~1KB, reconnect~1KB。
// ============================================================
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; ///< httpd:原 10KB,peak~5KB → 8KB(余量~3KB)
inline constexpr uint32_t TASK_STACK_WIFI_OTA = 4096; ///< wifi_ota 主循环:原 8KB,peak~1.4KB
inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(实测 peak~1KB;完整 GD32 中继后再确认水位)
inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:无单独水位,6144→5120 小幅收
inline constexpr uint32_t TASK_STACK_BLE_OTA = 3072; ///< ble_ota 主任务:peak~1KB
inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker:peak~1.1KB(负载峰值曾~1.9KB)
inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.2KB
inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~2KB
inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:peak~1KB
inline constexpr uint32_t TASK_STACK_CAN_FB = 3072; ///< can_fb:1Hz 轮询
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_RESTART = 2048; ///< 按需:延时重启
// ============================================================ // ============================================================
// OTA 配置 // OTA 配置
// ============================================================ // ============================================================
inline constexpr size_t OTA_BLOCK_SIZE = 2048; ///< 上传接收块:2KB 降峰值堆(原 4/8KB) inline constexpr size_t OTA_BLOCK_SIZE = 2048; ///< 上传接收块:2KB 降峰值 BSS/堆(s_ota_upload_recv 静态占用)
// OTA_BLOCK_SIZE 演进:8KB(原)→2KB(压堆)→4KB(试吞吐)→2KB(2026-07 回退省 DRAM)。
// 4KB 时 s_ota_upload_recv BSS +2KB,开机 free/largest 更紧;回退 2KB 优先保内存。
inline constexpr size_t OTA_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 实际转发包载荷
inline constexpr uint32_t OTA_POLL_RETRIES = 40; // 每包 ACK 轮询次数
inline constexpr uint32_t OTA_RESEND_RETRIES = 10; // 每包重发次数上限
inline constexpr uint32_t OTA_PACKET_DELAY_MS = 5; // 每包发送后延时(ms),给 GD32 处理时间
inline constexpr uint32_t OTA_POST_SEND_DELAY_MS = 3; // 发送请求后首次轮询前的延迟(ms),无 READY 拉低时防止读到 stale 数据
inline constexpr uint32_t OTA_POLL_NO_FRAME_DELAY_MS = 8; // 轮询未找到帧时的重试延迟(ms),无 READY 拉低时需更长
// ============================================================ // ============================================================
// 健康监控阈值 // 健康监控阈值
+4 -4
View File
@@ -4,11 +4,11 @@
// 固件版本 // 固件版本
#define FW_VERSION_MAJOR 0 #define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3 #define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 46 #define FW_VERSION_PATCH 47
#define FW_VERSION_STRING "0.3.46" #define FW_VERSION_STRING "0.3.47"
// 编译时间 // 编译时间
#define BUILD_TIME "2026-07-15 12:08:48" #define BUILD_TIME "2026-07-16 01:10:05"
// Git 信息 // Git 信息
#define GIT_HASH "5a89cec" #define GIT_HASH "a47a027"
+2 -1
View File
@@ -16,6 +16,7 @@
#include "freertos/task.h" #include "freertos/task.h"
#include "freertos/semphr.h" #include "freertos/semphr.h"
#include "app_config.h"
#include "transport/canfd_bridge.h" #include "transport/canfd_bridge.h"
#include "transport/spi_master.h" #include "transport/spi_master.h"
#include "monitor/debug_log.h" #include "monitor/debug_log.h"
@@ -130,7 +131,7 @@ bool startCanFeedbackTask()
BaseType_t xRet = xTaskCreatePinnedToCore( BaseType_t xRet = xTaskCreatePinnedToCore(
taskLoop, taskLoop,
"can_fb", "can_fb",
4096, TASK_STACK_CAN_FB,
nullptr, nullptr,
4, 4,
nullptr, nullptr,
+192
View File
@@ -0,0 +1,192 @@
/*
* cpu_stats.cpp - 1Hz FreeRTOS CPU 占用采样任务
*/
#include "cpu_stats.h"
#include <algorithm>
#include <cstring>
#include <inttypes.h>
#include <stdlib.h>
#include "app_config.h"
#include "esp_timer.h"
#include "monitor/debug_log.h"
CpuStats& CpuStats::instance()
{
static CpuStats inst;
return inst;
}
void CpuStats::start()
{
if (m_started) {
return;
}
m_started = true;
#if !defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
portENTER_CRITICAL(&m_mux);
m_published = {};
m_published.available = false;
portEXIT_CRITICAL(&m_mux);
DEBUG_WARN("CPU", "未启用 CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS,跳过采样任务");
return;
#else
portENTER_CRITICAL(&m_mux);
m_published = {};
m_published.available = true;
m_published.valid = false;
portEXIT_CRITICAL(&m_mux);
BaseType_t ok = xTaskCreatePinnedToCore(
&CpuStats::taskEntry,
"cpu_stats",
TASK_STACK_CPU_STATS,
this,
tskIDLE_PRIORITY + 1, /* 低优先级,少搅动占用统计 */
&m_taskHandle,
0);
if (ok != pdPASS) {
m_taskHandle = nullptr;
m_started = false;
DEBUG_ERR("CPU", "创建 cpu_stats 任务失败");
return;
}
DEBUG_INFO("CPU", "cpu_stats 任务已启动,采样周期 %" PRIu32 " ms", CPU_STATS_SAMPLE_MS);
#endif
}
void CpuStats::getSnapshot(CpuUsageSnapshot& out) const
{
portENTER_CRITICAL(&m_mux);
out = m_published;
portEXIT_CRITICAL(&m_mux);
}
#if defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
static uint64_t runtimeDelta(configRUN_TIME_COUNTER_TYPE cur, configRUN_TIME_COUNTER_TYPE prev)
{
return cur >= prev ? (uint64_t)(cur - prev) : 0U;
}
static bool taskNameIsIdle(const char* name)
{
return name && strncmp(name, "IDLE", 4) == 0;
}
void CpuStats::sampleOnce()
{
UBaseType_t task_count = uxTaskGetNumberOfTasks();
if (task_count == 0) {
return;
}
task_count = std::min<UBaseType_t>(task_count + 2U, (UBaseType_t)CPU_TASK_SNAPSHOT_MAX);
TaskStatus_t* tasks = (TaskStatus_t*)calloc(task_count, sizeof(TaskStatus_t));
if (!tasks) {
return;
}
configRUN_TIME_COUNTER_TYPE total_runtime = 0;
UBaseType_t captured = uxTaskGetSystemState(tasks, task_count, &total_runtime);
const int64_t now_us = esp_timer_get_time();
if (captured == 0 || captured > task_count) {
DEBUG_WARN("CPU", "uxTaskGetSystemState 异常: captured=%lu buf=%lu",
(unsigned long)captured, (unsigned long)task_count);
free(tasks);
return;
}
CpuUsageSnapshot snap = {};
snap.available = true;
const bool had_previous = m_hasLast;
const configRUN_TIME_COUNTER_TYPE previous_total = m_lastTotal;
const int64_t previous_time_us = m_lastTimeUs;
const size_t previous_count = m_lastCount;
TaskSample previous_samples[CPU_TASK_SNAPSHOT_MAX];
for (size_t i = 0; i < previous_count && i < CPU_TASK_SNAPSHOT_MAX; ++i) {
previous_samples[i] = m_lastSamples[i];
}
/* 先更新基线,再算本窗口(与旧 HTTP 路径语义一致) */
size_t copy_count = std::min<size_t>((size_t)captured, CPU_TASK_SNAPSHOT_MAX);
for (size_t i = 0; i < copy_count; ++i) {
m_lastSamples[i].task_number = tasks[i].xTaskNumber;
m_lastSamples[i].runtime = tasks[i].ulRunTimeCounter;
}
m_lastCount = copy_count;
m_lastTotal = total_runtime;
m_lastTimeUs = now_us;
m_hasLast = true;
const bool can_compute = had_previous && total_runtime >= previous_total && now_us > previous_time_us;
const uint64_t total_delta = can_compute ? runtimeDelta(total_runtime, previous_total) : 0U;
uint64_t idle_delta = 0U;
if (can_compute && total_delta > 0U) {
for (UBaseType_t i = 0; i < captured; ++i) {
configRUN_TIME_COUNTER_TYPE last_runtime = 0;
bool found = false;
for (size_t j = 0; j < previous_count; ++j) {
if (previous_samples[j].task_number == tasks[i].xTaskNumber) {
last_runtime = previous_samples[j].runtime;
found = true;
break;
}
}
if (!found) {
continue;
}
uint64_t delta = runtimeDelta(tasks[i].ulRunTimeCounter, last_runtime);
double pct = (double)delta * 100.0 / (double)total_delta;
if (taskNameIsIdle(tasks[i].pcTaskName)) {
idle_delta += delta;
}
if (snap.task_count < CPU_TASK_SNAPSHOT_MAX) {
CpuTaskMetric& m = snap.tasks[snap.task_count++];
const char* n = tasks[i].pcTaskName ? tasks[i].pcTaskName : "";
strncpy(m.name, n, sizeof(m.name) - 1);
m.name[sizeof(m.name) - 1] = '\0';
m.task_number = tasks[i].xTaskNumber;
m.runtime_delta = delta;
m.usage_pct = pct;
}
}
snap.valid = true;
snap.sample_ms = (uint32_t)((now_us - previous_time_us) / 1000);
snap.idle_pct = std::min(100.0, (double)idle_delta * 100.0 / (double)total_delta);
snap.usage_pct = std::max(0.0, 100.0 - snap.idle_pct);
}
free(tasks);
portENTER_CRITICAL(&m_mux);
m_published = snap;
portEXIT_CRITICAL(&m_mux);
}
void CpuStats::taskEntry(void* arg)
{
auto* self = static_cast<CpuStats*>(arg);
DEBUG_INFO("CPU", "=== cpu_stats 采样任务启动 ===");
TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(CPU_STATS_SAMPLE_MS);
/* 第一拍只建基线(valid=false);此后每拍窗口 ≈ 1000ms */
for (;;) {
self->sampleOnce();
vTaskDelayUntil(&last_wake, period);
}
}
#else /* !CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS */
void CpuStats::sampleOnce() {}
void CpuStats::taskEntry(void*) { vTaskDelete(nullptr); }
#endif
+73
View File
@@ -0,0 +1,73 @@
/*
* cpu_stats.h - FreeRTOS 运行时 CPU 占用采样
*
* 由独立低优先级任务按固定 1s 周期采样;/system/status 只读最新快照,
* 不再在 HTTP 线程里调用 uxTaskGetSystemState(避免窗口绑浏览器轮询、减 httpd 栈压)。
*/
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/** 单次快照最多跟踪的任务数 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 32;
/** 采样周期(ms)— 窗口 sample_ms 应对齐此值 */
static constexpr uint32_t CPU_STATS_SAMPLE_MS = 1000;
struct CpuTaskMetric {
char name[configMAX_TASK_NAME_LEN];
UBaseType_t task_number;
uint64_t runtime_delta;
double usage_pct;
};
struct CpuUsageSnapshot {
bool available; ///< 固件是否启用 RUN_TIME_STATS
bool valid; ///< 是否已有完整窗口(至少两拍)
uint32_t sample_ms; ///< 本窗口墙钟长度
double usage_pct; ///< 100 - idle
double idle_pct;
CpuTaskMetric tasks[CPU_TASK_SNAPSHOT_MAX];
size_t task_count;
};
/**
* @brief CPU 运行时统计(单例 + 1Hz 采样任务)
*/
class CpuStats {
public:
static CpuStats& instance();
/** 创建采样任务(幂等;失败仅打日志,不阻塞启动) */
void start();
/** 线程安全拷贝最新快照(HTTP 用) */
void getSnapshot(CpuUsageSnapshot& out) const;
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
private:
CpuStats() = default;
static void taskEntry(void* arg);
void sampleOnce();
mutable portMUX_TYPE m_mux = portMUX_INITIALIZER_UNLOCKED;
CpuUsageSnapshot m_published = {};
TaskHandle_t m_taskHandle = nullptr;
bool m_started = false;
/* 基线(仅采样任务写) */
struct TaskSample {
UBaseType_t task_number;
configRUN_TIME_COUNTER_TYPE runtime;
};
TaskSample m_lastSamples[CPU_TASK_SNAPSHOT_MAX] = {};
size_t m_lastCount = 0;
configRUN_TIME_COUNTER_TYPE m_lastTotal = 0;
int64_t m_lastTimeUs = 0;
bool m_hasLast = false;
};
+12 -1
View File
@@ -367,7 +367,8 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len)
} }
/* UDP 通道:堆紧时跳过 sendto,避免申请 LWIP TX pbuf 与 HTTP 回包抢堆。 /* UDP 通道:堆紧时跳过 sendto,避免申请 LWIP TX pbuf 与 HTTP 回包抢堆。
* WiFi 未连接时 sendto 失败也静默跳过。 */ * WiFi 未连接时 sendto 失败也静默跳过。
* ★ 修复:堆不足跳过时写一条告警到 ring buffer,让前端 /logs 可见。 */
if (m_initialized && free_now >= kSkipUdpFree) { if (m_initialized && free_now >= kSkipUdpFree) {
if (m_udpSock < 0) { if (m_udpSock < 0) {
ensureUdpSocket(); ensureUdpSocket();
@@ -381,6 +382,16 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len)
udp_ret = ESP_OK; udp_ret = ESP_OK;
} }
} }
} else if (m_initialized) {
/* 堆不足导致 UDP 被跳过——通过 ring buffer 记录,避免上位机无法感知 */
static uint32_t s_lastUdpSkipWarnMs = 0;
uint32_t now_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
/* 限制告警频率:每 10 秒最多一条,避免刷屏 */
if ((now_ms - s_lastUdpSkipWarnMs) > 10000) {
s_lastUdpSkipWarnMs = now_ms;
static const char kUdpSkipMsg[] = "[W][LOG] UDP 发送已暂停: 堆内存不足\n";
ringPush(kUdpSkipMsg, sizeof(kUdpSkipMsg) - 1);
}
} }
// 同时写入环形缓冲区(供 HTTP /logs 拉取)——静态,不占堆 // 同时写入环形缓冲区(供 HTTP /logs 拉取)——静态,不占堆
+2 -2
View File
@@ -113,8 +113,8 @@ private:
* free < kDrainMinFree → /logs 尽量仍返回 ring(降低门槛,避免“沉默失败”) */ * free < kDrainMinFree → /logs 尽量仍返回 ring(降低门槛,避免“沉默失败”) */
static constexpr uint32_t kDropWarnFree = 6U * 1024U; static constexpr uint32_t kDropWarnFree = 6U * 1024U;
static constexpr uint32_t kDropInfoFree = 10U * 1024U; static constexpr uint32_t kDropInfoFree = 10U * 1024U;
static constexpr uint32_t kSkipUdpFree = 10U * 1024U; static constexpr uint32_t kSkipUdpFree = 6U * 1024U; // ★ 修复:从 10KB 降至 6KB,sendto 本身不需大块堆
static constexpr uint32_t kDrainMinFree = 8U * 1024U; static constexpr uint32_t kDrainMinFree = 4U * 1024U; // ★ 修复:从 8KB 降至 4KB,降低 /logs 返空的概率
esp_err_t ensureUdpSocket(); esp_err_t ensureUdpSocket();
void ringPush(const char *data, int len); void ringPush(const char *data, int len);
+2 -2
View File
@@ -74,9 +74,9 @@ public:
TaskHandle_t getTaskHandle() const { return m_taskHandle; } TaskHandle_t getTaskHandle() const { return m_taskHandle; }
/** /**
* @brief 由 WiFi 事件回调推送连接状态(解耦 HealthMonitor → WifiOta 依赖)。 * @brief 由 WiFi 事件回调推送连接状态(避免 HealthMonitor → WifiOta 依赖)。
* WiFi 连接/断开时由 wifi_ota 事件处理器主动调用, * WiFi 连接/断开时由 wifi_ota 事件处理器主动调用,
* HealthMonitor 不再主动查询 WifiOta::isConnected()。 * HealthMonitor 无需主动查询 WifiOta::isConnected()。
* @param connected true=已连接,false=断开。 * @param connected true=已连接,false=断开。
*/ */
void setWifiConnected(bool connected); void setWifiConnected(bool connected);
+10 -6
View File
@@ -8,6 +8,7 @@
#include "app_config.h" #include "app_config.h"
#include "health_monitor.h" #include "health_monitor.h"
#include "cpu_stats.h"
#include "ota_relay.h" #include "ota_relay.h"
#include "wifi_ota.h" #include "wifi_ota.h"
#include "ble_ota.h" #include "ble_ota.h"
@@ -24,22 +25,25 @@ size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max)
out[n++] = { "http_server", xTaskGetHandle("httpd"), HTTP_SERVER_STACK_SIZE }; out[n++] = { "http_server", xTaskGetHandle("httpd"), HTTP_SERVER_STACK_SIZE };
} }
if (n < max) { if (n < max) {
out[n++] = { "health_mon", HealthMonitor::instance().getTaskHandle(), 4096 }; out[n++] = { "health_mon", HealthMonitor::instance().getTaskHandle(), TASK_STACK_HEALTH_MON };
} }
if (n < max) { if (n < max) {
out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), 6144 }; out[n++] = { "cpu_stats", CpuStats::instance().getTaskHandle(), TASK_STACK_CPU_STATS };
} }
if (n < max) { if (n < max) {
out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), 8192 }; out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), TASK_STACK_OTA_RELAY };
} }
if (n < max) { if (n < max) {
out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, 4096 }; out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), TASK_STACK_WIFI_OTA };
} }
if (n < max) { if (n < max) {
out[n++] = { "ble_ota", BleOta::instance().getTaskHandle(), 4096 }; out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, TASK_STACK_WIFI_RECONNECT };
} }
if (n < max) { if (n < max) {
out[n++] = { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), 4096 }; 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; return n;
+48 -15
View File
@@ -25,7 +25,7 @@
#define TAG "OTA_CORE" #define TAG "OTA_CORE"
// ============================================================ // ============================================================
// 分区互斥锁(M1: 内聚自 ota_mutex.h 全局变量) // 分区互斥锁
// ============================================================ // ============================================================
SemaphoreHandle_t Esp32OtaCore::s_mutex = NULL; SemaphoreHandle_t Esp32OtaCore::s_mutex = NULL;
volatile bool Esp32OtaCore::s_occupied = false; volatile bool Esp32OtaCore::s_occupied = false;
@@ -172,22 +172,57 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
return beginCleanupAndReturn(ESP_FAIL); return beginCleanupAndReturn(ESP_FAIL);
} }
/* ★ 用 OTA_WITH_SEQUENTIAL_WRITES:边写边按扇区擦除,begin 几乎瞬时返回。 /* ★ 整分区预擦(非 SEQUENTIAL 边写边擦):
* 旧逻辑按 64KB 对齐预擦 ~1MB,会在 /ota/upload 开头阻塞几十秒~数分钟: * 1) 扇区循环 erase + 喂狗/yield,避免 TASK_WDT 与 WiFi 饿死
* 浏览器 upload 进度已到 100%,设备还在擦 Flash → 表现为“卡在 100% 很久才成功”。 */ * 2) esp_ota_resume(erase_size≠SEQUENTIAL) → need_erase=false,写路径纯 write
DEBUG_INFO(TAG, "esp_ota_begin(SEQUENTIAL) 目标=%s size=%u free=%lu...", * 3) 显式 invalidate inactive slot(resume 不像 begin 那样自动做)
m_partition->label, (unsigned)total_size, * 预擦会在 begin 阶段占用数秒~数十秒,之后收流写 Flash 更快更稳。 */
const size_t part_size = m_partition->size;
const size_t sector = m_partition->erase_size ? m_partition->erase_size : 4096U;
DEBUG_INFO(TAG, "整分区预擦: %s size=%u sector=%u free=%lu...",
m_partition->label, (unsigned)part_size, (unsigned)sector,
(unsigned long)esp_get_free_heap_size()); (unsigned long)esp_get_free_heap_size());
const int64_t t0 = esp_timer_get_time(); const int64_t t0 = esp_timer_get_time();
esp_err_t err = esp_ota_begin(m_partition, OTA_WITH_SEQUENTIAL_WRITES, &m_handle); for (size_t off = 0; off < part_size; ) {
size_t chunk = sector;
if (off + chunk > part_size) {
chunk = part_size - off;
}
esp_err_t erase_err = esp_partition_erase_range(m_partition, off, chunk);
if (erase_err != ESP_OK) {
DEBUG_ERR(TAG, "整分区预擦失败 @%u: %s",
(unsigned)off, esp_err_to_name(erase_err));
return beginCleanupAndReturn(erase_err);
}
off += chunk;
(void)esp_task_wdt_reset();
taskYIELD();
}
const int64_t t1 = esp_timer_get_time(); const int64_t t1 = esp_timer_get_time();
DEBUG_INFO(TAG, "整分区预擦完成 耗时 %lld ms",
(long long)((t1 - t0) / 1000));
#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) || defined(CONFIG_APP_ROLLBACK_ENABLE)
{
esp_err_t inv = esp_ota_invalidate_inactive_ota_data_slot();
if (inv != ESP_OK && inv != ESP_ERR_NOT_SUPPORTED) {
DEBUG_WARN(TAG, "invalidate inactive OTA slot: %s(继续)",
esp_err_to_name(inv));
}
}
#endif
/* erase_size 传 0(≠ OTA_WITH_SEQUENTIAL_WRITES)→ 写时不再擦扇区 */
esp_err_t err = esp_ota_resume(m_partition, /*erase_size=*/0,
/*image_offset=*/0, &m_handle);
const int64_t t2 = esp_timer_get_time();
if (err != ESP_OK) { if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s (耗时 %lld ms)", DEBUG_ERR(TAG, "esp_ota_resume 失败: %s (耗时 %lld ms)",
esp_err_to_name(err), (long long)((t1 - t0) / 1000)); esp_err_to_name(err), (long long)((t2 - t1) / 1000));
return beginCleanupAndReturn(err); return beginCleanupAndReturn(err);
} }
DEBUG_INFO(TAG, "esp_ota_begin 完成 耗时 %lld ms free=%lu", DEBUG_INFO(TAG, "esp_ota_resume 完成 耗时 %lld ms free=%lu",
(long long)((t1 - t0) / 1000), (long long)((t2 - t1) / 1000),
(unsigned long)esp_get_free_heap_size()); (unsigned long)esp_get_free_heap_size());
m_received = 0; m_received = 0;
@@ -196,7 +231,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_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase)", DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (full-partition erase)",
m_partition->label, (unsigned)total_size); m_partition->label, (unsigned)total_size);
return ESP_OK; return ESP_OK;
} }
@@ -213,11 +248,9 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len)
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
/* sequential 模式写时可能触发扇区擦除(禁 cache),期间 WiFi 易饿死。 /* 整分区已预擦:此处为纯 write,不再扇区擦除。 */
* 写后 yield 一次即可;写前再 yield 会无谓拖慢吞吐(httpd 侧另有堆感知 pacing)。 */
(void)esp_task_wdt_reset(); (void)esp_task_wdt_reset();
esp_err_t err = esp_ota_write(m_handle, data, len); esp_err_t err = esp_ota_write(m_handle, data, len);
taskYIELD();
if (err == ESP_OK) { if (err == ESP_OK) {
m_received += len; m_received += len;
} else { } else {
+3 -4
View File
@@ -5,7 +5,7 @@
* begin → write*(N) → finalize / abort * begin → write*(N) → finalize / abort
* *
* 统一以 WiFi 流程为基准: * 统一以 WiFi 流程为基准:
* - esp_ota_begin 第二参数传 total_size * - begin:整分区预擦后 esp_ota_resume(写路径不再边写边擦)
* - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算) * - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算)
* - finalize 顺序: 读回 SHA-256 → 比对前端 SHA → esp_ota_end → esp_ota_set_boot_partition → 可选重启 * - finalize 顺序: 读回 SHA-256 → 比对前端 SHA → esp_ota_end → esp_ota_set_boot_partition → 可选重启
* - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle * - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle
@@ -102,16 +102,15 @@ public:
/** @brief 获取已接收的字节数。 */ /** @brief 获取已接收的字节数。 */
size_t getReceived() const { return m_received; } size_t getReceived() const { return m_received; }
// ── 分区互斥锁管理(M1: 从 ota_mutex.h 全局变量内聚到此类)── // ── 分区互斥锁管理 ──
/** /**
* @brief 初始化 ESP32 OTA 分区互斥锁(在 app_main 中调用)。 * @brief 初始化 ESP32 OTA 分区互斥锁(在 app_main 中调用)。
* 替代旧的全局 g_esp32OtaMutex / g_esp32OtaOccupied。
*/ */
static void initMutex(); static void initMutex();
private: private:
Esp32OtaCore() = default; Esp32OtaCore() = default;
// ── 分区互斥锁(M1: 内聚自 ota_mutex.h)── // ── 分区互斥锁 ──
static SemaphoreHandle_t s_mutex; ///< ESP32 OTA 分区互斥锁 static SemaphoreHandle_t s_mutex; ///< ESP32 OTA 分区互斥锁
static volatile bool s_occupied; ///< 分区占用标志 static volatile bool s_occupied; ///< 分区占用标志
static bool acquireMutex(uint32_t timeout_ms); static bool acquireMutex(uint32_t timeout_ms);
+75 -25
View File
@@ -98,13 +98,13 @@ void OtaCoordinator::watchdogCheckLocked()
return; // 未超时 return; // 未超时
} }
// ★ 超时自愈:强制转 ERROR。 // ★ 超时自愈:完整中止到 IDLE(释放 Esp32OtaCore / 中继 / GD32 meta 等)。
// ERROR 不在任何 beginXxx() 的拒绝列表中,下次请求会自动从 ERROR 恢复。 // 旧逻辑只标 ERROR 不清理子系统:若卡在 FINALIZING 且 core 已半释放,
// 清理由各子系统自行负责(abortCurrent 中会按 ERROR 不做实际清理,仅复位状态)。 // 前端仍长时间判“占用”;若 core 仍持互斥,ERROR 后 begin 还要靠 forceCleanup。
DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制自愈 → ERROR", // REBOOTING 本应已 esp_restart;超时则同样中止,避免永久卡死。
DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制中止 → IDLE",
(int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks)); (int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks));
m_sysState = OTA_SYS_ERROR; doAbortLocked();
m_busySinceTick = 0;
} }
// ============================================================ // ============================================================
@@ -124,7 +124,7 @@ void OtaCoordinator::init()
m_gd32StorageDone = false; m_gd32StorageDone = false;
m_busySinceTick = 0; m_busySinceTick = 0;
// ★ H4 解耦:注册协调器为 OtaRelay 的中继事件监听器 // 注册协调器为 OtaRelay 的中继事件监听器
OtaRelay::setRelayListener(this); OtaRelay::setRelayListener(this);
// ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态, // ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态,
@@ -221,12 +221,27 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t
return err; return err;
} }
// 3. 复位按需擦除水位(不在此处整段预擦) // 3. 整分区预擦(不再边写边擦):prepare 阶段擦完 gd32_ota 分区,
// 旧实现在 prepare 阶段 erase 整包固件对应分区:HTTP 主线程阻塞数秒~数十秒, // 收流时 flashWriteChunk 仅 write,吞吐更稳。会阻塞数秒~数十秒。
// 期间不 httpd_req_recv → TCP 窗口塞满后浏览器进度先到 100%,再空等擦+写+SHA。
// 现改为 flashWriteChunk 内按扇区 ensureErased,边收边擦写,进度与真实处理对齐。
Gd32FlashStore::instance().beginWriteSession(); Gd32FlashStore::instance().beginWriteSession();
DEBUG_INFO(TAG, "prepareGd32Storage: 按需擦除会话已复位 (fw=%" PRIu32 ")", total_size); {
const esp_partition_t *gd32_part = Gd32FlashStore::instance().getPartition();
const uint32_t erase_bytes = gd32_part ? (uint32_t)gd32_part->size : 0U;
if (erase_bytes == 0U) {
DEBUG_ERR(TAG, "prepareGd32Storage: 找不到 gd32_ota 分区");
abortCurrent();
return ESP_ERR_NOT_FOUND;
}
DEBUG_INFO(TAG, "prepareGd32Storage: 整分区预擦 %u 字节 (fw=%" PRIu32 ")...",
(unsigned)erase_bytes, total_size);
err = Gd32FlashStore::instance().erasePartition(erase_bytes);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "prepareGd32Storage: 整分区预擦失败: %s", esp_err_to_name(err));
abortCurrent();
return err;
}
DEBUG_INFO(TAG, "prepareGd32Storage: 整分区预擦完成");
}
// 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0) // 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0)
clearGd32Meta(); clearGd32Meta();
@@ -270,7 +285,7 @@ esp_err_t OtaCoordinator::finishGd32StorageAfterReceive()
} }
// ============================================================ // ============================================================
// GD32 存储操作(传输层统一入口,解决 H3 绕过协调器问题) // GD32 存储操作(传输层统一入口)
// ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到 // ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到
// Gd32FlashStore::instance(),消除跨 TU 调用开销。 // Gd32FlashStore::instance(),消除跨 TU 调用开销。
// ============================================================ // ============================================================
@@ -394,7 +409,7 @@ void OtaCoordinator::notifyRelayComplete(bool success)
} }
// ============================================================ // ============================================================
// IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器) // IRelayListener 接口实现
// ============================================================ // ============================================================
/** @brief IRelayListener 回调:中继已开始,委托给 notifyRelayStarted()。 */ /** @brief IRelayListener 回调:中继已开始,委托给 notifyRelayStarted()。 */
void OtaCoordinator::onRelayStarted() void OtaCoordinator::onRelayStarted()
@@ -409,7 +424,7 @@ void OtaCoordinator::onRelayComplete(bool success)
} }
// ============================================================ // ============================================================
// BLE 射频共存管理(L1: 消除 ble_ota → wifi_ota 直接依赖) // BLE 射频共存管理
// ============================================================ // ============================================================
void OtaCoordinator::setBleCoexListener(IBleCoexListener *listener) void OtaCoordinator::setBleCoexListener(IBleCoexListener *listener)
{ {
@@ -443,7 +458,6 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道, // ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。 // 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。 // 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
// FINALIZING/REBOOTING 是不可逆阶段,也拒绝。
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 OTA 接收中(通道=%d),拒绝跨通道请求(通道=%d)",
@@ -458,14 +472,23 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效 // epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效
} }
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 收尾/重启中,均拒绝。 // ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器
// ★ 必须显式拒绝 ESP32_FINALIZING/REBOOTING: // 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。
// 若放行,本调用会落到 Esp32OtaCore::begin 去抢全局互斥失败而返回 INVALID_STATE, // core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
// 随后 beginEsp32OtaWithRetry 的 forceCleanup 会把另一通道的活跃会话 if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) {
// (esp_ota_abort + 释放互斥)误中止 → 两通道并发写同一 OTA 分区。 if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "ESP32_FINALIZING 残留(core 已空闲),强制复位后允许新会话");
doAbortLocked();
} else {
DEBUG_WARN(TAG, "系统忙 (ESP32_FINALIZING, core 活跃),拒绝 ESP32 OTA");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 重启中,均拒绝。
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_FINALIZING ||
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));
@@ -586,6 +609,13 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h
r.device_sha_hex); r.device_sha_hex);
if (r.err == ESP_OK) { if (r.err == ESP_OK) {
notifyEsp32FinalizeComplete(); notifyEsp32FinalizeComplete();
} else {
// ★ SHA 不匹配 / end 失败:Esp32OtaCore 已 abort+释放互斥,但协调器若仍停在
// ESP32_FINALIZING,WiFi 前端会一直被“设备 OTA 占用”拒绝(BLE 路径曾单独
// 补 abortCurrent;统一放到这里,WiFi/BLE 共用)。
DEBUG_ERR(TAG, "ESP32 finalize 失败: %s,复位协调器 → IDLE",
esp_err_to_name(r.err));
abortCurrent();
} }
return r; return r;
} }
@@ -632,6 +662,26 @@ void OtaCoordinator::abortCurrent()
wifiOtaResumeBleIfNeeded(); wifiOtaResumeBleIfNeeded();
} }
void OtaCoordinator::healOrphanStates()
{
bool need_resume = false;
bool h = lock();
if (h) {
watchdogCheckLocked();
// finalize 失败后 core 已空、协调器仍 FINALIZING 的假占用:status 轮询即可自愈
if (m_sysState == OTA_SYS_ESP32_FINALIZING &&
!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "status 路径发现 ESP32_FINALIZING 残留,自愈 → IDLE");
doAbortLocked();
need_resume = true;
}
unlock(h);
}
if (need_resume) {
wifiOtaResumeBleIfNeeded();
}
}
uint32_t OtaCoordinator::getOtaEpoch() const uint32_t OtaCoordinator::getOtaEpoch() const
{ {
bool h = lock(); bool h = lock();
@@ -665,7 +715,7 @@ bool OtaCoordinator::abortCurrentIfEpoch(uint32_t epoch)
ota_system_state_t OtaCoordinator::getSystemState() const ota_system_state_t OtaCoordinator::getSystemState() const
{ {
// 对于纯读取,短暂加锁保证一致性 // 对于纯读取,短暂加锁保证一致性
// ★ M1/L3: m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast // m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
bool h = lock(); bool h = lock();
ota_system_state_t s = m_sysState; ota_system_state_t s = m_sysState;
unlock(h); unlock(h);
@@ -720,7 +770,7 @@ bool OtaCoordinator::getGd32AppAlive() const
} }
// ============================================================ // ============================================================
// OTA 状态快照(PR1: 收口状态读取) // OTA 状态快照
// ============================================================ // ============================================================
OtaSnapshot OtaCoordinator::getSnapshot() const OtaSnapshot OtaCoordinator::getSnapshot() const
{ {
@@ -754,7 +804,7 @@ OtaSnapshot OtaCoordinator::getSnapshot() const
} }
// ============================================================ // ============================================================
// GD32 APP 探测(PR2: 从 /system/status 移出) // GD32 APP 探测
// ============================================================ // ============================================================
// 注:本函数与 OtaRelay::probeAndCacheAppAlive 逻辑相似,但因依赖方向 // 注:本函数与 OtaRelay::probeAndCacheAppAlive 逻辑相似,但因依赖方向
// (OtaCoordinator → OtaRelay)不应调用 OtaRelay 的内部实现细节, // (OtaCoordinator → OtaRelay)不应调用 OtaRelay 的内部实现细节,
+24 -14
View File
@@ -21,7 +21,7 @@
#include "esp_err.h" #include "esp_err.h"
#include "app_config.h" // OTA_MAX_FIRMWARE_SIZE / GD32_OTA_PACKET_SIZE(validateGd32Size 用) #include "app_config.h" // OTA_MAX_FIRMWARE_SIZE / GD32_OTA_PACKET_SIZE(validateGd32Size 用)
#include "storage/gd32_flash_store.h" #include "storage/gd32_flash_store.h"
#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot(H4 解耦) #include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot
// ============================================================ // ============================================================
// 系统级 OTA 状态枚举 // 系统级 OTA 状态枚举
@@ -47,7 +47,7 @@ typedef enum {
} ota_channel_t; } ota_channel_t;
// ============================================================ // ============================================================
// OTA 系统全量只读快照(PR1: 收口状态读取) // OTA 系统全量只读快照
// ============================================================ // ============================================================
/** /**
* @brief OTA 系统全量只读快照。 * @brief OTA 系统全量只读快照。
@@ -64,11 +64,11 @@ struct OtaSnapshot {
}; };
// ============================================================ // ============================================================
// IBleCoexListener - BLE 射频共存通知接口(解决 L1 横向耦合) // IBleCoexListener - BLE 射频共存通知接口
// ============================================================ // ============================================================
// WifiOta 实现此接口并注册到 OtaCoordinator, // WifiOta 实现此接口并注册到 OtaCoordinator,
// BLE OTA 开始/结束时通过协调器通知 WiFi 层, // BLE OTA 开始/结束时通过协调器通知 WiFi 层,
// 消除 ble_ota → wifi_ota 的直接依赖。 // 避免 ble_ota → wifi_ota 的直接依赖。
/** /**
* @brief BLE 射频共存监听器接口。 * @brief BLE 射频共存监听器接口。
* BLE OTA 开始/结束时,协调器通过此接口通知 WiFi 层暂停/恢复射频活动。 * BLE OTA 开始/结束时,协调器通过此接口通知 WiFi 层暂停/恢复射频活动。
@@ -124,8 +124,8 @@ public:
esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel); esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel);
/** /**
* @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位按需擦除水位 + 清空 meta + 写入初始 meta。 * @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 整分区预擦 + 清空 meta + 写入初始 meta。
* 扇区擦除在 writeGd32Chunk/flashWriteChunk 内按写入范围推进(非整段预擦)。 * 收流时 write 路径只写 Flash(ensureErased 仅作水位兜底)。
* WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。 * WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。
* @param total_size 固件总字节数。 * @param total_size 固件总字节数。
* @param channel 发起通道(WiFi/BLE)。 * @param channel 发起通道(WiFi/BLE)。
@@ -146,7 +146,7 @@ public:
*/ */
esp_err_t finishGd32StorageAfterReceive(); esp_err_t finishGd32StorageAfterReceive();
// ── GD32 存储操作(传输层通过协调器操作 Flash,解决 H3 绕过问题)── // ── GD32 存储操作(传输层通过协调器操作 Flash)──
/** /**
* @brief 向 GD32 Flash 分区写入固件数据块。 * @brief 向 GD32 Flash 分区写入固件数据块。
* 传输层(WiFi/BLE)通过此接口写入,不再直接调用 Gd32FlashStore。 * 传输层(WiFi/BLE)通过此接口写入,不再直接调用 Gd32FlashStore。
@@ -312,7 +312,9 @@ public:
/** /**
* @brief ESP32 OTA 收尾(统一封装 BLE/WiFi 两端重复的收尾序列)。 * @brief ESP32 OTA 收尾(统一封装 BLE/WiFi 两端重复的收尾序列)。
* 步骤:notifyEsp32ReceiveComplete → Esp32OtaCore::finalize(reboot=false) * 步骤:notifyEsp32ReceiveComplete → Esp32OtaCore::finalize(reboot=false)
* → 成功则 notifyEsp32FinalizeComplete。 * → 成功则 notifyEsp32FinalizeComplete;
* → 失败(SHA 不匹配 / end 失败等)则 abortCurrent() 复位到 IDLE,
* 避免协调器卡在 ESP32_FINALIZING 导致后续上传被假拒绝。
* 不重启;调用方负责把 device_sha_hex 上报前端后再调 restartEsp32()。 * 不重启;调用方负责把 device_sha_hex 上报前端后再调 restartEsp32()。
* @param frontend_sha_hex 前端 64 字符 hex SHA-256,nullptr 表示跳过比对。 * @param frontend_sha_hex 前端 64 字符 hex SHA-256,nullptr 表示跳过比对。
* @return Esp32FinalizeResult,err 为 ESP_OK/ESP_ERR_INVALID_VERSION/其他。 * @return Esp32FinalizeResult,err 为 ESP_OK/ESP_ERR_INVALID_VERSION/其他。
@@ -333,6 +335,14 @@ public:
*/ */
void abortCurrent(); void abortCurrent();
/**
* @brief 自愈孤儿忙态(供 /system/status 等轮询路径调用)。
* - 跑忙状态 watchdog(超时完整 abort → IDLE)
* - ESP32_FINALIZING 但 Esp32OtaCore 已空闲:判定为 SHA 失败等残留,复位 IDLE
* 不影响真正正在 finalize(core.isActive())的会话。
*/
void healOrphanStates();
/** /**
* @brief 获取当前 OTA 会话的 epoch(代次)。 * @brief 获取当前 OTA 会话的 epoch(代次)。
* 每次 beginEsp32Ota/beginGd32Storage 成功时递增。 * 每次 beginEsp32Ota/beginGd32Storage 成功时递增。
@@ -354,7 +364,7 @@ public:
// ── 状态查询 ── // ── 状态查询 ──
/** /**
* @brief 一次性读取系统 OTA 全量状态快照(PR1: 收口状态读取)。 * @brief 一次性读取系统 OTA 全量状态快照。
* coordinator mutex + relay mutex 各取一次, * coordinator mutex + relay mutex 各取一次,
* 供 /system/status 等只读接口消费。 * 供 /system/status 等只读接口消费。
* @return 包含 sys_state、meta、relay 进度的完整快照。 * @return 包含 sys_state、meta、relay 进度的完整快照。
@@ -362,7 +372,7 @@ public:
OtaSnapshot getSnapshot() const; OtaSnapshot getSnapshot() const;
/** /**
* @brief 单次 SPI 探测 GD32 APP 是否存活(PR2: 从 status 移出)。 * @brief 单次 SPI 探测 GD32 APP 是否存活。
* 成功时更新 OtaRelay 缓存的 alive/version, * 成功时更新 OtaRelay 缓存的 alive/version,
* 失败时清除 alive 标志。 * 失败时清除 alive 标志。
* @param out_version 输出版本信息,可为 nullptr。 * @param out_version 输出版本信息,可为 nullptr。
@@ -421,7 +431,7 @@ public:
*/ */
void reset(); void reset();
// ── IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)── // ── IRelayListener 接口实现 ──
/** /**
* @brief IRelayListener 回调:中继已开始。 * @brief IRelayListener 回调:中继已开始。
* 由 OtaRelay task 在 forwardFirmware() 前通过 s_relayListener 调用。 * 由 OtaRelay task 在 forwardFirmware() 前通过 s_relayListener 调用。
@@ -435,7 +445,7 @@ public:
*/ */
void onRelayComplete(bool success) override; void onRelayComplete(bool success) override;
// ── BLE 射频共存管理(L1: 替代 ble_ota → wifi_ota 直接调用)── // ── BLE 射频共存管理 ──
/** /**
* @brief 注册 BLE 共存监听器(由 main 在初始化时调用)。 * @brief 注册 BLE 共存监听器(由 main 在初始化时调用)。
* @param listener WifiOta 实现的监听器指针,nullptr 取消注册。 * @param listener WifiOta 实现的监听器指针,nullptr 取消注册。
@@ -498,10 +508,10 @@ private:
// 仅在同类型活跃状态(ESP32_RECEIVING / GD32_STORING)下有意义。 // 仅在同类型活跃状态(ESP32_RECEIVING / GD32_STORING)下有意义。
ota_channel_t m_otaChannel = OTA_CHANNEL_WIFI; ota_channel_t m_otaChannel = OTA_CHANNEL_WIFI;
// 互斥锁(M1/L3: mutable 允许在 const 方法中加锁,消除 const_cast) // 互斥锁(mutable 允许在 const 方法中加锁,无需 const_cast)
mutable SemaphoreHandle_t m_mutex = nullptr; mutable SemaphoreHandle_t m_mutex = nullptr;
// BLE 共存监听器(L1: 由 WifiOta 实现并注册) // BLE 共存监听器(由 WifiOta 实现并注册)
IBleCoexListener *m_bleCoexListener = nullptr; IBleCoexListener *m_bleCoexListener = nullptr;
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100); static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100);
+9 -10
View File
@@ -1,7 +1,7 @@
/* /*
* ota_relay.cpp - OTA 中继事件驱动状态机实现 * ota_relay.cpp - OTA 中继事件驱动状态机实现
* *
* 将原 17 状态"声明式赋值"重构为 7 状态事件驱动架构: * 7 状态事件驱动架构:
* - 每个协议阶段封装为一个状态 handler * - 每个协议阶段封装为一个状态 handler
* - processEvent() 根据当前状态分发事件 * - processEvent() 根据当前状态分发事件
* - 状态转换显式通过 setState() 完成 * - 状态转换显式通过 setState() 完成
@@ -24,8 +24,8 @@
#include "protocol.h" #include "protocol.h"
#include "health_monitor.h" #include "health_monitor.h"
#include "monitor/debug_log.h" #include "monitor/debug_log.h"
// ★ H4 解耦:OtaRelay 不再直接 #include "ota_coordinator.h", // OtaRelay 不再直接 #include "ota_coordinator.h",
// 通过 IRelayListener 接口回调通知协调器,依赖方向变为单向。 // 通过 IRelayListener 接口回调通知协调器,依赖方向为单向。
// ============================================================ // ============================================================
// 公共拒绝事件判断(文件内工具) // 公共拒绝事件判断(文件内工具)
@@ -42,10 +42,9 @@ static ota_result_t commonReject(relay_event_t e, bool allowHealth = false)
} }
// ============================================================ // ============================================================
// doRelayPackets 重试阈值(与历史代码字面值保持一致,仅命名化) // doRelayPackets 重试阈值(仅命名化字面值)
// ============================================================ // ============================================================
// 原代码中 retry_count 阈值:行 1015/1075/1084 用 10U,行 1032/1050 用 3U。 // retry_count 阈值:NACK/轮询耗尽属于"通信异常",给予较多重试机会(10次);
// 含义:NACK/轮询耗尽属于"通信异常",给予较多重试机会(10次);
// 结构化错误属于"GD32 报告具体错误",较少重试(3次)。 // 结构化错误属于"GD32 报告具体错误",较少重试(3次)。
static constexpr uint32_t kMaxResendOnNack = 10U; // ACK 失败/轮询耗尽重发上限 static constexpr uint32_t kMaxResendOnNack = 10U; // ACK 失败/轮询耗尽重发上限
static constexpr uint32_t kMaxResendOnStructError = 3U; // 结构化错误重发上限 static constexpr uint32_t kMaxResendOnStructError = 3U; // 结构化错误重发上限
@@ -67,7 +66,7 @@ SemaphoreHandle_t OtaRelay::s_stateMutex = NULL;
TaskHandle_t OtaRelay::taskHandle = NULL; TaskHandle_t OtaRelay::taskHandle = NULL;
IRelayListener *OtaRelay::s_relayListener = nullptr; IRelayListener *OtaRelay::s_relayListener = nullptr;
// ── 中继事件监听器(解决 H4 双向耦合)── // ── 中继事件监听器 ──
void OtaRelay::setRelayListener(IRelayListener *listener) void OtaRelay::setRelayListener(IRelayListener *listener)
{ {
s_relayListener = listener; s_relayListener = listener;
@@ -78,7 +77,7 @@ IRelayListener* OtaRelay::getRelayListener()
return s_relayListener; return s_relayListener;
} }
// ── 队列访问器(M3: 封装 public static 成员)── // ── 队列访问器 ──
QueueHandle_t OtaRelay::getDispatchQueue() QueueHandle_t OtaRelay::getDispatchQueue()
{ {
return dispatchQueue; return dispatchQueue;
@@ -1415,7 +1414,7 @@ void OtaRelay::taskEntry(void *pvParameters)
} }
DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg); DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg);
// ★ H4: 通过 IRelayListener 接口回调通知协调器(不再直接依赖 OtaCoordinator) // 通过 IRelayListener 接口回调通知协调器
IRelayListener *listener = getRelayListener(); IRelayListener *listener = getRelayListener();
if (listener) listener->onRelayStarted(); if (listener) listener->onRelayStarted();
ota_result_t result = instance().forwardFirmware(); ota_result_t result = instance().forwardFirmware();
@@ -1432,7 +1431,7 @@ void OtaRelay::taskEntry(void *pvParameters)
DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result); DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result);
} }
instance().reset(); instance().reset();
// ★ H4: 通过 IRelayListener 接口回调通知协调器中继结束 // 通过 IRelayListener 接口回调通知协调器中继结束
listener = getRelayListener(); listener = getRelayListener();
if (listener) listener->onRelayComplete(result == OTA_RESULT_OK); if (listener) listener->onRelayComplete(result == OTA_RESULT_OK);
} }
+9 -11
View File
@@ -63,15 +63,15 @@ typedef enum {
} ota_result_t; } ota_result_t;
// ============================================================ // ============================================================
// IRelayListener - 中继事件监听接口(解决 H4 双向耦合) // IRelayListener - 中继事件监听接口
// ============================================================ // ============================================================
// OtaCoordinator 实现此接口并注册到 OtaRelay, // OtaCoordinator 实现此接口并注册到 OtaRelay,
// OtaRelay 通过接口回调通知协调器,不再直接 #include ota_coordinator.h。 // OtaRelay 通过接口回调通知协调器,不再直接 #include ota_coordinator.h。
// 依赖方向变为单向:OtaCoordinator → OtaRelay(通过接口)。 // 依赖方向为单向:OtaCoordinator → OtaRelay(通过接口)。
/** /**
* @brief 中继事件监听器接口。 * @brief 中继事件监听器接口。
* OtaCoordinator 实现此接口,OtaRelay 通过回调通知状态变更, * OtaCoordinator 实现此接口,OtaRelay 通过回调通知状态变更,
* 消除 OtaRelay → OtaCoordinator 的直接依赖(解决 H4 双向耦合)。 * 避免 OtaRelay → OtaCoordinator 的直接依赖。
*/ */
class IRelayListener { class IRelayListener {
public: public:
@@ -181,7 +181,7 @@ public:
*/ */
void reset(); void reset();
// ── 中继事件监听器(解决 H4 双向耦合)── // ── 中继事件监听器 ──
/** /**
* @brief 注册中继事件监听器(由 OtaCoordinator 在 init 时调用)。 * @brief 注册中继事件监听器(由 OtaCoordinator 在 init 时调用)。
* @param listener 监听器指针,传 nullptr 取消注册。 * @param listener 监听器指针,传 nullptr 取消注册。
@@ -193,7 +193,7 @@ public:
*/ */
static IRelayListener* getRelayListener(); static IRelayListener* getRelayListener();
// ── 队列访问器(解决 M3: 封装 public static 成员)── // ── 队列访问器 ──
/** /**
* @brief 获取事件分发队列句柄。 * @brief 获取事件分发队列句柄。
* 外部模块(OtaCoordinator / BLE)通过 getter 访问,不再直接引用静态成员。 * 外部模块(OtaCoordinator / BLE)通过 getter 访问,不再直接引用静态成员。
@@ -207,8 +207,6 @@ public:
*/ */
static QueueHandle_t getResultQueue(); static QueueHandle_t getResultQueue();
// ── Flash 操作已迁移至 Gd32FlashStore(storage/gd32_flash_store.h)──
// ── 线程安全锁 ── // ── 线程安全锁 ──
static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用) static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用)
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间 static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间
@@ -239,11 +237,11 @@ public:
static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36; static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36;
private: private:
// ── 队列(M3: 由 getter 访问,外部不再直接引用)── // ── 队列(由 getter 访问)──
static QueueHandle_t dispatchQueue; ///< 事件分发队列 static QueueHandle_t dispatchQueue; ///< 事件分发队列
static QueueHandle_t resultQueue; ///< 结果回传队列 static QueueHandle_t resultQueue; ///< 结果回传队列
// ── 状态成员(全部改 private,统一通过 public setter/getter 访问)── // ── 状态成员(统一通过 public setter/getter 访问)──
// 外部代码使用 getState()/getRelaySeq()/... 读取,setState()/setRelaySeq()/... 写入。 // 外部代码使用 getState()/getRelaySeq()/... 读取,setState()/setRelaySeq()/... 写入。
// 内部代码:若已在 StateLockGuard 块内(reset/getSnapshot/启动探测),可直接访问; // 内部代码:若已在 StateLockGuard 块内(reset/getSnapshot/启动探测),可直接访问;
// 否则必须使用 setter/getter,禁止裸读写(消除数据竞争)。 // 否则必须使用 setter/getter,禁止裸读写(消除数据竞争)。
@@ -252,7 +250,7 @@ private:
static uint32_t relayTotalPackets; ///< 本次中继总包数 static uint32_t relayTotalPackets; ///< 本次中继总包数
static volatile bool gd32AppAlive; ///< GD32 APP 存活标志 static volatile bool gd32AppAlive; ///< GD32 APP 存活标志
static gd32_version_t gd32Version; ///< GD32 固件版本信息 static gd32_version_t gd32Version; ///< GD32 固件版本信息
static IRelayListener *s_relayListener; ///< 中继事件监听器(解决 H4) static IRelayListener *s_relayListener; ///< 中继事件监听器
static SemaphoreHandle_t s_stateMutex; ///< 状态成员互斥锁(由 StateLockGuard 使用) static SemaphoreHandle_t s_stateMutex; ///< 状态成员互斥锁(由 StateLockGuard 使用)
// taskHandle 为 write-once-at-init 字段:仅 taskEntry 启动首行写入一次, // taskHandle 为 write-once-at-init 字段:仅 taskEntry 启动首行写入一次,
@@ -331,7 +329,7 @@ private:
/** /**
* @brief 发送单包并等待 ACK(doRelayPackets 子步骤)。 * @brief 发送单包并等待 ACK(doRelayPackets 子步骤)。
* *
* 内含重试循环,使用以下命名常量(与历史阈值保持一致): * 内含重试循环,使用以下命名常量:
* kMaxResendOnNack=10(ACK 失败/轮询耗尽重发上限) * kMaxResendOnNack=10(ACK 失败/轮询耗尽重发上限)
* kMaxResendOnStructError=3(结构化错误重发上限) * kMaxResendOnStructError=3(结构化错误重发上限)
* *
+32 -16
View File
@@ -66,7 +66,8 @@
// ── 数据队列:将 OTA 数据处理从 NimBLE host task 卸载到工作线程 ── // ── 数据队列:将 OTA 数据处理从 NimBLE host task 卸载到工作线程 ──
// 避免 flash 操作(esp_ota_begin / esp_ota_write)阻塞 BLE 协议栈导致连接断开。 // 避免 flash 操作(esp_ota_begin / esp_ota_write)阻塞 BLE 协议栈导致连接断开。
#define BLE_OTA_DATA_QUEUE_LEN (BLE_OTA_ACK_WINDOW + 4) // 深度略大于窗口:seq=0 整分区预擦期间前端若仍误发,可多扛几批,减少“队列满丢包→重传风暴”。
#define BLE_OTA_DATA_QUEUE_LEN (BLE_OTA_ACK_WINDOW * 2 + 8)
struct BleOtaDataBatch { struct BleOtaDataBatch {
uint8_t *data; uint8_t *data;
@@ -83,7 +84,7 @@ struct BleEsp32FinishCtx {
uint16_t session_id; uint16_t session_id;
}; };
// RAII 包装:保证任务所有 return 路径自动释放 ctx,避免历史多次漏 free。 // RAII 包装:保证任务所有 return 路径自动释放 ctx,避免漏 free。
// 注意:若调用 restartEsp32()(内部 esp_restart 不返回),析构不会执行, // 注意:若调用 restartEsp32()(内部 esp_restart 不返回),析构不会执行,
// 但这是设备重启路径,资源由系统回收。 // 但这是设备重启路径,资源由系统回收。
struct BleFinishCtxDeleter { struct BleFinishCtxDeleter {
@@ -236,13 +237,10 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
// ============================================================ // ============================================================
// WiFi 共存策略:BLE OTA 传输期间的处理 // WiFi 共存策略:BLE OTA 传输期间的处理
// //
// 历史:曾在此处把 WiFi 切到 WIFI_PS_MAX_MODEM 以把空中时间让给 BLE。 // 不切换 WiFi 省电模式(保持 WIFI_PS_NONE),让 STA 全程可用,
// 但 MAX_MODEM 下 STA 监听间隔被拉到极大,HTTP server 的 TCP 握手/数据包
// 会大量丢失或延迟到超时 → BLE OTA 期间任何 WiFi 请求(包括互斥拒绝应返回的
// 403)都到不了设备,前端 XHR 长时间无响应,表现为"Wifi 上传既不成功也不报错"。
//
// 现策略:不切换 WiFi 省电模式(保持 WIFI_PS_NONE),让 STA 全程可用,
// 协调器照常以 403 拒绝并发 OTA。ESP32-C3 coex 默认调度已能保证 BLE OTA 可用。 // 协调器照常以 403 拒绝并发 OTA。ESP32-C3 coex 默认调度已能保证 BLE OTA 可用。
// (曾尝试切到 WIFI_PS_MAX_MODEM 让出空中时间,但 STA 监听间隔被拉到极大,
// HTTP server 的 TCP 握手/数据包大量丢失或超时,前端 XHR 长时间无响应。)
// 仍保留 setBleOtaActive() 暂停 RSSI 轮询(避免 get_ap_info 的射频活动干扰 BLE)。 // 仍保留 setBleOtaActive() 暂停 RSSI 轮询(避免 get_ap_info 的射频活动干扰 BLE)。
// ============================================================ // ============================================================
static bool s_wifi_ps_boosted = false; static bool s_wifi_ps_boosted = false;
@@ -254,7 +252,7 @@ static void ble_ota_boost_wifi_ps()
{ {
if (s_wifi_ps_boosted) return; if (s_wifi_ps_boosted) return;
s_wifi_ps_boosted = true; s_wifi_ps_boosted = true;
// ★ L1: 通过协调器通知 WiFi 层(替代直接调用 WifiOta) // 通过协调器通知 WiFi 层
OtaCoordinator::instance().notifyBleCoexStart(); OtaCoordinator::instance().notifyBleCoexStart();
DEBUG_INFO(TAG, "BLE OTA 数据传输进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)"); DEBUG_INFO(TAG, "BLE OTA 数据传输进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)");
} }
@@ -266,7 +264,7 @@ static void ble_ota_restore_wifi_ps()
{ {
if (!s_wifi_ps_boosted) return; if (!s_wifi_ps_boosted) return;
s_wifi_ps_boosted = false; s_wifi_ps_boosted = false;
// ★ L1: 通过协调器通知 WiFi 层(替代直接调用 WifiOta) // 通过协调器通知 WiFi 层
OtaCoordinator::instance().notifyBleCoexEnd(); OtaCoordinator::instance().notifyBleCoexEnd();
DEBUG_INFO(TAG, "BLE OTA 数据传输结束:恢复 WiFi RSSI 轮询(GD32 中继仍可能进行中)"); DEBUG_INFO(TAG, "BLE OTA 数据传输结束:恢复 WiFi RSSI 轮询(GD32 中继仍可能进行中)");
} }
@@ -933,7 +931,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
ctx->session_id = session_id; ctx->session_id = session_id;
BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task, BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task,
"ble_gd32_wait", "ble_gd32_wait",
3072, TASK_STACK_BLE_GD32_WAIT,
ctx, ctx,
5, 5,
NULL); NULL);
@@ -992,7 +990,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
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,
"ble_ota_finish", "ble_ota_finish",
4096, TASK_STACK_BLE_FINISH,
finish_ctx, // heap context; task 负责 free finish_ctx, // heap context; task 负责 free
5, 5,
NULL); NULL);
@@ -1015,7 +1013,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
BleOta::instance().m_esp32RebootPending = true; BleOta::instance().m_esp32RebootPending = true;
BaseType_t ok = xTaskCreate(ble_esp32_restart_task, BaseType_t ok = xTaskCreate(ble_esp32_restart_task,
"ble_ota_restart", "ble_ota_restart",
2048, TASK_STACK_BLE_RESTART,
NULL, NULL,
5, 5,
NULL); NULL);
@@ -1223,17 +1221,25 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
// ACK 按累计连续进度触发,而不是按当前到达包的 seq 触发。缺口包到达后 // ACK 按累计连续进度触发,而不是按当前到达包的 seq 触发。缺口包到达后
// reorder 可能一次 flush 到下个窗口边界,例如 seq=99 使 expected 直达128; // reorder 可能一次 flush 到下个窗口边界,例如 seq=99 使 expected 直达128;
// 此时必须立即 ACK 127,否则后续重复包都不产生 progress,前端只能超时。 // 此时必须立即 ACK 127,否则后续重复包都不产生 progress,前端只能超时。
//
// ★ seq=0 强制 ACK:begin 含整分区预擦,可能耗时数秒~数十秒。
// 前端必须等“首包已落盘”ACK 后再发窗口内其余包,否则队列满丢包→重传极慢。
uint32_t completedPackets = (afterReceived + BLE_OTA_CHUNK_PAYLOAD - 1U) / uint32_t completedPackets = (afterReceived + BLE_OTA_CHUNK_PAYLOAD - 1U) /
BLE_OTA_CHUNK_PAYLOAD; BLE_OTA_CHUNK_PAYLOAD;
bool reachedAckBoundary = completedPackets > 0U && bool reachedAckBoundary = completedPackets > 0U &&
(completedPackets % BLE_OTA_ACK_WINDOW) == 0U; (completedPackets % BLE_OTA_ACK_WINDOW) == 0U;
bool firstPacketReady = (seq == 0U) && progressMade;
if (progressMade && if (progressMade &&
(reachedAckBoundary || (reachedAckBoundary ||
transferComplete || transferComplete ||
isLastPacket)) { isLastPacket ||
firstPacketReady)) {
if (isLastPacket || transferComplete) { if (isLastPacket || transferComplete) {
DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32, DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32,
seq, data_len, (unsigned int)afterReceived, total_size); seq, data_len, (unsigned int)afterReceived, total_size);
} else if (firstPacketReady) {
DEBUG_INFO(TAG, "ESP32 begin 就绪 ACK: seq=0 received=%u total=%" PRIu32,
(unsigned)afterReceived, total_size);
} }
uint16_t ackSeq = s_reorder.expected > 0U uint16_t ackSeq = s_reorder.expected > 0U
? (uint16_t)(s_reorder.expected - 1U) ? (uint16_t)(s_reorder.expected - 1U)
@@ -1384,7 +1390,9 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
meta.stored_size = stored_size; meta.stored_size = stored_size;
meta.last_seq = seq; meta.last_seq = seq;
const bool transfer_complete = stored_size >= meta.total_size; const bool transfer_complete = stored_size >= meta.total_size;
// seq=0:prepare 含整分区预擦,写完首包必须 ACK,前端才能继续发后续窗口
const bool checkpoint = const bool checkpoint =
(seq == 0U) ||
((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) || ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) ||
transfer_complete || transfer_complete ||
(expected_payload < BLE_OTA_CHUNK_PAYLOAD); (expected_payload < BLE_OTA_CHUNK_PAYLOAD);
@@ -1681,6 +1689,14 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
s_conn_fallback_requested = true; s_conn_fallback_requested = true;
ble_ota_request_conn_params(BleOta::instance().m_connHandle, true); ble_ota_request_conn_params(BleOta::instance().m_connHandle, true);
} }
/* 优先 LE 2M PHY(空中速率约翻倍);对端不支持则保持 1M,失败仅打日志 */
{
const uint8_t phy_mask = BLE_GAP_LE_PHY_1M_MASK | BLE_GAP_LE_PHY_2M_MASK;
int phy_rc = ble_gap_set_prefered_le_phy(
BleOta::instance().m_connHandle, phy_mask, phy_mask,
BLE_GAP_LE_PHY_CODED_ANY);
DEBUG_INFO(TAG, "请求 LE PHY 1M|2M: rc=%d", phy_rc);
}
} else { } else {
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status); DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
BleOta::instance().m_bleConnected = false; BleOta::instance().m_bleConnected = false;
@@ -2040,7 +2056,7 @@ esp_err_t BleOta::resumeStack()
m_suspendedForWifiOta = false; m_suspendedForWifiOta = false;
if (!m_stackRunning) { if (!m_stackRunning) {
/* 仅当历史上曾 hard-deinit 过才需要完整 serverStart */ /* 仅当曾 hard-deinit 过才需要完整 serverStart */
DEBUG_INFO(TAG, "WiFi OTA 结束:BLE 栈未运行,尝试 serverStart free=%lu", DEBUG_INFO(TAG, "WiFi OTA 结束: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();
@@ -2125,7 +2141,7 @@ void BleOta::taskEntry(void *pvParameters)
if (s_otaDataQueue) { if (s_otaDataQueue) {
BaseType_t wk_ok = xTaskCreate(ble_ota_data_worker_task, BaseType_t wk_ok = xTaskCreate(ble_ota_data_worker_task,
"ble_ota_worker", "ble_ota_worker",
4096, // 峰值仅 ~1.9KB,4KB 留有充足余量 TASK_STACK_BLE_WORKER, // 实测 peak~1.1KB,负载峰值曾~1.9KB
NULL, NULL,
10, // 中等优先级,避免被 NimBLE host task 过度抢占 10, // 中等优先级,避免被 NimBLE host task 过度抢占
NULL); NULL);
+15 -181
View File
@@ -1,6 +1,5 @@
/* /*
* http_handlers.cpp - HTTP 请求处理器实现 * http_handlers.cpp - HTTP 请求处理器实现
* 从 wifi_ota.cpp 提取的 HTTP handler 函数
*/ */
#include <string.h> #include <string.h>
#include <stdarg.h> #include <stdarg.h>
@@ -28,6 +27,7 @@
#include "storage/gd32_flash_store.h" #include "storage/gd32_flash_store.h"
#include "health_monitor.h" #include "health_monitor.h"
#include "monitor/debug_log.h" #include "monitor/debug_log.h"
#include "monitor/cpu_stats.h"
#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"
@@ -208,7 +208,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req)
DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len); DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len);
esp_wifi_set_ps(WIFI_PS_NONE); esp_wifi_set_ps(WIFI_PS_NONE);
// ★ L1: WiFi OTA 开始时恢复 BLE 共存状态(通过协调器) // WiFi OTA 开始时恢复 BLE 共存状态(通过协调器)
OtaCoordinator::instance().notifyBleCoexEnd(); OtaCoordinator::instance().notifyBleCoexEnd();
char peek; char peek;
@@ -307,7 +307,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req)
const uint32_t free_pre = esp_get_free_heap_size(); const uint32_t free_pre = esp_get_free_heap_size();
if (free_pre < 8192U) { if (free_pre < 8192U) {
(void)esp_task_wdt_reset(); (void)esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(free_pre < 4096U ? 5U : 2U)); vTaskDelay(pdMS_TO_TICKS(free_pre < 4096U ? 5U : 1));
} }
} }
@@ -615,12 +615,10 @@ esp_err_t can_feedback_handler(httpd_req_t *req)
valid[i] = getLatestCanFeedback(i, &results[i]); valid[i] = getLatestCanFeedback(i, &results[i]);
} }
/* 估算缓冲大小:每通道 ~2KB (统计+ID条目) */ /* ★ 修复:改用静态缓冲区,避免每次请求 malloc 8KB 加剧堆碎片化 */
char *resp = (char *)calloc(1, 8192); static char s_feedback_json[8192];
if (!resp) { char *resp = s_feedback_json;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); memset(resp, 0, sizeof(s_feedback_json));
return ESP_FAIL;
}
char *p = resp; char *p = resp;
p += sprintf(p, "{\"channels\":["); p += sprintf(p, "{\"channels\":[");
@@ -670,7 +668,6 @@ esp_err_t can_feedback_handler(httpd_req_t *req)
httpd_resp_set_type(req, "application/json"); httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, (int)(p - resp)); httpd_resp_send(req, resp, (int)(p - resp));
free(resp);
return ESP_OK; return ESP_OK;
} }
@@ -882,7 +879,6 @@ 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"。 */ /* 状态 JSON 用静态缓冲:OTA 后 largest free 常 <2KB,calloc(8KB) 会 500 "no memory"。 */
static constexpr size_t SYSTEM_STATUS_JSON_CAP = 6144; static constexpr size_t SYSTEM_STATUS_JSON_CAP = 6144;
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 32;
static char s_status_json[SYSTEM_STATUS_JSON_CAP]; static char s_status_json[SYSTEM_STATUS_JSON_CAP];
static SemaphoreHandle_t s_status_json_mux = nullptr; static SemaphoreHandle_t s_status_json_mux = nullptr;
static portMUX_TYPE s_status_json_init_mux = portMUX_INITIALIZER_UNLOCKED; static portMUX_TYPE s_status_json_init_mux = portMUX_INITIALIZER_UNLOCKED;
@@ -899,167 +895,6 @@ static SemaphoreHandle_t statusJsonMutex(void)
return s_status_json_mux; return s_status_json_mux;
} }
struct CpuTaskSample {
UBaseType_t task_number;
configRUN_TIME_COUNTER_TYPE runtime;
};
struct CpuTaskMetric {
const char* name;
UBaseType_t task_number;
uint64_t runtime_delta;
double usage_pct;
};
struct CpuUsageSnapshot {
bool available;
bool valid;
uint32_t sample_ms;
double usage_pct;
double idle_pct;
CpuTaskMetric tasks[CPU_TASK_SNAPSHOT_MAX];
size_t task_count;
};
static portMUX_TYPE s_cpu_stats_mux = portMUX_INITIALIZER_UNLOCKED;
static CpuTaskSample s_cpu_last_samples[CPU_TASK_SNAPSHOT_MAX];
static size_t s_cpu_last_count = 0;
static configRUN_TIME_COUNTER_TYPE s_cpu_last_total = 0;
static int64_t s_cpu_last_time_us = 0;
static bool s_cpu_has_last = false;
static uint64_t runtime_delta(configRUN_TIME_COUNTER_TYPE current, configRUN_TIME_COUNTER_TYPE previous)
{
return current >= previous ? (uint64_t)(current - previous) : 0U;
}
static bool task_name_is_idle(const char* name)
{
return name && strncmp(name, "IDLE", 4) == 0;
}
static void reset_cpu_usage_baseline_locked(const TaskStatus_t* tasks, size_t task_count,
configRUN_TIME_COUNTER_TYPE total_runtime,
int64_t now_us)
{
size_t copy_count = std::min(task_count, CPU_TASK_SNAPSHOT_MAX);
for (size_t i = 0; i < copy_count; ++i) {
s_cpu_last_samples[i].task_number = tasks[i].xTaskNumber;
s_cpu_last_samples[i].runtime = tasks[i].ulRunTimeCounter;
}
s_cpu_last_count = copy_count;
s_cpu_last_total = total_runtime;
s_cpu_last_time_us = now_us;
s_cpu_has_last = true;
}
// ★ 改为输出参数模式:CpuUsageSnapshot 608B + CpuTaskSample[24] 192B = 800B
// 之前全在栈上,system_status_handler 又拷一份 608B → 叠加 1.4KB,
// 加上 OtaSnapshot/TaskStackMetric/wifi_ap_record_t 约 2.3KB 栈消耗。
// HTTP 主线程上传 100% 后调用此 handler 时极易栈溢出。改堆分配。
static void sample_cpu_usage(CpuUsageSnapshot& result)
{
result = {};
#if defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
result.available = true;
UBaseType_t task_count = uxTaskGetNumberOfTasks();
if (task_count == 0) {
return;
}
task_count = std::min<UBaseType_t>(task_count + 2U, CPU_TASK_SNAPSHOT_MAX);
TaskStatus_t* tasks = (TaskStatus_t*)calloc(task_count, sizeof(TaskStatus_t));
if (!tasks) {
return;
}
configRUN_TIME_COUNTER_TYPE total_runtime = 0;
UBaseType_t captured_count = uxTaskGetSystemState(tasks, task_count, &total_runtime);
int64_t now_us = esp_timer_get_time();
if (captured_count == 0 || captured_count > task_count) {
DEBUG_WARN("STATUS", "CPU stats: uxTaskGetSystemState 返回 0 (actual_tasks=%lu, buf_slots=%lu)",
(unsigned long)uxTaskGetNumberOfTasks(), (unsigned long)task_count);
free(tasks);
return;
}
// ★ previous_samples[24] = 192B,改堆分配减少栈压力
CpuTaskSample* previous_samples = (CpuTaskSample*)calloc(CPU_TASK_SNAPSHOT_MAX, sizeof(CpuTaskSample));
if (!previous_samples) {
free(tasks);
return;
}
size_t previous_count = 0;
configRUN_TIME_COUNTER_TYPE previous_total = 0;
int64_t previous_time_us = 0;
bool had_previous = false;
portENTER_CRITICAL(&s_cpu_stats_mux);
previous_count = s_cpu_last_count;
previous_total = s_cpu_last_total;
previous_time_us = s_cpu_last_time_us;
had_previous = s_cpu_has_last;
for (size_t i = 0; i < previous_count; ++i) {
previous_samples[i] = s_cpu_last_samples[i];
}
reset_cpu_usage_baseline_locked(tasks, captured_count, total_runtime, now_us);
portEXIT_CRITICAL(&s_cpu_stats_mux);
bool can_compute = had_previous && total_runtime >= previous_total && now_us > previous_time_us;
uint64_t total_delta = can_compute ? runtime_delta(total_runtime, previous_total) : 0U;
uint64_t idle_delta = 0U;
if (can_compute && total_delta > 0U) {
for (UBaseType_t i = 0; i < captured_count; ++i) {
configRUN_TIME_COUNTER_TYPE last_runtime = 0;
bool found_previous = false;
for (size_t j = 0; j < previous_count; ++j) {
if (previous_samples[j].task_number == tasks[i].xTaskNumber) {
last_runtime = previous_samples[j].runtime;
found_previous = true;
break;
}
}
if (!found_previous) {
continue;
}
uint64_t delta = runtime_delta(tasks[i].ulRunTimeCounter, last_runtime);
double pct = (double)delta * 100.0 / (double)total_delta;
if (task_name_is_idle(tasks[i].pcTaskName)) {
idle_delta += delta;
}
if (result.task_count < CPU_TASK_SNAPSHOT_MAX) {
CpuTaskMetric& metric = result.tasks[result.task_count++];
metric.name = tasks[i].pcTaskName ? tasks[i].pcTaskName : "";
metric.task_number = tasks[i].xTaskNumber;
metric.runtime_delta = delta;
metric.usage_pct = pct;
}
}
result.valid = true;
result.sample_ms = (uint32_t)((now_us - previous_time_us) / 1000);
result.idle_pct = std::min(100.0, (double)idle_delta * 100.0 / (double)total_delta);
result.usage_pct = std::max(0.0, 100.0 - result.idle_pct);
}
if (!result.valid) {
DEBUG_DBG("STATUS", "CPU stats: valid=false had_prev=%d total=%lu prev_total=%lu "
"can_compute=%d delta=%lu captured=%lu",
(int)had_previous,
(unsigned long)total_runtime, (unsigned long)previous_total,
(int)can_compute, (unsigned long)total_delta,
(unsigned long)captured_count);
}
free(previous_samples);
free(tasks);
#else
result.available = false;
#endif
}
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;
@@ -1089,6 +924,7 @@ 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 线程采样) */
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)
{ {
offset = append_jsonf(buf, cap, offset, offset = append_jsonf(buf, cap, offset,
@@ -1168,7 +1004,9 @@ esp_err_t system_status_handler(httpd_req_t *req)
connected = true; connected = true;
} }
// ★ PR1: 一次性快照,替代散读 OtaCoordinator / OtaRelay 多个 getter // 先自愈孤儿忙态(如 SHA 失败后卡在 ESP32_FINALIZING),再取快照给 UI
OtaCoordinator::instance().healOrphanStates();
// 一次性快照,替代散读 OtaCoordinator / OtaRelay 多个 getter
OtaSnapshot snap = OtaCoordinator::instance().getSnapshot(); OtaSnapshot snap = OtaCoordinator::instance().getSnapshot();
size_t free_heap = esp_get_free_heap_size(); size_t free_heap = esp_get_free_heap_size();
@@ -1190,20 +1028,17 @@ esp_err_t system_status_handler(httpd_req_t *req)
} }
} }
/* CPU 采样在堆上;失败则跳过(available=false),不再 500 */ /* CPU:只读 cpu_stats 任务的 1Hz 快照,禁止在 httpd 里 uxTaskGetSystemState */
CpuUsageSnapshot cpu_local = {}; CpuUsageSnapshot cpu_local = {};
CpuUsageSnapshot *cpu = &cpu_local; CpuUsageSnapshot *cpu = &cpu_local;
CpuUsageSnapshot *cpu_heap = nullptr; CpuUsageSnapshot *cpu_heap = nullptr;
if (largest_free_block >= (sizeof(CpuUsageSnapshot) + 1024U)) { if (largest_free_block >= (sizeof(CpuUsageSnapshot) + 512U)) {
cpu_heap = (CpuUsageSnapshot *)calloc(1, sizeof(CpuUsageSnapshot)); cpu_heap = (CpuUsageSnapshot *)calloc(1, sizeof(CpuUsageSnapshot));
if (cpu_heap) { if (cpu_heap) {
cpu = cpu_heap; cpu = cpu_heap;
sample_cpu_usage(*cpu);
} }
} else {
cpu_local.available = false;
cpu_local.valid = false;
} }
CpuStats::instance().getSnapshot(*cpu);
char *resp = s_status_json; char *resp = s_status_json;
memset(resp, 0, SYSTEM_STATUS_JSON_CAP); memset(resp, 0, SYSTEM_STATUS_JSON_CAP);
@@ -1309,11 +1144,10 @@ esp_err_t system_status_handler(httpd_req_t *req)
} }
// ============================================================ // ============================================================
// GET /system/probe — GD32 APP 单次 SPI 探测(PR2) // GET /system/probe — GD32 APP 单次 SPI 探测
// ============================================================ // ============================================================
/** /**
* @brief 显式触发一次 GD32 APP 存活探测。 * @brief 显式触发一次 GD32 APP 存活探测。
* 从 system_status_handler 移出的副作用,改为独立端点。
* 成功时更新 OtaRelay 缓存的 alive/version 并返回版本信息。 * 成功时更新 OtaRelay 缓存的 alive/version 并返回版本信息。
*/ */
esp_err_t system_probe_handler(httpd_req_t *req) esp_err_t system_probe_handler(httpd_req_t *req)
+15 -13
View File
@@ -166,7 +166,7 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base,
inst.setStaRetryCount(0); inst.setStaRetryCount(0);
inst.setStaRetryExhausted(false); // ★ 连接成功,重置耗尽标志 inst.setStaRetryExhausted(false); // ★ 连接成功,重置耗尽标志
inst.stateUnlock(); inst.stateUnlock();
// ★ M2: 推送 WiFi 连接状态给 HealthMonitor(解耦监控层主动查询) // 推送 WiFi 连接状态给 HealthMonitor
HealthMonitor::instance().setWifiConnected(true); HealthMonitor::instance().setWifiConnected(true);
break; break;
@@ -177,7 +177,7 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base,
bool exhausted = inst.getStaRetryExhausted(); bool exhausted = inst.getStaRetryExhausted();
int retryCount = inst.getStaRetryCount(); int retryCount = inst.getStaRetryCount();
inst.stateUnlock(); inst.stateUnlock();
// ★ M2: 推送 WiFi 断开状态给 HealthMonitor // 推送 WiFi 断开状态给 HealthMonitor
HealthMonitor::instance().setWifiConnected(false); HealthMonitor::instance().setWifiConnected(false);
if (!exhausted) { if (!exhausted) {
@@ -401,15 +401,17 @@ esp_err_t WifiOta::httpServerStart()
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 → 业务 socket 不宜过大。 /* LWIP_MAX_SOCKETS=12;HTTP 内部约 3 + UDP 日志 1 → 业务 socket 不宜过大。
* 上传期间前端必须停 status/logs(见 app.js pausePollingForUpload)。 */ * 上传期间前端必须停 status/logs(见 app.js pausePollingForUpload)。
config.max_open_sockets = 4; * ★ 修复:增加到 7,防止浏览器超时 abort 后旧连接占满所有槽位。 */
/* 上传中途若再开 status 连接,LRU 会踢掉 /ota/upload → 浏览器卡在 100%。 config.max_open_sockets = 7;
* 关闭 LRU 踢连接:宁可新连接失败,也不拆正在写 Flash 的长传。 */ /* ★ 修复:启用 LRU 淘汰,当所有 socket 被占满时主动关闭最旧空闲连接。
config.lru_purge_enable = false; * OTA 上传期间前端已暂停轮询(pausePollingForUpload),不会踢掉 upload 连接。
/* 1MB sequential 擦写时,httpd 在 write 阶段可能 >60s 不调 recv; * 若仍担心极端情况,可在 ota_upload_handler 入口设置该 session 的更长超时。 */
* 超时后 handler 内会重试,但过短会放大“卡 100%”观感。 */ config.lru_purge_enable = true;
config.recv_wait_timeout = 120; /* ★ 修复:缩短超时,加速释放废弃连接。
config.send_wait_timeout = 60; * OTA 写 Flash 期间 handler 本身持有连接不会被超时踢(httpd 只在 idle 时检测)。 */
config.recv_wait_timeout = 10;
config.send_wait_timeout = 10;
DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ==="); DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ===");
DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port); DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port);
@@ -508,7 +510,7 @@ bool WifiOta::isConnected()
} }
// ============================================================ // ============================================================
// IBleCoexListener 接口实现(L1: 通过协调器接收 BLE 共存通知) // IBleCoexListener 接口实现
// ============================================================ // ============================================================
void WifiOta::onBleOtaStart() void WifiOta::onBleOtaStart()
{ {
@@ -603,7 +605,7 @@ bool WifiOta::earlyInit()
// 创建后台重连任务:避免在系统事件任务中调用 esp_wifi_connect()/vTaskDelay 导致死锁 // 创建后台重连任务:避免在系统事件任务中调用 esp_wifi_connect()/vTaskDelay 导致死锁
if (WifiOta::instance().m_reconnectTask == nullptr) { if (WifiOta::instance().m_reconnectTask == nullptr) {
BaseType_t ret = xTaskCreate(&WifiOta::reconnectTask, "wifi_reconnect", BaseType_t ret = xTaskCreate(&WifiOta::reconnectTask, "wifi_reconnect",
4096, NULL, 5, TASK_STACK_WIFI_RECONNECT, NULL, 5,
&WifiOta::instance().m_reconnectTask); &WifiOta::instance().m_reconnectTask);
if (ret != pdPASS) { if (ret != pdPASS) {
DEBUG_ERR("WIFI", "创建重连任务失败"); DEBUG_ERR("WIFI", "创建重连任务失败");
+1 -1
View File
@@ -90,7 +90,7 @@ public:
/** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */ /** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */
void setBleOtaActive(bool v) { m_bleOtaActive.store(v); } void setBleOtaActive(bool v) { m_bleOtaActive.store(v); }
// ── IBleCoexListener 接口实现(L1: 通过协调器接收 BLE 共存通知)── // ── IBleCoexListener 接口实现 ──
/** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询。 */ /** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询。 */
void onBleOtaStart() override; void onBleOtaStart() override;
/** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */ /** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */
+2 -2
View File
@@ -136,7 +136,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
return ESP_ERR_INVALID_SIZE; return ESP_ERR_INVALID_SIZE;
} }
// 写入前按需擦除目标扇区(替代 prepare 阶段整段预擦) // 正常路径:prepare 已整分区预擦;此处仅兜底(水位未覆盖时才擦)
esp_err_t erase_err = ensureErasedForWrite(aligned_offset, aligned_len); esp_err_t erase_err = ensureErasedForWrite(aligned_offset, aligned_len);
if (erase_err != ESP_OK) { if (erase_err != ESP_OK) {
return erase_err; return erase_err;
@@ -457,7 +457,7 @@ esp_err_t Gd32FlashStore::metaVerifyChecksum()
// 检查 checksum 是否全零(未计算过) // 检查 checksum 是否全零(未计算过)
// 注意:返回 INVALID_STATE 而非 ESP_OK,由调用方决定是否容忍。 // 注意:返回 INVALID_STATE 而非 ESP_OK,由调用方决定是否容忍。
// 历史上这里直接返回 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(调用方决定容忍或失败)");
+6 -8
View File
@@ -1,9 +1,8 @@
/* /*
* gd32_flash_store.h - GD32 OTA 固件 Flash 存储抽象层 * gd32_flash_store.h - GD32 OTA 固件 Flash 存储抽象层
* *
* 从 OtaRelay 中剥离的 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验职责。 * 封装 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验。
* 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储, * 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储。
* 不再由 OtaRelay 承担存储职责(解决 H1 职责超载)。
* *
* 线程模型: * 线程模型:
* - 通过 instance() 单例访问,NVS 元数据读写由内部 mutex 保护 * - 通过 instance() 单例访问,NVS 元数据读写由内部 mutex 保护
@@ -72,8 +71,8 @@ public:
esp_err_t erasePartition(uint32_t size); esp_err_t erasePartition(uint32_t size);
/** /**
* @brief 新一轮 GD32 固件写入会话开始时调用:复位按需擦除水位。 * @brief 新一轮 GD32 固件写入会话开始时调用:复位擦除水位。
* prepareGd32Storage 在开始收流前调用,避免整段预擦阻塞 HTTP/BLE 收包。 * prepareGd32Storage 随后会 erasePartition(整分区)。
*/ */
void beginWriteSession(); void beginWriteSession();
@@ -153,9 +152,8 @@ private:
const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化) const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化)
// ── 按需擦除水位:已保证 [0, m_erasedUpTo) 为擦除态(扇区对齐)── // ── 擦除水位:prepare 整分区预擦后 m_erasedUpTo==part->size;
// 收流时 flashWriteChunk 按写入范围推进擦除,替代上传前整段预擦。 // flashWriteChunk 内 ensureErased 为安全兜底(水位已覆盖则立即返回)。
// 这样 HTTP 主线程可边收边擦写,浏览器进度不会在“100% 发完”后空等整段擦除。
static constexpr uint32_t kFlashSectorSize = 4096U; static constexpr uint32_t kFlashSectorSize = 4096U;
uint32_t m_erasedUpTo = 0; uint32_t m_erasedUpTo = 0;
esp_err_t ensureErasedForWrite(uint32_t offset, size_t len); esp_err_t ensureErasedForWrite(uint32_t offset, size_t len);
+1 -1
View File
@@ -1,5 +1,5 @@
/* /*
* canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取) * canfd_bridge.cpp - CANFD 3通道通信桥接实现
*/ */
#include <string.h> #include <string.h>
#include "canfd_bridge.h" #include "canfd_bridge.h"
+2 -2
View File
@@ -1,7 +1,7 @@
/* /*
* canfd_bridge.h - CANFD 3通道通信桥接(M4: 从 SpiMaster 提取) * canfd_bridge.h - CANFD 3通道通信桥接
* *
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责解耦。 * 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责分离。
* 通过 SpiMaster 的公共 SPI 接口完成底层传输。 * 通过 SpiMaster 的公共 SPI 接口完成底层传输。
*/ */
#ifndef CANFD_BRIDGE_H #ifndef CANFD_BRIDGE_H
+25 -22
View File
@@ -23,6 +23,7 @@
#include "spi_master.h" #include "spi_master.h"
#include "spi_link_v2.h" #include "spi_link_v2.h"
#include "app_config.h"
#include "monitor/debug_log.h" #include "monitor/debug_log.h"
@@ -80,7 +81,7 @@ SpiMaster& SpiMaster::instance()
} }
// ============================================================ // ============================================================
// L4 SPI Worker:唯一持 SPI 的任务;ISR 只通知本任务 // SPI Worker:唯一持 SPI 的任务;ISR 只通知本任务
// ============================================================ // ============================================================
bool SpiMaster::onWorker() const bool SpiMaster::onWorker() const
@@ -171,7 +172,7 @@ esp_err_t SpiMaster::serviceRingOnce()
m_gd32RequestPending = false; m_gd32RequestPending = false;
m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING); m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING);
if (waitSpiDataReady(5U) != ESP_OK) { if (waitSpiDataReady(3U) != ESP_OK) {
m_linkState.exitBusy(); m_linkState.exitBusy();
return ESP_ERR_NOT_FOUND; return ESP_ERR_NOT_FOUND;
} }
@@ -295,7 +296,7 @@ void SpiMaster::workerLoop()
{ {
m_workerTask = xTaskGetCurrentTaskHandle(); m_workerTask = xTaskGetCurrentTaskHandle();
m_spiTaskHandle = m_workerTask; m_spiTaskHandle = m_workerTask;
DEBUG_INFO("SPI", "SPI worker started (L4), handle=%p", (void *)m_workerTask); DEBUG_INFO("SPI", "SPI worker started, handle=%p", (void *)m_workerTask);
for (;;) { for (;;) {
/* ★ 只在 ISR 置位 m_gd32RequestPending 时读 EVENT。 /* ★ 只在 ISR 置位 m_gd32RequestPending 时读 EVENT。
@@ -309,7 +310,7 @@ void SpiMaster::workerLoop()
} }
Job *job = nullptr; Job *job = nullptr;
if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(20)) == pdTRUE && job != nullptr) { if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(1)) == pdTRUE && job != nullptr) {
executeJob(*job); executeJob(*job);
if (job->done) { if (job->done) {
xSemaphoreGive(job->done); xSemaphoreGive(job->done);
@@ -317,9 +318,12 @@ void SpiMaster::workerLoop()
continue; continue;
} }
/* 无 job:等 GPIO / 提交唤醒 */ /* 无 job:等 GPIO / 提交唤醒。
* 超时只是无事件时的兜底回扫周期;ISR 的 xTaskNotifyFromISR 会立即唤醒
* 本任务,响应延迟不由这里决定。过短会令 spi_worker(优先级6) 忙转压住
* httpd(优先级5),导致 /system/status、/logs 响应不来 → 网页收不到消息。 */
uint32_t bits = 0U; uint32_t bits = 0U;
(void)xTaskNotifyWait(0U, 0xFFFFFFFFU, &bits, pdMS_TO_TICKS(50)); (void)xTaskNotifyWait(0U, 0xFFFFFFFFU, &bits, pdMS_TO_TICKS(25));
if (m_suppressRingService) { if (m_suppressRingService) {
m_gd32RequestPending = false; m_gd32RequestPending = false;
} else if ((bits & 0x01U) || m_gd32RequestPending) { } else if ((bits & 0x01U) || m_gd32RequestPending) {
@@ -386,7 +390,7 @@ esp_err_t SpiMaster::waitSpiReady(uint32_t timeout_ms)
if (readReadyLevelRaw() == 1) { return ESP_OK; } if (readReadyLevelRaw() == 1) { return ESP_OK; }
/* PB6 低:可能有未读响应。短等升高;BOOT 修好交付清 pending 后应很快。 /* PB6 低:可能有未读响应。短等升高;BOOT 修好交付清 pending 后应很快。
* L3:超时返回 ESP_ERR_TIMEOUT,禁止调用方假装“已就绪”后盲发。 */ * 超时返回 ESP_ERR_TIMEOUT,禁止调用方假装“已就绪”后盲发。 */
const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
while (xTaskGetTickCount() < deadline) { while (xTaskGetTickCount() < deadline) {
if (readReadyLevelRaw() == 1) { return ESP_OK; } if (readReadyLevelRaw() == 1) { return ESP_OK; }
@@ -800,7 +804,7 @@ esp_err_t SpiMaster::init()
m_stats = SpiLinkStats{}; m_stats = SpiLinkStats{};
m_eventHead = m_eventTail = m_eventCount = 0; m_eventHead = m_eventTail = m_eventCount = 0;
/* L4:job 队列 + 事件信号量 + SPI worker。 /* job 队列 + 事件信号量 + SPI worker。
* worker 启动后可能立刻抢 m_mutex;init 持锁时 worker 会阻塞在 LockGuard, * worker 启动后可能立刻抢 m_mutex;init 持锁时 worker 会阻塞在 LockGuard,
* init 返回释放锁后 worker 继续 —— 不会死锁。 */ * init 返回释放锁后 worker 继续 —— 不会死锁。 */
if (m_jobQueue == nullptr) { if (m_jobQueue == nullptr) {
@@ -818,13 +822,13 @@ esp_err_t SpiMaster::init()
} }
} }
if (m_workerTask == nullptr) { if (m_workerTask == nullptr) {
BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", 6144, this, 6, &m_workerTask); BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", TASK_STACK_SPI_WORKER, this, 6, &m_workerTask);
if (ok != pdPASS || m_workerTask == nullptr) { if (ok != pdPASS || m_workerTask == nullptr) {
DEBUG_ERR("SPI", "SPI worker task create failed"); DEBUG_ERR("SPI", "SPI worker task create failed");
m_workerTask = nullptr; m_workerTask = nullptr;
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
DEBUG_INFO("SPI", "SPI worker task created (L4)"); DEBUG_INFO("SPI", "SPI worker task created");
} }
m_spiTaskHandle = m_workerTask; m_spiTaskHandle = m_workerTask;
@@ -855,14 +859,15 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
return ESP_ERR_INVALID_SIZE; return ESP_ERR_INVALID_SIZE;
} }
/* L5:remoteCredit==0 时禁止发新 REQUEST;等 CREDIT_UPDATE 或 poll 读到新 credit。 /* CREDIT 背压(包间节奏):
* remoteCredit==0 禁止发新 REQUEST;等 CREDIT_UPDATE / 帧头 credit。
* HELLO / 链路控制不受 credit 限制(session 建立前 credit 可能为 0)。 */ * HELLO / 链路控制不受 credit 限制(session 建立前 credit 可能为 0)。 */
if (type != V2_TYPE_LINK_HELLO && m_remoteCredit == 0U) { if (type != V2_TYPE_LINK_HELLO && m_remoteCredit == 0U) {
const TickType_t cred_deadline = xTaskGetTickCount() + pdMS_TO_TICKS( const TickType_t cred_deadline = xTaskGetTickCount() + pdMS_TO_TICKS(
timeout_ms > 0U ? timeout_ms : 500U); timeout_ms > 0U ? timeout_ms : 500U);
while (m_remoteCredit == 0U && xTaskGetTickCount() < cred_deadline) { while (m_remoteCredit == 0U && xTaskGetTickCount() < cred_deadline) {
/* 有 DATA_READY 时变长读一帧(可能是 CREDIT_UPDATE 或陈旧响应) */ /* 有 DATA_READY 时变长读一帧(可能是 CREDIT_UPDATE 或陈旧响应) */
if (waitSpiDataReady(20U) == ESP_OK) { if (waitSpiDataReady(5U) == ESP_OK) {
uint8_t drain[SPI_OTA_FRAME_MAX]; uint8_t drain[SPI_OTA_FRAME_MAX];
size_t drain_len = 0U; size_t drain_len = 0U;
if (readV2FrameVarlen(drain, sizeof(drain), &drain_len, 100U) == ESP_OK) { if (readV2FrameVarlen(drain, sizeof(drain), &drain_len, 100U) == ESP_OK) {
@@ -1195,7 +1200,7 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
return send_ret; return send_ret;
} }
/* 等 GD32 完成 onCsRelease;poll 内仅在 DATA_READY 低时变长读(L3 不盲 CS)。 */ /* 等 GD32 完成 onCsRelease;poll 内仅在 DATA_READY 低时变长读(不盲 CS)。 */
{ {
const TickType_t gap = (pdMS_TO_TICKS(1) > 0) ? pdMS_TO_TICKS(1) : 1; const TickType_t gap = (pdMS_TO_TICKS(1) > 0) ? pdMS_TO_TICKS(1) : 1;
vTaskDelay(gap); vTaskDelay(gap);
@@ -1334,13 +1339,13 @@ esp_err_t SpiMaster::appRequestBootloader()
/* 先变长读最多 2 次,吞掉残留 0xC1(仅 DATA_READY 低时读,禁止固定长度盲读)。 */ /* 先变长读最多 2 次,吞掉残留 0xC1(仅 DATA_READY 低时读,禁止固定长度盲读)。 */
for (uint32_t d = 0U; d < 2U; ++d) { for (uint32_t d = 0U; d < 2U; ++d) {
if (waitSpiDataReady(10U) != ESP_OK) { if (waitSpiDataReady(5U) != ESP_OK) {
break; break;
} }
uint8_t rx_raw[SPI_OTA_FRAME_MAX]; uint8_t rx_raw[SPI_OTA_FRAME_MAX];
size_t rx_len = 0U; size_t rx_len = 0U;
m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE);
(void)readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, 50U); (void)readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, 20U);
m_linkState.exitBusy(); m_linkState.exitBusy();
vTaskDelay((pdMS_TO_TICKS(5) > 0) ? pdMS_TO_TICKS(5) : 1); vTaskDelay((pdMS_TO_TICKS(5) > 0) ? pdMS_TO_TICKS(5) : 1);
} }
@@ -1354,7 +1359,7 @@ esp_err_t SpiMaster::appRequestBootloader()
for (uint32_t retry = 0U; retry < kMaxSends; ++retry) { for (uint32_t retry = 0U; retry < kMaxSends; ++retry) {
if (retry > 0U) { if (retry > 0U) {
vTaskDelay(pdMS_TO_TICKS(50)); vTaskDelay(pdMS_TO_TICKS(2));
/* 序号/会话错乱时重同步一次再发 0xB0 */ /* 序号/会话错乱时重同步一次再发 0xB0 */
if (retry == 1U) { if (retry == 1U) {
m_v2Active = false; m_v2Active = false;
@@ -1412,11 +1417,11 @@ esp_err_t SpiMaster::appRequestBootloader()
while (xTaskGetTickCount() <= deadline) { while (xTaskGetTickCount() <= deadline) {
++poll_i; ++poll_i;
m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE);
(void)waitSpiDataReady(poll_i <= 2U ? 40U : 10U); (void)waitSpiDataReady(poll_i <= 2U ? 20U : 5U);
uint8_t rx_raw[SPI_OTA_FRAME_MAX]; uint8_t rx_raw[SPI_OTA_FRAME_MAX];
size_t rx_len = 0U; size_t rx_len = 0U;
ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, 80U); ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, 40U);
m_linkState.exitBusy(); m_linkState.exitBusy();
if (ret != ESP_OK) { if (ret != ESP_OK) {
vTaskDelay(gap); vTaskDelay(gap);
@@ -1470,7 +1475,7 @@ esp_err_t SpiMaster::appRequestBootloader()
if (command_sent) { if (command_sent) {
/* 静默:APP 擦配置页(可达数十 ms) + 软复位 + BOOT 起机。 /* 静默:APP 擦配置页(可达数十 ms) + 软复位 + BOOT 起机。
* 此间禁止 HELLO,否则 role 会一直像卡在 APP。 */ * 此间禁止 HELLO,否则 role 会一直像卡在 APP。 */
const uint32_t kQuietMs = got_ack ? 900U : 500U; const uint32_t kQuietMs = got_ack ? 100U : 30U;
DEBUG_INFO("SPI", "ENTER_BOOT 已上总线 (ack=%s),静默 %lums 等待 APP 复位...", DEBUG_INFO("SPI", "ENTER_BOOT 已上总线 (ack=%s),静默 %lums 等待 APP 复位...",
got_ack ? "yes" : "no", (unsigned long)kQuietMs); got_ack ? "yes" : "no", (unsigned long)kQuietMs);
vTaskDelay(pdMS_TO_TICKS(kQuietMs)); vTaskDelay(pdMS_TO_TICKS(kQuietMs));
@@ -1551,7 +1556,7 @@ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms)
} }
/** /**
* @brief 向 GD32 APP 发送存活探测(L3:帧长发送 + DATA_READY 变长读)。 * @brief 向 GD32 APP 发送存活探测(帧长发送 + DATA_READY 变长读)。
* *
* 两步法(持锁):otaTransferUnlocked(expect_stale) + otaPollResponseUnlocked。 * 两步法(持锁):otaTransferUnlocked(expect_stale) + otaPollResponseUnlocked。
* GD32 App PING 响应 type=kRspAck(0x80), * GD32 App PING 响应 type=kRspAck(0x80),
@@ -1789,5 +1794,3 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint)
return ESP_ERR_INVALID_RESPONSE; return ESP_ERR_INVALID_RESPONSE;
} }
// CANFD 接口已迁移至 transport/canfd_bridge.cpp(M4 解耦)
+5 -9
View File
@@ -41,8 +41,6 @@
#define SPI_OTA_RSP_WAIT (0x81) #define SPI_OTA_RSP_WAIT (0x81)
#define SPI_OTA_RSP_ERROR (0x82) #define SPI_OTA_RSP_ERROR (0x82)
// CANFD 类型已迁移至 transport/canfd_bridge.h(M4 解耦)
// GD32 版本信息(从 APP PING 响应解析) // GD32 版本信息(从 APP PING 响应解析)
typedef struct { typedef struct {
uint16_t app_major; uint16_t app_major;
@@ -121,14 +119,14 @@ public:
void confirmTxSeqAccepted(); void confirmTxSeqAccepted();
/** /**
* @brief L4 统一请求入口:经 SPI worker 串行执行(send+poll)。 * @brief 统一请求入口:经 SPI worker 串行执行(send+poll)。
*/ */
esp_err_t transact(uint8_t type, const uint8_t *payload, size_t payload_len, esp_err_t transact(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *resp_type, uint8_t *resp_payload, uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms); size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
/** /**
* @brief L4 取出 GD32 EVENT 帧(worker 已读入事件队列)。 * @brief 取出 GD32 EVENT 帧(worker 已读入事件队列)。
* @param timeout_ms 等待超时(ms)。0 = 非阻塞(原语义)。 * @param timeout_ms 等待超时(ms)。0 = 非阻塞(原语义)。
* @return true 取到事件;false 队列空且超时未到达。 * @return true 取到事件;false 队列空且超时未到达。
*/ */
@@ -196,8 +194,6 @@ public:
/** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */ /** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */
esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr); esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr);
// ── CANFD 接口已迁移至 transport/canfd_bridge.h(M4 解耦)──
private: private:
/** /**
* @brief 私有默认构造函数。 * @brief 私有默认构造函数。
@@ -271,7 +267,7 @@ private:
/** 事件计数信号量(worker push / takeEvent pop)。 */ /** 事件计数信号量(worker push / takeEvent pop)。 */
SemaphoreHandle_t m_eventSem = NULL; SemaphoreHandle_t m_eventSem = NULL;
// ── L4 多槽事件队列(worker 写入,业务 takeEvent 读出)── // ── 多槽事件队列(worker 写入,业务 takeEvent 读出)──
static constexpr uint8_t kEventSlots = 4U; static constexpr uint8_t kEventSlots = 4U;
struct EventSlot { struct EventSlot {
uint8_t type; uint8_t type;
@@ -283,7 +279,7 @@ private:
uint8_t m_eventTail = 0; uint8_t m_eventTail = 0;
uint8_t m_eventCount = 0; uint8_t m_eventCount = 0;
// ── L4 作业提交(调用方栈上 Job + StaticSemaphore,worker 执行)── // ── 作业提交(调用方栈上 Job + StaticSemaphore,worker 执行)──
enum class JobKind : uint8_t { enum class JobKind : uint8_t {
Transact = 0, Transact = 0,
Poll, Poll,
@@ -390,7 +386,7 @@ private:
uint32_t timeout_ms, uint32_t trans_flags); uint32_t timeout_ms, uint32_t trans_flags);
/** /**
* @brief L3 变长读:同 CS 先读 16B Header,再按 LEN 读 payload+CRC。 * @brief 变长读:同 CS 先读 16B Header,再按 LEN 读 payload+CRC。
* 调用者须已持锁;仅在 DATA_READY 已低(有数据)时调用,禁止盲读。 * 调用者须已持锁;仅在 DATA_READY 已低(有数据)时调用,禁止盲读。
* @param out_frame 完整帧输出(含头+payload+CRC) * @param out_frame 完整帧输出(含头+payload+CRC)
* @param out_max 输出缓冲容量 * @param out_max 输出缓冲容量
+18 -2
View File
@@ -4,11 +4,19 @@ CONFIG_ESP_WIFI_TX_BA_WIN=6
CONFIG_DCU_OTA_POLL_BACKOFF_MS=5 CONFIG_DCU_OTA_POLL_BACKOFF_MS=5
CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000 CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000
# Flash mode DIO(锁定):本板 PCB 实测 QIO 不可用。
# 2026-07 尝试过 CONFIG_ESPTOOLPY_FLASHMODE_QIO=y 加速 OTA,结果:
# 开机 boot 日志 SPI Mode : QIO 看似正常,但 WiFi OTA 写 flash 后
# 从分区读回计算 SHA-256 就与上传前 SHA 不一致(每次都不过),
# 属于“能启动但数据不可靠”。推断:当前 flash 芯片/PCB 走线/WP+HOLD 不支持 QIO。
# 不要再开 QIO,除非确认 flash 型号与硬件连接均兼容。
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
# DCU SPI clock -- 4 MHz(GD32 SPI0 RX DMA 武装后可安全提速;8/12MHz 须零错误验证后再开) # DCU SPI clock -- 4 MHz(GD32 SPI0 RX DMA 武装后可安全提速;8/12MHz 须零错误验证后再开)
CONFIG_DCU_SPI_CLOCK_HZ=4000000 CONFIG_DCU_SPI_CLOCK_HZ=4000000
# DCU OTA packet interval -- 给 GD32 主循环 AES/Flash drain 留时间(原 1ms 易在中后期卡死) # GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压
CONFIG_DCU_OTA_PACKET_INTERVAL_MS=3 # (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发)
# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 + UDP debug log + 余量 # LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 + UDP debug log + 余量
# 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE) # 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE)
@@ -35,6 +43,14 @@ CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y
CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y
# ── BLE / NimBLE 主机栈(set-target 会重生 sdkconfig,必须写在 defaults 里)──
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_CONTROLLER_ENABLED=y
# CONFIG_BT_BLUEDROID_ENABLED is not set
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
# ── NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service)── # ── NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service)──
# 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB # 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB
CONFIG_BT_NIMBLE_PROX_SERVICE=n CONFIG_BT_NIMBLE_PROX_SERVICE=n
+3 -3
View File
@@ -431,8 +431,8 @@
<span id="toastMsg">操作成功</span> <span id="toastMsg">操作成功</span>
</div> </div>
<script type="module" src="js/app.js?v=20260715a"></script> <script type="module" src="js/app.js?v=20260716c"></script>
<script type="module" src="js/ota/console-mode.js?v=20260715a"></script> <script type="module" src="js/ota/console-mode.js?v=20260716c"></script>
<script type="module" src="js/ble_ota.js?v=20260715a"></script> <script type="module" src="js/ble_ota.js?v=20260716c"></script>
</body> </body>
</html> </html>
+11 -4
View File
@@ -12,11 +12,11 @@ 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 testConnection, refreshSystemStatusNow, diagnoseNetwork
} from './common/connection.js'; } from './common/connection.js';
import { import {
scheduleStatusPoll, startPolling, pausePollingForUpload, scheduleStatusPoll, startPolling, pausePollingForUpload,
resumePollingAfterUpload, pollStatus, pollLogs resumePollingAfterUpload, pollStatus, pollLogs, setStatusPollIntervalMs
} from './common/polling.js'; } from './common/polling.js';
import { import {
@@ -40,9 +40,12 @@ import {
} from './ota/file-handle.js'; } from './ota/file-handle.js';
import { sendSpiFrame, clearSpiFields, updateSpiHint } from './ota/spi.js'; import { sendSpiFrame, clearSpiFields, updateSpiHint } from './ota/spi.js';
import { startUpload, verifyEsp32Firmware, restartEsp32 } from './ota/upload-esp32.js'; import { startUpload, verifyEsp32Firmware, restartEsp32 } from './ota/upload-esp32.js';
import { triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch, startRelayProgressWatch } from './ota/upload-gd32.js'; import {
triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch,
startRelayProgressWatch, emitRelayProgress, ensureRelayProgressComplete
} from './ota/upload-gd32.js';
// ==================== 工具函数(部分未拆分的辅助函数) ==================== // ==================== 工具函数 ====================
function formatPercent(value) { function formatPercent(value) {
if (!Number.isFinite(value)) return '--'; if (!Number.isFinite(value)) return '--';
return value.toFixed(1).replace(/\.0$/, '') + '%'; return value.toFixed(1).replace(/\.0$/, '') + '%';
@@ -178,6 +181,8 @@ window.triggerGd32Relay = triggerGd32Relay;
window.markGd32OtaUiComplete = markGd32OtaUiComplete; window.markGd32OtaUiComplete = markGd32OtaUiComplete;
window.stopRelayProgressWatch = stopRelayProgressWatch; window.stopRelayProgressWatch = stopRelayProgressWatch;
window.startRelayProgressWatch = startRelayProgressWatch; window.startRelayProgressWatch = startRelayProgressWatch;
window.emitRelayProgress = emitRelayProgress;
window.ensureRelayProgressComplete = ensureRelayProgressComplete;
// ==================== 挂载 common/* 函数到 window(供 polling.js 等通过 window.* 调用) ==================== // ==================== 挂载 common/* 函数到 window(供 polling.js 等通过 window.* 调用) ====================
window.formatBytes = formatBytes; window.formatBytes = formatBytes;
@@ -196,11 +201,13 @@ window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi;
window.testConnection = testConnection; window.testConnection = testConnection;
window.refreshSystemStatusNow = refreshSystemStatusNow; window.refreshSystemStatusNow = refreshSystemStatusNow;
window.scheduleStatusPoll = scheduleStatusPoll; window.scheduleStatusPoll = scheduleStatusPoll;
window.setStatusPollIntervalMs = setStatusPollIntervalMs;
window.startPolling = startPolling; window.startPolling = startPolling;
window.pausePollingForUpload = pausePollingForUpload; window.pausePollingForUpload = pausePollingForUpload;
window.resumePollingAfterUpload = resumePollingAfterUpload; window.resumePollingAfterUpload = resumePollingAfterUpload;
window.pollStatus = pollStatus; window.pollStatus = pollStatus;
window.pollLogs = pollLogs; window.pollLogs = pollLogs;
window.diagnoseNetwork = diagnoseNetwork;
// ==================== 挂载本入口的辅助函数到 window ==================== // ==================== 挂载本入口的辅助函数到 window ====================
window.getEspIp = getEspIp; window.getEspIp = getEspIp;
+1 -1
View File
@@ -10,7 +10,7 @@ export const UUIDS = {
version: 0x0004, version: 0x0004,
}; };
export const UI_BUILD = '2026-07-12-ble-reorder-v6'; export const UI_BUILD = '2026-07-16-ble-begin-ready';
export const BLE_OTA_NOTIFY_ACK = 0xA1; export const BLE_OTA_NOTIFY_ACK = 0xA1;
export const BLE_OTA_NOTIFY_FINISH = 0xB1; export const BLE_OTA_NOTIFY_FINISH = 0xB1;
export const BLE_OTA_NOTIFY_SHA256 = 0xC1; export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
+40 -16
View File
@@ -189,6 +189,9 @@ export async function uploadFirmware() {
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`); bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
const MAX_WINDOW_RETRIES = 3; const MAX_WINDOW_RETRIES = 3;
// 整分区预擦可长达数十秒;首包 ACK 超时必须覆盖 begin/erase
const BEGIN_READY_TIMEOUT_MS = 120000;
const WINDOW_ACK_TIMEOUT_MS = 8000;
for (let windowStart = 0; windowStart < packetTotal; windowStart += BLE_OTA_ACK_WINDOW) { for (let windowStart = 0; windowStart < packetTotal; windowStart += BLE_OTA_ACK_WINDOW) {
if (state.abortRequested) throw new Error('用户中止'); if (state.abortRequested) throw new Error('用户中止');
@@ -197,37 +200,50 @@ export async function uploadFirmware() {
// Windows Web Bluetooth 同一时刻只允许一个 GATT 操作。逐包等待仅保证 // Windows Web Bluetooth 同一时刻只允许一个 GATT 操作。逐包等待仅保证
// 提交顺序;实际吞吐由设备请求的高速 BLE 连接参数决定。 // 提交顺序;实际吞吐由设备请求的高速 BLE 连接参数决定。
// ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动 // ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动
//
// ★ 首窗特殊流控:seq=0 触发设备 begin+整分区预擦(worker 线程,可阻塞很久)。
// 旧逻辑 sleep(200) 后立刻 withoutResponse 连发 → 队列满丢包 → 整窗重传,体感极慢。
// 现改为:seq=0 送达后等设备「首包落盘 ACK」再发 1..endSeq。
for (let seq = windowStart; seq <= endSeq; seq++) { for (let seq = windowStart; seq <= endSeq; seq++) {
// ★ 中止检查放在循环内:abortTask 可能在本窗口发送过程中触发
// (windowStart 处的检查无法覆盖窗口中途的 abort)
if (state.abortRequested) throw new Error('用户中止'); if (state.abortRequested) throw new Error('用户中止');
const start = seq * payloadSize; const start = seq * payloadSize;
const end = Math.min(start + payloadSize, totalSize); const end = Math.min(start + payloadSize, totalSize);
const payload = fileBytes.slice(start, end); const payload = fileBytes.slice(start, end);
const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd); const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd);
// ★ seq=0 必须用 writeValueWithResponse(带响应):
// seq=0 触发设备端 esp_ota_begin()(flash 擦除 + OTA 会话创建),
// 若用 writeValueWithoutResponse 在 BLE 链路层丢失,
// 设备永远不会启动 OTA 会话,后续所有包都被丢弃。
if (seq === 0) { if (seq === 0) {
try { try {
await state.dataInChar.writeValueWithResponse(packet); await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达', 'I'); bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'I');
setBleStageText('设备预擦 Flash 中,请等待…');
} catch (e) { } catch (e) {
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E'); bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message); throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message);
} }
// seq=0 写入后等待设备完成 esp_ota_begin()(flash 擦除较耗时) state.ackGeneration++;
await sleep(200); const firstBytes = Math.min(payloadSize, totalSize);
} else { try {
await state.dataInChar.writeValueWithoutResponse(packet); await waitForAck(cfg.packetCmd, 0, BEGIN_READY_TIMEOUT_MS, {
expectedTotalSize: totalSize,
payloadSize: payloadSize
});
} catch (e) {
throw new Error('等待设备 begin/预擦完成超时: ' + e.message);
} }
if (state.lastAckBytes < firstBytes) {
throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes);
}
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'I');
setBleStageText('正在写入 ' + packetTotal + ' 包数据...');
continue;
}
await state.dataInChar.writeValueWithoutResponse(packet);
} }
state.ackGeneration++; state.ackGeneration++;
try { try {
await waitForAck(cfg.packetCmd, endSeq, 5000, { expectedTotalSize: totalSize, payloadSize }); await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize });
} catch (e) { } catch (e) {
bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W'); bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W');
} }
@@ -270,15 +286,23 @@ export async function uploadFirmware() {
const end = Math.min(start + payloadSize, totalSize); const end = Math.min(start + payloadSize, totalSize);
const payload = fileBytes.slice(start, end); const payload = fileBytes.slice(start, end);
const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd); const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd);
// ★ 重试路径中 seq=0 也必须用 writeValueWithResponse // ★ 重试路径中 seq=0 也必须用 writeValueWithResponse,并等 begin 就绪 ACK
if (seq === 0) { if (seq === 0) {
try { try {
await state.dataInChar.writeValueWithResponse(packet); await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', '重试 seq=0 已用 writeValueWithResponse 确认', 'I'); bleLog('OTA', '重试 seq=0 已送达,等待 begin 就绪 ACK…', 'I');
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, 0, BEGIN_READY_TIMEOUT_MS, {
expectedTotalSize: totalSize,
payloadSize: payloadSize
});
} catch (e2) {
bleLog('OTA', '重试 seq=0 等待 begin ACK 失败: ' + e2.message, 'W');
}
} catch (e) { } catch (e) {
bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E'); bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E');
} }
await sleep(200);
} else { } else {
await state.dataInChar.writeValueWithoutResponse(packet); await state.dataInChar.writeValueWithoutResponse(packet);
} }
@@ -286,7 +310,7 @@ export async function uploadFirmware() {
state.ackGeneration++; state.ackGeneration++;
try { try {
await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize }); await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize });
} catch (e) { } catch (e) {
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W'); bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
} }
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* can.js - CAN 工具页面入口(ESM 组装层) * can.js - CAN 工具页面入口(ESM 组装层)
* *
* 本文件对应原 tools/can.js 中未拆分到子模块的编排逻辑: * CAN 页面的编排逻辑:
* - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象) * - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象)
* - setConnectionState:CAN 页面专用连接状态更新(canConnPill 等元素) * - setConnectionState:CAN 页面专用连接状态更新(canConnPill 等元素)
* - testConnection:CAN 页面专用连接检测(GET /system/status,操作 el.connectionNote 等) * - testConnection:CAN 页面专用连接检测(GET /system/status,操作 el.connectionNote 等)
+1 -2
View File
@@ -1,8 +1,7 @@
/** /**
* dbc.js - DBC 解析器 + 共享状态/元素缓存/基础工具 * dbc.js - DBC 解析器 + 共享状态/元素缓存/基础工具
* *
* 从 tools/can.js 拆分。对应原 can.js 约 4~21 行(state/el)、78~104 行(基础工具)、 * 包含:共享 state/el、基础工具、DBC 解析、cssEscape、readDbcText。
* 178~335 行(DBC 解析)、830~833 行(cssEscape)、1335~1352 行(readDbcText)。
* *
* 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出, * 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出,
* 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。 * 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。
+2 -11
View File
@@ -1,17 +1,8 @@
/** /**
* feedback.js - 1Hz 反馈轮询(预留模块) * feedback.js - 1Hz 反馈轮询(预留模块)
* *
* ⚠️ 说明:原 tools/can.js(1526 行)中不存在 1Hz 反馈轮询 / ID 快照表渲染相关代码。 * 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。
* 这些功能实际位于 tools/can_monitor.js(CAN 监控页面),操作的是 can_monitor.html * 1Hz 轮询 /can/feedback 或 ID 快照功能可在本模块按需实现。
* 的 DOM 元素(fb0State / fb0Rx / idsnapBody 等),与 can.html 页面的元素体系不同,
* 因此无法直接迁移。
*
* can.js 中与"连接状态反馈"相关的代码为 setConnectionState / testConnection
* (一次性 GET /system/status),属于入口文件(js/can.js)的 init/cacheElements/bindEvents
* 编排范畴,不在本子模块内。
*
* 本模块按任务要求 import common/connection.js,供入口文件在需要时引入连接管理能力。
* 若后续 can 页面需要增加 1Hz 轮询 /can/feedback 或 ID 快照功能,可在此模块实现。
*/ */
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js'; import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js';
+1 -2
View File
@@ -1,8 +1,7 @@
/** /**
* messages.js - 报文列表管理(渲染、过滤、节点过滤、批量 UI) * messages.js - 报文列表管理(渲染、过滤、节点过滤、批量 UI)
* *
* 从 tools/can.js 拆分。对应原 can.js 约 337~686 行(DBC 文件信息、报文过滤、 * 包含:DBC 文件信息、报文过滤、节点过滤面板、报文列表渲染、报文选择/展开、批量 UI。
* 节点过滤面板、报文列表渲染、报文选择/展开、批量 UI)。
* *
* 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、 * 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、
* common/utils.js(escapeHtml/formatBytes)、 * common/utils.js(escapeHtml/formatBytes)、
+4 -5
View File
@@ -1,17 +1,16 @@
/** /**
* send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史) * send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史)
* *
* 从 tools/can.js 拆分。对应原 can.js 约 1030~1333 行(sendCanRequest、响应框、 * 包含:sendCanRequest、响应框、历史、批量/选中/手动发送、
* 历史、批量/选中/手动发送、手动帧校验/重置/载入预览)及 1491~1523 行 * 手动帧校验/重置/载入预览、CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText。
* (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)、
* messages.js(updateBatchUi)、signals.js(ensureMessageDefaults/getSignalValueMap/ * messages.js(updateBatchUi)、signals.js(ensureMessageDefaults/getSignalValueMap/
* getSelectedMessage/updateEncodedPreview/encodeMessageData)。 * getSelectedMessage/updateEncodedPreview/encodeMessageData)。
* *
* 注意:formatTime 保留 can.js 本地实现(HH:MM:SS,不含毫秒),与 common/utils.js * 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出与原版完全一致。 * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。
*/ */
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js'; import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js';
import { escapeHtml as sanitizeHtml } from '../common/utils.js'; import { escapeHtml as sanitizeHtml } from '../common/utils.js';
+2 -2
View File
@@ -1,8 +1,8 @@
/** /**
* signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical) * signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical)
* *
* 从 tools/can.js 拆分。对应原 can.js 约 629~1028 行(信号行渲染、信号值存取、 * 包含:信号行渲染、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作、
* 编码写入、浮点/整数/Motorola/Intel 位操作)及 761 行的 renderSelectedMessage shim。 * renderSelectedMessage shim。
* *
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。 * 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
*/ */
+56 -16
View File
@@ -1,17 +1,16 @@
/** /**
* connection.js - 连接管理 + 系统状态显示 * connection.js - 连接管理 + 系统状态显示
* *
* 从 app.js 的「连接管理」段 + testConnection / refreshSystemStatusNow 提取。
* 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。 * 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。
* *
* 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载): * 共享状态(通过 window.* 引用):
* window.latestSystemStatus — 最近一次 /system/status 响应 * window.latestSystemStatus — 最近一次 /system/status 响应
* window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取) * window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取)
* window.lastRelayState — 上次成功的 relay_state(polling.js 读取) * window.lastRelayState — 上次成功的 relay_state(polling.js 读取)
* window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取) * window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取)
* *
* 未拆分的 app.js 函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.getBaseUrl / window.updateSystemStatusUi / window.diagnoseNetwork / window.formatPercent * window.getBaseUrl / window.updateSystemStatusUi / window.formatPercent
*/ */
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';
@@ -51,15 +50,18 @@ export function updateHealthUi(healthCode, freeHeap) {
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err'); mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
} }
} }
/**
* 每次 pollStatus 成功后刷新堆/栈/CPU(固定周期约 1000ms,中继期 400ms;
* sample_ms 由设备两次采样墙钟差给出;WiFi 上传 ESP32 期间 pause 不刷)。
*/
export function updateHeapAndTaskStatusUi(data) { export function updateHeapAndTaskStatusUi(data) {
if (!data) return; if (!data) return;
var ota = data.ota || {}; var ota = data.ota || {};
var wifi = data.wifi || {};
var targetHost = window.getBaseUrl().replace(/^http:\/\//, '');
var setText = function (id, value) { var setText = function (id, value) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.textContent = value; if (el) el.textContent = value;
}; };
// ---- 堆概况:已用 / 总计 + 占用条 ---- // ---- 堆概况:已用 / 总计 + 占用条 ----
function setFill(id, pct) { function setFill(id, pct) {
var el = document.getElementById(id); var el = document.getElementById(id);
@@ -85,10 +87,10 @@ export function updateHeapAndTaskStatusUi(data) {
el.style.width = safePct + '%'; el.style.width = safePct + '%';
el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''); el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : '');
} }
function updateCpuUsageUi(cpu) { function updateCpuUsageUi(cpuData) {
cpu = cpu || {}; cpuData = cpuData || {};
var taskList = document.getElementById('cpuTaskList'); var taskList = document.getElementById('cpuTaskList');
if (!cpu.available) { if (!cpuData.available) {
setText('cpuUsageText', '未启用 FreeRTOS 统计'); setText('cpuUsageText', '未启用 FreeRTOS 统计');
setText('cpuUsagePct', '--'); setText('cpuUsagePct', '--');
setText('cpuIdlePct', '--'); setText('cpuIdlePct', '--');
@@ -98,7 +100,7 @@ export function updateHeapAndTaskStatusUi(data) {
if (taskList) taskList.innerHTML = '<div class="status-empty">固件未启用 CPU runtime stats</div>'; if (taskList) taskList.innerHTML = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
return; return;
} }
if (!cpu.valid) { if (!cpuData.valid) {
setText('cpuUsageText', '等待下一次采样...'); setText('cpuUsageText', '等待下一次采样...');
setText('cpuUsagePct', '--'); setText('cpuUsagePct', '--');
setText('cpuIdlePct', '--'); setText('cpuIdlePct', '--');
@@ -108,10 +110,10 @@ export function updateHeapAndTaskStatusUi(data) {
if (taskList) taskList.innerHTML = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>'; if (taskList) taskList.innerHTML = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>';
return; return;
} }
var usagePct = Number(cpu.usage_pct) || 0; var usagePct = Number(cpuData.usage_pct) || 0;
var idlePct = Number(cpu.idle_pct) || 0; var idlePct = Number(cpuData.idle_pct) || 0;
var sampleMs = Number(cpu.sample_ms) || 0; var sampleMs = Number(cpuData.sample_ms) || 0;
var tasks = Array.isArray(cpu.tasks) ? cpu.tasks.slice() : []; var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : [];
tasks.sort(function (a, b) { tasks.sort(function (a, b) {
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0); return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
}); });
@@ -159,7 +161,6 @@ export function updateHeapAndTaskStatusUi(data) {
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) : '--');
// 缓存最近一次成功的 sys_state_code / relay_state,供 pollStatus 失败时判断设备是否在忙
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 = '--'; var storedText = '--';
@@ -205,6 +206,45 @@ export function updateHeapAndTaskStatusUi(data) {
statusJson.textContent = JSON.stringify(data, null, 2); statusJson.textContent = JSON.stringify(data, null, 2);
} }
} }
/** 从设备 IP 推导网关 IP(取前三段 + .1) */
function getGatewayIp() {
var parts = (window.getEspIp ? window.getEspIp() : '192.168.31.127').split('.');
if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1';
return '192.168.31.1';
}
/**
* 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通。
* 放在 connection.js,避免与 polling.js 形成循环 import。
*/
export async function diagnoseNetwork() {
var result = [];
if (navigator.onLine === false) {
result.push('浏览器无网络连接');
return result.join(';');
}
var gwIp = getGatewayIp();
var gwOk = false;
try {
await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) });
gwOk = true;
} catch (_) {}
if (!gwOk) {
try {
await fetch('http://' + gwIp + '/favicon.ico', { mode: 'no-cors', signal: AbortSignal.timeout(1500) });
gwOk = true;
} catch (_) {}
}
if (!gwOk) {
result.push('网关 ' + gwIp + ' 不可达(本地网络不通)');
} else {
result.push('网关 ' + gwIp + ' 可达');
var espIp = window.getEspIp ? window.getEspIp() : '设备';
result.push('设备 ' + espIp + ' 不响应(可能断电、重启中或 WiFi 断开)');
}
return result.join(';');
}
/** @description 测试与 ESP32 的连接(GET /status)。 */ /** @description 测试与 ESP32 的连接(GET /status)。 */
export async function testConnection() { export async function testConnection() {
// 立即发一次请求检测 // 立即发一次请求检测
@@ -232,7 +272,7 @@ export async function testConnection() {
} catch (e) { } catch (e) {
addLog('E', '连接', '连接失败: ' + e.message); addLog('E', '连接', '连接失败: ' + e.message);
// 连接失败时自动诊断 // 连接失败时自动诊断
var diag = await window.diagnoseNetwork(); var diag = await diagnoseNetwork();
addLog('W', '诊断', diag); addLog('W', '诊断', diag);
showToast('无法连接: ' + diag, 'error'); showToast('无法连接: ' + diag, 'error');
} finally { } finally {
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* log.js - 日志系统(带去重 + 自动滚动) * log.js - 日志系统(带去重 + 自动滚动)
* *
* 从 app.js 提取的日志环形缓冲、去重、过滤、自动滚动逻辑。 * 日志环形缓冲、去重、过滤、自动滚动逻辑。
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。 * 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
*/ */
import { formatTime } from './utils.js'; import { formatTime } from './utils.js';
+41 -57
View File
@@ -1,14 +1,13 @@
/** /**
* polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取 * polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取
* *
* 从 app.js 的「轮询状态」「Page Visibility 补偿」「ESP32 实时日志拉取 (增量)」段提取。
* 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。 * 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。
* *
* 模块级状态(仅本模块使用,不导出): * 模块级状态(仅本模块使用,不导出):
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs * statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs
* lastDiagTime / diagResult / uploadPollChip / lastLogTimestamp / pageVisible * uploadPollChip / lastLogTimestamp / pageVisible
* *
* 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载): * 共享状态(通过 window.* 引用):
* window.pollingPausedForUpload — 上传期间暂停轮询标志 * window.pollingPausedForUpload — 上传期间暂停轮询标志
* window.consecutivePollFailures — 连续轮询失败计数(connection.js 也写入) * window.consecutivePollFailures — 连续轮询失败计数(connection.js 也写入)
* window.lastSysStateCode — 上次成功的 sys_state_code(connection.js 写入) * window.lastSysStateCode — 上次成功的 sys_state_code(connection.js 写入)
@@ -21,7 +20,7 @@
* window.activeUploadChip — 当前上传目标芯片 * window.activeUploadChip — 当前上传目标芯片
* window.currentOtaTarget — 当前 OTA 目标 * window.currentOtaTarget — 当前 OTA 目标
* *
* 未拆分的 app.js 函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.getEspIp / window.getBaseUrl * window.getEspIp / window.getBaseUrl
* window.updateSystemStatusUi / window.updateEsp32StagesFromPhase * window.updateSystemStatusUi / window.updateEsp32StagesFromPhase
* window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState * window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState
@@ -30,7 +29,7 @@
*/ */
import { addLog } from './log.js'; import { addLog } from './log.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { updateConnectionUi } from './connection.js'; import { updateConnectionUi, diagnoseNetwork } from './connection.js';
// ==================== 轮询状态 ==================== // ==================== 轮询状态 ====================
// 共享状态(初始化到 window,供 connection.js 等其他模块读写) // 共享状态(初始化到 window,供 connection.js 等其他模块读写)
@@ -41,66 +40,32 @@ window.lastRelayState = 0; // 上次成功的 relay_state
// 模块级状态(仅本模块使用) // 模块级状态(仅本模块使用)
var statusTimerId = null; var statusTimerId = null;
var logsTimerId = null; var logsTimerId = null;
var lastDiagTime = 0; // 上次诊断时间
var diagResult = ''; // 最近一次诊断结果
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用) var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用)
var statusPollIntervalMs = 1000; // 状态轮询目标间隔 var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms)
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠 var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
// 从设备 IP 推导网关 IP(取前三段 + .1) /**
function getGatewayIp() { * 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后;
var parts = window.getEspIp().split('.'); * 结束后恢复 1000ms,避免常态堆/栈卡片过刷。
if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1'; */
return '192.168.31.1'; export function setStatusPollIntervalMs(ms) {
} var n = Number(ms);
// 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通 if (!Number.isFinite(n) || n < 200) n = 1000;
async function diagnoseNetwork() { if (n > 5000) n = 5000;
lastDiagTime = Date.now(); statusPollIntervalMs = n;
var result = [];
// 1) 浏览器是否在线
if (navigator.onLine === false) {
result.push('浏览器无网络连接');
diagResult = result.join(';');
return diagResult;
}
// 2) 尝试触达网关(超时 2s)
var gwIp = getGatewayIp();
var gwOk = false;
try {
await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) });
gwOk = true; // no-cors 模式下不抛异常即视为可达
} catch (_) {}
if (!gwOk) {
// 再试一次 favicon(有些路由器不响应 /)
try {
await fetch('http://' + gwIp + '/favicon.ico', { mode: 'no-cors', signal: AbortSignal.timeout(1500) });
gwOk = true;
} catch (_) {}
}
if (!gwOk) {
result.push('网关 ' + gwIp + ' 不可达(本地网络不通)');
} else {
result.push('网关 ' + gwIp + ' 可达');
// 3) 网关通但设备不通 → 设备自身问题
result.push('设备 ' + window.getEspIp() + ' 不响应(可能断电、重启中或 WiFi 断开)');
}
diagResult = result.join(';');
return diagResult;
} }
/** @description 调度下一次 /system/status 轮询(递归 setTimeout)。 /** @description 调度下一次 /system/status 轮询(递归 setTimeout)。
* 上一次请求完成(或 3s 超时)后才调度下一次,避免 setInterval 把慢响应 * 默认 delay = statusPollIntervalMs;pollStatus 完成后会按「固定周期」
* 直接 abort 而导致堆/任务栈/CPU 等卡片刷新率不稳。 * 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval,
* 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。
* 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。
*/ */
export function scheduleStatusPoll(delayMs) { export function scheduleStatusPoll(delayMs) {
if (statusTimerId) clearTimeout(statusTimerId); if (statusTimerId) clearTimeout(statusTimerId);
// 中继期加快:进度条依赖 status.relay_seq,1s 间隔体感像卡死
var interval = statusPollIntervalMs; var interval = statusPollIntervalMs;
// 防御性守卫:window.OtaSessionState 可能尚未挂载(模块加载早期),此时不加速
if (window.OtaSessionState && window.otaSessionState === window.OtaSessionState.RELAYING) {
interval = Math.min(interval, 400);
}
statusTimerId = setTimeout(function() { statusTimerId = setTimeout(function() {
statusTimerId = null; statusTimerId = null;
pollStatus(); pollStatus();
@@ -161,8 +126,9 @@ export async function pollStatus() {
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求 // 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
if (statusPollInFlight) return; if (statusPollInFlight) return;
statusPollInFlight = true; statusPollInFlight = true;
var pollStartedAt = Date.now();
statusAbortCtrl = new AbortController(); statusAbortCtrl = new AbortController();
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 3000); var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000);
try { try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { var resp = await fetch(window.getBaseUrl() + '/system/status', {
signal: statusAbortCtrl.signal signal: statusAbortCtrl.signal
@@ -287,7 +253,13 @@ export async function pollStatus() {
} }
} finally { } finally {
statusPollInFlight = false; statusPollInFlight = false;
scheduleStatusPoll(); statusAbortCtrl = null;
/* 固定周期调度:interval 从本轮开始计时扣减请求耗时。
* 旧逻辑「完成后 + 1000ms」→ 窗口稳定 1100+;现 sample_ms 贴近 1000/400。
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。 */
var elapsed = Date.now() - pollStartedAt;
var delay = Math.max(0, statusPollIntervalMs - elapsed);
scheduleStatusPoll(delay);
} }
} }
// 注意:startPoll() 由入口模块(app.js / ble_ota.js)在所有模块加载完成后调用, // 注意:startPoll() 由入口模块(app.js / ble_ota.js)在所有模块加载完成后调用,
@@ -316,7 +288,7 @@ export async function pollLogs() {
// ★ ESP32 上传期间也允许日志轮询 // ★ ESP32 上传期间也允许日志轮询
try { try {
var resp = await fetch(window.getBaseUrl() + '/logs', { var resp = await fetch(window.getBaseUrl() + '/logs', {
signal: AbortSignal.timeout(2000) signal: AbortSignal.timeout(5000)
}); });
if (!resp.ok) return; if (!resp.ok) return;
var logs = await resp.json(); var logs = await resp.json();
@@ -338,6 +310,18 @@ export async function pollLogs() {
showToast('ESP32 已恢复在线', 'success'); showToast('ESP32 已恢复在线', 'success');
if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE); if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE);
} }
/*
* 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。
* status 轮询可能仍停在旧 relay_seq,否则会出现 FINISH 日志之后还刷 94%。
*/
if (m[2] === 'OTA' && /全部数据包已转发/.test(m[3]) &&
typeof window.emitRelayProgress === 'function') {
var otaSnap = (window.latestSystemStatus && window.latestSystemStatus.ota) || {};
var tot = Number(otaSnap.relay_total_packets) || 0;
if (tot > 0) {
window.emitRelayProgress(tot, tot, { force100: true });
}
}
} else { } else {
addLog('I', 'ESP32', line.trimEnd()); addLog('I', 'ESP32', line.trimEnd());
} }
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* toast.js - Toast 提示组件 * toast.js - Toast 提示组件
* *
* 从 app.js 提取的 showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。 * showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。
* 支持 info / success / warning / error 类型,内置 5s 去重防抖。 * 支持 info / success / warning / error 类型,内置 5s 去重防抖。
*/ */
/** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */ /** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* utils.js - 通用工具函数 * utils.js - 通用工具函数
* *
* 从 app.js 提取的格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。 * 格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。
*/ */
// ==================== 工具函数 ==================== // ==================== 工具函数 ====================
/** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */ /** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */
+80 -23
View File
@@ -1,14 +1,13 @@
/** /**
* file-handle.js - 文件处理 + Intel HEX → BIN 转换 * file-handle.js - 文件处理 + Intel HEX → BIN 转换
* *
* 从 app.js 的「文件处理」「Intel HEX → BIN 转换」「文件处理」段提取。
* 包含:HEX 解析、文件选择、FSAPI、SHA-256 计算、文件信息 UI、清除。 * 包含:HEX 解析、文件选择、FSAPI、SHA-256 计算、文件信息 UI、清除。
* *
* 共享状态(通过 window.* 引用): * 共享状态(通过 window.* 引用):
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle * window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
* window._esp32FileSha256 / window._gd32FileSha256 * window._esp32FileSha256 / window._gd32FileSha256
* *
* 未拆分的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.updateUploadButtonLabel / window.showToast / window.addLog * window.updateUploadButtonLabel / window.showToast / window.addLog
*/ */
import { formatBytes } from '../common/utils.js'; import { formatBytes } from '../common/utils.js';
@@ -179,6 +178,73 @@ 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 算好并缓存(同时记录 file meta),上传前
// 若 handle.getFile() 拿回的 fresh file meta 未变就直接复用,
// 避免每次点上传都重算 30–80ms 的 crypto.subtle.digest。
// 缓存 key:size + lastModified + name 三项均一致(lastModified 在
// 文件 rebuild 后必变,是浏览器给的最可靠 rebuild 信号)。
/**
* @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) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
var shaElem = document.getElementById('fileSha256' + suffix); // GD32 可能为 null
if (shaElem) shaElem.textContent = '计算中...';
var bufPromise = optArrayBuffer ? Promise.resolve(optArrayBuffer) : file.arrayBuffer();
return bufPromise.then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase();
if (shaElem) shaElem.textContent = hashHex;
var meta = { size: file.size, lastModified: file.lastModified, name: file.name };
if (chip === 'esp32') {
window._esp32FileSha256 = hashHex;
window._esp32FileShaMeta = meta;
} else {
window._gd32FileSha256 = hashHex;
window._gd32FileShaMeta = meta;
}
addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex);
return hashHex;
}).catch(function(err) {
if (shaElem) shaElem.textContent = '计算失败: ' + 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;
});
}
/**
* @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-256 计算、文件校验)。 */
export function processFile(file, chip) { export function processFile(file, chip) {
@@ -196,6 +262,7 @@ export function processFile(file, chip) {
document.getElementById('fileType' + suffix).textContent = 'Binary(?)'; document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
window.updateUploadButtonLabel(); window.updateUploadButtonLabel();
ensureFileShaCached(file, 'gd32');
return; return;
} }
window.gd32File = result.file; window.gd32File = result.file;
@@ -208,6 +275,7 @@ export function processFile(file, chip) {
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size); document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size);
window.updateUploadButtonLabel(); window.updateUploadButtonLabel();
ensureFileShaCached(result.file, 'gd32');
}).catch(function(err) { }).catch(function(err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message); addLog('E', '文件', 'HEX 解析失败: ' + err.message);
showToast('HEX 文件解析失败: ' + err.message, 'error'); showToast('HEX 文件解析失败: ' + err.message, 'error');
@@ -228,32 +296,21 @@ export function processFile(file, chip) {
window.updateUploadButtonLabel(); window.updateUploadButtonLabel();
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');
// ESP32 文件: 计算并显示 SHA-256 // 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算),
if (chip === 'esp32') { // 上传时若文件未变 ensureFileShaCached 命中直接复用。
var shaElem = document.getElementById('fileSha256' + suffix); ensureFileShaCached(file, chip);
if (shaElem) {
shaElem.textContent = '计算中...';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
shaElem.textContent = hashHex.toUpperCase();
// 存储到全局变量供上传时使用
window._esp32FileSha256 = hashHex.toUpperCase();
addLog('I', '文件', 'ESP32 固件 SHA-256: ' + hashHex.toUpperCase());
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
});
}
}
} }
/** @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') { window.esp32File = null; window.esp32FileHandle = null; window._esp32FileSha256 = null; } if (chip === 'esp32') {
else { window.gd32File = null; window.gd32FileHandle = null; } window.esp32File = null; window.esp32FileHandle = null;
window._esp32FileSha256 = null; window._esp32FileShaMeta = null;
} else {
window.gd32File = null; window.gd32FileHandle = null;
window._gd32FileSha256 = null; window._gd32FileShaMeta = null;
}
document.getElementById('fileInput' + suffix).value = ''; document.getElementById('fileInput' + suffix).value = '';
document.getElementById('fileInfo' + suffix).classList.remove('show'); document.getElementById('fileInfo' + suffix).classList.remove('show');
var shaElem = document.getElementById('fileSha256' + suffix); var shaElem = document.getElementById('fileSha256' + suffix);
+1 -2
View File
@@ -1,13 +1,12 @@
/** /**
* progress.js - 折叠面板 + 刷写进度 + 阶段节点 UI * progress.js - 折叠面板 + 刷写进度 + 阶段节点 UI
* *
* 从 app.js 的「折叠面板」「刷写进度」段提取。
* 操作 DOM: #esp32Stages / #gd32Stages / #stageStatusBar / #progressFill / #progressTxt / #progressPct * 操作 DOM: #esp32Stages / #gd32Stages / #stageStatusBar / #progressFill / #progressTxt / #progressPct
* *
* 共享状态(通过 window.* 引用): * 共享状态(通过 window.* 引用):
* window.activePhase / window.currentOtaTarget / window.esp32OtaPhase * window.activePhase / window.currentOtaTarget / window.esp32OtaPhase
* *
* 未拆分的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.updateStagesFromRelayState / window.cleanupAfterOtaFailure * window.updateStagesFromRelayState / window.cleanupAfterOtaFailure
* window.resumePollingAfterUpload / window.addLog * window.resumePollingAfterUpload / window.addLog
*/ */
+2 -3
View File
@@ -1,8 +1,7 @@
/** /**
* session.js - 前端 OTA 会话状态机 * session.js - 前端 OTA 会话状态机
* *
* 从 app.js 的「全局状态」+「前端 OTA 会话状态机」段提取。 * 统一管理一次 OTA 会话的完整生命周期,避免散布的布尔标志
* 统一管理一次 OTA 会话的完整生命周期,消除散布的布尔标志
* (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting) * (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting)
* 在网络错误后被轮询错误覆盖、状态混乱的问题。 * 在网络错误后被轮询错误覆盖、状态混乱的问题。
* *
@@ -20,7 +19,7 @@
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive * window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState * window.lastSysStateCode / window.lastRelayState
* *
* 未拆分的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.stopRelayProgressWatch / window.setProgress / window.addLog * window.stopRelayProgressWatch / window.setProgress / window.addLog
*/ */
import { addLog } from '../common/log.js'; import { addLog } from '../common/log.js';
+1 -2
View File
@@ -1,10 +1,9 @@
/** /**
* spi.js - SPI 帧构建与发送 * spi.js - SPI 帧构建与发送
* *
* 从 app.js 的「SPI 发送」段提取。
* 操作 DOM: #spiType / #spiData / #spiTimeout / #spiHint / #spiResp / #spiRespText * 操作 DOM: #spiType / #spiData / #spiTimeout / #spiHint / #spiResp / #spiRespText
* *
* 未拆分的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.getBaseUrl / window.showToast / window.addLog * window.getBaseUrl / window.showToast / window.addLog
*/ */
import { showToast } from '../common/toast.js'; import { showToast } from '../common/toast.js';
+19 -9
View File
@@ -1,7 +1,6 @@
/** /**
* stages.js - OTA 阶段驱动 (真实状态) * stages.js - OTA 阶段驱动 (真实状态)
* *
* 从 app.js 的「OTA 阶段驱动 (真实状态)」段提取。
* 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI, * 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI,
* 并驱动 updateSystemStatusUi 综合 UI 更新。 * 并驱动 updateSystemStatusUi 综合 UI 更新。
* *
@@ -11,7 +10,7 @@
* window.gd32CompleteAnnounced / window.gd32RelaySessionLive * window.gd32CompleteAnnounced / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState * window.lastSysStateCode / window.lastRelayState
* *
* 未拆分的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.updateHeapAndTaskStatusUi / window.updateConnectionUi / window.updateHealthUi * window.updateHeapAndTaskStatusUi / window.updateConnectionUi / window.updateHealthUi
* window.updateStagesFromRelayState / window.setActivePhase / window.setProgress * window.updateStagesFromRelayState / window.setActivePhase / window.setProgress
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset * window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
@@ -234,6 +233,16 @@ export function updateSystemStatusUi(data) {
if (isGd32Relaying || (rs >= 1 && rs <= 4)) { if (isGd32Relaying || (rs >= 1 && rs <= 4)) {
window.gd32RelaySessionLive = true; window.gd32RelaySessionLive = true;
} }
/* 包进度:进入 FINISHING/COMPLETE 时先补 100%,再走完成/收尾 UI */
if (window.gd32RelaySessionLive && relayTotal > 0 &&
(relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) {
if (typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relayTotal, relayTotal, { force100: true });
}
} else if (window.gd32RelaySessionLive && relayTotal > 0 && relaySeq > 0 &&
typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relaySeq, relayTotal);
}
if (window.gd32RelaySessionLive && if (window.gd32RelaySessionLive &&
(isGd32Complete || rs === 5 || (isGd32Complete || rs === 5 ||
(gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) { (gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) {
@@ -244,8 +253,14 @@ export function updateSystemStatusUi(data) {
// HANDSHAKING // HANDSHAKING
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
setProgress(50, '握手与认证中...'); setProgress(50, '握手与认证中...');
} else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5)) { } else if (rs === 4) {
// TRANSFERRING // FINISHING:转发已 100%,进度条进入收尾段(勿再落回中间百分比)
setActivePhase('gd32Ota');
if (!window.gd32CompleteAnnounced) {
setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
}
} else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5 && rs !== 4)) {
// TRANSFERRING(不含 FINISHING)
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
var relayOverall = 60 + Math.round(relayProgress * 0.30); var relayOverall = 60 + Math.round(relayProgress * 0.30);
var relayLabel = relayTotal > 0 var relayLabel = relayTotal > 0
@@ -258,11 +273,6 @@ export function updateSystemStatusUi(data) {
setProgress(55, relayTotal > 0 setProgress(55, relayTotal > 0
? ('大小协商中(共 ' + relayTotal + ' 包)') ? ('大小协商中(共 ' + relayTotal + ' 包)')
: '固件大小协商中'); : '固件大小协商中');
} else if (rs === 4) {
setActivePhase('gd32Ota');
if (!window.gd32CompleteAnnounced) {
setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
}
} else if (!window.gd32RelaySessionLive) { } else if (!window.gd32RelaySessionLive) {
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
setProgress(48, '已触发中继,等待设备进入握手…'); setProgress(48, '已触发中继,等待设备进入握手…');
+13 -30
View File
@@ -1,7 +1,6 @@
/** /**
* upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级 * upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级
* *
* 从 app.js 的「上传固件」「ESP32 OTA 完成升级」段提取。
* 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、 * 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、
* XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。 * XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。
* *
@@ -10,7 +9,7 @@
* window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr * window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr
* window.isUploading / window.uploadBtnOrigText / window.activeUploadChip * window.isUploading / window.uploadBtnOrigText / window.activeUploadChip
* *
* 未拆分的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset * window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget * window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget
* window.cleanupAfterOtaFailure / window.pausePollingForUpload * window.cleanupAfterOtaFailure / window.pausePollingForUpload
@@ -31,6 +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';
function isOtaBusyError(status, detail) { function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase(); var text = (detail || '').toLowerCase();
@@ -104,9 +104,13 @@ export async function startUpload(chip) {
var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI'); var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI');
// ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出, // ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出,
// 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。 // 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。
// ERROR(8) 也放行给后端清理底层 OTA 句柄;FINALIZING(6)/REBOOTING(7) 是不可逆忙态。 // ERROR(8) 放行给后端清理;REBOOTING(7) 不可逆。
if (ssc === 5 && otaChannel === 1 && _pi < 3) continue; // FINALIZING(6):真收尾时后端会 403;SHA 失败后可能假占用(core 已空),
var esp32Busy = (ssc === 6 || ssc === 7 || (ssc === 5 && otaChannel !== 0)); // status 自愈 / begin 残留清理需要请求触达设备,前端同通道不硬拦。
if ((ssc === 5 || ssc === 6) && otaChannel === 1 && _pi < 3) continue;
var esp32Busy = (ssc === 7 ||
(ssc === 6 && otaChannel !== 0) ||
(ssc === 5 && otaChannel !== 0));
// GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。 // GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。
var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0)); var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0));
if ((esp32Busy || gd32Busy)) { if ((esp32Busy || gd32Busy)) {
@@ -183,31 +187,10 @@ 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(ESP32 和 GD32 都算) // SHA-256:processFile 阶段已算好并缓存;这里若 handle.getFile() 拿回的
if (chip === 'esp32') { // fresh file meta 未变就直接复用(命中 30–100ms/MB 的计算);
try { // 用户 rebuild 使 lastModified 变化时 ensureFileShaCached 自动 fallback 到重算。
var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); await ensureFileShaCached(file, chip, arrayBuffer);
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
window._esp32FileSha256 = hashHex.toUpperCase();
var shaElem = document.getElementById('fileSha256Esp32');
if (shaElem) shaElem.textContent = window._esp32FileSha256;
addLog('I', '文件', '刷写前重新计算 SHA-256: ' + window._esp32FileSha256);
} catch (e) {
addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message);
}
} else if (chip === 'gd32') {
try {
var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
window._gd32FileSha256 = hashHex.toUpperCase();
addLog('I', '文件', 'GD32 SHA-256: ' + window._gd32FileSha256);
} catch (e) {
addLog('W', '文件', 'GD32 SHA-256 重新计算失败: ' + e.message);
window._gd32FileSha256 = null;
}
}
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') {
+78 -33
View File
@@ -1,7 +1,6 @@
/** /**
* upload-gd32.js - GD32 中继 OTA 触发 + relay 态跟踪 * upload-gd32.js - GD32 中继 OTA 触发 + relay 态跟踪
* *
* 从 app.js 的「GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)」段提取。
* 包含:relay 进度观察定时器、markGd32OtaUiComplete、triggerGd32Relay。 * 包含:relay 进度观察定时器、markGd32OtaUiComplete、triggerGd32Relay。
* *
* 共享状态(通过 window.* 引用): * 共享状态(通过 window.* 引用):
@@ -9,7 +8,7 @@
* window.pollingPausedForUpload / window.statusPollIntervalMs * window.pollingPausedForUpload / window.statusPollIntervalMs
* window.otaSessionState / window.lastSysStateCode / window.lastRelayState * window.otaSessionState / window.lastSysStateCode / window.lastRelayState
* *
* 未拆分的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionActive * window.otaSessionTransition / window.OtaSessionState / window.otaSessionActive
* window.markPhaseDone / window.setProgress / window.updateSystemStatusUi * window.markPhaseDone / window.setProgress / window.updateSystemStatusUi
* window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs * window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs
@@ -26,16 +25,61 @@ import { markPhaseDone, setProgress } from './progress.js';
* GD32 中继进度观察:以 /system/status 为唯一真相源。 * GD32 中继进度观察:以 /system/status 为唯一真相源。
* 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间—— * 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间——
* 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。 * 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。
*
* 时序约定(避免「设备已 FINISH / 完成,UI 还在 94%」):
* 1) 包进度变化先刷进度条 + 日志;
* 2) 一旦 rseq>=rtot 或 rs>=4(FINISHING),先强制打出 100% 再进收尾/完成;
* 3) markGd32OtaUiComplete 前再兜底一次 100%。
*/ */
let relayWatchTimerId = null; let relayWatchTimerId = null;
let relayWatchInFlight = false;
window.gd32CompleteAnnounced = false; window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1; window.relayWatchLastSeq = -1;
/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */ /** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */
window.gd32RelaySessionLive = false; window.gd32RelaySessionLive = false;
/**
* 包进度变更时更新进度条并写一条「转发」日志(同 seq 不重复)。
* @param {number} rseq
* @param {number} rtot
* @param {{force100?: boolean}} [opts]
* @returns {boolean} 是否实际输出了进度
*/
export function emitRelayProgress(rseq, rtot, opts) {
opts = opts || {};
rseq = Number(rseq) || 0;
rtot = Number(rtot) || 0;
if (rtot <= 0) return false;
if (opts.force100 || rseq >= rtot) {
rseq = rtot;
}
if (rseq <= 0) return false;
if (rseq === window.relayWatchLastSeq) return false;
window.relayWatchLastSeq = rseq;
var pct = Math.min(100, Math.round(rseq * 100 / rtot));
setProgress(60 + Math.round(pct * 0.30),
'已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)');
addLog('I', 'OTA', '转发 ' + rseq + '/' + rtot + ' (' + pct + '%)');
return true;
}
/** 完成/收尾前:若尚未打出 100%,用 status 里的包数补一条 */
export function ensureRelayProgressComplete() {
var data = window.latestSystemStatus;
var o = (data && data.ota) || {};
var rtot = Number(o.relay_total_packets) || 0;
var rseq = Number(o.relay_seq) || 0;
if (rtot <= 0) return;
/* FINISHING/DONE 时后端 seq 已到 total;status 滞后时也强制 100% */
if (rseq < rtot && (Number(o.relay_state) >= 4 || Number(o.sys_state_code) === 4)) {
rseq = rtot;
}
if (rseq >= rtot) {
emitRelayProgress(rtot, rtot, { force100: true });
}
}
export function markGd32OtaUiComplete(msg) { export function markGd32OtaUiComplete(msg) {
// 已公告过:禁止再改 className / setProgress / toast,否则每 400ms 重触发动效会「疯狂跳 ✓」 // 已公告过:禁止再改 className / setProgress / toast,否则定时器重触发动效会「疯狂跳 ✓」
if (window.gd32CompleteAnnounced) { if (window.gd32CompleteAnnounced) {
return; return;
} }
@@ -44,6 +88,8 @@ export function markGd32OtaUiComplete(msg) {
window.otaSessionState !== OtaSessionState.FAILED) { window.otaSessionState !== OtaSessionState.FAILED) {
return; return;
} }
/* 先 100% 转发进度,再宣布刷写完成 —— 避免日志里「完成」早于「1089/1089」 */
ensureRelayProgressComplete();
window.gd32CompleteAnnounced = true; window.gd32CompleteAnnounced = true;
stopRelayProgressWatch(); stopRelayProgressWatch();
@@ -67,18 +113,25 @@ export function stopRelayProgressWatch() {
clearInterval(relayWatchTimerId); clearInterval(relayWatchTimerId);
relayWatchTimerId = null; relayWatchTimerId = null;
} }
relayWatchInFlight = false; /* 中继结束:恢复常态 1s status 轮询 */
if (window.statusPollIntervalMs < 1000) { if (typeof window.setStatusPollIntervalMs === 'function') {
window.statusPollIntervalMs = 1000; window.setStatusPollIntervalMs(1000);
} }
} }
/**
* 中继进度观察:只读 pollStatus 写入的 latestSystemStatus,不再另起 /system/status。
* 中继期 status 加速到 400ms,使「转发 N/M」更贴近设备真实进度。
*/
export function startRelayProgressWatch() { export function startRelayProgressWatch() {
stopRelayProgressWatch(); stopRelayProgressWatch();
if (window.pollingPausedForUpload) { if (window.pollingPausedForUpload) {
window.resumePollingAfterUpload(); window.resumePollingAfterUpload();
} }
window.statusPollIntervalMs = 400; /* 中继期加速 status,缩小 UI 进度相对设备日志的滞后 */
if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(400);
}
window.scheduleStatusPoll(0); window.scheduleStatusPoll(0);
window.pollLogs(); window.pollLogs();
addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志'); addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志');
@@ -92,13 +145,8 @@ export function startRelayProgressWatch() {
} }
return; return;
} }
if (relayWatchInFlight) return; var data = window.latestSystemStatus;
relayWatchInFlight = true; if (!data || !data.ota) return;
fetch(window.getBaseUrl() + '/system/status', { signal: AbortSignal.timeout(3000) })
.then(function (resp) { return resp.ok ? resp.json() : null; })
.then(function (data) {
if (!data) return;
try { window.updateSystemStatusUi(data); } catch (e) { /* ignore */ }
var o = data.ota || {}; var o = data.ota || {};
var code = Number(o.sys_state_code); var code = Number(o.sys_state_code);
var rstate = Number(o.relay_state); var rstate = Number(o.relay_state);
@@ -108,30 +156,27 @@ export function startRelayProgressWatch() {
if (rstate >= 1 && rstate <= 4) { if (rstate >= 1 && rstate <= 4) {
window.gd32RelaySessionLive = true; window.gd32RelaySessionLive = true;
} }
if (window.gd32RelaySessionLive &&
(code === 4 || rstate === 5 ||
(alive && rtot > 0 && rseq >= rtot && rstate >= 3))) {
markGd32OtaUiComplete('GD32 刷写完成');
window.pollLogs();
return;
}
if (code === 8 || rstate === 255) { if (code === 8 || rstate === 255) {
stopRelayProgressWatch(); stopRelayProgressWatch();
window.pollLogs(); window.pollLogs();
return; return;
} }
// 前端进度同步:包数变化时更新进度条(updateSystemStatusUi 已做)并写一条前端日志 /*
// 后端不再打「转发进度」文字,只靠这里 + 进度条 * 顺序:先刷包进度(含强制 100%),再判定完成。
if (rtot > 0 && rseq > 0 && rseq !== window.relayWatchLastSeq) { * FINISHING/DONE 时若 status 滞后未到 total,仍按 total 打 100%。
window.relayWatchLastSeq = rseq; */
var pct = Math.round(rseq * 100 / rtot); var transferDone = rtot > 0 && (rseq >= rtot || rstate >= 4 || code === 4);
setProgress(60 + Math.round(pct * 0.30), if (transferDone) {
'已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)'); emitRelayProgress(rtot, rtot, { force100: true });
addLog('I', 'OTA', '转发 ' + rseq + '/' + rtot + ' (' + pct + '%)'); } else if (rtot > 0 && rseq > 0) {
emitRelayProgress(rseq, rtot);
}
if (window.gd32RelaySessionLive &&
(code === 4 || rstate === 5 ||
(alive && rtot > 0 && rseq >= rtot && rstate >= 3))) {
markGd32OtaUiComplete('GD32 刷写完成');
window.pollLogs();
} }
})
.catch(function () { /* 下轮继续 */ })
.then(function () { relayWatchInFlight = false; });
}, 400); }, 400);
} }