diff --git a/main/include/version.h b/main/include/version.h index 89d67b9..2438ea9 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 26 -#define FW_VERSION_STRING "0.3.26" +#define FW_VERSION_PATCH 27 +#define FW_VERSION_STRING "0.3.27" // 编译时间 -#define BUILD_TIME "2026-06-18 01:32:44" +#define BUILD_TIME "2026-06-19 02:00:09" // Git 信息 -#define GIT_HASH "2586582" +#define GIT_HASH "41fee93" diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index e943669..8be3d7e 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -16,6 +16,7 @@ #include "esp_err.h" #include "driver/temperature_sensor.h" +#include "esp_heap_caps.h" #include "health_monitor.h" #include "debug_log.h" #include "server/wifi_ota.h" @@ -197,7 +198,9 @@ void HealthMonitor::taskEntry(void *pvParameters) while (1) { uint32_t free_heap = esp_get_free_heap_size(); if (free_heap < HEAP_WARNING_MIN) { - DEBUG_WARN("MON", "堆内存不足: %lu < %lu", 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); } health_state_t cur = self.getState(); if (cur >= SYS_CRITICAL) { diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index 202e8a4..604345f 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -171,30 +171,24 @@ esp_err_t Esp32OtaCore::computeShaFromPartition() mbedtls_sha256_init(&ctx); mbedtls_sha256_starts(&ctx, 0); - uint8_t *read_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); - if (!read_buf) { - DEBUG_ERR(TAG, "SHA 计算: malloc 失败"); - mbedtls_sha256_free(&ctx); - return ESP_ERR_NO_MEM; - } + // ★ static 缓冲区:OTA 操作由 OtaCoordinator 互斥串行化,无并发风险 + static uint8_t s_sha_read_buf[OTA_BLOCK_SIZE]; size_t offset = 0; while (offset < m_received) { size_t to_read = m_received - offset; if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; - esp_err_t rd_err = esp_partition_read(m_partition, offset, read_buf, to_read); + 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)); - free(read_buf); mbedtls_sha256_free(&ctx); return rd_err; } - mbedtls_sha256_update(&ctx, read_buf, to_read); + mbedtls_sha256_update(&ctx, s_sha_read_buf, to_read); offset += to_read; } - free(read_buf); mbedtls_sha256_finish(&ctx, m_sha256); mbedtls_sha256_free(&ctx); m_shaComputed = true; diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 866804f..07e7c05 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -8,6 +8,9 @@ #include "esp32_ota_core.h" #include "relay/ota_relay.h" #include "monitor/debug_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #define TAG "OTA_COORD" @@ -283,11 +286,18 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size) // ★ 卡死自愈检查 if (h) watchdogCheckLocked(); - // 系统忙检查 + // 系统忙检查:GD32 传输/中继进行中,或另一通道的 ESP32 OTA 进行中,均拒绝。 + // ★ 必须显式拒绝 ESP32_RECEIVING/FINALIZING/REBOOTING: + // 若放行,本调用会落到 Esp32OtaCore::begin 去抢全局互斥失败而返回 INVALID_STATE, + // 随后 beginEsp32OtaWithRetry 的 forceCleanup 会把另一通道的活跃会话 + // (esp_ota_abort + 释放互斥)误中止 → 两通道并发写同一 OTA 分区。 if (h && (m_sysState == OTA_SYS_GD32_STORING || - m_sysState == OTA_SYS_GD32_RELAYING)) { - DEBUG_WARN(TAG, "GD32 正在 %s,拒绝 ESP32 OTA", - m_sysState == OTA_SYS_GD32_STORING ? "存储" : "中继"); + m_sysState == OTA_SYS_GD32_RELAYING || + m_sysState == OTA_SYS_ESP32_RECEIVING || + m_sysState == OTA_SYS_ESP32_FINALIZING || + m_sysState == OTA_SYS_ESP32_REBOOTING)) { + DEBUG_WARN(TAG, "系统忙 (state=%d, %s),拒绝 ESP32 OTA", + (int)m_sysState, getSystemStateString()); unlock(h); return ESP_ERR_INVALID_STATE; } @@ -309,6 +319,29 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size) return err; } +esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size) +{ + esp_err_t err = beginEsp32Ota(total_size); + if (err == ESP_ERR_INVALID_STATE) { + // ★ 仅当系统确实空闲(无任何活跃 OTA)时才允许 forceCleanup: + // 这种情况只可能是"锁残留"——前一次 OTA 异常退出没释放全局互斥, + // 而协调器状态已回 IDLE。 + // 若系统正处于任何活跃 OTA(ESP32/GD32),绝不强制清理——那会 + // 把另一通道正在进行的 esp_ota_handle 中止、释放互斥,导致 + // 两通道并发写同一 OTA 分区(之前 BLE 与 WiFi 可同时 OTA 的根因)。 + if (!isIdle()) { + DEBUG_WARN(TAG, "ESP32 OTA 被拒绝且系统非空闲 (state=%d),不强制清理,直接失败", + (int)getSystemState()); + return err; + } + // 协调器空闲但 Esp32OtaCore.begin 仍被拒 → 锁残留,强制清理后重试 + DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但系统空闲,判定为锁残留,强制清理状态机后重试"); + Esp32OtaCore::instance().forceCleanup(); + err = beginEsp32Ota(total_size); + } + return err; +} + void OtaCoordinator::notifyEsp32ReceiveComplete() { bool h = lock(); @@ -339,6 +372,40 @@ void OtaCoordinator::notifyEsp32Rebooting() unlock(h); } +// ============================================================ +// ESP32 OTA 收尾+重启序列(BLE/WiFi 共用封装) +// 关键:finalize() 内部已 end+set_boot+释放锁,reboot=false 返回后 +// m_active 仍为 true、m_ended 为 true,调用方可在重启前自由查询状态。 +// ============================================================ +Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_hex) +{ + Esp32FinalizeResult r = {}; + notifyEsp32ReceiveComplete(); + // reboot=false:让 core.finalize 真正返回,由调用方上报 device_sha 后再调 restartEsp32 + r.err = Esp32OtaCore::instance().finalize(frontend_sha_hex, + /*reboot=*/false, + r.device_sha_hex); + if (r.err == ESP_OK) { + notifyEsp32FinalizeComplete(); + } + return r; +} + +void OtaCoordinator::restartEsp32() +{ + notifyEsp32Rebooting(); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + // 不会返回 +} + +int OtaCoordinator::esp32FinalizeErrCategory(esp_err_t err) +{ + if (err == ESP_OK) return 0; // 成功 + if (err == ESP_ERR_INVALID_VERSION) return 1; // SHA 不匹配 + return 2; // 其他失败 +} + // ============================================================ // 中止 // ============================================================ diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index a37f074..a20a8db 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -35,6 +35,15 @@ typedef enum { OTA_SYS_ERROR // 错误状态 } ota_system_state_t; +/** + * @brief ESP32 OTA 收尾结果(由 finalizeForReboot 返回)。 + * err 为 ESP_OK 时 device_sha_hex 有效;SHA 不匹配时也填充 device_sha_hex 供前端核对。 + */ +typedef struct { + esp_err_t err; ///< ESP_OK / ESP_ERR_INVALID_VERSION(SHA不匹配) / 其他 + char device_sha_hex[65]; ///< 设备端 SHA hex(成功或 SHA 不匹配时填充) +} Esp32FinalizeResult; + /** * @brief 全局 OTA 协调器(单例) * @@ -101,6 +110,14 @@ public: */ esp_err_t beginEsp32Ota(uint32_t total_size); + /** + * @brief 发起 ESP32 OTA,若被 ESP_ERR_INVALID_STATE 拒绝则 forceCleanup 后重试一次。 + * 封装 BLE/WiFi 两端完全相同的重试模式,调用方只需调一次。 + * @param total_size 固件总字节数。 + * @return ESP_OK 成功;其他 = 协调器拒绝或 Esp32OtaCore::begin 失败。 + */ + esp_err_t beginEsp32OtaWithRetry(uint32_t total_size); + /** * @brief ESP32 OTA 固件接收完毕,准备收尾。 * 更新系统状态为 OTA_SYS_ESP32_FINALIZING。 @@ -117,6 +134,23 @@ public: */ void notifyEsp32Rebooting(); + /** + * @brief ESP32 OTA 收尾(统一封装 BLE/WiFi 两端重复的收尾序列)。 + * 步骤:notifyEsp32ReceiveComplete → Esp32OtaCore::finalize(reboot=false) + * → 成功则 notifyEsp32FinalizeComplete。 + * 不重启;调用方负责把 device_sha_hex 上报前端后再调 restartEsp32()。 + * @param frontend_sha_hex 前端 64 字符 hex SHA-256,nullptr 表示跳过比对。 + * @return Esp32FinalizeResult,err 为 ESP_OK/ESP_ERR_INVALID_VERSION/其他。 + */ + Esp32FinalizeResult finalizeForReboot(const char *frontend_sha_hex); + + /** + * @brief 重启 ESP32(统一封装 BLE/WiFi 两端重复的重启序列)。 + * 步骤:notifyEsp32Rebooting → vTaskDelay(500ms) → esp_restart()。 + * 应在 finalizeForReboot 成功并完成前端上报后调用。不会返回。 + */ + void restartEsp32(); + // ── 中止 ── /** * @brief 中止当前 OTA 操作(GD32 或 ESP32)。 @@ -167,6 +201,14 @@ public: */ void reset(); + // ── ESP32 OTA finalize 错误分类(BLE/WiFi 共用)── + /** + * @brief 把 finalize 返回的 esp_err_t 映射为前端可读的事件码。 + * 统一 BLE/WiFi 两端语义,便于日志/状态一致。 + * @return 0=成功;1=SHA 不匹配;2=其他失败。 + */ + static int esp32FinalizeErrCategory(esp_err_t err); + private: OtaCoordinator() = default; ~OtaCoordinator() = default; diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index c497486..9b58633 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -835,6 +835,28 @@ esp_err_t OtaRelay::metaVerifyChecksum() return ESP_OK; } +esp_err_t OtaRelay::verifyFrontendSha(const uint8_t frontend_sha32[32], + uint8_t out_device_sha[32]) +{ + if (!frontend_sha32) return ESP_ERR_INVALID_ARG; + + // 1) 计算设备端 SHA 并写入 meta.checksum(metaFinalizeChecksum 内部已校验固件完整性) + esp_err_t err = metaFinalizeChecksum(); + if (err != ESP_OK) return err; + + // 2) 读回 meta 拿到 checksum,回传给调用方 + ota_meta_t meta; + err = metaLoad(&meta); + if (err != ESP_OK) return err; + if (out_device_sha) memcpy(out_device_sha, meta.checksum, 32); + + // 3) 比对前端 SHA 与设备端 SHA + if (memcmp(meta.checksum, frontend_sha32, 32) != 0) { + return ESP_ERR_INVALID_CRC; + } + return ESP_OK; +} + esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len) { if (!instance().m_gd32Partition) { @@ -1167,8 +1189,13 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) } 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); - result = OTA_RESULT_ERR_TIMEOUT; - break; + if (++retry_count >= 10U) { + DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq); + result = OTA_RESULT_ERR_TIMEOUT; + break; + } + vTaskDelay(pdMS_TO_TICKS(1)); + continue; // 重发本包 } uint32_t expected_ack = (seq == m_transferTotalPackets - 1) ? 0xF0F0F0F0U : (seq + 1U); diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 16e6c34..31f78e7 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -180,6 +180,16 @@ public: * @brief 校验固件完整性(SHA-256). */ static esp_err_t metaVerifyChecksum(); + /** + * @brief 校验前端提供的 SHA-256 是否与设备端计算的一致。 + * BLE/WiFi 传输层共用:内部完成 metaFinalizeChecksum → metaLoad → memcmp。 + * 无论是否匹配,都会通过 out_device_sha 回传设备端 SHA(便于前端核对)。 + * @param frontend_sha32 前端 32 字节原始 SHA(非 hex)。 + * @param out_device_sha 可为 nullptr;非空时输出设备端 32 字节 SHA。 + * @return ESP_OK 匹配;ESP_ERR_INVALID_CRC 不匹配;其他 = 元数据/计算失败。 + */ + static esp_err_t verifyFrontendSha(const uint8_t frontend_sha32[32], + uint8_t out_device_sha[32]); // ── 公开状态成员 ── static relay_state_t relayState; diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index ded74e8..ef5781d 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -127,15 +127,13 @@ static void ble_esp32_restart_task(void *pvParameters) (void)pvParameters; DEBUG_INFO(TAG, "[RESTART] 推送 FINISH_REBOOT_NOW 后 500ms 重启"); sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); - vTaskDelay(pdMS_TO_TICKS(500)); - OtaCoordinator::instance().notifyEsp32Rebooting(); - esp_restart(); + OtaCoordinator::instance().restartEsp32(); // 内部 notify + delay + esp_restart,不返回 } /** * @brief 异步完成 ESP32 OTA 收尾并触发重启。 - * @param pvParameters BleEsp32FinishCtx(堆分配,任务负责释放)。 - */ + * @param pvParameters BleEsp32FinishCtx(堆分配,任务负责释放)。 + */ static void ble_esp32_finish_and_reboot_task(void *pvParameters) { BleEsp32FinishCtx *ctx = (BleEsp32FinishCtx *)pvParameters; @@ -166,26 +164,17 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) } DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); - OtaCoordinator::instance().notifyEsp32ReceiveComplete(); - // 关键:reboot=false,让 finalize 真正返回、拿到 device_sha_hex, - // 再依次推送 SHA 通知 + FINISH_DONE + FINISH_REBOOT_NOW, - // 最后延迟重启。若 reboot=true,core 内部直接 esp_restart(), - // 下面所有 notifyResponse / sendFinishEvent 都不会执行, - // 前端也就拿不到设备端 SHA 与"收尾完成/即将重启"事件。 - char device_sha_hex[65] = {0}; - esp_err_t err = core.finalize(frontend_sha_hex, - /*reboot=*/false, - device_sha_hex); + // ★ 收尾序列(notify + finalize)由协调器统一封装,reboot=false 让 device_sha 可被读取 + Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot(frontend_sha_hex); - if (err == ESP_OK) { - OtaCoordinator::instance().notifyEsp32FinalizeComplete(); + if (fr.err == ESP_OK) { // 1) 把设备端 SHA 通过 SHA256 通知推给前端 - if (device_sha_hex[0] != '\0') { + if (fr.device_sha_hex[0] != '\0') { uint8_t device_sha[32] = {0}; for (int i = 0; i < 32; ++i) { unsigned int byte = 0; - sscanf(&device_sha_hex[i * 2], "%02x", &byte); + sscanf(&fr.device_sha_hex[i * 2], "%02x", &byte); device_sha[i] = (uint8_t)byte; } sendSha256Notify(device_sha, session_id); @@ -193,24 +182,22 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) // 2) 等待 SHA 通知从 NimBLE host 队列送出 vTaskDelay(pdMS_TO_TICKS(150)); - // 3) 推送"OTA 收尾完成"与"即将重启"事件 + // 3) 推送"OTA 收尾完成"事件 sendFinishEvent(BLE_OTA_FINISH_DONE, packBleFinishValue(session_id, 0)); sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, packBleFinishValue(session_id, 0)); - // 4) 通知全部发出后延迟 500ms 重启 - DEBUG_INFO(TAG, "[FINISH] 通知已发送,500ms 后重启..."); - vTaskDelay(pdMS_TO_TICKS(500)); - OtaCoordinator::instance().notifyEsp32Rebooting(); - esp_restart(); - // 不会返回 - } else if (err == ESP_ERR_INVALID_VERSION) { + // 4) 通知全部发出后由协调器统一重启(notify + delay + esp_restart) + DEBUG_INFO(TAG, "[FINISH] 通知已发送,重启..."); + if (ctx) free(ctx); + OtaCoordinator::instance().restartEsp32(); // 不返回 + } else if (fr.err == ESP_ERR_INVALID_VERSION) { DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, packBleFinishValue(session_id, 0)); BleOta::instance().m_esp32RebootPending = false; // ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer esp32_reorder_free(); } else { - DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err)); + DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(fr.err)); sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, packBleFinishValue(session_id, 1)); BleOta::instance().m_esp32RebootPending = false; @@ -223,41 +210,39 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) } // ============================================================ -// WiFi 共存优化:BLE OTA 传输期间降低 WiFi 射频占用 +// WiFi 共存策略:BLE OTA 传输期间的处理 +// +// 历史:曾在此处把 WiFi 切到 WIFI_PS_MAX_MODEM 以把空中时间让给 BLE。 +// 但 MAX_MODEM 下 STA 监听间隔被拉到极大,HTTP server 的 TCP 握手/数据包 +// 会大量丢失或延迟到超时 → BLE OTA 期间任何 WiFi 请求(包括互斥拒绝应返回的 +// 403)都到不了设备,前端 XHR 长时间无响应,表现为"Wifi 上传既不成功也不报错"。 +// +// 现策略:不切换 WiFi 省电模式(保持 WIFI_PS_NONE),让 STA 全程可用, +// 协调器照常以 403 拒绝并发 OTA。ESP32-C3 coex 默认调度已能保证 BLE OTA 可用。 +// 仍保留 setBleOtaActive() 暂停 RSSI 轮询(避免 get_ap_info 的射频活动干扰 BLE)。 // ============================================================ static bool s_wifi_ps_boosted = false; /** - * @brief BLE OTA 开始时,将 WiFi 切换到最大省电模式,减少射频抢占。 - * WiFi STA 仍保持连接,但信标监听间隔大幅拉长,BLE 获得更多空中时间。 + * @brief BLE OTA 开始时的 WiFi 共存处理(暂停 RSSI 轮询,不切省电模式)。 */ static void ble_ota_boost_wifi_ps() { if (s_wifi_ps_boosted) return; - esp_err_t err = esp_wifi_set_ps(WIFI_PS_MAX_MODEM); - if (err == ESP_OK) { - s_wifi_ps_boosted = true; - WifiOta::instance().setBleOtaActive(true); // ★ 暂停 WiFi RSSI 轮询 - DEBUG_INFO(TAG, "WiFi 已切换到 MAX_MODEM 省电模式(BLE OTA 传输优化)"); - } else { - DEBUG_WARN(TAG, "WiFi 省电模式切换失败: %s", esp_err_to_name(err)); - } + s_wifi_ps_boosted = true; + WifiOta::instance().setBleOtaActive(true); // ★ 暂停 WiFi RSSI 轮询 + DEBUG_INFO(TAG, "BLE OTA 进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)"); } /** - * @brief BLE OTA 结束/中止时,恢复 WiFi 省电策略为 WIFI_PS_NONE。 + * @brief BLE OTA 结束/中止时恢复 WiFi RSSI 轮询。 */ static void ble_ota_restore_wifi_ps() { if (!s_wifi_ps_boosted) return; - esp_err_t err = esp_wifi_set_ps(WIFI_PS_NONE); - if (err == ESP_OK) { - s_wifi_ps_boosted = false; - WifiOta::instance().setBleOtaActive(false); // ★ 恢复 WiFi RSSI 轮询 - DEBUG_INFO(TAG, "WiFi 已恢复 WIFI_PS_NONE 模式"); - } else { - DEBUG_WARN(TAG, "WiFi 省电模式恢复失败: %s", esp_err_to_name(err)); - } + s_wifi_ps_boosted = false; + WifiOta::instance().setBleOtaActive(false); // ★ 恢复 WiFi RSSI 轮询 + DEBUG_INFO(TAG, "BLE OTA 结束:恢复 WiFi RSSI 轮询"); } // ============================================================ @@ -558,14 +543,8 @@ static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd, */ static esp_err_t ble_esp32_ota_begin(size_t total_size) { - // ★ 通过全局协调器发起 ESP32 OTA - esp_err_t err = OtaCoordinator::instance().beginEsp32Ota(total_size); - if (err == ESP_ERR_INVALID_STATE) { - // 协调器拒绝 → 尝试强制清理后重试 - DEBUG_WARN(TAG, "ESP32 OTA 被协调器拒绝,强制清理状态机后重试"); - Esp32OtaCore::instance().forceCleanup(); - err = OtaCoordinator::instance().beginEsp32Ota(total_size); - } + // ★ 通过协调器发起(内部已含 forceCleanup 重试) + esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size); if (err != ESP_OK) { return err; } @@ -765,32 +744,24 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, char device_sha_hex[65] = {0}; shaBytesToHexUpper(frontend_sha, frontend_sha_hex); - err = OtaRelay::metaFinalizeChecksum(); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 缓存 SHA-256 计算失败: %s", esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 3)); - return 0; - } - - err = OtaRelay::metaLoad(&meta); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 SHA-256 元数据读取失败: %s", esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 3)); - return 0; - } - - shaBytesToHexUpper(meta.checksum, device_sha_hex); - sendSha256Notify(meta.checksum, session_id); + // ★ 统一走共享 helper:计算设备端 SHA → 比对前端 SHA + uint8_t device_sha[32] = {0}; + err = OtaRelay::verifyFrontendSha(frontend_sha, device_sha); + shaBytesToHexUpper(device_sha, device_sha_hex); + sendSha256Notify(device_sha, session_id); DEBUG_INFO(TAG, "GD32 前端 SHA-256: %s, session=%u", frontend_sha_hex, session_id); DEBUG_INFO(TAG, "GD32 缓存 SHA-256: %s", device_sha_hex); - if (memcmp(meta.checksum, frontend_sha, 32) != 0) { + if (err == ESP_ERR_INVALID_CRC) { DEBUG_ERR(TAG, "GD32 缓存 SHA-256 与前端文件不匹配,拒绝触发 SPI 中继"); sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, packBleFinishValue(session_id, 0)); return 0; + } else if (err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 SHA-256 计算/读取失败: %s", esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 3)); + return 0; } DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继", @@ -1032,13 +1003,22 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size); return; } - // 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成 - esp_err_t err = ble_esp32_ota_begin(total_size); - if (err != ESP_OK) { - if (err == ESP_ERR_INVALID_STATE) { - sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + // ★ 幂等保护:若已有活跃 ESP32 OTA 会话,说明这是客户端因 ACK 超时 + // 重传的 seq=0(同一会话续传)。若再次走 begin,会被协调器以 + // "OTA 已 ESP32_RECEIVING" 拒绝 → 误报"被其他通道占用"并中止整条 + // OTA 流程。此处跳过 begin 复用现有会话,后续 reorder buffer 会 + // 按 seq 去重(seq=0 < expected 时作为重复包丢弃)。 + if (Esp32OtaCore::instance().isActive()) { + DEBUG_INFO(TAG, "ESP32 OTA 会话已激活,seq=0 视为重传,跳过 begin"); + } else { + // 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成 + esp_err_t err = ble_esp32_ota_begin(total_size); + if (err != ESP_OK) { + if (err == ESP_ERR_INVALID_STATE) { + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + } + return; } - return; } } diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index a4be5b1..2ab0f55 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -48,6 +48,9 @@ WifiOta& WifiOta::instance() static constexpr uint32_t GD32_OTA_FLASH_SECTOR_SIZE = 4096U; +// ★ OTA 接收共享缓冲区:由 OtaCoordinator 互斥串行化,无并发风险,避免频繁 malloc/free 导致堆碎片化 +static uint8_t s_ota_recv_buf[OTA_BLOCK_SIZE]; + /** * @brief 按指定对齐值向上取整。 * @param value 待对齐的值。 @@ -509,47 +512,39 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req) return ESP_OK; } - // ── 完整模式:finalize 不重启,先把含设备端 SHA 的结果返回给前端 ── - // 关键:reboot=false,让 core.finalize 真正返回,才能拿到 device_sha_hex - // 并把响应发回前端;待响应发完后再延迟重启。 - // 若 reboot=true,core 内部直接 esp_restart(),HTTP 响应永远发不出去, - // 前端会得到 "Failed to fetch" 而错过核对结果。 + // ── 完整模式:收尾+重启序列由协调器统一封装 ── + // 关键:reboot=false 行为由 finalizeForReboot 内部封装, + // 响应发回前端后再由 restartEsp32() 统一通知+delay+esp_restart。 DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived()); - OtaCoordinator::instance().notifyEsp32ReceiveComplete(); - esp_err_t err = core.finalize(has_frontend_sha ? frontend_sha_hex : nullptr, - /*reboot=*/false, - device_sha_hex); + Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot( + has_frontend_sha ? frontend_sha_hex : nullptr); - if (err == ESP_OK) { - OtaCoordinator::instance().notifyEsp32FinalizeComplete(); + if (fr.err == ESP_OK) { // 1) 把包含设备端 SHA 的响应发回前端 char resp[320] = {0}; snprintf(resp, sizeof(resp), "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", " "\"sha256\": \"%s\"}", - device_sha_hex); + fr.device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); - // 2) 响应发完后延迟 500ms 重启,给 TCP 缓冲区时间 flush 到客户端 - DEBUG_INFO("OTA", "响应已发送,500ms 后重启..."); - vTaskDelay(pdMS_TO_TICKS(500)); - OtaCoordinator::instance().notifyEsp32Rebooting(); - esp_restart(); - // 不会返回 + // 2) 响应发完后由协调器统一重启(notify + delay + esp_restart) + DEBUG_INFO("OTA", "响应已发送,重启..."); + OtaCoordinator::instance().restartEsp32(); // 不返回 return ESP_OK; } // 失败:core 已内部清理(abort + mutex release) - if (err == ESP_ERR_INVALID_VERSION) { + if (fr.err == ESP_ERR_INVALID_VERSION) { char err_resp[256] = {0}; snprintf(err_resp, sizeof(err_resp), "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}", - frontend_sha_hex, device_sha_hex); + frontend_sha_hex, fr.device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, err_resp, strlen(err_resp)); } else { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(fr.err)); } return ESP_FAIL; } @@ -564,15 +559,13 @@ static esp_err_t restart_handler(httpd_req_t *req) (void)req; http_set_cors_headers(req); - DEBUG_INFO("OTA", "收到重启请求,500ms 后重启..."); + DEBUG_INFO("OTA", "收到重启请求,重启..."); const char resp[] = "{\"status\": \"ok\", \"message\": \"rebooting\"}"; httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); - vTaskDelay(pdMS_TO_TICKS(500)); - OtaCoordinator::instance().notifyEsp32Rebooting(); - esp_restart(); + OtaCoordinator::instance().restartEsp32(); // 内部 notify + delay + esp_restart,不返回 return ESP_OK; } @@ -613,7 +606,8 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size) static esp_err_t ota_upload_handler(httpd_req_t *req) { http_set_cors_headers(req); - DEBUG_INFO("WIFI", "=== HTTP: POST /ota/upload ==="); + // ★ 入口追踪:用于确认 HTTP 请求是否真的到达设备(排查"前端点了但设备没反应") + DEBUG_WARN("WIFI", "=== HTTP: POST /ota/upload === content_len=%d", req->content_len); DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len); // ★ WiFi OTA 入口:强制恢复 WIFI_PS_NONE,防止 BLE OTA 残留省电模式导致断连 @@ -671,14 +665,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) return ESP_FAIL; } - // ★ 通过全局协调器发起 ESP32 OTA - esp_err_t err = OtaCoordinator::instance().beginEsp32Ota(total_size); - if (err == ESP_ERR_INVALID_STATE) { - // 协调器拒绝(系统忙或通道被占用)→ 尝试强制清理后重试 - DEBUG_WARN("WIFI", "ESP32 OTA 被协调器拒绝,强制清理状态机后重试"); - Esp32OtaCore::instance().forceCleanup(); - err = OtaCoordinator::instance().beginEsp32Ota(total_size); - } + // ★ 通过协调器发起(内部已含 forceCleanup 重试) + esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size); if (err != ESP_OK) { if (err == ESP_ERR_INVALID_STATE) { httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); @@ -690,13 +678,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) Esp32OtaCore &core = Esp32OtaCore::instance(); - // 堆分配缓冲区,避免栈溢出(HTTP server 栈仅 8KB) - uint8_t *fw_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); - if (!fw_buf) { - core.abort(); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败"); - return ESP_FAIL; - } + // ★ 使用文件级 static 缓冲区,避免堆碎片化(HTTP server 栈仅 8KB) + uint8_t *fw_buf = s_ota_recv_buf; size_t total_firmware_written = 0; size_t total_received = json_header_len; // 已消费的字节数 @@ -709,7 +692,6 @@ static 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); - free(fw_buf); core.abort(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); return ESP_FAIL; @@ -718,7 +700,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) err = core.write(fw_buf, (size_t)ret); if (err != ESP_OK) { DEBUG_ERR("WIFI", "OTA write failed: %s", esp_err_to_name(err)); - free(fw_buf); core.abort(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); return ESP_FAIL; @@ -737,7 +718,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) } } - free(fw_buf); DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节 (跳过 JSON 头 %d 字节)", (unsigned int)core.getReceived(), json_header_len); const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", \"target\": \"esp32\"}"; httpd_resp_set_type(req, "application/json"); @@ -805,11 +785,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) } OtaRelay::metaClear(); - uint8_t *buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); - if (!buf) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败"); - return ESP_FAIL; - } + uint8_t *buf = s_ota_recv_buf; int received = 0; @@ -837,7 +813,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) if (err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s", first_chunk_len, esp_err_to_name(err)); - free(buf); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); return ESP_FAIL; } @@ -857,7 +832,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) if (ret <= 0) { DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d", received, req->content_len); - free(buf); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted"); return ESP_FAIL; } @@ -866,7 +840,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) if (err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 write failed: offset=%u len=%d err=%s", (unsigned)gd32_offset, ret, esp_err_to_name(err)); - free(buf); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); return ESP_FAIL; } @@ -875,8 +848,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) gd32_offset += (uint32_t)ret; } - free(buf); - // update meta with written size OtaRelay::metaUpdate(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); @@ -891,18 +862,17 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) WifiOta::instance().stateLock(); WifiOta::instance().setGd32StorageDone(true); WifiOta::instance().stateUnlock(); - DEBUG_INFO("WIFI", "GD32 firmware fully stored, computing SHA-256..."); - // 计算并存储 SHA-256 校验和 + DEBUG_INFO("WIFI", "GD32 firmware fully stored, pre-computing SHA-256..."); + // 预计算 SHA-256(写入 NVS),由前端在 /ota/relay 时再校验通过后触发中继 esp_err_t sha_err = OtaRelay::metaFinalizeChecksum(); if (sha_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); } - DEBUG_INFO("WIFI", "auto-triggering relay"); - // ★ 通过协调器触发中继 - OtaCoordinator::instance().triggerGd32Relay(); + // ★ 不再自动 triggerGd32Relay:安全闸门已统一在 /ota/relay 处理(带 SHA 校验) } - const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\"}"; + 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)); return ESP_OK; @@ -910,8 +880,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) /** * @brief HTTP POST /ota/relay 处理器 - * @param req - * @return + * 必传 body: {"sha256": "<64-char hex>"}。 + * 校验通过后触发 GD32 中继;响应回传设备端 SHA(对齐 BLE 的 sendSha256Notify)。 */ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) { @@ -919,6 +889,7 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) DEBUG_INFO("WIFI", "=== HTTP: POST /ota/relay ==="); DEBUG_INFO("WIFI", "收到触发 OTA 中继请求"); + // 1) 固件就绪检查 ota_meta_t meta; memset(&meta, 0, sizeof(meta)); if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) { @@ -926,14 +897,72 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) return ESP_FAIL; } - // ★ 通过全局协调器触发 GD32 中继 - esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); - if (trigger_err != ESP_OK) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay trigger failed - system busy or firmware not ready"); + // 2) 解析前端 SHA-256(必须 64 字符 hex) + char frontend_sha_hex[65] = {0}; + if (req->content_len > 0 && req->content_len < 512) { + char buf[512] = {0}; + int ret = httpd_req_recv(req, buf, req->content_len); + if (ret > 0) { + buf[ret] = '\0'; + cJSON *root = cJSON_Parse(buf); + if (root) { + cJSON *sha_item = cJSON_GetObjectItem(root, "sha256"); + if (sha_item && cJSON_IsString(sha_item) && + strlen(cJSON_GetStringValue(sha_item)) == 64) { + strncpy(frontend_sha_hex, cJSON_GetStringValue(sha_item), 64); + frontend_sha_hex[64] = '\0'; + } + cJSON_Delete(root); + } + } + } + if (frontend_sha_hex[0] == '\0') { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + "missing sha256 in body (frontend SHA-256 required)"); return ESP_FAIL; } - const char resp[] = "{\"status\": \"ok\", \"message\": \"OTA relay triggered\"}"; + // 3) hex(64) → 32 字节 + uint8_t frontend_sha[32] = {0}; + for (int i = 0; i < 32; ++i) { + char hex[3] = { frontend_sha_hex[i*2], frontend_sha_hex[i*2+1], 0 }; + frontend_sha[i] = (uint8_t)strtoul(hex, nullptr, 16); + } + + // 4) 共享 helper:计算设备端 SHA → 比对 → 回传 device_sha(无论是否匹配都回传) + uint8_t device_sha[32] = {0}; + char device_sha_hex[65] = {0}; + esp_err_t verify_err = OtaRelay::verifyFrontendSha(frontend_sha, device_sha); + for (int i = 0; i < 32; ++i) { + snprintf(&device_sha_hex[i*2], 3, "%02X", device_sha[i]); + } + + if (verify_err == ESP_ERR_INVALID_CRC) { + char err_resp[256]; + snprintf(err_resp, sizeof(err_resp), + "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", " + "\"device\": \"%s\"}", device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, err_resp, strlen(err_resp)); + return ESP_FAIL; + } else if (verify_err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SHA compute failed"); + return ESP_FAIL; + } + + // 5) 校验通过 → 触发中继 + esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); + if (trigger_err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + "relay trigger failed - system busy or firmware not ready"); + return ESP_FAIL; + } + + // 6) 成功响应回传设备端 SHA(对齐 BLE 的 sendSha256Notify) + char resp[256]; + snprintf(resp, sizeof(resp), + "{\"status\": \"ok\", \"message\": \"OTA relay triggered\", " + "\"sha256\": \"%s\"}", device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); diff --git a/tools/app.js b/tools/app.js index 0630f85..5ca3d10 100644 --- a/tools/app.js +++ b/tools/app.js @@ -10,6 +10,7 @@ let logEntries = []; let lastLogHash = null; let autoScroll = true; let logSocket = null; +let pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false let latestSystemStatus = null; let isEsp32Rebooting = false; let rebootStartTime = 0; @@ -298,10 +299,6 @@ function resetStageFlow() { function applyDisconnectedRuntimeState() { document.getElementById('pillIP').textContent = '--'; document.getElementById('pillRSSI').textContent = '--'; - document.getElementById('pillRelayTxt').textContent = '离线'; - document.getElementById('pillRelay').className = 'pill offline'; - var bp = document.getElementById('bOTAProgress'); - if (bp) { bp.textContent = '--'; bp.className = 'v err'; } resetProgress(); resetStageFlow(); } @@ -318,8 +315,6 @@ function applyRebootingRuntimeState() { var bc = document.getElementById('bConn'); if (bc) bc.textContent = '重启中'; if (bc) bc.className = 'v warn'; - document.getElementById('pillRelayTxt').textContent = '重启中'; - document.getElementById('pillRelay').className = 'pill processing'; } function applyUploadingRuntimeState() { var pc = document.getElementById('pillConn'); @@ -493,9 +488,6 @@ function updateSystemStatusUi(data) { } // relay state - skip GD32 relay UI when ESP32 OTA is active if (currentOtaTarget === 'esp32' && esp32OtaPhase > 0) { - document.getElementById('pillRelayTxt').textContent = esp32OtaPhase >= 6 ? 'ESP32 OTA 完成' : 'ESP32 OTA'; - var pr = document.getElementById('pillRelay'); - if (pr) pr.className = esp32OtaPhase >= 6 ? 'pill online' : 'pill processing'; return; } var rs = ota.relay_state; @@ -505,15 +497,6 @@ function updateSystemStatusUi(data) { var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3; var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4; var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255; - var relayText = isGd32Complete ? 'GD32 OTA 完成' - : isGd32Storing ? 'GD32 OTA 上传中' - : (relayStateDetailMap[rs] || '待机'); - document.getElementById('pillRelayTxt').textContent = relayText; - var pr = document.getElementById('pillRelay'); - if (isGd32Error) pr.className = 'pill offline'; - else if (isGd32Complete || rs === 5) pr.className = 'pill online'; - else if (isGd32Storing || isGd32Relaying || (rs > 0 && rs < 5)) pr.className = 'pill processing'; - else pr.className = 'pill idle'; // OTA cache progress (GD32 upload to ESP32 flash) var total = ota.total_size || 0; var stored = ota.stored_size || 0; @@ -994,9 +977,50 @@ function clearFile(chip) { addLog('I', '文件', chip.toUpperCase() + ' 文件已清除'); } // ==================== 上传固件 ==================== +// ★ 跨通道互斥检查:WiFi 与 BLE 共用同一个 ESP32 OTA 状态机与全局互斥, +// 但前端只用一个 `isUploading` bool 锁同一通道。这里在每次 startUpload +// 入口主动查询 /system/status,若发现另一通道已经进入 OTA 活跃态 +// (ESP32 三个状态或 GD32 存储/中继),直接拒绝并提示用户。 async function startUpload(chip) { var file = chip === 'esp32' ? esp32File : gd32File; if (!file || isUploading) return; + // ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。 + // 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间 + // 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send, + // 设备端互斥被绕过、403 路径走漏。 + isUploading = true; + // 跨通道互斥前置检查 + try { + var probe = await fetch(getBaseUrl() + '/system/status', { + signal: AbortSignal.timeout(2000) + }); + if (probe.ok) { + var st = await probe.json(); + var ota = st.ota || {}; + var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; + // ESP32 三种活跃态(5=RECEIVING, 6=FINALIZING, 7=REBOOTING) + var esp32Busy = (ssc === 5 || ssc === 6 || ssc === 7); + // GD32 存储/中继(1=STORING, 3=RELAYING)→ 阻止新上传,避免 NVS 与 Flash 互踩 + var gd32Busy = (ssc === 1 || ssc === 3); + // 同通道(chip=esp32)已在进行中:本通道会重入;不做阻拦 + // 另一通道活跃:拒绝 + if (chip === 'esp32' && (esp32Busy || gd32Busy)) { + addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 ESP32 上传'); + showToast('设备正在 OTA 中,请等待结束后再试', 'error'); + isUploading = false; + return; + } + if (chip === 'gd32' && (esp32Busy || gd32Busy)) { + addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 GD32 上传'); + showToast('设备正在 OTA 中,请等待结束后再试', 'error'); + isUploading = false; + return; + } + } + } catch (e) { + // 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。 + addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)'); + } // 每次上传前尝试重新从本地读取文件 var handle = chip === 'esp32' ? esp32FileHandle : gd32FileHandle; if (handle) { @@ -1029,10 +1053,10 @@ async function startUpload(chip) { addLog('E', '文件', '本地文件已变更或失效,请重新选择文件'); showToast('文件已变更,请重新选择', 'error'); clearFile(chip); + isUploading = false; return; } } - isUploading = true; var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn'); var origText = uploadBtn.textContent; uploadBtn.disabled = true; @@ -1053,7 +1077,7 @@ async function startUpload(chip) { showToast('开始上传固件到 ' + chip.toUpperCase(), 'success'); try { var arrayBuffer = await file.arrayBuffer(); - // 刷写时重新计算 SHA-256 + // 刷写时重新计算 SHA-256(ESP32 和 GD32 都算) if (chip === 'esp32') { try { var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); @@ -1066,6 +1090,17 @@ async function startUpload(chip) { } catch (e) { addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message); } + } else if (chip === 'gd32') { + try { + var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + var hashArray = Array.from(new Uint8Array(hashBuffer)); + var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + window._gd32FileSha256 = hashHex.toUpperCase(); + addLog('I', '文件', 'GD32 SHA-256: ' + window._gd32FileSha256); + } catch (e) { + addLog('W', '文件', 'GD32 SHA-256 重新计算失败: ' + e.message); + window._gd32FileSha256 = null; + } } var fileSize = file.size; var body, url = getBaseUrl() + '/ota/upload'; @@ -1126,7 +1161,8 @@ async function startUpload(chip) { } else { switchStageFlow('gd32'); setActivePhase('gd32Ota'); - setProgress(45, '上传完成,已自动触发 GD32 OTA...'); + setProgress(45, '上传完成,校验并触发 GD32 OTA...'); + triggerGd32Relay(); } } else if (xhr.status === 403) { addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试'); @@ -1305,29 +1341,55 @@ async function restartEsp32() { showToast('重启失败', 'error'); } } -// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ==================== -async function triggerRelay() { - addLog('I', 'OTA', '手动触发 GD32 OTA...'); +// ==================== GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)==================== +async function triggerGd32Relay() { + var sha256 = window._gd32FileSha256; + if (!sha256) { + addLog('E', 'OTA', '缺少 GD32 文件 SHA-256,请先上传文件'); + showToast('SHA-256 缺失', 'error'); + return; + } + addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...'); showToast('正在触发 GD32 OTA', 'success'); try { var resp = await fetch(getBaseUrl() + '/ota/relay', { - method: 'POST', signal: AbortSignal.timeout(5000) + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sha256: sha256 }), + signal: AbortSignal.timeout(30000) // 中继可能耗时较长 }); + var text = await resp.text(); if (resp.ok) { + try { + var data = JSON.parse(text); + if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256); + } catch (_) {} addLog('I', 'OTA', 'GD32 OTA 已触发,等待设备响应...'); - // 不再使用假的 setTimeout 推进阶段。 - // pollStatus 的 updateSystemStatusUi 会从 ota.relay_state - // 自动更新 stage flow 和 header 状态。 - showToast('GD32 OTA 已触发,等待设备响应', 'success'); + showToast('GD32 OTA 已触发', 'success'); } else { - addLog('E', 'OTA', '触发失败: HTTP ' + resp.status); - showToast('触发失败', 'error'); + // 尝试解析错误响应中的 device SHA + try { + var err = JSON.parse(text); + if (err.device) { + addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device + ' 前端=' + sha256); + showToast('SHA-256 不匹配', 'error'); + return; + } + } catch (_) {} + addLog('E', 'OTA', 'GD32 触发失败: HTTP ' + resp.status + ' ' + text); + showToast('GD32 触发失败', 'error'); } } catch (e) { - addLog('E', 'OTA', '触发失败: ' + e.message); - showToast('触发失败', 'error'); + addLog('E', 'OTA', 'GD32 触发失败: ' + e.message); + showToast('GD32 触发失败', 'error'); } } + +// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ==================== +async function triggerRelay() { + // 转发到统一入口:自动使用最近一次上传的 GD32 文件 SHA + await triggerGd32Relay(); +} // ==================== 轮询状态 ==================== var consecutivePollFailures = 0; var statusTimerId = null; @@ -1426,9 +1488,11 @@ async function pollStatus() { updateSystemStatusUi(data); } } catch (e) { - consecutivePollFailures++; + // 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次, + // 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。 + if (pageVisible) consecutivePollFailures++; // 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的) - if (consecutivePollFailures === 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) { + if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) { addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); var diag = await diagnoseNetwork(); addLog('W', '诊断', diag); @@ -1460,6 +1524,19 @@ async function pollStatus() { } } startPolling(); +// ==================== Page Visibility 补偿 ==================== +// Chrome/Edge 在标签隐藏/窗口最小化时会把 setInterval 节流到最低 1 分钟/次。 +// 切回前台时立即拉一次状态 + 日志,并清零后台期间累积的失败计数, +// 避免在"刚切回前台那一轮"被误判为 5-fail 离线。 +document.addEventListener('visibilitychange', function () { + pageVisible = !document.hidden; + if (!pageVisible) return; + consecutivePollFailures = 0; + pollStatus(); + pollLogs(); + // 之前若在慢速模式(10s),切回前台先用 1s 观察一会儿 + if (pollSlowMode) restorePolling(); +}); // ==================== ESP32 实时日志拉取 (增量) ==================== var lastLogTimestamp = 0; async function pollLogs() { diff --git a/tools/ble_ota.js b/tools/ble_ota.js index 5d053aa..b10daa2 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -81,6 +81,8 @@ reconnecting: false, // 自动重连中 reconnectAttempts: 0, // 重连尝试次数 uploadTargetKey: null, + activeTotalSize: 0, + activePacketTotal: 0, }; const $ = (id) => document.getElementById(id); @@ -314,6 +316,25 @@ } } + // ★ 以设备端 ACK 字节数为唯一进度数据源 + // 仅当处于上传阶段(state.uploading)且未中止时才刷 UI,避免陈旧 ACK / abort 后覆盖 + function updateProgressFromAck(totalSize, packetTotal, ackBytes) { + if (!state.uploading) return; + if (state.abortRequested) return; + if (!Number.isFinite(totalSize) || totalSize <= 0) return; + const safeBytes = Math.max(0, Math.min(totalSize, ackBytes >>> 0)); + const percent = (safeBytes / totalSize) * 100; + const payloadSize = Math.max(1, Number(els.payloadSize.value) || 480); + const ackedPackets = Math.min(packetTotal, Math.ceil(safeBytes / payloadSize)); + const cfg = currentTarget(); + setProgress(percent, `[${cfg.label}] 已确认 ${ackedPackets}/${packetTotal} 包`); + els.sentCount.textContent = String(ackedPackets); + // 阶段 4 detail 文本:只在写入阶段刷新,避免与 stage 5/6 冲突 + var detailEl = document.getElementById('bsd4'); + if (detailEl) detailEl.textContent = `${ackedPackets}/${packetTotal} 包`; + setBleStageText(`数据写入 ${Math.round(percent)}%`); + } + function setReadyHint(html) { const el = $('readyHint'); if (el) el.innerHTML = html; @@ -441,6 +462,12 @@ setConnectionState(true); bleLog('BLE', 'GATT 服务与特征获取完成'); await enableNotifications(); + // ★ enableNotifications 内部已包含重试 + 主动断开逻辑; + // 若返回时仍为 false,说明连接半死,UI 已被断开事件重置,不要继续推进到 Stage 3 + if (!state.notificationsEnabled) { + bleLog('BLE', '连接就绪但 DATA_OUT 通知开启失败,等待用户重连', 'E'); + return; + } // Mark stages 1-2 as done setBleStage(3, '', null); setBleStageText('BLE 连接就绪,请选择固件文件'); @@ -517,10 +544,33 @@ const ackCmd = bytes[1]; const ackSeq = bytes[2] | (bytes[3] << 8); const ackBytes = (bytes[4]) | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24); + // ★ 保护:陈旧 ACK 来自跨 run / 旧窗口时直接丢弃。 + // 仅以「本 run 上界」过滤(ackSeq 不能超过文件总包数 + 一个窗口余量), + // 不再用 ackMinSeenSeq 作下界 —— 因为本 run 第一个窗口的 seq 本身就是 0..31, + // 下界过严会把正常 ACK 也误判为越界(导致 ackBytes 永远 0)。 + if (state.uploading && state.activeTotalSize > 0) { + const payloadSize = Math.max(1, Number(els.payloadSize.value) || 480); + const maxExpectedSeq = Math.ceil(state.activeTotalSize / payloadSize) - 1; + if (ackSeq > maxExpectedSeq + BLE_OTA_ACK_WINDOW) { + bleLog('ACK', `忽略越界 ACK: seq=${ackSeq} (run 范围 0..${maxExpectedSeq})`, 'W'); + return; + } + } else if (!state.uploading) { + // 非上传阶段:清空状态,避免污染下次 run + state.lastAckCmd = null; + state.lastAckSeq = -1; + state.lastAckBytes = 0; + return; + } state.lastAckCmd = ackCmd; state.lastAckSeq = ackSeq; state.lastAckBytes = ackBytes >>> 0; + // ★ 以设备端真实 ACK 字节数驱动 UI 进度 + if (state.uploading && state.activeTotalSize > 0 && state.activePacketTotal > 0) { + updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes); + } + const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`; bleLog('ACK', ackText); @@ -577,7 +627,7 @@ if (typeof setActivePhase === 'function') setActivePhase('gd32Ota'); if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(1); setProgress(50, '[GD32] OTA 转发已开始,正在认证握手...'); - showBleVerifyInfo(state.fileSha256 || '-', '等待 GD32 APP 启动确认...', { text: '缓存写入完成,正在通过 SPI 转发' }); + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '等待设备端上报...', { text: '缓存写入完成,正在通过 SPI 转发' }); return; } @@ -587,7 +637,7 @@ if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(5); if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota'); setProgress(100, '[GD32] OTA 转发完成,APP 已启动'); - showBleVerifyInfo(state.fileSha256 || '-', 'GD32 APP 已确认启动', { text: 'GD32 OTA 完成', ok: true }); + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true }); var gd32Bar = document.getElementById('stageStatusBar'); var gd32Text = document.getElementById('stageStatusText'); if (gd32Bar) gd32Bar.className = 'stage-status-bar ok'; @@ -623,9 +673,14 @@ if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return; state.expectedReboot = true; setBleStage(finalizeStage, '设备重启', null); - setBleStageText('ESP32 即将重启'); - var bar = document.getElementById('stageStatusBar'); - if (bar) bar.className = 'stage-status-bar ok'; + // ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝 + var rebootBar = document.getElementById('stageStatusBar'); + var rebootBarTxt = document.getElementById('stageStatusText'); + if (rebootBar) rebootBar.className = 'stage-status-bar ok'; + if (rebootBarTxt) rebootBarTxt.textContent = 'ESP32 刷写完成,等待重启'; + if (typeof markPhaseDone === 'function') { + markPhaseDone('esp32Ota'); + } } // OTA 结果 toast 提示(用户可见,不只是在日志里) @@ -718,7 +773,14 @@ bleLog('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I'); showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true }); setBleStage(verifyStage, 'SHA-256 ✓', null); - setBleStageText('校验通过 — 固件完整性已确认'); + // ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进 + var okBar = document.getElementById('stageStatusBar'); + var okTxt = document.getElementById('stageStatusText'); + if (okBar) okBar.className = 'stage-status-bar ok'; + if (okTxt) okTxt.textContent = '刷写成功 — SHA-256 校验通过'; + if (typeof markPhaseDone === 'function') { + markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota'); + } } else if (frontSha) { bleLog('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E'); showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true }); @@ -853,18 +915,36 @@ } async function enableNotifications() { - try { - await state.dataOutChar.startNotifications(); - state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify); - state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify); - state.notificationsEnabled = true; - setConnectionState(!!state.server); - bleLog('BLE', 'DATA_OUT 通知已自动开启'); - } catch (err) { - state.notificationsEnabled = false; - setConnectionState(!!state.server); - bleLog('BLE', `自动开启 DATA_OUT 通知失败: ${err.message}`, 'E'); + // 修复偶发的 GATT CCC 竞态:startNotifications 与 NimBLE gap 完成握手存在窗口冲突。 + // 现象:Chrome 报 "GATT operation failed for unknown reason",NimBLE 实际返回 EBUSY/ENOTCONN。 + // 处理:内部退避重试 + 最终失败时主动断开,让上层进入干净的重连路径,而不是带着半死连接去刷写。 + const MAX_RETRY = 3; + for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { + try { + await state.dataOutChar.startNotifications(); + state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify); + state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify); + state.notificationsEnabled = true; + setConnectionState(!!state.server); + bleLog('BLE', 'DATA_OUT 通知已自动开启' + (attempt > 1 ? ` (重试 ${attempt}/${MAX_RETRY} 成功)` : '')); + return; + } catch (err) { + bleLog('BLE', `自动开启 DATA_OUT 通知失败(第 ${attempt}/${MAX_RETRY} 次): ${err.message}`, 'E'); + if (attempt < MAX_RETRY) { + // 200ms / 400ms 退避,给 NimBLE 主机任务时间把 connect/MTU 事件消化完 + await new Promise(r => setTimeout(r, 200 * attempt)); + } + } } + state.notificationsEnabled = false; + setConnectionState(!!state.server); + // ★ 最终失败:连接处于半死状态,主动断开让用户重连,避免带着残废连接走到刷写 + try { + if (state.device && state.device.gatt && state.device.gatt.connected) { + state.device.gatt.disconnect(); + } + } catch (_) {} + bleLog('BLE', 'DATA_OUT 通知开启彻底失败,已主动断开,请重连后重试', 'E'); } function updateFileInfo(file) { @@ -1064,6 +1144,9 @@ state.lastUploadStatus = 'uploading'; const otaSessionId = nextOtaSessionId(); clearOtaResultWaiter(); + if (typeof setActivePhase === 'function') { + setActivePhase(cfg.label === 'GD32' ? 'gd32Ota' : 'esp32Ota'); + } switchStageFlow('ble'); state.lastAckCmd = null; state.lastAckSeq = -1; @@ -1071,6 +1154,8 @@ state.ackGeneration = 0; state.deviceSha256 = null; state.lastEsp32DoneSession = null; + state.activeTotalSize = 0; + state.activePacketTotal = 0; if (state.ackWaiter) { clearTimeout(state.ackWaiter.timer); state.ackWaiter = null; @@ -1120,6 +1205,11 @@ setBleStage(4, '写入中...', null); setBleStageText(`正在写入 ${packetTotal} 包数据...`); + // ★ 写入阶段已读出文件大小/包数,记录到 state 供 handleNotify 在 ACK 到达时使用 + state.activeTotalSize = totalSize; + state.activePacketTotal = packetTotal; + els.sentCount.textContent = '0'; + setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`); bleLog('OTA', `开始写入 DATA_IN: target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`); const MAX_WINDOW_RETRIES = 3; @@ -1131,6 +1221,7 @@ const endSeq = Math.min(windowStart + BLE_OTA_ACK_WINDOW, packetTotal) - 1; // 流水线发送:不逐包 await,减少 BLE 栈 per-packet 开销 + // ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动 for (let seq = windowStart; seq <= endSeq; seq++) { const start = seq * payloadSize; const end = Math.min(start + payloadSize, totalSize); @@ -1162,12 +1253,6 @@ state.dataInChar.writeValueWithoutResponse(packet); // fire-and-forget } bleWriteTs = performance.now(); - - const percent = ((seq + 1) / packetTotal) * 100; - els.sentCount.textContent = String(seq + 1); - setProgress(percent, `[${cfg.label}] 已写 ${seq + 1}/${packetTotal} 包`); - setBleStage(4, `${seq + 1}/${packetTotal} 包`, null); - setBleStageText(`数据写入 ${Math.round(percent)}%`); } state.ackGeneration++; @@ -1246,8 +1331,16 @@ } // Stage 4 done + // ★ 不再用本地 seq 推算固定百分比:进度以设备端 ACK 字节数为准 + const confirmedPct = state.activeTotalSize > 0 + ? Math.min(100, (state.lastAckBytes >>> 0) / state.activeTotalSize * 100) + : 100; + // 阶段 4 完成后,base 进度:GD32 留 55% 给中继/校验,ESP32 留 10% 给收尾 + const phaseBase = cfg.label === 'GD32' ? 45 : 90; + // ACK 推进到的进度 = 阶段 4 在整体里占的份额 (phaseBase) × 设备真实确认比例 + const stage4Share = phaseBase * (confirmedPct / 100); setBleStage(4, '完成', null); - setProgress(cfg.label === 'GD32' ? 45 : 90, `[${cfg.label}] 固件数据已全部写入 DATA_IN`); + setProgress(Math.min(phaseBase, stage4Share), `[${cfg.label}] 固件数据已全部写入 DATA_IN`); // Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对 if (cfg.label === 'ESP32') { diff --git a/tools/index.html b/tools/index.html index fd30ae8..c953d6f 100644 --- a/tools/index.html +++ b/tools/index.html @@ -20,7 +20,6 @@ v-- -- -- - 待机 运行 --