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

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

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

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

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

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

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

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

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

移除已废弃的OTA_PACKET_DELAY_MS参数,更新包间节奏控制说明为
基于CREDIT背压机制的描述。
This commit is contained in:
小龙虾
2026-07-16 01:24:12 +08:00
parent a47a027178
commit 51c89b3065
52 changed files with 1006 additions and 628 deletions
+1 -2
View File
@@ -433,9 +433,8 @@ UDP socket 延迟创建(首次 `send()` 时才分配),WiFi 未连接时 `s
| OTA | `OTA_BLOCK_SIZE` | 8192 | 读写块大小 |
| OTA | `OTA_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 | 温度警告阈值 |
+1
View File
@@ -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 共享)
+5 -13
View File
@@ -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)"
+14 -11
View File
@@ -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 窗口
{
+23 -10
View File
@@ -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 拉低时需更长
// ============================================================
// 健康监控阈值
+4 -4
View File
@@ -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"
+2 -1
View File
@@ -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,
+192
View File
@@ -0,0 +1,192 @@
/*
* cpu_stats.cpp - 1Hz FreeRTOS CPU 占用采样任务
*/
#include "cpu_stats.h"
#include <algorithm>
#include <cstring>
#include <inttypes.h>
#include <stdlib.h>
#include "app_config.h"
#include "esp_timer.h"
#include "monitor/debug_log.h"
CpuStats& CpuStats::instance()
{
static CpuStats inst;
return inst;
}
void CpuStats::start()
{
if (m_started) {
return;
}
m_started = true;
#if !defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
portENTER_CRITICAL(&m_mux);
m_published = {};
m_published.available = false;
portEXIT_CRITICAL(&m_mux);
DEBUG_WARN("CPU", "未启用 CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS,跳过采样任务");
return;
#else
portENTER_CRITICAL(&m_mux);
m_published = {};
m_published.available = true;
m_published.valid = false;
portEXIT_CRITICAL(&m_mux);
BaseType_t ok = xTaskCreatePinnedToCore(
&CpuStats::taskEntry,
"cpu_stats",
TASK_STACK_CPU_STATS,
this,
tskIDLE_PRIORITY + 1, /* 低优先级,少搅动占用统计 */
&m_taskHandle,
0);
if (ok != pdPASS) {
m_taskHandle = nullptr;
m_started = false;
DEBUG_ERR("CPU", "创建 cpu_stats 任务失败");
return;
}
DEBUG_INFO("CPU", "cpu_stats 任务已启动,采样周期 %" PRIu32 " ms", CPU_STATS_SAMPLE_MS);
#endif
}
void CpuStats::getSnapshot(CpuUsageSnapshot& out) const
{
portENTER_CRITICAL(&m_mux);
out = m_published;
portEXIT_CRITICAL(&m_mux);
}
#if defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
static uint64_t runtimeDelta(configRUN_TIME_COUNTER_TYPE cur, configRUN_TIME_COUNTER_TYPE prev)
{
return cur >= prev ? (uint64_t)(cur - prev) : 0U;
}
static bool taskNameIsIdle(const char* name)
{
return name && strncmp(name, "IDLE", 4) == 0;
}
void CpuStats::sampleOnce()
{
UBaseType_t task_count = uxTaskGetNumberOfTasks();
if (task_count == 0) {
return;
}
task_count = std::min<UBaseType_t>(task_count + 2U, (UBaseType_t)CPU_TASK_SNAPSHOT_MAX);
TaskStatus_t* tasks = (TaskStatus_t*)calloc(task_count, sizeof(TaskStatus_t));
if (!tasks) {
return;
}
configRUN_TIME_COUNTER_TYPE total_runtime = 0;
UBaseType_t captured = uxTaskGetSystemState(tasks, task_count, &total_runtime);
const int64_t now_us = esp_timer_get_time();
if (captured == 0 || captured > task_count) {
DEBUG_WARN("CPU", "uxTaskGetSystemState 异常: captured=%lu buf=%lu",
(unsigned long)captured, (unsigned long)task_count);
free(tasks);
return;
}
CpuUsageSnapshot snap = {};
snap.available = true;
const bool had_previous = m_hasLast;
const configRUN_TIME_COUNTER_TYPE previous_total = m_lastTotal;
const int64_t previous_time_us = m_lastTimeUs;
const size_t previous_count = m_lastCount;
TaskSample previous_samples[CPU_TASK_SNAPSHOT_MAX];
for (size_t i = 0; i < previous_count && i < CPU_TASK_SNAPSHOT_MAX; ++i) {
previous_samples[i] = m_lastSamples[i];
}
/* 先更新基线,再算本窗口(与旧 HTTP 路径语义一致) */
size_t copy_count = std::min<size_t>((size_t)captured, CPU_TASK_SNAPSHOT_MAX);
for (size_t i = 0; i < copy_count; ++i) {
m_lastSamples[i].task_number = tasks[i].xTaskNumber;
m_lastSamples[i].runtime = tasks[i].ulRunTimeCounter;
}
m_lastCount = copy_count;
m_lastTotal = total_runtime;
m_lastTimeUs = now_us;
m_hasLast = true;
const bool can_compute = had_previous && total_runtime >= previous_total && now_us > previous_time_us;
const uint64_t total_delta = can_compute ? runtimeDelta(total_runtime, previous_total) : 0U;
uint64_t idle_delta = 0U;
if (can_compute && total_delta > 0U) {
for (UBaseType_t i = 0; i < captured; ++i) {
configRUN_TIME_COUNTER_TYPE last_runtime = 0;
bool found = false;
for (size_t j = 0; j < previous_count; ++j) {
if (previous_samples[j].task_number == tasks[i].xTaskNumber) {
last_runtime = previous_samples[j].runtime;
found = true;
break;
}
}
if (!found) {
continue;
}
uint64_t delta = runtimeDelta(tasks[i].ulRunTimeCounter, last_runtime);
double pct = (double)delta * 100.0 / (double)total_delta;
if (taskNameIsIdle(tasks[i].pcTaskName)) {
idle_delta += delta;
}
if (snap.task_count < CPU_TASK_SNAPSHOT_MAX) {
CpuTaskMetric& m = snap.tasks[snap.task_count++];
const char* n = tasks[i].pcTaskName ? tasks[i].pcTaskName : "";
strncpy(m.name, n, sizeof(m.name) - 1);
m.name[sizeof(m.name) - 1] = '\0';
m.task_number = tasks[i].xTaskNumber;
m.runtime_delta = delta;
m.usage_pct = pct;
}
}
snap.valid = true;
snap.sample_ms = (uint32_t)((now_us - previous_time_us) / 1000);
snap.idle_pct = std::min(100.0, (double)idle_delta * 100.0 / (double)total_delta);
snap.usage_pct = std::max(0.0, 100.0 - snap.idle_pct);
}
free(tasks);
portENTER_CRITICAL(&m_mux);
m_published = snap;
portEXIT_CRITICAL(&m_mux);
}
void CpuStats::taskEntry(void* arg)
{
auto* self = static_cast<CpuStats*>(arg);
DEBUG_INFO("CPU", "=== cpu_stats 采样任务启动 ===");
TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(CPU_STATS_SAMPLE_MS);
/* 第一拍只建基线(valid=false);此后每拍窗口 ≈ 1000ms */
for (;;) {
self->sampleOnce();
vTaskDelayUntil(&last_wake, period);
}
}
#else /* !CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS */
void CpuStats::sampleOnce() {}
void CpuStats::taskEntry(void*) { vTaskDelete(nullptr); }
#endif
+73
View File
@@ -0,0 +1,73 @@
/*
* cpu_stats.h - FreeRTOS 运行时 CPU 占用采样
*
* 由独立低优先级任务按固定 1s 周期采样;/system/status 只读最新快照,
* 不再在 HTTP 线程里调用 uxTaskGetSystemState(避免窗口绑浏览器轮询、减 httpd 栈压)。
*/
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/** 单次快照最多跟踪的任务数 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 32;
/** 采样周期(ms)— 窗口 sample_ms 应对齐此值 */
static constexpr uint32_t CPU_STATS_SAMPLE_MS = 1000;
struct CpuTaskMetric {
char name[configMAX_TASK_NAME_LEN];
UBaseType_t task_number;
uint64_t runtime_delta;
double usage_pct;
};
struct CpuUsageSnapshot {
bool available; ///< 固件是否启用 RUN_TIME_STATS
bool valid; ///< 是否已有完整窗口(至少两拍)
uint32_t sample_ms; ///< 本窗口墙钟长度
double usage_pct; ///< 100 - idle
double idle_pct;
CpuTaskMetric tasks[CPU_TASK_SNAPSHOT_MAX];
size_t task_count;
};
/**
* @brief CPU 运行时统计(单例 + 1Hz 采样任务)
*/
class CpuStats {
public:
static CpuStats& instance();
/** 创建采样任务(幂等;失败仅打日志,不阻塞启动) */
void start();
/** 线程安全拷贝最新快照(HTTP 用) */
void getSnapshot(CpuUsageSnapshot& out) const;
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
private:
CpuStats() = default;
static void taskEntry(void* arg);
void sampleOnce();
mutable portMUX_TYPE m_mux = portMUX_INITIALIZER_UNLOCKED;
CpuUsageSnapshot m_published = {};
TaskHandle_t m_taskHandle = nullptr;
bool m_started = false;
/* 基线(仅采样任务写) */
struct TaskSample {
UBaseType_t task_number;
configRUN_TIME_COUNTER_TYPE runtime;
};
TaskSample m_lastSamples[CPU_TASK_SNAPSHOT_MAX] = {};
size_t m_lastCount = 0;
configRUN_TIME_COUNTER_TYPE m_lastTotal = 0;
int64_t m_lastTimeUs = 0;
bool m_hasLast = false;
};
+12 -1
View File
@@ -367,7 +367,8 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len)
}
/* UDP 通道:堆紧时跳过 sendto,避免申请 LWIP TX pbuf 与 HTTP 回包抢堆。
* 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 拉取)——静态,不占堆
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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);
+10 -6
View File
@@ -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;
+48 -15
View File
@@ -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 {
+3 -4
View File
@@ -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);
+75 -25
View File
@@ -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 的内部实现细节,
+24 -14
View File
@@ -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);
+9 -10
View File
@@ -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);
}
+9 -11
View File
@@ -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(结构化错误重发上限)
*
+32 -16
View File
@@ -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);
+15 -181
View File
@@ -1,6 +1,5 @@
/*
* http_handlers.cpp - HTTP 请求处理器实现
* 从 wifi_ota.cpp 提取的 HTTP handler 函数
*/
#include <string.h>
#include <stdarg.h>
@@ -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<UBaseType_t>(task_count + 2U, CPU_TASK_SNAPSHOT_MAX);
TaskStatus_t* tasks = (TaskStatus_t*)calloc(task_count, sizeof(TaskStatus_t));
if (!tasks) {
return;
}
configRUN_TIME_COUNTER_TYPE total_runtime = 0;
UBaseType_t captured_count = uxTaskGetSystemState(tasks, task_count, &total_runtime);
int64_t now_us = esp_timer_get_time();
if (captured_count == 0 || captured_count > task_count) {
DEBUG_WARN("STATUS", "CPU stats: uxTaskGetSystemState 返回 0 (actual_tasks=%lu, buf_slots=%lu)",
(unsigned long)uxTaskGetNumberOfTasks(), (unsigned long)task_count);
free(tasks);
return;
}
// ★ previous_samples[24] = 192B,改堆分配减少栈压力
CpuTaskSample* previous_samples = (CpuTaskSample*)calloc(CPU_TASK_SNAPSHOT_MAX, sizeof(CpuTaskSample));
if (!previous_samples) {
free(tasks);
return;
}
size_t previous_count = 0;
configRUN_TIME_COUNTER_TYPE previous_total = 0;
int64_t previous_time_us = 0;
bool had_previous = false;
portENTER_CRITICAL(&s_cpu_stats_mux);
previous_count = s_cpu_last_count;
previous_total = s_cpu_last_total;
previous_time_us = s_cpu_last_time_us;
had_previous = s_cpu_has_last;
for (size_t i = 0; i < previous_count; ++i) {
previous_samples[i] = s_cpu_last_samples[i];
}
reset_cpu_usage_baseline_locked(tasks, captured_count, total_runtime, now_us);
portEXIT_CRITICAL(&s_cpu_stats_mux);
bool can_compute = had_previous && total_runtime >= previous_total && now_us > previous_time_us;
uint64_t total_delta = can_compute ? runtime_delta(total_runtime, previous_total) : 0U;
uint64_t idle_delta = 0U;
if (can_compute && total_delta > 0U) {
for (UBaseType_t i = 0; i < captured_count; ++i) {
configRUN_TIME_COUNTER_TYPE last_runtime = 0;
bool found_previous = false;
for (size_t j = 0; j < previous_count; ++j) {
if (previous_samples[j].task_number == tasks[i].xTaskNumber) {
last_runtime = previous_samples[j].runtime;
found_previous = true;
break;
}
}
if (!found_previous) {
continue;
}
uint64_t delta = runtime_delta(tasks[i].ulRunTimeCounter, last_runtime);
double pct = (double)delta * 100.0 / (double)total_delta;
if (task_name_is_idle(tasks[i].pcTaskName)) {
idle_delta += delta;
}
if (result.task_count < CPU_TASK_SNAPSHOT_MAX) {
CpuTaskMetric& metric = result.tasks[result.task_count++];
metric.name = tasks[i].pcTaskName ? tasks[i].pcTaskName : "";
metric.task_number = tasks[i].xTaskNumber;
metric.runtime_delta = delta;
metric.usage_pct = pct;
}
}
result.valid = true;
result.sample_ms = (uint32_t)((now_us - previous_time_us) / 1000);
result.idle_pct = std::min(100.0, (double)idle_delta * 100.0 / (double)total_delta);
result.usage_pct = std::max(0.0, 100.0 - result.idle_pct);
}
if (!result.valid) {
DEBUG_DBG("STATUS", "CPU stats: valid=false had_prev=%d total=%lu prev_total=%lu "
"can_compute=%d delta=%lu captured=%lu",
(int)had_previous,
(unsigned long)total_runtime, (unsigned long)previous_total,
(int)can_compute, (unsigned long)total_delta,
(unsigned long)captured_count);
}
free(previous_samples);
free(tasks);
#else
result.available = false;
#endif
}
static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle)
{
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)
+15 -13
View File
@@ -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", "创建重连任务失败");
+1 -1
View File
@@ -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 射频活动。 */
+2 -2
View File
@@ -136,7 +136,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
return ESP_ERR_INVALID_SIZE;
}
// 写入前按需擦除目标扇区(替代 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(调用方决定容忍或失败)");
+6 -8
View File
@@ -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);
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取)
* canfd_bridge.cpp - CANFD 3通道通信桥接实现
*/
#include <string.h>
#include "canfd_bridge.h"
+2 -2
View File
@@ -1,7 +1,7 @@
/*
* canfd_bridge.h - CANFD 3通道通信桥接(M4: 从 SpiMaster 提取)
* canfd_bridge.h - CANFD 3通道通信桥接
*
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责解耦。
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责分离。
* 通过 SpiMaster 的公共 SPI 接口完成底层传输。
*/
#ifndef CANFD_BRIDGE_H
+25 -22
View File
@@ -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 解耦)
+5 -9
View File
@@ -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 输出缓冲容量
+18 -2
View File
@@ -4,11 +4,19 @@ CONFIG_ESP_WIFI_TX_BA_WIN=6
CONFIG_DCU_OTA_POLL_BACKOFF_MS=5
CONFIG_DCU_OTA_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
+3 -3
View File
@@ -431,8 +431,8 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/app.js?v=20260715a"></script>
<script type="module" src="js/ota/console-mode.js?v=20260715a"></script>
<script type="module" src="js/ble_ota.js?v=20260715a"></script>
<script type="module" src="js/app.js?v=20260716c"></script>
<script type="module" src="js/ota/console-mode.js?v=20260716c"></script>
<script type="module" src="js/ble_ota.js?v=20260716c"></script>
</body>
</html>
+11 -4
View File
@@ -12,11 +12,11 @@ import { showToast } from './common/toast.js';
import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js';
import {
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;
+1 -1
View File
@@ -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;
+40 -16
View File
@@ -189,6 +189,9 @@ export async function uploadFirmware() {
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
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');
}
+1 -1
View File
@@ -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 等)
+1 -2
View File
@@ -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 保证引用一致)。
+2 -11
View File
@@ -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';
+1 -2
View File
@@ -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)、
+4 -5
View File
@@ -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';
+2 -2
View File
@@ -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)。
*/
+56 -16
View File
@@ -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 = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
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 = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>';
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 {
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* log.js - 日志系统(带去重 + 自动滚动)
*
* 从 app.js 提取的日志环形缓冲、去重、过滤、自动滚动逻辑。
* 日志环形缓冲、去重、过滤、自动滚动逻辑。
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
*/
import { formatTime } from './utils.js';
+41 -57
View File
@@ -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());
}
+1 -1
View File
@@ -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 类型。 */
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* utils.js - 通用工具函数
*
* 从 app.js 提取的格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。
* 格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。
*/
// ==================== 工具函数 ====================
/** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */
+80 -23
View File
@@ -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<string|null>} hex 大写 SHA,失败返回 null
*/
function _computeAndCacheFileSha(file, chip, optArrayBuffer) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
var shaElem = document.getElementById('fileSha256' + suffix); // GD32 可能为 null
if (shaElem) shaElem.textContent = '计算中...';
var bufPromise = optArrayBuffer ? Promise.resolve(optArrayBuffer) : file.arrayBuffer();
return bufPromise.then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase();
if (shaElem) shaElem.textContent = hashHex;
var meta = { size: file.size, lastModified: file.lastModified, name: file.name };
if (chip === 'esp32') {
window._esp32FileSha256 = hashHex;
window._esp32FileShaMeta = meta;
} else {
window._gd32FileSha256 = hashHex;
window._gd32FileShaMeta = meta;
}
addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex);
return hashHex;
}).catch(function(err) {
if (shaElem) shaElem.textContent = '计算失败: ' + err.message;
addLog('W', '文件', chip.toUpperCase() + ' SHA-256 计算失败: ' + err.message);
if (chip === 'esp32') { window._esp32FileSha256 = null; window._esp32FileShaMeta = null; }
else { window._gd32FileSha256 = null; window._gd32FileShaMeta = null; }
return null;
});
}
/**
* @description 确保 file 的 SHA-256 已在缓存中:命中直接返回,不命中则重算并缓存。
* @param {File} file 目标文件
* @param {'esp32'|'gd32'} chip 通道
* @param {ArrayBuffer=} optArrayBuffer 命中时不用,未命中时传入可避免重复读文件
* @returns {Promise<string|null>} hex 大写 SHA
*/
export function ensureFileShaCached(file, chip, optArrayBuffer) {
var cachedMeta = chip === 'esp32' ? window._esp32FileShaMeta : window._gd32FileShaMeta;
var cachedSha = chip === 'esp32' ? window._esp32FileSha256 : window._gd32FileSha256;
if (cachedSha && cachedMeta &&
cachedMeta.size === file.size &&
cachedMeta.lastModified === file.lastModified &&
cachedMeta.name === file.name) {
// 命中:把 UI 上的 SHA 同步刷一下(幂等),避免其它代码误清
var shaElem = document.getElementById(chip === 'esp32' ? 'fileSha256Esp32' : 'fileSha256Gd32');
if (shaElem) shaElem.textContent = cachedSha;
addLog('I', '文件', chip.toUpperCase() + ' 复用已缓存 SHA-256(文件未变)');
return Promise.resolve(cachedSha);
}
return _computeAndCacheFileSha(file, chip, optArrayBuffer);
}
// ==================== 文件处理 ====================
/** @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 */
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);
+1 -2
View File
@@ -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
*/
+2 -3
View File
@@ -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';
+1 -2
View File
@@ -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';
+19 -9
View File
@@ -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, '已触发中继,等待设备进入握手…');
+13 -30
View File
@@ -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') {
+93 -48
View File
@@ -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);
}