From 76782349a91eaac49f91641492cc092dff86b6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Mon, 13 Jul 2026 03:12:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86SPI=E9=93=BE?= =?UTF-8?q?=E6=8E=A5V2=E6=94=AF=E6=8C=81=E6=96=87=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E6=9E=84OTA=E5=8D=8F=E8=B0=83=E5=99=A8=E7=9A=84?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=87=86=E5=A4=87=E6=B5=81=E7=A8=8B=EF=BC=8C?= =?UTF-8?q?=20=E7=BB=9F=E4=B8=80WiFi/BLE=E4=BC=A0=E8=BE=93=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=9A=84=E6=94=B6=E5=B0=BE=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E3=80=82=20feat(ota):=20=E4=BC=98=E5=8C=96OTA?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E5=B9=B6=E5=A2=9E=E5=BC=BA=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改RSSI警告阈值从-75dBm调整为-80dBm,更新相关文档说明 - 增加HTTP服务器任务栈大小至10240字节,解决栈溢出问题 - 将OTA块大小从4096字节降至2048字节以降低峰值堆内存占用 - 实现按需擦除机制替代预擦除,在GD32 OTA中按扇区动态擦除 - 添加任务栈水位主动巡检功能,监控栈使用情况并告警 - 集成FreeRTOS栈溢出钩子,提供栈溢出检测和告警 - 优化ESP32 OTA实现使用顺序写入模式,消除长时间擦除阻塞 - 改进堆内存不足告警机制,添加限流避免刷屏 - 移除不必要的调试日志输出,精简健康监控模块 --- .codex/config.toml | 4 + ...ss_62703d36-b353-46c4-b5fb-4f137f524819.md | 62 + ...ss_b0c54f72-18b6-47ed-b0ed-a2f89c313dc6.md | 58 + README.md | 4 +- components/transport/CMakeLists.txt | 2 + main/CMakeLists.txt | 4 +- main/include/app_config.h | 14 +- main/include/version.h | 8 +- main/monitor/can_feedback_task.cpp | 1 + main/monitor/health_monitor.cpp | 69 +- main/monitor/stack_overflow_hook.cpp | 41 + main/monitor/stack_overflow_hook.h | 18 + main/monitor/task_stack_check.cpp | 46 + main/monitor/task_stack_check.h | 30 + main/ota/esp32_ota_core.cpp | 59 +- main/ota/ota_coordinator.cpp | 27 +- main/ota/ota_coordinator.h | 14 +- main/relay/ota_relay.cpp | 227 ++-- main/relay/ota_relay.h | 2 +- main/server/ble_ota.cpp | 81 +- main/server/http_handlers.cpp | 397 ++++--- main/server/wifi_ota.cpp | 16 +- main/server/wifi_ota.h | 3 - main/storage/gd32_flash_store.cpp | 98 +- main/storage/gd32_flash_store.h | 13 + main/transport/protocol.cpp | 1 - main/transport/spi_master.cpp | 1026 ++++++++++++----- main/transport/spi_master.h | 69 +- sdkconfig.defaults | 8 +- tools/app.js | 579 ++++++++-- tools/ble_ota.js | 42 +- tools/index.html | 10 +- 32 files changed, 2245 insertions(+), 788 deletions(-) create mode 100644 .codex/config.toml create mode 100644 .zcode/plans/plan-sess_62703d36-b353-46c4-b5fb-4f137f524819.md create mode 100644 .zcode/plans/plan-sess_b0c54f72-18b6-47ed-b0ed-a2f89c313dc6.md create mode 100644 main/monitor/stack_overflow_hook.cpp create mode 100644 main/monitor/stack_overflow_hook.h create mode 100644 main/monitor/task_stack_check.cpp create mode 100644 main/monitor/task_stack_check.h diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..9ac7c6e --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,4 @@ +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true diff --git a/.zcode/plans/plan-sess_62703d36-b353-46c4-b5fb-4f137f524819.md b/.zcode/plans/plan-sess_62703d36-b353-46c4-b5fb-4f137f524819.md new file mode 100644 index 0000000..e3e4488 --- /dev/null +++ b/.zcode/plans/plan-sess_62703d36-b353-46c4-b5fb-4f137f524819.md @@ -0,0 +1,62 @@ +# 修复:WiFi 上传阻塞 HTTP 主线程导致日志挤压/内存爆满 + +## 根本原因 + +`esp_http_server`(IDF v6.0)是**单线程 select 模型**:一个主线程在 `select()` 循环里依次处理所有 socket。`ota_upload_handler` 在 HTTP 主线程里同步阻塞执行整个上传(GD32 含 Flash 擦除可达数十秒,ESP32 同样)。期间主线程不回 `select()`,导致: + +1. 前端 `pausePollingForUpload()` 仍以 2.5–3s 发 `/system/status`、`/logs`,但 HTTP 主线程无法 accept/处理 → 连接在 lwIP 侧堆积,pbuf 占用堆内存 → 内存爆满 +2. `/logs` 无法被 drain,日志在 ring buffer(64×256,有上限但前端看不到)→ 体现为"LOG挤压" +3. `max_open_sockets=4` + `lru_purge` 也无效:线程不回 `select()`,LRU 清理不触发 + +## 方案:异步上传 handler + +用 IDF v6.0 的 `httpd_req_async_handler_begin/complete` API(已确认可用,`examples/protocols/http_server/async_handlers` 即此模式),把上传移到独立 worker 任务。HTTP 主线程立即返回 `select()` 循环,继续服务 `/logs`、`/system/status`。 + +### 改动文件 + +#### 1. `main/server/http_handlers.h`(新增声明) +- 新增 `esp_err_t ota_upload_handler_async(httpd_req_t *req);`(新的上传入口,注册替代原 `ota_upload_handler`) +- 保留原 `ota_upload_handler` 的实现,但改为 static 内部函数,仅供 async worker 调用 + +#### 2. `main/server/http_handlers.cpp`(核心改动) +- 新增 async worker 基础设施(文件内 static): + - `static QueueHandle_t s_uploadQueue`(长度 1,元素为 `httpd_req_t*`) + - `static SemaphoreHandle_t s_workerReady`(binary,初值 0) + - `static TaskHandle_t s_uploadWorker`(栈 8192,优先级 5,固定 core 0 与现有任务一致) + - `static void uploadWorkerTask(void*)`:循环 `xSemaphoreGive(s_workerReady)` → `xQueueReceive` → 调用原上传逻辑(用 async req 句柄)→ `httpd_req_async_handler_complete()` + - `static esp_err_t ensureUploadWorker()`:懒启动 worker(首次上传请求时创建,幂等) +- 新增 `ota_upload_handler_async(httpd_req_t *req)`: + 1. `ensureUploadWorker()` + 2. `httpd_req_t *async_req; httpd_req_async_handler_begin(req, &async_req)` —— 把 socket 标记 `for_async_req`,主线程不再处理它 + 3. `if (xSemaphoreTake(s_workerReady, 0) == false)` —— worker 忙(已有上传在进行),立即 `httpd_req_async_handler_complete(async_req)` 并返回 503: + ``` + httpd_resp_set_status(req, "503 Service Unavailable"); + httpd_resp_sendstr(req, "{\"status\":\"error\",\"message\":\"upload in progress\"}"); + return ESP_OK; + ``` + 注意:503 要发在**原 req** 上(begin 之后 async req 才能发响应,但此处用原 req 发错误更简单——实际上 begin 后原 req 的 remaining_len 被清零,发响应需用 async_req。所以错误路径用 async_req 发)。 + 4. `xQueueSend(s_uploadQueue, &async_req, portMAX_DELAY)` + 5. 返回 `ESP_OK`(主线程已完成投递,立即回 select) +- 把原 `ota_upload_handler` 改名 `static esp_err_t ota_upload_handler_impl(httpd_req_t *req)`,逻辑不变(它内部已用 `httpd_req_recv`/`httpd_resp_send`,async 句柄完全兼容) +- **保留 CORS 头设置**:`http_set_cors_headers` 在 `ota_upload_handler_async` 里对原 req 设置(begin 之前),async 路径的响应头也需在 worker 里设置——实际上 `httpd_req_async_handler_begin` 会拷贝 resp_hdrs,所以 CORS 头在 begin 前对原 req 设置即可被子拷贝 + +#### 3. `main/server/wifi_ota.cpp`(路由注册) +- `kHttpRoutes[]` 中 `/ota/upload` 的 handler 从 `ota_upload_handler` 改为 `ota_upload_handler_async` +- OPTIONS 预检不变(仍走 `http_options_handler`) + +### 不改动的部分 +- **前端 app.js 不改**:`pausePollingForUpload` 仍降速轮询,但现在能真正拿到 `/logs` 和 `/system/status` 响应(主线程不再阻塞) +- **日志系统不改**:ring buffer 已有上限,异步化后 `/logs` 能被正常 drain,不再积压 +- **OtaCoordinator 不改**:已有 `ESP_ERR_INVALID_STATE` 拒绝并发会话,worker 的 503 是更早的防线 +- **OTA_BLOCK_SIZE、栈大小不改** + +### 内存影响 +- 新增 1 个 worker 任务栈 8192 字节(从现有 `free_heap` 出,ESP32-C3 内部堆约 200KB+,可接受) +- 队列 1×`sizeof(httpd_req_t*)`=4 字节,信号量 ~80 字节,忽略不计 +- **收益**:上传期间不再堆积 lwIP pbuf(每个未 accept 的连接可达数 KB),消除内存爆满主因 + +### 验证方式 +1. 编译通过(`idf.py build`) +2. 功能验证:WiFi 上传 GD32/ESP32 固件,上传期间网页 `/logs` 持续刷新、`/system/status` 能返回(不再超时) +3. 内存验证:`/system/status` 的 `free_heap` 在上传期间不再持续下降 +4. 并发验证:上传进行中再发一个 `/ota/upload`,立即返回 503 而非阻塞 \ No newline at end of file diff --git a/.zcode/plans/plan-sess_b0c54f72-18b6-47ed-b0ed-a2f89c313dc6.md b/.zcode/plans/plan-sess_b0c54f72-18b6-47ed-b0ed-a2f89c313dc6.md new file mode 100644 index 0000000..16c0193 --- /dev/null +++ b/.zcode/plans/plan-sess_b0c54f72-18b6-47ed-b0ed-a2f89c313dc6.md @@ -0,0 +1,58 @@ +## 删除"断线后切换慢速轮询"机制 + +**文件:** `G:\Robot_v2\DCU\ESP32_COMM\tools\app.js` + +**范围:** 只删除"降速到 10s"的行为,保留 5 次失败触发的网络诊断(diagnoseNetwork)和断线 UI 提示。删除后断线时保持 1s 轮询,仅显示"未连接"并按需做网络诊断。 + +### 改动点 + +1. **删除变量 `pollSlowMode`(1600 行)** + - 移除 `var pollSlowMode = false; // 降速模式(10s 一次)` + +2. **删除函数 `slowDownPolling()`(1671-1681 行)** + - 整个函数移除 + +3. **删除函数 `restorePolling()`(1682-1695 行)** + - 整个函数移除(保留 `startPolling`、`pausePollingForUpload`、`resumePollingAfterUpload`) + +4. **简化 `startPolling()`(1662-1670 行)** + - 删除 `pollSlowMode = false;` 这一行(其余保留) + +5. **简化 `resumePollingAfterUpload()`(1714 行)** + - `statusPollIntervalMs = pollSlowMode ? 10000 : 1000;` → `statusPollIntervalMs = 1000;` + +6. **简化 `pollStatus()` 成功分支(1738-1747 行)** + - 移除 `wasSlow` / `restorePolling()` 调用,仅保留 `consecutivePollFailures = 0;`(保留"恢复在线"的 toast 体验需要另判断;改为:若 `consecutivePollFailures > 0` 则提示已恢复并清零) + +7. **简化 `pollStatus()` 失败分支的 5-fail 块(1789-1800 行)** + - 删除 `!pollSlowMode` 条件判断中的 `pollSlowMode` 部分 + - 删除 `slowDownPolling();` 调用和"已切换为慢速轮询"日志 + - 保留 `diagnoseNetwork()` 诊断、`updateConnectionUi(false, '未连接')`、`applyDisconnectedRuntimeState()` 和诊断文本显示 + +8. **简化 `pollStatus()` 失败分支末尾(1828 行)** + - `} else if (!pollSlowMode) {` → `} else {`(该 else 分支总是显示未连接状态) + +9. **清理 `testConnection()`(817-820 行)** + - 删除 `if (pollSlowMode) { addLog(...); restorePolling(); }` 块 + +10. **清理 `refreshSystemStatusNow()`(874 行)** + - 删除 `if (pollSlowMode) restorePolling();` + +11. **清理 `visibilitychange` 监听(1854-1855 行)** + - 删除 `// 之前若在慢速模式...` 注释和 `if (pollSlowMode) restorePolling();` + +12. **清理 IP 输入 change 监听(1887-1892 行)** + - 简化为:IP 变化时清零失败计数并立即拉一次状态(不再依赖 restorePolling) + - 或直接移除 `if (pollSlowMode) { ... restorePolling(); }` 块,保留监听器做基本的失败计数清零 + +### 不删除的部分 +- `consecutivePollFailures` 变量及其计数逻辑(诊断仍需要) +- `diagnoseNetwork()` 网络诊断函数 +- `lastDiagTime` / `diagResult` 诊断状态变量 +- `applyDisconnectedRuntimeState()` 断线 UI 更新 +- `statusPollIntervalMs` 变量(被 upload 暂停/恢复机制复用,保持 1000 默认值) + +### 验证 +- 浏览器打开 `tools/index.html`,确认页面加载无 JS 错误 +- 模拟 ESP32 断线:连续 5 次失败后应仍触发网络诊断并显示"未连接",但轮询间隔保持 1s +- 恢复 ESP32:应清零失败计数并提示"恢复在线" \ No newline at end of file diff --git a/README.md b/README.md index 605f134..13c88ad 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ curl http://192.168.31.1/logs | 等级 | 名称 | 触发条件 | OTA 影响 | |------|------|----------|----------| | 0 | SYS_NORMAL | 正常 | 允许 | -| 1 | SYS_WARNING | 温度 ≥ 80°C 或 RSSI < -75 dBm | 允许(带警告)| +| 1 | SYS_WARNING | 温度 ≥ 80°C 或 RSSI < -80 dBm | 允许(带警告)| | 2 | SYS_CRITICAL | RSSI < -85 dBm | ESP32 OTA 禁止,SPI 中继允许 | | 3 | SYS_SAFE_SHUTDOWN | 温度 ≥ 100°C | 所有 OTA 禁止 | @@ -427,7 +427,7 @@ UDP socket 延迟创建(首次 `send()` 时才分配),WiFi 未连接时 `s | WiFi | `CONFIG_WIFI_MODE` | 1 (STA) | 0=AP, 1=STA | | WiFi | `WIFI_STA_SSID` | "004舰桥" | 路由器名称 | | WiFi | `WIFI_AP_SSID` | "ESP32_OTA_RELAY" | 热点名称 | -| WiFi | `WIFI_RSSI_WARNING` | -75 dBm | RSSI 警告阈值 | +| WiFi | `WIFI_RSSI_WARNING` | -80 dBm | RSSI 警告阈值 | | WiFi | `WIFI_RSSI_CRITICAL` | -85 dBm | RSSI 危险阈值 | | OTA | `OTA_BLOCK_SIZE` | 8192 | 读写块大小 | | OTA | `OTA_MAX_FIRMWARE_SIZE` | 216KB | GD32 固件上限 | diff --git a/components/transport/CMakeLists.txt b/components/transport/CMakeLists.txt index dafd67f..326cdb2 100644 --- a/components/transport/CMakeLists.txt +++ b/components/transport/CMakeLists.txt @@ -8,10 +8,12 @@ idf_component_register( SRCS "../../main/transport/spi_master.cpp" + "../../../spi_link/spi_link_v2.cpp" "../../main/transport/canfd_bridge.cpp" "../../main/transport/protocol.cpp" INCLUDE_DIRS "../../main/transport" # spi_master.h, canfd_bridge.h, protocol.h + "../../../spi_link" # spi_link_v2.h(三工程共享协议头) PRIV_INCLUDE_DIRS "../../main" # monitor/debug_log.h(仅本组件编译时使用) "../../main/include" # app_config.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b58b3cd..8b9e0d9 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -9,9 +9,11 @@ idf_component_register( "server/wifi_ota.cpp" # WiFi HTTP OTA 服务器 "server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取) "server/ble_ota.cpp" # BLE GATT OTA 服务器 - "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存) + "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检) "monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1) "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) + "monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享) + "monitor/stack_overflow_hook.cpp" # FreeRTOS 栈溢出钩子(canary 触发时 UART 硬写告警) INCLUDE_DIRS "." "core" diff --git a/main/include/app_config.h b/main/include/app_config.h index 06b386c..6d10ac7 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -53,17 +53,15 @@ 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 = 8192; ///< HTTP 服务器任务栈大小(字节) -// ★ 曾降至 6142 省 2KB,但 system_status_handler→sample_cpu_usage() 在栈上分配 -// TaskStatus_t tasks[24](~864B)+previous_samples[24](192B)+CpuUsageSnapshot(~608B)≈1.7KB, -// 加上 spi_send_handler 的 buf[512]+resp[1024]+SPI 调用链 ≈3.5KB, -// 再加 httpd 框架开销 ~2KB → 总需求逼近 6KB,6144 栈导致栈溢出 → TCB 被破坏 -// → "Stack protection fault"(栈边界被 "http" ASCII 覆盖)。恢复 8192 留足余量。 +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% 更多是堆碎导致回包/后续分配失败。 // ============================================================ // OTA 配置 // ============================================================ -inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< OTA 上传静态接收缓冲区,从 8192 降到 4096 省 4KB +inline constexpr size_t OTA_BLOCK_SIZE = 2048; ///< 上传接收块:2KB 降峰值堆(原 4/8KB) 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 轮询次数 @@ -76,7 +74,7 @@ inline constexpr uint32_t OTA_POLL_NO_FRAME_DELAY_MS = 8; // 轮询未找 // 健康监控阈值 // ============================================================ inline constexpr int32_t WIFI_RSSI_CRITICAL = -85; ///< WiFi 信号严重警告阈值(dBm) -inline constexpr int32_t WIFI_RSSI_WARNING = -75; ///< WiFi 信号一般警告阈值(dBm) +inline constexpr int32_t WIFI_RSSI_WARNING = -80; ///< WiFi 信号一般警告阈值(dBm) inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; ///< OTA 传输期间 WiFi 信号临界阈值 inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; ///< RSSI 采样最大有效期(毫秒) inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; ///< 每 N 包执行一次健康检查 diff --git a/main/include/version.h b/main/include/version.h index 37a07e3..d4058ca 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 41 -#define FW_VERSION_STRING "0.3.41" +#define FW_VERSION_PATCH 42 +#define FW_VERSION_STRING "0.3.42" // 编译时间 -#define BUILD_TIME "2026-07-12 03:33:39" +#define BUILD_TIME "2026-07-13 03:05:29" // Git 信息 -#define GIT_HASH "c0ca4e2" +#define GIT_HASH "0b36349" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index 6b719f4..ae2d3f9 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -150,6 +150,7 @@ bool startCanFeedbackTask() void pauseCanFeedbackTask(bool pause) { + if (s_paused == pause) return; // 幂等:状态未变时不重复日志 s_paused = pause; if (pause) { DEBUG_INFO(kTag, "CAN 监听已暂停(OTA 进行中)"); diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 0d36719..5a497aa 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -14,10 +14,25 @@ #include "esp_wifi.h" #include "esp_timer.h" #include "esp_err.h" +#include "esp_heap_caps.h" +#include "esp_system.h" #include "driver/temperature_sensor.h" #include "health_monitor.h" #include "debug_log.h" +#include "task_stack_check.h" +#include "stack_overflow_hook.h" + +// ============================================================ +// 栈水位主动巡检 +// ============================================================ +// 告警阈值:剩余高水位 < 栈大小 20% 即告警(复用 /system/status 的任务表)。 +// 去重:每个任务只在"水位由安全跌入危险"时告警一次,恢复后再跌入才再次告警, +// 避免 5 秒巡检刷屏。health_mon 任务从不被 OTA 暂停,DEBUG_ERR 经 UDP/UART +// 直发,不受 OTA 门控抑制。 +static constexpr float STACK_WARN_RATIO = 0.20f; ///< 剩余水位告警阈值(栈大小比例) +static constexpr size_t STACK_TASK_MAX = 8; ///< 受监控任务上限 +static bool s_stackWarned[STACK_TASK_MAX] = {}; ///< 各任务是否已告警(去重) // ============================================================ // 单例 @@ -190,7 +205,6 @@ void HealthMonitor::taskEntry(void *pvParameters) DEBUG_ERR("MON", "温度传感器初始化失败: %s", esp_err_to_name(err)); } else { ESP_ERROR_CHECK(temperature_sensor_enable(self.m_tempHandle)); - DEBUG_INFO("MON", "温度传感器已启用 (范围 20°C ~ 80°C)"); } // 定时器,每1秒读取一次温度 @@ -202,7 +216,6 @@ void HealthMonitor::taskEntry(void *pvParameters) if (esp_timer_create(&timer_args, &self.m_tempTimer) == ESP_OK) { esp_timer_start_periodic(self.m_tempTimer, 1000000); // 1秒 - DEBUG_INFO("MON", "温度监控定时器已启动 (1秒间隔)"); } else { self.m_tempTimer = nullptr; DEBUG_ERR("MON", "创建温度监控定时器失败"); @@ -210,16 +223,62 @@ void HealthMonitor::taskEntry(void *pvParameters) // 周期性巡检 while (1) { + // 栈溢出 canary 触发检测:vApplicationStackOverflowHook 置位后, + // 在 health_mon 完好栈上用 DEBUG_ERR 经 UDP/UART 发详细告警。 + // 标志只在置位后告警一次,不自动清除(canary 触发即不可逆,需重启恢复)。 + if (g_stackOverflowDetected) { + DEBUG_ERR("MON", "!!! 栈溢出已被内核 canary 检测到,请检查任务栈大小 !!!"); + // 告警一次后清标志,避免 5 秒刷屏;后续若再次触发会重新置位 + g_stackOverflowDetected = false; + } + uint32_t free_heap = esp_get_free_heap_size(); if (free_heap < HEAP_WARNING_MIN) { - uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - DEBUG_WARN("MON", "堆内存不足: free=%lu min=%lu largest_blk=%lu", - free_heap, esp_get_minimum_free_heap_size(), largest_block); + /* OTA 期间堆会短暂变紧,限流避免串口/UDP 二次吃堆、刷屏 */ + static TickType_t s_last_heap_warn = 0; + TickType_t now = xTaskGetTickCount(); + if ((now - s_last_heap_warn) >= pdMS_TO_TICKS(2000)) { + s_last_heap_warn = now; + uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); + DEBUG_WARN("MON", "堆内存不足: free=%lu min=%lu largest_blk=%lu", + (unsigned long)free_heap, + (unsigned long)esp_get_minimum_free_heap_size(), + (unsigned long)largest_block); + } } health_state_t cur = self.getState(); if (cur >= SYS_CRITICAL) { DEBUG_ERR("MON", "健康状态危险: %d", cur); } + + // 栈水位主动巡检:复用 /system/status 的任务表,低水位时 DEBUG_ERR 主动告警。 + // health_mon 任务不被 OTA 暂停,告警经 UDP/UART 直发,穿透 OTA 门控。 + TaskStackMetric metrics[STACK_TASK_MAX]; + size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX); + for (size_t i = 0; i < count && i < STACK_TASK_MAX; ++i) { + if (metrics[i].handle == NULL || metrics[i].stack_size_bytes == 0) { + continue; + } + UBaseType_t hw = uxTaskGetStackHighWaterMark(metrics[i].handle); + size_t warn_bytes = (size_t)(metrics[i].stack_size_bytes * STACK_WARN_RATIO); + if (hw < (UBaseType_t)warn_bytes) { + if (!s_stackWarned[i]) { + DEBUG_ERR("MON", "!!! 栈水位过低 !!! 任务=%s 剩余=%luB 阈值=%luB(%.0f%%) 栈大小=%luB", + metrics[i].name, + (unsigned long)hw, + (unsigned long)warn_bytes, + STACK_WARN_RATIO * 100.0f, + (unsigned long)metrics[i].stack_size_bytes); + s_stackWarned[i] = true; + } + } else if (s_stackWarned[i]) { + // 水位恢复,清除告警标志,允许下次再次跌入时告警 + s_stackWarned[i] = false; + DEBUG_INFO("MON", "栈水位恢复: 任务=%s 剩余=%luB", + metrics[i].name, (unsigned long)hw); + } + } + vTaskDelay(pdMS_TO_TICKS(5000)); } } diff --git a/main/monitor/stack_overflow_hook.cpp b/main/monitor/stack_overflow_hook.cpp new file mode 100644 index 0000000..822b233 --- /dev/null +++ b/main/monitor/stack_overflow_hook.cpp @@ -0,0 +1,41 @@ +/* + * stack_overflow_hook.cpp - FreeRTOS 栈溢出钩子 + * + * CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY 开启后,内核在任务切换时检查 + * 栈 canary,若被踩则回调 vApplicationStackOverflowHook。 + * + * 钩子在被踩栈的任务上下文中执行,此时该任务栈已损坏 —— 禁止任何依赖栈 / + * mutex / 网络栈的调用(printf 格式化、UDP sendto、xSemaphoreTake 等都可能 + * 二次崩溃)。唯一安全做法:用 uart_write_bytes 往 UART0 硬写预写好的常量 + * 字符串(物理外设直写,不需要栈空间),并置一个 volatile 标志,由 + * HealthMonitor 巡检循环(运行在完好的 health_mon 栈上)后续用 DEBUG_ERR + * 经 UDP/UART 发详细告警。 + * + * UART0 直写不受 OTA 门控抑制,是 OTA 末尾断网阶段唯一可靠的物理通道。 + */ +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/uart.h" +#include "esp_system.h" + +// 供 HealthMonitor 巡检检测:canary 触发后置位,由巡检发详细告警。 +volatile bool g_stackOverflowDetected = false; + +extern "C" void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) +{ + (void)xTask; + g_stackOverflowDetected = true; + + // 栈已损坏,只能做不依赖栈的最小操作。UART0 硬写常量字符串。 + static const char prefix[] = "\r\n!!! STACK OVERFLOW in task: "; + uart_write_bytes(UART_NUM_0, prefix, sizeof(prefix) - 1); + // 任务名最多 configMAX_TASK_NAME_LEN,strlen 对小已知 buffer 安全 + uart_write_bytes(UART_NUM_0, pcTaskName, strnlen(pcTaskName, configMAX_TASK_NAME_LEN)); + static const char suffix[] = " !!!\r\n"; + uart_write_bytes(UART_NUM_0, suffix, sizeof(suffix) - 1); + + /* 立刻 abort:OTA 上传 100% 后若 httpd 栈溢出却不 abort, + * 前端会一直停在 100% 像“卡住”,且 TCB 已损坏无法恢复。 */ + esp_system_abort("stack overflow"); +} \ No newline at end of file diff --git a/main/monitor/stack_overflow_hook.h b/main/monitor/stack_overflow_hook.h new file mode 100644 index 0000000..8faef6c --- /dev/null +++ b/main/monitor/stack_overflow_hook.h @@ -0,0 +1,18 @@ +/* + * stack_overflow_hook.h - FreeRTOS 栈溢出钩子标志声明 + */ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* canary 触发时由 vApplicationStackOverflowHook 置位; + * HealthMonitor 巡检循环检测此标志,置位后用 DEBUG_ERR 发详细告警。 */ +extern volatile bool g_stackOverflowDetected; + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/main/monitor/task_stack_check.cpp b/main/monitor/task_stack_check.cpp new file mode 100644 index 0000000..0c85c20 --- /dev/null +++ b/main/monitor/task_stack_check.cpp @@ -0,0 +1,46 @@ +/* + * task_stack_check.cpp - 任务栈水位指标收集(共享) + * + * 实现 collectTaskStackMetrics():复用 http_handlers 旧版 task_metrics[] 任务表, + * 供 health_monitor 主动巡检与 /system/status 被动报告共用。 + */ +#include "task_stack_check.h" + +#include "app_config.h" +#include "health_monitor.h" +#include "ota_relay.h" +#include "wifi_ota.h" +#include "ble_ota.h" + +size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max) +{ + if (!out || max == 0) return 0; + + size_t n = 0; + + // httpd 由 esp_http_server 创建,无公开句柄;按任务名取。 + // httpd 任务在 httpd_start() 后才存在,未启动时 xTaskGetHandle 返回 NULL。 + if (n < max) { + out[n++] = { "http_server", xTaskGetHandle("httpd"), HTTP_SERVER_STACK_SIZE }; + } + if (n < max) { + out[n++] = { "health_mon", HealthMonitor::instance().getTaskHandle(), 4096 }; + } + if (n < max) { + out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), 6144 }; + } + if (n < max) { + out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), 8192 }; + } + if (n < max) { + out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, 4096 }; + } + if (n < max) { + out[n++] = { "ble_ota", BleOta::instance().getTaskHandle(), 4096 }; + } + if (n < max) { + out[n++] = { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), 4096 }; + } + + return n; +} \ No newline at end of file diff --git a/main/monitor/task_stack_check.h b/main/monitor/task_stack_check.h new file mode 100644 index 0000000..665374d --- /dev/null +++ b/main/monitor/task_stack_check.h @@ -0,0 +1,30 @@ +/* + * task_stack_check.h - 任务栈水位指标收集(共享) + * + * 将受监控任务的任务名/句柄/栈大小统一收集,供: + * - health_monitor 巡检主动告警(低水位时 DEBUG_ERR,突破 OTA 门控) + * - http_handlers /system/status 被动报告(前端每秒轮询展示) + * 两处复用同一份任务表,避免散落维护。 + */ +#pragma once + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +/** @brief 单个任务的栈指标描述。 */ +struct TaskStackMetric { + const char* name; ///< 任务名(展示用) + TaskHandle_t handle; ///< FreeRTOS 任务句柄(可能为 NULL:任务尚未创建) + size_t stack_size_bytes; ///< 创建该任务时分配的栈大小(字节) +}; + +/** + * @brief 收集所有受监控任务的栈指标。 + * @param out 输出数组。 + * @param max out 数组容量。 + * @return 实际写入条数(<= max)。 + * + * 注意:部分任务句柄在任务创建前为 NULL,调用方应跳过 handle==NULL 的条目。 + */ +size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max); \ No newline at end of file diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index 7e8b736..72d7906 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -12,6 +12,8 @@ #include "esp_system.h" #include "esp_ota_ops.h" #include "esp_partition.h" +#include "esp_task_wdt.h" +#include "esp_timer.h" #define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS #include "mbedtls/private/sha256.h" @@ -170,19 +172,23 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) return beginCleanupAndReturn(ESP_FAIL); } - // BLE OTA 修复:传固件实际大小而非分区全尺寸,避免全分区擦除导致 - // 长时间阻塞触发 Task Watchdog(ESP32-C3 单核 5s 超时)。 - // esp_ota_begin 按 image_size 擦除;esp_ota_end 仅校验已写数据, - // 未擦除残留扇区不影响校验。size 对齐到 64KB 块边界确保尾部干净。 - size_t erase_size = (total_size + 0xFFFF) & ~0xFFFFULL; - if (erase_size > m_partition->size) { - erase_size = m_partition->size; - } - esp_err_t err = esp_ota_begin(m_partition, erase_size, &m_handle); + /* ★ 用 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, + (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); + const int64_t t1 = esp_timer_get_time(); if (err != ESP_OK) { - DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err)); + DEBUG_ERR(TAG, "esp_ota_begin 失败: %s (耗时 %lld ms)", + esp_err_to_name(err), (long long)((t1 - t0) / 1000)); return beginCleanupAndReturn(err); } + DEBUG_INFO(TAG, "esp_ota_begin 完成 耗时 %lld ms free=%lu", + (long long)((t1 - t0) / 1000), + (unsigned long)esp_get_free_heap_size()); m_received = 0; m_active = true; @@ -190,7 +196,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 字节", + DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase)", m_partition->label, (unsigned)total_size); return ESP_OK; } @@ -207,11 +213,14 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len) return ESP_ERR_INVALID_STATE; } + /* sequential 模式写时可能触发扇区擦除,喂狗防 WDT */ + (void)esp_task_wdt_reset(); esp_err_t err = esp_ota_write(m_handle, data, len); if (err == ESP_OK) { m_received += len; } else { - DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u)", esp_err_to_name(err), (unsigned)len); + DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u offset=%u)", + esp_err_to_name(err), (unsigned)len, (unsigned)m_received); } return err; } @@ -228,30 +237,36 @@ esp_err_t Esp32OtaCore::computeShaFromPartition() DEBUG_INFO(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received); - mbedtls_sha256_context ctx; - mbedtls_sha256_init(&ctx); - mbedtls_sha256_starts(&ctx, 0); + /* static context + 小读缓冲:勿占 httpd 栈,也勿再静态吃 4KB BSS */ + static mbedtls_sha256_context s_sha_ctx; + static uint8_t s_sha_read_buf[1024]; - // ★ static 缓冲区:OTA 操作由 OtaCoordinator 互斥串行化,无并发风险 - static uint8_t s_sha_read_buf[OTA_BLOCK_SIZE]; + mbedtls_sha256_init(&s_sha_ctx); + mbedtls_sha256_starts(&s_sha_ctx, 0); size_t offset = 0; + uint32_t wdt_chunk = 0; while (offset < m_received) { size_t to_read = m_received - offset; - if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + if (to_read > sizeof(s_sha_read_buf)) to_read = sizeof(s_sha_read_buf); esp_err_t rd_err = esp_partition_read(m_partition, offset, s_sha_read_buf, to_read); if (rd_err != ESP_OK) { DEBUG_ERR(TAG, "SHA 计算: 分区读失败 @offset %u: %s", (unsigned)offset, esp_err_to_name(rd_err)); - mbedtls_sha256_free(&ctx); + mbedtls_sha256_free(&s_sha_ctx); return rd_err; } - mbedtls_sha256_update(&ctx, s_sha_read_buf, to_read); + mbedtls_sha256_update(&s_sha_ctx, s_sha_read_buf, to_read); offset += to_read; + /* 长固件 SHA 可能 > TASK_WDT(5s);在 httpd 上跑时必须喂狗 */ + if ((++wdt_chunk & 0x0FU) == 0U) { + (void)esp_task_wdt_reset(); + taskYIELD(); + } } - mbedtls_sha256_finish(&ctx, m_sha256); - mbedtls_sha256_free(&ctx); + mbedtls_sha256_finish(&s_sha_ctx, m_sha256); + mbedtls_sha256_free(&s_sha_ctx); m_shaComputed = true; char hex[65] = {0}; diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 2a006d3..1206032 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -181,9 +181,6 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch return h ? ESP_OK : ESP_ERR_INVALID_STATE; } -// GD32 Flash 扇区大小(与 http_handlers.cpp 中的常量保持一致,用于擦除时向上对齐) -static constexpr uint32_t kGd32FlashSectorSize = 4096U; - esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t channel) { // 1. 校验固件大小(统一 BLE/HTTP 两路径的大小检查) @@ -200,18 +197,12 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t return err; } - // 3. 擦除 GD32 OTA 分区(按扇区大小向上对齐) - // ★ 原 BLE 路径未擦除分区,此处统一补齐,确保两路径行为一致。 - const uint32_t erase_size = - ((total_size + kGd32FlashSectorSize - 1U) / kGd32FlashSectorSize) * kGd32FlashSectorSize; - DEBUG_INFO(TAG, "prepareGd32Storage: 擦除 gd32_ota 分区 %u bytes (fw=%" PRIu32 ")", - (unsigned)erase_size, total_size); - err = eraseGd32Partition(erase_size); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "prepareGd32Storage: 擦除失败: %s", esp_err_to_name(err)); - abortCurrent(); - return err; - } + // 3. 复位按需擦除水位(不在此处整段预擦) + // 旧实现在 prepare 阶段 erase 整包固件对应分区:HTTP 主线程阻塞数秒~数十秒, + // 期间不 httpd_req_recv → TCP 窗口塞满后浏览器进度先到 100%,再空等擦+写+SHA。 + // 现改为 flashWriteChunk 内按扇区 ensureErased,边收边擦写,进度与真实处理对齐。 + Gd32FlashStore::instance().beginWriteSession(); + DEBUG_INFO(TAG, "prepareGd32Storage: 按需擦除会话已复位 (fw=%" PRIu32 ")", total_size); // 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0) clearGd32Meta(); @@ -243,6 +234,12 @@ void OtaCoordinator::completeGd32Storage() unlock(h); } +esp_err_t OtaCoordinator::finishGd32StorageAfterReceive() +{ + completeGd32Storage(); + return finalizeGd32Checksum(); +} + // ============================================================ // GD32 存储操作(传输层统一入口,解决 H3 绕过协调器问题) // ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到 diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index 1c80905..0c7a5c6 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -124,10 +124,9 @@ public: esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel); /** - * @brief 统一 GD32 OTA 写入前的准备工作:校验大小 + begin + 擦除分区 + 清空 meta + 写入初始 meta。 - * 供 BLE seq=0 首包 / HTTP GD32 upload(非 prepared 复用路径)调用, - * 消除两路径对"擦除/meta 初始化"序列的隐式差异(原 BLE 路径未擦除分区)。 - * 任意步骤失败时内部调 abortCurrent 清理,调用方无需再清理。 + * @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位按需擦除水位 + 清空 meta + 写入初始 meta。 + * 扇区擦除在 writeGd32Chunk/flashWriteChunk 内按写入范围推进(非整段预擦)。 + * WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。 * @param total_size 固件总字节数。 * @param channel 发起通道(WiFi/BLE)。 * @return ESP_OK 成功;ESP_ERR_INVALID_SIZE 大小非法;ESP_ERR_INVALID_STATE 系统忙;其他擦除/写入错误。 @@ -140,6 +139,13 @@ public: */ void completeGd32Storage(); + /** + * @brief 收流完成后的统一收尾:completeGd32Storage + finalizeGd32Checksum。 + * WiFi/BLE 传输层共用,避免一端只 complete、另一端漏算 SHA 的分叉。 + * @return finalizeGd32Checksum 的返回值(complete 本身无错误码)。 + */ + esp_err_t finishGd32StorageAfterReceive(); + // ── GD32 存储操作(传输层通过协调器操作 Flash,解决 H3 绕过问题)── /** * @brief 向 GD32 Flash 分区写入固件数据块。 diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 9cfde49..307a6e3 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -10,7 +10,6 @@ * 协议子步骤与加密/Flash 工具方法保持不变。 */ #include -#include "monitor/can_feedback_task.h" #include #include #include "freertos/FreeRTOS.h" @@ -258,12 +257,13 @@ ota_result_t OtaRelay::handleHandshaking(relay_event_t event, const uint8_t *pay // 进入 HANDSHAKING 阶段:执行完整的握手 + 认证流程 DEBUG_INFO("OTA", "=== 阶段1: 握手与认证开始 ==="); - // 步骤1: 请求 GD32 进入 Bootloader - // 0xB0 对 APP:触发写标志+复位 → BOOT 启动 - // 0xB0 对 BOOT:返回 0xB5 + resetToIdle()(IDLE 状态下无害) + // 步骤1: 请求 GD32 进入 Bootloader(或确认已在 BOOT) + // 必须 HELLO role=boot 成功,禁止“未确认就盲发握手” esp_err_t err = requestGd32AppEnterBootloader(); if (err != ESP_OK) { - DEBUG_WARN("OTA", "GD32 进入 Bootloader 请求未确认,继续尝试握手"); + DEBUG_ERR("OTA", "Bootloader 未就绪: %s", esp_err_to_name(err)); + setState(RELAY_ERROR); + return OTA_RESULT_ERR_TIMEOUT; } // 步骤2: 发送握手请求 @@ -311,21 +311,23 @@ ota_result_t OtaRelay::handleSizeNeg(relay_event_t event, const uint8_t *payload DEBUG_INFO("OTA", "=== 阶段2: 固件大小协商 (%" PRIu32 " 字节) ===", meta.total_size); + m_transferTotalPackets = (meta.total_size + GD32_OTA_PACKET_SIZE - 1U) / GD32_OTA_PACKET_SIZE; + m_transferCurrentSeq = 0; + m_transferFlashOffset = 0; + m_transferRetryCount = 0; + /* total 提前写入,前端可显示「共 N 包」;seq 保持 0 直到真正开始转发 */ + setRelayTotalPackets(m_transferTotalPackets); + setRelaySeq(0); + ota_result_t res = doSendFirmwareSize(meta.total_size); if (res != OTA_RESULT_OK) { setState(RELAY_ERROR); return res; } - // 初始化传输进度 - m_transferTotalPackets = (meta.total_size + GD32_OTA_PACKET_SIZE - 1U) / GD32_OTA_PACKET_SIZE; - m_transferCurrentSeq = 0; - m_transferFlashOffset = 0; - m_transferRetryCount = 0; - setRelayTotalPackets(m_transferTotalPackets); - setRelaySeq(0); - + /* 进入转发前先切状态,避免 status 长时间停在 SIZE_NEG 导致前端像卡在 0/N */ setState(RELAY_TRANSFERRING); + setRelaySeq(0); DEBUG_INFO("OTA", "=== 阶段2 完成: 总包数=%" PRIu32 ", → TRANSFERRING ===", m_transferTotalPackets); return OTA_RESULT_OK; } @@ -373,7 +375,7 @@ ota_result_t OtaRelay::handleFinishing(relay_event_t event, const uint8_t *paylo return rej; } - DEBUG_INFO("OTA", "=== 阶段4-5: 结束OTA + APP验证 ==="); + DEBUG_INFO("OTA", "结束中继,等待 GD32 APP 启动"); ota_result_t res = doFinishAndVerify(); if (res != OTA_RESULT_OK) { @@ -382,7 +384,7 @@ ota_result_t OtaRelay::handleFinishing(relay_event_t event, const uint8_t *paylo } setState(RELAY_DONE); - DEBUG_INFO("OTA", "=== OTA 全流程完成: → DONE ==="); + // 成功总结只在 doWaitForAppAlive 打一条,避免多层「完成」刷屏 return OTA_RESULT_OK; } @@ -572,8 +574,8 @@ esp_err_t OtaRelay::waitUntilNotWait(const char *stage, uint8_t *resp_type, uint32_t round = 0; while (*resp_type == SPI_OTA_RSP_WAIT && round < max_wait_rounds) { ++round; - DEBUG_WARN("OTA", "%s WAIT, poll #%" PRIu32 "...", stage, round); - esp_err_t err = SpiMaster::instance().otaPollResponse(resp_type, resp_payload, resp_max, resp_len, 100); + /* 进度/等待文案只走前端;此处不打 INFO,避免 /logs 刷屏 */ + esp_err_t err = SpiMaster::instance().otaPollResponse(resp_type, resp_payload, resp_max, resp_len, timeout_ms); if (err == ESP_ERR_TIMEOUT) { *resp_type = SPI_OTA_RSP_WAIT; continue; } if (err != ESP_OK) return err; logRespPayload(stage, *resp_type, resp_payload, resp_len ? *resp_len : 0); @@ -667,11 +669,18 @@ esp_err_t OtaRelay::pollUntilExpectedAckShape(const char *stage, if (err != ESP_OK) return err; } for (uint32_t extra = 0; extra < maxExtraPolls; ++extra) { - if (*resp_type != SPI_OTA_RSP_ACK) return ESP_OK; - if (matcher(resp_payload, *resp_len)) return ESP_OK; - uint32_t err_seq = 0; uint8_t err_code = 0; - if (parseStructuredError(resp_payload, *resp_len, &err_seq, &err_code)) return ESP_OK; - DEBUG_WARN("OTA", "%s stale ACK, poll more: len=%u", stage, (unsigned)*resp_len); + if (*resp_type == SPI_OTA_RSP_ACK) { + if (matcher(resp_payload, *resp_len)) return ESP_OK; + uint32_t err_seq = 0; uint8_t err_code = 0; + if (parseStructuredError(resp_payload, *resp_len, &err_seq, &err_code)) return ESP_OK; + DEBUG_WARN("OTA", "%s stale ACK, poll more: len=%u", stage, (unsigned)*resp_len); + } else if (*resp_type == SPI_OTA_RSP_ERROR || *resp_type == 0x7FU) { + /* 明确错误帧:交给调用方判失败,不再空转 poll */ + return ESP_OK; + } else { + /* 非 ACK(含 0xC1 事件/空 type):继续 poll 等延迟响应,勿提前返回 */ + DEBUG_WARN("OTA", "%s unexpected type=0x%02X, poll more", stage, *resp_type); + } *resp_len = 0; err = SpiMaster::instance().otaPollResponse(resp_type, resp_payload, resp_max, resp_len, poll_timeout_ms); if (err != ESP_OK) return err; @@ -761,17 +770,49 @@ void OtaRelay::calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48, esp_err_t OtaRelay::requestGd32AppEnterBootloader() { DEBUG_INFO("OTA", "尝试通知 GD32 APP 进入 Bootloader OTA 窗口"); - SpiMaster::instance().appRequestBootloader(); - /* 方案A: 通过 PB6 LOW→HIGH 序列等待 Bootloader 就绪 - * APP 模式: 复位前拉低 PB6 → BOOT 极早拉低 → init 完成释放 HIGH - * BOOT 模式: PB6 一直 HIGH,Phase1 超时后直接握手 - * 超时退化为固定延时(容错旧固件) */ - esp_err_t wait_err = SpiMaster::instance().waitForBootloaderReady(3000); - if (wait_err != ESP_OK) { - DEBUG_WARN("OTA", "硬件就绪信号超时,退化为固定延时"); - vTaskDelay(pdMS_TO_TICKS(1500)); + /* 1) 探测:已在 BOOT 则零开销直接成功 */ + esp_err_t hello_err = SpiMaster::instance().hello(1500U, 0U); + if (hello_err == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_BOOT) { + DEBUG_INFO("OTA", "GD32 已在 Bootloader (HELLO role=boot),跳过 ENTER_BOOT"); + return ESP_OK; } + + /* 2) 发 ENTER_BOOT(ACK 可选;发送后静默,门控是 BOOT HELLO) */ + if (hello_err != ESP_OK) { + DEBUG_WARN("OTA", "探测 HELLO 未成功: %s,仍尝试 ENTER_BOOT", + esp_err_to_name(hello_err)); + } + esp_err_t boot_err = SpiMaster::instance().appRequestBootloader(); + if (boot_err != ESP_OK) { + DEBUG_WARN("OTA", "ENTER_BOOT 发送失败: %s", esp_err_to_name(boot_err)); + } + + /* 3) 硬门控:HELLO role=boot(给 APP 擦页+复位+BOOT 起机更长时间) */ + esp_err_t wait_err = SpiMaster::instance().waitForBootloaderReady(12000); + if (wait_err == ESP_OK) { + DEBUG_INFO("OTA", "Bootloader 已就绪 (HELLO role=boot)"); + return ESP_OK; + } + + /* 4) 一次重试:仍为 APP 时再 HELLO + ENTER_BOOT */ + DEBUG_WARN("OTA", "BOOT HELLO 超时,二次 HELLO+ENTER_BOOT 重试..."); + hello_err = SpiMaster::instance().hello(2000U, 0U); + if (hello_err == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_BOOT) { + DEBUG_INFO("OTA", "二次探测已在 Bootloader,继续"); + return ESP_OK; + } + boot_err = SpiMaster::instance().appRequestBootloader(); + if (boot_err != ESP_OK) { + DEBUG_WARN("OTA", "二次 ENTER_BOOT 未确认 ACK: %s(仍等 BOOT HELLO)", + esp_err_to_name(boot_err)); + } + wait_err = SpiMaster::instance().waitForBootloaderReady(12000); + if (wait_err != ESP_OK) { + DEBUG_ERR("OTA", "Bootloader HELLO 就绪失败(重试后): %s", esp_err_to_name(wait_err)); + return wait_err; + } + DEBUG_INFO("OTA", "Bootloader 已就绪 (HELLO role=boot, 二次)"); return ESP_OK; } @@ -785,18 +826,27 @@ ota_result_t OtaRelay::doHandshakeReq(uint32_t attempts) uint8_t last_resp_type = 0U; size_t last_resp_len = 0U; esp_err_t last_err = ESP_OK; - constexpr uint32_t kHandshakeReqTimeoutMs = 200U; - constexpr uint32_t kHandshakePollTimeoutMs = 200U; - constexpr uint32_t kHandshakeRetryGapMs = 100U; + constexpr uint32_t kHandshakeReqTimeoutMs = 800U; + constexpr uint32_t kHandshakePollTimeoutMs = 800U; + constexpr uint32_t kHandshakeRetryGapMs = 50U; for (uint32_t attempt = 0U; attempt < attempts; ++attempt) { uint8_t resp_type = 0U; uint8_t resp_payload[64] = {0}; size_t resp_len = 0U; + /* 每若干次失败做一次 HELLO 重同步(session/从机状态) */ + if (attempt > 0U && (attempt % 5U) == 0U) { + esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_BOOT); + DEBUG_WARN("OTA", "HANDSHAKE 前 HELLO 重同步: %s role=%u", + esp_err_to_name(hr), SpiMaster::instance().gd32Role()); + } + + /* otaTransfer 已含延迟一拍两步法;再经 pollUntil 处理 WAIT/陈旧 ACK。 */ + const uint32_t total_timeout_ms = kHandshakeReqTimeoutMs + kHandshakePollTimeoutMs; esp_err_t err = SpiMaster::instance().otaTransfer(SPI_OTA_CMD_REQ, payload, sizeof(payload), &resp_type, resp_payload, sizeof(resp_payload), &resp_len, - kHandshakeReqTimeoutMs); + total_timeout_ms); last_err = err; last_resp_type = resp_type; last_resp_len = resp_len; @@ -915,16 +965,19 @@ ota_result_t OtaRelay::doSendFirmwareSize(uint32_t size) esp_err_t err = SpiMaster::instance().otaTransfer(SPI_OTA_CMD_DATA, size_payload, sizeof(size_payload), &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000); logRespPayload("SEND_SIZE", resp_type, resp_payload, resp_len); - DEBUG_WARN("OTA", "SEND_SIZE poll config: poll_timeout_ms=2000 wait_rounds=300 maxExtraPolls=24"); + /* GD32 收到 SIZE 后擦 Flash,先 WAIT 再 ACK(seq=0);进度由前端读 status,不打过程 INFO */ err = pollUntilExpectedAckShape("SEND_SIZE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, - 2000, 300, 24, OtaRelay::isSizeSeq0Ack); + /*poll_timeout_ms=*/500, + /*wait_rounds=*/120, + /*maxExtraPolls=*/40, + OtaRelay::isSizeSeq0Ack); if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !isSizeSeq0Ack(resp_payload, resp_len)) { DEBUG_ERR("OTA", "SEND_SIZE 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len); return OTA_RESULT_ERR_TIMEOUT; } - DEBUG_INFO("OTA", "大小已发送: %" PRIu32 " 字节,等待 GD32 确认...", size); + DEBUG_INFO("OTA", "大小协商完成: %" PRIu32 " 字节,GD32 已就绪收包", size); return OTA_RESULT_OK; } @@ -988,17 +1041,33 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], // ★ 单包耗时统计,用于定位 OTA 速度瓶颈。 TickType_t pkt_start = xTaskGetTickCount(); + /* 单包硬超时:避免重试环 + WAIT/CREDIT 轮询在异常时“静默卡死”十几分钟。 */ + const TickType_t pkt_deadline = pkt_start + pdMS_TO_TICKS(15000); + TickType_t last_stall_log = pkt_start; // 注意:goto resend_packet 保持原有语义,仅作用范围缩小到本 helper。 // 完全去除 goto 需配合 GD32 全链路 OTA 回归验证后再进行。 resend_packet: while (1) { + if (xTaskGetTickCount() >= pkt_deadline) { + DEBUG_ERR("OTA", "包 %" PRIu32 " 单包超时(15s), retries=%" PRIu32 " credit=%u", + seq, retry_count, (unsigned)SpiMaster::instance().remoteCredit()); + result = OTA_RESULT_ERR_TIMEOUT; + break; + } + if ((xTaskGetTickCount() - last_stall_log) >= pdMS_TO_TICKS(2000)) { + DEBUG_INFO("OTA", "包 %" PRIu32 " 仍在等待 ACK (retry=%" PRIu32 " credit=%u)", + seq, retry_count, (unsigned)SpiMaster::instance().remoteCredit()); + last_stall_log = xTaskGetTickCount(); + } + resp_len = 0; esp_err_t err = SpiMaster::instance().otaTransfer(SPI_OTA_CMD_DATA, tx_buf, 64, &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000); logRespPayload("DATA_PACKET", resp_type, resp_payload, resp_len); if (err == ESP_OK) { - err = waitUntilNotWait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10); + /* WAIT 常见于 GD32 主循环 AES/写 Flash:多轮 poll,勿过早放弃。 */ + err = waitUntilNotWait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 200, 40); } if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) { DEBUG_WARN("OTA", "数据包 %" PRIu32 " 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len); @@ -1007,7 +1076,7 @@ resend_packet: result = OTA_RESULT_ERR_TIMEOUT; break; } - vTaskDelay(pdMS_TO_TICKS(1)); + vTaskDelay(pdMS_TO_TICKS(2)); continue; // 重发本包 } @@ -1026,11 +1095,13 @@ resend_packet: } } - /* CREDIT 感知:若 GD32 报 CREDIT=0,说明它在写 Flash 缓冲, - * 此时重发只会被丢弃。改为耐心 POLL 等待 CREDIT 恢复,最多等 500ms。 */ + /* CREDIT 感知:GD32 写 Flash / DATA 队列满时 CREDIT=0,耐心 POLL。 */ if (SpiMaster::instance().remoteCredit() == 0U) { - DEBUG_DBG("OTA", "包 %" PRIu32 " GD32 CREDIT=0(写Flash中),POLL 等待...", seq); - for (uint32_t cw = 0; cw < 50U; ++cw) { + DEBUG_INFO("OTA", "包 %" PRIu32 " GD32 CREDIT=0,等待恢复...", seq); + for (uint32_t cw = 0; cw < 100U; ++cw) { + if (xTaskGetTickCount() >= pkt_deadline) { + break; + } err = SpiMaster::instance().otaPollResponse(&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 100); if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK && @@ -1038,10 +1109,13 @@ resend_packet: got_expected_credit_ok = true; break; } - if (err == ESP_OK && SpiMaster::instance().remoteCredit() > 0U) { - break; /* CREDIT 恢复,可以继续轮询或重发 */ + if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) { + continue; } - vTaskDelay(pdMS_TO_TICKS(1)); + if (err == ESP_OK && SpiMaster::instance().remoteCredit() > 0U) { + break; + } + vTaskDelay(pdMS_TO_TICKS(2)); } } @@ -1120,8 +1194,6 @@ resend_packet: ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) { - DEBUG_INFO("OTA", "开始转发 %" PRIu32 " 包", m_transferTotalPackets); - ota_meta_t meta; if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK) { DEBUG_ERR("OTA", "元数据加载失败"); @@ -1160,19 +1232,20 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) } setRelaySeq(seq + 1); - // 每 5% 或前 5 包/最后 5 包打印进度,避免 9 秒+静默期无法判断是否卡死 - uint32_t step = m_transferTotalPackets / 20 + 1; // ~5% 间隔 - if (seq < 5 || seq >= m_transferTotalPackets - 5 || seq % step == 0) { - // 进度日志直接用本地 seq+1,避免再次加锁读 relaySeq - uint32_t done = seq + 1; - uint32_t progress = (done * 100U) / m_transferTotalPackets; - DEBUG_INFO("OTA", "转发进度: %" PRIu32 "/%" PRIu32 " 包 (%" PRIu32 "%%)", done, m_transferTotalPackets, progress); - } + // 进度只写 relay_seq 供 /system/status;不打文字日志(前端进度条负责展示)。 - // ★ 包间延迟:给 GD32 Bootloader 充足时间完成 flash 写入并准备下一包 ACK。 - // 无此延迟时,ESP32 立即 poll 会读到上一包的 stale ACK,导致误判为丢包。 - if (seq < m_transferTotalPackets - 1 && CONFIG_DCU_OTA_PACKET_INTERVAL_MS > 0) { - vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_PACKET_INTERVAL_MS)); + // ★ 包间延迟:给 GD32 写 Flash + 让低优先级 httpd 能应答 /system/status。 + // ota_relay 优先级高于 wifi_ota:若不主动让出,前端只能看到 SIZE→完成,中间全跳过。 + if (seq < m_transferTotalPackets - 1) { + uint32_t gap_ms = CONFIG_DCU_OTA_PACKET_INTERVAL_MS; + if (gap_ms < 1U) { + gap_ms = 1U; + } + /* 每 16 包额外让出 15ms,保证 status/logs 轮询有机会跑 */ + if (((seq + 1U) % 16U) == 0U) { + gap_ms = (gap_ms > 15U) ? gap_ms : 15U; + } + vTaskDelay(pdMS_TO_TICKS(gap_ms)); } } return result; @@ -1195,10 +1268,11 @@ bool OtaRelay::probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime) gd32AppAlive = true; gd32Version = ver; if (out_uptime) *out_uptime = uptime; - DEBUG_INFO("OTA", "GD32 APP 存活确认: uptime=%" PRIu32 " sec, APP=v%d.%d.%d.%d, BOOT=v%d.%d.%d.%d", - uptime, - ver.app_major, ver.app_minor, ver.app_patch, ver.app_build, - ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build); + // 启动探测等场景用 DBG;OTA 成功路径由 doWaitForAppAlive 统一 INFO 总结 + DEBUG_DBG("OTA", "GD32 APP 存活: uptime=%" PRIu32 "s APP=v%d.%d.%d.%d BOOT=v%d.%d.%d.%d", + uptime, + ver.app_major, ver.app_minor, ver.app_patch, ver.app_build, + ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build); return true; } if (out_uptime) *out_uptime = 0; @@ -1271,27 +1345,31 @@ ota_result_t OtaRelay::doSendFinishOta() return OTA_RESULT_OK; } -/** @brief 协议子步骤:轮询等待 GD32 APP 存活(30 次重试)。 */ +/** @brief 协议子步骤:轮询等待 GD32 APP 存活(10 次重试)。 */ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms) { // ★ APP 存活验证 setGd32AppAlive(false); - for (uint32_t attempt = 0; attempt < 30; ++attempt) { + for (uint32_t attempt = 0; attempt < 10; ++attempt) { vTaskDelay(pdMS_TO_TICKS(500)); if (probeAndCacheAppAlive(timeout_ms)) { - DEBUG_INFO("OTA", "=== OTA 全流程完成 (APP 已启动) ==="); break; } - DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/30)", attempt + 1); + DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/10)", attempt + 1); } if (!getGd32AppAlive()) { - DEBUG_ERR("OTA", "GD32 APP alive confirm timeout, OTA failed"); + DEBUG_ERR("OTA", "GD32 APP 启动确认超时,OTA 失败"); reset(); return OTA_RESULT_ERR_GD32; } + // 全流程唯一成功总结(版本来自 probe 写入的快照) + gd32_version_t ver = getSnapshot().version; + DEBUG_INFO("OTA", "GD32 OTA 完成: APP=v%d.%d.%d.%d, BOOT=v%d.%d.%d.%d", + ver.app_major, ver.app_minor, ver.app_patch, ver.app_build, + ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build); return OTA_RESULT_OK; } @@ -1311,11 +1389,10 @@ ota_result_t OtaRelay::doFinishAndVerify() memset(m_nonceServer, 0, sizeof(m_nonceServer)); memset(m_gd32UidRuntime, 0, sizeof(m_gd32UidRuntime)); - DEBUG_INFO("OTA", "=== OTA 完成,等待 GD32 APP 启动 ==="); - + // 元数据清理安静进行;等待/成功总结由 doWaitForAppAlive 统一输出 Gd32FlashStore::instance().metaClear(); - // 阶段2: APP 存活轮询(单次 appPing 超时 1000ms,共 30 次) + // 阶段2: APP 存活轮询 return doWaitForAppAlive(1000); } @@ -1357,7 +1434,6 @@ void OtaRelay::taskEntry(void *pvParameters) DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg); // ★ H4: 通过 IRelayListener 接口回调通知协调器(不再直接依赖 OtaCoordinator) IRelayListener *listener = getRelayListener(); - pauseCanFeedbackTask(true); if (listener) listener->onRelayStarted(); ota_result_t result = instance().forwardFirmware(); @@ -1365,7 +1441,7 @@ void OtaRelay::taskEntry(void *pvParameters) xQueueSend(resultQueue, &result_msg, pdMS_TO_TICKS(100)); if (result == OTA_RESULT_OK) { - DEBUG_INFO("OTA", "OTA 转发成功完成"); + // 成功日志已在 doWaitForAppAlive 输出,此处不再重复 vTaskDelay(pdMS_TO_TICKS(3000)); } else if (result == OTA_RESULT_ERR_USER_ABORT) { DEBUG_ERR("OTA", "OTA 全局超时中止 (OTA_RESULT_ERR_USER_ABORT=%d)", result); @@ -1376,6 +1452,5 @@ void OtaRelay::taskEntry(void *pvParameters) // ★ H4: 通过 IRelayListener 接口回调通知协调器中继结束 listener = getRelayListener(); if (listener) listener->onRelayComplete(result == OTA_RESULT_OK); - pauseCanFeedbackTask(false); } } diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 6621537..110b260 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -353,7 +353,7 @@ private: */ ota_result_t doSendFinishOta(); /** - * @brief 轮询等待 GD32 APP 存活(30 次重试). + * @brief 轮询等待 GD32 APP 存活(10 次重试). * @param timeout_ms 单次 appPing 超时(毫秒)。 * @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_GD32 超时失败。 */ diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index ba175fe..529a5fe 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -1196,8 +1196,6 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, uint32_t afterReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); bool progressMade = (afterReceived > beforeReceived); - DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u progress=%d", - seq, (unsigned int)afterReceived, progressMade ? 1 : 0); // 完成判定:以实际按序写入字节数为准。 bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD); @@ -1239,35 +1237,59 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, * @brief 处理单个 GD32 OTA 数据包(ble_ota_process_data_batch 子步骤)。 * @return true 中止整个 batch;false 继续下一包(offset 由外层统一推进)。 */ +/** GD32 路径硬失败:与 WiFi abortCurrentIfEpoch 对齐,复位协调器 + 本通道 RAM meta。 */ +static void ble_gd32_fail_abort(const char *why) +{ + DEBUG_ERR(TAG, "GD32 OTA 中止: %s", why ? why : "?"); + OtaCoordinator::instance().abortCurrent(); + s_ble_gd32_meta = {}; + s_ble_gd32_meta_active = false; + s_gd32_ooo_count = 0; + ble_ota_restore_wifi_ps(); +} + static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, const uint8_t *buf, size_t data_offset, size_t data_len, uint32_t expected_payload) { if (seq == 0 && total_size > 0) { - // ★ 统一调用 prepareGd32Storage 完成:validate + begin + erase + clearMeta + saveMeta - // 原 BLE 路径未擦除分区,现通过 prepareGd32Storage 与 HTTP 路径对齐。 - esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage(total_size, OTA_CHANNEL_BLE); - if (prep_err == ESP_ERR_INVALID_SIZE) { - DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " max=%zu", - total_size, OTA_MAX_FIRMWARE_SIZE); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_SIZE, total_size); - return true; - } else if (prep_err == ESP_ERR_INVALID_STATE) { - DEBUG_ERR(TAG, "GD32 OTA 协调器拒绝存储(系统忙)"); - sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); - return true; - } else if (prep_err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); - return true; - } + // 与 ESP32 BLE 路径一致:活跃会话中的 seq=0 视为首包重传,禁止再次 prepare + // (否则会 erase 分区、清 meta,毁掉已写入进度)。 + if (s_ble_gd32_meta_active && s_ble_gd32_meta.total_size == total_size) { + if (s_ble_gd32_meta.stored_size > 0) { + DEBUG_INFO(TAG, "GD32 会话已激活,seq=0 视为重传,跳过写入 stored=%" PRIu32, + s_ble_gd32_meta.stored_size); + sendWindowAck(cmd, 0, s_ble_gd32_meta.stored_size); + return false; + } + DEBUG_INFO(TAG, "GD32 会话已激活且未落盘,seq=0 重传首块(不重新 prepare)"); + // fall through:stored_size==0 时重写 offset 0 + } else { + // 与 WiFi 共用 prepareGd32Storage:validate + begin + erase + clearMeta + saveMeta + esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage( + total_size, OTA_CHANNEL_BLE); + if (prep_err == ESP_ERR_INVALID_SIZE) { + DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " max=%zu", + total_size, OTA_MAX_FIRMWARE_SIZE); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_SIZE, total_size); + return true; + } else if (prep_err == ESP_ERR_INVALID_STATE) { + DEBUG_ERR(TAG, "GD32 OTA 协调器拒绝存储(系统忙)"); + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + return true; + } else if (prep_err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + return true; + } - DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu", - total_size, data_len); - s_ble_gd32_meta = {}; - s_ble_gd32_meta.total_size = total_size; - s_ble_gd32_meta_active = true; - ble_ota_boost_wifi_ps(); + DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu", + total_size, data_len); + s_ble_gd32_meta = {}; + s_ble_gd32_meta.total_size = total_size; + s_ble_gd32_meta_active = true; + ble_ota_boost_wifi_ps(); + } } if (!s_ble_gd32_meta_active || s_ble_gd32_meta.total_size == 0) { @@ -1325,6 +1347,8 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, if (flash_offset + (uint32_t)data_len > meta.total_size) { DEBUG_ERR(TAG, "GD32 写入越界: offset=%" PRIu32 " len=%zu total=%" PRIu32, flash_offset, data_len, meta.total_size); + ble_gd32_fail_abort("write OOB"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0); return true; } @@ -1332,6 +1356,8 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, if (err != ESP_OK) { DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s", seq, flash_offset, esp_err_to_name(err)); + ble_gd32_fail_abort(esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err); return true; } @@ -1351,6 +1377,7 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, if (err != ESP_OK) { DEBUG_ERR(TAG, "GD32 元数据 checkpoint 失败 (seq=%u): %s", seq, esp_err_to_name(err)); + ble_gd32_fail_abort(esp_err_to_name(err)); sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err); return true; } @@ -1365,9 +1392,9 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)", meta.stored_size, meta.total_size, seq + 1); s_ble_gd32_meta_active = false; - OtaCoordinator::instance().completeGd32Storage(); ble_ota_restore_wifi_ps(); - esp_err_t sha_err = OtaCoordinator::instance().finalizeGd32Checksum(); + // 与 WiFi 共用 finishGd32StorageAfterReceive:complete + SHA + esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); if (sha_err != ESP_OK) { DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); } diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index 1efeeaa..fabdcb2 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -14,6 +14,8 @@ #include "esp_system.h" #include "esp_timer.h" #include "esp_ota_ops.h" +#include "esp_task_wdt.h" +#include "esp_heap_caps.h" #include "cJSON.h" #include "app_config.h" @@ -29,17 +31,9 @@ #include "spi_master.h" #include "canfd_bridge.h" #include "monitor/can_feedback_task.h" +#include "monitor/task_stack_check.h" #include "ble_ota.h" -// OTA 准备状态:/ota/prepare 完成后设置,/ota/upload 消费后清除 -// ★ 同时记录 prepare 时的 epoch,clear 时仅在同一会话才中止,避免误清新会话 -static bool s_esp32_prepared = false; -static uint32_t s_esp32_prepare_size = 0; -static uint32_t s_esp32_prepare_epoch = 0; -static bool s_gd32_prepared = false; -static uint32_t s_gd32_prepare_size = 0; -static uint32_t s_gd32_prepare_epoch = 0; - void http_set_cors_headers(httpd_req_t *req) { httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); @@ -48,27 +42,6 @@ void http_set_cors_headers(httpd_req_t *req) httpd_resp_set_hdr(req, "Access-Control-Max-Age", "600"); } -static void clear_esp32_prepare() -{ - if (s_esp32_prepared) { - // ★ 仅在 prepare 仍属当前会话时中止;同类型重启已发生时跳过,避免误清新会话 - OtaCoordinator::instance().abortCurrentIfEpoch(s_esp32_prepare_epoch); - s_esp32_prepared = false; - s_esp32_prepare_size = 0; - s_esp32_prepare_epoch = 0; - } -} - -static void clear_gd32_prepare() -{ - if (s_gd32_prepared) { - OtaCoordinator::instance().abortCurrentIfEpoch(s_gd32_prepare_epoch); - s_gd32_prepared = false; - s_gd32_prepare_size = 0; - s_gd32_prepare_epoch = 0; - } -} - esp_err_t http_options_handler(httpd_req_t *req) { http_set_cors_headers(req); @@ -203,16 +176,21 @@ esp_err_t ota_upload_handler(httpd_req_t *req) { http_set_cors_headers(req); - // 每请求分配 OTA 接收缓冲区:OTA_BLOCK_SIZE=8KB > 4KB 不适合栈分配, - // 改为 per-request malloc 避免 ESP32/GD32 两条路径共享全局 buffer, - // max_open_sockets=4 时多连接并发也互不干扰。 + // per-request 接收块:2KB,避免大块 malloc 失败 / 碎片 uint8_t *recv_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); if (!recv_buf) { + DEBUG_ERR("WIFI", "/ota/upload malloc(%u) 失败 free=%lu largest=%lu", + (unsigned)OTA_BLOCK_SIZE, + (unsigned long)esp_get_free_heap_size(), + (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "alloc failed"); return ESP_FAIL; } - DEBUG_WARN("WIFI", "=== HTTP: POST /ota/upload === content_len=%d", req->content_len); + DEBUG_WARN("WIFI", "=== HTTP: POST /ota/upload === content_len=%d free=%lu largest=%lu", + req->content_len, + (unsigned long)esp_get_free_heap_size(), + (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len); esp_wifi_set_ps(WIFI_PS_NONE); @@ -230,19 +208,35 @@ esp_err_t ota_upload_handler(httpd_req_t *req) bool is_esp32_ota = false; uint32_t total_size = 0; int json_header_len = 0; - uint8_t pre_read_buf[256] = {0}; + // ★ 改为堆分配:pre_read_buf[256]+json_buf[256] 同时在栈上占 512B, + // 上传 100% 后 GD32 路径调用 computeFlashSha256(栈上 mbedtls_sha256_context + // ~108B)+ 日志格式化 608B,叠加逼近 8192 栈极限 → 栈溢出卡死。 + // 移到堆上释放栈压力。json_buf 在解析完成后立即释放。 + uint8_t *pre_read_buf = (uint8_t *)calloc(256, 1); + if (!pre_read_buf) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "alloc failed"); + free(recv_buf); + free(pre_read_buf); + return ESP_FAIL; + } int pre_read_len = 1; pre_read_buf[0] = (uint8_t)peek; if (peek == '{') { - char json_buf[256]; + char *json_buf = (char *)calloc(256, 1); + if (!json_buf) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "alloc failed"); + free(recv_buf); + free(pre_read_buf); + return ESP_FAIL; + } json_buf[0] = peek; - json_header_len = recv_json_header(req, json_buf + 1, sizeof(json_buf) - 1); + json_header_len = recv_json_header(req, json_buf + 1, 255); if (json_header_len > 0) { json_header_len++; json_buf[json_header_len] = '\0'; pre_read_len = json_header_len; - if (pre_read_len > (int)sizeof(pre_read_buf)) pre_read_len = (int)sizeof(pre_read_buf); + if (pre_read_len > 256) pre_read_len = 256; memcpy(pre_read_buf, json_buf, pre_read_len); cJSON *root = cJSON_Parse(json_buf); @@ -260,45 +254,27 @@ esp_err_t ota_upload_handler(httpd_req_t *req) cJSON_Delete(root); } } + free(json_buf); // JSON 解析完成,立即释放 } if (is_esp32_ota) { if (total_size == 0) { - clear_esp32_prepare(); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing total_size"); free(recv_buf); + free(pre_read_buf); return ESP_FAIL; } - // ★ 若前端已通过 /ota/prepare 完成擦除,直接复用 prepared 状态, - // 避免在大量数据传输前再次长时间阻塞 HTTP worker。 - bool used_prepare = false; - if (s_esp32_prepared) { - if (total_size == s_esp32_prepare_size) { - used_prepare = true; - s_esp32_prepared = false; - s_esp32_prepare_size = 0; - DEBUG_INFO("WIFI", "ESP32 OTA 使用已 prepared 状态,跳过擦除"); + esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_WIFI); + if (err != ESP_OK) { + if (err == ESP_ERR_INVALID_STATE) { + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); } else { - DEBUG_WARN("WIFI", "ESP32 prepared size mismatch (prepared=%u, upload=%u),重新初始化", - s_esp32_prepare_size, total_size); - clear_esp32_prepare(); - } - } - - esp_err_t err; - if (!used_prepare) { - err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_WIFI); - if (err != ESP_OK) { - clear_esp32_prepare(); - if (err == ESP_ERR_INVALID_STATE) { - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); - } else { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); - } - free(recv_buf); - return ESP_FAIL; + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); } + free(recv_buf); + free(pre_read_buf); + return ESP_FAIL; } Esp32OtaCore &core = Esp32OtaCore::instance(); @@ -310,7 +286,10 @@ esp_err_t ota_upload_handler(httpd_req_t *req) size_t total_firmware_written = 0; size_t total_received = json_header_len; - int last_percent = 0; + size_t last_log_recv = 0; + + DEBUG_INFO("WIFI", "ESP32 OTA 开始收流: content_len=%d json_hdr=%d", + req->content_len, json_header_len); while (total_received < req->content_len) { size_t to_read = req->content_len - total_received; @@ -318,46 +297,68 @@ esp_err_t ota_upload_handler(httpd_req_t *req) int ret = httpd_req_recv(req, (char *)fw_buf, to_read); if (ret <= 0) { - DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret); - // ★ 客户端断开/接收失败:用 abortCurrentIfEpoch 复位协调器状态机 - // 若 epoch 不匹配(新请求已接管),跳过中止,避免误清新会话 + DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA recv=%u/%d", + ret, (unsigned)total_received, req->content_len); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); - clear_esp32_prepare(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); free(recv_buf); + free(pre_read_buf); return ESP_FAIL; } err = core.write(fw_buf, (size_t)ret); if (err != ESP_OK) { - DEBUG_ERR("WIFI", "OTA write failed: %s", esp_err_to_name(err)); - // ★ 同上:写入失败也用 abortCurrentIfEpoch,避免误中止新会话 + DEBUG_ERR("WIFI", "OTA write failed: %s @%u", + esp_err_to_name(err), (unsigned)total_firmware_written); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); - clear_esp32_prepare(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); free(recv_buf); + free(pre_read_buf); return ESP_FAIL; } - total_firmware_written += ret; - total_received += ret; + total_firmware_written += (size_t)ret; + total_received += (size_t)ret; - if (total_size > 0) { - int percent = (int)((total_firmware_written * 100) / total_size); - if (percent / 10 > last_percent / 10) { - last_percent = percent; - DEBUG_INFO("WIFI", "ESP32 OTA 进度: %u / %u (%d%%)", - (unsigned int)total_firmware_written, total_size, percent); - } + /* 长写 Flash:喂狗 + 进度日志,避免“浏览器 100% 而设备仍在写”时假死无日志 */ + (void)esp_task_wdt_reset(); + if (total_received - last_log_recv >= (128U * 1024U) || + total_received >= (size_t)req->content_len) { + last_log_recv = total_received; + DEBUG_INFO("WIFI", "ESP32 OTA 收流进度: %u / %d (written=%u)", + (unsigned)total_received, req->content_len, + (unsigned)total_firmware_written); } } - DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节 (跳过 JSON 头 %d 字节)", (unsigned int)core.getReceived(), json_header_len); + DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节 (跳过 JSON 头 %d 字节)", + (unsigned int)core.getReceived(), json_header_len); + DEBUG_INFO("WIFI", "堆(写完): free=%lu largest=%lu", + (unsigned long)esp_get_free_heap_size(), + (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); + + /* ★ 先释放上传缓冲再回包:堆碎时 largest~1KB,先 free 4KB/2KB 再 send, + * 避免 LWIP/httpd 发响应时 malloc pbuf 失败导致客户端卡在 100%。 */ + free(recv_buf); + recv_buf = nullptr; + free(pre_read_buf); + pre_read_buf = nullptr; + vTaskDelay(pdMS_TO_TICKS(10)); + (void)esp_task_wdt_reset(); + const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", \"target\": \"esp32\"}"; httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - free(recv_buf); - return ESP_OK; + esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp)); + if (send_err != ESP_OK) { + DEBUG_ERR("WIFI", "ESP32 OTA 响应发送失败: %s (free=%lu largest=%lu)", + esp_err_to_name(send_err), + (unsigned long)esp_get_free_heap_size(), + (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); + } else { + DEBUG_INFO("WIFI", "ESP32 OTA 上传响应已发送 (free=%lu)", + (unsigned long)esp_get_free_heap_size()); + } + return (send_err == ESP_OK) ? ESP_OK : ESP_FAIL; } const uint32_t gd32_fw_size = (uint32_t)req->content_len; @@ -367,58 +368,34 @@ esp_err_t ota_upload_handler(httpd_req_t *req) } else { gd32_valid_size = gd32_fw_size; } - if (OtaCoordinator::instance().validateGd32Size(gd32_valid_size) != ESP_OK) { + if (OtaRelay::getState() != RELAY_IDLE) { + DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState()); + OtaRelay::instance().reset(); + } + + // 与 BLE 共用 prepareGd32Storage(内含 validateGd32Size + begin + erase + meta) + esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage(gd32_valid_size, OTA_CHANNEL_WIFI); + if (prep_err == ESP_ERR_INVALID_SIZE) { DEBUG_ERR("WIFI", "firmware size invalid: valid=%u max=%u", (unsigned)gd32_valid_size, (unsigned)OTA_MAX_FIRMWARE_SIZE); - clear_gd32_prepare(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "firmware too large or alignment overflow"); free(recv_buf); + free(pre_read_buf); + return ESP_FAIL; + } else if (prep_err == ESP_ERR_INVALID_STATE) { + DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)"); + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "system busy with another OTA operation"); + free(recv_buf); + free(pre_read_buf); + return ESP_FAIL; + } else if (prep_err != ESP_OK) { + DEBUG_ERR("WIFI", "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); + char err_msg[96]; + snprintf(err_msg, sizeof(err_msg), "prepare gd32_ota failed: %s", esp_err_to_name(prep_err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg); + free(recv_buf); + free(pre_read_buf); return ESP_FAIL; - } - - // ★ 若前端已通过 /ota/prepare 完成擦除,直接复用 prepared 状态 - bool used_gd32_prepare = false; - if (s_gd32_prepared) { - if (gd32_valid_size == s_gd32_prepare_size) { - used_gd32_prepare = true; - s_gd32_prepared = false; - s_gd32_prepare_size = 0; - DEBUG_INFO("WIFI", "GD32 OTA 使用已 prepared 状态,跳过擦除"); - } else { - DEBUG_WARN("WIFI", "GD32 prepared size mismatch (prepared=%u, upload=%u),重新初始化", - s_gd32_prepare_size, gd32_valid_size); - clear_gd32_prepare(); - } - } - - if (!used_gd32_prepare) { - if (OtaRelay::getState() != RELAY_IDLE) { - DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState()); - OtaRelay::instance().reset(); - } - - // ★ 统一调用 prepareGd32Storage 完成:begin + erase + clearMeta + saveMeta - // 与 BLE 路径(process_gd32_packet seq=0)共用同一序列,消除隐式差异。 - esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage(gd32_valid_size, OTA_CHANNEL_WIFI); - if (prep_err == ESP_ERR_INVALID_STATE) { - DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)"); - clear_gd32_prepare(); - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "system busy with another OTA operation"); - free(recv_buf); - return ESP_FAIL; - } else if (prep_err != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); - clear_gd32_prepare(); - char err_msg[96]; - snprintf(err_msg, sizeof(err_msg), "prepare gd32_ota failed: %s", esp_err_to_name(prep_err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg); - free(recv_buf); - return ESP_FAIL; - } - - WifiOta::instance().stateLock(); - WifiOta::instance().setGd32StorageDone(false); - WifiOta::instance().stateUnlock(); } // ★ 捕获当前 epoch,用于失败清理时检查会话是否仍属于本 handler @@ -428,13 +405,14 @@ esp_err_t ota_upload_handler(httpd_req_t *req) int received = 0; - // expected_fw_size_raw 仅用于后续日志输出;meta 已由 prepareGd32Storage - // (非 prepared 路径)或 /ota/prepare(prepared 路径)写入,此处不再重复保存。 + // expected_fw_size_raw 仅用于后续日志;meta 已由 prepareGd32Storage 写入 uint32_t expected_fw_size_raw = gd32_valid_size; uint32_t gd32_offset = 0; DEBUG_INFO("WIFI", "GD32 raw upload start: %u bytes", expected_fw_size_raw); + size_t last_log_recv = (size_t)received; + if (json_header_len == 0 && pre_read_len > 0) { int first_chunk_len = pre_read_len; if (first_chunk_len > req->content_len) first_chunk_len = req->content_len; @@ -448,6 +426,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); free(recv_buf); + free(pre_read_buf); return ESP_FAIL; } received = first_chunk_len; @@ -470,6 +449,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted"); free(recv_buf); + free(pre_read_buf); return ESP_FAIL; } @@ -481,11 +461,20 @@ esp_err_t ota_upload_handler(httpd_req_t *req) OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); free(recv_buf); + free(pre_read_buf); return ESP_FAIL; } received += ret; gd32_offset += (uint32_t)ret; + + (void)esp_task_wdt_reset(); + if ((size_t)received - last_log_recv >= (64U * 1024U) || + (size_t)received >= (size_t)req->content_len) { + last_log_recv = (size_t)received; + DEBUG_INFO("WIFI", "GD32 OTA 收流进度: %d / %d (written=%u)", + received, req->content_len, (unsigned)gd32_offset); + } } OtaCoordinator::instance().updateGd32Meta(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); @@ -495,23 +484,42 @@ esp_err_t ota_upload_handler(httpd_req_t *req) ota_meta_t final_meta; OtaCoordinator::instance().loadGd32Meta(&final_meta); if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) { - OtaCoordinator::instance().completeGd32Storage(); - WifiOta::instance().stateLock(); - WifiOta::instance().setGd32StorageDone(true); - WifiOta::instance().stateUnlock(); - DEBUG_INFO("WIFI", "GD32 firmware fully stored, pre-computing SHA-256..."); - esp_err_t sha_err = OtaCoordinator::instance().finalizeGd32Checksum(); + /* 先释放上传缓冲再算 SHA,避免与 2KB recv 并存 */ + free(recv_buf); + recv_buf = nullptr; + free(pre_read_buf); + pre_read_buf = nullptr; + vTaskDelay(pdMS_TO_TICKS(10)); + (void)esp_task_wdt_reset(); + + DEBUG_INFO("WIFI", "GD32 firmware fully stored, pre-computing SHA-256 (free=%lu)...", + (unsigned long)esp_get_free_heap_size()); + // 与 BLE 共用 finishGd32StorageAfterReceive:complete + SHA + esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); if (sha_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); } + } else { + free(recv_buf); + recv_buf = nullptr; + free(pre_read_buf); + pre_read_buf = nullptr; } const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\", " "\"message\": \"stored, call POST /ota/relay with sha256 to trigger\"}"; httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - free(recv_buf); - return ESP_OK; + esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp)); + if (send_err != ESP_OK) { + DEBUG_ERR("WIFI", "GD32 OTA 响应发送失败: %s (free=%lu largest=%lu)", + esp_err_to_name(send_err), + (unsigned long)esp_get_free_heap_size(), + (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); + } else { + DEBUG_INFO("WIFI", "GD32 OTA 上传响应已发送 (free=%lu)", + (unsigned long)esp_get_free_heap_size()); + } + return (send_err == ESP_OK) ? ESP_OK : ESP_FAIL; } esp_err_t ota_relay_trigger_handler(httpd_req_t *req) @@ -908,12 +916,6 @@ static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_ static constexpr size_t SYSTEM_STATUS_JSON_CAP = 8192; static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 24; -struct TaskStackMetric { - const char* name; - TaskHandle_t handle; - size_t stack_size_bytes; -}; - struct CpuTaskSample { UBaseType_t task_number; configRUN_TIME_COUNTER_TYPE runtime; @@ -968,21 +970,25 @@ static void reset_cpu_usage_baseline_locked(const TaskStatus_t* tasks, size_t ta s_cpu_has_last = true; } -static CpuUsageSnapshot sample_cpu_usage() +// ★ 改为输出参数模式: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) { - CpuUsageSnapshot result = {}; + result = {}; #if defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS) result.available = true; UBaseType_t task_count = uxTaskGetNumberOfTasks(); if (task_count == 0) { - return result; + return; } task_count = std::min(task_count + 2U, CPU_TASK_SNAPSHOT_MAX); TaskStatus_t* tasks = (TaskStatus_t*)calloc(task_count, sizeof(TaskStatus_t)); if (!tasks) { - return result; + return; } configRUN_TIME_COUNTER_TYPE total_runtime = 0; @@ -990,10 +996,16 @@ static CpuUsageSnapshot sample_cpu_usage() int64_t now_us = esp_timer_get_time(); if (captured_count == 0 || captured_count > task_count) { free(tasks); - return result; + return; + } + + // ★ previous_samples[24] = 192B,改堆分配减少栈压力 + CpuTaskSample* previous_samples = (CpuTaskSample*)calloc(CPU_TASK_SNAPSHOT_MAX, sizeof(CpuTaskSample)); + if (!previous_samples) { + free(tasks); + return; } - CpuTaskSample previous_samples[CPU_TASK_SNAPSHOT_MAX] = {}; size_t previous_count = 0; configRUN_TIME_COUNTER_TYPE previous_total = 0; int64_t previous_time_us = 0; @@ -1047,11 +1059,11 @@ static CpuUsageSnapshot sample_cpu_usage() result.usage_pct = std::max(0.0, 100.0 - result.idle_pct); } + free(previous_samples); free(tasks); #else result.available = false; #endif - return result; } static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle) @@ -1104,7 +1116,8 @@ static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUs task.usage_pct); } - return append_jsonf(buf, cap, offset, "]},"); + // 不带尾随逗号;由后续拼接块自行添加前导逗号,避免双重逗号 + return append_jsonf(buf, cap, offset, "]}"); } esp_err_t system_status_handler(httpd_req_t *req) @@ -1139,20 +1152,27 @@ esp_err_t system_status_handler(httpd_req_t *req) size_t internal_largest_free_block = heap_caps_get_largest_free_block(INTERNAL_HEAP_CAPS); size_t internal_heap_total = heap_caps_get_total_size(INTERNAL_HEAP_CAPS); size_t default_heap_total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT); - TaskStackMetric task_metrics[] = { - { "http_server", xTaskGetCurrentTaskHandle(), HTTP_SERVER_STACK_SIZE }, - { "health_mon", HealthMonitor::instance().getTaskHandle(), 4096 }, - { "ota_relay", OtaRelay::getTaskHandle(), 6144 }, - { "wifi_ota", WifiOta::instance().getTaskHandle(), 8192 }, - { "wifi_reconnect", WifiOta::instance().m_reconnectTask, 4096 }, - { "ble_ota", BleOta::instance().getTaskHandle(), 4096 }, - { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), 4096 } - }; - CpuUsageSnapshot cpu = sample_cpu_usage(); + // 任务栈指标复用 health_monitor 共享的任务表(collectTaskStackMetrics), + // 避免与巡检逻辑两地维护;http_server 在本 handler 上下文中,句柄即当前任务。 + TaskStackMetric task_metrics[8]; + size_t task_metrics_count = collectTaskStackMetrics(task_metrics, 8); + // http_server 行的句柄在 httpd 线程中取当前任务更准确 + for (size_t i = 0; i < task_metrics_count; ++i) { + if (strcmp(task_metrics[i].name, "http_server") == 0) { + task_metrics[i].handle = xTaskGetCurrentTaskHandle(); + } + } + CpuUsageSnapshot* cpu = (CpuUsageSnapshot*)calloc(1, sizeof(CpuUsageSnapshot)); + if (!cpu) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); + return ESP_FAIL; + } + sample_cpu_usage(*cpu); char* resp = (char*)calloc(1, SYSTEM_STATUS_JSON_CAP); if (!resp) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); + free(cpu); return ESP_FAIL; } @@ -1197,9 +1217,41 @@ esp_err_t system_status_handler(httpd_req_t *req) snap.relay.version.boot_major, snap.relay.version.boot_minor, snap.relay.version.boot_patch, snap.relay.version.boot_build); - len = append_cpu_status_json(resp, SYSTEM_STATUS_JSON_CAP, len, cpu); - len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, "\"task_stack\": {"); - for (size_t i = 0; i < sizeof(task_metrics) / sizeof(task_metrics[0]); ++i) { + len = append_cpu_status_json(resp, SYSTEM_STATUS_JSON_CAP, len, *cpu); + + /* SPI Link v2 统计(SPEC §12.1) */ + { + SpiMaster::SpiLinkStats ls = SpiMaster::instance().linkStats(); + const char *role_str = "unknown"; + switch (SpiMaster::instance().gd32Role()) { + case 1: role_str = "app"; break; + case 2: role_str = "boot"; break; + default: break; + } + len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, + ",\"spi_link\":{\"version\":2,\"gd32_role\":\"%s\",\"session_id\":\"0x%08lX\"," + "\"v2_active\":%s,\"remote_credit\":%u," + "\"crc_errors\":%lu,\"duplicate_requests\":%lu,\"seq_errors\":%lu," + "\"retries\":%lu,\"ready_timeouts\":%lu,\"rx_overruns\":%lu," + "\"resyncs\":%lu,\"max_resp_latency_us\":%lu}", + role_str, + (unsigned long)SpiMaster::instance().sessionId(), + SpiMaster::instance().v2Active() ? "true" : "false", + (unsigned)SpiMaster::instance().remoteCredit(), + (unsigned long)ls.crc_errors, + (unsigned long)ls.duplicate_requests, + (unsigned long)ls.seq_errors, + (unsigned long)ls.retries, + (unsigned long)ls.ready_timeouts, + (unsigned long)ls.rx_overruns, + (unsigned long)ls.resyncs, + (unsigned long)ls.max_resp_latency_us); + } + + // 前导逗号与前一块分隔;之前因 append_cpu_status_json 尾随逗号叠加 + // 形成 "],," 导致前端 JSON.parse 报 "Expected double-quoted property name" + len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, ",\"task_stack\": {"); + for (size_t i = 0; i < task_metrics_count; ++i) { len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, "%s\"%s\":{\"stack_size_bytes\":%lu,\"high_watermark_bytes\":%lu,\"peak_used_bytes\":%lu}", i == 0 ? "" : ",", @@ -1213,6 +1265,7 @@ esp_err_t system_status_handler(httpd_req_t *req) httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); free(resp); + free(cpu); return ESP_OK; } diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 8194d78..05fb3f1 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -418,9 +418,13 @@ esp_err_t WifiOta::httpServerStart() config.server_port = HTTP_SERVER_PORT; config.stack_size = HTTP_SERVER_STACK_SIZE; config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检 - config.max_open_sockets = 4; // 配合 LWIP_MAX_SOCKETS=12: 4(httpd) + 1(UDP) + ~3(LWIP内部) = 8, 余量 4; 空闲时前端 status/logs/上传可能并发,3 个易被 LRU 踢掉导致连接中断 - config.lru_purge_enable = true; // socket 满时自动清理最久未用的连接 - config.recv_wait_timeout = 30; // GD32 上传前会擦除 Flash,5 秒默认值过紧 + /* 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; + config.recv_wait_timeout = 60; // 1MB OTA 写 Flash 可能超过 30s 空闲读 config.send_wait_timeout = 30; DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ==="); @@ -432,13 +436,13 @@ esp_err_t WifiOta::httpServerStart() return err; } - // 从 NVS 恢复固件存储状态(重启后保留) + // GD32 固件就绪真相源为 OtaCoordinator / NVS meta(/system/status), + // 不再在 WifiOta 上维护影子 storage_done 标志。 { ota_meta_t boot_meta; if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK && boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) { - WifiOta::instance().setGd32StorageDone(true); - DEBUG_INFO("WIFI", "从 NVS 恢复: 固件已就绪 (%u bytes)", boot_meta.total_size); + DEBUG_INFO("WIFI", "从 NVS 恢复: GD32 固件已就绪 (%u bytes)", boot_meta.total_size); } } diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 01d514f..9200487 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -69,8 +69,6 @@ public: bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); } /** @brief 设置 STA 重试耗尽标志。 */ void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); } - /** @brief 设置 GD32 固件存储完成标志。 */ - void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); } // ── 状态互斥锁(保护复合读-改-写操作)── /** @brief 获取状态互斥锁句柄。 */ SemaphoreHandle_t getStateMutex() const { return m_stateMutex; } @@ -107,7 +105,6 @@ private: std::atomic m_staConnected{false}; std::atomic m_staRetryCount{0}; std::atomic m_staRetryExhausted{false}; - std::atomic m_gd32StorageDone{false}; SemaphoreHandle_t m_stateMutex = NULL; TaskHandle_t m_taskHandle = nullptr; diff --git a/main/storage/gd32_flash_store.cpp b/main/storage/gd32_flash_store.cpp index dd293a4..0290ff9 100644 --- a/main/storage/gd32_flash_store.cpp +++ b/main/storage/gd32_flash_store.cpp @@ -7,6 +7,7 @@ #include #include #include "esp_partition.h" +#include "esp_task_wdt.h" #include "nvs_flash.h" #include "nvs.h" #define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS @@ -15,6 +16,7 @@ #include "gd32_flash_store.h" #include "monitor/debug_log.h" #include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "freertos/semphr.h" // ============================================================ @@ -73,6 +75,49 @@ const esp_partition_t* Gd32FlashStore::getPartition() // Flash 分区读写 // ============================================================ +void Gd32FlashStore::beginWriteSession() +{ + m_erasedUpTo = 0; +} + +/** @brief 确保 [offset, offset+len) 覆盖的扇区已擦除(从 m_erasedUpTo 顺序推进)。 */ +esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len) +{ + if (len == 0) return ESP_OK; + const esp_partition_t *part = getPartition(); + if (!part) return ESP_ERR_NOT_FOUND; + + const uint32_t end = offset + (uint32_t)len; + // 目标区间已在水位内:无需再擦(顺序 OTA 不回写旧扇区) + if (end <= m_erasedUpTo) return ESP_OK; + + // 水位始终扇区对齐;只从 m_erasedUpTo 向高地址推进,禁止重擦已写扇区。 + // 若写入跳空,一并擦掉空隙,保持 [0, m_erasedUpTo) 连续已擦。 + uint32_t erase_to = (end + (kFlashSectorSize - 1U)) & ~(kFlashSectorSize - 1U); + if (erase_to > part->size) { + erase_to = part->size; + } + uint32_t erase_from = m_erasedUpTo; + if (erase_from >= erase_to) return ESP_OK; + + for (uint32_t addr = erase_from; addr < erase_to; ) { + uint32_t chunk = kFlashSectorSize; + if (addr + chunk > erase_to) { + chunk = erase_to - addr; + } + esp_err_t err = esp_partition_erase_range(part, addr, chunk); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "ensureErased: erase @%" PRIu32 " len=%" PRIu32 " failed: %s", + addr, chunk, esp_err_to_name(err)); + return err; + } + addr += chunk; + m_erasedUpTo = addr; + (void)esp_task_wdt_reset(); + } + return ESP_OK; +} + /** @brief 向 gd32_ota 分区写入数据块,自动处理非 4 字节对齐的读写。 */ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len) { @@ -91,6 +136,12 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, return ESP_ERR_INVALID_SIZE; } + // 写入前按需擦除目标扇区(替代 prepare 阶段整段预擦) + esp_err_t erase_err = ensureErasedForWrite(aligned_offset, aligned_len); + if (erase_err != ESP_OK) { + return erase_err; + } + // 快速路径:offset 和 len 都对齐 if (head_pad == 0 && len % 4 == 0) { return esp_partition_write(part, offset, data, len); @@ -155,7 +206,24 @@ esp_err_t Gd32FlashStore::erasePartition(uint32_t size) DEBUG_ERR("OTA", "erasePartition: size=%" PRIu32 " > partition=%" PRIu32, size, part->size); return ESP_ERR_INVALID_SIZE; } - return esp_partition_erase_range(part, 0, size); + // 按扇区擦除并喂狗,避免大块 erase 长时间占住调用任务 + uint32_t done = 0; + while (done < size) { + uint32_t chunk = kFlashSectorSize; + if (done + chunk > size) { + chunk = size - done; + } + esp_err_t err = esp_partition_erase_range(part, done, chunk); + if (err != ESP_OK) { + return err; + } + done += chunk; + if (done > m_erasedUpTo) { + m_erasedUpTo = done; + } + (void)esp_task_wdt_reset(); + } + return ESP_OK; } // ============================================================ @@ -279,7 +347,7 @@ esp_err_t Gd32FlashStore::metaUpdate(uint32_t stored_size, uint32_t last_seq) /** @brief OTA 完成或中止时清除元数据(全零写入)。 */ esp_err_t Gd32FlashStore::metaClear() { - DEBUG_INFO("OTA", "清除 OTA 元数据"); + DEBUG_DBG("OTA", "清除 OTA 元数据"); bool locked = lockNvs(kNvsLockTimeoutMs); if (!locked) { DEBUG_WARN("OTA", "metaClear 加锁超时"); @@ -307,43 +375,49 @@ esp_err_t Gd32FlashStore::computeFlashSha256(uint8_t out_sha256[32]) return ESP_ERR_INVALID_STATE; } - mbedtls_sha256_context ctx; - mbedtls_sha256_init(&ctx); - if (mbedtls_sha256_starts(&ctx, 0) != 0) { - mbedtls_sha256_free(&ctx); + /* static context:OTA 串行;勿放 httpd 栈(上传 100% 后 SHA 易栈溢出) */ + static mbedtls_sha256_context s_sha_ctx; + mbedtls_sha256_init(&s_sha_ctx); + if (mbedtls_sha256_starts(&s_sha_ctx, 0) != 0) { + mbedtls_sha256_free(&s_sha_ctx); return ESP_FAIL; } static constexpr size_t kChunkSize = 1024; uint8_t *buf = (uint8_t *)malloc(kChunkSize); if (!buf) { - mbedtls_sha256_free(&ctx); + mbedtls_sha256_free(&s_sha_ctx); return ESP_ERR_NO_MEM; } uint32_t remaining = meta.total_size; uint32_t offset = 0; + uint32_t wdt_chunk = 0; while (remaining > 0) { size_t to_read = (remaining > kChunkSize) ? kChunkSize : remaining; err = flashReadChunk(offset, buf, to_read); if (err != ESP_OK) { DEBUG_ERR("OTA", "computeSha256: Flash 读取失败 offset=%" PRIu32, offset); free(buf); - mbedtls_sha256_free(&ctx); + mbedtls_sha256_free(&s_sha_ctx); return err; } - if (mbedtls_sha256_update(&ctx, buf, to_read) != 0) { + if (mbedtls_sha256_update(&s_sha_ctx, buf, to_read) != 0) { free(buf); - mbedtls_sha256_free(&ctx); + mbedtls_sha256_free(&s_sha_ctx); return ESP_FAIL; } offset += to_read; remaining -= to_read; + if ((++wdt_chunk & 0x0FU) == 0U) { + (void)esp_task_wdt_reset(); + taskYIELD(); + } } free(buf); - int ret = mbedtls_sha256_finish(&ctx, out_sha256); - mbedtls_sha256_free(&ctx); + int ret = mbedtls_sha256_finish(&s_sha_ctx, out_sha256); + mbedtls_sha256_free(&s_sha_ctx); return (ret == 0) ? ESP_OK : ESP_FAIL; } diff --git a/main/storage/gd32_flash_store.h b/main/storage/gd32_flash_store.h index 3539daa..743ce1d 100644 --- a/main/storage/gd32_flash_store.h +++ b/main/storage/gd32_flash_store.h @@ -71,6 +71,12 @@ public: */ esp_err_t erasePartition(uint32_t size); + /** + * @brief 新一轮 GD32 固件写入会话开始时调用:复位按需擦除水位。 + * prepareGd32Storage 在开始收流前调用,避免整段预擦阻塞 HTTP/BLE 收包。 + */ + void beginWriteSession(); + // ── NVS 元数据持久化 ── /** @@ -147,6 +153,13 @@ private: const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化) + // ── 按需擦除水位:已保证 [0, m_erasedUpTo) 为擦除态(扇区对齐)── + // 收流时 flashWriteChunk 按写入范围推进擦除,替代上传前整段预擦。 + // 这样 HTTP 主线程可边收边擦写,浏览器进度不会在“100% 发完”后空等整段擦除。 + static constexpr uint32_t kFlashSectorSize = 4096U; + uint32_t m_erasedUpTo = 0; + esp_err_t ensureErasedForWrite(uint32_t offset, size_t len); + // ── NVS 互斥锁(保护元数据读-改-写原子性)── // lockNvs 返回是否成功 take;unlockNvs(handle) 据此决定是否 give, // 参考 OtaCoordinator::lock/unlock 模式(避免共享 bool 被并发覆盖导致死锁)。 diff --git a/main/transport/protocol.cpp b/main/transport/protocol.cpp index 2885e5d..a74f4c1 100644 --- a/main/transport/protocol.cpp +++ b/main/transport/protocol.cpp @@ -50,7 +50,6 @@ void Protocol::init() } m_initialized = true; - DEBUG_INFO("PROT", "协议层就绪"); } /** diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 7708ba4..d5f07b9 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -18,14 +18,35 @@ #include "esp_err.h" #include "esp_log.h" #include "rom/ets_sys.h" // esp_rom_delay_us (ets_delay_us) +#include "esp_random.h" // esp_random() for HELLO session_id #include "esp_intr_alloc.h" // ESP_INTR_FLAG_IRAM #include "spi_master.h" +#include "spi_link_v2.h" #include "monitor/debug_log.h" static constexpr size_t kSpiTransferMaxBytes = 256U; +/** + * @brief 计算本笔 CS 实际时钟字节数。 + * 不再固定打满 256B:GD32 BOOT 旧 RBNE 路径会 RX-OVR;即使 RX-DMA 后也浪费带宽。 + * 取 max(帧长, 最小响应窗口),上限 SPI_OTA_FRAME_MAX。 + */ +static size_t spiXferBytes(size_t frame_len) +{ + /* HELLO_ACK ~26B;OTA data ~84B。与 BOOT kTxBufSize=128 对齐,默认 128。 + * 过短会截断响应;过长(256)曾导致从机 RX-OVR。 */ + size_t n = frame_len; + if (n < 128U) { + n = 128U; + } + if (n > SPI_OTA_FRAME_MAX) { + n = SPI_OTA_FRAME_MAX; + } + return n; +} + /** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。 * 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。 */ @@ -48,21 +69,20 @@ SpiMaster& SpiMaster::instance() } /** - * @brief 下拉 SPIready(开漏驱动低)。 - * 线与架构下,ESP32 主动发起通信时调用,通知 GD32「我要来了」。 + * @brief 下拉 SPIready(v2 DATA_READY 模式下 ESP32 不再操作 PB6)。 + * 保留函数体兼容调用方,实际为空操作。 */ void SpiMaster::driveReadyLow() { - gpio_set_level((gpio_num_t)SPI_READY_GPIO, 0); + /* v2 DATA_READY: PB6 仅 GD32 下拉,ESP32 GPIO10 只输入。 */ } /** - * @brief 释放 SPIready(开漏高阻,靠上拉拉高)。 - * 线与架构下,ESP32 作为响应方完成读事务后调用。 + * @brief 释放 SPIready(v2 DATA_READY 模式下空操作)。 */ void SpiMaster::releaseReady() { - gpio_set_level((gpio_num_t)SPI_READY_GPIO, 1); + /* v2 DATA_READY: ESP32 不操作 PB6。 */ } /** @@ -123,11 +143,12 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) } /** - * @brief 等 GD32 释放 SPIready(上升沿),中断驱动非忙等。 + * @brief 等 SPIready 适合发起下一笔主机事务(v2 DATA_READY 语义)。 * - * 调用前须将 m_linkState 置为 WAITING_RESPONSE(enterBusy)。ISR 上升沿检测到该状态后 - * 发 0x02 通知唤醒本任务。GD32 写 Flash 期间保持 PB6 低,本函数阻塞, - * 写完拉高 → 唤醒 → 继续,自然避免 overrun。 + * v2:PB6 低 = GD32 有待读数据;高 = 队列空/空闲。 + * - 发新命令前:若 PB6 已高,立即返回;若仍低,可能是未读走的响应, + * 短暂等待其升高(BOOT 交付后应拉高)。超时不阻塞——CS 本身互斥。 + * - 注意:poll 读响应路径不应调用本函数等 HIGH;应等 LOW 或直接读。 */ esp_err_t SpiMaster::waitSpiReady(uint32_t timeout_ms) { @@ -136,22 +157,32 @@ esp_err_t SpiMaster::waitSpiReady(uint32_t timeout_ms) /* 已高,立即返回 */ if (readReadyLevelRaw() == 1) { return ESP_OK; } - m_spiTaskHandle = xTaskGetCurrentTaskHandle(); - /* 清遗留 0x02 通知位 */ - xTaskNotifyWait(0x02U, 0x02U, NULL, 0U); + /* PB6 低:可能有未读响应。短等升高;BOOT 修好交付清 pending 后应很快。 + * 超时仍 OK,避免卡死(随后 CS 本身可互斥推进)。 */ + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); + while (xTaskGetTickCount() < deadline) { + if (readReadyLevelRaw() == 1) { return ESP_OK; } + vTaskDelay(pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1); + } + return ESP_OK; +} + +/** + * @brief 等 GD32 拉低 PB6(有待读响应)。 + * v2 DATA_READY:低 = 有数据,与 waitSpiReady(等高)语义相反。 + * @return ESP_OK 已低;ESP_ERR_TIMEOUT 超时仍为高(调用方可再决定是否盲 poll)。 + */ +static esp_err_t waitSpiDataReady(uint32_t timeout_ms) +{ + if (readReadyLevelRaw() == 0) { return ESP_OK; } /* 已低 = 有数据 */ + if (timeout_ms == 0U) { return ESP_ERR_TIMEOUT; } const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); - for (;;) { - TickType_t now = xTaskGetTickCount(); - if (now >= deadline) { return ESP_ERR_TIMEOUT; } - TickType_t remain = deadline - now; - uint32_t notify_val = 0U; - BaseType_t ok = xTaskNotifyWait(0x02U, 0x02U, ¬ify_val, - pdMS_TO_TICKS(remain)); - if (ok == pdTRUE && (notify_val & 0x02U)) { return ESP_OK; } - /* 电平二次检查,防中断丢失 */ - if (readReadyLevelRaw() == 1) { return ESP_OK; } + while (xTaskGetTickCount() < deadline) { + if (readReadyLevelRaw() == 0) { return ESP_OK; } + vTaskDelay(pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1); } + return ESP_ERR_TIMEOUT; } // ============================================================ @@ -248,105 +279,127 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, // ============================================================ /** * @brief 计算 CRC32(MPEG-2,与 GD32 三端一致)。 + * 委托给 v2_crc32,算法完全相同。 */ uint32_t SpiMaster::otaCrc32(const uint8_t *data, size_t len) { - uint32_t crc = 0xFFFFFFFFU; - for (size_t i = 0; i < len; ++i) { - crc ^= (uint32_t)data[i] << 24; - for (int b = 0; b < 8; ++b) { - if (crc & 0x80000000U) { - crc = (crc << 1) ^ 0x04C11DB7U; - } else { - crc <<= 1; - } - } - } - return crc; + return v2_crc32(data, len); } -/* 同步帧头(6 字节): - * SOF0 SOF1 CREDIT TYPE LEN_LO LEN_HI - * 随后 payload,末尾 4 字节 CRC32(覆盖头 6 + payload,不含自身)。 */ -static constexpr size_t kSyncHeaderLen = 6U; -static constexpr size_t kSyncCrcLen = 4U; -static constexpr size_t kSyncOverhead = kSyncHeaderLen + kSyncCrcLen; +/* v1 同步帧头已升级为 v2 16B 头(见 spi_link_v2.h)。 + * 旧 6B 头常量保留为兼容注释,不再用于构建/解析。 */ +static constexpr size_t kSyncHeaderLen = V2_HEADER_LEN; /* 16 */ +static constexpr size_t kSyncCrcLen = V2_CRC_LEN; /* 4 */ +static constexpr size_t kSyncOverhead = V2_OVERHEAD; /* 20 */ -static void putU16Le(uint8_t *pp, uint16_t v) -{ - pp[0] = (uint8_t)(v & 0xFFU); - pp[1] = (uint8_t)((v >> 8) & 0xFFU); -} - -static uint16_t getU16Le(const uint8_t *pp) -{ - return (uint16_t)(pp[0] | ((uint16_t)pp[1] << 8)); -} - -static void putU32Le(uint8_t *pp, uint32_t v) -{ - pp[0] = (uint8_t)(v & 0xFFU); - pp[1] = (uint8_t)((v >> 8) & 0xFFU); - pp[2] = (uint8_t)((v >> 16) & 0xFFU); - pp[3] = (uint8_t)((v >> 24) & 0xFFU); -} - -static uint32_t getU32Le(const uint8_t *pp) -{ - return (uint32_t)pp[0] | ((uint32_t)pp[1] << 8) | - ((uint32_t)pp[2] << 16) | ((uint32_t)pp[3] << 24); -} +/* v1 LE 工具已迁移至 spi_link_v2.h 的 v2_put/v2_get 内联函数。 */ +/* + * v2 shim:旧 otaBuildFrame 接口委托给 v2_build_frame。 + * 使用当前 session/seq 成员填充 v2 帧头,FLAGS=REQUEST。 + * 记录 m_lastSentSeq 供响应归属匹配。 + */ bool SpiMaster::otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len, uint8_t *out, size_t *out_len) { if (!out || !out_len) return false; if (payload_len > 0U && payload == NULL) return false; - size_t total = kSyncOverhead + payload_len; - if (total > SPI_OTA_FRAME_MAX) return false; SpiMaster &self = SpiMaster::instance(); - out[0] = SPI_SYNC_SOF0; - out[1] = SPI_SYNC_SOF1; - out[2] = self.m_localCredit; - out[3] = type; - putU16Le(&out[4], (uint16_t)payload_len); - if (payload && payload_len > 0U) { - memcpy(&out[kSyncHeaderLen], payload, payload_len); - } - uint32_t crc = otaCrc32(out, kSyncHeaderLen + payload_len); - putU32Le(&out[kSyncHeaderLen + payload_len], crc); - *out_len = total; - return true; + const uint8_t flags = V2_FLAG_REQUEST; + const uint8_t tx_seq = self.m_txSeq; + const uint8_t ack_seq = self.m_rxAckedSeq; + const uint8_t credit = self.m_localCredit; + self.m_lastSentSeq = tx_seq; /* 记录发送序号供响应归属匹配 */ + + return v2_build_frame(type, flags, self.m_sessionId, + tx_seq, ack_seq, credit, + payload, payload_len, out, out_len); } +/* + * v2 shim:旧 otaFindFrame 接口委托给 v2_parse_frame + SESSION/SEQ 校验。 + * ESP32 是主机,收到 GD32 的 RESPONSE/EVENT 帧: + * - ack_seq 携带"GD32 确认收到的 ESP32 请求序号",用于响应归属匹配 + * - tx_seq 是 GD32 的发送序号 + */ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, uint8_t *type, const uint8_t **payload, size_t *payload_len, size_t *frame_offset) { if (!buf) return false; - for (size_t i = 0; i + kSyncOverhead <= buf_len; ++i) { - if (buf[i] != SPI_SYNC_SOF0 || buf[i + 1] != SPI_SYNC_SOF1) continue; - size_t plen = getU16Le(&buf[i + 4]); - size_t total = kSyncOverhead + plen; - if (i + total > buf_len) continue; - uint32_t expect = getU32Le(&buf[i + kSyncHeaderLen + plen]); - uint32_t calc = otaCrc32(&buf[i], kSyncHeaderLen + plen); - if (expect != calc) { - DEBUG_WARN("SPI", "SYNC CRC mismatch @%u: plen=%u", - (unsigned)i, (unsigned)plen); - continue; - } - /* 解析对端 CREDIT,让 ota_relay 的 CREDIT 感知逻辑生效 */ - SpiMaster::instance().m_remoteCredit = buf[i + 2]; - if (type) *type = buf[i + 3]; - if (payload) *payload = (plen > 0) ? &buf[i + kSyncHeaderLen] : nullptr; - if (payload_len) *payload_len = plen; - if (frame_offset) *frame_offset = i; - return true; + v2_header_t hdr; + const uint8_t *pload = nullptr; + if (!v2_parse_frame(buf, buf_len, &hdr, &pload)) { + return false; } - return false; + + SpiMaster &self = SpiMaster::instance(); + + /* 更新对端 credit */ + self.m_remoteCredit = hdr.credit; + + if (self.m_v2Active) { + /* SESSION_ID 校验 */ + if (hdr.session_id != self.m_sessionId) { + self.m_stats.seq_errors++; + const bool is_event = (hdr.flags & V2_FLAG_EVENT) != 0U; + /* EVENT 按铃帧在 GD32 session 未激活时 session_id=0(见 requestEsp32Read)。 + * 若此处直接拒绝:按铃被读走却解析失败 → 狂刷 WARN,CAN 反馈全丢。 + * 策略:EVENT 一律放行;REQUEST/RESPONSE 仍拒绝错 session。 */ + if (!is_event) { + static uint32_t s_sess_mm_logs = 0U; + if ((++s_sess_mm_logs % 32U) == 1U) { + DEBUG_WARN("SPI", "v2 SESSION mismatch: got=0x%08lX expected=0x%08lX type=0x%02X (log#%lu)", + (unsigned long)hdr.session_id, (unsigned long)self.m_sessionId, + hdr.type, (unsigned long)s_sess_mm_logs); + } + return false; + } + static uint32_t s_evt_sess0_logs = 0U; + if ((++s_evt_sess0_logs % 64U) == 1U) { + DEBUG_DBG("SPI", "EVENT session mismatch accepted: got=0x%08lX exp=0x%08lX type=0x%02X", + (unsigned long)hdr.session_id, (unsigned long)self.m_sessionId, hdr.type); + } + } + + /* 响应归属匹配:ack_seq 应等于 ESP32 刚发的 m_lastSentSeq。 + * 兼容旧 GD32 固件:业务响应曾写死 ack_seq=0。在 session 已匹配时 + * 接受 ack_seq==0,否则无法在“只刷 ESP32+BOOT、APP 仍旧”时完成 ENTER_BOOT。 */ + if (hdr.flags & V2_FLAG_RESPONSE) { + const bool ack_match = (hdr.ack_seq == self.m_lastSentSeq); + const bool legacy_zero_ack = (hdr.ack_seq == 0U); + if (!ack_match && !legacy_zero_ack) { + /* 延迟一拍下常见:上一帧 ACK 对不上当前 m_lastSentSeq。 + * 属协议常态,不刷 WARN(前端会当警告刷屏);仅 DBG 且限频。 */ + static uint32_t s_stale_logs = 0U; + if ((++s_stale_logs % 64U) == 1U) { + DEBUG_DBG("SPI", "v2 stale response: type=0x%02X ack_seq=%u expected=%u (log#%lu)", + hdr.type, hdr.ack_seq, self.m_lastSentSeq, + (unsigned long)s_stale_logs); + } + return false; + } + if (!ack_match && legacy_zero_ack) { + DEBUG_DBG("SPI", "v2 legacy ack_seq=0 accepted: type=0x%02X expected=%u", + hdr.type, self.m_lastSentSeq); + } + /* 响应归属匹配成功,更新已确认序号 */ + self.m_rxAckedSeq = self.m_lastSentSeq; + } + + /* 更新期望的对端序号(EVENT 帧的 tx_seq) */ + if (hdr.flags & V2_FLAG_EVENT) { + self.m_expectedRxSeq = hdr.tx_seq + 1U; + } + } + + if (type) *type = hdr.type; + if (payload) *payload = pload; + if (payload_len) *payload_len = hdr.payload_len; + if (frame_offset) *frame_offset = hdr.frame_offset; + return true; } // ============================================================ @@ -395,8 +448,9 @@ esp_err_t SpiMaster::init() dev_cfg.command_bits = 0; dev_cfg.address_bits = 0; dev_cfg.dummy_bits = 0; - dev_cfg.cs_ena_posttrans = 20; - dev_cfg.cs_ena_pretrans = 40; + /* 加长 CS 建立时间,给 GD32 EXTI/预装 DMA 留余量(单位:SPI 半周期相关) */ + dev_cfg.cs_ena_posttrans = 32; + dev_cfg.cs_ena_pretrans = 64; ret = spi_bus_add_device(SPI2_HOST, &dev_cfg, &m_spiDevice); if (ret != ESP_OK) { @@ -407,15 +461,15 @@ esp_err_t SpiMaster::init() DEBUG_INFO("SPI", "SPI2 总线初始化完成: MOSI=GPIO5, MISO=GPIO7, SCLK=GPIO6, CS=GPIO%d, CLK=%d Hz", SPI_MASTER_CS_GPIO, SPI_CLOCK_HZ); - /* 配置 SPI Ready 握手引脚(GPIO10),开漏线与(GPIO10<->GD32 PB6)。 - * 双方都是开漏输出+上拉,任一下拉 = 线低;双方释放 = 线高(空闲)。 - * 双边沿中断:下降沿=GD32 按铃,上升沿=GD32 响应就绪。 */ + /* 配置 SPI Ready 握手引脚(GPIO10),v2 DATA_READY 模式:仅输入。 + * PB6 仅由 GD32 下拉(有数据待发时拉低,队列空时释放高)。 + * ESP32 不再下拉 PB6,只检测边沿:下降沿=GD32 有数据按铃。 */ gpio_config_t ready_cfg = {}; ready_cfg.pin_bit_mask = (1ULL << SPI_READY_GPIO); - ready_cfg.mode = GPIO_MODE_INPUT_OUTPUT_OD; /* 开漏:可输出驱动低,也可读回电平 */ + ready_cfg.mode = GPIO_MODE_INPUT; /* v2: 仅输入,不再开漏输出 */ ready_cfg.pull_up_en = GPIO_PULLUP_ENABLE; ready_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; - ready_cfg.intr_type = GPIO_INTR_ANYEDGE; /* 双边沿中断 */ + ready_cfg.intr_type = GPIO_INTR_ANYEDGE; /* 双边沿中断 */ esp_err_t gpio_ret = gpio_config(&ready_cfg); if (gpio_ret != ESP_OK) { DEBUG_WARN("SPI", "SPI Ready 握手引脚 GPIO%d 配置失败: %s", SPI_READY_GPIO, esp_err_to_name(gpio_ret)); @@ -451,6 +505,15 @@ esp_err_t SpiMaster::init() m_gd32RequestPending = false; m_localCredit = 8; m_remoteCredit = 8; + /* v2 链路状态初始化:session 未建立,序号从 0 开始 */ + m_sessionId = 0; + m_txSeq = 0; + m_lastSentSeq = 0; + m_rxAckedSeq = 0; + m_expectedRxSeq = 0; + m_gd32Role = 0; + m_v2Active = false; + m_stats = SpiLinkStats{}; return ESP_OK; } @@ -474,9 +537,10 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { - LockGuard lock(m_mutex); - return otaTransferUnlocked(type, payload, payload_len, - resp_type, resp_payload, resp_max, resp_len, timeout_ms); + /* 公开 otaTransfer 固定走两步法:GD32 延迟一拍,首笔 CS 读到的是旧响应。 + * 与 otaSendAndPoll 同语义,保证 OTA 握手/数据包调用方无需各自再拼 poll。 */ + return otaSendAndPoll(type, payload, payload_len, + resp_type, resp_payload, resp_max, resp_len, timeout_ms); } /** @@ -518,21 +582,21 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s esp_rom_delay_us(SPI_READY_DRIVE_DELAY_US); } + const size_t xfer_len = spiXferBytes(tx_len); uint8_t tx_padded[SPI_OTA_FRAME_MAX]; - if (tx_len > sizeof(tx_padded)) { + if (tx_len > sizeof(tx_padded) || xfer_len > sizeof(tx_padded)) { DEBUG_ERR("SPI", "OTA frame %u exceeds tx_padded %u", (unsigned)tx_len, (unsigned)sizeof(tx_padded)); releaseReady(); m_linkState.exitBusy(); return ESP_ERR_INVALID_SIZE; } - memset(tx_padded, 0xFF, sizeof(tx_padded)); + memset(tx_padded, 0xFF, xfer_len); memcpy(tx_padded, tx_frame, tx_len); - constexpr size_t kRxBytes = SPI_OTA_FRAME_MAX; - uint8_t rx_raw[kRxBytes]; - memset(rx_raw, 0xFF, sizeof(rx_raw)); + uint8_t rx_raw[SPI_OTA_FRAME_MAX]; + memset(rx_raw, 0xFF, xfer_len); - esp_err_t ret = transferClocked(tx_padded, sizeof(tx_padded), rx_raw, sizeof(rx_raw), timeout_ms); + esp_err_t ret = transferClocked(tx_padded, xfer_len, rx_raw, xfer_len, timeout_ms); releaseReady(); m_linkState.exitBusy(); if (ret != ESP_OK) { @@ -544,7 +608,7 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s const uint8_t *found_payload = nullptr; size_t found_len = 0; size_t frame_offset = 0; - if (otaFindFrame(rx_raw, sizeof(rx_raw), &found_type, &found_payload, &found_len, &frame_offset)) { + if (otaFindFrame(rx_raw, xfer_len, &found_type, &found_payload, &found_len, &frame_offset)) { if (resp_type) *resp_type = found_type; if (resp_payload && resp_max > 0) { size_t copy_len = (found_len < resp_max) ? found_len : resp_max; @@ -553,6 +617,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s } } if (resp_len) *resp_len = found_len; + /* 成功收到响应,递增发送序号(重发时调用方在调用前已保存原序号) */ + m_txSeq = (uint8_t)(m_txSeq + 1U); return ESP_OK; } @@ -589,22 +655,20 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload, * @return ESP_OK 表示成功,否则返回错误码。 */ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload, - size_t resp_max, size_t *resp_len, uint32_t timeout_ms) + size_t resp_max, size_t *resp_len, uint32_t timeout_ms, + uint8_t expected_type) { - constexpr size_t kPollBytes = 160U; + constexpr size_t kPollBytes = 128U; /* 与 spiXferBytes 默认窗口一致 */ TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); uint32_t attempt = 0; while (xTaskGetTickCount() <= deadline) { ++attempt; - /* 等 GD32 释放 PB6(当前帧 onCsRelease 处理完)再发起 CS#2 poll。 - * 中断驱动:GD32 拉高 → ISR 0x02 → 唤醒。GD32 写 Flash 期间 - * PB6 保持低,本函数阻塞直到写完,自然避免 overrun。 */ + /* v2 DATA_READY:poll 是读响应。PB6 低 = 有待读数据,应立即 CS; + * 不要 waitSpiReady(等 HIGH)。首轮多等一会,给 GD32 主循环 AES 时间。 */ m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - if (waitSpiReady(SPI_READY_TIMEOUT_MS) != ESP_OK) { - /* 超时则退化为继续 poll(容错) */ - } + (void)waitSpiDataReady(attempt == 1U ? 100U : 20U); uint8_t rx_raw[kPollBytes]; uint8_t dummy[kPollBytes]; @@ -614,7 +678,10 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p uint32_t remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U; if (remain_ms == 0U) break; uint32_t spi_timeout = (remain_ms > 500U) ? 500U : remain_ms; + /* 与发送路径一致:先 driveReady 再 CS,避免 GD32 侧时序偶发丢响应 */ + driveReadyLow(); esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), spi_timeout); + releaseReady(); if (ret != ESP_OK) { vTaskDelay(pdMS_TO_TICKS(2)); continue; @@ -636,8 +703,17 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p size_t frame_offset = 0; if (otaFindFrame(rx_raw, sizeof(rx_raw), &found_type, &found_payload, &found_len, &frame_offset)) { - /* Skip empty ACK/ERROR responses (stale from previous transaction) */ - if ((found_type == SPI_OTA_RSP_ACK || found_type == SPI_OTA_RSP_ERROR) && found_len == 0) { + /* v2: 不再跳过 found_len==0 的 ACK/ERROR。 + * 延迟一拍协议下首次 poll 可能读到上一笔的过期响应, + * 调用方通过 resp_type 和 payload 内容自行判断是否为期望响应。 + * WAIT 仍需继续轮询。 */ + + /* 类型过滤:如果指定了 expected_type 且不匹配,丢弃继续轮询。 + * 避免消费掉不相关的帧(如 CAN feedback 事件帧被 PING poll 吃掉)。 */ + if (expected_type != 0xFFU && found_type != expected_type && + found_type != SPI_OTA_RSP_WAIT) { + DEBUG_DBG("SPI", "poll type mismatch: got=0x%02X want=0x%02X, discarding", + found_type, expected_type); vTaskDelay(pdMS_TO_TICKS(2)); continue; } @@ -655,6 +731,10 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_POLL_BACKOFF_MS)); continue; } + /* 真实响应匹配成功:推进发送序号。 + * expect_stale 的 step1 若读到 0xFF 不会自增 m_txSeq, + * 必须在 poll 成功时补齐,避免下一命令复用同一 TX_SEQ。 */ + m_txSeq = (uint8_t)(m_lastSentSeq + 1U); m_linkState.exitBusy(); return ESP_OK; } @@ -693,156 +773,371 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t return send_ret; } + /* 等 GD32 完成 onCsRelease,并优先等 PB6 变低(响应/WAIT 已装载)。 + * OTA DATA 在 GD32 主循环做 AES,可能数 ms;不可固定短 delay 后盲 poll。 */ + { + const TickType_t gap = (pdMS_TO_TICKS(1) > 0) ? pdMS_TO_TICKS(1) : 1; + vTaskDelay(gap); + } + m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); + const uint32_t ready_wait_ms = (timeout_ms > 800U) ? 300U : 80U; + if (waitSpiDataReady(ready_wait_ms) != ESP_OK) { + DEBUG_DBG("SPI", "DATA_READY 未拉低(%u ms),继续 poll", (unsigned)ready_wait_ms); + } + m_linkState.exitBusy(); + /* 步骤 2: 轮询读取真实响应(dummy TX,不重发命令) */ const uint32_t poll_timeout = (timeout_ms > send_timeout) ? (timeout_ms - send_timeout) : 500U; - return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout); + return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout, + /*expected_type=*/0xFFU); +} + +/** + * @brief 带重试退避的事务(SPEC §9.2)。 + * + * 同序号重试最多 3 次,退避 10/30/90ms; + * 3 次失败后 HELLO 重同步,再试 1 次。 + * 调用者须已持有锁。 + */ +esp_err_t SpiMaster::transactWithRetry(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) +{ + /* 保存原始序号用于重发时恢复 */ + const uint8_t saved_tx_seq = m_txSeq; + static constexpr uint32_t kBackoffMs[] = { 10U, 30U, 90U }; + static constexpr uint32_t kMaxRetries = 3U; + + for (uint32_t retry = 0U; retry <= kMaxRetries; ++retry) { + if (retry > 0U) { + const uint32_t backoff = kBackoffMs[(retry - 1U) < 3U ? (retry - 1U) : 2U]; + vTaskDelay(pdMS_TO_TICKS(backoff)); + m_stats.retries++; + /* 重发时恢复原序号 */ + m_txSeq = saved_tx_seq; + } + + /* 使用两步法(持锁已由调用者完成,用 Unlocked 版本) */ + const uint32_t send_timeout = (timeout_ms > 500U) ? 500U : timeout_ms; + esp_err_t send_ret = otaTransferUnlocked(type, payload, payload_len, + nullptr, nullptr, 0U, nullptr, send_timeout, + /*expect_stale=*/true); + if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) { + if (retry < kMaxRetries) { continue; } + return send_ret; + } + + const uint32_t poll_timeout = (timeout_ms > send_timeout) + ? (timeout_ms - send_timeout) : 500U; + esp_err_t ret = otaPollResponseUnlocked(resp_type, resp_payload, + resp_max, resp_len, poll_timeout, + /*expected_type=*/0xFFU); + if (ret == ESP_OK) { + return ESP_OK; + } + + if (ret == ESP_ERR_TIMEOUT) { + m_stats.ready_timeouts++; + } + + /* 3 次重试后尝试 HELLO 重同步(最后一次循环) */ + if (retry == kMaxRetries - 1U) { + DEBUG_WARN("SPI", "transactWithRetry: %u 次失败, HELLO 重同步...", (unsigned)retry); + m_v2Active = false; + /* HELLO 需要锁——但调用者已持锁。 + * hello() 内部有 LockGuard,会递归死锁。 + * 因此用 hello 的核心逻辑但不加锁:两阶段(发 + dummy 收)。 */ + m_sessionId = esp_random() | 0x00000001U; + m_txSeq = 0; + m_lastSentSeq = 0; + m_rxAckedSeq = 0; + m_expectedRxSeq = 0; + + uint8_t hello_frame[V2_MAX_FRAME]; + size_t hello_len = 0; + if (v2_build_hello_frame(m_sessionId, 0U, hello_frame, &hello_len)) { + /* 同 session 连发 2 次 HELLO,覆盖延迟一拍(勿用紧贴 dummy) */ + for (uint32_t hi = 0U; hi < 2U; ++hi) { + m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); + (void)waitSpiReady(SPI_READY_TIMEOUT_MS); + m_linkState.enterBusy(HandshakeState::MASTER_SENDING); + driveReadyLow(); + + const size_t xfer_len = spiXferBytes(hello_len); + uint8_t tx_p[SPI_OTA_FRAME_MAX]; + memset(tx_p, 0xFF, xfer_len); + memcpy(tx_p, hello_frame, hello_len); + uint8_t rx_r[SPI_OTA_FRAME_MAX]; + memset(rx_r, 0xFF, xfer_len); + esp_err_t hr = transferClocked(tx_p, xfer_len, rx_r, xfer_len, 2000U); + releaseReady(); + m_linkState.exitBusy(); + if (hr != ESP_OK) { + continue; + } + v2_header_t hdr; + const uint8_t *pl = nullptr; + if (v2_parse_frame(rx_r, xfer_len, &hdr, &pl) && + hdr.type == V2_TYPE_LINK_HELLO && + (hdr.flags & V2_FLAG_RESPONSE) && + hdr.payload_len >= 6U && pl && + v2_get_u32le(&pl[0]) == m_sessionId) { + m_gd32Role = pl[4]; + m_localCredit = pl[5]; + m_v2Active = true; + m_stats.resyncs++; + DEBUG_INFO("SPI", "transactWithRetry: HELLO 重同步成功 role=%u", m_gd32Role); + break; + } + vTaskDelay(pdMS_TO_TICKS(5)); + } + } + } + } + + return ESP_ERR_TIMEOUT; } /** - * @brief 通知 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。 + * @brief 通知 GD32 APP 设置 OTA 标志并软复位进入 Bootloader(v2 标准帧)。 * @return ESP_OK 表示成功,否则返回错误码。 */ esp_err_t SpiMaster::appRequestBootloader() { LockGuard lock(m_mutex); - DEBUG_INFO("SPI", "请求 GD32 APP 进入 Bootloader..."); + DEBUG_INFO("SPI", "请求 GD32 APP 进入 Bootloader (v2)..."); - /* 清空残留的 GD32 按铃标志,避免 can_feedback_task 的 checkGd32Request() - * 在 OTA 期间因残留标志发起 SPI 读事务,与 0xB0 通信抢占总线。 - * can_feedback_task 此时已被 pauseCanFeedbackTask(true) 暂停,但可能 - * 有一次 checkGd32Request() 正在阻塞中,清空标志让它安全返回。 */ m_gd32RequestPending = false; - constexpr uint32_t kMaxRetries = 3U; - constexpr uint32_t kPerRetryAckMs = 100U; - constexpr uint32_t kRetryGapMs = 50U; + /* 先空读 1~2 次,尽量吞掉残留 0xC1 按铃帧,避免盖住 0xB0 的延迟一拍响应。 */ + for (uint32_t d = 0U; d < 2U; ++d) { + constexpr size_t kPoll = 128U; + uint8_t rx_raw[kPoll]; + uint8_t dummy[kPoll]; + memset(dummy, 0xFF, sizeof(dummy)); + memset(rx_raw, 0xFF, sizeof(rx_raw)); + m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); + driveReadyLow(); + (void)transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), 50U); + releaseReady(); + m_linkState.exitBusy(); + vTaskDelay((pdMS_TO_TICKS(5) > 0) ? pdMS_TO_TICKS(5) : 1); + } - for (uint32_t retry = 0U; retry < kMaxRetries; ++retry) { + /* 最多 3 次 0xB0。只认 type=0xB0 + status=0xB5 为真 ACK。 + * 勿把 session=0 的 0x80 当成功(常见为过期/错帧,导致“假 ack、真没进 BOOT”)。 */ + constexpr uint32_t kMaxSends = 3U; + bool command_sent = false; + bool got_ack = false; + const TickType_t gap = (pdMS_TO_TICKS(10) > 0) ? pdMS_TO_TICKS(10) : 1; + + for (uint32_t retry = 0U; retry < kMaxSends; ++retry) { if (retry > 0U) { - DEBUG_WARN("SPI", "Bootloader 请求重发 (%lu/%lu)", (unsigned long)retry, (unsigned long)kMaxRetries); - vTaskDelay(pdMS_TO_TICKS(kRetryGapMs)); + vTaskDelay(pdMS_TO_TICKS(50)); + /* 序号/会话错乱时重同步一次再发 0xB0 */ + if (retry == 1U) { + m_v2Active = false; + m_sessionId = esp_random() | 0x00000001U; + m_txSeq = 0; + m_lastSentSeq = 0; + m_rxAckedSeq = 0; + m_expectedRxSeq = 0; + uint8_t hf[V2_MAX_FRAME]; + size_t hl = 0; + if (v2_build_hello_frame(m_sessionId, V2_ROLE_APP, hf, &hl)) { + for (uint32_t hi = 0U; hi < 2U; ++hi) { + const size_t xlen = spiXferBytes(hl); + uint8_t tx_p[SPI_OTA_FRAME_MAX]; + uint8_t rx_r[SPI_OTA_FRAME_MAX]; + memset(tx_p, 0xFF, xlen); + memcpy(tx_p, hf, hl); + memset(rx_r, 0xFF, xlen); + m_linkState.enterBusy(HandshakeState::MASTER_SENDING); + driveReadyLow(); + (void)transferClocked(tx_p, xlen, rx_r, xlen, 200U); + releaseReady(); + m_linkState.exitBusy(); + v2_header_t h; + const uint8_t *pl = nullptr; + if (v2_parse_frame(rx_r, xlen, &h, &pl) && + h.type == V2_TYPE_LINK_HELLO && (h.flags & V2_FLAG_RESPONSE) && + pl && h.payload_len >= 6U && + v2_get_u32le(&pl[0]) == m_sessionId) { + m_gd32Role = pl[4]; + m_v2Active = true; + DEBUG_INFO("SPI", "ENTER_BOOT 前 HELLO 重同步 ok role=%u", m_gd32Role); + break; + } + vTaskDelay(gap); + } + } + } } - uint8_t cmd = SPI_APP_ENTER_BOOT_CMD; - /* 0xB0 是单字节命令,GD32 APP 可能在主动按铃(PB6=LOW)。 - * GD32 onCsAssert() 会立即处理 0xB0,不等 SPIready 就绪信号。 */ - esp_err_t ret = transferClocked(&cmd, 1, NULL, 0, 0U); - if (ret != ESP_OK) { - DEBUG_WARN("SPI", "发送 Bootloader 请求失败: %s", esp_err_to_name(ret)); + esp_err_t ret = otaTransferUnlocked(SPI_APP_ENTER_BOOT_CMD, nullptr, 0U, + nullptr, nullptr, 0U, nullptr, 400U, + /*expect_stale=*/true); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_RESPONSE) { + DEBUG_WARN("SPI", "ENTER_BOOT transfer failed: %s", esp_err_to_name(ret)); continue; } + command_sent = true; + m_txSeq = (uint8_t)(m_lastSentSeq + 1U); + vTaskDelay(gap); - TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(kPerRetryAckMs); + /* poll 最多 ~150ms:只认 0xB0+0xB5;0xC1/0x82/过期 0x80 都忽略 */ + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(150U); + uint8_t last_type = 0xFFU; while (xTaskGetTickCount() <= deadline) { - uint8_t rx = 0; - uint8_t dummy = 0xFF; - /* 读 ACK 时 GD32 APP/BOOT 可能仍持有 PB6=LOW(正在准备 0xB5 响应), - * 延迟一拍协议下下一次 CS 的 onCsAssert() 会装载 0xB5 到 DMA。 */ - ret = transferClocked(&dummy, 1, &rx, 1, 0U); - if (ret == ESP_OK && rx == SPI_APP_ENTER_BOOT_ACK_OK) { - DEBUG_INFO("SPI", "GD32 APP 已确认进入 Bootloader (retry=%lu)", (unsigned long)retry); - return ESP_OK; + m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); + (void)waitSpiDataReady(30U); + + constexpr size_t kPoll = 128U; + uint8_t rx_raw[kPoll]; + uint8_t dummy[kPoll]; + memset(dummy, 0xFF, sizeof(dummy)); + memset(rx_raw, 0xFF, sizeof(rx_raw)); + driveReadyLow(); + ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), 80U); + releaseReady(); + m_linkState.exitBusy(); + if (ret != ESP_OK) { + vTaskDelay(gap); + continue; } - if (ret == ESP_OK && rx != 0xFFU) { - DEBUG_DBG("SPI", "Bootloader ACK 字节=0x%02X (非0xB5,APP可能使用帧ACK)", rx); + + v2_header_t hdr; + const uint8_t *pload = nullptr; + if (!v2_parse_frame(rx_raw, sizeof(rx_raw), &hdr, &pload) || + !(hdr.flags & V2_FLAG_RESPONSE)) { + vTaskDelay(gap); + continue; } - vTaskDelay(pdMS_TO_TICKS(10)); + last_type = hdr.type; + + if (hdr.type == SPI_APP_ENTER_BOOT_CMD) { + const uint8_t status = (hdr.payload_len > 0U && pload) + ? pload[0] : 0x00U; + if (status == SPI_APP_ENTER_BOOT_ACK_OK) { + DEBUG_INFO("SPI", "ENTER_BOOT ACK type=0xB0 status=0xB5 sess=0x%08lX", + (unsigned long)hdr.session_id); + got_ack = true; + break; + } + DEBUG_WARN("SPI", "ENTER_BOOT ACK status=0x%02X (want 0xB5)", status); + } else if (hdr.type == V2_TYPE_LINK_ERROR) { + const uint8_t errc = (hdr.payload_len > 0U && pload) ? pload[0] : 0xFFU; + const uint8_t exp = (hdr.payload_len > 1U && pload) ? pload[1] : 0xFFU; + DEBUG_WARN("SPI", "ENTER_BOOT 被拒 LINK_ERROR code=0x%02X expected_seq=%u last_sent=%u", + errc, exp, (unsigned)m_lastSentSeq); + break; + } else if (hdr.type == SPI_OTA_RSP_ERROR) { + DEBUG_WARN("SPI", "ENTER_BOOT 收到 RSP_ERROR(0x82),将重发"); + break; + } else { + /* 0xC1 事件 / 过期 0x80 等:继续 poll,不记成功 */ + DEBUG_DBG("SPI", "ENTER_BOOT poll 忽略 type=0x%02X flags=0x%02X", + hdr.type, hdr.flags); + } + vTaskDelay(gap); + } + + if (got_ack) { + break; + } + if (last_type != 0xFFU) { + DEBUG_INFO("SPI", "ENTER_BOOT poll 结束 last_type=0x%02X (未获 0xB0/0xB5)", last_type); } } - DEBUG_WARN("SPI", "GD32 APP 进入 Bootloader 确认超时 (retries exhausted)"); + if (command_sent) { + /* 静默:APP 擦配置页(可达数十 ms) + 软复位 + BOOT 起机。 + * 此间禁止 HELLO,否则 role 会一直像卡在 APP。 */ + const uint32_t kQuietMs = got_ack ? 900U : 500U; + DEBUG_INFO("SPI", "ENTER_BOOT 已上总线 (ack=%s),静默 %lums 等待 APP 复位...", + got_ack ? "yes" : "no", (unsigned long)kQuietMs); + vTaskDelay(pdMS_TO_TICKS(kQuietMs)); + return got_ack ? ESP_OK : ESP_ERR_INVALID_RESPONSE; + } + DEBUG_WARN("SPI", "ENTER_BOOT 未能发出 SPI 帧"); return ESP_ERR_TIMEOUT; } /** - * @brief 方案A: 通过 PB6 LOW→HIGH 序列等待 Bootloader 就绪(中断驱动)。 + * @brief 通过 HELLO 轮询等待 Bootloader 就绪(v2 方式)。 * - * 时序: - * APP 拉低 PB6 → 20ms → 复位(μs级HIGH) → Bootloader极早推挽LOW → init... → 释放HIGH - * ESP32 过滤: - * Phase1: 置 WAITING_BOOT_READY,GPIO 下降沿中断唤醒(bit 0x04) - * Phase2: 同状态,GPIO 上升沿中断唤醒(bit 0x08)+ 10ms 稳定确认 - * - * @param timeout_ms 总超时(毫秒)。 - * @return ESP_OK 表示 Bootloader 就绪;ESP_ERR_TIMEOUT 表示超时。 + * App→Boot 切换后轮询 HELLO,直到 role=boot 或超时。 + * APP 仍在线时拉大间隔、降噪日志,避免 role=1 刷屏。 */ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) { - m_spiTaskHandle = xTaskGetCurrentTaskHandle(); + DEBUG_INFO("SPI", "等待 Bootloader 就绪 (HELLO 轮询, timeout=%lums)...", + (unsigned long)timeout_ms); - /* 清除遗留通知位 (0x04=下降沿, 0x08=上升沿) */ - xTaskNotifyWait(0x0CU, 0x0CU, NULL, 0U); - - /* Phase1: 等待下降沿 (APP 复位前拉低 / Bootloader 极早拉低) - * 先检查当前电平,若已为 LOW 则跳过等待 */ - m_linkState.enterBusy(HandshakeState::WAITING_BOOT_READY); - - if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { - /* 仍为 HIGH,等下降沿中断 */ - constexpr uint32_t kPhase1TimeoutMs = 500U; - const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(kPhase1TimeoutMs); - bool seen_low = false; - for (;;) { - TickType_t now = xTaskGetTickCount(); - if (now >= deadline) { break; } - TickType_t remain = deadline - now; - uint32_t notify_val = 0U; - BaseType_t ok = xTaskNotifyWait(0x0CU, 0x0CU, ¬ify_val, - pdMS_TO_TICKS(remain > portMAX_DELAY / 2 ? portMAX_DELAY / 2 : remain)); - if (ok == pdTRUE && (notify_val & 0x04U)) { seen_low = true; break; } - /* 防中断丢失:再次检查电平 */ - if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 0) { seen_low = true; break; } - } - if (!seen_low) { - m_linkState.exitBusy(); - DEBUG_WARN("SPI", "waitForBootloaderReady: Phase1 未检测到 LOW"); - return ESP_ERR_TIMEOUT; - } + /* 复位/起机再留白;未走 appRequestBootloader 的调用方也需要 */ + { + const TickType_t settle = (pdMS_TO_TICKS(200) > 0) ? pdMS_TO_TICKS(200) : 1; + vTaskDelay(settle); } - DEBUG_INFO("SPI", "waitForBootloaderReady: Phase1 检测到 LOW,等待 Bootloader 就绪"); - - /* Phase2: 等待上升沿 (Bootloader init 完成释放 HIGH) - * 10ms 稳定确认过滤复位期间 μs 级虚假 HIGH */ const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); - for (;;) { + constexpr uint32_t kHelloAttemptMs = 400U; + uint32_t app_hits = 0U; + uint32_t rounds = 0U; + + while (xTaskGetTickCount() < deadline) { TickType_t now = xTaskGetTickCount(); - if (now >= deadline) { break; } - TickType_t remain = deadline - now; - uint32_t notify_val = 0U; - BaseType_t ok = xTaskNotifyWait(0x0CU, 0x0CU, ¬ify_val, - pdMS_TO_TICKS(remain > portMAX_DELAY / 2 ? portMAX_DELAY / 2 : remain)); - if (ok == pdTRUE && (notify_val & 0x08U)) { - /* 收到上升沿,确认 HIGH 持续 10ms 过滤虚假脉冲 */ - vTaskDelay(pdMS_TO_TICKS(10)); - if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { - m_linkState.exitBusy(); - DEBUG_INFO("SPI", "waitForBootloaderReady: Bootloader 就绪 (PB6 HIGH stable)"); - return ESP_OK; - } - /* 虚假脉冲,继续等待 */ - DEBUG_DBG("SPI", "waitForBootloaderReady: 虚假 HIGH,继续等待"); + uint32_t remain_ms = (deadline > now) + ? (uint32_t)((deadline - now) * portTICK_PERIOD_MS) : 0U; + if (remain_ms == 0U) { + break; } - /* 防中断丢失:再次检查电平 */ - if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { - vTaskDelay(pdMS_TO_TICKS(10)); - if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { - m_linkState.exitBusy(); - DEBUG_INFO("SPI", "waitForBootloaderReady: Bootloader 就绪 (poll fallback)"); - return ESP_OK; - } + const uint32_t attempt_ms = (remain_ms < kHelloAttemptMs) ? remain_ms : kHelloAttemptMs; + ++rounds; + + esp_err_t ret = hello(attempt_ms, V2_ROLE_BOOT); + if (ret == ESP_OK && m_gd32Role == V2_ROLE_BOOT) { + DEBUG_INFO("SPI", "Bootloader 就绪 (HELLO role=boot, round=%lu)", + (unsigned long)rounds); + return ESP_OK; } + + if (ret == ESP_OK && m_gd32Role == V2_ROLE_APP) { + ++app_hits; + /* 仍在 APP:复位未完成或 ENTER_BOOT 未生效;放慢 HELLO 给 Flash/复位时间 */ + if (app_hits == 1U || (app_hits % 5U) == 0U) { + DEBUG_INFO("SPI", "仍为 APP (role=1),等待复位进 BOOT... (app_hits=%lu)", + (unsigned long)app_hits); + } + const uint32_t app_gap_ms = (app_hits < 3U) ? 200U : 150U; + const TickType_t app_gap = (pdMS_TO_TICKS(app_gap_ms) > 0) + ? pdMS_TO_TICKS(app_gap_ms) : 1; + vTaskDelay(app_gap); + continue; + } + + /* 无响应 / 0xC1 噪声:中等等待 */ + const TickType_t miss_gap = (pdMS_TO_TICKS(60) > 0) ? pdMS_TO_TICKS(60) : 1; + vTaskDelay(miss_gap); } - m_linkState.exitBusy(); - DEBUG_WARN("SPI", "waitForBootloaderReady: Phase2 等待 HIGH 超时 (%ums)", (unsigned)timeout_ms); + DEBUG_WARN("SPI", "Bootloader 就绪超时 (%lums, rounds=%lu, app_hits=%lu)", + (unsigned long)timeout_ms, (unsigned long)rounds, (unsigned long)app_hits); return ESP_ERR_TIMEOUT; } /** - * @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 + * @brief 向 GD32 APP 发送存活探测命令(v2 标准帧,单 CS 同步传输)。 + * + * 与旧版一致采用单 CS 事务:发送 PING 帧同时读回响应, + * 不走 otaPollResponseUnlocked(避免消费不相关帧)。 + * 用 v2_parse_frame 在 RX buffer 里定位响应帧。 + * + * GD32 App PING handler 用 kRspAck(0x80) 作为响应 type, + * payload(16B) = APP version(4B) + BOOT version(4B) + uptime_ms(4B) + build_hash(4B)。 + * * @param timeout_ms 超时时间(毫秒)。 * @param uptime_sec 输出设备运行时间(秒)。 * @param version 输出 GD32 版本信息(可选)。 @@ -853,9 +1148,12 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver LockGuard lock(m_mutex); constexpr uint8_t kAppPingType = 0xA1U; - uint8_t tx_frame[16] = {0}; - size_t tx_len = 0U; - if (!otaBuildFrame(kAppPingType, NULL, 0U, tx_frame, &tx_len)) { + /* 构建 v2 PING 请求帧 */ + uint8_t tx_frame[V2_MAX_FRAME]; + size_t tx_len = 0; + if (!v2_build_frame(kAppPingType, V2_FLAG_REQUEST, m_sessionId, + m_txSeq, m_rxAckedSeq, m_localCredit, + nullptr, 0, tx_frame, &tx_len)) { return ESP_ERR_INVALID_ARG; } @@ -865,101 +1163,92 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver while (xTaskGetTickCount() <= deadline) { ++attempt; - /* 发送 PING 帧,填充至 16 字节: - * GD32 SPI 从机可能在 CS 断言时丢失首字节(EXTI/SPI 中断竞态), - * 填充确保即使首字节丢失,后续仍有完整 0xA5 帧头可供扫描定位。 */ - uint8_t tx_padded[16]; - memset(tx_padded, 0xFF, sizeof(tx_padded)); - memcpy(tx_padded, tx_frame, tx_len); /* [0..4]=PING帧, [5..15]=0xFF */ + /* 单 CS 同步传输:下拉 SPIready 后,在单次 SPI 事务中发送 PING 并读回响应。 + * GD32 延迟一拍协议下,本次 CS 读到的是上一笔的响应(首笔为 0xFF)。 + * 发送 v2 帧后 GD32 在 CS 释放后处理,响应在下一次 CS 返回。 + * 因此需要多次循环:第一次发 PING + 读 0xFF,第二次发 PING + 读上一笔 PING 的 ACK。 */ + const size_t xfer_len = spiXferBytes(tx_len); + uint8_t tx_padded[SPI_OTA_FRAME_MAX]; + memset(tx_padded, 0xFF, xfer_len); + memcpy(tx_padded, tx_frame, tx_len); - /* 同步单 CS PING:下拉 SPIready 后,在单次 SPI 事务中发送并读回响应。 */ m_linkState.enterBusy(HandshakeState::MASTER_SENDING); driveReadyLow(); if (SPI_READY_DRIVE_DELAY_US > 0U) { esp_rom_delay_us(SPI_READY_DRIVE_DELAY_US); } - uint8_t rx_raw[48]; /* 增大缓冲区,吸收 SPI 字节偏移 */ - memset(rx_raw, 0xFF, sizeof(rx_raw)); + uint8_t rx_raw[SPI_OTA_FRAME_MAX]; + memset(rx_raw, 0xFF, xfer_len); - esp_err_t err = transferClocked(tx_padded, sizeof(tx_padded), rx_raw, sizeof(rx_raw), 0U); + esp_err_t err = transferClocked(tx_padded, xfer_len, rx_raw, xfer_len, 0U); releaseReady(); m_linkState.exitBusy(); if (err != ESP_OK) { - DEBUG_DBG("SPI", "APP PING sync transfer failed[%lu]: %s", + DEBUG_DBG("SPI", "APP PING transfer failed[%lu]: %s", (unsigned long)attempt, esp_err_to_name(err)); vTaskDelay(pdMS_TO_TICKS(5)); continue; } - bool found = false; - - /* GD32 SPI 从机字节偏移不可预测,直接在 raw buffer 中 - * 搜索完整的标签化 PING 响应模式: - * 0xA1 [0x55...] app_vers [0xAA...] boot_vers - * 找到 0xA1 后跳过重复字节,验证 0x55 和 0xAA 标签。 - */ - for (size_t i = 0; i + 14 < sizeof(rx_raw); ++i) { - if (rx_raw[i] != kAppPingType) continue; /* 找 0xA1 */ - size_t p = i + 1; - /* 跳过最多 1 个重复的 A1 字节(SPI 偏移最多产生 1 个重复) */ - if (p < sizeof(rx_raw) && rx_raw[p] == kAppPingType) ++p; - if (p >= sizeof(rx_raw) || rx_raw[p] != 0x55U) continue; - /* 跳过最多 1 个重复的 0x55 */ - if (p + 1 < sizeof(rx_raw) && rx_raw[p + 1] == 0x55U) ++p; - size_t app_start = p + 1; /* APP 版本起始 */ - if (app_start + 4 > sizeof(rx_raw)) continue; - size_t bp = app_start + 4; - /* 跳过最多 1 个重复字节 */ - if (bp < sizeof(rx_raw) && bp > 0 && rx_raw[bp] == rx_raw[bp - 1]) ++bp; - if (bp >= sizeof(rx_raw) || rx_raw[bp] != 0xAAU) continue; - /* 跳过最多 1 个重复的 0xAA */ - if (bp + 1 < sizeof(rx_raw) && rx_raw[bp + 1] == 0xAAU) ++bp; - size_t boot_start = bp + 1; - if (boot_start + 4 > sizeof(rx_raw)) continue; - - /* 模式匹配成功!提取版本数据 */ - found = true; + /* 在 RX buffer 中用 v2_parse_frame 定位响应帧 */ + v2_header_t hdr; + const uint8_t *pload = nullptr; + if (v2_parse_frame(rx_raw, xfer_len, &hdr, &pload) && + hdr.type == SPI_OTA_RSP_ACK && hdr.payload_len >= 12U && pload) { + /* GD32 APP 重启后 g_v2Active=false,响应 session_id=0。 + * appPing 不经 otaFindFrame,故不会因 session 失配被拒—— + * 但后续 otaSendAndPoll 走 otaFindFrame 仍会拒绝。 + * 在此同步 m_sessionId 到 GD32 实际值,避免失配。 */ + if (hdr.session_id != m_sessionId) { + DEBUG_INFO("SPI", "appPing session 同步: 0x%08lX -> 0x%08lX", + (unsigned long)m_sessionId, (unsigned long)hdr.session_id); + m_sessionId = hdr.session_id; + } + /* 成功收到 PING ACK 响应,递增发送序号 */ + m_txSeq = (uint8_t)(m_txSeq + 1U); + /* payload 布局兼容两种 GD32 App 固件版本: + * 16B: APP ver(4) + BOOT ver(4) + uptime_ms(4) + build_hash(4) + * 12B: APP ver(4) + uptime_ms(4) + build_hash(4) (旧固件,无 BOOT 版本) */ if (version) { memset(version, 0, sizeof(gd32_version_t)); - version->app_major = rx_raw[app_start]; - version->app_minor = rx_raw[app_start + 1]; - version->app_patch = rx_raw[app_start + 2]; - version->app_build = rx_raw[app_start + 3]; - version->boot_major = rx_raw[boot_start]; - version->boot_minor = rx_raw[boot_start + 1]; - version->boot_patch = rx_raw[boot_start + 2]; - version->boot_build = rx_raw[boot_start + 3]; + version->app_major = pload[0]; + version->app_minor = pload[1]; + version->app_patch = pload[2]; + version->app_build = pload[3]; + if (hdr.payload_len >= 16U) { + /* 新固件:[4..7] = BOOT 版本 */ + version->boot_major = pload[4]; + version->boot_minor = pload[5]; + version->boot_patch = pload[6]; + version->boot_build = pload[7]; + } + /* 旧固件 payload_len==12:boot 版本保持 0(已在 memset 中清零) */ } if (uptime_sec) { - size_t ut = boot_start + 4; - if (ut + 3 < sizeof(rx_raw)) { - *uptime_sec = (uint32_t)rx_raw[ut] - | ((uint32_t)rx_raw[ut + 1] << 8) - | ((uint32_t)rx_raw[ut + 2] << 16) - | ((uint32_t)rx_raw[ut + 3] << 24); + if (hdr.payload_len >= 16U) { + /* 新固件:uptime 在 [8..11] */ + const uint8_t *ut = &pload[8]; + const uint32_t uptime_ms = (uint32_t)ut[0] + | ((uint32_t)ut[1] << 8) + | ((uint32_t)ut[2] << 16) + | ((uint32_t)ut[3] << 24); + *uptime_sec = uptime_ms / 1000U; } else { - *uptime_sec = 0; + /* 旧固件 payload_len==12:uptime 在 [4..7] */ + const uint8_t *ut = &pload[4]; + const uint32_t uptime_ms = (uint32_t)ut[0] + | ((uint32_t)ut[1] << 8) + | ((uint32_t)ut[2] << 16) + | ((uint32_t)ut[3] << 24); + *uptime_sec = uptime_ms / 1000U; } } - DEBUG_WARN("SPI", "APP PING 标签匹配: A1@%u APP@%u BOOT@%u", - (unsigned)i, (unsigned)app_start, (unsigned)boot_start); - break; - } - - if (found) { - DEBUG_INFO("SPI", "APP PING 成功: uptime=%lu sec, attempt=%lu", - uptime_sec ? (unsigned long)*uptime_sec : 0UL, - (unsigned long)attempt); + DEBUG_DBG("SPI", "APP PING v2 成功: attempt=%lu", (unsigned long)attempt); return ESP_OK; } - DEBUG_WARN("SPI", "APP PING 无匹配[%lu]: first8=%02X%02X%02X%02X%02X%02X%02X%02X", - (unsigned long)attempt, - rx_raw[0], rx_raw[1], rx_raw[2], rx_raw[3], - rx_raw[4], rx_raw[5], rx_raw[6], rx_raw[7]); - vTaskDelay(pdMS_TO_TICKS(5)); } @@ -1044,7 +1333,13 @@ esp_err_t SpiMaster::checkGd32Request(uint32_t timeout_ms) return ESP_OK; } - DEBUG_WARN("SPI", "GD32 按铃读事务未找到合法帧"); + /* 降噪:无合法帧时限流,避免 ready 毛刺/残留 0xFF 刷屏 */ + static uint32_t s_ring_bad_logs = 0U; + if ((++s_ring_bad_logs % 32U) == 1U) { + DEBUG_WARN("SPI", "GD32 按铃读事务未找到合法帧 (log#%lu) head=%02X %02X %02X %02X", + (unsigned long)s_ring_bad_logs, + rx_raw[0], rx_raw[1], rx_raw[2], rx_raw[3]); + } return ESP_ERR_INVALID_RESPONSE; } @@ -1065,5 +1360,138 @@ bool SpiMaster::takeRingRx(uint8_t *type, uint8_t *payload, size_t max_len, size return true; } +// ============================================================ +// SPI Link v2 链路状态 +// ============================================================ +SpiMaster::SpiLinkStats SpiMaster::linkStats() +{ + LockGuard lock(m_mutex); + return m_stats; +} + +esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) +{ + LockGuard lock(m_mutex); + + /* 1. 生成随机非零 SESSION_ID(整次 hello 内固定,禁止每 CS 换 session) */ + m_sessionId = esp_random() | 0x00000001U; + m_txSeq = 0; + m_lastSentSeq = 0; + m_rxAckedSeq = 0; + m_expectedRxSeq = 0; + m_gd32Role = 0; + m_v2Active = false; + + /* 2. 构建 HELLO 帧 */ + uint8_t hello_frame[V2_MAX_FRAME]; + size_t hello_len = 0; + if (!v2_build_hello_frame(m_sessionId, role_hint, hello_frame, &hello_len)) { + DEBUG_ERR("SPI", "HELLO build failed"); + return ESP_ERR_INVALID_RESPONSE; + } + + /* GD32 延迟一拍:采用与 appPing 相同的“同 session 连发”策略。 + * 第 N 次 CS 读到第 N-1 次 HELLO 的 ACK;dummy 紧贴重发会与 GD32 EXTI + * 处理竞态,实测易得到全 0xFF。 */ + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); + uint8_t rx_raw[SPI_OTA_FRAME_MAX]; + uint32_t attempt = 0U; + uint8_t last_type = 0xFFU; + uint8_t last_flags = 0U; + bool saw_any_frame = false; + uint8_t last_rx_head[16]; + memset(last_rx_head, 0xFF, sizeof(last_rx_head)); + + /* FreeRTOS tick 常为 10ms:pdMS_TO_TICKS(5)==0 会变成忙等上千次。 + * 至少 1 tick,并在两次 CS 间留出 GD32 EXTI 处理时间。 */ + const TickType_t gap_ticks = (pdMS_TO_TICKS(10) > 0) ? pdMS_TO_TICKS(10) : 1; + + while (xTaskGetTickCount() <= deadline) { + ++attempt; + + m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); + (void)waitSpiReady(SPI_READY_TIMEOUT_MS); + m_linkState.enterBusy(HandshakeState::MASTER_SENDING); + driveReadyLow(); + + const size_t xfer_len = spiXferBytes(hello_len); + uint8_t tx_padded[SPI_OTA_FRAME_MAX]; + memset(tx_padded, 0xFF, xfer_len); + memcpy(tx_padded, hello_frame, hello_len); + + memset(rx_raw, 0xFF, xfer_len); + esp_err_t ret = transferClocked(tx_padded, xfer_len, rx_raw, xfer_len, 0U); + releaseReady(); + m_linkState.exitBusy(); + if (ret != ESP_OK) { + DEBUG_WARN("SPI", "HELLO transfer failed[%lu]: %s", + (unsigned long)attempt, esp_err_to_name(ret)); + vTaskDelay(gap_ticks); + continue; + } + + memcpy(last_rx_head, rx_raw, sizeof(last_rx_head)); + + v2_header_t hdr; + const uint8_t *pload = nullptr; + if (!v2_parse_frame(rx_raw, xfer_len, &hdr, &pload)) { + vTaskDelay(gap_ticks); + continue; + } + saw_any_frame = true; + last_type = hdr.type; + last_flags = hdr.flags; + + if (hdr.type != V2_TYPE_LINK_HELLO || !(hdr.flags & V2_FLAG_RESPONSE)) { + vTaskDelay(gap_ticks); + continue; + } + if (hdr.payload_len < 6U || !pload) { + vTaskDelay(gap_ticks); + continue; + } + + const uint32_t session_echo = v2_get_u32le(&pload[0]); + if (session_echo != m_sessionId) { + /* 延迟一拍的旧 ACK:同 session 再发一次即可对齐 */ + DEBUG_DBG("SPI", "HELLO lag echo=0x%08lX expected=0x%08lX attempt=%lu", + (unsigned long)session_echo, (unsigned long)m_sessionId, + (unsigned long)attempt); + vTaskDelay(gap_ticks); + continue; + } + + m_gd32Role = pload[4]; + m_localCredit = pload[5]; + m_v2Active = true; + m_stats.resyncs++; + /* 等 BOOT 时 role=APP 的成功极吵:仅 role 匹配 hint、或 hint=any、或已是 boot 时 INFO */ + if (role_hint == 0U || m_gd32Role == role_hint || m_gd32Role == V2_ROLE_BOOT) { + DEBUG_INFO("SPI", "HELLO success: session=0x%08lX role=%u credit=%u attempt=%lu", + (unsigned long)m_sessionId, m_gd32Role, m_localCredit, + (unsigned long)attempt); + } else { + DEBUG_DBG("SPI", "HELLO ok role=%u (want hint=%u) attempt=%lu", + m_gd32Role, role_hint, (unsigned long)attempt); + } + return ESP_OK; + } + + if (!saw_any_frame) { + DEBUG_WARN("SPI", + "HELLO: no valid v2 frame in response (attempts=%lu) rx[0..15]=" + "%02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", + (unsigned long)attempt, + last_rx_head[0], last_rx_head[1], last_rx_head[2], last_rx_head[3], + last_rx_head[4], last_rx_head[5], last_rx_head[6], last_rx_head[7], + last_rx_head[8], last_rx_head[9], last_rx_head[10], last_rx_head[11], + last_rx_head[12], last_rx_head[13], last_rx_head[14], last_rx_head[15]); + } else { + DEBUG_WARN("SPI", "HELLO: bad/lag response type=0x%02X flags=0x%02X attempts=%lu", + last_type, last_flags, (unsigned long)attempt); + } + return ESP_ERR_INVALID_RESPONSE; +} + // CANFD 接口已迁移至 transport/canfd_bridge.cpp(M4 解耦) diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 09552cc..cf9306a 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -19,6 +19,7 @@ #include "driver/gpio.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "spi_link_v2.h" // SPI 配置常量 #define SPI_MASTER_CS_GPIO (4) // CS 片选引脚 @@ -76,7 +77,8 @@ public: // SPI OTA 协议 // ============================================================ - /** @brief 按 GD32 SPI OTA 最小协议执行一次请求 + 响应读取。 */ + /** @brief 按 GD32 SPI OTA 协议执行一次请求 + 响应读取(延迟一拍两步法)。 + * 内部等价于 otaSendAndPoll:首笔 CS 发请求(忽略旧响应),次笔 CS 收真实响应。 */ esp_err_t otaTransfer(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); @@ -108,13 +110,49 @@ public: /** @brief 读取对端最新上报的 CREDIT(0 表示对端忙,应 POLL 等待而非重发)。 */ uint8_t remoteCredit() const { return m_remoteCredit; } + // ============================================================ + // SPI Link v2 链路层(阶段1:帧格式 + 状态成员就位) + // ============================================================ + + /** @brief v2 链路状态统计(SPEC/spi_link_v2.md §12)。 */ + struct SpiLinkStats { + uint32_t crc_errors; ///< CRC 校验失败计数 + uint32_t duplicate_requests; ///< 重复请求计数(序号回退) + uint32_t seq_errors; ///< 序号跳变计数 + uint32_t retries; ///< 重试总数 + uint32_t ready_timeouts; ///< DATA_READY 超时计数 + uint32_t rx_overruns; ///< RX overrun 计数 + uint32_t resyncs; ///< HELLO 重同步次数 + uint32_t max_resp_latency_us; ///< 最大响应延迟(微秒) + }; + + /** @brief 当前会话 ID(0 = 未建立 session)。 */ + uint32_t sessionId() const { return m_sessionId; } + + /** @brief v2 session 是否已激活。 */ + bool v2Active() const { return m_v2Active; } + + /** @brief 最近一次 HELLO 得到的 GD32 角色(0=unknown, 1=app, 2=boot)。 */ + uint8_t gd32Role() const { return m_gd32Role; } + + /** @brief 获取链路统计快照(线程安全,持锁读取)。 */ + SpiLinkStats linkStats(); + + /** @brief 发起 HELLO 握手建立 v2 session(SPEC §5.1)。 + * 生成随机非零 SESSION_ID,发送 LINK_HELLO,等待 HELLO_ACK。 + * 成功后 m_v2Active=true,序号归零。 + * @param timeout_ms 总超时(毫秒),默认 3000。 + * @param role_hint 期望的对端角色(0=any, 1=app, 2=boot)。 + * @return ESP_OK 成功;ESP_ERR_TIMEOUT 超时;ESP_ERR_INVALID_RESPONSE 响应异常。 */ + esp_err_t hello(uint32_t timeout_ms = 3000U, uint8_t role_hint = 0U); + /** @brief 通知 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。 */ esp_err_t appRequestBootloader(); - /** @brief 方案A: 通过 PB6 LOW→HIGH 序列等待 Bootloader 就绪。 - * Phase1 轮询等待 LOW (APP 复位前拉低 / Bootloader 极早拉低); - * Phase2 轮询等待 HIGH 并确认稳定 10ms (Bootloader init 完成释放)。 - * 超时返回 ESP_ERR_TIMEOUT,调用方应退化为固定延时。 */ + /** @brief 通过 HELLO 轮询等待 Bootloader 就绪(v2 方式,不再依赖 PB6 序列)。 + * App→Boot 切换后,ESP32 每 20ms 发送 HELLO(role_hint=boot), + * 等待 HELLO_ACK 且 role=boot,最长等待 timeout_ms。 + * 超时返回 ESP_ERR_TIMEOUT。 */ esp_err_t waitForBootloaderReady(uint32_t timeout_ms); /** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */ @@ -226,6 +264,16 @@ private: volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口) volatile uint8_t m_remoteCredit = 8; ///< 对端上报的剩余信用 + // ── SPI Link v2 会话/序号状态(阶段1 就位,阶段2 启用校验)── + volatile uint32_t m_sessionId = 0; ///< 会话 ID(0 = 未建立,HELLO 时由 ESP32 生成) + volatile uint8_t m_txSeq = 0; ///< 本端发送序号(每发一帧递增,重发不变) + volatile uint8_t m_lastSentSeq = 0; ///< 上次发送的序号(用于响应归属匹配) + volatile uint8_t m_rxAckedSeq = 0; ///< 已确认的对端最新序号 + volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号 + volatile uint8_t m_gd32Role = 0; ///< GD32 角色(0=unknown, 1=app, 2=boot) + volatile bool m_v2Active = false; ///< v2 session 是否激活 + SpiLinkStats m_stats = {}; ///< 链路统计 + // RAII 锁守卫 class LockGuard { public: @@ -284,7 +332,8 @@ private: * @return ESP_OK 表示成功,否则返回错误码。 */ esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload, - size_t resp_max, size_t *resp_len, uint32_t timeout_ms); + size_t resp_max, size_t *resp_len, uint32_t timeout_ms, + uint8_t expected_type = 0xFFU); /** @brief 无锁版本的 otaTransfer(调用者须已持有锁)。 * @param expect_stale true 表示调用方预期读到过期/空响应(如 otaSendAndPoll 步骤1), * 找不到帧时降级为 DBG 日志,不打 WARN。 */ @@ -292,6 +341,14 @@ private: uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms, bool expect_stale = false); + + /** @brief 带重试退避的事务(SPEC §9.2)。 + * 同序号重试最多 3 次,退避 10/30/90ms; + * 3 次失败后 HELLO 重同步,再试 1 次。 + * 调用者须已持有锁。 */ + esp_err_t transactWithRetry(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); // OTA 帧工具(静态,无状态) static uint32_t otaCrc32(const uint8_t *data, size_t len); static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len, diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f922bd4..80ad3f3 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -4,11 +4,11 @@ CONFIG_ESP_WIFI_TX_BA_WIN=6 CONFIG_DCU_OTA_POLL_BACKOFF_MS=5 CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000 -# DCU SPI clock -- default 4 MHz, can be raised via menuconfig to speed up GD32 OTA -CONFIG_DCU_SPI_CLOCK_HZ=4000000 +# DCU SPI clock -- 1 MHz 优先保证 BOOT 从机可靠(RBNE/DMA 武装窗口);OTA 稳后再提速 +CONFIG_DCU_SPI_CLOCK_HZ=1000000 -# DCU OTA packet interval -- default 1 ms, can be lowered to speed up GD32 OTA -CONFIG_DCU_OTA_PACKET_INTERVAL_MS=1 +# DCU OTA packet interval -- 给 GD32 主循环 AES/Flash drain 留时间(原 1ms 易在中后期卡死) +CONFIG_DCU_OTA_PACKET_INTERVAL_MS=3 # LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 + UDP debug log + 余量 # 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE) diff --git a/tools/app.js b/tools/app.js index 8435bc6..bea8c07 100644 --- a/tools/app.js +++ b/tools/app.js @@ -32,6 +32,116 @@ let uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 W let activeXhr = null; // 当前上传 XHR,用于 abort let uploadBtnOrigText = ''; // 上传按钮原始文字 let activeUploadChip = null; // 当前上传目标芯片 ('esp32' | 'gd32' | null) +let uploadWaitHeartbeatId = null; // 发完 body 后等待 HTTP 响应的心跳定时器 +let uploadWaitStartMs = 0; + +/** @description 停止「等待设备 HTTP 响应」心跳,避免残留定时器改进度条。 */ +function stopUploadWaitHeartbeat() { + if (uploadWaitHeartbeatId) { + clearInterval(uploadWaitHeartbeatId); + uploadWaitHeartbeatId = null; + } + uploadWaitStartMs = 0; +} + +/** + * @description 浏览器 body 已发完但尚未收到 HTTP 响应时启动心跳。 + * 上传期轮询已暂停,若无此反馈会像“卡在 100%”。 + */ +function startUploadWaitHeartbeat() { + stopUploadWaitHeartbeat(); + uploadWaitStartMs = Date.now(); + uploadWaitHeartbeatId = setInterval(function () { + if (!activeXhr) { + stopUploadWaitHeartbeat(); + return; + } + var sec = Math.round((Date.now() - uploadWaitStartMs) / 1000); + setProgress(100, '数据已发完,设备处理中… 已等待 ' + sec + 's'); + // 每 10s 打一条日志,便于区分“仍在处理”与“真死锁” + if (sec > 0 && sec % 10 === 0) { + addLog('I', '上传', '仍在等待设备 HTTP 响应(已 ' + sec + + 's;设备可能仍在写 Flash / 算 SHA,或连接异常)'); + } + }, 5000); +} + +// ==================== 前端 OTA 会话状态机 ==================== +// 统一管理一次 OTA 会话的完整生命周期,消除散布的布尔标志 +// (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting) +// 在网络错误后被轮询错误覆盖、状态混乱的问题。 +// +// 状态流转: +// IDLE → UPLOADING → (ESP32: VERIFYING → REBOOTING) → DONE +// IDLE → UPLOADING → (GD32: RELAYING) → DONE +// 任意活跃态 ──(failure/abort)──→ FAILED +// FAILED/DONE ──(startUpload)──→ UPLOADING +// FAILED/DONE ──(device idle detected by poll)──→ IDLE +var OtaSessionState = { + IDLE: 'idle', // 无会话 + UPLOADING: 'uploading', // 固件上传中(XHR 上传阶段) + VERIFYING: 'verifying', // ESP32 SHA-256 校验 + 应用中 + RELAYING: 'relaying', // GD32 中继转发中(triggerGd32Relay 发起后) + REBOOTING: 'rebooting', // ESP32 重启等待中 + DONE: 'done', // 会话成功完成 + FAILED: 'failed' // 会话失败/中止 +}; +var otaSessionState = OtaSessionState.IDLE; +var otaSessionTarget = null; // 'esp32' | 'gd32' | null — 本会话的目标芯片 +var otaFailReason = ''; // FAILED 时的失败原因(用于 UI 显示) + +/** @description 查询当前 OTA 会话是否处于活跃态(非 IDLE/DONE/FAILED)。 */ +function otaSessionActive() { + return otaSessionState === OtaSessionState.UPLOADING || + otaSessionState === OtaSessionState.VERIFYING || + otaSessionState === OtaSessionState.RELAYING || + otaSessionState === OtaSessionState.REBOOTING; +} + +/** @description 查询当前 OTA 会话是否已终结(DONE 或 FAILED)。 */ +function otaSessionTerminated() { + return otaSessionState === OtaSessionState.DONE || + otaSessionState === OtaSessionState.FAILED; +} + +/** + * @description 转换 OTA 会话状态。 + * 统一在此处维护状态机一致性,同时同步旧标志位 + * (isUploading / currentOtaTarget),保证渐进式迁移期间两套标志不冲突。 + * @param {string} newState 目标状态。 + * @param {string} [reason] 失败原因(仅转入 FAILED 时使用)。 + */ +function otaSessionTransition(newState, reason) { + var prev = otaSessionState; + otaSessionState = newState; + if (newState === OtaSessionState.FAILED) { + otaFailReason = reason || '未知原因'; + } else if (newState === OtaSessionState.UPLOADING) { + otaFailReason = ''; + } + // 同步旧标志(渐进式迁移):UPLOADING 对应 isUploading=true + if (newState === OtaSessionState.UPLOADING) isUploading = true; + else if (newState === OtaSessionState.IDLE || + newState === OtaSessionState.DONE || + newState === OtaSessionState.FAILED) isUploading = false; + // REBOOTING 复用 isEsp32Rebooting(pollStatus 重启检测依赖) + if (newState === OtaSessionState.REBOOTING) isEsp32Rebooting = true; + else if (newState !== OtaSessionState.VERIFYING) isEsp32Rebooting = false; +} + +/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */ +function otaSessionReset() { + stopRelayProgressWatch(); + gd32CompleteAnnounced = false; + relayWatchLastSeq = -1; + if (otaSessionState !== OtaSessionState.IDLE) { + otaSessionState = OtaSessionState.IDLE; + otaSessionTarget = null; + otaFailReason = ''; + currentOtaTarget = null; + isUploading = false; + } +} // ==================== 折叠面板 ==================== function toggleCollapse(headEl) { @@ -120,11 +230,20 @@ function updateEsp32StagesError() { * @description OTA 失败后的统一清理函数。 * 所有上传失败、校验失败、中继触发失败等异常路径都应调用此函数, * 确保 UI 状态完整重置,不会残留"上传中"或中间态。 + * + * ★ 状态机改造:转入 FAILED 态,置位 otaFailReason。 + * 之后轮询(pollStatus/updateSystemStatusUi)不得覆盖此失败态, + * 直到用户发起新上传(otaSessionReset)或检测到设备已回 IDLE。 * @param {string} reason 失败原因描述 */ function cleanupAfterOtaFailure(reason) { + stopRelayProgressWatch(); + // 0. 状态机:转入 FAILED(先于标志重置,确保 otaSessionActive() 即刻返回 false) + otaSessionTransition(OtaSessionState.FAILED, reason); + // 1. 重置上传锁 isUploading = false; + stopUploadWaitHeartbeat(); // 2. 中止正在进行的 XHR(如有) if (activeXhr) { @@ -159,7 +278,7 @@ function cleanupAfterOtaFailure(reason) { if (verifyInfo) verifyInfo.style.display = 'none'; // 9. 恢复上传按钮(基于实际上传的芯片,而非当前 UI 选中的标签) - var chip = activeUploadChip || window.flashTarget || 'esp32'; + var chip = activeUploadChip || otaSessionTarget || window.flashTarget || 'esp32'; var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn'; var btn = document.getElementById(btnId); if (btn && uploadBtnOrigText) { @@ -285,15 +404,31 @@ function isOtaBusyError(status, detail) { text.indexOf('系统忙') >= 0; } /** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */ +var _toastHideTimer = null; +var _toastLastKey = ''; +var _toastLastAt = 0; function showToast(message, type) { type = type || 'success'; + // 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」 + var key = type + '|' + message; + var now = Date.now(); + if (key === _toastLastKey && (now - _toastLastAt) < 5000) { + return; + } + _toastLastKey = key; + _toastLastAt = now; + const toast = document.getElementById('toast'); const ico = document.getElementById('toastIco'); const msg = document.getElementById('toastMsg'); toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show'; ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0'; msg.textContent = message; - setTimeout(function () { toast.classList.remove('show'); }, 3000); + if (_toastHideTimer) clearTimeout(_toastHideTimer); + _toastHideTimer = setTimeout(function () { + toast.classList.remove('show'); + _toastHideTimer = null; + }, 3000); } function getEspIp() { return document.getElementById('espIp').value.trim() || '192.168.31.127'; @@ -380,7 +515,10 @@ function resetStageFlow() { function applyDisconnectedRuntimeState() { document.getElementById('pillIP').textContent = '--'; document.getElementById('pillRSSI').textContent = '--'; - if (isUploading || currentOtaTarget) return; + // ★ 状态机守卫:会话活跃时不重置进度/阶段,避免覆盖进行中的 OTA 进度 + if (otaSessionActive()) return; + // FAILED 态:保留失败进度显示,只清连接胶囊(让用户看到失败原因) + if (otaSessionState === OtaSessionState.FAILED) return; resetProgress(); resetStageFlow(); } @@ -399,6 +537,12 @@ function applyRebootingRuntimeState() { if (bc) bc.className = 'v warn'; } function applyUploadingRuntimeState() { + // ★ 状态机守卫:会话已失败/完成时,绝不重新应用"上传中"运行时态, + // 防止网络抖动后的轮询错误分支把 UI 拉回"上传中"。 + if (otaSessionState === OtaSessionState.FAILED || + otaSessionState === OtaSessionState.DONE) { + return; + } var pc = document.getElementById('pillConn'); var pct = document.getElementById('pillConnTxt'); pc.className = 'pill processing'; @@ -737,6 +881,10 @@ function updateSystemStatusUi(data) { if (currentOtaTarget === 'esp32' && esp32OtaPhase > 0) { return; } + // ★ 状态机守卫:本会话已 FAILED 时,禁止后端状态覆盖失败 UI。 + // 后端可能仍在跑 GD32 中继(网络断开不会停止设备端流程), + // 若不拦截,会把进度条/阶段从"网络错误"错误地刷回"数据转发"。 + // 只有设备真正回 IDLE 才允许自动复位状态机(见下方 else 分支)。 var rs = ota.relay_state; var sysState = ota.sys_state || ''; var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; @@ -748,15 +896,38 @@ function updateSystemStatusUi(data) { var total = ota.total_size || 0; var stored = ota.stored_size || 0; var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0; - // GD32 relay forwarding progress + // GD32 relay forwarding progress — 以 /system/status 的 relay_seq 为准,不依赖日志 var relaySeq = ota.relay_seq || 0; var relayTotal = ota.relay_total_packets || 0; var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0; var gd32AppAlive = ota.gd32_app_alive; + + if (otaSessionState === OtaSessionState.FAILED) { + var _deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) && + (rs === 0 || rs === 5); + if (!_deviceIdle) { + // 会话已失败,但设备可能仍在中继:继续用 relay_seq 刷进度,避免“假卡死” + if ((rs === 3 || isGd32Relaying) && relayTotal > 0) { + setActivePhase('gd32Ota'); + var failRelayOverall = 60 + Math.round(relayProgress * 0.30); + setProgress(failRelayOverall, + '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)'); + updateStagesFromRelayState(3); + } else { + updateStagesFromRelayState(255); + } + return; + } + // 设备已回 IDLE —— 自动复位状态机,恢复正常 UI + otaSessionReset(); + stopRelayProgressWatch(); + // 落到下方正常分支刷新 UI + } + var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs); if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); } - if (isGd32Error && currentOtaTarget === 'gd32') { + if (isGd32Error && (currentOtaTarget === 'gd32' || otaSessionTarget === 'gd32')) { cleanupAfterOtaFailure('GD32 OTA 失败'); } else if (isGd32Error) { // 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理 @@ -765,59 +936,56 @@ function updateSystemStatusUi(data) { var stErr = document.getElementById('stageStatusText'); if (sbErr) sbErr.className = 'stage-status-bar err'; if (stErr) stErr.textContent = 'GD32 错误(待机)'; - } else if (isGd32Complete || rs === 5 || (gd32AppAlive && sysState === 'GD32_COMPLETE')) { - markPhaseDone('gd32Ota'); - currentOtaTarget = null; // GD32 OTA 完成,允许切换目标 - setProgress(100, 'GD32 刷写完成'); - var sbDone = document.getElementById('stageStatusBar'); - var stDone = document.getElementById('stageStatusText'); - if (sbDone) sbDone.className = 'stage-status-bar ok'; - if (stDone) stDone.textContent = 'GD32 刷写完成'; + } else if (isGd32Complete || rs === 5 || + (gd32AppAlive && (sysState === 'GD32_COMPLETE' || sysStateCode === 4)) || + /* 包已全部转发且 APP 已起来:即使日志停在 SEND_SIZE 也算完成 */ + (gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && + (isGd32Relaying || otaSessionState === OtaSessionState.RELAYING || rs >= 3))) { + // 已完成:只进一次 UI/toast,后续轮询直接忽略 + if (!gd32CompleteAnnounced && otaSessionState !== OtaSessionState.DONE) { + markGd32OtaUiComplete('GD32 刷写完成'); + } } else if (isGd32Storing || (total > 0 && stored < total && !isUploading)) { setActivePhase('gd32Ota'); var storeOverall = Math.min(45, Math.round(storageProgress * 0.45)); if (!isUploading) { - setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total)); + setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total) + ''); } } else if (rs === 1) { // HANDSHAKING: 握手 + 挑战认证 setActivePhase('gd32Ota'); setProgress(50, '握手与认证中...'); - } else if (rs === 2) { - // SIZE_NEG: 固件大小协商 - setActivePhase('gd32Ota'); - setProgress(55, '协商固件大小...'); - } else if (rs === 3) { - // TRANSFERRING: 数据包传输 + } else if (rs === 3 || (relayTotal > 0 && relaySeq > 0)) { + // TRANSFERRING:以 relay_seq 为准(优先于 rs===2,避免 total 预写后一直显示 0/N) setActivePhase('gd32Ota'); var relayOverall = 60 + Math.round(relayProgress * 0.30); - setProgress(relayOverall, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包'); + var relayLabel = relayTotal > 0 + ? ('已转发 ' + relaySeq + ' / ' + relayTotal + ' 包 (' + relayProgress + '%)') + : '数据包传输中...'; + setProgress(relayOverall, relayLabel); + } else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) { + // SIZE_NEG:只说协商中,不写「0/N」(total 会提前写入 status,易被误认为卡在 0 包) + setActivePhase('gd32Ota'); + setProgress(55, relayTotal > 0 + ? ('大小协商中(共 ' + relayTotal + ' 包)') + : '固件大小协商中'); } else if (rs === 4) { // FINISHING: 结束OTA + APP存活验证 setActivePhase('gd32Ota'); if (gd32AppAlive) { - markPhaseDone('gd32Ota'); - currentOtaTarget = null; // GD32 OTA 完成,允许切换目标 - setProgress(100, 'GD32 刷写完成'); - var sb = document.getElementById('stageStatusBar'); - var st = document.getElementById('stageStatusText'); - if (sb) sb.className = 'stage-status-bar ok'; - if (st) st.textContent = 'GD32 刷写完成'; - } else { + if (!gd32CompleteAnnounced && otaSessionState !== OtaSessionState.DONE) { + markGd32OtaUiComplete('GD32 刷写完成'); + } + } else if (!gd32CompleteAnnounced) { setProgress(95, '收尾中,等待 APP 启动...'); } - } else if (total > 0 && stored >= total) { + } else if (total > 0 && stored >= total && !isGd32Relaying && otaSessionState !== OtaSessionState.RELAYING) { setActivePhase('gd32Ota'); setProgress(45, '上传完成,等待自动启动 GD32 OTA...'); } } /** @description 测试与 ESP32 的连接(GET /status)。 */ async function testConnection() { - // 如果在降速模式,先恢复正常轮询 - if (pollSlowMode) { - addLog('I', '连接', '正在重新连接...'); - restorePolling(); - } // 立即发一次请求检测 // 手动检测使用独立的 AbortController,避免打断递归调度的 pollStatus var testAbort = new AbortController(); @@ -871,7 +1039,6 @@ async function refreshSystemStatusNow(event) { latestSystemStatus = data; updateSystemStatusUi(data); consecutivePollFailures = 0; - if (pollSlowMode) restorePolling(); addLog('I', '状态', '已手动刷新 /system/status'); } catch (e) { addLog('E', '状态', '刷新失败: ' + e.message); @@ -1217,18 +1384,26 @@ function clearFile(chip) { async function startUpload(chip) { var file = chip === 'esp32' ? esp32File : gd32File; if (!file) return; - if (isUploading) { - var busyChip = (activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase(); - addLog('W', '上传', '已有 ' + busyChip + ' OTA 正在进行,拒绝新的 ' + chip.toUpperCase() + ' 上传'); + // ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。 + // FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。 + if (otaSessionActive()) { + var busyChip = (otaSessionTarget || activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase(); + addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传'); showToast('OTA 通道正在占用,请等待当前刷写结束', 'error'); return; } + // 上一次会话已终结(DONE/FAILED):发起新会话前先复位状态机 + if (otaSessionTerminated()) otaSessionReset(); + // 记录本会话目标 + otaSessionTarget = chip; + pausePollingForUpload(); // ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。 // 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间 // 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send, // 设备端互斥被绕过、403 路径走漏。 - isUploading = true; + // ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true) + otaSessionTransition(OtaSessionState.UPLOADING); activeUploadChip = chip; // 跨通道互斥前置检查(含 BLE 取消后的瞬态重试) var _probeReady = false; @@ -1258,8 +1433,8 @@ async function startUpload(chip) { if ((esp32Busy || gd32Busy)) { addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + ', channel=' + otaChannelName + '),拒绝本次 ' + chip.toUpperCase() + ' 上传'); showToast('设备正在 OTA 中,请等待结束后再试', 'error'); - isUploading = false; - resumePollingAfterUpload(); + // ★ 状态机:预检失败也需完整清理(转入 FAILED) + cleanupAfterOtaFailure('设备 OTA 占用中'); return; } } @@ -1304,8 +1479,8 @@ async function startUpload(chip) { addLog('E', '文件', '本地文件已变更或失效,请重新选择文件'); showToast('文件已变更,请重新选择', 'error'); clearFile(chip); - isUploading = false; - resumePollingAfterUpload(); + // ★ 状态机:文件失效属于会话失败 + cleanupAfterOtaFailure('本地文件失效'); return; } } @@ -1359,7 +1534,9 @@ async function startUpload(chip) { if (chip === 'esp32') { var headerObj = { type: 'esp32', total_size: fileSize }; var payload = arrayBuffer; - addLog('I', '上传', '直传: ' + formatBytes(fileSize)); + addLog('I', '上传', '直传: ' + formatBytes(fileSize) + + '(设备边收边写 Flash;进度 100% 后仍需等 HTTP 响应)'); + setProgress(1, '连接设备并开始写入…'); var headerJson = JSON.stringify(headerObj); var encoder = new TextEncoder(); var headerBytes = encoder.encode(headerJson); @@ -1388,12 +1565,30 @@ async function startUpload(chip) { if (chip === 'gd32') { var overallPct = Math.min(45, Math.round(pct * 0.45)); setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total)); + } else if (pct >= 100) { + setProgress(100, '数据已发完,等待设备写 Flash 并响应…'); } else { setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total)); } } }); + xhr.upload.addEventListener('load', function () { + // 请求体发送完毕 ≠ 服务端处理完毕 + setProgress(100, '数据已发完,等待设备写 Flash 并响应…'); + addLog('I', '上传', '浏览器已发完全部数据,等待 ESP32 HTTP 响应…'); + startUploadWaitHeartbeat(); + }); + // 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈 + xhr.timeout = 300000; + xhr.addEventListener('timeout', function () { + stopUploadWaitHeartbeat(); + if (activeXhr) { + cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)'); + showToast('上传超时:设备可能忙或连接被踢,请重试', 'error'); + } + }); xhr.addEventListener('load', function () { + stopUploadWaitHeartbeat(); isUploading = false; activeXhr = null; resumePollingAfterUpload(); @@ -1401,18 +1596,24 @@ async function startUpload(chip) { uploadBtn.textContent = uploadBtnOrigText; updateUploadButtonLabel(); if (xhr.status === 200) { - addLog('I', '上传', '固件上传完成!'); - showToast(chip === 'gd32' ? '上传完成,GD32 OTA 已自动开始' : '固件上传成功', 'success'); - setProgress(100, '上传完成'); + // 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复 if (chip === 'esp32') { + addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…'); + showToast('固件上传成功', 'success'); + setProgress(100, '上传完成'); markPhaseDone('esp32Ota'); updateEsp32StagesFromPhase(2); // OTA 写入阶段 - addLog('I', 'OTA', '固件上传完成,准备 SHA-256 校验...'); + // ★ 状态机:ESP32 上传完成 → 进入 VERIFYING(verifyEsp32Firmware 内部继续推进) + otaSessionTransition(OtaSessionState.VERIFYING); verifyEsp32Firmware(); } else { + addLog('I', 'OTA', '上传完成,校验 SHA 并触发 GD32 中继…'); + showToast('上传完成,GD32 OTA 已自动开始', 'success'); switchStageFlow('gd32'); setActivePhase('gd32Ota'); - setProgress(45, '上传完成,校验并触发 GD32 OTA...'); + setProgress(45, '上传完成,校验并触发 GD32 OTA…'); + // ★ 状态机:GD32 上传完成 → 进入 RELAYING(triggerGd32Relay 内部驱动中继) + otaSessionTransition(OtaSessionState.RELAYING); triggerGd32Relay(); } } else if (isOtaBusyError(xhr.status, formatHttpErrorDetail(xhr.responseText))) { @@ -1429,12 +1630,14 @@ async function startUpload(chip) { } }); xhr.addEventListener('error', function () { + stopUploadWaitHeartbeat(); if (activeXhr) { cleanupAfterOtaFailure('网络错误'); showToast('上传过程发生网络错误', 'error'); } }); xhr.addEventListener('abort', function () { + stopUploadWaitHeartbeat(); if (activeXhr) { cleanupAfterOtaFailure('已中止'); showToast('上传已中止', 'error'); @@ -1464,11 +1667,9 @@ async function verifyEsp32Firmware() { showToast('ESP32 正在校验固件并应用...', 'success'); try { - // verify_only=false: 服务器端会先校验 SHA-256,匹配后自动设置启动分区并重启 - // 单次请求即完成全部流程,无需前端再发送"应用"请求 + // 带 sha256 时设备端校验、set_boot 并自动重启;无需二次“应用”请求 var requestBody = {}; if (sha256) requestBody.sha256 = sha256; - requestBody.verify_only = false; var resp = await fetch(getBaseUrl() + '/ota/update', { method: 'POST', @@ -1499,10 +1700,11 @@ async function verifyEsp32Firmware() { addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...'); showToast('校验通过,设备重启中...', 'success'); updateEsp32StagesFromPhase(4); // 应用固件 - isEsp32Rebooting = true; rebootObservedDown = false; // 重置:尚未观察到设备实际下线 updateEsp32StagesFromPhase(5); // 重启中 rebootStartTime = Date.now(); + // ★ 状态机:校验通过 → 进入 REBOOTING(内部同步 isEsp32Rebooting = true) + otaSessionTransition(OtaSessionState.REBOOTING); } else { var detail = data.message || (data.device ? '不匹配' : '未知错误'); addLog('E', 'OTA', '校验失败: ' + detail); @@ -1545,6 +1747,108 @@ async function restartEsp32() { } } // ==================== GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)==================== +/** + * GD32 中继进度观察:以 /system/status 为唯一真相源。 + * 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间—— + * 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。 + */ +var relayWatchTimerId = null; +var relayWatchLastUiLogAt = 0; +var relayWatchInFlight = false; +var gd32CompleteAnnounced = false; +var relayWatchLastSeq = -1; + +function markGd32OtaUiComplete(msg) { + // 已公告过:禁止再改 className / setProgress / toast,否则每 400ms 重触发动效会「疯狂跳 ✓」 + if (gd32CompleteAnnounced) { + return; + } + gd32CompleteAnnounced = true; + stopRelayProgressWatch(); + + markPhaseDone('gd32Ota'); + currentOtaTarget = null; + setProgress(100, msg || 'GD32 刷写完成'); + var sbDone = document.getElementById('stageStatusBar'); + var stDone = document.getElementById('stageStatusText'); + if (sbDone) sbDone.className = 'stage-status-bar ok'; + if (stDone) stDone.textContent = msg || 'GD32 刷写完成'; + if (otaSessionState === OtaSessionState.RELAYING || + otaSessionState === OtaSessionState.FAILED) { + otaSessionTransition(OtaSessionState.DONE); + } + addLog('I', 'OTA', msg || 'GD32 刷写完成'); + showToast(msg || 'GD32 刷写完成', 'success'); +} + +function stopRelayProgressWatch() { + if (relayWatchTimerId) { + clearInterval(relayWatchTimerId); + relayWatchTimerId = null; + } + relayWatchInFlight = false; + if (statusPollIntervalMs < 1000) { + statusPollIntervalMs = 1000; + } +} + +function startRelayProgressWatch() { + stopRelayProgressWatch(); + if (pollingPausedForUpload) { + resumePollingAfterUpload(); + } + statusPollIntervalMs = 400; + scheduleStatusPoll(0); + pollLogs(); + addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志'); + relayWatchTimerId = setInterval(function () { + var active = otaSessionState === OtaSessionState.RELAYING || + (otaSessionState === OtaSessionState.FAILED && + (lastSysStateCode === 3 || lastRelayState >= 1 && lastRelayState <= 4)); + if (!active) { + if (otaSessionState === OtaSessionState.DONE) { + stopRelayProgressWatch(); + } + return; + } + if (relayWatchInFlight) return; + relayWatchInFlight = true; + fetch(getBaseUrl() + '/system/status', { signal: AbortSignal.timeout(3000) }) + .then(function (resp) { return resp.ok ? resp.json() : null; }) + .then(function (data) { + if (!data) return; + try { 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 (code === 4 || rstate === 5 || (alive && rtot > 0 && rseq >= rtot && rstate >= 3)) { + markGd32OtaUiComplete('GD32 刷写完成'); + pollLogs(); + return; + } + if (code === 8 || rstate === 255) { + stopRelayProgressWatch(); + pollLogs(); + return; + } + // 前端进度同步:包数变化时更新进度条(updateSystemStatusUi 已做)并写一条前端日志 + // 后端不再打「转发进度」文字,只靠这里 + 进度条 + if (rtot > 0 && rseq > 0 && rseq !== relayWatchLastSeq) { + 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; }); + }, 400); +} + /** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */ async function triggerGd32Relay() { var sha256 = window._gd32FileSha256; @@ -1556,12 +1860,14 @@ async function triggerGd32Relay() { } addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...'); showToast('正在触发 GD32 OTA', 'success'); + setProgress(48, '校验 SHA 并触发 GD32 中继…'); try { + // 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒 var resp = await fetch(getBaseUrl() + '/ota/relay', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sha256: sha256 }), - signal: AbortSignal.timeout(30000) // 中继可能耗时较长 + signal: AbortSignal.timeout(120000) }); var text = await resp.text(); if (resp.ok) { @@ -1569,8 +1875,12 @@ async function triggerGd32Relay() { var data = JSON.parse(text); if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256); } catch (_) {} - addLog('I', 'OTA', 'GD32 OTA 已触发,等待设备响应...'); + addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数(勿只看日志时间戳)'); showToast('GD32 OTA 已触发', 'success'); + setProgress(50, '中继已启动,同步进度…'); + gd32CompleteAnnounced = false; + relayWatchLastSeq = -1; + startRelayProgressWatch(); } else { // 尝试解析错误响应中的 device SHA try { @@ -1587,6 +1897,28 @@ async function triggerGd32Relay() { cleanupAfterOtaFailure('触发失败 HTTP ' + resp.status); } } catch (e) { + // 请求超时/断连 ≠ 设备未启动中继:先探测 status 再决定是否标失败 + try { + var probe = await fetch(getBaseUrl() + '/system/status', { + signal: AbortSignal.timeout(3000) + }); + if (probe.ok) { + var st = await probe.json(); + var o = st.ota || {}; + var code = Number(o.sys_state_code); + var rstate = Number(o.relay_state); + if (code === 3 || (rstate >= 1 && rstate <= 4)) { + addLog('W', 'OTA', '触发请求中断(' + e.message + '),但设备已在中继,继续跟踪进度'); + showToast('设备已在刷写,同步进度中', 'success'); + if (otaSessionState !== OtaSessionState.RELAYING) { + otaSessionTransition(OtaSessionState.RELAYING); + } + try { updateSystemStatusUi(st); } catch (_) {} + startRelayProgressWatch(); + return; + } + } + } catch (_) { /* probe 失败再走失败清理 */ } addLog('E', 'OTA', 'GD32 触发失败: ' + e.message); showToast('GD32 触发失败', 'error'); cleanupAfterOtaFailure('触发异常'); @@ -1597,7 +1929,6 @@ async function triggerGd32Relay() { var consecutivePollFailures = 0; var statusTimerId = null; var logsTimerId = null; -var pollSlowMode = false; // 降速模式(10s 一次) var lastDiagTime = 0; // 上次诊断时间 var diagResult = ''; // 最近一次诊断结果 var lastSysStateCode = -1; // 上次成功的 sys_state_code,用于失败时判断设备是否在忙 @@ -1651,67 +1982,56 @@ async function diagnoseNetwork() { */ function scheduleStatusPoll(delayMs) { if (statusTimerId) clearTimeout(statusTimerId); + // 中继期加快:进度条依赖 status.relay_seq,1s 间隔体感像卡死 + var interval = statusPollIntervalMs; + if (otaSessionState === OtaSessionState.RELAYING) { + interval = Math.min(interval, 400); + } statusTimerId = setTimeout(function() { statusTimerId = null; pollStatus(); - }, delayMs !== undefined ? delayMs : statusPollIntervalMs); + }, delayMs !== undefined ? delayMs : interval); } /** @description 启动定时轮询(状态 + 日志)。 */ function startPolling() { consecutivePollFailures = 0; - pollSlowMode = false; statusPollIntervalMs = 1000; if (statusTimerId) clearTimeout(statusTimerId); if (logsTimerId) clearInterval(logsTimerId); scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度 logsTimerId = setInterval(pollLogs, 1000); } -function slowDownPolling() { - pollSlowMode = true; - statusPollIntervalMs = 10000; - // 不中断正在进行的请求;它完成后会按新的 10s 间隔调度下一次 - if (!statusPollInFlight) { - if (statusTimerId) clearTimeout(statusTimerId); - scheduleStatusPoll(statusPollIntervalMs); - } - if (logsTimerId) clearInterval(logsTimerId); - logsTimerId = setInterval(pollLogs, 10000); -} -function restorePolling() { - if (pollSlowMode) { - pollSlowMode = false; - consecutivePollFailures = 0; - statusPollIntervalMs = 1000; - if (logsTimerId) clearInterval(logsTimerId); - logsTimerId = setInterval(pollLogs, 1000); - } - // 如果当前没有请求在飞,立即触发一次,确保 UI 尽快恢复 - if (!statusPollInFlight) { - if (statusTimerId) clearTimeout(statusTimerId); - scheduleStatusPoll(0); - } -} function pausePollingForUpload() { if (pollingPausedForUpload) return; pollingPausedForUpload = true; uploadPollChip = activeUploadChip || window.flashTarget || null; consecutivePollFailures = 0; - statusPollIntervalMs = uploadPollChip === "gd32" ? 2500 : 3000; - // ★ ESP32/GD32 上传期间均保持轮询,让用户在网页上看到完整 OTA 进展 - // ESP32 用 3s 间隔(上传为主),GD32 用 2.5s(中继进度依赖 status) - if (statusTimerId) clearTimeout(statusTimerId); - if (logsTimerId) clearInterval(logsTimerId); - scheduleStatusPoll(statusPollIntervalMs); - logsTimerId = setInterval(pollLogs, statusPollIntervalMs); + // ★ 上传大固件期间必须停掉 status/logs 轮询。 + // httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉 + // 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完), + // 但永远等不到 HTTP 响应,卡在「已上传 xxx / xxx」。 + if (statusTimerId) { + clearTimeout(statusTimerId); + statusTimerId = null; + } + if (logsTimerId) { + clearInterval(logsTimerId); + logsTimerId = null; + } + if (statusAbortCtrl) { + try { statusAbortCtrl.abort(); } catch (e) { /* ignore */ } + statusAbortCtrl = null; + statusPollInFlight = false; + } } function resumePollingAfterUpload() { if (!pollingPausedForUpload) return; pollingPausedForUpload = false; uploadPollChip = null; consecutivePollFailures = 0; - statusPollIntervalMs = pollSlowMode ? 10000 : 1000; + statusPollIntervalMs = 1000; if (logsTimerId) clearInterval(logsTimerId); logsTimerId = setInterval(pollLogs, statusPollIntervalMs); // 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续 @@ -1735,22 +2055,18 @@ async function pollStatus() { }); if (resp.ok) { var data = await resp.json(); - // 恢复:如果之前在降速模式,切回 1 秒轮询 - if (consecutivePollFailures > 0 || pollSlowMode) { - var wasSlow = pollSlowMode; + // 恢复:之前有失败计数,说明设备曾离线,现在恢复在线 + if (consecutivePollFailures > 0) { consecutivePollFailures = 0; - if (wasSlow) { - restorePolling(); - addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); - showToast('设备已恢复在线', 'success'); - } + addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); + showToast('设备已恢复在线', 'success'); } if (isEsp32Rebooting) { var rbElapsed = Math.round((Date.now() - rebootStartTime) / 1000); if (!rebootObservedDown && rbElapsed < 10) { // pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询 // 保持"重启中"状态,不清除标志,不推进阶段 - addLog('I', '系统', '设备尚未实际重启,等待 esp_restart...'); + // ★ 真正的"重启完成"由 pollLogs 收到启动 banner(MAIN: ESP32-C3 OTA Relay v1.0 启动)触发 } else { // 设备已重启并恢复在线(rebootObservedDown=true) // 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复 @@ -1759,6 +2075,8 @@ async function pollStatus() { updateEsp32StagesFromPhase(6); // 启动完成 addLog('I', '系统', 'ESP32 已恢复在线'); showToast('ESP32 已恢复在线', 'success'); + // ★ 状态机:ESP32 重启完成 → DONE + if (otaSessionState === OtaSessionState.REBOOTING) otaSessionTransition(OtaSessionState.DONE); } } try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); } @@ -1786,7 +2104,11 @@ async function pollStatus() { (lastRelayState >= 1 && lastRelayState <= 4); // OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的) // 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈 - if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget && !lastDeviceBusy) { + // ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。 + // FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。 + var sessionActive = otaSessionActive(); + var sessionFailed = otaSessionState === OtaSessionState.FAILED; + if (pageVisible && consecutivePollFailures >= 5 && !sessionActive && !isEsp32Rebooting && !currentOtaTarget && !lastDeviceBusy && !sessionFailed) { addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); var diag = await diagnoseNetwork(); addLog('W', '诊断', diag); @@ -1794,16 +2116,31 @@ async function pollStatus() { applyDisconnectedRuntimeState(); var cst = document.getElementById('connStatusTxt'); if (cst) cst.textContent = '诊断中: ' + diag; - // 降速到 10 秒继续监测,等待设备恢复 - slowDownPolling(); - addLog('I', '系统', '已切换为慢速轮询(10s),持续监测设备恢复...'); } - if (isUploading) { + // ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接" + if (sessionFailed) { + if (lastDeviceBusy) { + // 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段 + var pcF = document.getElementById('pillConn'); + var pctF = document.getElementById('pillConnTxt'); + if (pcF) pcF.className = 'pill processing'; + if (pctF) pctF.textContent = '设备忙'; + var csF = document.getElementById('connStatus'); + var cstF = document.getElementById('connStatusTxt'); + if (csF) csF.className = 'conn-indicator warn'; + if (cstF) cstF.textContent = '设备忙(OTA 后台运行中),等待回 IDLE'; + updateConnectionUi(true, '设备忙'); + } else { + // 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留 + updateConnectionUi(false, '未连接'); + } + } else if (otaSessionState === OtaSessionState.UPLOADING) { applyUploadingRuntimeState(); - } else if (isEsp32Rebooting) { + } else if (otaSessionState === OtaSessionState.REBOOTING || isEsp32Rebooting) { rebootObservedDown = true; // 确认设备已下线(正在重启) var elapsed = Math.round((Date.now() - rebootStartTime) / 1000); if (elapsed > 60) { + otaSessionTransition(OtaSessionState.FAILED, 'ESP32 重启超时'); isEsp32Rebooting = false; rebootObservedDown = false; addLog('E', '系统', 'ESP32 重启超时,设备未恢复'); @@ -1813,7 +2150,7 @@ async function pollStatus() { } else { applyRebootingRuntimeState(); } - } else if (lastDeviceBusy) { + } else if (sessionActive || lastDeviceBusy) { // 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙" // 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导) var pc = document.getElementById('pillConn'); @@ -1825,7 +2162,7 @@ async function pollStatus() { if (cs) cs.className = 'conn-indicator warn'; if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询'; updateConnectionUi(true, '设备忙'); - } else if (!pollSlowMode) { + } else { updateConnectionUi(false, '未连接'); applyDisconnectedRuntimeState(); } @@ -1851,8 +2188,6 @@ document.addEventListener('visibilitychange', function () { scheduleStatusPoll(0); } pollLogs(); - // 之前若在慢速模式(10s),切回前台先用 1s 观察一会儿 - if (pollSlowMode) restorePolling(); }); // ==================== ESP32 实时日志拉取 (增量) ==================== var lastLogTimestamp = 0; @@ -1871,6 +2206,18 @@ async function pollLogs() { var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/); if (m) { addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd()); + // ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。 + // esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断 + // "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动, + // 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。 + if (isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay v1\.0 启动/.test(m[3])) { + isEsp32Rebooting = false; + rebootObservedDown = false; + updateEsp32StagesFromPhase(6); // 启动完成 + addLog('I', '系统', 'ESP32 已恢复在线(启动 banner 收到)'); + showToast('ESP32 已恢复在线', 'success'); + if (otaSessionState === OtaSessionState.REBOOTING) otaSessionTransition(OtaSessionState.DONE); + } } else { addLog('I', 'ESP32', line.trimEnd()); } @@ -1881,13 +2228,15 @@ setTimeout(function () { updateUploadButtonLabel(); addLog('I', '系统', '控制台初始化完成,等待固件文件...'); updateSpiHint(); - // IP 输入变化时自动恢复轮询 + // IP 输入变化时清零失败计数并立即刷新状态 var ipInput = document.getElementById('espIp'); if (ipInput) { ipInput.addEventListener('change', function () { - if (pollSlowMode) { - addLog('I', '系统', 'IP 已修改,重新启动轮询...'); - restorePolling(); + consecutivePollFailures = 0; + addLog('I', '系统', 'IP 已修改,重新启动轮询...'); + if (!statusPollInFlight) { + if (statusTimerId) clearTimeout(statusTimerId); + scheduleStatusPoll(0); } }); } diff --git a/tools/ble_ota.js b/tools/ble_ota.js index 2651ce9..2d271e3 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -413,7 +413,11 @@ function refreshTargetUi() { const cfg = currentTarget(); updateBleStageLabels(cfg); - if (state.selectedFile) updateFileInfo(state.selectedFile); + // ★ 上传进行中不调用 updateFileInfo:避免目标切换时进度条被重置。 + // 阶段标签更新(updateBleStageLabels)安全,不碰进度条。 + if (state.selectedFile && !state.uploading && !state.autoFlashing) { + updateFileInfo(state.selectedFile); + } setConnectionState(!!state.server); // 同步目标胶囊标签 var espTab = document.getElementById('targetEsp32Tab'); @@ -964,6 +968,16 @@ } function updateFileInfo(file) { + // ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget + // 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...) + // 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。 + var isUploading = state.uploading || state.autoFlashing; + if (isUploading && file && state.selectedFile) { + // 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段 + bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D'); + return Promise.resolve(); + } + els.fileName.textContent = file ? file.name : '-'; els.fileType.textContent = file ? (file.name.toLowerCase().includes('.hex') ? 'Intel HEX' : 'Binary') : '-'; els.fileSize.textContent = file ? formatBytes(file.size) : '0 B'; @@ -1018,6 +1032,13 @@ } function finalizeFileInfo(file) { + // ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间 + // 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。 + var isUploading = state.uploading || state.autoFlashing; + if (isUploading) { + bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D'); + return; + } setConnectionState(!!state.server); if (file) { setProgress(0, `已选择 ${file.name}`); @@ -1065,6 +1086,9 @@ } async function syncSelectedFileFromInput() { + // ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间 + // 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。 + if (state.uploading || state.autoFlashing) return; // FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空 if (state.selectedFileHandle) return; const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null; @@ -1099,6 +1123,12 @@ } function clearSelectedFile() { + // ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用, + // 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。 + if (state.uploading || state.autoFlashing) { + bleLog('FILE', '上传进行中,拒绝清除文件', 'W'); + return; + } state.selectedFile = null; state.selectedFileHandle = null; state.lastUploadStatus = 'idle'; @@ -1592,6 +1622,12 @@ disconnect: disconnectBle, showToast: showToast, notifyFileChange: async function(file, handle) { + // ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget + // 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。 + if (state.uploading || state.autoFlashing) { + bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D'); + return; + } if (file) { if (handle) state.selectedFileHandle = handle; await updateFileInfo(file); @@ -1602,6 +1638,10 @@ setConnectionState(!!state.server); }, refreshTargetUi: refreshTargetUi, + // ★ 暴露上传状态查询,供 index.html 的 selectFlashTarget 等外部守卫使用 + isUploading: function() { + return !!(state.uploading || state.autoFlashing); + }, }; // 脚本在 body 末尾同步加载,DOM 元素已就绪,直接初始化 diff --git a/tools/index.html b/tools/index.html index 6c8a71a..fbf0555 100644 --- a/tools/index.html +++ b/tools/index.html @@ -427,7 +427,7 @@ 操作成功 - + - +