Compare commits
3
Commits
2586582e57
...
59c729d77a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c729d77a | ||
|
|
7feef70750 | ||
|
|
41fee936df |
@@ -5,15 +5,28 @@
|
|||||||
# SOURCE_DIR - 项目根目录 (ESP32_COMM)
|
# SOURCE_DIR - 项目根目录 (ESP32_COMM)
|
||||||
# OUTPUT_FILE - 输出的 version.h 路径
|
# OUTPUT_FILE - 输出的 version.h 路径
|
||||||
|
|
||||||
# 版本号(可手动修改)
|
# 主/次版本号(手动修改,大版本时改)
|
||||||
set(FW_VERSION_MAJOR 0)
|
set(FW_VERSION_MAJOR 0)
|
||||||
set(FW_VERSION_MINOR 3)
|
set(FW_VERSION_MINOR 3)
|
||||||
set(FW_VERSION_PATCH 12)
|
|
||||||
|
|
||||||
# 获取编译时间
|
# PATCH 自动递增:git 总提交次数,每次新提交 +1
|
||||||
|
find_package(Git QUIET)
|
||||||
|
if(GIT_FOUND)
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${GIT_EXECUTABLE} rev-list --count HEAD
|
||||||
|
WORKING_DIRECTORY ${SOURCE_DIR}
|
||||||
|
OUTPUT_VARIABLE FW_VERSION_PATCH
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
ERROR_QUIET
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
set(FW_VERSION_PATCH 0)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 编译时间
|
||||||
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")
|
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
# 获取 Git 信息
|
# Git 短 hash
|
||||||
find_package(Git QUIET)
|
find_package(Git QUIET)
|
||||||
if(GIT_FOUND)
|
if(GIT_FOUND)
|
||||||
execute_process(
|
execute_process(
|
||||||
@@ -27,7 +40,7 @@ else()
|
|||||||
set(GIT_HASH "unknown")
|
set(GIT_HASH "unknown")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# 生成版本头文件内容
|
# 生成版本头文件
|
||||||
set(VERSION_HEADER_CONTENT
|
set(VERSION_HEADER_CONTENT
|
||||||
"// Auto-generated version info - DO NOT EDIT
|
"// Auto-generated version info - DO NOT EDIT
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
// 固件版本
|
// 固件版本
|
||||||
#define FW_VERSION_MAJOR 0
|
#define FW_VERSION_MAJOR 0
|
||||||
#define FW_VERSION_MINOR 3
|
#define FW_VERSION_MINOR 3
|
||||||
#define FW_VERSION_PATCH 12
|
#define FW_VERSION_PATCH 28
|
||||||
#define FW_VERSION_STRING "0.3.12"
|
#define FW_VERSION_STRING "0.3.28"
|
||||||
|
|
||||||
// 编译时间
|
// 编译时间
|
||||||
#define BUILD_TIME "2026-06-17 03:09:19"
|
#define BUILD_TIME "2026-06-19 03:24:28"
|
||||||
|
|
||||||
// Git 信息
|
// Git 信息
|
||||||
#define GIT_HASH "98b5cc1"
|
#define GIT_HASH "7feef70"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include "driver/temperature_sensor.h"
|
#include "driver/temperature_sensor.h"
|
||||||
|
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
#include "health_monitor.h"
|
#include "health_monitor.h"
|
||||||
#include "debug_log.h"
|
#include "debug_log.h"
|
||||||
#include "server/wifi_ota.h"
|
#include "server/wifi_ota.h"
|
||||||
@@ -197,7 +198,9 @@ void HealthMonitor::taskEntry(void *pvParameters)
|
|||||||
while (1) {
|
while (1) {
|
||||||
uint32_t free_heap = esp_get_free_heap_size();
|
uint32_t free_heap = esp_get_free_heap_size();
|
||||||
if (free_heap < HEAP_WARNING_MIN) {
|
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();
|
health_state_t cur = self.getState();
|
||||||
if (cur >= SYS_CRITICAL) {
|
if (cur >= SYS_CRITICAL) {
|
||||||
|
|||||||
@@ -171,30 +171,24 @@ esp_err_t Esp32OtaCore::computeShaFromPartition()
|
|||||||
mbedtls_sha256_init(&ctx);
|
mbedtls_sha256_init(&ctx);
|
||||||
mbedtls_sha256_starts(&ctx, 0);
|
mbedtls_sha256_starts(&ctx, 0);
|
||||||
|
|
||||||
uint8_t *read_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
|
// ★ static 缓冲区:OTA 操作由 OtaCoordinator 互斥串行化,无并发风险
|
||||||
if (!read_buf) {
|
static uint8_t s_sha_read_buf[OTA_BLOCK_SIZE];
|
||||||
DEBUG_ERR(TAG, "SHA 计算: malloc 失败");
|
|
||||||
mbedtls_sha256_free(&ctx);
|
|
||||||
return ESP_ERR_NO_MEM;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
while (offset < m_received) {
|
while (offset < m_received) {
|
||||||
size_t to_read = m_received - offset;
|
size_t to_read = m_received - offset;
|
||||||
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
|
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) {
|
if (rd_err != ESP_OK) {
|
||||||
DEBUG_ERR(TAG, "SHA 计算: 分区读失败 @offset %u: %s",
|
DEBUG_ERR(TAG, "SHA 计算: 分区读失败 @offset %u: %s",
|
||||||
(unsigned)offset, esp_err_to_name(rd_err));
|
(unsigned)offset, esp_err_to_name(rd_err));
|
||||||
free(read_buf);
|
|
||||||
mbedtls_sha256_free(&ctx);
|
mbedtls_sha256_free(&ctx);
|
||||||
return rd_err;
|
return rd_err;
|
||||||
}
|
}
|
||||||
mbedtls_sha256_update(&ctx, read_buf, to_read);
|
mbedtls_sha256_update(&ctx, s_sha_read_buf, to_read);
|
||||||
offset += to_read;
|
offset += to_read;
|
||||||
}
|
}
|
||||||
|
|
||||||
free(read_buf);
|
|
||||||
mbedtls_sha256_finish(&ctx, m_sha256);
|
mbedtls_sha256_finish(&ctx, m_sha256);
|
||||||
mbedtls_sha256_free(&ctx);
|
mbedtls_sha256_free(&ctx);
|
||||||
m_shaComputed = true;
|
m_shaComputed = true;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
#include "esp32_ota_core.h"
|
#include "esp32_ota_core.h"
|
||||||
#include "relay/ota_relay.h"
|
#include "relay/ota_relay.h"
|
||||||
#include "monitor/debug_log.h"
|
#include "monitor/debug_log.h"
|
||||||
|
#include "esp_system.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
#define TAG "OTA_COORD"
|
#define TAG "OTA_COORD"
|
||||||
|
|
||||||
@@ -283,11 +286,18 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size)
|
|||||||
// ★ 卡死自愈检查
|
// ★ 卡死自愈检查
|
||||||
if (h) watchdogCheckLocked();
|
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 ||
|
if (h && (m_sysState == OTA_SYS_GD32_STORING ||
|
||||||
m_sysState == OTA_SYS_GD32_RELAYING)) {
|
m_sysState == OTA_SYS_GD32_RELAYING ||
|
||||||
DEBUG_WARN(TAG, "GD32 正在 %s,拒绝 ESP32 OTA",
|
m_sysState == OTA_SYS_ESP32_RECEIVING ||
|
||||||
m_sysState == OTA_SYS_GD32_STORING ? "存储" : "中继");
|
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);
|
unlock(h);
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
}
|
}
|
||||||
@@ -309,6 +319,29 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size)
|
|||||||
return err;
|
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()
|
void OtaCoordinator::notifyEsp32ReceiveComplete()
|
||||||
{
|
{
|
||||||
bool h = lock();
|
bool h = lock();
|
||||||
@@ -339,6 +372,40 @@ void OtaCoordinator::notifyEsp32Rebooting()
|
|||||||
unlock(h);
|
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; // 其他失败
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 中止
|
// 中止
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ typedef enum {
|
|||||||
OTA_SYS_ERROR // 错误状态
|
OTA_SYS_ERROR // 错误状态
|
||||||
} ota_system_state_t;
|
} 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 协调器(单例)
|
* @brief 全局 OTA 协调器(单例)
|
||||||
*
|
*
|
||||||
@@ -101,6 +110,14 @@ public:
|
|||||||
*/
|
*/
|
||||||
esp_err_t beginEsp32Ota(uint32_t total_size);
|
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 固件接收完毕,准备收尾。
|
* @brief ESP32 OTA 固件接收完毕,准备收尾。
|
||||||
* 更新系统状态为 OTA_SYS_ESP32_FINALIZING。
|
* 更新系统状态为 OTA_SYS_ESP32_FINALIZING。
|
||||||
@@ -117,6 +134,23 @@ public:
|
|||||||
*/
|
*/
|
||||||
void notifyEsp32Rebooting();
|
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)。
|
* @brief 中止当前 OTA 操作(GD32 或 ESP32)。
|
||||||
@@ -167,6 +201,14 @@ public:
|
|||||||
*/
|
*/
|
||||||
void reset();
|
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:
|
private:
|
||||||
OtaCoordinator() = default;
|
OtaCoordinator() = default;
|
||||||
~OtaCoordinator() = default;
|
~OtaCoordinator() = default;
|
||||||
|
|||||||
+83
-33
@@ -835,6 +835,28 @@ esp_err_t OtaRelay::metaVerifyChecksum()
|
|||||||
return ESP_OK;
|
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)
|
esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len)
|
||||||
{
|
{
|
||||||
if (!instance().m_gd32Partition) {
|
if (!instance().m_gd32Partition) {
|
||||||
@@ -846,40 +868,29 @@ esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offset % 4 != 0) {
|
|
||||||
DEBUG_ERR("OTA", "flashWriteChunk offset非4对齐: offset=%" PRIu32, offset);
|
|
||||||
return ESP_ERR_INVALID_ARG;
|
|
||||||
}
|
|
||||||
|
|
||||||
const esp_partition_t *part = instance().m_gd32Partition;
|
const esp_partition_t *part = instance().m_gd32Partition;
|
||||||
|
|
||||||
if (len % 4 == 0) {
|
// ★ 计算 head/tail padding,使写入地址和长度都对齐到 4 字节
|
||||||
if (offset + len > part->size) {
|
// WiFi HTTP chunk 边界不一定 4 对齐,flash 写入必须对齐。
|
||||||
DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu part_size=%" PRIu32,
|
size_t head_pad = offset % 4;
|
||||||
offset, len, part->size);
|
uint32_t aligned_offset = offset - head_pad;
|
||||||
return ESP_ERR_INVALID_SIZE;
|
size_t total = head_pad + len;
|
||||||
}
|
size_t aligned_len = (total + 3) & ~(size_t)3;
|
||||||
return esp_partition_write(part, offset, data, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t aligned_len = (len + 3) & ~(size_t)3;
|
if (aligned_offset + aligned_len > part->size) {
|
||||||
|
DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu aligned_off=%" PRIu32 " aligned_len=%zu part=%" PRIu32,
|
||||||
if (offset + aligned_len > part->size) {
|
offset, len, aligned_offset, aligned_len, part->size);
|
||||||
DEBUG_ERR("OTA", "flashWriteChunk对齐后越界: offset=%" PRIu32 " len=%zu aligned=%zu part=%" PRIu32,
|
|
||||||
offset, len, aligned_len, part->size);
|
|
||||||
return ESP_ERR_INVALID_SIZE;
|
return ESP_ERR_INVALID_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_WARN("OTA", "flashWriteChunk len非4对齐: len=%zu aligned_len=%zu offset=%" PRIu32,
|
// 快速路径:offset 和 len 都对齐
|
||||||
len, aligned_len, offset);
|
if (head_pad == 0 && len % 4 == 0) {
|
||||||
|
return esp_partition_write(part, offset, data, len);
|
||||||
|
}
|
||||||
|
|
||||||
size_t pad_len = aligned_len - len;
|
if (head_pad > 0 || len % 4 != 0) {
|
||||||
uint8_t pad[4] = {0};
|
DEBUG_WARN("OTA", "flashWriteChunk 非对齐: offset=%" PRIu32 " len=%zu head=%zu aligned_len=%zu",
|
||||||
esp_err_t err = esp_partition_read(part, offset + len, pad, pad_len);
|
offset, len, head_pad, aligned_len);
|
||||||
if (err != ESP_OK) {
|
|
||||||
DEBUG_ERR("OTA", "flashWriteChunk读回padding失败: offset=%" PRIu32 " pad=%zu err=%s",
|
|
||||||
offset + len, pad_len, esp_err_to_name(err));
|
|
||||||
return err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
|
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
|
||||||
@@ -887,10 +898,34 @@ esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t
|
|||||||
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
|
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
|
||||||
return ESP_ERR_NO_MEM;
|
return ESP_ERR_NO_MEM;
|
||||||
}
|
}
|
||||||
memcpy(aligned_buf, data, len);
|
|
||||||
memcpy(aligned_buf + len, pad, pad_len);
|
|
||||||
|
|
||||||
err = esp_partition_write(part, offset, aligned_buf, aligned_len);
|
// 读回 head padding(offset 之前的字节,属于上一次写入的尾部)
|
||||||
|
if (head_pad > 0) {
|
||||||
|
esp_err_t err = esp_partition_read(part, aligned_offset, aligned_buf, head_pad);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s",
|
||||||
|
aligned_offset, head_pad, esp_err_to_name(err));
|
||||||
|
free(aligned_buf);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拷贝实际数据
|
||||||
|
memcpy(aligned_buf + head_pad, data, len);
|
||||||
|
|
||||||
|
// 读回 tail padding(数据末尾到 4 字节边界的字节)
|
||||||
|
size_t tail_pad = aligned_len - total;
|
||||||
|
if (tail_pad > 0) {
|
||||||
|
esp_err_t err = esp_partition_read(part, aligned_offset + total, aligned_buf + total, tail_pad);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s",
|
||||||
|
aligned_offset + total, tail_pad, esp_err_to_name(err));
|
||||||
|
free(aligned_buf);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len);
|
||||||
free(aligned_buf);
|
free(aligned_buf);
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
@@ -1154,8 +1189,13 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick)
|
|||||||
}
|
}
|
||||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) {
|
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);
|
DEBUG_WARN("OTA", "数据包 %" PRIu32 " 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len);
|
||||||
result = OTA_RESULT_ERR_TIMEOUT;
|
if (++retry_count >= 10U) {
|
||||||
break;
|
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);
|
uint32_t expected_ack = (seq == m_transferTotalPackets - 1) ? 0xF0F0F0F0U : (seq + 1U);
|
||||||
@@ -1189,6 +1229,8 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick)
|
|||||||
result = OTA_RESULT_ERR_GD32;
|
result = OTA_RESULT_ERR_GD32;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// ★ resend 前延迟
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1));
|
||||||
goto resend_packet;
|
goto resend_packet;
|
||||||
}
|
}
|
||||||
if (isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) {
|
if (isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) {
|
||||||
@@ -1215,12 +1257,14 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_WARN("OTA", "包 %" PRIu32 " 轮询耗尽未匹配,重发 (retry=%" PRIu32 "/3)", seq, retry_count + 1);
|
DEBUG_WARN("OTA", "包 %" PRIu32 " 轮询耗尽未匹配,重发 (retry=%" PRIu32 "/10)", seq, retry_count + 1);
|
||||||
if (++retry_count >= 10U) {
|
if (++retry_count >= 10U) {
|
||||||
DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq);
|
DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq);
|
||||||
result = OTA_RESULT_ERR_GD32;
|
result = OTA_RESULT_ERR_GD32;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// ★ resend 前延迟:给 GD32 时间处理上一包并更新 SPI 响应缓冲区
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1));
|
||||||
goto resend_packet;
|
goto resend_packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1281,12 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick)
|
|||||||
uint32_t progress = (relaySeq * 100U) / m_transferTotalPackets;
|
uint32_t progress = (relaySeq * 100U) / m_transferTotalPackets;
|
||||||
DEBUG_INFO("OTA", "转发进度: %" PRIu32 "/%" PRIu32 " 包 (%" PRIu32 "%%)", relaySeq, m_transferTotalPackets, progress);
|
DEBUG_INFO("OTA", "转发进度: %" PRIu32 "/%" PRIu32 " 包 (%" PRIu32 "%%)", relaySeq, m_transferTotalPackets, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 包间延迟:给 GD32 Bootloader 充足时间完成 flash 写入并准备下一包 ACK。
|
||||||
|
// 无此延迟时,ESP32 立即 poll 会读到上一包的 stale ACK,导致误判为丢包。
|
||||||
|
if (seq < m_transferTotalPackets - 1) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,16 @@ public:
|
|||||||
* @brief 校验固件完整性(SHA-256).
|
* @brief 校验固件完整性(SHA-256).
|
||||||
*/
|
*/
|
||||||
static esp_err_t metaVerifyChecksum();
|
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;
|
static relay_state_t relayState;
|
||||||
|
|||||||
+255
-89
@@ -9,7 +9,7 @@
|
|||||||
*
|
*
|
||||||
* GATT 服务:
|
* GATT 服务:
|
||||||
* Service UUID: 0xFFFF
|
* Service UUID: 0xFFFF
|
||||||
* - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级(含 32B SHA-256 校验并 reboot),0xFA=重启ESP32
|
* - CTRL (0x0001, Write): 0xF7=触发GD32中继(含 32B SHA-256 校验), 0xF8=触发ESP32 OTA升级(含 32B SHA-256 校验并 reboot),0xFA=重启ESP32
|
||||||
* - DATA_IN (0x0002, Write): cmd=0x00=GD32数据, cmd=0x01=ESP32数据
|
* - DATA_IN (0x0002, Write): cmd=0x00=GD32数据, cmd=0x01=ESP32数据
|
||||||
* - DATA_OUT (0x0003, Notify): 推送响应
|
* - DATA_OUT (0x0003, Notify): 推送响应
|
||||||
* - VERSION (0x0004, Read): 固件版本
|
* - VERSION (0x0004, Read): 固件版本
|
||||||
@@ -53,12 +53,15 @@
|
|||||||
#define BLE_OTA_NOTIFY_SHA256 0xC1 // 设备端 SHA-256 上报([0xC1] + [32字节])
|
#define BLE_OTA_NOTIFY_SHA256 0xC1 // 设备端 SHA-256 上报([0xC1] + [32字节])
|
||||||
#define BLE_OTA_FINISH_DONE 0x02
|
#define BLE_OTA_FINISH_DONE 0x02
|
||||||
#define BLE_OTA_FINISH_REBOOT_NOW 0x07
|
#define BLE_OTA_FINISH_REBOOT_NOW 0x07
|
||||||
|
#define BLE_OTA_FINISH_GD32_RELAY_START 0x10
|
||||||
|
#define BLE_OTA_FINISH_GD32_RELAY_DONE 0x11
|
||||||
#define BLE_OTA_FINISH_ERR_FINALIZE 0x81
|
#define BLE_OTA_FINISH_ERR_FINALIZE 0x81
|
||||||
#define BLE_OTA_ACK_WINDOW 32
|
#define BLE_OTA_ACK_WINDOW 32
|
||||||
#define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用
|
#define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用
|
||||||
#define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用
|
#define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用
|
||||||
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小
|
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小
|
||||||
#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 // ESP32 OTA SHA-256 不匹配
|
#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 // OTA SHA-256 不匹配
|
||||||
|
#define BLE_OTA_FINISH_ERR_GD32_RELAY 0x86 // GD32 SPI OTA 转发失败
|
||||||
|
|
||||||
// ── 数据队列:将 OTA 数据处理从 NimBLE host task 卸载到工作线程 ──
|
// ── 数据队列:将 OTA 数据处理从 NimBLE host task 卸载到工作线程 ──
|
||||||
// 避免 flash 操作(esp_ota_begin / esp_ota_write)阻塞 BLE 协议栈导致连接断开。
|
// 避免 flash 操作(esp_ota_begin / esp_ota_write)阻塞 BLE 协议栈导致连接断开。
|
||||||
@@ -70,14 +73,27 @@ struct BleOtaDataBatch {
|
|||||||
};
|
};
|
||||||
static QueueHandle_t s_otaDataQueue = nullptr;
|
static QueueHandle_t s_otaDataQueue = nullptr;
|
||||||
|
|
||||||
|
struct BleGd32RelayWaitCtx {
|
||||||
|
uint16_t session_id;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct BleEsp32FinishCtx {
|
||||||
|
char sha_hex[65];
|
||||||
|
uint16_t session_id;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 前向声明
|
// 前向声明
|
||||||
// ============================================================
|
// ============================================================
|
||||||
static void ble_ota_start_advertising(void);
|
static void ble_ota_start_advertising(void);
|
||||||
static void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
|
static void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
|
||||||
static void sendFinishEvent(uint8_t stage, uint32_t value);
|
static void sendFinishEvent(uint8_t stage, uint32_t value);
|
||||||
|
static uint32_t packBleFinishValue(uint16_t session_id, uint16_t code);
|
||||||
|
static void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]);
|
||||||
|
static void sendSha256Notify(const uint8_t sha[32], uint16_t session_id);
|
||||||
static void esp32_reorder_free();
|
static void esp32_reorder_free();
|
||||||
static void ble_ota_data_worker_task(void *pvParameters);
|
static void ble_ota_data_worker_task(void *pvParameters);
|
||||||
|
static void ble_gd32_relay_wait_task(void *pvParameters);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
/**
|
/**
|
||||||
@@ -111,19 +127,18 @@ static void ble_esp32_restart_task(void *pvParameters)
|
|||||||
(void)pvParameters;
|
(void)pvParameters;
|
||||||
DEBUG_INFO(TAG, "[RESTART] 推送 FINISH_REBOOT_NOW 后 500ms 重启");
|
DEBUG_INFO(TAG, "[RESTART] 推送 FINISH_REBOOT_NOW 后 500ms 重启");
|
||||||
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
||||||
vTaskDelay(pdMS_TO_TICKS(500));
|
OtaCoordinator::instance().restartEsp32(); // 内部 notify + delay + esp_restart,不返回
|
||||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
|
||||||
esp_restart();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 异步完成 ESP32 OTA 收尾并触发重启。
|
* @brief 异步完成 ESP32 OTA 收尾并触发重启。
|
||||||
* @param pvParameters 前端 SHA-256 十六进制字符串(堆分配,任务负责释放)。
|
* @param pvParameters BleEsp32FinishCtx(堆分配,任务负责释放)。
|
||||||
*/
|
*/
|
||||||
static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||||
{
|
{
|
||||||
// pvParameters: heap-allocated char[65] frontend SHA hex string (or nullptr)
|
BleEsp32FinishCtx *ctx = (BleEsp32FinishCtx *)pvParameters;
|
||||||
char *frontend_sha_hex = (char *)pvParameters;
|
char *frontend_sha_hex = ctx ? ctx->sha_hex : nullptr;
|
||||||
|
uint16_t session_id = ctx ? ctx->session_id : 0;
|
||||||
DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动");
|
DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动");
|
||||||
|
|
||||||
Esp32OtaCore &core = Esp32OtaCore::instance();
|
Esp32OtaCore &core = Esp32OtaCore::instance();
|
||||||
@@ -139,7 +154,9 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
|||||||
|
|
||||||
if (!core.isActive()) {
|
if (!core.isActive()) {
|
||||||
DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启");
|
DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启");
|
||||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||||
|
packBleFinishValue(session_id, 1));
|
||||||
|
if (ctx) free(ctx);
|
||||||
BleOta::instance().m_esp32RebootPending = false;
|
BleOta::instance().m_esp32RebootPending = false;
|
||||||
esp32_reorder_free();
|
esp32_reorder_free();
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
@@ -147,98 +164,88 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
|||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived());
|
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived());
|
||||||
OtaCoordinator::instance().notifyEsp32ReceiveComplete();
|
|
||||||
|
|
||||||
// 关键:reboot=false,让 finalize 真正返回、拿到 device_sha_hex,
|
// ★ 收尾序列(notify + finalize)由协调器统一封装,reboot=false 让 device_sha 可被读取
|
||||||
// 再依次推送 SHA 通知 + FINISH_DONE + FINISH_REBOOT_NOW,
|
Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot(frontend_sha_hex);
|
||||||
// 最后延迟重启。若 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);
|
|
||||||
|
|
||||||
if (err == ESP_OK) {
|
if (fr.err == ESP_OK) {
|
||||||
OtaCoordinator::instance().notifyEsp32FinalizeComplete();
|
|
||||||
// 1) 把设备端 SHA 通过 SHA256 通知推给前端
|
// 1) 把设备端 SHA 通过 SHA256 通知推给前端
|
||||||
if (device_sha_hex[0] != '\0') {
|
if (fr.device_sha_hex[0] != '\0') {
|
||||||
uint8_t sha_pkt[33] = {0};
|
uint8_t device_sha[32] = {0};
|
||||||
sha_pkt[0] = BLE_OTA_NOTIFY_SHA256;
|
|
||||||
for (int i = 0; i < 32; ++i) {
|
for (int i = 0; i < 32; ++i) {
|
||||||
unsigned int byte = 0;
|
unsigned int byte = 0;
|
||||||
sscanf(&device_sha_hex[i * 2], "%02x", &byte);
|
sscanf(&fr.device_sha_hex[i * 2], "%02x", &byte);
|
||||||
sha_pkt[1 + i] = (uint8_t)byte;
|
device_sha[i] = (uint8_t)byte;
|
||||||
}
|
}
|
||||||
BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt));
|
sendSha256Notify(device_sha, session_id);
|
||||||
}
|
}
|
||||||
// 2) 等待 SHA 通知从 NimBLE host 队列送出
|
// 2) 等待 SHA 通知从 NimBLE host 队列送出
|
||||||
vTaskDelay(pdMS_TO_TICKS(150));
|
vTaskDelay(pdMS_TO_TICKS(150));
|
||||||
|
|
||||||
// 3) 推送"OTA 收尾完成"与"即将重启"事件
|
// 3) 推送"OTA 收尾完成"事件
|
||||||
sendFinishEvent(BLE_OTA_FINISH_DONE, 0);
|
sendFinishEvent(BLE_OTA_FINISH_DONE, packBleFinishValue(session_id, 0));
|
||||||
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, packBleFinishValue(session_id, 0));
|
||||||
|
|
||||||
// 4) 通知全部发出后延迟 500ms 重启
|
// 4) 通知全部发出后由协调器统一重启(notify + delay + esp_restart)
|
||||||
DEBUG_INFO(TAG, "[FINISH] 通知已发送,500ms 后重启...");
|
DEBUG_INFO(TAG, "[FINISH] 通知已发送,重启...");
|
||||||
vTaskDelay(pdMS_TO_TICKS(500));
|
if (ctx) free(ctx);
|
||||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
OtaCoordinator::instance().restartEsp32(); // 不返回
|
||||||
esp_restart();
|
} else if (fr.err == ESP_ERR_INVALID_VERSION) {
|
||||||
// 不会返回
|
|
||||||
} else if (err == ESP_ERR_INVALID_VERSION) {
|
|
||||||
DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止");
|
DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止");
|
||||||
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0);
|
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, packBleFinishValue(session_id, 0));
|
||||||
BleOta::instance().m_esp32RebootPending = false;
|
BleOta::instance().m_esp32RebootPending = false;
|
||||||
// ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer
|
// ★ finalize 内部已 abort 并释放 mutex,这里补充通知协调器复位状态机,
|
||||||
|
// 避免 m_sysState 卡在 ESP32_FINALIZING 导致下次 OTA 被假拒绝。
|
||||||
|
OtaCoordinator::instance().abortCurrent();
|
||||||
esp32_reorder_free();
|
esp32_reorder_free();
|
||||||
} else {
|
} 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, (uint32_t)err);
|
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||||
|
packBleFinishValue(session_id, 1));
|
||||||
BleOta::instance().m_esp32RebootPending = false;
|
BleOta::instance().m_esp32RebootPending = false;
|
||||||
// ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer
|
// ★ 同上:finalize 已 abort core,这里通知协调器复位状态机
|
||||||
|
OtaCoordinator::instance().abortCurrent();
|
||||||
esp32_reorder_free();
|
esp32_reorder_free();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontend_sha_hex) free(frontend_sha_hex);
|
if (ctx) free(ctx);
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 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;
|
static bool s_wifi_ps_boosted = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief BLE OTA 开始时,将 WiFi 切换到最大省电模式,减少射频抢占。
|
* @brief BLE OTA 开始时的 WiFi 共存处理(暂停 RSSI 轮询,不切省电模式)。
|
||||||
* WiFi STA 仍保持连接,但信标监听间隔大幅拉长,BLE 获得更多空中时间。
|
|
||||||
*/
|
*/
|
||||||
static void ble_ota_boost_wifi_ps()
|
static void ble_ota_boost_wifi_ps()
|
||||||
{
|
{
|
||||||
if (s_wifi_ps_boosted) return;
|
if (s_wifi_ps_boosted) return;
|
||||||
esp_err_t err = esp_wifi_set_ps(WIFI_PS_MAX_MODEM);
|
s_wifi_ps_boosted = true;
|
||||||
if (err == ESP_OK) {
|
WifiOta::instance().setBleOtaActive(true); // ★ 暂停 WiFi RSSI 轮询
|
||||||
s_wifi_ps_boosted = true;
|
DEBUG_INFO(TAG, "BLE OTA 进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)");
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief BLE OTA 结束/中止时,恢复 WiFi 省电策略为 WIFI_PS_NONE。
|
* @brief BLE OTA 结束/中止时恢复 WiFi RSSI 轮询。
|
||||||
*/
|
*/
|
||||||
static void ble_ota_restore_wifi_ps()
|
static void ble_ota_restore_wifi_ps()
|
||||||
{
|
{
|
||||||
if (!s_wifi_ps_boosted) return;
|
if (!s_wifi_ps_boosted) return;
|
||||||
esp_err_t err = esp_wifi_set_ps(WIFI_PS_NONE);
|
s_wifi_ps_boosted = false;
|
||||||
if (err == ESP_OK) {
|
WifiOta::instance().setBleOtaActive(false); // ★ 恢复 WiFi RSSI 轮询
|
||||||
s_wifi_ps_boosted = false;
|
DEBUG_INFO(TAG, "BLE OTA 结束:恢复 WiFi RSSI 轮询");
|
||||||
WifiOta::instance().setBleOtaActive(false); // ★ 恢复 WiFi RSSI 轮询
|
|
||||||
DEBUG_INFO(TAG, "WiFi 已恢复 WIFI_PS_NONE 模式");
|
|
||||||
} else {
|
|
||||||
DEBUG_WARN(TAG, "WiFi 省电模式恢复失败: %s", esp_err_to_name(err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -539,14 +546,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)
|
static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||||
{
|
{
|
||||||
// ★ 通过全局协调器发起 ESP32 OTA
|
// ★ 通过协调器发起(内部已含 forceCleanup 重试)
|
||||||
esp_err_t err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size);
|
||||||
if (err == ESP_ERR_INVALID_STATE) {
|
|
||||||
// 协调器拒绝 → 尝试强制清理后重试
|
|
||||||
DEBUG_WARN(TAG, "ESP32 OTA 被协调器拒绝,强制清理状态机后重试");
|
|
||||||
Esp32OtaCore::instance().forceCleanup();
|
|
||||||
err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
|
||||||
}
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
@@ -563,9 +564,18 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 中止 ESP32 OTA 会话并清理(薄包装)。
|
* @brief 中止 ESP32 OTA 会话并清理(薄包装)。
|
||||||
|
*
|
||||||
|
* 关键:必须先通知协调器释放 m_sysState(避免下次 begin 被 ESP32_RECEIVING
|
||||||
|
* 状态假拒绝),再调 core.abort() 释放 mutex。两者重复 abort 幂等安全。
|
||||||
*/
|
*/
|
||||||
static void ble_esp32_ota_abort()
|
static void ble_esp32_ota_abort()
|
||||||
{
|
{
|
||||||
|
// ★ 通知协调器释放 ESP32 通道占用(m_sysState → IDLE),
|
||||||
|
// 否则后续 begin() 会因协调器仍记为 ESP32_RECEIVING 而假拒绝。
|
||||||
|
ota_system_state_t cur = OtaCoordinator::instance().getSystemState();
|
||||||
|
if (cur == OTA_SYS_ESP32_RECEIVING || cur == OTA_SYS_ESP32_FINALIZING) {
|
||||||
|
OtaCoordinator::instance().abortCurrent();
|
||||||
|
}
|
||||||
Esp32OtaCore::instance().abort();
|
Esp32OtaCore::instance().abort();
|
||||||
BleOta::instance().m_esp32RebootPending = false;
|
BleOta::instance().m_esp32RebootPending = false;
|
||||||
// ★ 释放 reorder buffer
|
// ★ 释放 reorder buffer
|
||||||
@@ -623,6 +633,66 @@ static void sendFinishEvent(uint8_t stage, uint32_t value)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static uint32_t packBleFinishValue(uint16_t session_id, uint16_t code)
|
||||||
|
{
|
||||||
|
return ((uint32_t)code << 16) | (uint32_t)session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65])
|
||||||
|
{
|
||||||
|
if (!sha || !out_hex) return;
|
||||||
|
for (int i = 0; i < 32; ++i) {
|
||||||
|
snprintf(&out_hex[i * 2], 3, "%02X", sha[i]);
|
||||||
|
}
|
||||||
|
out_hex[64] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sendSha256Notify(const uint8_t sha[32], uint16_t session_id)
|
||||||
|
{
|
||||||
|
if (!sha) return;
|
||||||
|
|
||||||
|
uint8_t sha_pkt[35] = {0};
|
||||||
|
sha_pkt[0] = BLE_OTA_NOTIFY_SHA256;
|
||||||
|
memcpy(&sha_pkt[1], sha, 32);
|
||||||
|
sha_pkt[33] = (uint8_t)(session_id & 0xFFU);
|
||||||
|
sha_pkt[34] = (uint8_t)((session_id >> 8) & 0xFFU);
|
||||||
|
BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_gd32_relay_wait_task(void *pvParameters)
|
||||||
|
{
|
||||||
|
BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)pvParameters;
|
||||||
|
uint16_t session_id = ctx ? ctx->session_id : 0;
|
||||||
|
if (ctx) free(ctx);
|
||||||
|
|
||||||
|
uint32_t result_msg = (uint32_t)OTA_RESULT_ERR_TIMEOUT;
|
||||||
|
bool got_result = false;
|
||||||
|
if (OtaRelay::resultQueue) {
|
||||||
|
got_result = (xQueueReceive(OtaRelay::resultQueue, &result_msg, pdMS_TO_TICKS(120000)) == pdTRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!got_result) {
|
||||||
|
DEBUG_ERR(TAG, "GD32 BLE OTA 等待中继结果超时");
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||||
|
packBleFinishValue(session_id, (uint16_t)(-OTA_RESULT_ERR_TIMEOUT)));
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ota_result_t result = (ota_result_t)((int32_t)result_msg);
|
||||||
|
if (result == OTA_RESULT_OK) {
|
||||||
|
DEBUG_INFO(TAG, "GD32 BLE OTA 中继完成,通知前端");
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_DONE,
|
||||||
|
packBleFinishValue(session_id, 0));
|
||||||
|
} else {
|
||||||
|
uint16_t code = (uint16_t)(result < 0 ? -result : result);
|
||||||
|
DEBUG_ERR(TAG, "GD32 BLE OTA 中继失败: result=%d", (int)result);
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||||
|
packBleFinishValue(session_id, code));
|
||||||
|
}
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GATT 访问回调
|
// GATT 访问回调
|
||||||
@@ -661,26 +731,111 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
|||||||
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
|
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
|
||||||
|
|
||||||
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
|
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
|
||||||
|
uint16_t session_id = 0;
|
||||||
|
if (len >= 35) {
|
||||||
|
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
|
||||||
|
} else if (len >= 3) {
|
||||||
|
session_id = (uint16_t)(buf[1] | (buf[2] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len < 35) {
|
||||||
|
DEBUG_WARN(TAG, "CTRL 0xF7 数据长度不足 35(前端 SHA-256 必传),拒绝触发 GD32 中继");
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
|
||||||
|
packBleFinishValue(session_id, 1));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继
|
// 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继
|
||||||
ota_meta_t meta;
|
ota_meta_t meta = {};
|
||||||
esp_err_t err = OtaRelay::metaLoad(&meta);
|
esp_err_t err = OtaRelay::metaLoad(&meta);
|
||||||
if (err == ESP_OK &&
|
if (err == ESP_OK &&
|
||||||
meta.total_size > 0 &&
|
meta.total_size > 0 &&
|
||||||
meta.stored_size >= meta.total_size) {
|
meta.stored_size >= meta.total_size) {
|
||||||
DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继",
|
const uint8_t *frontend_sha = &buf[1];
|
||||||
|
char frontend_sha_hex[65] = {0};
|
||||||
|
char device_sha_hex[65] = {0};
|
||||||
|
shaBytesToHexUpper(frontend_sha, frontend_sha_hex);
|
||||||
|
|
||||||
|
// ★ 统一走共享 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 (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 " 字节),通过协调器触发中继",
|
||||||
meta.stored_size, meta.total_size);
|
meta.stored_size, meta.total_size);
|
||||||
OtaCoordinator::instance().triggerGd32Relay();
|
if (OtaRelay::resultQueue) {
|
||||||
|
uint32_t stale = 0;
|
||||||
|
while (xQueueReceive(OtaRelay::resultQueue, &stale, 0) == pdTRUE) {
|
||||||
|
DEBUG_WARN(TAG, "清理旧 GD32 中继结果: %" PRIu32, stale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
esp_err_t trig_err = OtaCoordinator::instance().triggerGd32Relay();
|
||||||
|
if (trig_err != ESP_OK) {
|
||||||
|
DEBUG_ERR(TAG, "GD32 中继触发失败: %s", esp_err_to_name(trig_err));
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||||
|
packBleFinishValue(session_id, 1));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START,
|
||||||
|
packBleFinishValue(session_id, 0));
|
||||||
|
|
||||||
|
BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx));
|
||||||
|
if (!ctx) {
|
||||||
|
DEBUG_ERR(TAG, "GD32 中继等待任务上下文分配失败");
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||||
|
packBleFinishValue(session_id, 2));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
ctx->session_id = session_id;
|
||||||
|
BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task,
|
||||||
|
"ble_gd32_wait",
|
||||||
|
3072,
|
||||||
|
ctx,
|
||||||
|
5,
|
||||||
|
NULL);
|
||||||
|
if (ok != pdPASS) {
|
||||||
|
DEBUG_ERR(TAG, "创建 GD32 中继等待任务失败");
|
||||||
|
free(ctx);
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||||
|
packBleFinishValue(session_id, 2));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
|
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
|
||||||
meta.stored_size, meta.total_size);
|
meta.stored_size, meta.total_size);
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||||
|
packBleFinishValue(session_id, 1));
|
||||||
}
|
}
|
||||||
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
|
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
|
||||||
|
uint16_t session_id = 0;
|
||||||
|
if (len >= 35) {
|
||||||
|
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
|
||||||
|
}
|
||||||
if (!Esp32OtaCore::instance().isActive()) {
|
if (!Esp32OtaCore::instance().isActive()) {
|
||||||
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
|
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||||
|
packBleFinishValue(session_id, 1));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (BleOta::instance().m_esp32RebootPending) {
|
if (BleOta::instance().m_esp32RebootPending) {
|
||||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
|
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
|
||||||
|
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||||
|
packBleFinishValue(session_id, 1));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,28 +847,30 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
char *frontend_sha_hex = (char *)malloc(65);
|
BleEsp32FinishCtx *finish_ctx = (BleEsp32FinishCtx *)malloc(sizeof(BleEsp32FinishCtx));
|
||||||
if (!frontend_sha_hex) {
|
if (!finish_ctx) {
|
||||||
DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败");
|
DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败");
|
||||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_NO_MEM);
|
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||||
|
packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < 32; ++i) {
|
for (int i = 0; i < 32; ++i) {
|
||||||
snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]);
|
snprintf(&finish_ctx->sha_hex[i * 2], 3, "%02x", buf[1 + i]);
|
||||||
}
|
}
|
||||||
frontend_sha_hex[64] = '\0';
|
finish_ctx->sha_hex[64] = '\0';
|
||||||
DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex);
|
finish_ctx->session_id = session_id;
|
||||||
|
DEBUG_INFO(TAG, "前端 SHA-256: %s, session=%u", finish_ctx->sha_hex, session_id);
|
||||||
|
|
||||||
BleOta::instance().m_esp32RebootPending = true;
|
BleOta::instance().m_esp32RebootPending = true;
|
||||||
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
||||||
"ble_ota_finish",
|
"ble_ota_finish",
|
||||||
4096,
|
4096,
|
||||||
frontend_sha_hex, // heap string; task 负责 free
|
finish_ctx, // heap context; task 负责 free
|
||||||
5,
|
5,
|
||||||
NULL);
|
NULL);
|
||||||
if (ok != pdPASS) {
|
if (ok != pdPASS) {
|
||||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||||
free(frontend_sha_hex);
|
free(finish_ctx);
|
||||||
BleOta::instance().m_esp32RebootPending = false;
|
BleOta::instance().m_esp32RebootPending = false;
|
||||||
} else {
|
} else {
|
||||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
|
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
|
||||||
@@ -858,13 +1015,22 @@ static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len)
|
|||||||
DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size);
|
DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成
|
// ★ 幂等保护:若已有活跃 ESP32 OTA 会话,说明这是客户端因 ACK 超时
|
||||||
esp_err_t err = ble_esp32_ota_begin(total_size);
|
// 重传的 seq=0(同一会话续传)。若再次走 begin,会被协调器以
|
||||||
if (err != ESP_OK) {
|
// "OTA 已 ESP32_RECEIVING" 拒绝 → 误报"被其他通道占用"并中止整条
|
||||||
if (err == ESP_ERR_INVALID_STATE) {
|
// OTA 流程。此处跳过 begin 复用现有会话,后续 reorder buffer 会
|
||||||
sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0);
|
// 按 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+96
-67
@@ -48,6 +48,9 @@ WifiOta& WifiOta::instance()
|
|||||||
|
|
||||||
static constexpr uint32_t GD32_OTA_FLASH_SECTOR_SIZE = 4096U;
|
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 按指定对齐值向上取整。
|
* @brief 按指定对齐值向上取整。
|
||||||
* @param value 待对齐的值。
|
* @param value 待对齐的值。
|
||||||
@@ -509,47 +512,39 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req)
|
|||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 完整模式:finalize 不重启,先把含设备端 SHA 的结果返回给前端 ──
|
// ── 完整模式:收尾+重启序列由协调器统一封装 ──
|
||||||
// 关键:reboot=false,让 core.finalize 真正返回,才能拿到 device_sha_hex
|
// 关键:reboot=false 行为由 finalizeForReboot 内部封装,
|
||||||
// 并把响应发回前端;待响应发完后再延迟重启。
|
// 响应发回前端后再由 restartEsp32() 统一通知+delay+esp_restart。
|
||||||
// 若 reboot=true,core 内部直接 esp_restart(),HTTP 响应永远发不出去,
|
|
||||||
// 前端会得到 "Failed to fetch" 而错过核对结果。
|
|
||||||
DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived());
|
DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived());
|
||||||
OtaCoordinator::instance().notifyEsp32ReceiveComplete();
|
Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot(
|
||||||
esp_err_t err = core.finalize(has_frontend_sha ? frontend_sha_hex : nullptr,
|
has_frontend_sha ? frontend_sha_hex : nullptr);
|
||||||
/*reboot=*/false,
|
|
||||||
device_sha_hex);
|
|
||||||
|
|
||||||
if (err == ESP_OK) {
|
if (fr.err == ESP_OK) {
|
||||||
OtaCoordinator::instance().notifyEsp32FinalizeComplete();
|
|
||||||
// 1) 把包含设备端 SHA 的响应发回前端
|
// 1) 把包含设备端 SHA 的响应发回前端
|
||||||
char resp[320] = {0};
|
char resp[320] = {0};
|
||||||
snprintf(resp, sizeof(resp),
|
snprintf(resp, sizeof(resp),
|
||||||
"{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", "
|
"{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", "
|
||||||
"\"sha256\": \"%s\"}",
|
"\"sha256\": \"%s\"}",
|
||||||
device_sha_hex);
|
fr.device_sha_hex);
|
||||||
httpd_resp_set_type(req, "application/json");
|
httpd_resp_set_type(req, "application/json");
|
||||||
httpd_resp_send(req, resp, strlen(resp));
|
httpd_resp_send(req, resp, strlen(resp));
|
||||||
|
|
||||||
// 2) 响应发完后延迟 500ms 重启,给 TCP 缓冲区时间 flush 到客户端
|
// 2) 响应发完后由协调器统一重启(notify + delay + esp_restart)
|
||||||
DEBUG_INFO("OTA", "响应已发送,500ms 后重启...");
|
DEBUG_INFO("OTA", "响应已发送,重启...");
|
||||||
vTaskDelay(pdMS_TO_TICKS(500));
|
OtaCoordinator::instance().restartEsp32(); // 不返回
|
||||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
|
||||||
esp_restart();
|
|
||||||
// 不会返回
|
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失败:core 已内部清理(abort + mutex release)
|
// 失败:core 已内部清理(abort + mutex release)
|
||||||
if (err == ESP_ERR_INVALID_VERSION) {
|
if (fr.err == ESP_ERR_INVALID_VERSION) {
|
||||||
char err_resp[256] = {0};
|
char err_resp[256] = {0};
|
||||||
snprintf(err_resp, sizeof(err_resp),
|
snprintf(err_resp, sizeof(err_resp),
|
||||||
"{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}",
|
"{\"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_set_type(req, "application/json");
|
||||||
httpd_resp_send(req, err_resp, strlen(err_resp));
|
httpd_resp_send(req, err_resp, strlen(err_resp));
|
||||||
} else {
|
} 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;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
@@ -564,15 +559,13 @@ static esp_err_t restart_handler(httpd_req_t *req)
|
|||||||
(void)req;
|
(void)req;
|
||||||
http_set_cors_headers(req);
|
http_set_cors_headers(req);
|
||||||
|
|
||||||
DEBUG_INFO("OTA", "收到重启请求,500ms 后重启...");
|
DEBUG_INFO("OTA", "收到重启请求,重启...");
|
||||||
|
|
||||||
const char resp[] = "{\"status\": \"ok\", \"message\": \"rebooting\"}";
|
const char resp[] = "{\"status\": \"ok\", \"message\": \"rebooting\"}";
|
||||||
httpd_resp_set_type(req, "application/json");
|
httpd_resp_set_type(req, "application/json");
|
||||||
httpd_resp_send(req, resp, strlen(resp));
|
httpd_resp_send(req, resp, strlen(resp));
|
||||||
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(500));
|
OtaCoordinator::instance().restartEsp32(); // 内部 notify + delay + esp_restart,不返回
|
||||||
OtaCoordinator::instance().notifyEsp32Rebooting();
|
|
||||||
esp_restart();
|
|
||||||
return ESP_OK;
|
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)
|
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||||
{
|
{
|
||||||
http_set_cors_headers(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);
|
DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len);
|
||||||
|
|
||||||
// ★ WiFi OTA 入口:强制恢复 WIFI_PS_NONE,防止 BLE OTA 残留省电模式导致断连
|
// ★ 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;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ★ 通过全局协调器发起 ESP32 OTA
|
// ★ 通过协调器发起(内部已含 forceCleanup 重试)
|
||||||
esp_err_t err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size);
|
||||||
if (err == ESP_ERR_INVALID_STATE) {
|
|
||||||
// 协调器拒绝(系统忙或通道被占用)→ 尝试强制清理后重试
|
|
||||||
DEBUG_WARN("WIFI", "ESP32 OTA 被协调器拒绝,强制清理状态机后重试");
|
|
||||||
Esp32OtaCore::instance().forceCleanup();
|
|
||||||
err = OtaCoordinator::instance().beginEsp32Ota(total_size);
|
|
||||||
}
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
if (err == ESP_ERR_INVALID_STATE) {
|
if (err == ESP_ERR_INVALID_STATE) {
|
||||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel");
|
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();
|
Esp32OtaCore &core = Esp32OtaCore::instance();
|
||||||
|
|
||||||
// 堆分配缓冲区,避免栈溢出(HTTP server 栈仅 8KB)
|
// ★ 使用文件级 static 缓冲区,避免堆碎片化(HTTP server 栈仅 8KB)
|
||||||
uint8_t *fw_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
|
uint8_t *fw_buf = s_ota_recv_buf;
|
||||||
if (!fw_buf) {
|
|
||||||
core.abort();
|
|
||||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败");
|
|
||||||
return ESP_FAIL;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t total_firmware_written = 0;
|
size_t total_firmware_written = 0;
|
||||||
size_t total_received = json_header_len; // 已消费的字节数
|
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);
|
int ret = httpd_req_recv(req, (char *)fw_buf, to_read);
|
||||||
if (ret <= 0) {
|
if (ret <= 0) {
|
||||||
DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret);
|
DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret);
|
||||||
free(fw_buf);
|
|
||||||
core.abort();
|
core.abort();
|
||||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed");
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed");
|
||||||
return ESP_FAIL;
|
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);
|
err = core.write(fw_buf, (size_t)ret);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
DEBUG_ERR("WIFI", "OTA write failed: %s", esp_err_to_name(err));
|
DEBUG_ERR("WIFI", "OTA write failed: %s", esp_err_to_name(err));
|
||||||
free(fw_buf);
|
|
||||||
core.abort();
|
core.abort();
|
||||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed");
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed");
|
||||||
return ESP_FAIL;
|
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);
|
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\"}";
|
const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", \"target\": \"esp32\"}";
|
||||||
httpd_resp_set_type(req, "application/json");
|
httpd_resp_set_type(req, "application/json");
|
||||||
@@ -805,11 +785,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
|||||||
}
|
}
|
||||||
OtaRelay::metaClear();
|
OtaRelay::metaClear();
|
||||||
|
|
||||||
uint8_t *buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
|
uint8_t *buf = s_ota_recv_buf;
|
||||||
if (!buf) {
|
|
||||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败");
|
|
||||||
return ESP_FAIL;
|
|
||||||
}
|
|
||||||
|
|
||||||
int received = 0;
|
int received = 0;
|
||||||
|
|
||||||
@@ -837,7 +813,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
|||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s",
|
DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s",
|
||||||
first_chunk_len, esp_err_to_name(err));
|
first_chunk_len, esp_err_to_name(err));
|
||||||
free(buf);
|
|
||||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed");
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed");
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
@@ -857,7 +832,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
|||||||
if (ret <= 0) {
|
if (ret <= 0) {
|
||||||
DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d",
|
DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d",
|
||||||
received, req->content_len);
|
received, req->content_len);
|
||||||
free(buf);
|
|
||||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted");
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted");
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
@@ -866,7 +840,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
|||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
DEBUG_ERR("WIFI", "GD32 write failed: offset=%u len=%d err=%s",
|
DEBUG_ERR("WIFI", "GD32 write failed: offset=%u len=%d err=%s",
|
||||||
(unsigned)gd32_offset, ret, esp_err_to_name(err));
|
(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");
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed");
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
@@ -875,8 +848,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
|||||||
gd32_offset += (uint32_t)ret;
|
gd32_offset += (uint32_t)ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
free(buf);
|
|
||||||
|
|
||||||
// update meta with written size
|
// update meta with written size
|
||||||
OtaRelay::metaUpdate(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_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().stateLock();
|
||||||
WifiOta::instance().setGd32StorageDone(true);
|
WifiOta::instance().setGd32StorageDone(true);
|
||||||
WifiOta::instance().stateUnlock();
|
WifiOta::instance().stateUnlock();
|
||||||
DEBUG_INFO("WIFI", "GD32 firmware fully stored, computing SHA-256...");
|
DEBUG_INFO("WIFI", "GD32 firmware fully stored, pre-computing SHA-256...");
|
||||||
// 计算并存储 SHA-256 校验和
|
// 预计算 SHA-256(写入 NVS),由前端在 /ota/relay 时再校验通过后触发中继
|
||||||
esp_err_t sha_err = OtaRelay::metaFinalizeChecksum();
|
esp_err_t sha_err = OtaRelay::metaFinalizeChecksum();
|
||||||
if (sha_err != ESP_OK) {
|
if (sha_err != ESP_OK) {
|
||||||
DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err));
|
DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err));
|
||||||
}
|
}
|
||||||
DEBUG_INFO("WIFI", "auto-triggering relay");
|
// ★ 不再自动 triggerGd32Relay:安全闸门已统一在 /ota/relay 处理(带 SHA 校验)
|
||||||
// ★ 通过协调器触发中继
|
|
||||||
OtaCoordinator::instance().triggerGd32Relay();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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_set_type(req, "application/json");
|
||||||
httpd_resp_send(req, resp, strlen(resp));
|
httpd_resp_send(req, resp, strlen(resp));
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
@@ -910,8 +880,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief HTTP POST /ota/relay 处理器
|
* @brief HTTP POST /ota/relay 处理器
|
||||||
* @param req
|
* 必传 body: {"sha256": "<64-char hex>"}。
|
||||||
* @return
|
* 校验通过后触发 GD32 中继;响应回传设备端 SHA(对齐 BLE 的 sendSha256Notify)。
|
||||||
*/
|
*/
|
||||||
static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
|
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", "=== HTTP: POST /ota/relay ===");
|
||||||
DEBUG_INFO("WIFI", "收到触发 OTA 中继请求");
|
DEBUG_INFO("WIFI", "收到触发 OTA 中继请求");
|
||||||
|
|
||||||
|
// 1) 固件就绪检查
|
||||||
ota_meta_t meta;
|
ota_meta_t meta;
|
||||||
memset(&meta, 0, sizeof(meta));
|
memset(&meta, 0, sizeof(meta));
|
||||||
if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) {
|
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;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ★ 通过全局协调器触发 GD32 中继
|
// 2) 解析前端 SHA-256(必须 64 字符 hex)
|
||||||
esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay();
|
char frontend_sha_hex[65] = {0};
|
||||||
if (trigger_err != ESP_OK) {
|
if (req->content_len > 0 && req->content_len < 512) {
|
||||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay trigger failed - system busy or firmware not ready");
|
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;
|
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_set_type(req, "application/json");
|
||||||
httpd_resp_send(req, resp, strlen(resp));
|
httpd_resp_send(req, resp, strlen(resp));
|
||||||
|
|
||||||
|
|||||||
+251
-141
@@ -10,6 +10,7 @@ let logEntries = [];
|
|||||||
let lastLogHash = null;
|
let lastLogHash = null;
|
||||||
let autoScroll = true;
|
let autoScroll = true;
|
||||||
let logSocket = null;
|
let logSocket = null;
|
||||||
|
let pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
|
||||||
let latestSystemStatus = null;
|
let latestSystemStatus = null;
|
||||||
let isEsp32Rebooting = false;
|
let isEsp32Rebooting = false;
|
||||||
let rebootStartTime = 0;
|
let rebootStartTime = 0;
|
||||||
@@ -19,7 +20,7 @@ function toggleCollapse(headEl) {
|
|||||||
headEl.closest('.card').classList.toggle('collapsed');
|
headEl.closest('.card').classList.toggle('collapsed');
|
||||||
}
|
}
|
||||||
// ==================== 刷写进度 ====================
|
// ==================== 刷写进度 ====================
|
||||||
var activePhase = null; // 'esp32Ota' | 'gd32Upload' | 'gd32Relay' | null
|
var activePhase = null; // 'esp32Ota' | 'gd32Ota' | null
|
||||||
var currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
|
var currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
|
||||||
// 切换 OTA 阶段面板显示
|
// 切换 OTA 阶段面板显示
|
||||||
function switchStageFlow(target) {
|
function switchStageFlow(target) {
|
||||||
@@ -103,8 +104,9 @@ function showVerifyActions(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setActivePhase(phase) {
|
function setActivePhase(phase) {
|
||||||
|
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
|
||||||
activePhase = phase;
|
activePhase = phase;
|
||||||
var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' };
|
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
|
||||||
Object.keys(stepMap).forEach(function(p) {
|
Object.keys(stepMap).forEach(function(p) {
|
||||||
var el = document.getElementById(stepMap[p]);
|
var el = document.getElementById(stepMap[p]);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -116,11 +118,12 @@ function setActivePhase(phase) {
|
|||||||
if (fill) fill.classList.remove('done');
|
if (fill) fill.classList.remove('done');
|
||||||
}
|
}
|
||||||
function markPhaseDone(phase) {
|
function markPhaseDone(phase) {
|
||||||
var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' };
|
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
|
||||||
|
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
|
||||||
var el = document.getElementById(stepMap[phase]);
|
var el = document.getElementById(stepMap[phase]);
|
||||||
if (el) { el.classList.remove('active'); el.classList.add('done'); }
|
if (el) { el.classList.remove('active'); el.classList.add('done'); }
|
||||||
// All done? turn progress bar green
|
// All done? turn progress bar green
|
||||||
if (phase === 'gd32Relay' || phase === 'esp32Ota') {
|
if (phase === 'gd32Ota' || phase === 'esp32Ota') {
|
||||||
var fill = document.getElementById('progressFill');
|
var fill = document.getElementById('progressFill');
|
||||||
if (fill) fill.classList.add('done');
|
if (fill) fill.classList.add('done');
|
||||||
}
|
}
|
||||||
@@ -138,7 +141,7 @@ function resetProgress() {
|
|||||||
activePhase = null;
|
activePhase = null;
|
||||||
esp32OtaPhase = 0;
|
esp32OtaPhase = 0;
|
||||||
switchStageFlow(null);
|
switchStageFlow(null);
|
||||||
['ppEsp32', 'ppGd32Up', 'ppGd32Relay'].forEach(function(id) {
|
['ppEsp32', 'ppGd32Ota'].forEach(function(id) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (el) el.classList.remove('active', 'done');
|
if (el) el.classList.remove('active', 'done');
|
||||||
});
|
});
|
||||||
@@ -296,10 +299,6 @@ function resetStageFlow() {
|
|||||||
function applyDisconnectedRuntimeState() {
|
function applyDisconnectedRuntimeState() {
|
||||||
document.getElementById('pillIP').textContent = '--';
|
document.getElementById('pillIP').textContent = '--';
|
||||||
document.getElementById('pillRSSI').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();
|
resetProgress();
|
||||||
resetStageFlow();
|
resetStageFlow();
|
||||||
}
|
}
|
||||||
@@ -316,8 +315,6 @@ function applyRebootingRuntimeState() {
|
|||||||
var bc = document.getElementById('bConn');
|
var bc = document.getElementById('bConn');
|
||||||
if (bc) bc.textContent = '重启中';
|
if (bc) bc.textContent = '重启中';
|
||||||
if (bc) bc.className = 'v warn';
|
if (bc) bc.className = 'v warn';
|
||||||
document.getElementById('pillRelayTxt').textContent = '重启中';
|
|
||||||
document.getElementById('pillRelay').className = 'pill processing';
|
|
||||||
}
|
}
|
||||||
function applyUploadingRuntimeState() {
|
function applyUploadingRuntimeState() {
|
||||||
var pc = document.getElementById('pillConn');
|
var pc = document.getElementById('pillConn');
|
||||||
@@ -361,23 +358,16 @@ function updateHealthUi(healthCode, freeHeap) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ==================== OTA 阶段驱动 (真实状态) ====================
|
// ==================== OTA 阶段驱动 (真实状态) ====================
|
||||||
// Relay state → 6-stage mapping:
|
// Relay state comes from relay_state_t in main/relay/ota_relay.h:
|
||||||
// Stage 1 (认证握手): rs 1-6 握手/挑战/会话密钥/认证
|
// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING,
|
||||||
// Stage 2 (传输协商): rs 7-8 固件大小协商
|
// 4=FINISHING, 5=DONE, 255=ERROR.
|
||||||
// Stage 3 (数据转发): rs 9-13 数据包收发/ACK/重发
|
|
||||||
// Stage 4 (传输完成): rs 14-15 数据接收完毕
|
|
||||||
// Stage 5 (OTA刷写): rs 16-17 OTA 结束/完成
|
|
||||||
// Stage 6 (校验验证): rs 18 APP 校验
|
|
||||||
// Error: rs 255
|
|
||||||
var relayStateDetailMap = {
|
var relayStateDetailMap = {
|
||||||
0: '待机',
|
0: '待机',
|
||||||
1: '等待握手', 2: '握手已发', 3: '挑战已发',
|
1: '认证握手',
|
||||||
4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成',
|
2: '传输协商',
|
||||||
7: '大小已发', 8: '大小确认',
|
3: '数据转发',
|
||||||
9: '接收中', 10: '转发中', 11: '数据包已发', 12: 'ACK正常', 13: '等待重发',
|
4: '收尾验证',
|
||||||
14: '接收完成', 15: '传输完成',
|
5: 'OTA完成',
|
||||||
16: '结束已发', 17: 'OTA完成',
|
|
||||||
18: 'APP验证',
|
|
||||||
255: '错误'
|
255: '错误'
|
||||||
};
|
};
|
||||||
function updateStagesFromRelayState(rs) {
|
function updateStagesFromRelayState(rs) {
|
||||||
@@ -403,16 +393,17 @@ function updateStagesFromRelayState(rs) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var activeStage = 0;
|
var activeStage = 0;
|
||||||
if (rs >= 1 && rs <= 6) activeStage = 1;
|
if (rs === 1) activeStage = 1;
|
||||||
else if (rs >= 7 && rs <= 8) activeStage = 2;
|
else if (rs === 2) activeStage = 2;
|
||||||
else if (rs >= 9 && rs <= 13) activeStage = 3;
|
else if (rs === 3) activeStage = 3;
|
||||||
else if (rs >= 14 && rs <= 15) activeStage = 4;
|
else if (rs === 4) activeStage = 5;
|
||||||
else if (rs >= 16 && rs <= 17) activeStage = 5;
|
else if (rs === 5) activeStage = 6;
|
||||||
else if (rs >= 18 && rs <= 18) activeStage = 6;
|
|
||||||
for (var i = 0; i < activeStage - 1; i++) {
|
for (var i = 0; i < activeStage - 1; i++) {
|
||||||
if (all[i]) all[i].className = 'stage-node done';
|
if (all[i]) all[i].className = 'stage-node done';
|
||||||
}
|
}
|
||||||
if (activeStage >= 1 && activeStage <= 6) {
|
if (rs === 5) {
|
||||||
|
if (all[5]) all[5].className = 'stage-node done';
|
||||||
|
} else if (activeStage >= 1 && activeStage <= 6) {
|
||||||
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
|
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
|
||||||
}
|
}
|
||||||
// Show detail text for the active stage
|
// Show detail text for the active stage
|
||||||
@@ -497,81 +488,91 @@ function updateSystemStatusUi(data) {
|
|||||||
}
|
}
|
||||||
// relay state - skip GD32 relay UI when ESP32 OTA is active
|
// relay state - skip GD32 relay UI when ESP32 OTA is active
|
||||||
if (currentOtaTarget === 'esp32' && esp32OtaPhase > 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var rs = ota.relay_state;
|
var rs = ota.relay_state;
|
||||||
var relayText = relayStateDetailMap[rs] || '待机';
|
var sysState = ota.sys_state || '';
|
||||||
document.getElementById('pillRelayTxt').textContent = relayText;
|
var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
|
||||||
var pr = document.getElementById('pillRelay');
|
var isGd32Storing = sysState === 'GD32_STORING' || sysStateCode === 1;
|
||||||
if (rs === 255) pr.className = 'pill offline';
|
var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
|
||||||
else if (rs > 0 && rs < 5) pr.className = 'pill processing';
|
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
|
||||||
else if (rs === 5) pr.className = 'pill online';
|
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
|
||||||
else pr.className = 'pill idle';
|
|
||||||
// OTA cache progress (GD32 upload to ESP32 flash)
|
// OTA cache progress (GD32 upload to ESP32 flash)
|
||||||
var total = ota.total_size || 0;
|
var total = ota.total_size || 0;
|
||||||
var stored = ota.stored_size || 0;
|
var stored = ota.stored_size || 0;
|
||||||
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
||||||
var bp = document.getElementById('bOTAProgress');
|
var bp = document.getElementById('bOTAProgress');
|
||||||
if (bp) {
|
function setOtaProgressBadge(pct) {
|
||||||
bp.textContent = storageProgress + '%';
|
var safePct = Math.max(0, Math.min(100, Math.round(pct || 0)));
|
||||||
bp.className = 'v ' + (storageProgress === 100 ? 'ok' : storageProgress > 0 ? 'warn' : '');
|
if (bp) {
|
||||||
|
bp.textContent = safePct + '%';
|
||||||
|
bp.className = 'v ' + (safePct >= 100 ? 'ok' : safePct > 0 ? 'warn' : '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// GD32 relay forwarding progress
|
// GD32 relay forwarding progress
|
||||||
var relaySeq = ota.relay_seq || 0;
|
var relaySeq = ota.relay_seq || 0;
|
||||||
var relayTotal = ota.relay_total_packets || 0;
|
var relayTotal = ota.relay_total_packets || 0;
|
||||||
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
|
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
|
||||||
var gd32AppAlive = ota.gd32_app_alive;
|
var gd32AppAlive = ota.gd32_app_alive;
|
||||||
// ★ 修复:后端实际状态机枚举 (main/relay/ota_relay.h):
|
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
|
||||||
// 0=RELAY_IDLE, 1=RELAY_HANDSHAKING, 2=RELAY_SIZE_NEG,
|
if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); }
|
||||||
// 3=RELAY_TRANSFERRING, 4=RELAY_FINISHING, 5=RELAY_DONE, 0xFF=RELAY_ERROR
|
|
||||||
// 旧版 1-18 范围判断已与新状态机不匹配,进度条永远停在认证中阶段。
|
if (isGd32Error) {
|
||||||
if (rs === 1) {
|
setActivePhase('gd32Ota');
|
||||||
|
setOtaProgressBadge(0);
|
||||||
|
setProgress(0, 'GD32 OTA 失败');
|
||||||
|
} else if (isGd32Complete || rs === 5 || (gd32AppAlive && sysState === 'GD32_COMPLETE')) {
|
||||||
|
markPhaseDone('gd32Ota');
|
||||||
|
setOtaProgressBadge(100);
|
||||||
|
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 (isGd32Storing || (total > 0 && stored < total && !isUploading)) {
|
||||||
|
setActivePhase('gd32Ota');
|
||||||
|
var storeOverall = Math.min(45, Math.round(storageProgress * 0.45));
|
||||||
|
setOtaProgressBadge(storeOverall);
|
||||||
|
if (!isUploading) {
|
||||||
|
setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total));
|
||||||
|
}
|
||||||
|
} else if (rs === 1) {
|
||||||
// HANDSHAKING: 握手 + 挑战认证
|
// HANDSHAKING: 握手 + 挑战认证
|
||||||
setActivePhase('gd32Relay');
|
setActivePhase('gd32Ota');
|
||||||
setProgress(0, '握手与认证中...');
|
setOtaProgressBadge(50);
|
||||||
|
setProgress(50, '握手与认证中...');
|
||||||
} else if (rs === 2) {
|
} else if (rs === 2) {
|
||||||
// SIZE_NEG: 固件大小协商
|
// SIZE_NEG: 固件大小协商
|
||||||
setActivePhase('gd32Relay');
|
setActivePhase('gd32Ota');
|
||||||
setProgress(0, '协商固件大小...');
|
setOtaProgressBadge(55);
|
||||||
|
setProgress(55, '协商固件大小...');
|
||||||
} else if (rs === 3) {
|
} else if (rs === 3) {
|
||||||
// TRANSFERRING: 数据包传输
|
// TRANSFERRING: 数据包传输
|
||||||
setActivePhase('gd32Relay');
|
setActivePhase('gd32Ota');
|
||||||
setProgress(relayProgress, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包');
|
var relayOverall = 60 + Math.round(relayProgress * 0.30);
|
||||||
|
setOtaProgressBadge(relayOverall);
|
||||||
|
setProgress(relayOverall, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包');
|
||||||
} else if (rs === 4) {
|
} else if (rs === 4) {
|
||||||
// FINISHING: 结束OTA + APP存活验证
|
// FINISHING: 结束OTA + APP存活验证
|
||||||
setActivePhase('gd32Relay');
|
setActivePhase('gd32Ota');
|
||||||
|
setOtaProgressBadge(95);
|
||||||
if (gd32AppAlive) {
|
if (gd32AppAlive) {
|
||||||
markPhaseDone('gd32Relay');
|
markPhaseDone('gd32Ota');
|
||||||
setProgress(100, 'GD32 刷写完成');
|
setProgress(100, 'GD32 刷写完成');
|
||||||
var sb = document.getElementById('stageStatusBar');
|
var sb = document.getElementById('stageStatusBar');
|
||||||
var st = document.getElementById('stageStatusText');
|
var st = document.getElementById('stageStatusText');
|
||||||
if (sb) sb.className = 'stage-status-bar ok';
|
if (sb) sb.className = 'stage-status-bar ok';
|
||||||
if (st) st.textContent = 'GD32 刷写完成';
|
if (st) st.textContent = 'GD32 刷写完成';
|
||||||
} else {
|
} else {
|
||||||
setProgress(100, '收尾中,等待 APP 启动...');
|
setProgress(95, '收尾中,等待 APP 启动...');
|
||||||
}
|
}
|
||||||
} else if (rs === 5) {
|
} else if (total > 0 && stored >= total) {
|
||||||
// DONE: 中继完成(metaClear 已清空,relaySeq/relayTotal 会被 reset 为 0)
|
setActivePhase('gd32Ota');
|
||||||
setActivePhase('gd32Relay');
|
setOtaProgressBadge(45);
|
||||||
setProgress(100, 'OTA 完成');
|
setProgress(45, '上传完成,等待自动启动 GD32 OTA...');
|
||||||
} else if (total > 0 && stored < total && !isUploading) {
|
} else {
|
||||||
// GD32 upload to cache in progress (handled by upload progress if uploading)
|
setOtaProgressBadge(storageProgress);
|
||||||
setActivePhase('gd32Upload');
|
|
||||||
setProgress(storageProgress, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total));
|
|
||||||
} else if (gd32AppAlive && rs === 0 && total > 0 && stored >= total) {
|
|
||||||
// Relay finished and reset, but APP is alive → truly done
|
|
||||||
markPhaseDone('gd32Relay');
|
|
||||||
setProgress(100, 'GD32 刷写完成');
|
|
||||||
var sb2 = document.getElementById('stageStatusBar');
|
|
||||||
var st2 = document.getElementById('stageStatusText');
|
|
||||||
if (sb2) sb2.className = 'stage-status-bar ok';
|
|
||||||
if (st2) st2.textContent = 'GD32 刷写完成';
|
|
||||||
}
|
}
|
||||||
// stage flow from real state
|
|
||||||
if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(rs); }
|
|
||||||
}
|
}
|
||||||
async function testConnection() {
|
async function testConnection() {
|
||||||
// 如果在降速模式,先恢复正常轮询
|
// 如果在降速模式,先恢复正常轮询
|
||||||
@@ -876,51 +877,61 @@ function isIntelHexText(text) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。
|
||||||
|
* BLE / WiFi 共用此函数,避免转换逻辑重复。
|
||||||
|
* @param {File} file 原始文件
|
||||||
|
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null}>}
|
||||||
|
*/
|
||||||
|
async function convertHexFileToBin(file) {
|
||||||
|
if (!file || !file.name.toLowerCase().endsWith('.hex')) {
|
||||||
|
return { file: file, converted: false, baseAddr: null };
|
||||||
|
}
|
||||||
|
var text = await file.text();
|
||||||
|
if (!isIntelHexText(text)) {
|
||||||
|
return { file: file, converted: false, baseAddr: null };
|
||||||
|
}
|
||||||
|
var result = parseIntelHex(text);
|
||||||
|
var binName = file.name.replace(/\.hex$/i, '.bin');
|
||||||
|
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
|
||||||
|
var binFile = new File([binBlob], binName, { type: 'application/octet-stream', lastModified: file.lastModified });
|
||||||
|
return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr };
|
||||||
|
}
|
||||||
// ==================== 文件处理 ====================
|
// ==================== 文件处理 ====================
|
||||||
function processFile(file, chip) {
|
function processFile(file, chip) {
|
||||||
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
||||||
var isHexByName = file.name.toLowerCase().endsWith('.hex');
|
var isHexByName = file.name.toLowerCase().endsWith('.hex');
|
||||||
if (chip === 'gd32' && isHexByName) {
|
if (chip === 'gd32' && isHexByName) {
|
||||||
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
|
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
|
||||||
var reader = new FileReader();
|
convertHexFileToBin(file).then(function(result) {
|
||||||
reader.onload = function (e) {
|
if (!result.converted) {
|
||||||
try {
|
// 文件名 .hex 但内容不像,直接用原始文件
|
||||||
var text = e.target.result;
|
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
|
||||||
// 二次确认: 内容也像 HEX
|
gd32File = file;
|
||||||
if (!isIntelHexText(text)) {
|
|
||||||
// 文件名 .hex 但内容不像,直接用原始文件
|
|
||||||
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
|
|
||||||
gd32File = file;
|
|
||||||
document.getElementById('fileInfo' + suffix).classList.add('show');
|
|
||||||
document.getElementById('fileName' + suffix).textContent = file.name;
|
|
||||||
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
|
|
||||||
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
|
|
||||||
updateUploadButtonLabel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var result = parseIntelHex(text);
|
|
||||||
var binName = file.name.replace(/\.hex$/i, '.bin');
|
|
||||||
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
|
|
||||||
var binFile = new File([binBlob], binName, { type: 'application/octet-stream' });
|
|
||||||
gd32File = binFile;
|
|
||||||
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
|
|
||||||
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + binName);
|
|
||||||
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.data.length));
|
|
||||||
showToast('HEX 已自动转换为 BIN', 'success');
|
|
||||||
document.getElementById('fileInfo' + suffix).classList.add('show');
|
document.getElementById('fileInfo' + suffix).classList.add('show');
|
||||||
document.getElementById('fileName' + suffix).textContent = binName + ' (由 ' + file.name + ' 转换)';
|
document.getElementById('fileName' + suffix).textContent = file.name;
|
||||||
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
|
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
|
||||||
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.data.length);
|
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
|
||||||
updateUploadButtonLabel();
|
|
||||||
} catch (err) {
|
|
||||||
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
|
|
||||||
showToast('HEX 文件解析失败: ' + err.message, 'error');
|
|
||||||
gd32File = null;
|
|
||||||
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
|
||||||
updateUploadButtonLabel();
|
updateUploadButtonLabel();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
gd32File = result.file;
|
||||||
reader.readAsText(file);
|
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
|
||||||
|
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name);
|
||||||
|
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size));
|
||||||
|
showToast('HEX 已自动转换为 BIN', 'success');
|
||||||
|
document.getElementById('fileInfo' + suffix).classList.add('show');
|
||||||
|
document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
|
||||||
|
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
|
||||||
|
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size);
|
||||||
|
updateUploadButtonLabel();
|
||||||
|
}).catch(function(err) {
|
||||||
|
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
|
||||||
|
showToast('HEX 文件解析失败: ' + err.message, 'error');
|
||||||
|
gd32File = null;
|
||||||
|
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
||||||
|
updateUploadButtonLabel();
|
||||||
|
});
|
||||||
return; // 异步,回调里设置 gd32File
|
return; // 异步,回调里设置 gd32File
|
||||||
}
|
}
|
||||||
// .bin 文件或 ESP32 文件: 直接使用
|
// .bin 文件或 ESP32 文件: 直接使用
|
||||||
@@ -966,9 +977,50 @@ function clearFile(chip) {
|
|||||||
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
|
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
|
||||||
}
|
}
|
||||||
// ==================== 上传固件 ====================
|
// ==================== 上传固件 ====================
|
||||||
|
// ★ 跨通道互斥检查:WiFi 与 BLE 共用同一个 ESP32 OTA 状态机与全局互斥,
|
||||||
|
// 但前端只用一个 `isUploading` bool 锁同一通道。这里在每次 startUpload
|
||||||
|
// 入口主动查询 /system/status,若发现另一通道已经进入 OTA 活跃态
|
||||||
|
// (ESP32 三个状态或 GD32 存储/中继),直接拒绝并提示用户。
|
||||||
async function startUpload(chip) {
|
async function startUpload(chip) {
|
||||||
var file = chip === 'esp32' ? esp32File : gd32File;
|
var file = chip === 'esp32' ? esp32File : gd32File;
|
||||||
if (!file || isUploading) return;
|
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;
|
var handle = chip === 'esp32' ? esp32FileHandle : gd32FileHandle;
|
||||||
if (handle) {
|
if (handle) {
|
||||||
@@ -1001,16 +1053,16 @@ async function startUpload(chip) {
|
|||||||
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
|
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
|
||||||
showToast('文件已变更,请重新选择', 'error');
|
showToast('文件已变更,请重新选择', 'error');
|
||||||
clearFile(chip);
|
clearFile(chip);
|
||||||
|
isUploading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isUploading = true;
|
|
||||||
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
|
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
|
||||||
var origText = uploadBtn.textContent;
|
var origText = uploadBtn.textContent;
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
uploadBtn.textContent = '上传中...';
|
uploadBtn.textContent = '上传中...';
|
||||||
switchStageFlow(chip);
|
switchStageFlow(chip);
|
||||||
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Upload');
|
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota');
|
||||||
if (chip === 'esp32') updateEsp32StagesFromPhase(1);
|
if (chip === 'esp32') updateEsp32StagesFromPhase(1);
|
||||||
// Reset verification panel
|
// Reset verification panel
|
||||||
var verifyInfo = document.getElementById('stageVerifyInfo');
|
var verifyInfo = document.getElementById('stageVerifyInfo');
|
||||||
@@ -1025,7 +1077,7 @@ async function startUpload(chip) {
|
|||||||
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
|
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
|
||||||
try {
|
try {
|
||||||
var arrayBuffer = await file.arrayBuffer();
|
var arrayBuffer = await file.arrayBuffer();
|
||||||
// 刷写时重新计算 SHA-256
|
// 刷写时重新计算 SHA-256(ESP32 和 GD32 都算)
|
||||||
if (chip === 'esp32') {
|
if (chip === 'esp32') {
|
||||||
try {
|
try {
|
||||||
var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||||
@@ -1038,6 +1090,17 @@ async function startUpload(chip) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message);
|
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 fileSize = file.size;
|
||||||
var body, url = getBaseUrl() + '/ota/upload';
|
var body, url = getBaseUrl() + '/ota/upload';
|
||||||
@@ -1069,9 +1132,16 @@ async function startUpload(chip) {
|
|||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
var pct = Math.round((e.loaded / e.total) * 100);
|
var pct = Math.round((e.loaded / e.total) * 100);
|
||||||
uploadProgress = pct;
|
uploadProgress = pct;
|
||||||
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
if (chip === 'gd32') {
|
||||||
var el = document.getElementById('bOTAProgress');
|
var overallPct = Math.min(45, Math.round(pct * 0.45));
|
||||||
if (el) el.textContent = pct + '%';
|
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||||
|
var el = document.getElementById('bOTAProgress');
|
||||||
|
if (el) el.textContent = overallPct + '%';
|
||||||
|
} else {
|
||||||
|
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||||
|
var el = document.getElementById('bOTAProgress');
|
||||||
|
if (el) el.textContent = pct + '%';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
xhr.addEventListener('load', function () {
|
xhr.addEventListener('load', function () {
|
||||||
@@ -1081,7 +1151,7 @@ async function startUpload(chip) {
|
|||||||
updateUploadButtonLabel();
|
updateUploadButtonLabel();
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
addLog('I', '上传', '固件上传完成!');
|
addLog('I', '上传', '固件上传完成!');
|
||||||
showToast('固件上传成功', 'success');
|
showToast(chip === 'gd32' ? '上传完成,GD32 OTA 已自动开始' : '固件上传成功', 'success');
|
||||||
setProgress(100, '上传完成');
|
setProgress(100, '上传完成');
|
||||||
if (chip === 'esp32') {
|
if (chip === 'esp32') {
|
||||||
markPhaseDone('esp32Ota');
|
markPhaseDone('esp32Ota');
|
||||||
@@ -1090,9 +1160,9 @@ async function startUpload(chip) {
|
|||||||
verifyEsp32Firmware();
|
verifyEsp32Firmware();
|
||||||
} else {
|
} else {
|
||||||
switchStageFlow('gd32');
|
switchStageFlow('gd32');
|
||||||
markPhaseDone('gd32Upload');
|
setActivePhase('gd32Ota');
|
||||||
setActivePhase('gd32Relay');
|
setProgress(45, '上传完成,校验并触发 GD32 OTA...');
|
||||||
setProgress(0, '等待触发转发...');
|
triggerGd32Relay();
|
||||||
}
|
}
|
||||||
} else if (xhr.status === 403) {
|
} else if (xhr.status === 403) {
|
||||||
addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试');
|
addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试');
|
||||||
@@ -1271,29 +1341,55 @@ async function restartEsp32() {
|
|||||||
showToast('重启失败', 'error');
|
showToast('重启失败', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ==================== 触发 OTA 中继 (真实状态驱动) ====================
|
// ==================== GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)====================
|
||||||
async function triggerRelay() {
|
async function triggerGd32Relay() {
|
||||||
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
|
var sha256 = window._gd32FileSha256;
|
||||||
showToast('正在触发 OTA 中继', 'success');
|
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 {
|
try {
|
||||||
var resp = await fetch(getBaseUrl() + '/ota/relay', {
|
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) {
|
if (resp.ok) {
|
||||||
addLog('I', 'OTA', 'OTA 中继已触发,等待 GD32 响应...');
|
try {
|
||||||
// 不再使用假的 setTimeout 推进阶段。
|
var data = JSON.parse(text);
|
||||||
// pollStatus 的 updateSystemStatusUi 会从 ota.relay_state
|
if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256);
|
||||||
// 自动更新 stage flow 和 header 状态。
|
} catch (_) {}
|
||||||
showToast('中继已触发,等待设备响应', 'success');
|
addLog('I', 'OTA', 'GD32 OTA 已触发,等待设备响应...');
|
||||||
|
showToast('GD32 OTA 已触发', 'success');
|
||||||
} else {
|
} else {
|
||||||
addLog('E', 'OTA', '触发失败: HTTP ' + resp.status);
|
// 尝试解析错误响应中的 device SHA
|
||||||
showToast('触发失败', 'error');
|
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) {
|
} catch (e) {
|
||||||
addLog('E', 'OTA', '触发失败: ' + e.message);
|
addLog('E', 'OTA', 'GD32 触发失败: ' + e.message);
|
||||||
showToast('触发失败', 'error');
|
showToast('GD32 触发失败', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ====================
|
||||||
|
async function triggerRelay() {
|
||||||
|
// 转发到统一入口:自动使用最近一次上传的 GD32 文件 SHA
|
||||||
|
await triggerGd32Relay();
|
||||||
|
}
|
||||||
// ==================== 轮询状态 ====================
|
// ==================== 轮询状态 ====================
|
||||||
var consecutivePollFailures = 0;
|
var consecutivePollFailures = 0;
|
||||||
var statusTimerId = null;
|
var statusTimerId = null;
|
||||||
@@ -1392,9 +1488,11 @@ async function pollStatus() {
|
|||||||
updateSystemStatusUi(data);
|
updateSystemStatusUi(data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 次无响应,正在诊断网络...');
|
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||||
var diag = await diagnoseNetwork();
|
var diag = await diagnoseNetwork();
|
||||||
addLog('W', '诊断', diag);
|
addLog('W', '诊断', diag);
|
||||||
@@ -1426,6 +1524,19 @@ async function pollStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
startPolling();
|
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 实时日志拉取 (增量) ====================
|
// ==================== ESP32 实时日志拉取 (增量) ====================
|
||||||
var lastLogTimestamp = 0;
|
var lastLogTimestamp = 0;
|
||||||
async function pollLogs() {
|
async function pollLogs() {
|
||||||
@@ -1463,4 +1574,3 @@ setTimeout(function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
|||||||
+408
-96
@@ -15,6 +15,15 @@
|
|||||||
const BLE_OTA_NOTIFY_FINISH = 0xB1;
|
const BLE_OTA_NOTIFY_FINISH = 0xB1;
|
||||||
const BLE_OTA_NOTIFY_SHA256 = 0xC1;
|
const BLE_OTA_NOTIFY_SHA256 = 0xC1;
|
||||||
const BLE_OTA_ACK_WINDOW = 32;
|
const BLE_OTA_ACK_WINDOW = 32;
|
||||||
|
const BLE_OTA_FINISH_ESP32_DONE = 0x02;
|
||||||
|
const BLE_OTA_FINISH_REBOOT_NOW = 0x07;
|
||||||
|
const BLE_OTA_FINISH_GD32_RELAY_START = 0x10;
|
||||||
|
const BLE_OTA_FINISH_GD32_RELAY_DONE = 0x11;
|
||||||
|
const BLE_OTA_FINISH_ERR_FINALIZE = 0x81;
|
||||||
|
const BLE_OTA_FINISH_ERR_MUTEX = 0x83;
|
||||||
|
const BLE_OTA_FINISH_ERR_GD32_SIZE = 0x84;
|
||||||
|
const BLE_OTA_FINISH_ERR_SHA_MISMATCH = 0x85;
|
||||||
|
const BLE_OTA_FINISH_ERR_GD32_RELAY = 0x86;
|
||||||
|
|
||||||
const TARGETS = {
|
const TARGETS = {
|
||||||
gd32: {
|
gd32: {
|
||||||
@@ -63,12 +72,17 @@
|
|||||||
lastAckSeq: -1,
|
lastAckSeq: -1,
|
||||||
lastAckBytes: 0,
|
lastAckBytes: 0,
|
||||||
ackWaiter: null,
|
ackWaiter: null,
|
||||||
|
otaResultWaiter: null,
|
||||||
|
otaRunId: 0,
|
||||||
|
lastEsp32DoneSession: null,
|
||||||
fileSha256: null,
|
fileSha256: null,
|
||||||
deviceSha256: null,
|
deviceSha256: null,
|
||||||
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
|
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
|
||||||
reconnecting: false, // 自动重连中
|
reconnecting: false, // 自动重连中
|
||||||
reconnectAttempts: 0, // 重连尝试次数
|
reconnectAttempts: 0, // 重连尝试次数
|
||||||
uploadTargetKey: null,
|
uploadTargetKey: null,
|
||||||
|
activeTotalSize: 0,
|
||||||
|
activePacketTotal: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -185,6 +199,71 @@
|
|||||||
setBleStageText('错误: ' + (detail || ''));
|
setBleStageText('错误: ' + (detail || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearOtaResultWaiter() {
|
||||||
|
if (state.otaResultWaiter) {
|
||||||
|
clearTimeout(state.otaResultWaiter.timer);
|
||||||
|
state.otaResultWaiter = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOtaResult(status, detail) {
|
||||||
|
if (!state.otaResultWaiter) return;
|
||||||
|
var waiter = state.otaResultWaiter;
|
||||||
|
state.otaResultWaiter = null;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
waiter.resolve({ status: status, detail: detail || {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectOtaResult(message, detail) {
|
||||||
|
if (!state.otaResultWaiter) return;
|
||||||
|
var waiter = state.otaResultWaiter;
|
||||||
|
state.otaResultWaiter = null;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
var err = new Error(message || 'OTA 收尾失败');
|
||||||
|
err.detail = detail || {};
|
||||||
|
waiter.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextOtaSessionId() {
|
||||||
|
state.otaRunId = (state.otaRunId + 1) & 0xFFFF;
|
||||||
|
if (state.otaRunId === 0) state.otaRunId = 1;
|
||||||
|
return state.otaRunId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishBelongsToWaiter(targetKey, value) {
|
||||||
|
if (!state.otaResultWaiter || state.otaResultWaiter.targetKey !== targetKey) return false;
|
||||||
|
return (value & 0xFFFF) === state.otaResultWaiter.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSessionId(value) {
|
||||||
|
return value & 0xFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCurrentWaiter(targetKey) {
|
||||||
|
return !!(state.otaResultWaiter && state.otaResultWaiter.targetKey === targetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForOtaResult(targetKey, sessionId, timeoutMs) {
|
||||||
|
clearOtaResultWaiter();
|
||||||
|
var runId = state.otaRunId;
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var timer = setTimeout(function() {
|
||||||
|
if (state.otaResultWaiter && state.otaResultWaiter.runId === runId) {
|
||||||
|
state.otaResultWaiter = null;
|
||||||
|
}
|
||||||
|
reject(new Error(targetKey === 'gd32' ? '等待 GD32 OTA 转发结果超时' : '等待 ESP32 OTA 收尾结果超时'));
|
||||||
|
}, timeoutMs);
|
||||||
|
state.otaResultWaiter = {
|
||||||
|
targetKey: targetKey,
|
||||||
|
sessionId: sessionId,
|
||||||
|
runId: runId,
|
||||||
|
timer: timer,
|
||||||
|
resolve: resolve,
|
||||||
|
reject: reject,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showBleVerifyInfo(frontSha, devSha, result) {
|
function showBleVerifyInfo(frontSha, devSha, result) {
|
||||||
var panel = document.getElementById('stageVerifyInfo');
|
var panel = document.getElementById('stageVerifyInfo');
|
||||||
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
|
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
|
||||||
@@ -237,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) {
|
function setReadyHint(html) {
|
||||||
const el = $('readyHint');
|
const el = $('readyHint');
|
||||||
if (el) el.innerHTML = html;
|
if (el) el.innerHTML = html;
|
||||||
@@ -364,6 +462,12 @@
|
|||||||
setConnectionState(true);
|
setConnectionState(true);
|
||||||
bleLog('BLE', 'GATT 服务与特征获取完成');
|
bleLog('BLE', 'GATT 服务与特征获取完成');
|
||||||
await enableNotifications();
|
await enableNotifications();
|
||||||
|
// ★ enableNotifications 内部已包含重试 + 主动断开逻辑;
|
||||||
|
// 若返回时仍为 false,说明连接半死,UI 已被断开事件重置,不要继续推进到 Stage 3
|
||||||
|
if (!state.notificationsEnabled) {
|
||||||
|
bleLog('BLE', '连接就绪但 DATA_OUT 通知开启失败,等待用户重连', 'E');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Mark stages 1-2 as done
|
// Mark stages 1-2 as done
|
||||||
setBleStage(3, '', null);
|
setBleStage(3, '', null);
|
||||||
setBleStageText('BLE 连接就绪,请选择固件文件');
|
setBleStageText('BLE 连接就绪,请选择固件文件');
|
||||||
@@ -440,10 +544,33 @@
|
|||||||
const ackCmd = bytes[1];
|
const ackCmd = bytes[1];
|
||||||
const ackSeq = bytes[2] | (bytes[3] << 8);
|
const ackSeq = bytes[2] | (bytes[3] << 8);
|
||||||
const ackBytes = (bytes[4]) | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24);
|
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.lastAckCmd = ackCmd;
|
||||||
state.lastAckSeq = ackSeq;
|
state.lastAckSeq = ackSeq;
|
||||||
state.lastAckBytes = ackBytes >>> 0;
|
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}`;
|
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);
|
bleLog('ACK', ackText);
|
||||||
|
|
||||||
@@ -479,54 +606,123 @@
|
|||||||
const stage = bytes[1];
|
const stage = bytes[1];
|
||||||
const value = (bytes[2]) | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24);
|
const value = (bytes[2]) | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24);
|
||||||
const finishMap = {
|
const finishMap = {
|
||||||
0x02: '[FINISH] ESP32 OTA 收尾完成',
|
[BLE_OTA_FINISH_ESP32_DONE]: '[FINISH] ESP32 OTA 收尾完成',
|
||||||
0x07: '[FINISH] ESP32 即将重启',
|
[BLE_OTA_FINISH_REBOOT_NOW]: '[FINISH] ESP32 即将重启',
|
||||||
0x81: `[FINISH] ESP32 OTA 收尾失败: err=${value}`,
|
[BLE_OTA_FINISH_GD32_RELAY_START]: '[FINISH] GD32 OTA 转发已开始',
|
||||||
0x83: '[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝',
|
[BLE_OTA_FINISH_GD32_RELAY_DONE]: '[FINISH] GD32 OTA 转发完成',
|
||||||
0x84: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`,
|
[BLE_OTA_FINISH_ERR_FINALIZE]: `[FINISH] ESP32 OTA 收尾失败: err=${value}`,
|
||||||
0x85: '[FINISH] ESP32 OTA SHA-256 校验失败',
|
[BLE_OTA_FINISH_ERR_MUTEX]: '[FINISH] OTA 被其他通道占用,互斥拒绝',
|
||||||
|
[BLE_OTA_FINISH_ERR_GD32_SIZE]: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`,
|
||||||
|
[BLE_OTA_FINISH_ERR_SHA_MISMATCH]: '[FINISH] OTA SHA-256 校验失败',
|
||||||
|
[BLE_OTA_FINISH_ERR_GD32_RELAY]: `[FINISH] GD32 OTA 转发失败: result=${value >> 16}`,
|
||||||
};
|
};
|
||||||
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
|
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
|
||||||
bleLog('FINISH', finishText);
|
bleLog('FINISH', finishText);
|
||||||
const finalizeStage = TARGETS.esp32.finalizeStage;
|
const finalizeStage = TARGETS.esp32.finalizeStage;
|
||||||
|
const resultCode = (value >>> 16);
|
||||||
|
|
||||||
|
if (stage === BLE_OTA_FINISH_GD32_RELAY_START) {
|
||||||
|
if (!finishBelongsToWaiter('gd32', value)) return;
|
||||||
|
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
|
||||||
|
if (typeof setActivePhase === 'function') setActivePhase('gd32Ota');
|
||||||
|
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(1);
|
||||||
|
setProgress(50, '[GD32] OTA 转发已开始,正在认证握手...');
|
||||||
|
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '等待设备端上报...', { text: '缓存写入完成,正在通过 SPI 转发' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === BLE_OTA_FINISH_GD32_RELAY_DONE) {
|
||||||
|
if (!finishBelongsToWaiter('gd32', value)) return;
|
||||||
|
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
|
||||||
|
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(5);
|
||||||
|
if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota');
|
||||||
|
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
|
||||||
|
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';
|
||||||
|
if (gd32Text) gd32Text.textContent = 'GD32 刷写完成';
|
||||||
|
state.lastUploadStatus = 'success';
|
||||||
|
showToast('GD32 OTA 刷写完成', 'success');
|
||||||
|
resolveOtaResult('success', { stage: stage, value: value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === BLE_OTA_FINISH_ERR_GD32_RELAY) {
|
||||||
|
if (!finishBelongsToWaiter('gd32', value)) return;
|
||||||
|
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
|
||||||
|
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(255);
|
||||||
|
setProgress(0, '[GD32] OTA 转发失败');
|
||||||
|
state.lastUploadStatus = 'failed';
|
||||||
|
showToast('GD32 OTA 转发失败', 'error');
|
||||||
|
showBleVerifyInfo(state.fileSha256 || '-', 'GD32 未完成转发', { text: 'GD32 OTA 转发失败: ' + resultCode, err: true });
|
||||||
|
rejectOtaResult('GD32 OTA 转发失败: ' + resultCode, { stage: stage, value: value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update BLE stage flow based on FINISH notifications
|
// Update BLE stage flow based on FINISH notifications
|
||||||
if (stage === 0x02) {
|
if (stage === BLE_OTA_FINISH_ESP32_DONE) {
|
||||||
|
if (!finishBelongsToWaiter('esp32', value)) return;
|
||||||
|
state.lastEsp32DoneSession = finishSessionId(value);
|
||||||
setBleStage(finalizeStage, '收尾完成', null);
|
setBleStage(finalizeStage, '收尾完成', null);
|
||||||
setBleStageText('OTA 收尾完成,等待重启');
|
setBleStageText('OTA 收尾完成,等待重启');
|
||||||
} else if (stage === 0x07) {
|
state.lastUploadStatus = 'success';
|
||||||
|
setProgress(100, '[ESP32] OTA 收尾完成,等待重启');
|
||||||
|
resolveOtaResult('success', { stage: stage, value: value });
|
||||||
|
} else if (stage === BLE_OTA_FINISH_REBOOT_NOW) {
|
||||||
|
if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return;
|
||||||
state.expectedReboot = true;
|
state.expectedReboot = true;
|
||||||
setBleStage(finalizeStage, '设备重启', null);
|
setBleStage(finalizeStage, '设备重启', null);
|
||||||
setBleStageText('ESP32 即将重启');
|
// ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝
|
||||||
var bar = document.getElementById('stageStatusBar');
|
var rebootBar = document.getElementById('stageStatusBar');
|
||||||
if (bar) bar.className = 'stage-status-bar ok';
|
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 提示(用户可见,不只是在日志里)
|
// OTA 结果 toast 提示(用户可见,不只是在日志里)
|
||||||
if (stage === 0x83) {
|
if (stage === BLE_OTA_FINISH_ERR_MUTEX) {
|
||||||
showToast('ESP32 OTA 被其他通道占用,请稍后重试', 'error');
|
if (hasCurrentWaiter('esp32') && !finishBelongsToWaiter('esp32', value)) return;
|
||||||
|
if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return;
|
||||||
|
showToast('OTA 被其他通道占用,请稍后重试', 'error');
|
||||||
setProgress(0, 'OTA 被其他通道占用');
|
setProgress(0, 'OTA 被其他通道占用');
|
||||||
state.lastUploadStatus = 'failed';
|
state.lastUploadStatus = 'failed';
|
||||||
setBleStageError('OTA 被其他通道占用');
|
setBleStageError('OTA 被其他通道占用');
|
||||||
} else if (stage === 0x84) {
|
rejectOtaResult('OTA 被其他通道占用', { stage: stage, value: value });
|
||||||
|
} else if (stage === BLE_OTA_FINISH_ERR_GD32_SIZE) {
|
||||||
|
if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return;
|
||||||
showToast(`GD32 固件超出分区大小 (${formatBytes(value)},上限 216KB)`, 'error');
|
showToast(`GD32 固件超出分区大小 (${formatBytes(value)},上限 216KB)`, 'error');
|
||||||
setProgress(0, 'GD32 固件太大,无法刷写');
|
setProgress(0, 'GD32 固件太大,无法刷写');
|
||||||
state.lastUploadStatus = 'failed';
|
state.lastUploadStatus = 'failed';
|
||||||
setBleStageError('固件超出分区大小');
|
setBleStageError('固件超出分区大小');
|
||||||
} else if (stage === 0x81) {
|
rejectOtaResult('GD32 固件超出分区大小', { stage: stage, value: value });
|
||||||
|
} else if (stage === BLE_OTA_FINISH_ERR_FINALIZE) {
|
||||||
|
if (!finishBelongsToWaiter('esp32', value)) return;
|
||||||
showToast('ESP32 OTA 刷写失败', 'error');
|
showToast('ESP32 OTA 刷写失败', 'error');
|
||||||
setProgress(0, '刷写失败');
|
setProgress(0, '刷写失败');
|
||||||
state.lastUploadStatus = 'failed';
|
state.lastUploadStatus = 'failed';
|
||||||
setBleStageError('OTA 刷写失败');
|
setBleStageError('OTA 刷写失败');
|
||||||
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true });
|
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true });
|
||||||
} else if (stage === 0x85) {
|
rejectOtaResult('ESP32 OTA 刷写失败', { stage: stage, value: value });
|
||||||
showToast('ESP32 OTA 校验失败,未触发重启', 'error');
|
} else if (stage === BLE_OTA_FINISH_ERR_SHA_MISMATCH) {
|
||||||
|
const isGd32Waiter = hasCurrentWaiter('gd32');
|
||||||
|
const targetKey = isGd32Waiter ? 'gd32' : 'esp32';
|
||||||
|
if (!finishBelongsToWaiter(targetKey, value)) return;
|
||||||
|
const msg = isGd32Waiter
|
||||||
|
? 'GD32 缓存 SHA-256 校验失败,未进入 SPI 转发'
|
||||||
|
: 'ESP32 OTA 校验失败,未触发重启';
|
||||||
|
showToast(msg, 'error');
|
||||||
setProgress(0, '设备端 SHA-256 校验失败');
|
setProgress(0, '设备端 SHA-256 校验失败');
|
||||||
state.lastUploadStatus = 'failed';
|
state.lastUploadStatus = 'failed';
|
||||||
setBleStage(5, 'SHA-256 ✗', null);
|
setBleStage(flowTarget().verifyStage, 'SHA-256 ✗', null);
|
||||||
setBleStageError('设备端 SHA-256 校验失败');
|
setBleStageError('设备端 SHA-256 校验失败');
|
||||||
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '设备端校验失败,已中止 OTA', err: true });
|
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: msg, err: true });
|
||||||
} else if (stage === 0x07) {
|
rejectOtaResult(msg, { stage: stage, value: value });
|
||||||
|
} else if (stage === BLE_OTA_FINISH_REBOOT_NOW) {
|
||||||
|
if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return;
|
||||||
showToast('ESP32 OTA 刷写完成,等待重启', 'success');
|
showToast('ESP32 OTA 刷写完成,等待重启', 'success');
|
||||||
// If device SHA-256 hasn't arrived yet, show what we have
|
// If device SHA-256 hasn't arrived yet, show what we have
|
||||||
var devSha = state.deviceSha256 || '设备未上报';
|
var devSha = state.deviceSha256 || '设备未上报';
|
||||||
@@ -556,6 +752,14 @@
|
|||||||
|
|
||||||
// SHA-256 verification from device (after transfer complete)
|
// SHA-256 verification from device (after transfer complete)
|
||||||
if (bytes[0] === BLE_OTA_NOTIFY_SHA256 && bytes.length >= 33) {
|
if (bytes[0] === BLE_OTA_NOTIFY_SHA256 && bytes.length >= 33) {
|
||||||
|
if (bytes.length >= 35) {
|
||||||
|
var shaSessionId = bytes[33] | (bytes[34] << 8);
|
||||||
|
if (!state.otaResultWaiter ||
|
||||||
|
state.otaResultWaiter.sessionId !== shaSessionId) {
|
||||||
|
bleLog('VERIFY', '忽略非本轮 SHA-256 通知: session=' + shaSessionId, 'W');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
var devSha = Array.from(bytes.slice(1, 33)).map(function(b) {
|
var devSha = Array.from(bytes.slice(1, 33)).map(function(b) {
|
||||||
return b.toString(16).padStart(2, '0');
|
return b.toString(16).padStart(2, '0');
|
||||||
}).join('').toUpperCase();
|
}).join('').toUpperCase();
|
||||||
@@ -569,7 +773,14 @@
|
|||||||
bleLog('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I');
|
bleLog('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I');
|
||||||
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
|
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
|
||||||
setBleStage(verifyStage, 'SHA-256 ✓', null);
|
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) {
|
} else if (frontSha) {
|
||||||
bleLog('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E');
|
bleLog('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E');
|
||||||
showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true });
|
showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true });
|
||||||
@@ -704,18 +915,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enableNotifications() {
|
async function enableNotifications() {
|
||||||
try {
|
// 修复偶发的 GATT CCC 竞态:startNotifications 与 NimBLE gap 完成握手存在窗口冲突。
|
||||||
await state.dataOutChar.startNotifications();
|
// 现象:Chrome 报 "GATT operation failed for unknown reason",NimBLE 实际返回 EBUSY/ENOTCONN。
|
||||||
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
|
// 处理:内部退避重试 + 最终失败时主动断开,让上层进入干净的重连路径,而不是带着半死连接去刷写。
|
||||||
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
|
const MAX_RETRY = 3;
|
||||||
state.notificationsEnabled = true;
|
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
||||||
setConnectionState(!!state.server);
|
try {
|
||||||
bleLog('BLE', 'DATA_OUT 通知已自动开启');
|
await state.dataOutChar.startNotifications();
|
||||||
} catch (err) {
|
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
|
||||||
state.notificationsEnabled = false;
|
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
|
||||||
setConnectionState(!!state.server);
|
state.notificationsEnabled = true;
|
||||||
bleLog('BLE', `自动开启 DATA_OUT 通知失败: ${err.message}`, 'E');
|
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) {
|
function updateFileInfo(file) {
|
||||||
@@ -735,7 +964,63 @@
|
|||||||
else els.fileInfo.classList.remove('show');
|
else els.fileInfo.classList.remove('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA-256 computation and display
|
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 convertHexFileToBin
|
||||||
|
if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') {
|
||||||
|
return convertHexFileToBin(file).then(function(result) {
|
||||||
|
if (!result.converted) {
|
||||||
|
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
|
||||||
|
finalizeFileInfo(file);
|
||||||
|
computeFileSha256(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.selectedFile = result.file;
|
||||||
|
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
|
||||||
|
bleLog('FILE', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name + ' (基地址 ' + baseHex + ', ' + formatBytes(result.file.size) + ')');
|
||||||
|
showToast('HEX 已自动转换为 BIN', 'success');
|
||||||
|
// 更新 UI 显示转换后的信息
|
||||||
|
els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
|
||||||
|
els.fileType.textContent = 'Binary (HEX转换)';
|
||||||
|
els.fileSize.textContent = formatBytes(result.file.size);
|
||||||
|
var newPackets = Math.ceil(result.file.size / payloadSize);
|
||||||
|
els.packetCount.textContent = String(newPackets);
|
||||||
|
finalizeFileInfo(result.file);
|
||||||
|
computeFileSha256(result.file);
|
||||||
|
}).catch(function(err) {
|
||||||
|
bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E');
|
||||||
|
showToast('HEX 文件解析失败: ' + err.message, 'error');
|
||||||
|
state.selectedFile = null;
|
||||||
|
state.fileSha256 = null;
|
||||||
|
if (els.fileInfo) els.fileInfo.classList.remove('show');
|
||||||
|
setConnectionState(!!state.server);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 HEX 文件直接完成
|
||||||
|
finalizeFileInfo(file);
|
||||||
|
computeFileSha256(file);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeFileInfo(file) {
|
||||||
|
setConnectionState(!!state.server);
|
||||||
|
if (file) {
|
||||||
|
setProgress(0, `已选择 ${file.name}`);
|
||||||
|
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
|
||||||
|
if (state.server) {
|
||||||
|
setBleStage(3, '计算哈希...', 'active');
|
||||||
|
setBleStageText('正在计算文件 SHA-256...');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setProgress(0, '等待选择文件');
|
||||||
|
if (state.server) {
|
||||||
|
resetBleStages();
|
||||||
|
setBleStage(3, '', null);
|
||||||
|
setBleStageText('BLE 连接就绪,请选择固件文件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFileSha256(file) {
|
||||||
var shaElem = document.getElementById('fileSha256');
|
var shaElem = document.getElementById('fileSha256');
|
||||||
if (file && shaElem) {
|
if (file && shaElem) {
|
||||||
shaElem.textContent = '计算中...';
|
shaElem.textContent = '计算中...';
|
||||||
@@ -761,31 +1046,13 @@
|
|||||||
shaElem.textContent = '-';
|
shaElem.textContent = '-';
|
||||||
state.fileSha256 = null;
|
state.fileSha256 = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnectionState(!!state.server);
|
|
||||||
if (file) {
|
|
||||||
setProgress(0, `已选择 ${file.name}`);
|
|
||||||
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
|
|
||||||
// If connected, show stage 3 as active
|
|
||||||
if (state.server) {
|
|
||||||
setBleStage(3, '计算哈希...', 'active');
|
|
||||||
setBleStageText('正在计算文件 SHA-256...');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setProgress(0, '等待选择文件');
|
|
||||||
if (state.server) {
|
|
||||||
resetBleStages();
|
|
||||||
setBleStage(3, '', null);
|
|
||||||
setBleStageText('BLE 连接就绪,请选择固件文件');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSelectedFileFromInput() {
|
async function syncSelectedFileFromInput() {
|
||||||
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
|
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
|
||||||
if (state.selectedFileHandle) return;
|
if (state.selectedFileHandle) return;
|
||||||
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
|
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
|
||||||
updateFileInfo(file);
|
await updateFileInfo(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 File System Access API 选择文件(支持热重载)
|
// 通过 File System Access API 选择文件(支持热重载)
|
||||||
@@ -803,7 +1070,7 @@
|
|||||||
const handle = handles[0];
|
const handle = handles[0];
|
||||||
const file = await handle.getFile();
|
const file = await handle.getFile();
|
||||||
state.selectedFileHandle = handle;
|
state.selectedFileHandle = handle;
|
||||||
updateFileInfo(file);
|
await updateFileInfo(file);
|
||||||
bleLog('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
|
bleLog('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -856,13 +1123,14 @@
|
|||||||
if (state.selectedFileHandle) {
|
if (state.selectedFileHandle) {
|
||||||
try {
|
try {
|
||||||
const freshFile = await state.selectedFileHandle.getFile();
|
const freshFile = await state.selectedFileHandle.getFile();
|
||||||
updateFileInfo(freshFile);
|
await updateFileInfo(freshFile);
|
||||||
bleLog('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
|
var actualFile = state.selectedFile || freshFile;
|
||||||
|
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
syncSelectedFileFromInput();
|
await syncSelectedFileFromInput();
|
||||||
}
|
}
|
||||||
if (!state.selectedFile || !state.dataInChar) return false;
|
if (!state.selectedFile || !state.dataInChar) return false;
|
||||||
|
|
||||||
@@ -874,12 +1142,20 @@
|
|||||||
state.abortRequested = false;
|
state.abortRequested = false;
|
||||||
state.uploading = true;
|
state.uploading = true;
|
||||||
state.lastUploadStatus = 'uploading';
|
state.lastUploadStatus = 'uploading';
|
||||||
|
const otaSessionId = nextOtaSessionId();
|
||||||
|
clearOtaResultWaiter();
|
||||||
|
if (typeof setActivePhase === 'function') {
|
||||||
|
setActivePhase(cfg.label === 'GD32' ? 'gd32Ota' : 'esp32Ota');
|
||||||
|
}
|
||||||
switchStageFlow('ble');
|
switchStageFlow('ble');
|
||||||
state.lastAckCmd = null;
|
state.lastAckCmd = null;
|
||||||
state.lastAckSeq = -1;
|
state.lastAckSeq = -1;
|
||||||
state.lastAckBytes = 0;
|
state.lastAckBytes = 0;
|
||||||
state.ackGeneration = 0;
|
state.ackGeneration = 0;
|
||||||
state.deviceSha256 = null;
|
state.deviceSha256 = null;
|
||||||
|
state.lastEsp32DoneSession = null;
|
||||||
|
state.activeTotalSize = 0;
|
||||||
|
state.activePacketTotal = 0;
|
||||||
if (state.ackWaiter) {
|
if (state.ackWaiter) {
|
||||||
clearTimeout(state.ackWaiter.timer);
|
clearTimeout(state.ackWaiter.timer);
|
||||||
state.ackWaiter = null;
|
state.ackWaiter = null;
|
||||||
@@ -929,6 +1205,11 @@
|
|||||||
|
|
||||||
setBleStage(4, '写入中...', null);
|
setBleStage(4, '写入中...', null);
|
||||||
setBleStageText(`正在写入 ${packetTotal} 包数据...`);
|
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}`);
|
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;
|
const MAX_WINDOW_RETRIES = 3;
|
||||||
@@ -940,6 +1221,7 @@
|
|||||||
const endSeq = Math.min(windowStart + BLE_OTA_ACK_WINDOW, packetTotal) - 1;
|
const endSeq = Math.min(windowStart + BLE_OTA_ACK_WINDOW, packetTotal) - 1;
|
||||||
|
|
||||||
// 流水线发送:不逐包 await,减少 BLE 栈 per-packet 开销
|
// 流水线发送:不逐包 await,减少 BLE 栈 per-packet 开销
|
||||||
|
// ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动
|
||||||
for (let seq = windowStart; seq <= endSeq; seq++) {
|
for (let seq = windowStart; seq <= endSeq; seq++) {
|
||||||
const start = seq * payloadSize;
|
const start = seq * payloadSize;
|
||||||
const end = Math.min(start + payloadSize, totalSize);
|
const end = Math.min(start + payloadSize, totalSize);
|
||||||
@@ -971,12 +1253,6 @@
|
|||||||
state.dataInChar.writeValueWithoutResponse(packet); // fire-and-forget
|
state.dataInChar.writeValueWithoutResponse(packet); // fire-and-forget
|
||||||
}
|
}
|
||||||
bleWriteTs = performance.now();
|
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++;
|
state.ackGeneration++;
|
||||||
@@ -1055,9 +1331,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stage 4 done
|
// 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);
|
setBleStage(4, '完成', null);
|
||||||
setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
|
setProgress(Math.min(phaseBase, stage4Share), `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
|
||||||
state.lastUploadStatus = 'success';
|
|
||||||
|
|
||||||
// Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对
|
// Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对
|
||||||
if (cfg.label === 'ESP32') {
|
if (cfg.label === 'ESP32') {
|
||||||
@@ -1067,9 +1350,12 @@
|
|||||||
for (let i = 0; i < 32; i++) {
|
for (let i = 0; i < 32; i++) {
|
||||||
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
|
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
|
||||||
}
|
}
|
||||||
const trigger = new Uint8Array(33);
|
const trigger = new Uint8Array(35);
|
||||||
trigger[0] = 0xF8;
|
trigger[0] = 0xF8;
|
||||||
trigger.set(frontShaBytes, 1);
|
trigger.set(frontShaBytes, 1);
|
||||||
|
trigger[33] = otaSessionId & 0xFF;
|
||||||
|
trigger[34] = (otaSessionId >> 8) & 0xFF;
|
||||||
|
const resultPromise = waitForOtaResult('esp32', otaSessionId, 45000);
|
||||||
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' });
|
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' });
|
||||||
setBleStage(5, '发送校验...', 'active');
|
setBleStage(5, '发送校验...', 'active');
|
||||||
setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...');
|
setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...');
|
||||||
@@ -1077,6 +1363,7 @@
|
|||||||
await state.ctrlChar.writeValue(trigger);
|
await state.ctrlChar.writeValue(trigger);
|
||||||
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验');
|
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
clearOtaResultWaiter();
|
||||||
state.lastUploadStatus = 'failed';
|
state.lastUploadStatus = 'failed';
|
||||||
setProgress(0, `0xF8 发送失败: ${e.message}`);
|
setProgress(0, `0xF8 发送失败: ${e.message}`);
|
||||||
bleLog('OTA', `0xF8 发送失败: ${e.message}`, 'E');
|
bleLog('OTA', `0xF8 发送失败: ${e.message}`, 'E');
|
||||||
@@ -1086,20 +1373,44 @@
|
|||||||
setBleStage(5, '等待设备端校验', null);
|
setBleStage(5, '等待设备端校验', null);
|
||||||
setBleStage(6, '设备端收尾中', 'active');
|
setBleStage(6, '设备端收尾中', 'active');
|
||||||
setBleStageText('0xF8 已发,等待设备端完成 OTA 收尾...');
|
setBleStageText('0xF8 已发,等待设备端完成 OTA 收尾...');
|
||||||
|
try {
|
||||||
|
await resultPromise;
|
||||||
|
} catch (e) {
|
||||||
|
state.lastUploadStatus = 'failed';
|
||||||
|
setProgress(0, e.message);
|
||||||
|
setBleStageError(e.message);
|
||||||
|
clearOtaResultWaiter();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GD32: send trigger command
|
// GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result.
|
||||||
|
const frontSha = fileSha256.toUpperCase();
|
||||||
|
const frontShaBytes = new Uint8Array(32);
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
state.deviceSha256 = null;
|
||||||
setBleStage(5, '发送触发...', 'active');
|
setBleStage(5, '发送触发...', 'active');
|
||||||
setBleStageText('正在发送触发命令...');
|
setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...');
|
||||||
|
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待 GD32 缓存校验' });
|
||||||
await sleep(30);
|
await sleep(30);
|
||||||
const cmdHex = els.ctrlHex.value || cfg.ctrlHex;
|
const cmdHex = els.ctrlHex.value || cfg.ctrlHex;
|
||||||
const cmd = hexToBytes(cmdHex);
|
const cmdBase = hexToBytes(cmdHex);
|
||||||
|
const cmd = new Uint8Array(cmdBase.length + 32 + 2);
|
||||||
|
cmd.set(cmdBase, 0);
|
||||||
|
cmd.set(frontShaBytes, cmdBase.length);
|
||||||
|
cmd[cmdBase.length + 32] = otaSessionId & 0xFF;
|
||||||
|
cmd[cmdBase.length + 33] = (otaSessionId >> 8) & 0xFF;
|
||||||
|
const resultPromise = waitForOtaResult('gd32', otaSessionId, 120000);
|
||||||
try {
|
try {
|
||||||
await state.ctrlChar.writeValue(cmd);
|
await state.ctrlChar.writeValue(cmd);
|
||||||
setBleStage(5, '已触发', null);
|
setBleStage(5, '已触发', null);
|
||||||
bleLog('OTA', `触发命令已发送: ${toHex(cmd)}`);
|
setBleStage(6, '等待设备端校验', 'active');
|
||||||
|
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
clearOtaResultWaiter();
|
||||||
state.lastUploadStatus = 'failed';
|
state.lastUploadStatus = 'failed';
|
||||||
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
|
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
|
||||||
bleLog('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
|
bleLog('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
|
||||||
@@ -1107,24 +1418,18 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 6: SHA-256 verification (GD32 mode)
|
setBleStageText('GD32 缓存校验已发起,等待 SPI 转发流程...');
|
||||||
setBleStage(6, 'GD32 无回传', null);
|
try {
|
||||||
setBleStageText('GD32 OTA 中继已触发');
|
await resultPromise;
|
||||||
state.deviceSha256 = null;
|
} catch (e) {
|
||||||
showBleVerifyInfo(fileSha256.toUpperCase(), 'GD32 中继不回传 SHA', { text: 'GD32 模式无设备端校验(仅前端 SHA-256)' });
|
state.lastUploadStatus = 'failed';
|
||||||
|
setProgress(0, e.message);
|
||||||
bleLog('OTA', `✅ 固件刷写完成!`);
|
setBleStageError(e.message);
|
||||||
bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`);
|
clearOtaResultWaiter();
|
||||||
|
return false;
|
||||||
// Mark all stages done
|
|
||||||
for (var si = 0; si < bleStageIds.length; si++) {
|
|
||||||
var el = document.getElementById(bleStageIds[si]);
|
|
||||||
if (el && el.className !== 'stage-node err') el.className = 'stage-node done';
|
|
||||||
}
|
}
|
||||||
var bar = document.getElementById('stageStatusBar');
|
bleLog('OTA', `✅ GD32 OTA 刷写完成!`);
|
||||||
if (bar) bar.className = 'stage-status-bar ok';
|
bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`);
|
||||||
setBleStageText('刷写完成');
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.lastUploadStatus = 'failed';
|
state.lastUploadStatus = 'failed';
|
||||||
@@ -1140,6 +1445,9 @@
|
|||||||
clearTimeout(state.ackWaiter.timer);
|
clearTimeout(state.ackWaiter.timer);
|
||||||
state.ackWaiter = null;
|
state.ackWaiter = null;
|
||||||
}
|
}
|
||||||
|
if (state.lastUploadStatus === 'failed') {
|
||||||
|
clearOtaResultWaiter();
|
||||||
|
}
|
||||||
state.uploading = false;
|
state.uploading = false;
|
||||||
state.uploadTargetKey = null;
|
state.uploadTargetKey = null;
|
||||||
setConnectionState(!!state.server);
|
setConnectionState(!!state.server);
|
||||||
@@ -1155,19 +1463,22 @@
|
|||||||
// (name/size/lastModified 任一不同即视为变化)
|
// (name/size/lastModified 任一不同即视为变化)
|
||||||
// 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志
|
// 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志
|
||||||
const cached = state.selectedFile;
|
const cached = state.selectedFile;
|
||||||
|
// HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex,
|
||||||
|
// 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换
|
||||||
|
const isHex = freshFile.name.toLowerCase().endsWith('.hex');
|
||||||
const changed = !cached ||
|
const changed = !cached ||
|
||||||
cached.name !== freshFile.name ||
|
(isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) ||
|
||||||
cached.size !== freshFile.size ||
|
|
||||||
cached.lastModified !== freshFile.lastModified;
|
cached.lastModified !== freshFile.lastModified;
|
||||||
if (changed) {
|
if (changed) {
|
||||||
updateFileInfo(freshFile);
|
await updateFileInfo(freshFile);
|
||||||
bleLog('FILE', '已重新读取本地文件(已变更): ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
|
var actualFile = state.selectedFile || freshFile;
|
||||||
|
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
syncSelectedFileFromInput();
|
await syncSelectedFileFromInput();
|
||||||
}
|
}
|
||||||
if (!state.selectedFile || !state.server) return;
|
if (!state.selectedFile || !state.server) return;
|
||||||
|
|
||||||
@@ -1180,11 +1491,12 @@
|
|||||||
bleLog('AUTO', '一键刷写中止', 'W');
|
bleLog('AUTO', '一键刷写中止', 'W');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setProgress(100, `[${cfg.label}] 刷写完成`);
|
|
||||||
if (cfg.label === 'ESP32') {
|
if (cfg.label === 'ESP32') {
|
||||||
|
setProgress(100, '[ESP32] OTA 完成,等待设备重启');
|
||||||
bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启');
|
bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启');
|
||||||
} else {
|
} else {
|
||||||
bleLog('AUTO', 'GD32 OTA 中继已触发');
|
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
|
||||||
|
bleLog('AUTO', 'GD32 OTA 完成');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
|
bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
|
||||||
@@ -1226,10 +1538,10 @@
|
|||||||
},
|
},
|
||||||
disconnect: disconnectBle,
|
disconnect: disconnectBle,
|
||||||
showToast: showToast,
|
showToast: showToast,
|
||||||
notifyFileChange: function(file, handle) {
|
notifyFileChange: async function(file, handle) {
|
||||||
if (file) {
|
if (file) {
|
||||||
if (handle) state.selectedFileHandle = handle;
|
if (handle) state.selectedFileHandle = handle;
|
||||||
updateFileInfo(file);
|
await updateFileInfo(file);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearFile: clearSelectedFile,
|
clearFile: clearSelectedFile,
|
||||||
|
|||||||
+14
-14
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ESP32 OTA 中继控制台</title>
|
<title>ESP32 OTA 中继控制台</title>
|
||||||
<link rel="stylesheet" href="style.css?v=20260614c">
|
<link rel="stylesheet" href="style.css?v=20260617a">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
<span class="pill" id="pillVer">v--</span>
|
<span class="pill" id="pillVer">v--</span>
|
||||||
<span class="pill" id="pillIP">--</span>
|
<span class="pill" id="pillIP">--</span>
|
||||||
<span class="pill" id="pillRSSI">--</span>
|
<span class="pill" id="pillRSSI">--</span>
|
||||||
<span class="pill idle" id="pillRelay"><span class="indicator"></span><span id="pillRelayTxt">待机</span></span>
|
|
||||||
<span class="pill" id="pillUptime">运行 --</span>
|
<span class="pill" id="pillUptime">运行 --</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-sub">
|
<div class="header-sub">
|
||||||
@@ -162,11 +161,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="wifiGd32ActionPane" style="display:none;">
|
<div id="wifiGd32ActionPane" style="display:none;">
|
||||||
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled style="margin-bottom:8px;">
|
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled>
|
||||||
上传固件到缓存
|
GD32 OTA
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline btn-block" id="relayBtn" onclick="triggerRelay()">
|
|
||||||
触发 GD32 OTA 中继
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-2" id="bleActionPane" style="display:none;">
|
<div class="row-2" id="bleActionPane" style="display:none;">
|
||||||
@@ -184,8 +180,7 @@
|
|||||||
<!-- Phase step indicators -->
|
<!-- Phase step indicators -->
|
||||||
<div class="progress-phase-row">
|
<div class="progress-phase-row">
|
||||||
<div class="pp-step active" id="ppEsp32"><span class="pp-ico">📦</span><span class="pp-txt">ESP32 OTA</span></div>
|
<div class="pp-step active" id="ppEsp32"><span class="pp-ico">📦</span><span class="pp-txt">ESP32 OTA</span></div>
|
||||||
<div class="pp-step" id="ppGd32Up"><span class="pp-ico">📤</span><span class="pp-txt">GD32 上传</span></div>
|
<div class="pp-step" id="ppGd32Ota"><span class="pp-ico">📤</span><span class="pp-txt">GD32 OTA</span></div>
|
||||||
<div class="pp-step" id="ppGd32Relay"><span class="pp-ico">🔀</span><span class="pp-txt">GD32 转发</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Unified progress bar -->
|
<!-- Unified progress bar -->
|
||||||
<div class="progress-wrap show" id="progressWrap">
|
<div class="progress-wrap show" id="progressWrap">
|
||||||
@@ -374,7 +369,7 @@
|
|||||||
<span id="toastMsg">操作成功</span>
|
<span id="toastMsg">操作成功</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js?v=20260614f"></script>
|
<script src="app.js?v=20260619c"></script>
|
||||||
<script>
|
<script>
|
||||||
window.connectionMode = window.connectionMode || 'wifi';
|
window.connectionMode = window.connectionMode || 'wifi';
|
||||||
window.flashTarget = window.flashTarget || 'esp32';
|
window.flashTarget = window.flashTarget || 'esp32';
|
||||||
@@ -419,11 +414,14 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
|||||||
syncBleFileFromSelectedTarget();
|
syncBleFileFromSelectedTarget();
|
||||||
if (isBle) {
|
if (isBle) {
|
||||||
window.switchStageFlow('ble');
|
window.switchStageFlow('ble');
|
||||||
|
if (typeof setActivePhase === 'function') {
|
||||||
|
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||||||
|
}
|
||||||
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
|
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
|
||||||
window.bleOta.refreshButtons();
|
window.bleOta.refreshButtons();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.switchStageFlow(window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32');
|
window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -433,6 +431,9 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
|||||||
syncBleFileFromSelectedTarget();
|
syncBleFileFromSelectedTarget();
|
||||||
if (window.connectionMode === 'ble') {
|
if (window.connectionMode === 'ble') {
|
||||||
window.switchStageFlow('ble');
|
window.switchStageFlow('ble');
|
||||||
|
if (typeof setActivePhase === 'function') {
|
||||||
|
setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.switchStageFlow(window.flashTarget);
|
window.switchStageFlow(window.flashTarget);
|
||||||
}
|
}
|
||||||
@@ -610,7 +611,7 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
|||||||
if (isBle) {
|
if (isBle) {
|
||||||
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
|
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
|
||||||
} else if (isGd32) {
|
} else if (isGd32) {
|
||||||
hint.innerHTML = 'WiFi 模式:上传 GD32 固件到缓存后,再触发 SPI OTA 中继。';
|
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
|
||||||
} else {
|
} else {
|
||||||
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
|
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
|
||||||
}
|
}
|
||||||
@@ -620,7 +621,6 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
|||||||
applySharedModeUi();
|
applySharedModeUi();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="ble_ota.js?v=20260614f"></script>
|
<script src="ble_ota.js?v=20260617b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -603,9 +603,8 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
|||||||
/* ===== Header sub-row (设备名/设备ID) ===== */
|
/* ===== Header sub-row (设备名/设备ID) ===== */
|
||||||
.header-sub {
|
.header-sub {
|
||||||
display: flex; gap: 12px;
|
display: flex; gap: 12px;
|
||||||
border-top: 1px solid var(--border);
|
margin-top: 0;
|
||||||
margin-top: 4px;
|
padding: 0;
|
||||||
padding: 6px 20px 4px;
|
|
||||||
}
|
}
|
||||||
.pill-val {
|
.pill-val {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Reference in New Issue
Block a user