diff --git a/README.md b/README.md index 0edbf6c..eff98a4 100644 --- a/README.md +++ b/README.md @@ -433,9 +433,8 @@ UDP socket 延迟创建(首次 `send()` 时才分配),WiFi 未连接时 `s | OTA | `OTA_BLOCK_SIZE` | 8192 | 读写块大小 | | OTA | `OTA_MAX_FIRMWARE_SIZE` | 216KB | GD32 固件上限 | | OTA | `GD32_OTA_PACKET_SIZE` | 48 | 每包载荷字节 | -| OTA | `OTA_RESEND_RETRIES` | 10 | 单包最大重发次数 | | 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_SAMPLE_MAX_AGE_MS` | 15000 | OTA 前 RSSI 采样最大时效 | | 阈值 | `TEMP_WARNING` | 80°C | 温度警告阈值 | diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 8b9e0d9..839199a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取) "server/ble_ota.cpp" # BLE GATT OTA 服务器 "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检) + "monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务 "monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1) "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) "monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 6c64c5a..5added0 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -10,25 +10,17 @@ menu "DCU OTA Configuration" validated against actual wiring length/quality and GD32 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 int "Polling backoff when GD32 is not ready (ms)" default 2 range 0 50 help Delay between SPI response polls when GD32 returns WAIT - or does not drive MISO yet. Lower value reduces per-packet - idle time and can significantly speed up OTA, but if GD32 - cannot finish flash write before the next poll, increase it. + or does not drive MISO yet. Packet pacing uses SPI Link v2 + CREDIT backpressure in SpiMaster (remoteCredit==0 → wait/ + 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 int "Timeout for FINISH_OTA command (ms)" diff --git a/main/core/main.cpp b/main/core/main.cpp index dcc860b..b200454 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -14,6 +14,7 @@ #include "esp_ota_ops.h" #include "monitor/health_monitor.h" +#include "monitor/cpu_stats.h" #include "relay/ota_relay.h" #include "ota/ota_coordinator.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(&chip_info); - // ★ M1: ESP32 OTA 分区互斥锁内聚到 Esp32OtaCore(替代旧的全局变量) + // ESP32 OTA 分区互斥锁内聚到 Esp32OtaCore Esp32OtaCore::initMutex(); // 初始化网络接口 + 事件循环(必须最早) @@ -43,7 +44,7 @@ extern "C" void app_main(void) // 初始化调试日志(UART0 先就绪,UDP socket 延迟到首次发送时创建) DebugLog::instance().init(); - // ★ WiFi 提前初始化(调度器启动前) + // WiFi 提前初始化(调度器启动前) bool wifi_sta_ok = WifiOta::instance().earlyInit(); HealthMonitor::instance().init(); @@ -61,7 +62,7 @@ extern "C" void app_main(void) xRet = xTaskCreatePinnedToCore( HealthMonitor::taskEntry, "health_mon", - 4096, + TASK_STACK_HEALTH_MON, NULL, configMAX_PRIORITIES - 4, NULL, @@ -76,12 +77,15 @@ extern "C" void app_main(void) DEBUG_INFO("MAIN", "健康监控任务已创建"); } + /* CPU 占用 1Hz 采样(非致命;失败时 /system/status 的 cpu.valid 保持 false) */ + CpuStats::instance().start(); + Protocol::instance().init(); DEBUG_INFO("MAIN", "协议层初始化完成"); - // ★ 全局 OTA 协调器初始化(在 OTA task 创建前,确保 mutex 就绪) + // OTA 全局协调器初始化(在 OTA task 创建前,确保 mutex 就绪) OtaCoordinator::instance().init(); - // ★ L1: 注册 BLE 射频共存监听器(WifiOta 实现 IBleCoexListener) + // 注册 BLE 射频共存监听器(WifiOta 实现 IBleCoexListener) OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance()); DEBUG_INFO("MAIN", "OTA 全局协调器已初始化"); @@ -96,7 +100,7 @@ extern "C" void app_main(void) xRet = xTaskCreatePinnedToCore( OtaRelay::taskEntry, "ota_relay", - 6144, + TASK_STACK_OTA_RELAY, NULL, configMAX_PRIORITIES - 2, NULL, @@ -115,7 +119,7 @@ extern "C" void app_main(void) xRet = xTaskCreatePinnedToCore( WifiOta::taskEntry, "wifi_ota", - 8192, + TASK_STACK_WIFI_OTA, NULL, configMAX_PRIORITIES - 3, NULL, @@ -134,7 +138,7 @@ extern "C" void app_main(void) xRet = xTaskCreatePinnedToCore( BleOta::taskEntry, "ble_ota", - 4096, + TASK_STACK_BLE_OTA, NULL, configMAX_PRIORITIES - 3, NULL, @@ -158,7 +162,7 @@ extern "C" void app_main(void) DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)"); } - // ★ OTA 回滚保护:标记当前固件有效 + // OTA 回滚保护:标记当前固件有效 // 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数 // 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区 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", "所有任务创建完成,OTA Relay 运行中"); - // ★ 启动最后步骤:读取 GD32 APP 版本号 - // 参考 OtaRelay::doWaitForAppAlive 刷写后读取逻辑: + // 启动最后步骤:读取 GD32 APP 版本号 // 先 hello() 建立 SPI v2 会话,再 probeGd32AppOnce 读取版本 // GD32 APP 完整初始化需 5-6 秒,使用 20 次重试 × 300ms 窗口 { diff --git a/main/include/app_config.h b/main/include/app_config.h index 89e7760..c5d60c4 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -44,22 +44,35 @@ inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; ///< AP 模 // 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 折中。 -// 真·栈溢出钩子会 abort;卡 100% 更多是堆碎导致回包/后续分配失败。 + +// ============================================================ +// 任务栈大小(字节)— 2026-07 按板端 task_stack 水位收敛 +// 规则:peak_used 实测 + 至少 ~1.5–2KB 余量;httpd 路径最重单独放宽。 +// 实测(空闲/常规):http peak~5KB, wifi_ota~1.4KB, ota_relay~1KB, +// health~1.2KB, cpu_stats~2KB, ble*~1KB, reconnect~1KB。 +// ============================================================ +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 配置 // ============================================================ -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 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 拉低时需更长 // ============================================================ // 健康监控阈值 diff --git a/main/include/version.h b/main/include/version.h index eaf023d..161748d 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 46 -#define FW_VERSION_STRING "0.3.46" +#define FW_VERSION_PATCH 47 +#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 信息 -#define GIT_HASH "5a89cec" +#define GIT_HASH "a47a027" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index 58c3766..6e12cbe 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -16,6 +16,7 @@ #include "freertos/task.h" #include "freertos/semphr.h" +#include "app_config.h" #include "transport/canfd_bridge.h" #include "transport/spi_master.h" #include "monitor/debug_log.h" @@ -130,7 +131,7 @@ bool startCanFeedbackTask() BaseType_t xRet = xTaskCreatePinnedToCore( taskLoop, "can_fb", - 4096, + TASK_STACK_CAN_FB, nullptr, 4, nullptr, diff --git a/main/monitor/cpu_stats.cpp b/main/monitor/cpu_stats.cpp new file mode 100644 index 0000000..269c83d --- /dev/null +++ b/main/monitor/cpu_stats.cpp @@ -0,0 +1,192 @@ +/* + * cpu_stats.cpp - 1Hz FreeRTOS CPU 占用采样任务 + */ +#include "cpu_stats.h" + +#include +#include +#include +#include + +#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(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)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(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 diff --git a/main/monitor/cpu_stats.h b/main/monitor/cpu_stats.h new file mode 100644 index 0000000..1d3b2fe --- /dev/null +++ b/main/monitor/cpu_stats.h @@ -0,0 +1,73 @@ +/* + * cpu_stats.h - FreeRTOS 运行时 CPU 占用采样 + * + * 由独立低优先级任务按固定 1s 周期采样;/system/status 只读最新快照, + * 不再在 HTTP 线程里调用 uxTaskGetSystemState(避免窗口绑浏览器轮询、减 httpd 栈压)。 + */ +#pragma once + +#include +#include +#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; +}; diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index d037a68..8b77b2b 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -367,7 +367,8 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len) } /* UDP 通道:堆紧时跳过 sendto,避免申请 LWIP TX pbuf 与 HTTP 回包抢堆。 - * WiFi 未连接时 sendto 失败也静默跳过。 */ + * WiFi 未连接时 sendto 失败也静默跳过。 + * ★ 修复:堆不足跳过时写一条告警到 ring buffer,让前端 /logs 可见。 */ if (m_initialized && free_now >= kSkipUdpFree) { if (m_udpSock < 0) { ensureUdpSocket(); @@ -381,6 +382,16 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len) 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 拉取)——静态,不占堆 diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index 13b10c6..b8446e6 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -113,8 +113,8 @@ private: * free < kDrainMinFree → /logs 尽量仍返回 ring(降低门槛,避免“沉默失败”) */ static constexpr uint32_t kDropWarnFree = 6U * 1024U; static constexpr uint32_t kDropInfoFree = 10U * 1024U; - static constexpr uint32_t kSkipUdpFree = 10U * 1024U; - static constexpr uint32_t kDrainMinFree = 8U * 1024U; + static constexpr uint32_t kSkipUdpFree = 6U * 1024U; // ★ 修复:从 10KB 降至 6KB,sendto 本身不需大块堆 + static constexpr uint32_t kDrainMinFree = 4U * 1024U; // ★ 修复:从 8KB 降至 4KB,降低 /logs 返空的概率 esp_err_t ensureUdpSocket(); void ringPush(const char *data, int len); diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index 82839e1..25bdf73 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -74,9 +74,9 @@ public: TaskHandle_t getTaskHandle() const { return m_taskHandle; } /** - * @brief 由 WiFi 事件回调推送连接状态(解耦 HealthMonitor → WifiOta 依赖)。 + * @brief 由 WiFi 事件回调推送连接状态(避免 HealthMonitor → WifiOta 依赖)。 * WiFi 连接/断开时由 wifi_ota 事件处理器主动调用, - * HealthMonitor 不再主动查询 WifiOta::isConnected()。 + * HealthMonitor 无需主动查询 WifiOta::isConnected()。 * @param connected true=已连接,false=断开。 */ void setWifiConnected(bool connected); diff --git a/main/monitor/task_stack_check.cpp b/main/monitor/task_stack_check.cpp index 0c85c20..4477999 100644 --- a/main/monitor/task_stack_check.cpp +++ b/main/monitor/task_stack_check.cpp @@ -8,6 +8,7 @@ #include "app_config.h" #include "health_monitor.h" +#include "cpu_stats.h" #include "ota_relay.h" #include "wifi_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 }; } 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) { - out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), 6144 }; + out[n++] = { "cpu_stats", CpuStats::instance().getTaskHandle(), TASK_STACK_CPU_STATS }; } if (n < max) { - out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), 8192 }; + out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), TASK_STACK_OTA_RELAY }; } 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) { - out[n++] = { "ble_ota", BleOta::instance().getTaskHandle(), 4096 }; + out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, TASK_STACK_WIFI_RECONNECT }; } 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; diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index 5da802a..ed973ae 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -25,7 +25,7 @@ #define TAG "OTA_CORE" // ============================================================ -// 分区互斥锁(M1: 内聚自 ota_mutex.h 全局变量) +// 分区互斥锁 // ============================================================ SemaphoreHandle_t Esp32OtaCore::s_mutex = NULL; volatile bool Esp32OtaCore::s_occupied = false; @@ -172,22 +172,57 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) return beginCleanupAndReturn(ESP_FAIL); } - /* ★ 用 OTA_WITH_SEQUENTIAL_WRITES:边写边按扇区擦除,begin 几乎瞬时返回。 - * 旧逻辑按 64KB 对齐预擦 ~1MB,会在 /ota/upload 开头阻塞几十秒~数分钟: - * 浏览器 upload 进度已到 100%,设备还在擦 Flash → 表现为“卡在 100% 很久才成功”。 */ - DEBUG_INFO(TAG, "esp_ota_begin(SEQUENTIAL) 目标=%s size=%u free=%lu...", - m_partition->label, (unsigned)total_size, + /* ★ 整分区预擦(非 SEQUENTIAL 边写边擦): + * 1) 扇区循环 erase + 喂狗/yield,避免 TASK_WDT 与 WiFi 饿死 + * 2) esp_ota_resume(erase_size≠SEQUENTIAL) → need_erase=false,写路径纯 write + * 3) 显式 invalidate inactive slot(resume 不像 begin 那样自动做) + * 预擦会在 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()); 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(); + 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) { - DEBUG_ERR(TAG, "esp_ota_begin 失败: %s (耗时 %lld ms)", - esp_err_to_name(err), (long long)((t1 - t0) / 1000)); + DEBUG_ERR(TAG, "esp_ota_resume 失败: %s (耗时 %lld ms)", + esp_err_to_name(err), (long long)((t2 - t1) / 1000)); return beginCleanupAndReturn(err); } - DEBUG_INFO(TAG, "esp_ota_begin 完成 耗时 %lld ms free=%lu", - (long long)((t1 - t0) / 1000), + DEBUG_INFO(TAG, "esp_ota_resume 完成 耗时 %lld ms free=%lu", + (long long)((t2 - t1) / 1000), (unsigned long)esp_get_free_heap_size()); m_received = 0; @@ -196,7 +231,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) m_ended = false; 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); return ESP_OK; } @@ -213,11 +248,9 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len) return ESP_ERR_INVALID_STATE; } - /* sequential 模式写时可能触发扇区擦除(禁 cache),期间 WiFi 易饿死。 - * 写后 yield 一次即可;写前再 yield 会无谓拖慢吞吐(httpd 侧另有堆感知 pacing)。 */ + /* 整分区已预擦:此处为纯 write,不再扇区擦除。 */ (void)esp_task_wdt_reset(); esp_err_t err = esp_ota_write(m_handle, data, len); - taskYIELD(); if (err == ESP_OK) { m_received += len; } else { diff --git a/main/ota/esp32_ota_core.h b/main/ota/esp32_ota_core.h index fe263e0..fee1fa8 100644 --- a/main/ota/esp32_ota_core.h +++ b/main/ota/esp32_ota_core.h @@ -5,7 +5,7 @@ * begin → write*(N) → finalize / abort * * 统一以 WiFi 流程为基准: - * - esp_ota_begin 第二参数传 total_size + * - begin:整分区预擦后 esp_ota_resume(写路径不再边写边擦) * - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算) * - finalize 顺序: 读回 SHA-256 → 比对前端 SHA → esp_ota_end → esp_ota_set_boot_partition → 可选重启 * - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle @@ -102,16 +102,15 @@ public: /** @brief 获取已接收的字节数。 */ size_t getReceived() const { return m_received; } - // ── 分区互斥锁管理(M1: 从 ota_mutex.h 全局变量内聚到此类)── + // ── 分区互斥锁管理 ── /** * @brief 初始化 ESP32 OTA 分区互斥锁(在 app_main 中调用)。 - * 替代旧的全局 g_esp32OtaMutex / g_esp32OtaOccupied。 */ static void initMutex(); private: Esp32OtaCore() = default; - // ── 分区互斥锁(M1: 内聚自 ota_mutex.h)── + // ── 分区互斥锁 ── static SemaphoreHandle_t s_mutex; ///< ESP32 OTA 分区互斥锁 static volatile bool s_occupied; ///< 分区占用标志 static bool acquireMutex(uint32_t timeout_ms); diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 99f66a8..81a8db2 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -98,13 +98,13 @@ void OtaCoordinator::watchdogCheckLocked() return; // 未超时 } - // ★ 超时自愈:强制转 ERROR。 - // ERROR 不在任何 beginXxx() 的拒绝列表中,下次请求会自动从 ERROR 恢复。 - // 清理由各子系统自行负责(abortCurrent 中会按 ERROR 不做实际清理,仅复位状态)。 - DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制自愈 → ERROR", + // ★ 超时自愈:完整中止到 IDLE(释放 Esp32OtaCore / 中继 / GD32 meta 等)。 + // 旧逻辑只标 ERROR 不清理子系统:若卡在 FINALIZING 且 core 已半释放, + // 前端仍长时间判“占用”;若 core 仍持互斥,ERROR 后 begin 还要靠 forceCleanup。 + // REBOOTING 本应已 esp_restart;超时则同样中止,避免永久卡死。 + DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制中止 → IDLE", (int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks)); - m_sysState = OTA_SYS_ERROR; - m_busySinceTick = 0; + doAbortLocked(); } // ============================================================ @@ -124,7 +124,7 @@ void OtaCoordinator::init() m_gd32StorageDone = false; m_busySinceTick = 0; - // ★ H4 解耦:注册协调器为 OtaRelay 的中继事件监听器 + // 注册协调器为 OtaRelay 的中继事件监听器 OtaRelay::setRelayListener(this); // ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态, @@ -221,12 +221,27 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t return err; } - // 3. 复位按需擦除水位(不在此处整段预擦) - // 旧实现在 prepare 阶段 erase 整包固件对应分区:HTTP 主线程阻塞数秒~数十秒, - // 期间不 httpd_req_recv → TCP 窗口塞满后浏览器进度先到 100%,再空等擦+写+SHA。 - // 现改为 flashWriteChunk 内按扇区 ensureErased,边收边擦写,进度与真实处理对齐。 + // 3. 整分区预擦(不再边写边擦):prepare 阶段擦完 gd32_ota 分区, + // 收流时 flashWriteChunk 仅 write,吞吐更稳。会阻塞数秒~数十秒。 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) clearGd32Meta(); @@ -270,7 +285,7 @@ esp_err_t OtaCoordinator::finishGd32StorageAfterReceive() } // ============================================================ -// GD32 存储操作(传输层统一入口,解决 H3 绕过协调器问题) +// GD32 存储操作(传输层统一入口) // ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到 // Gd32FlashStore::instance(),消除跨 TU 调用开销。 // ============================================================ @@ -394,7 +409,7 @@ void OtaCoordinator::notifyRelayComplete(bool success) } // ============================================================ -// IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器) +// IRelayListener 接口实现 // ============================================================ /** @brief IRelayListener 回调:中继已开始,委托给 notifyRelayStarted()。 */ void OtaCoordinator::onRelayStarted() @@ -409,7 +424,7 @@ void OtaCoordinator::onRelayComplete(bool success) } // ============================================================ -// BLE 射频共存管理(L1: 消除 ble_ota → wifi_ota 直接依赖) +// BLE 射频共存管理 // ============================================================ 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)且来自同一通道, // 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。 // 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。 - // FINALIZING/REBOOTING 是不可逆阶段,也拒绝。 if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) { if (m_otaChannel != channel) { 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 的清理代码失效 } - // 系统忙检查:GD32 传输/中继进行中,或 ESP32 收尾/重启中,均拒绝。 - // ★ 必须显式拒绝 ESP32_FINALIZING/REBOOTING: - // 若放行,本调用会落到 Esp32OtaCore::begin 去抢全局互斥失败而返回 INVALID_STATE, - // 随后 beginEsp32OtaWithRetry 的 forceCleanup 会把另一通道的活跃会话 - // (esp_ota_abort + 释放互斥)误中止 → 两通道并发写同一 OTA 分区。 + // ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器 + // 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。 + // core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。 + if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) { + 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 || m_sysState == OTA_SYS_GD32_RELAYING || - m_sysState == OTA_SYS_ESP32_FINALIZING || m_sysState == OTA_SYS_ESP32_REBOOTING)) { DEBUG_WARN(TAG, "系统忙 (state=%d, %s),拒绝 ESP32 OTA", (int)m_sysState, stateToString(m_sysState)); @@ -586,6 +609,13 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h r.device_sha_hex); if (r.err == ESP_OK) { 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; } @@ -632,6 +662,26 @@ void OtaCoordinator::abortCurrent() 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 { bool h = lock(); @@ -665,7 +715,7 @@ bool OtaCoordinator::abortCurrentIfEpoch(uint32_t epoch) 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(); ota_system_state_t s = m_sysState; unlock(h); @@ -720,7 +770,7 @@ bool OtaCoordinator::getGd32AppAlive() const } // ============================================================ -// OTA 状态快照(PR1: 收口状态读取) +// OTA 状态快照 // ============================================================ OtaSnapshot OtaCoordinator::getSnapshot() const { @@ -754,7 +804,7 @@ OtaSnapshot OtaCoordinator::getSnapshot() const } // ============================================================ -// GD32 APP 探测(PR2: 从 /system/status 移出) +// GD32 APP 探测 // ============================================================ // 注:本函数与 OtaRelay::probeAndCacheAppAlive 逻辑相似,但因依赖方向 // (OtaCoordinator → OtaRelay)不应调用 OtaRelay 的内部实现细节, diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index 832996c..14b0d09 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -21,7 +21,7 @@ #include "esp_err.h" #include "app_config.h" // OTA_MAX_FIRMWARE_SIZE / GD32_OTA_PACKET_SIZE(validateGd32Size 用) #include "storage/gd32_flash_store.h" -#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot(H4 解耦) +#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot // ============================================================ // 系统级 OTA 状态枚举 @@ -47,7 +47,7 @@ typedef enum { } ota_channel_t; // ============================================================ -// OTA 系统全量只读快照(PR1: 收口状态读取) +// OTA 系统全量只读快照 // ============================================================ /** * @brief OTA 系统全量只读快照。 @@ -64,11 +64,11 @@ struct OtaSnapshot { }; // ============================================================ -// IBleCoexListener - BLE 射频共存通知接口(解决 L1 横向耦合) +// IBleCoexListener - BLE 射频共存通知接口 // ============================================================ // WifiOta 实现此接口并注册到 OtaCoordinator, // BLE OTA 开始/结束时通过协调器通知 WiFi 层, -// 消除 ble_ota → wifi_ota 的直接依赖。 +// 避免 ble_ota → wifi_ota 的直接依赖。 /** * @brief BLE 射频共存监听器接口。 * BLE OTA 开始/结束时,协调器通过此接口通知 WiFi 层暂停/恢复射频活动。 @@ -124,8 +124,8 @@ public: esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel); /** - * @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位按需擦除水位 + 清空 meta + 写入初始 meta。 - * 扇区擦除在 writeGd32Chunk/flashWriteChunk 内按写入范围推进(非整段预擦)。 + * @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 整分区预擦 + 清空 meta + 写入初始 meta。 + * 收流时 write 路径只写 Flash(ensureErased 仅作水位兜底)。 * WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。 * @param total_size 固件总字节数。 * @param channel 发起通道(WiFi/BLE)。 @@ -146,7 +146,7 @@ public: */ esp_err_t finishGd32StorageAfterReceive(); - // ── GD32 存储操作(传输层通过协调器操作 Flash,解决 H3 绕过问题)── + // ── GD32 存储操作(传输层通过协调器操作 Flash)── /** * @brief 向 GD32 Flash 分区写入固件数据块。 * 传输层(WiFi/BLE)通过此接口写入,不再直接调用 Gd32FlashStore。 @@ -312,7 +312,9 @@ public: /** * @brief ESP32 OTA 收尾(统一封装 BLE/WiFi 两端重复的收尾序列)。 * 步骤:notifyEsp32ReceiveComplete → Esp32OtaCore::finalize(reboot=false) - * → 成功则 notifyEsp32FinalizeComplete。 + * → 成功则 notifyEsp32FinalizeComplete; + * → 失败(SHA 不匹配 / end 失败等)则 abortCurrent() 复位到 IDLE, + * 避免协调器卡在 ESP32_FINALIZING 导致后续上传被假拒绝。 * 不重启;调用方负责把 device_sha_hex 上报前端后再调 restartEsp32()。 * @param frontend_sha_hex 前端 64 字符 hex SHA-256,nullptr 表示跳过比对。 * @return Esp32FinalizeResult,err 为 ESP_OK/ESP_ERR_INVALID_VERSION/其他。 @@ -333,6 +335,14 @@ public: */ void abortCurrent(); + /** + * @brief 自愈孤儿忙态(供 /system/status 等轮询路径调用)。 + * - 跑忙状态 watchdog(超时完整 abort → IDLE) + * - ESP32_FINALIZING 但 Esp32OtaCore 已空闲:判定为 SHA 失败等残留,复位 IDLE + * 不影响真正正在 finalize(core.isActive())的会话。 + */ + void healOrphanStates(); + /** * @brief 获取当前 OTA 会话的 epoch(代次)。 * 每次 beginEsp32Ota/beginGd32Storage 成功时递增。 @@ -354,7 +364,7 @@ public: // ── 状态查询 ── /** - * @brief 一次性读取系统 OTA 全量状态快照(PR1: 收口状态读取)。 + * @brief 一次性读取系统 OTA 全量状态快照。 * coordinator mutex + relay mutex 各取一次, * 供 /system/status 等只读接口消费。 * @return 包含 sys_state、meta、relay 进度的完整快照。 @@ -362,7 +372,7 @@ public: OtaSnapshot getSnapshot() const; /** - * @brief 单次 SPI 探测 GD32 APP 是否存活(PR2: 从 status 移出)。 + * @brief 单次 SPI 探测 GD32 APP 是否存活。 * 成功时更新 OtaRelay 缓存的 alive/version, * 失败时清除 alive 标志。 * @param out_version 输出版本信息,可为 nullptr。 @@ -421,7 +431,7 @@ public: */ void reset(); - // ── IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)── + // ── IRelayListener 接口实现 ── /** * @brief IRelayListener 回调:中继已开始。 * 由 OtaRelay task 在 forwardFirmware() 前通过 s_relayListener 调用。 @@ -435,7 +445,7 @@ public: */ void onRelayComplete(bool success) override; - // ── BLE 射频共存管理(L1: 替代 ble_ota → wifi_ota 直接调用)── + // ── BLE 射频共存管理 ── /** * @brief 注册 BLE 共存监听器(由 main 在初始化时调用)。 * @param listener WifiOta 实现的监听器指针,nullptr 取消注册。 @@ -498,10 +508,10 @@ private: // 仅在同类型活跃状态(ESP32_RECEIVING / GD32_STORING)下有意义。 ota_channel_t m_otaChannel = OTA_CHANNEL_WIFI; - // 互斥锁(M1/L3: mutable 允许在 const 方法中加锁,消除 const_cast) + // 互斥锁(mutable 允许在 const 方法中加锁,无需 const_cast) mutable SemaphoreHandle_t m_mutex = nullptr; - // BLE 共存监听器(L1: 由 WifiOta 实现并注册) + // BLE 共存监听器(由 WifiOta 实现并注册) IBleCoexListener *m_bleCoexListener = nullptr; static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100); diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 08870b1..6efcedc 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -1,7 +1,7 @@ /* * ota_relay.cpp - OTA 中继事件驱动状态机实现 * - * 将原 17 状态"声明式赋值"重构为 7 状态事件驱动架构: + * 7 状态事件驱动架构: * - 每个协议阶段封装为一个状态 handler * - processEvent() 根据当前状态分发事件 * - 状态转换显式通过 setState() 完成 @@ -24,8 +24,8 @@ #include "protocol.h" #include "health_monitor.h" #include "monitor/debug_log.h" -// ★ H4 解耦:OtaRelay 不再直接 #include "ota_coordinator.h", -// 通过 IRelayListener 接口回调通知协调器,依赖方向变为单向。 +// OtaRelay 不再直接 #include "ota_coordinator.h", +// 通过 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。 -// 含义:NACK/轮询耗尽属于"通信异常",给予较多重试机会(10次); +// retry_count 阈值:NACK/轮询耗尽属于"通信异常",给予较多重试机会(10次); // 结构化错误属于"GD32 报告具体错误",较少重试(3次)。 static constexpr uint32_t kMaxResendOnNack = 10U; // ACK 失败/轮询耗尽重发上限 static constexpr uint32_t kMaxResendOnStructError = 3U; // 结构化错误重发上限 @@ -67,7 +66,7 @@ SemaphoreHandle_t OtaRelay::s_stateMutex = NULL; TaskHandle_t OtaRelay::taskHandle = NULL; IRelayListener *OtaRelay::s_relayListener = nullptr; -// ── 中继事件监听器(解决 H4 双向耦合)── +// ── 中继事件监听器 ── void OtaRelay::setRelayListener(IRelayListener *listener) { s_relayListener = listener; @@ -78,7 +77,7 @@ IRelayListener* OtaRelay::getRelayListener() return s_relayListener; } -// ── 队列访问器(M3: 封装 public static 成员)── +// ── 队列访问器 ── QueueHandle_t OtaRelay::getDispatchQueue() { return dispatchQueue; @@ -1415,7 +1414,7 @@ void OtaRelay::taskEntry(void *pvParameters) } DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg); - // ★ H4: 通过 IRelayListener 接口回调通知协调器(不再直接依赖 OtaCoordinator) + // 通过 IRelayListener 接口回调通知协调器 IRelayListener *listener = getRelayListener(); if (listener) listener->onRelayStarted(); ota_result_t result = instance().forwardFirmware(); @@ -1432,7 +1431,7 @@ void OtaRelay::taskEntry(void *pvParameters) DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result); } instance().reset(); - // ★ H4: 通过 IRelayListener 接口回调通知协调器中继结束 + // 通过 IRelayListener 接口回调通知协调器中继结束 listener = getRelayListener(); if (listener) listener->onRelayComplete(result == OTA_RESULT_OK); } diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 1338a89..c991f7d 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -63,15 +63,15 @@ typedef enum { } ota_result_t; // ============================================================ -// IRelayListener - 中继事件监听接口(解决 H4 双向耦合) +// IRelayListener - 中继事件监听接口 // ============================================================ // OtaCoordinator 实现此接口并注册到 OtaRelay, // OtaRelay 通过接口回调通知协调器,不再直接 #include ota_coordinator.h。 -// 依赖方向变为单向:OtaCoordinator → OtaRelay(通过接口)。 +// 依赖方向为单向:OtaCoordinator → OtaRelay(通过接口)。 /** * @brief 中继事件监听器接口。 * OtaCoordinator 实现此接口,OtaRelay 通过回调通知状态变更, - * 消除 OtaRelay → OtaCoordinator 的直接依赖(解决 H4 双向耦合)。 + * 避免 OtaRelay → OtaCoordinator 的直接依赖。 */ class IRelayListener { public: @@ -181,7 +181,7 @@ public: */ void reset(); - // ── 中继事件监听器(解决 H4 双向耦合)── + // ── 中继事件监听器 ── /** * @brief 注册中继事件监听器(由 OtaCoordinator 在 init 时调用)。 * @param listener 监听器指针,传 nullptr 取消注册。 @@ -193,7 +193,7 @@ public: */ static IRelayListener* getRelayListener(); - // ── 队列访问器(解决 M3: 封装 public static 成员)── + // ── 队列访问器 ── /** * @brief 获取事件分发队列句柄。 * 外部模块(OtaCoordinator / BLE)通过 getter 访问,不再直接引用静态成员。 @@ -207,8 +207,6 @@ public: */ static QueueHandle_t getResultQueue(); - // ── Flash 操作已迁移至 Gd32FlashStore(storage/gd32_flash_store.h)── - // ── 线程安全锁 ── static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用) static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间 @@ -239,11 +237,11 @@ public: static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36; private: - // ── 队列(M3: 由 getter 访问,外部不再直接引用)── + // ── 队列(由 getter 访问)── static QueueHandle_t dispatchQueue; ///< 事件分发队列 static QueueHandle_t resultQueue; ///< 结果回传队列 - // ── 状态成员(全部改 private,统一通过 public setter/getter 访问)── + // ── 状态成员(统一通过 public setter/getter 访问)── // 外部代码使用 getState()/getRelaySeq()/... 读取,setState()/setRelaySeq()/... 写入。 // 内部代码:若已在 StateLockGuard 块内(reset/getSnapshot/启动探测),可直接访问; // 否则必须使用 setter/getter,禁止裸读写(消除数据竞争)。 @@ -252,7 +250,7 @@ private: static uint32_t relayTotalPackets; ///< 本次中继总包数 static volatile bool gd32AppAlive; ///< GD32 APP 存活标志 static gd32_version_t gd32Version; ///< GD32 固件版本信息 - static IRelayListener *s_relayListener; ///< 中继事件监听器(解决 H4) + static IRelayListener *s_relayListener; ///< 中继事件监听器 static SemaphoreHandle_t s_stateMutex; ///< 状态成员互斥锁(由 StateLockGuard 使用) // taskHandle 为 write-once-at-init 字段:仅 taskEntry 启动首行写入一次, @@ -331,7 +329,7 @@ private: /** * @brief 发送单包并等待 ACK(doRelayPackets 子步骤)。 * - * 内含重试循环,使用以下命名常量(与历史阈值保持一致): + * 内含重试循环,使用以下命名常量: * kMaxResendOnNack=10(ACK 失败/轮询耗尽重发上限) * kMaxResendOnStructError=3(结构化错误重发上限) * diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index f292491..7e8f1c9 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -66,7 +66,8 @@ // ── 数据队列:将 OTA 数据处理从 NimBLE host task 卸载到工作线程 ── // 避免 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 { uint8_t *data; @@ -83,7 +84,7 @@ struct BleEsp32FinishCtx { uint16_t session_id; }; -// RAII 包装:保证任务所有 return 路径自动释放 ctx,避免历史多次漏 free。 +// RAII 包装:保证任务所有 return 路径自动释放 ctx,避免漏 free。 // 注意:若调用 restartEsp32()(内部 esp_restart 不返回),析构不会执行, // 但这是设备重启路径,资源由系统回收。 struct BleFinishCtxDeleter { @@ -236,13 +237,10 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) // ============================================================ // WiFi 共存策略:BLE OTA 传输期间的处理 // -// 历史:曾在此处把 WiFi 切到 WIFI_PS_MAX_MODEM 以把空中时间让给 BLE。 -// 但 MAX_MODEM 下 STA 监听间隔被拉到极大,HTTP server 的 TCP 握手/数据包 -// 会大量丢失或延迟到超时 → BLE OTA 期间任何 WiFi 请求(包括互斥拒绝应返回的 -// 403)都到不了设备,前端 XHR 长时间无响应,表现为"Wifi 上传既不成功也不报错"。 -// -// 现策略:不切换 WiFi 省电模式(保持 WIFI_PS_NONE),让 STA 全程可用, +// 不切换 WiFi 省电模式(保持 WIFI_PS_NONE),让 STA 全程可用, // 协调器照常以 403 拒绝并发 OTA。ESP32-C3 coex 默认调度已能保证 BLE OTA 可用。 +// (曾尝试切到 WIFI_PS_MAX_MODEM 让出空中时间,但 STA 监听间隔被拉到极大, +// HTTP server 的 TCP 握手/数据包大量丢失或超时,前端 XHR 长时间无响应。) // 仍保留 setBleOtaActive() 暂停 RSSI 轮询(避免 get_ap_info 的射频活动干扰 BLE)。 // ============================================================ static bool s_wifi_ps_boosted = false; @@ -254,7 +252,7 @@ static void ble_ota_boost_wifi_ps() { if (s_wifi_ps_boosted) return; s_wifi_ps_boosted = true; - // ★ L1: 通过协调器通知 WiFi 层(替代直接调用 WifiOta) + // 通过协调器通知 WiFi 层 OtaCoordinator::instance().notifyBleCoexStart(); 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; s_wifi_ps_boosted = false; - // ★ L1: 通过协调器通知 WiFi 层(替代直接调用 WifiOta) + // 通过协调器通知 WiFi 层 OtaCoordinator::instance().notifyBleCoexEnd(); 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; BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task, "ble_gd32_wait", - 3072, + TASK_STACK_BLE_GD32_WAIT, ctx, 5, NULL); @@ -992,7 +990,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, BleOta::instance().m_esp32RebootPending = true; BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, "ble_ota_finish", - 4096, + TASK_STACK_BLE_FINISH, finish_ctx, // heap context; task 负责 free 5, NULL); @@ -1015,7 +1013,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, BleOta::instance().m_esp32RebootPending = true; BaseType_t ok = xTaskCreate(ble_esp32_restart_task, "ble_ota_restart", - 2048, + TASK_STACK_BLE_RESTART, NULL, 5, NULL); @@ -1223,17 +1221,25 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, // ACK 按累计连续进度触发,而不是按当前到达包的 seq 触发。缺口包到达后 // reorder 可能一次 flush 到下个窗口边界,例如 seq=99 使 expected 直达128; // 此时必须立即 ACK 127,否则后续重复包都不产生 progress,前端只能超时。 + // + // ★ seq=0 强制 ACK:begin 含整分区预擦,可能耗时数秒~数十秒。 + // 前端必须等“首包已落盘”ACK 后再发窗口内其余包,否则队列满丢包→重传极慢。 uint32_t completedPackets = (afterReceived + BLE_OTA_CHUNK_PAYLOAD - 1U) / BLE_OTA_CHUNK_PAYLOAD; bool reachedAckBoundary = completedPackets > 0U && (completedPackets % BLE_OTA_ACK_WINDOW) == 0U; + bool firstPacketReady = (seq == 0U) && progressMade; if (progressMade && (reachedAckBoundary || transferComplete || - isLastPacket)) { + isLastPacket || + firstPacketReady)) { if (isLastPacket || transferComplete) { DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32, 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)(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.last_seq = seq; const bool transfer_complete = stored_size >= meta.total_size; + // seq=0:prepare 含整分区预擦,写完首包必须 ACK,前端才能继续发后续窗口 const bool checkpoint = + (seq == 0U) || ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) || transfer_complete || (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; 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 { DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status); BleOta::instance().m_bleConnected = false; @@ -2040,7 +2056,7 @@ esp_err_t BleOta::resumeStack() m_suspendedForWifiOta = false; if (!m_stackRunning) { - /* 仅当历史上曾 hard-deinit 过才需要完整 serverStart */ + /* 仅当曾 hard-deinit 过才需要完整 serverStart */ DEBUG_INFO(TAG, "WiFi OTA 结束:BLE 栈未运行,尝试 serverStart free=%lu", (unsigned long)esp_get_free_heap_size()); esp_err_t err = serverStart(); @@ -2125,7 +2141,7 @@ void BleOta::taskEntry(void *pvParameters) if (s_otaDataQueue) { BaseType_t wk_ok = xTaskCreate(ble_ota_data_worker_task, "ble_ota_worker", - 4096, // 峰值仅 ~1.9KB,4KB 留有充足余量 + TASK_STACK_BLE_WORKER, // 实测 peak~1.1KB,负载峰值曾~1.9KB NULL, 10, // 中等优先级,避免被 NimBLE host task 过度抢占 NULL); diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index 93db22c..271b1ba 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -1,6 +1,5 @@ /* * http_handlers.cpp - HTTP 请求处理器实现 - * 从 wifi_ota.cpp 提取的 HTTP handler 函数 */ #include #include @@ -28,6 +27,7 @@ #include "storage/gd32_flash_store.h" #include "health_monitor.h" #include "monitor/debug_log.h" +#include "monitor/cpu_stats.h" #include "spi_master.h" #include "canfd_bridge.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); esp_wifi_set_ps(WIFI_PS_NONE); - // ★ L1: WiFi OTA 开始时恢复 BLE 共存状态(通过协调器) + // WiFi OTA 开始时恢复 BLE 共存状态(通过协调器) OtaCoordinator::instance().notifyBleCoexEnd(); 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(); if (free_pre < 8192U) { (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]); } - /* 估算缓冲大小:每通道 ~2KB (统计+ID条目) */ - char *resp = (char *)calloc(1, 8192); - if (!resp) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); - return ESP_FAIL; - } + /* ★ 修复:改用静态缓冲区,避免每次请求 malloc 8KB 加剧堆碎片化 */ + static char s_feedback_json[8192]; + char *resp = s_feedback_json; + memset(resp, 0, sizeof(s_feedback_json)); char *p = resp; 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_send(req, resp, (int)(p - resp)); - free(resp); 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; /* 状态 JSON 用静态缓冲:OTA 后 largest free 常 <2KB,calloc(8KB) 会 500 "no memory"。 */ 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 SemaphoreHandle_t s_status_json_mux = nullptr; 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; } -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(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) { 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; } +/** 将 CpuStats 1Hz 快照写入 JSON(不在 HTTP 线程采样) */ static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUsageSnapshot& cpu) { offset = append_jsonf(buf, cap, offset, @@ -1168,7 +1004,9 @@ esp_err_t system_status_handler(httpd_req_t *req) connected = true; } - // ★ PR1: 一次性快照,替代散读 OtaCoordinator / OtaRelay 多个 getter + // 先自愈孤儿忙态(如 SHA 失败后卡在 ESP32_FINALIZING),再取快照给 UI + OtaCoordinator::instance().healOrphanStates(); + // 一次性快照,替代散读 OtaCoordinator / OtaRelay 多个 getter OtaSnapshot snap = OtaCoordinator::instance().getSnapshot(); 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 = &cpu_local; 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)); if (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; 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 存活探测。 - * 从 system_status_handler 移出的副作用,改为独立端点。 * 成功时更新 OtaRelay 缓存的 alive/version 并返回版本信息。 */ esp_err_t system_probe_handler(httpd_req_t *req) diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 089749e..787568e 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -166,7 +166,7 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base, inst.setStaRetryCount(0); inst.setStaRetryExhausted(false); // ★ 连接成功,重置耗尽标志 inst.stateUnlock(); - // ★ M2: 推送 WiFi 连接状态给 HealthMonitor(解耦监控层主动查询) + // 推送 WiFi 连接状态给 HealthMonitor HealthMonitor::instance().setWifiConnected(true); break; @@ -177,7 +177,7 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base, bool exhausted = inst.getStaRetryExhausted(); int retryCount = inst.getStaRetryCount(); inst.stateUnlock(); - // ★ M2: 推送 WiFi 断开状态给 HealthMonitor + // 推送 WiFi 断开状态给 HealthMonitor HealthMonitor::instance().setWifiConnected(false); if (!exhausted) { @@ -401,15 +401,17 @@ esp_err_t WifiOta::httpServerStart() config.stack_size = HTTP_SERVER_STACK_SIZE; config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检 /* LWIP_MAX_SOCKETS=12;HTTP 内部约 3 + UDP 日志 1 → 业务 socket 不宜过大。 - * 上传期间前端必须停 status/logs(见 app.js pausePollingForUpload)。 */ - config.max_open_sockets = 4; - /* 上传中途若再开 status 连接,LRU 会踢掉 /ota/upload → 浏览器卡在 100%。 - * 关闭 LRU 踢连接:宁可新连接失败,也不拆正在写 Flash 的长传。 */ - config.lru_purge_enable = false; - /* 1MB sequential 擦写时,httpd 在 write 阶段可能 >60s 不调 recv; - * 超时后 handler 内会重试,但过短会放大“卡 100%”观感。 */ - config.recv_wait_timeout = 120; - config.send_wait_timeout = 60; + * 上传期间前端必须停 status/logs(见 app.js pausePollingForUpload)。 + * ★ 修复:增加到 7,防止浏览器超时 abort 后旧连接占满所有槽位。 */ + config.max_open_sockets = 7; + /* ★ 修复:启用 LRU 淘汰,当所有 socket 被占满时主动关闭最旧空闲连接。 + * OTA 上传期间前端已暂停轮询(pausePollingForUpload),不会踢掉 upload 连接。 + * 若仍担心极端情况,可在 ota_upload_handler 入口设置该 session 的更长超时。 */ + config.lru_purge_enable = true; + /* ★ 修复:缩短超时,加速释放废弃连接。 + * OTA 写 Flash 期间 handler 本身持有连接不会被超时踢(httpd 只在 idle 时检测)。 */ + config.recv_wait_timeout = 10; + config.send_wait_timeout = 10; DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ==="); DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port); @@ -508,7 +510,7 @@ bool WifiOta::isConnected() } // ============================================================ -// IBleCoexListener 接口实现(L1: 通过协调器接收 BLE 共存通知) +// IBleCoexListener 接口实现 // ============================================================ void WifiOta::onBleOtaStart() { @@ -603,7 +605,7 @@ bool WifiOta::earlyInit() // 创建后台重连任务:避免在系统事件任务中调用 esp_wifi_connect()/vTaskDelay 导致死锁 if (WifiOta::instance().m_reconnectTask == nullptr) { BaseType_t ret = xTaskCreate(&WifiOta::reconnectTask, "wifi_reconnect", - 4096, NULL, 5, + TASK_STACK_WIFI_RECONNECT, NULL, 5, &WifiOta::instance().m_reconnectTask); if (ret != pdPASS) { DEBUG_ERR("WIFI", "创建重连任务失败"); diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 2ae4320..568c65a 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -90,7 +90,7 @@ public: /** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */ void setBleOtaActive(bool v) { m_bleOtaActive.store(v); } - // ── IBleCoexListener 接口实现(L1: 通过协调器接收 BLE 共存通知)── + // ── IBleCoexListener 接口实现 ── /** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询。 */ void onBleOtaStart() override; /** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */ diff --git a/main/storage/gd32_flash_store.cpp b/main/storage/gd32_flash_store.cpp index 0290ff9..44eb72f 100644 --- a/main/storage/gd32_flash_store.cpp +++ b/main/storage/gd32_flash_store.cpp @@ -136,7 +136,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, return ESP_ERR_INVALID_SIZE; } - // 写入前按需擦除目标扇区(替代 prepare 阶段整段预擦) + // 正常路径:prepare 已整分区预擦;此处仅兜底(水位未覆盖时才擦) esp_err_t erase_err = ensureErasedForWrite(aligned_offset, aligned_len); if (erase_err != ESP_OK) { return erase_err; @@ -457,7 +457,7 @@ esp_err_t Gd32FlashStore::metaVerifyChecksum() // 检查 checksum 是否全零(未计算过) // 注意:返回 INVALID_STATE 而非 ESP_OK,由调用方决定是否容忍。 - // 历史上这里直接返回 OK 跳过校验,但 meta 被意外清零时会掩盖固件损坏。 + // 若直接返回 OK 跳过校验,meta 被意外清零时会掩盖固件损坏。 static const uint8_t kZeroSha[32] = {0}; if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) { DEBUG_WARN("OTA", "metaVerify: checksum 未计算,返回 INVALID_STATE(调用方决定容忍或失败)"); diff --git a/main/storage/gd32_flash_store.h b/main/storage/gd32_flash_store.h index 743ce1d..706e789 100644 --- a/main/storage/gd32_flash_store.h +++ b/main/storage/gd32_flash_store.h @@ -1,9 +1,8 @@ /* * gd32_flash_store.h - GD32 OTA 固件 Flash 存储抽象层 * - * 从 OtaRelay 中剥离的 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验职责。 - * 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储, - * 不再由 OtaRelay 承担存储职责(解决 H1 职责超载)。 + * 封装 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验。 + * 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储。 * * 线程模型: * - 通过 instance() 单例访问,NVS 元数据读写由内部 mutex 保护 @@ -72,8 +71,8 @@ public: esp_err_t erasePartition(uint32_t size); /** - * @brief 新一轮 GD32 固件写入会话开始时调用:复位按需擦除水位。 - * prepareGd32Storage 在开始收流前调用,避免整段预擦阻塞 HTTP/BLE 收包。 + * @brief 新一轮 GD32 固件写入会话开始时调用:复位擦除水位。 + * prepareGd32Storage 随后会 erasePartition(整分区)。 */ void beginWriteSession(); @@ -153,9 +152,8 @@ private: const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化) - // ── 按需擦除水位:已保证 [0, m_erasedUpTo) 为擦除态(扇区对齐)── - // 收流时 flashWriteChunk 按写入范围推进擦除,替代上传前整段预擦。 - // 这样 HTTP 主线程可边收边擦写,浏览器进度不会在“100% 发完”后空等整段擦除。 + // ── 擦除水位:prepare 整分区预擦后 m_erasedUpTo==part->size; + // flashWriteChunk 内 ensureErased 为安全兜底(水位已覆盖则立即返回)。 static constexpr uint32_t kFlashSectorSize = 4096U; uint32_t m_erasedUpTo = 0; esp_err_t ensureErasedForWrite(uint32_t offset, size_t len); diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index b096ca7..329bebe 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -1,5 +1,5 @@ /* - * canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取) + * canfd_bridge.cpp - CANFD 3通道通信桥接实现 */ #include #include "canfd_bridge.h" diff --git a/main/transport/canfd_bridge.h b/main/transport/canfd_bridge.h index 6264d96..a31a880 100644 --- a/main/transport/canfd_bridge.h +++ b/main/transport/canfd_bridge.h @@ -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 接口完成底层传输。 */ #ifndef CANFD_BRIDGE_H diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index d6221d2..d05ee6d 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -23,6 +23,7 @@ #include "spi_master.h" #include "spi_link_v2.h" +#include "app_config.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 @@ -171,7 +172,7 @@ esp_err_t SpiMaster::serviceRingOnce() m_gd32RequestPending = false; m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING); - if (waitSpiDataReady(5U) != ESP_OK) { + if (waitSpiDataReady(3U) != ESP_OK) { m_linkState.exitBusy(); return ESP_ERR_NOT_FOUND; } @@ -295,7 +296,7 @@ void SpiMaster::workerLoop() { m_workerTask = xTaskGetCurrentTaskHandle(); 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 (;;) { /* ★ 只在 ISR 置位 m_gd32RequestPending 时读 EVENT。 @@ -309,7 +310,7 @@ void SpiMaster::workerLoop() } 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); if (job->done) { xSemaphoreGive(job->done); @@ -317,9 +318,12 @@ void SpiMaster::workerLoop() continue; } - /* 无 job:等 GPIO / 提交唤醒 */ + /* 无 job:等 GPIO / 提交唤醒。 + * 超时只是无事件时的兜底回扫周期;ISR 的 xTaskNotifyFromISR 会立即唤醒 + * 本任务,响应延迟不由这里决定。过短会令 spi_worker(优先级6) 忙转压住 + * httpd(优先级5),导致 /system/status、/logs 响应不来 → 网页收不到消息。 */ 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) { m_gd32RequestPending = false; } 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; } /* PB6 低:可能有未读响应。短等升高;BOOT 修好交付清 pending 后应很快。 - * L3:超时返回 ESP_ERR_TIMEOUT,禁止调用方假装“已就绪”后盲发。 */ + * 超时返回 ESP_ERR_TIMEOUT,禁止调用方假装“已就绪”后盲发。 */ const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); while (xTaskGetTickCount() < deadline) { if (readReadyLevelRaw() == 1) { return ESP_OK; } @@ -800,7 +804,7 @@ esp_err_t SpiMaster::init() m_stats = SpiLinkStats{}; m_eventHead = m_eventTail = m_eventCount = 0; - /* L4:job 队列 + 事件信号量 + SPI worker。 + /* job 队列 + 事件信号量 + SPI worker。 * worker 启动后可能立刻抢 m_mutex;init 持锁时 worker 会阻塞在 LockGuard, * init 返回释放锁后 worker 继续 —— 不会死锁。 */ if (m_jobQueue == nullptr) { @@ -818,13 +822,13 @@ esp_err_t SpiMaster::init() } } 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) { DEBUG_ERR("SPI", "SPI worker task create failed"); m_workerTask = nullptr; return ESP_ERR_NO_MEM; } - DEBUG_INFO("SPI", "SPI worker task created (L4)"); + DEBUG_INFO("SPI", "SPI worker task created"); } 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; } - /* L5:remoteCredit==0 时禁止发新 REQUEST;等 CREDIT_UPDATE 或 poll 读到新 credit。 + /* CREDIT 背压(包间节奏): + * remoteCredit==0 禁止发新 REQUEST;等 CREDIT_UPDATE / 帧头 credit。 * HELLO / 链路控制不受 credit 限制(session 建立前 credit 可能为 0)。 */ if (type != V2_TYPE_LINK_HELLO && m_remoteCredit == 0U) { const TickType_t cred_deadline = xTaskGetTickCount() + pdMS_TO_TICKS( timeout_ms > 0U ? timeout_ms : 500U); while (m_remoteCredit == 0U && xTaskGetTickCount() < cred_deadline) { /* 有 DATA_READY 时变长读一帧(可能是 CREDIT_UPDATE 或陈旧响应) */ - if (waitSpiDataReady(20U) == ESP_OK) { + if (waitSpiDataReady(5U) == ESP_OK) { uint8_t drain[SPI_OTA_FRAME_MAX]; size_t drain_len = 0U; 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; } - /* 等 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; vTaskDelay(gap); @@ -1334,13 +1339,13 @@ esp_err_t SpiMaster::appRequestBootloader() /* 先变长读最多 2 次,吞掉残留 0xC1(仅 DATA_READY 低时读,禁止固定长度盲读)。 */ for (uint32_t d = 0U; d < 2U; ++d) { - if (waitSpiDataReady(10U) != ESP_OK) { + if (waitSpiDataReady(5U) != ESP_OK) { break; } uint8_t rx_raw[SPI_OTA_FRAME_MAX]; size_t rx_len = 0U; 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(); 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) { if (retry > 0U) { - vTaskDelay(pdMS_TO_TICKS(50)); + vTaskDelay(pdMS_TO_TICKS(2)); /* 序号/会话错乱时重同步一次再发 0xB0 */ if (retry == 1U) { m_v2Active = false; @@ -1412,11 +1417,11 @@ esp_err_t SpiMaster::appRequestBootloader() while (xTaskGetTickCount() <= deadline) { ++poll_i; 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]; 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(); if (ret != ESP_OK) { vTaskDelay(gap); @@ -1470,7 +1475,7 @@ esp_err_t SpiMaster::appRequestBootloader() if (command_sent) { /* 静默:APP 擦配置页(可达数十 ms) + 软复位 + BOOT 起机。 * 此间禁止 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 复位...", got_ack ? "yes" : "no", (unsigned long)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。 * 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; } -// CANFD 接口已迁移至 transport/canfd_bridge.cpp(M4 解耦) - diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 4c160e8..f677bb1 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -41,8 +41,6 @@ #define SPI_OTA_RSP_WAIT (0x81) #define SPI_OTA_RSP_ERROR (0x82) -// CANFD 类型已迁移至 transport/canfd_bridge.h(M4 解耦) - // GD32 版本信息(从 APP PING 响应解析) typedef struct { uint16_t app_major; @@ -121,14 +119,14 @@ public: 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, uint8_t *resp_type, uint8_t *resp_payload, 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 = 非阻塞(原语义)。 * @return true 取到事件;false 队列空且超时未到达。 */ @@ -196,8 +194,6 @@ public: /** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */ esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr); - // ── CANFD 接口已迁移至 transport/canfd_bridge.h(M4 解耦)── - private: /** * @brief 私有默认构造函数。 @@ -271,7 +267,7 @@ private: /** 事件计数信号量(worker push / takeEvent pop)。 */ SemaphoreHandle_t m_eventSem = NULL; - // ── L4 多槽事件队列(worker 写入,业务 takeEvent 读出)── + // ── 多槽事件队列(worker 写入,业务 takeEvent 读出)── static constexpr uint8_t kEventSlots = 4U; struct EventSlot { uint8_t type; @@ -283,7 +279,7 @@ private: uint8_t m_eventTail = 0; uint8_t m_eventCount = 0; - // ── L4 作业提交(调用方栈上 Job + StaticSemaphore,worker 执行)── + // ── 作业提交(调用方栈上 Job + StaticSemaphore,worker 执行)── enum class JobKind : uint8_t { Transact = 0, Poll, @@ -390,7 +386,7 @@ private: 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 已低(有数据)时调用,禁止盲读。 * @param out_frame 完整帧输出(含头+payload+CRC) * @param out_max 输出缓冲容量 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index c47100f..b581ece 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -4,11 +4,19 @@ CONFIG_ESP_WIFI_TX_BA_WIN=6 CONFIG_DCU_OTA_POLL_BACKOFF_MS=5 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 须零错误验证后再开) CONFIG_DCU_SPI_CLOCK_HZ=4000000 -# DCU OTA packet interval -- 给 GD32 主循环 AES/Flash drain 留时间(原 1ms 易在中后期卡死) -CONFIG_DCU_OTA_PACKET_INTERVAL_MS=3 +# GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压 +# (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发) # LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 + UDP debug log + 余量 # 原值 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_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)── # 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB CONFIG_BT_NIMBLE_PROX_SERVICE=n diff --git a/tools/index.html b/tools/index.html index dab2dd9..2f5341f 100644 --- a/tools/index.html +++ b/tools/index.html @@ -431,8 +431,8 @@ 操作成功 - - - + + + diff --git a/tools/js/app.js b/tools/js/app.js index bb6edf6..c5e70fa 100644 --- a/tools/js/app.js +++ b/tools/js/app.js @@ -12,11 +12,11 @@ import { showToast } from './common/toast.js'; import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js'; import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, - testConnection, refreshSystemStatusNow + testConnection, refreshSystemStatusNow, diagnoseNetwork } from './common/connection.js'; import { scheduleStatusPoll, startPolling, pausePollingForUpload, - resumePollingAfterUpload, pollStatus, pollLogs + resumePollingAfterUpload, pollStatus, pollLogs, setStatusPollIntervalMs } from './common/polling.js'; import { @@ -40,9 +40,12 @@ import { } from './ota/file-handle.js'; import { sendSpiFrame, clearSpiFields, updateSpiHint } from './ota/spi.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) { if (!Number.isFinite(value)) return '--'; return value.toFixed(1).replace(/\.0$/, '') + '%'; @@ -178,6 +181,8 @@ window.triggerGd32Relay = triggerGd32Relay; window.markGd32OtaUiComplete = markGd32OtaUiComplete; window.stopRelayProgressWatch = stopRelayProgressWatch; window.startRelayProgressWatch = startRelayProgressWatch; +window.emitRelayProgress = emitRelayProgress; +window.ensureRelayProgressComplete = ensureRelayProgressComplete; // ==================== 挂载 common/* 函数到 window(供 polling.js 等通过 window.* 调用) ==================== window.formatBytes = formatBytes; @@ -196,11 +201,13 @@ window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi; window.testConnection = testConnection; window.refreshSystemStatusNow = refreshSystemStatusNow; window.scheduleStatusPoll = scheduleStatusPoll; +window.setStatusPollIntervalMs = setStatusPollIntervalMs; window.startPolling = startPolling; window.pausePollingForUpload = pausePollingForUpload; window.resumePollingAfterUpload = resumePollingAfterUpload; window.pollStatus = pollStatus; window.pollLogs = pollLogs; +window.diagnoseNetwork = diagnoseNetwork; // ==================== 挂载本入口的辅助函数到 window ==================== window.getEspIp = getEspIp; diff --git a/tools/js/ble/constants.js b/tools/js/ble/constants.js index dac2bf8..9f18d64 100644 --- a/tools/js/ble/constants.js +++ b/tools/js/ble/constants.js @@ -10,7 +10,7 @@ export const UUIDS = { version: 0x0004, }; -export const UI_BUILD = '2026-07-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_FINISH = 0xB1; export const BLE_OTA_NOTIFY_SHA256 = 0xC1; diff --git a/tools/js/ble/transfer.js b/tools/js/ble/transfer.js index 332e169..52b2d36 100644 --- a/tools/js/ble/transfer.js +++ b/tools/js/ble/transfer.js @@ -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}`); 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) { if (state.abortRequested) throw new Error('用户中止'); @@ -197,37 +200,50 @@ export async function uploadFirmware() { // Windows Web Bluetooth 同一时刻只允许一个 GATT 操作。逐包等待仅保证 // 提交顺序;实际吞吐由设备请求的高速 BLE 连接参数决定。 // ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动 + // + // ★ 首窗特殊流控:seq=0 触发设备 begin+整分区预擦(worker 线程,可阻塞很久)。 + // 旧逻辑 sleep(200) 后立刻 withoutResponse 连发 → 队列满丢包 → 整窗重传,体感极慢。 + // 现改为:seq=0 送达后等设备「首包落盘 ACK」再发 1..endSeq。 for (let seq = windowStart; seq <= endSeq; seq++) { - // ★ 中止检查放在循环内:abortTask 可能在本窗口发送过程中触发 - // (windowStart 处的检查无法覆盖窗口中途的 abort) if (state.abortRequested) throw new Error('用户中止'); const start = seq * payloadSize; const end = Math.min(start + payloadSize, totalSize); const payload = fileBytes.slice(start, end); const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd); - // ★ seq=0 必须用 writeValueWithResponse(带响应): - // seq=0 触发设备端 esp_ota_begin()(flash 擦除 + OTA 会话创建), - // 若用 writeValueWithoutResponse 在 BLE 链路层丢失, - // 设备永远不会启动 OTA 会话,后续所有包都被丢弃。 if (seq === 0) { try { await state.dataInChar.writeValueWithResponse(packet); - bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达', 'I'); + bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'I'); + setBleStageText('设备预擦 Flash 中,请等待…'); } catch (e) { bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E'); throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message); } - // seq=0 写入后等待设备完成 esp_ota_begin()(flash 擦除较耗时) - await sleep(200); - } else { - await state.dataInChar.writeValueWithoutResponse(packet); + state.ackGeneration++; + const firstBytes = Math.min(payloadSize, totalSize); + try { + 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++; try { - await waitForAck(cfg.packetCmd, endSeq, 5000, { expectedTotalSize: totalSize, payloadSize }); + await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize }); } catch (e) { bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W'); } @@ -270,15 +286,23 @@ export async function uploadFirmware() { const end = Math.min(start + payloadSize, totalSize); const payload = fileBytes.slice(start, end); const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd); - // ★ 重试路径中 seq=0 也必须用 writeValueWithResponse + // ★ 重试路径中 seq=0 也必须用 writeValueWithResponse,并等 begin 就绪 ACK if (seq === 0) { try { 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) { bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E'); } - await sleep(200); } else { await state.dataInChar.writeValueWithoutResponse(packet); } @@ -286,7 +310,7 @@ export async function uploadFirmware() { state.ackGeneration++; try { - await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize }); + await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize }); } catch (e) { bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W'); } diff --git a/tools/js/can.js b/tools/js/can.js index 2e9b513..e6df236 100644 --- a/tools/js/can.js +++ b/tools/js/can.js @@ -1,7 +1,7 @@ /** * can.js - CAN 工具页面入口(ESM 组装层) * - * 本文件对应原 tools/can.js 中未拆分到子模块的编排逻辑: + * CAN 页面的编排逻辑: * - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象) * - setConnectionState:CAN 页面专用连接状态更新(canConnPill 等元素) * - testConnection:CAN 页面专用连接检测(GET /system/status,操作 el.connectionNote 等) diff --git a/tools/js/can/dbc.js b/tools/js/can/dbc.js index 52eb7a3..39281a2 100644 --- a/tools/js/can/dbc.js +++ b/tools/js/can/dbc.js @@ -1,8 +1,7 @@ /** * dbc.js - DBC 解析器 + 共享状态/元素缓存/基础工具 * - * 从 tools/can.js 拆分。对应原 can.js 约 4~21 行(state/el)、78~104 行(基础工具)、 - * 178~335 行(DBC 解析)、830~833 行(cssEscape)、1335~1352 行(readDbcText)。 + * 包含:共享 state/el、基础工具、DBC 解析、cssEscape、readDbcText。 * * 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出, * 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。 diff --git a/tools/js/can/feedback.js b/tools/js/can/feedback.js index 86d456d..ede4f76 100644 --- a/tools/js/can/feedback.js +++ b/tools/js/can/feedback.js @@ -1,17 +1,8 @@ /** * feedback.js - 1Hz 反馈轮询(预留模块) * - * ⚠️ 说明:原 tools/can.js(1526 行)中不存在 1Hz 反馈轮询 / ID 快照表渲染相关代码。 - * 这些功能实际位于 tools/can_monitor.js(CAN 监控页面),操作的是 can_monitor.html - * 的 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 快照功能,可在此模块实现。 + * 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。 + * 1Hz 轮询 /can/feedback 或 ID 快照功能可在本模块按需实现。 */ import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js'; diff --git a/tools/js/can/messages.js b/tools/js/can/messages.js index 72c3862..b43f033 100644 --- a/tools/js/can/messages.js +++ b/tools/js/can/messages.js @@ -1,8 +1,7 @@ /** * messages.js - 报文列表管理(渲染、过滤、节点过滤、批量 UI) * - * 从 tools/can.js 拆分。对应原 can.js 约 337~686 行(DBC 文件信息、报文过滤、 - * 节点过滤面板、报文列表渲染、报文选择/展开、批量 UI)。 + * 包含:DBC 文件信息、报文过滤、节点过滤面板、报文列表渲染、报文选择/展开、批量 UI。 * * 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、 * common/utils.js(escapeHtml/formatBytes)、 diff --git a/tools/js/can/send.js b/tools/js/can/send.js index e690b8f..d877f40 100644 --- a/tools/js/can/send.js +++ b/tools/js/can/send.js @@ -1,17 +1,16 @@ /** * send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史) * - * 从 tools/can.js 拆分。对应原 can.js 约 1030~1333 行(sendCanRequest、响应框、 - * 历史、批量/选中/手动发送、手动帧校验/重置/载入预览)及 1491~1523 行 - * (CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText)。 + * 包含:sendCanRequest、响应框、历史、批量/选中/手动发送、 + * 手动帧校验/重置/载入预览、CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText。 * * 依赖:dbc.js(state/el/getBaseUrl/padHex/bytesToHex/parseNumericId)、 * common/toast.js(showToast)、common/utils.js(escapeHtml)、 * messages.js(updateBatchUi)、signals.js(ensureMessageDefaults/getSignalValueMap/ * getSelectedMessage/updateEncodedPreview/encodeMessageData)。 * - * 注意:formatTime 保留 can.js 本地实现(HH:MM:SS,不含毫秒),与 common/utils.js - * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出与原版完全一致。 + * 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js + * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。 */ import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js'; import { escapeHtml as sanitizeHtml } from '../common/utils.js'; diff --git a/tools/js/can/signals.js b/tools/js/can/signals.js index d85980e..ef3c3da 100644 --- a/tools/js/can/signals.js +++ b/tools/js/can/signals.js @@ -1,8 +1,8 @@ /** * signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical) * - * 从 tools/can.js 拆分。对应原 can.js 约 629~1028 行(信号行渲染、信号值存取、 - * 编码写入、浮点/整数/Motorola/Intel 位操作)及 761 行的 renderSelectedMessage shim。 + * 包含:信号行渲染、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作、 + * renderSelectedMessage shim。 * * 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。 */ diff --git a/tools/js/common/connection.js b/tools/js/common/connection.js index 3db3740..411d070 100644 --- a/tools/js/common/connection.js +++ b/tools/js/common/connection.js @@ -1,17 +1,16 @@ /** * connection.js - 连接管理 + 系统状态显示 * - * 从 app.js 的「连接管理」段 + testConnection / refreshSystemStatusNow 提取。 * 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。 * - * 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载): + * 共享状态(通过 window.* 引用): * window.latestSystemStatus — 最近一次 /system/status 响应 * window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取) * window.lastRelayState — 上次成功的 relay_state(polling.js 读取) * window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取) * - * 未拆分的 app.js 函数(通过 window.* 引用): - * window.getBaseUrl / window.updateSystemStatusUi / window.diagnoseNetwork / window.formatPercent + * 复用的函数(通过 window.* 引用): + * window.getBaseUrl / window.updateSystemStatusUi / window.formatPercent */ import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.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'); } } +/** + * 每次 pollStatus 成功后刷新堆/栈/CPU(固定周期约 1000ms,中继期 400ms; + * sample_ms 由设备两次采样墙钟差给出;WiFi 上传 ESP32 期间 pause 不刷)。 + */ export function updateHeapAndTaskStatusUi(data) { if (!data) return; var ota = data.ota || {}; - var wifi = data.wifi || {}; - var targetHost = window.getBaseUrl().replace(/^http:\/\//, ''); var setText = function (id, value) { var el = document.getElementById(id); if (el) el.textContent = value; }; + // ---- 堆概况:已用 / 总计 + 占用条 ---- function setFill(id, pct) { var el = document.getElementById(id); @@ -85,10 +87,10 @@ export function updateHeapAndTaskStatusUi(data) { el.style.width = safePct + '%'; el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''); } - function updateCpuUsageUi(cpu) { - cpu = cpu || {}; + function updateCpuUsageUi(cpuData) { + cpuData = cpuData || {}; var taskList = document.getElementById('cpuTaskList'); - if (!cpu.available) { + if (!cpuData.available) { setText('cpuUsageText', '未启用 FreeRTOS 统计'); setText('cpuUsagePct', '--'); setText('cpuIdlePct', '--'); @@ -98,7 +100,7 @@ export function updateHeapAndTaskStatusUi(data) { if (taskList) taskList.innerHTML = '
固件未启用 CPU runtime stats
'; return; } - if (!cpu.valid) { + if (!cpuData.valid) { setText('cpuUsageText', '等待下一次采样...'); setText('cpuUsagePct', '--'); setText('cpuIdlePct', '--'); @@ -108,10 +110,10 @@ export function updateHeapAndTaskStatusUi(data) { if (taskList) taskList.innerHTML = '
首次刷新只建立基线,下一次刷新后显示 CPU 占用
'; return; } - var usagePct = Number(cpu.usage_pct) || 0; - var idlePct = Number(cpu.idle_pct) || 0; - var sampleMs = Number(cpu.sample_ms) || 0; - var tasks = Array.isArray(cpu.tasks) ? cpu.tasks.slice() : []; + var usagePct = Number(cpuData.usage_pct) || 0; + var idlePct = Number(cpuData.idle_pct) || 0; + var sampleMs = Number(cpuData.sample_ms) || 0; + var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : []; tasks.sort(function (a, b) { 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('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.relay_state)) window.lastRelayState = ota.relay_state; var storedText = '--'; @@ -205,6 +206,45 @@ export function updateHeapAndTaskStatusUi(data) { 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)。 */ export async function testConnection() { // 立即发一次请求检测 @@ -232,7 +272,7 @@ export async function testConnection() { } catch (e) { addLog('E', '连接', '连接失败: ' + e.message); // 连接失败时自动诊断 - var diag = await window.diagnoseNetwork(); + var diag = await diagnoseNetwork(); addLog('W', '诊断', diag); showToast('无法连接: ' + diag, 'error'); } finally { diff --git a/tools/js/common/log.js b/tools/js/common/log.js index 078b0d6..d82be06 100644 --- a/tools/js/common/log.js +++ b/tools/js/common/log.js @@ -1,7 +1,7 @@ /** * log.js - 日志系统(带去重 + 自动滚动) * - * 从 app.js 提取的日志环形缓冲、去重、过滤、自动滚动逻辑。 + * 日志环形缓冲、去重、过滤、自动滚动逻辑。 * 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。 */ import { formatTime } from './utils.js'; diff --git a/tools/js/common/polling.js b/tools/js/common/polling.js index f99f7c4..6511ce6 100644 --- a/tools/js/common/polling.js +++ b/tools/js/common/polling.js @@ -1,14 +1,13 @@ /** * polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取 * - * 从 app.js 的「轮询状态」「Page Visibility 补偿」「ESP32 实时日志拉取 (增量)」段提取。 * 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。 * * 模块级状态(仅本模块使用,不导出): * statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs - * lastDiagTime / diagResult / uploadPollChip / lastLogTimestamp / pageVisible + * uploadPollChip / lastLogTimestamp / pageVisible * - * 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载): + * 共享状态(通过 window.* 引用): * window.pollingPausedForUpload — 上传期间暂停轮询标志 * window.consecutivePollFailures — 连续轮询失败计数(connection.js 也写入) * window.lastSysStateCode — 上次成功的 sys_state_code(connection.js 写入) @@ -21,7 +20,7 @@ * window.activeUploadChip — 当前上传目标芯片 * window.currentOtaTarget — 当前 OTA 目标 * - * 未拆分的 app.js 函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.getEspIp / window.getBaseUrl * window.updateSystemStatusUi / window.updateEsp32StagesFromPhase * window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState @@ -30,7 +29,7 @@ */ import { addLog } from './log.js'; import { showToast } from './toast.js'; -import { updateConnectionUi } from './connection.js'; +import { updateConnectionUi, diagnoseNetwork } from './connection.js'; // ==================== 轮询状态 ==================== // 共享状态(初始化到 window,供 connection.js 等其他模块读写) @@ -41,66 +40,32 @@ window.lastRelayState = 0; // 上次成功的 relay_state // 模块级状态(仅本模块使用) var statusTimerId = null; var logsTimerId = null; -var lastDiagTime = 0; // 上次诊断时间 -var diagResult = ''; // 最近一次诊断结果 var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用) -var statusPollIntervalMs = 1000; // 状态轮询目标间隔 +var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms) var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠 var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 -// 从设备 IP 推导网关 IP(取前三段 + .1) -function getGatewayIp() { - var parts = window.getEspIp().split('.'); - if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1'; - return '192.168.31.1'; -} -// 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通 -async function diagnoseNetwork() { - lastDiagTime = Date.now(); - 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; +/** + * 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后; + * 结束后恢复 1000ms,避免常态堆/栈卡片过刷。 + */ +export function setStatusPollIntervalMs(ms) { + var n = Number(ms); + if (!Number.isFinite(n) || n < 200) n = 1000; + if (n > 5000) n = 5000; + statusPollIntervalMs = n; } + /** @description 调度下一次 /system/status 轮询(递归 setTimeout)。 - * 上一次请求完成(或 3s 超时)后才调度下一次,避免 setInterval 把慢响应 - * 直接 abort 而导致堆/任务栈/CPU 等卡片刷新率不稳。 + * 默认 delay = statusPollIntervalMs;pollStatus 完成后会按「固定周期」 + * 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval, + * 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。 + * 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。 */ export function scheduleStatusPoll(delayMs) { if (statusTimerId) clearTimeout(statusTimerId); - // 中继期加快:进度条依赖 status.relay_seq,1s 间隔体感像卡死 var interval = statusPollIntervalMs; - // 防御性守卫:window.OtaSessionState 可能尚未挂载(模块加载早期),此时不加速 - if (window.OtaSessionState && window.otaSessionState === window.OtaSessionState.RELAYING) { - interval = Math.min(interval, 400); - } statusTimerId = setTimeout(function() { statusTimerId = null; pollStatus(); @@ -161,8 +126,9 @@ export async function pollStatus() { // 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求 if (statusPollInFlight) return; statusPollInFlight = true; + var pollStartedAt = Date.now(); statusAbortCtrl = new AbortController(); - var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 3000); + var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000); try { var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: statusAbortCtrl.signal @@ -287,7 +253,13 @@ export async function pollStatus() { } } finally { 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)在所有模块加载完成后调用, @@ -316,7 +288,7 @@ export async function pollLogs() { // ★ ESP32 上传期间也允许日志轮询 try { var resp = await fetch(window.getBaseUrl() + '/logs', { - signal: AbortSignal.timeout(2000) + signal: AbortSignal.timeout(5000) }); if (!resp.ok) return; var logs = await resp.json(); @@ -338,6 +310,18 @@ export async function pollLogs() { showToast('ESP32 已恢复在线', 'success'); 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 { addLog('I', 'ESP32', line.trimEnd()); } diff --git a/tools/js/common/toast.js b/tools/js/common/toast.js index 59b7ab4..c1180c5 100644 --- a/tools/js/common/toast.js +++ b/tools/js/common/toast.js @@ -1,7 +1,7 @@ /** * toast.js - Toast 提示组件 * - * 从 app.js 提取的 showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。 + * showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。 * 支持 info / success / warning / error 类型,内置 5s 去重防抖。 */ /** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */ diff --git a/tools/js/common/utils.js b/tools/js/common/utils.js index 90a3279..6513ee3 100644 --- a/tools/js/common/utils.js +++ b/tools/js/common/utils.js @@ -1,7 +1,7 @@ /** * utils.js - 通用工具函数 * - * 从 app.js 提取的格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。 + * 格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。 */ // ==================== 工具函数 ==================== /** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */ diff --git a/tools/js/ota/file-handle.js b/tools/js/ota/file-handle.js index 9bbafca..22a0eb6 100644 --- a/tools/js/ota/file-handle.js +++ b/tools/js/ota/file-handle.js @@ -1,14 +1,13 @@ /** * file-handle.js - 文件处理 + Intel HEX → BIN 转换 * - * 从 app.js 的「文件处理」「Intel HEX → BIN 转换」「文件处理」段提取。 * 包含:HEX 解析、文件选择、FSAPI、SHA-256 计算、文件信息 UI、清除。 * * 共享状态(通过 window.* 引用): * window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle * window._esp32FileSha256 / window._gd32FileSha256 * - * 未拆分的函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.updateUploadButtonLabel / window.showToast / window.addLog */ 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 }; } +// ==================== 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} 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} 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 计算、文件校验)。 */ export function processFile(file, chip) { @@ -196,6 +262,7 @@ export function processFile(file, chip) { document.getElementById('fileType' + suffix).textContent = 'Binary(?)'; document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); window.updateUploadButtonLabel(); + ensureFileShaCached(file, 'gd32'); return; } window.gd32File = result.file; @@ -208,6 +275,7 @@ export function processFile(file, chip) { document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size); window.updateUploadButtonLabel(); + ensureFileShaCached(result.file, 'gd32'); }).catch(function(err) { addLog('E', '文件', 'HEX 解析失败: ' + err.message); showToast('HEX 文件解析失败: ' + err.message, 'error'); @@ -228,32 +296,21 @@ export function processFile(file, chip) { window.updateUploadButtonLabel(); addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); showToast(chip.toUpperCase() + ' 文件已选择', 'success'); - // ESP32 文件: 计算并显示 SHA-256 - if (chip === 'esp32') { - var shaElem = document.getElementById('fileSha256' + suffix); - 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; - }); - } - } + // 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算), + // 上传时若文件未变 ensureFileShaCached 命中直接复用。 + ensureFileShaCached(file, chip); } /** @description 清除已选固件文件并重置对应 UI 状态。 */ export function clearFile(chip) { var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; - if (chip === 'esp32') { window.esp32File = null; window.esp32FileHandle = null; window._esp32FileSha256 = null; } - else { window.gd32File = null; window.gd32FileHandle = null; } + if (chip === 'esp32') { + 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('fileInfo' + suffix).classList.remove('show'); var shaElem = document.getElementById('fileSha256' + suffix); diff --git a/tools/js/ota/progress.js b/tools/js/ota/progress.js index a5c013b..179b872 100644 --- a/tools/js/ota/progress.js +++ b/tools/js/ota/progress.js @@ -1,13 +1,12 @@ /** * progress.js - 折叠面板 + 刷写进度 + 阶段节点 UI * - * 从 app.js 的「折叠面板」「刷写进度」段提取。 * 操作 DOM: #esp32Stages / #gd32Stages / #stageStatusBar / #progressFill / #progressTxt / #progressPct * * 共享状态(通过 window.* 引用): * window.activePhase / window.currentOtaTarget / window.esp32OtaPhase * - * 未拆分的函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.updateStagesFromRelayState / window.cleanupAfterOtaFailure * window.resumePollingAfterUpload / window.addLog */ diff --git a/tools/js/ota/session.js b/tools/js/ota/session.js index c2cd665..c3b61b2 100644 --- a/tools/js/ota/session.js +++ b/tools/js/ota/session.js @@ -1,8 +1,7 @@ /** * session.js - 前端 OTA 会话状态机 * - * 从 app.js 的「全局状态」+「前端 OTA 会话状态机」段提取。 - * 统一管理一次 OTA 会话的完整生命周期,消除散布的布尔标志 + * 统一管理一次 OTA 会话的完整生命周期,避免散布的布尔标志 * (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting) * 在网络错误后被轮询错误覆盖、状态混乱的问题。 * @@ -20,7 +19,7 @@ * window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive * window.lastSysStateCode / window.lastRelayState * - * 未拆分的函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.stopRelayProgressWatch / window.setProgress / window.addLog */ import { addLog } from '../common/log.js'; diff --git a/tools/js/ota/spi.js b/tools/js/ota/spi.js index e2f2437..5e2e38f 100644 --- a/tools/js/ota/spi.js +++ b/tools/js/ota/spi.js @@ -1,10 +1,9 @@ /** * spi.js - SPI 帧构建与发送 * - * 从 app.js 的「SPI 发送」段提取。 * 操作 DOM: #spiType / #spiData / #spiTimeout / #spiHint / #spiResp / #spiRespText * - * 未拆分的函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.getBaseUrl / window.showToast / window.addLog */ import { showToast } from '../common/toast.js'; diff --git a/tools/js/ota/stages.js b/tools/js/ota/stages.js index 7fe5c29..9c40e35 100644 --- a/tools/js/ota/stages.js +++ b/tools/js/ota/stages.js @@ -1,7 +1,6 @@ /** * stages.js - OTA 阶段驱动 (真实状态) * - * 从 app.js 的「OTA 阶段驱动 (真实状态)」段提取。 * 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI, * 并驱动 updateSystemStatusUi 综合 UI 更新。 * @@ -11,7 +10,7 @@ * window.gd32CompleteAnnounced / window.gd32RelaySessionLive * window.lastSysStateCode / window.lastRelayState * - * 未拆分的函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.updateHeapAndTaskStatusUi / window.updateConnectionUi / window.updateHealthUi * window.updateStagesFromRelayState / window.setActivePhase / window.setProgress * window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset @@ -234,6 +233,16 @@ export function updateSystemStatusUi(data) { if (isGd32Relaying || (rs >= 1 && rs <= 4)) { 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 && (isGd32Complete || rs === 5 || (gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) { @@ -244,8 +253,14 @@ export function updateSystemStatusUi(data) { // HANDSHAKING setActivePhase('gd32Ota'); setProgress(50, '握手与认证中...'); - } else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5)) { - // TRANSFERRING + } else if (rs === 4) { + // 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'); var relayOverall = 60 + Math.round(relayProgress * 0.30); var relayLabel = relayTotal > 0 @@ -258,11 +273,6 @@ export function updateSystemStatusUi(data) { setProgress(55, relayTotal > 0 ? ('大小协商中(共 ' + relayTotal + ' 包)') : '固件大小协商中'); - } else if (rs === 4) { - setActivePhase('gd32Ota'); - if (!window.gd32CompleteAnnounced) { - setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...'); - } } else if (!window.gd32RelaySessionLive) { setActivePhase('gd32Ota'); setProgress(48, '已触发中继,等待设备进入握手…'); diff --git a/tools/js/ota/upload-esp32.js b/tools/js/ota/upload-esp32.js index bc65c3a..8006c67 100644 --- a/tools/js/ota/upload-esp32.js +++ b/tools/js/ota/upload-esp32.js @@ -1,7 +1,6 @@ /** * upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级 * - * 从 app.js 的「上传固件」「ESP32 OTA 完成升级」段提取。 * 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、 * XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。 * @@ -10,7 +9,7 @@ * window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr * window.isUploading / window.uploadBtnOrigText / window.activeUploadChip * - * 未拆分的函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset * window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget * window.cleanupAfterOtaFailure / window.pausePollingForUpload @@ -31,6 +30,7 @@ import { startUploadWaitHeartbeat } from './session.js'; import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js'; +import { ensureFileShaCached } from './file-handle.js'; function isOtaBusyError(status, detail) { var text = (detail || '').toLowerCase(); @@ -104,9 +104,13 @@ export async function startUpload(chip) { var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI'); // ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出, // 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。 - // ERROR(8) 也放行给后端清理底层 OTA 句柄;FINALIZING(6)/REBOOTING(7) 是不可逆忙态。 - if (ssc === 5 && otaChannel === 1 && _pi < 3) continue; - var esp32Busy = (ssc === 6 || ssc === 7 || (ssc === 5 && otaChannel !== 0)); + // ERROR(8) 放行给后端清理;REBOOTING(7) 不可逆。 + // FINALIZING(6):真收尾时后端会 403;SHA 失败后可能假占用(core 已空), + // 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) 不可中断。 var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0)); if ((esp32Busy || gd32Busy)) { @@ -183,31 +187,10 @@ export async function startUpload(chip) { showToast('开始上传固件到 ' + chip.toUpperCase(), 'success'); try { var arrayBuffer = await file.arrayBuffer(); - // 刷写时重新计算 SHA-256(ESP32 和 GD32 都算) - if (chip === 'esp32') { - 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._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; - } - } + // SHA-256:processFile 阶段已算好并缓存;这里若 handle.getFile() 拿回的 + // fresh file meta 未变就直接复用(命中 30–100ms/MB 的计算); + // 用户 rebuild 使 lastModified 变化时 ensureFileShaCached 自动 fallback 到重算。 + await ensureFileShaCached(file, chip, arrayBuffer); var fileSize = file.size; var body, url = window.getBaseUrl() + '/ota/upload'; if (chip === 'esp32') { diff --git a/tools/js/ota/upload-gd32.js b/tools/js/ota/upload-gd32.js index 92311fb..825b564 100644 --- a/tools/js/ota/upload-gd32.js +++ b/tools/js/ota/upload-gd32.js @@ -1,7 +1,6 @@ /** * upload-gd32.js - GD32 中继 OTA 触发 + relay 态跟踪 * - * 从 app.js 的「GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)」段提取。 * 包含:relay 进度观察定时器、markGd32OtaUiComplete、triggerGd32Relay。 * * 共享状态(通过 window.* 引用): @@ -9,7 +8,7 @@ * window.pollingPausedForUpload / window.statusPollIntervalMs * window.otaSessionState / window.lastSysStateCode / window.lastRelayState * - * 未拆分的函数(通过 window.* 引用): + * 复用的函数(通过 window.* 引用): * window.otaSessionTransition / window.OtaSessionState / window.otaSessionActive * window.markPhaseDone / window.setProgress / window.updateSystemStatusUi * window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs @@ -26,16 +25,61 @@ import { markPhaseDone, setProgress } from './progress.js'; * GD32 中继进度观察:以 /system/status 为唯一真相源。 * 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间—— * 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。 + * + * 时序约定(避免「设备已 FINISH / 完成,UI 还在 94%」): + * 1) 包进度变化先刷进度条 + 日志; + * 2) 一旦 rseq>=rtot 或 rs>=4(FINISHING),先强制打出 100% 再进收尾/完成; + * 3) markGd32OtaUiComplete 前再兜底一次 100%。 */ let relayWatchTimerId = null; -let relayWatchInFlight = false; window.gd32CompleteAnnounced = false; window.relayWatchLastSeq = -1; /** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */ 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) { - // 已公告过:禁止再改 className / setProgress / toast,否则每 400ms 重触发动效会「疯狂跳 ✓」 + // 已公告过:禁止再改 className / setProgress / toast,否则定时器重触发动效会「疯狂跳 ✓」 if (window.gd32CompleteAnnounced) { return; } @@ -44,6 +88,8 @@ export function markGd32OtaUiComplete(msg) { window.otaSessionState !== OtaSessionState.FAILED) { return; } + /* 先 100% 转发进度,再宣布刷写完成 —— 避免日志里「完成」早于「1089/1089」 */ + ensureRelayProgressComplete(); window.gd32CompleteAnnounced = true; stopRelayProgressWatch(); @@ -67,18 +113,25 @@ export function stopRelayProgressWatch() { clearInterval(relayWatchTimerId); relayWatchTimerId = null; } - relayWatchInFlight = false; - if (window.statusPollIntervalMs < 1000) { - window.statusPollIntervalMs = 1000; + /* 中继结束:恢复常态 1s status 轮询 */ + if (typeof window.setStatusPollIntervalMs === 'function') { + window.setStatusPollIntervalMs(1000); } } +/** + * 中继进度观察:只读 pollStatus 写入的 latestSystemStatus,不再另起 /system/status。 + * 中继期 status 加速到 400ms,使「转发 N/M」更贴近设备真实进度。 + */ export function startRelayProgressWatch() { stopRelayProgressWatch(); if (window.pollingPausedForUpload) { window.resumePollingAfterUpload(); } - window.statusPollIntervalMs = 400; + /* 中继期加速 status,缩小 UI 进度相对设备日志的滞后 */ + if (typeof window.setStatusPollIntervalMs === 'function') { + window.setStatusPollIntervalMs(400); + } window.scheduleStatusPoll(0); window.pollLogs(); addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志'); @@ -92,46 +145,38 @@ export function startRelayProgressWatch() { } return; } - if (relayWatchInFlight) return; - relayWatchInFlight = true; - 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 code = Number(o.sys_state_code); - var rstate = Number(o.relay_state); - var alive = !!o.gd32_app_alive; - var rseq = Number(o.relay_seq) || 0; - var rtot = Number(o.relay_total_packets) || 0; - if (rstate >= 1 && rstate <= 4) { - 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) { - stopRelayProgressWatch(); - window.pollLogs(); - return; - } - // 前端进度同步:包数变化时更新进度条(updateSystemStatusUi 已做)并写一条前端日志 - // 后端不再打「转发进度」文字,只靠这里 + 进度条 - if (rtot > 0 && rseq > 0 && rseq !== window.relayWatchLastSeq) { - window.relayWatchLastSeq = rseq; - var pct = Math.round(rseq * 100 / rtot); - setProgress(60 + Math.round(pct * 0.30), - '已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)'); - addLog('I', 'OTA', '转发 ' + rseq + '/' + rtot + ' (' + pct + '%)'); - } - }) - .catch(function () { /* 下轮继续 */ }) - .then(function () { relayWatchInFlight = false; }); + var data = window.latestSystemStatus; + if (!data || !data.ota) return; + var o = data.ota || {}; + var code = Number(o.sys_state_code); + var rstate = Number(o.relay_state); + var alive = !!o.gd32_app_alive; + var rseq = Number(o.relay_seq) || 0; + var rtot = Number(o.relay_total_packets) || 0; + if (rstate >= 1 && rstate <= 4) { + window.gd32RelaySessionLive = true; + } + if (code === 8 || rstate === 255) { + stopRelayProgressWatch(); + window.pollLogs(); + return; + } + /* + * 顺序:先刷包进度(含强制 100%),再判定完成。 + * FINISHING/DONE 时若 status 滞后未到 total,仍按 total 打 100%。 + */ + var transferDone = rtot > 0 && (rseq >= rtot || rstate >= 4 || code === 4); + if (transferDone) { + emitRelayProgress(rtot, rtot, { force100: true }); + } 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(); + } }, 400); }