添加了SPI链接V2支持文件,重构OTA协调器的存储准备流程,
统一WiFi/BLE传输路径的收尾处理逻辑。 feat(ota): 优化OTA性能并增强系统监控功能 - 修改RSSI警告阈值从-75dBm调整为-80dBm,更新相关文档说明 - 增加HTTP服务器任务栈大小至10240字节,解决栈溢出问题 - 将OTA块大小从4096字节降至2048字节以降低峰值堆内存占用 - 实现按需擦除机制替代预擦除,在GD32 OTA中按扇区动态擦除 - 添加任务栈水位主动巡检功能,监控栈使用情况并告警 - 集成FreeRTOS栈溢出钩子,提供栈溢出检测和告警 - 优化ESP32 OTA实现使用顺序写入模式,消除长时间擦除阻塞 - 改进堆内存不足告警机制,添加限流避免刷屏 - 移除不必要的调试日志输出,精简健康监控模块
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
sandbox_mode = "workspace-write"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
@@ -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 而非阻塞
|
||||
@@ -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:应清零失败计数并提示"恢复在线"
|
||||
@@ -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 固件上限 |
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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"
|
||||
|
||||
@@ -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 包执行一次健康检查
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 进行中)");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <cstring>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "driver/uart.h"
|
||||
#include "esp_system.h"
|
||||
|
||||
// 供 HealthMonitor 巡检检测:canary 触发后置位,由巡检发详细告警。
|
||||
volatile bool g_stackOverflowDetected = false;
|
||||
|
||||
extern "C" void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
|
||||
{
|
||||
(void)xTask;
|
||||
g_stackOverflowDetected = true;
|
||||
|
||||
// 栈已损坏,只能做不依赖栈的最小操作。UART0 硬写常量字符串。
|
||||
static const char prefix[] = "\r\n!!! STACK OVERFLOW in task: ";
|
||||
uart_write_bytes(UART_NUM_0, prefix, sizeof(prefix) - 1);
|
||||
// 任务名最多 configMAX_TASK_NAME_LEN,strlen 对小已知 buffer 安全
|
||||
uart_write_bytes(UART_NUM_0, pcTaskName, strnlen(pcTaskName, configMAX_TASK_NAME_LEN));
|
||||
static const char suffix[] = " !!!\r\n";
|
||||
uart_write_bytes(UART_NUM_0, suffix, sizeof(suffix) - 1);
|
||||
|
||||
/* 立刻 abort:OTA 上传 100% 后若 httpd 栈溢出却不 abort,
|
||||
* 前端会一直停在 100% 像“卡住”,且 TCB 已损坏无法恢复。 */
|
||||
esp_system_abort("stack overflow");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* stack_overflow_hook.h - FreeRTOS 栈溢出钩子标志声明
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* canary 触发时由 vApplicationStackOverflowHook 置位;
|
||||
* HealthMonitor 巡检循环检测此标志,置位后用 DEBUG_ERR 发详细告警。 */
|
||||
extern volatile bool g_stackOverflowDetected;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* task_stack_check.h - 任务栈水位指标收集(共享)
|
||||
*
|
||||
* 将受监控任务的任务名/句柄/栈大小统一收集,供:
|
||||
* - health_monitor 巡检主动告警(低水位时 DEBUG_ERR,突破 OTA 门控)
|
||||
* - http_handlers /system/status 被动报告(前端每秒轮询展示)
|
||||
* 两处复用同一份任务表,避免散落维护。
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
/** @brief 单个任务的栈指标描述。 */
|
||||
struct TaskStackMetric {
|
||||
const char* name; ///< 任务名(展示用)
|
||||
TaskHandle_t handle; ///< FreeRTOS 任务句柄(可能为 NULL:任务尚未创建)
|
||||
size_t stack_size_bytes; ///< 创建该任务时分配的栈大小(字节)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 收集所有受监控任务的栈指标。
|
||||
* @param out 输出数组。
|
||||
* @param max out 数组容量。
|
||||
* @return 实际写入条数(<= max)。
|
||||
*
|
||||
* 注意:部分任务句柄在任务创建前为 NULL,调用方应跳过 handle==NULL 的条目。
|
||||
*/
|
||||
size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max);
|
||||
+37
-22
@@ -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};
|
||||
|
||||
@@ -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,直接转发到
|
||||
|
||||
@@ -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 分区写入固件数据块。
|
||||
|
||||
+151
-76
@@ -10,7 +10,6 @@
|
||||
* 协议子步骤与加密/Flash 工具方法保持不变。
|
||||
*/
|
||||
#include <string.h>
|
||||
#include "monitor/can_feedback_task.h"
|
||||
#include <stdlib.h>
|
||||
#include <inttypes.h>
|
||||
#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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 超时失败。
|
||||
*/
|
||||
|
||||
+54
-27
@@ -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));
|
||||
}
|
||||
|
||||
+225
-172
@@ -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<UBaseType_t>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool> m_staConnected{false};
|
||||
std::atomic<int> m_staRetryCount{0};
|
||||
std::atomic<bool> m_staRetryExhausted{false};
|
||||
std::atomic<bool> m_gd32StorageDone{false};
|
||||
SemaphoreHandle_t m_stateMutex = NULL;
|
||||
TaskHandle_t m_taskHandle = nullptr;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <inttypes.h>
|
||||
#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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 被并发覆盖导致死锁)。
|
||||
|
||||
@@ -50,7 +50,6 @@ void Protocol::init()
|
||||
}
|
||||
|
||||
m_initialized = true;
|
||||
DEBUG_INFO("PROT", "协议层就绪");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+727
-299
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+464
-115
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+41
-1
@@ -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 元素已就绪,直接初始化
|
||||
|
||||
+6
-4
@@ -427,7 +427,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260712a"></script>
|
||||
<script src="app.js?v=20260713i"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
@@ -485,8 +485,10 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
|
||||
window.selectFlashTarget = function (target) {
|
||||
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
|
||||
if (window.isUploading) {
|
||||
var active = (window.activeUploadChip || 'OTA').toUpperCase();
|
||||
// ★ 兼容 BLE 模式:BLE 上传用 bleOta.state.uploading,window.isUploading 仅 WiFi 通道使用。
|
||||
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
|
||||
if (window.isUploading || bleBusy) {
|
||||
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
|
||||
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
|
||||
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
|
||||
return;
|
||||
@@ -670,6 +672,6 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
applySharedModeUi();
|
||||
})();
|
||||
</script>
|
||||
<script src="ble_ota.js?v=20260712d"></script>
|
||||
<script src="ble_ota.js?v=20260712e"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user