diff --git a/cmake/gen_version_script.cmake b/cmake/gen_version_script.cmake index 705bc34..df2e2ff 100644 --- a/cmake/gen_version_script.cmake +++ b/cmake/gen_version_script.cmake @@ -5,15 +5,28 @@ # SOURCE_DIR - 项目根目录 (ESP32_COMM) # OUTPUT_FILE - 输出的 version.h 路径 -# 版本号(可手动修改) +# 主/次版本号(手动修改,大版本时改) set(FW_VERSION_MAJOR 0) 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") -# 获取 Git 信息 +# Git 短 hash find_package(Git QUIET) if(GIT_FOUND) execute_process( @@ -27,7 +40,7 @@ else() set(GIT_HASH "unknown") endif() -# 生成版本头文件内容 +# 生成版本头文件 set(VERSION_HEADER_CONTENT "// Auto-generated version info - DO NOT EDIT #pragma once diff --git a/main/include/version.h b/main/include/version.h index cc79a6b..89d67b9 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 12 -#define FW_VERSION_STRING "0.3.12" +#define FW_VERSION_PATCH 26 +#define FW_VERSION_STRING "0.3.26" // 编译时间 -#define BUILD_TIME "2026-06-17 03:09:19" +#define BUILD_TIME "2026-06-18 01:32:44" // Git 信息 -#define GIT_HASH "98b5cc1" +#define GIT_HASH "2586582" diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index cb92e98..c497486 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -846,40 +846,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; - if (len % 4 == 0) { - if (offset + len > part->size) { - DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu part_size=%" PRIu32, - offset, len, part->size); - return ESP_ERR_INVALID_SIZE; - } - return esp_partition_write(part, offset, data, len); - } + // ★ 计算 head/tail padding,使写入地址和长度都对齐到 4 字节 + // WiFi HTTP chunk 边界不一定 4 对齐,flash 写入必须对齐。 + size_t head_pad = offset % 4; + uint32_t aligned_offset = offset - head_pad; + size_t total = head_pad + len; + size_t aligned_len = (total + 3) & ~(size_t)3; - size_t aligned_len = (len + 3) & ~(size_t)3; - - if (offset + aligned_len > part->size) { - DEBUG_ERR("OTA", "flashWriteChunk对齐后越界: offset=%" PRIu32 " len=%zu aligned=%zu part=%" PRIu32, - offset, len, aligned_len, part->size); + if (aligned_offset + aligned_len > part->size) { + DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu aligned_off=%" PRIu32 " aligned_len=%zu part=%" PRIu32, + offset, len, aligned_offset, aligned_len, part->size); return ESP_ERR_INVALID_SIZE; } - DEBUG_WARN("OTA", "flashWriteChunk len非4对齐: len=%zu aligned_len=%zu offset=%" PRIu32, - len, aligned_len, offset); + // 快速路径:offset 和 len 都对齐 + if (head_pad == 0 && len % 4 == 0) { + return esp_partition_write(part, offset, data, len); + } - size_t pad_len = aligned_len - len; - uint8_t pad[4] = {0}; - esp_err_t err = esp_partition_read(part, offset + len, pad, pad_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; + if (head_pad > 0 || len % 4 != 0) { + DEBUG_WARN("OTA", "flashWriteChunk 非对齐: offset=%" PRIu32 " len=%zu head=%zu aligned_len=%zu", + offset, len, head_pad, aligned_len); } uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len); @@ -887,10 +876,34 @@ esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len); 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); return err; } @@ -1189,6 +1202,8 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) result = OTA_RESULT_ERR_GD32; break; } + // ★ resend 前延迟 + vTaskDelay(pdMS_TO_TICKS(1)); goto resend_packet; } if (isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) { @@ -1215,12 +1230,14 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) 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) { DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq); result = OTA_RESULT_ERR_GD32; break; } + // ★ resend 前延迟:给 GD32 时间处理上一包并更新 SPI 响应缓冲区 + vTaskDelay(pdMS_TO_TICKS(1)); goto resend_packet; } @@ -1237,6 +1254,12 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) uint32_t progress = (relaySeq * 100U) / m_transferTotalPackets; 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; } diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index e3e2b0e..ded74e8 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -9,7 +9,7 @@ * * GATT 服务: * 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_OUT (0x0003, Notify): 推送响应 * - VERSION (0x0004, Read): 固件版本 @@ -53,12 +53,15 @@ #define BLE_OTA_NOTIFY_SHA256 0xC1 // 设备端 SHA-256 上报([0xC1] + [32字节]) #define BLE_OTA_FINISH_DONE 0x02 #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_ACK_WINDOW 32 #define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用 #define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用 #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 卸载到工作线程 ── // 避免 flash 操作(esp_ota_begin / esp_ota_write)阻塞 BLE 协议栈导致连接断开。 @@ -70,14 +73,27 @@ struct BleOtaDataBatch { }; 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 sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes); 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 ble_ota_data_worker_task(void *pvParameters); +static void ble_gd32_relay_wait_task(void *pvParameters); // ============================================================ /** @@ -118,12 +134,13 @@ static void ble_esp32_restart_task(void *pvParameters) /** * @brief 异步完成 ESP32 OTA 收尾并触发重启。 - * @param pvParameters 前端 SHA-256 十六进制字符串(堆分配,任务负责释放)。 + * @param pvParameters BleEsp32FinishCtx(堆分配,任务负责释放)。 */ static void ble_esp32_finish_and_reboot_task(void *pvParameters) { - // pvParameters: heap-allocated char[65] frontend SHA hex string (or nullptr) - char *frontend_sha_hex = (char *)pvParameters; + BleEsp32FinishCtx *ctx = (BleEsp32FinishCtx *)pvParameters; + char *frontend_sha_hex = ctx ? ctx->sha_hex : nullptr; + uint16_t session_id = ctx ? ctx->session_id : 0; DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动"); Esp32OtaCore &core = Esp32OtaCore::instance(); @@ -139,7 +156,9 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) if (!core.isActive()) { 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; esp32_reorder_free(); vTaskDelete(NULL); @@ -163,21 +182,20 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) OtaCoordinator::instance().notifyEsp32FinalizeComplete(); // 1) 把设备端 SHA 通过 SHA256 通知推给前端 if (device_sha_hex[0] != '\0') { - uint8_t sha_pkt[33] = {0}; - sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; + uint8_t device_sha[32] = {0}; for (int i = 0; i < 32; ++i) { unsigned int byte = 0; sscanf(&device_sha_hex[i * 2], "%02x", &byte); - 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 队列送出 vTaskDelay(pdMS_TO_TICKS(150)); // 3) 推送"OTA 收尾完成"与"即将重启"事件 - sendFinishEvent(BLE_OTA_FINISH_DONE, 0); - sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); + sendFinishEvent(BLE_OTA_FINISH_DONE, packBleFinishValue(session_id, 0)); + sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, packBleFinishValue(session_id, 0)); // 4) 通知全部发出后延迟 500ms 重启 DEBUG_INFO(TAG, "[FINISH] 通知已发送,500ms 后重启..."); @@ -187,19 +205,20 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) // 不会返回 } else if (err == ESP_ERR_INVALID_VERSION) { 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; // ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer esp32_reorder_free(); } else { DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(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; // ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer esp32_reorder_free(); } - if (frontend_sha_hex) free(frontend_sha_hex); + if (ctx) free(ctx); vTaskDelete(NULL); } @@ -623,6 +642,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 访问回调 @@ -661,26 +740,119 @@ 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); 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 中继 - ota_meta_t meta; + ota_meta_t meta = {}; esp_err_t err = OtaRelay::metaLoad(&meta); if (err == ESP_OK && meta.total_size > 0 && 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); + + err = OtaRelay::metaFinalizeChecksum(); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 缓存 SHA-256 计算失败: %s", esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 3)); + return 0; + } + + err = OtaRelay::metaLoad(&meta); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 SHA-256 元数据读取失败: %s", esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 3)); + return 0; + } + + shaBytesToHexUpper(meta.checksum, device_sha_hex); + sendSha256Notify(meta.checksum, session_id); + DEBUG_INFO(TAG, "GD32 前端 SHA-256: %s, session=%u", frontend_sha_hex, session_id); + DEBUG_INFO(TAG, "GD32 缓存 SHA-256: %s", device_sha_hex); + + if (memcmp(meta.checksum, frontend_sha, 32) != 0) { + DEBUG_ERR(TAG, "GD32 缓存 SHA-256 与前端文件不匹配,拒绝触发 SPI 中继"); + sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, + packBleFinishValue(session_id, 0)); + return 0; + } + + DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继", 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 { DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")", 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) { + uint16_t session_id = 0; + if (len >= 35) { + session_id = (uint16_t)(buf[33] | (buf[34] << 8)); + } if (!Esp32OtaCore::instance().isActive()) { DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, + packBleFinishValue(session_id, 1)); return 0; } if (BleOta::instance().m_esp32RebootPending) { DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, + packBleFinishValue(session_id, 1)); return 0; } @@ -692,28 +864,30 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, return 0; } - char *frontend_sha_hex = (char *)malloc(65); - if (!frontend_sha_hex) { + BleEsp32FinishCtx *finish_ctx = (BleEsp32FinishCtx *)malloc(sizeof(BleEsp32FinishCtx)); + if (!finish_ctx) { 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; } 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'; - DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); + finish_ctx->sha_hex[64] = '\0'; + 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; BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, "ble_ota_finish", 4096, - frontend_sha_hex, // heap string; task 负责 free + finish_ctx, // heap context; task 负责 free 5, NULL); if (ok != pdPASS) { DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - free(frontend_sha_hex); + free(finish_ctx); BleOta::instance().m_esp32RebootPending = false; } else { DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); diff --git a/tools/app.js b/tools/app.js index 478527a..0630f85 100644 --- a/tools/app.js +++ b/tools/app.js @@ -19,7 +19,7 @@ function toggleCollapse(headEl) { 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 目标,决定显示哪套阶段 // 切换 OTA 阶段面板显示 function switchStageFlow(target) { @@ -103,8 +103,9 @@ function showVerifyActions(type) { } function setActivePhase(phase) { + if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; activePhase = phase; - var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' }; + var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' }; Object.keys(stepMap).forEach(function(p) { var el = document.getElementById(stepMap[p]); if (!el) return; @@ -116,11 +117,12 @@ function setActivePhase(phase) { if (fill) fill.classList.remove('done'); } 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]); if (el) { el.classList.remove('active'); el.classList.add('done'); } // All done? turn progress bar green - if (phase === 'gd32Relay' || phase === 'esp32Ota') { + if (phase === 'gd32Ota' || phase === 'esp32Ota') { var fill = document.getElementById('progressFill'); if (fill) fill.classList.add('done'); } @@ -138,7 +140,7 @@ function resetProgress() { activePhase = null; esp32OtaPhase = 0; switchStageFlow(null); - ['ppEsp32', 'ppGd32Up', 'ppGd32Relay'].forEach(function(id) { + ['ppEsp32', 'ppGd32Ota'].forEach(function(id) { var el = document.getElementById(id); if (el) el.classList.remove('active', 'done'); }); @@ -361,23 +363,16 @@ function updateHealthUi(healthCode, freeHeap) { } } // ==================== OTA 阶段驱动 (真实状态) ==================== -// Relay state → 6-stage mapping: -// Stage 1 (认证握手): rs 1-6 握手/挑战/会话密钥/认证 -// Stage 2 (传输协商): rs 7-8 固件大小协商 -// 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 +// Relay state comes from relay_state_t in main/relay/ota_relay.h: +// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING, +// 4=FINISHING, 5=DONE, 255=ERROR. var relayStateDetailMap = { 0: '待机', - 1: '等待握手', 2: '握手已发', 3: '挑战已发', - 4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成', - 7: '大小已发', 8: '大小确认', - 9: '接收中', 10: '转发中', 11: '数据包已发', 12: 'ACK正常', 13: '等待重发', - 14: '接收完成', 15: '传输完成', - 16: '结束已发', 17: 'OTA完成', - 18: 'APP验证', + 1: '认证握手', + 2: '传输协商', + 3: '数据转发', + 4: '收尾验证', + 5: 'OTA完成', 255: '错误' }; function updateStagesFromRelayState(rs) { @@ -403,16 +398,17 @@ function updateStagesFromRelayState(rs) { return; } var activeStage = 0; - if (rs >= 1 && rs <= 6) activeStage = 1; - else if (rs >= 7 && rs <= 8) activeStage = 2; - else if (rs >= 9 && rs <= 13) activeStage = 3; - else if (rs >= 14 && rs <= 15) activeStage = 4; - else if (rs >= 16 && rs <= 17) activeStage = 5; - else if (rs >= 18 && rs <= 18) activeStage = 6; + if (rs === 1) activeStage = 1; + else if (rs === 2) activeStage = 2; + else if (rs === 3) activeStage = 3; + else if (rs === 4) activeStage = 5; + else if (rs === 5) activeStage = 6; for (var i = 0; i < activeStage - 1; i++) { 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'; } // Show detail text for the active stage @@ -503,75 +499,97 @@ function updateSystemStatusUi(data) { return; } var rs = ota.relay_state; - var relayText = relayStateDetailMap[rs] || '待机'; + var sysState = ota.sys_state || ''; + var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; + var isGd32Storing = sysState === 'GD32_STORING' || sysStateCode === 1; + var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3; + var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4; + var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255; + var relayText = isGd32Complete ? 'GD32 OTA 完成' + : isGd32Storing ? 'GD32 OTA 上传中' + : (relayStateDetailMap[rs] || '待机'); document.getElementById('pillRelayTxt').textContent = relayText; var pr = document.getElementById('pillRelay'); - if (rs === 255) pr.className = 'pill offline'; - else if (rs > 0 && rs < 5) pr.className = 'pill processing'; - else if (rs === 5) pr.className = 'pill online'; + if (isGd32Error) pr.className = 'pill offline'; + else if (isGd32Complete || rs === 5) pr.className = 'pill online'; + else if (isGd32Storing || isGd32Relaying || (rs > 0 && rs < 5)) pr.className = 'pill processing'; else pr.className = 'pill idle'; // OTA cache progress (GD32 upload to ESP32 flash) var total = ota.total_size || 0; var stored = ota.stored_size || 0; var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0; var bp = document.getElementById('bOTAProgress'); - if (bp) { - bp.textContent = storageProgress + '%'; - bp.className = 'v ' + (storageProgress === 100 ? 'ok' : storageProgress > 0 ? 'warn' : ''); + function setOtaProgressBadge(pct) { + var safePct = Math.max(0, Math.min(100, Math.round(pct || 0))); + if (bp) { + bp.textContent = safePct + '%'; + bp.className = 'v ' + (safePct >= 100 ? 'ok' : safePct > 0 ? 'warn' : ''); + } } // GD32 relay forwarding progress var relaySeq = ota.relay_seq || 0; var relayTotal = ota.relay_total_packets || 0; var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0; var gd32AppAlive = ota.gd32_app_alive; - // ★ 修复:后端实际状态机枚举 (main/relay/ota_relay.h): - // 0=RELAY_IDLE, 1=RELAY_HANDSHAKING, 2=RELAY_SIZE_NEG, - // 3=RELAY_TRANSFERRING, 4=RELAY_FINISHING, 5=RELAY_DONE, 0xFF=RELAY_ERROR - // 旧版 1-18 范围判断已与新状态机不匹配,进度条永远停在认证中阶段。 - if (rs === 1) { + var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs); + if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); } + + if (isGd32Error) { + 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: 握手 + 挑战认证 - setActivePhase('gd32Relay'); - setProgress(0, '握手与认证中...'); + setActivePhase('gd32Ota'); + setOtaProgressBadge(50); + setProgress(50, '握手与认证中...'); } else if (rs === 2) { // SIZE_NEG: 固件大小协商 - setActivePhase('gd32Relay'); - setProgress(0, '协商固件大小...'); + setActivePhase('gd32Ota'); + setOtaProgressBadge(55); + setProgress(55, '协商固件大小...'); } else if (rs === 3) { // TRANSFERRING: 数据包传输 - setActivePhase('gd32Relay'); - setProgress(relayProgress, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包'); + setActivePhase('gd32Ota'); + var relayOverall = 60 + Math.round(relayProgress * 0.30); + setOtaProgressBadge(relayOverall); + setProgress(relayOverall, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包'); } else if (rs === 4) { // FINISHING: 结束OTA + APP存活验证 - setActivePhase('gd32Relay'); + setActivePhase('gd32Ota'); + setOtaProgressBadge(95); if (gd32AppAlive) { - markPhaseDone('gd32Relay'); + markPhaseDone('gd32Ota'); setProgress(100, 'GD32 刷写完成'); var sb = document.getElementById('stageStatusBar'); var st = document.getElementById('stageStatusText'); if (sb) sb.className = 'stage-status-bar ok'; if (st) st.textContent = 'GD32 刷写完成'; } else { - setProgress(100, '收尾中,等待 APP 启动...'); + setProgress(95, '收尾中,等待 APP 启动...'); } - } else if (rs === 5) { - // DONE: 中继完成(metaClear 已清空,relaySeq/relayTotal 会被 reset 为 0) - setActivePhase('gd32Relay'); - setProgress(100, 'OTA 完成'); - } else if (total > 0 && stored < total && !isUploading) { - // GD32 upload to cache in progress (handled by upload progress if uploading) - 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 刷写完成'; + } else if (total > 0 && stored >= total) { + setActivePhase('gd32Ota'); + setOtaProgressBadge(45); + setProgress(45, '上传完成,等待自动启动 GD32 OTA...'); + } else { + setOtaProgressBadge(storageProgress); } - // stage flow from real state - if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(rs); } } async function testConnection() { // 如果在降速模式,先恢复正常轮询 @@ -876,51 +894,61 @@ function isIntelHexText(text) { } 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) { var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; var isHexByName = file.name.toLowerCase().endsWith('.hex'); if (chip === 'gd32' && isHexByName) { // GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象 - var reader = new FileReader(); - reader.onload = function (e) { - try { - var text = e.target.result; - // 二次确认: 内容也像 HEX - 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'); + convertHexFileToBin(file).then(function(result) { + if (!result.converted) { + // 文件名 .hex 但内容不像,直接用原始文件 + addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理'); + gd32File = file; document.getElementById('fileInfo' + suffix).classList.add('show'); - document.getElementById('fileName' + suffix).textContent = binName + ' (由 ' + file.name + ' 转换)'; - document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; - document.getElementById('fileSize' + suffix).textContent = formatBytes(result.data.length); - updateUploadButtonLabel(); - } catch (err) { - addLog('E', '文件', 'HEX 解析失败: ' + err.message); - showToast('HEX 文件解析失败: ' + err.message, 'error'); - gd32File = null; - document.getElementById('fileInfo' + suffix).classList.remove('show'); + document.getElementById('fileName' + suffix).textContent = file.name; + document.getElementById('fileType' + suffix).textContent = 'Binary(?)'; + document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); updateUploadButtonLabel(); + return; } - }; - reader.readAsText(file); + gd32File = result.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 } // .bin 文件或 ESP32 文件: 直接使用 @@ -1010,7 +1038,7 @@ async function startUpload(chip) { uploadBtn.disabled = true; uploadBtn.textContent = '上传中...'; switchStageFlow(chip); - setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Upload'); + setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota'); if (chip === 'esp32') updateEsp32StagesFromPhase(1); // Reset verification panel var verifyInfo = document.getElementById('stageVerifyInfo'); @@ -1069,9 +1097,16 @@ async function startUpload(chip) { if (e.lengthComputable) { var pct = Math.round((e.loaded / e.total) * 100); uploadProgress = pct; - setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total)); - var el = document.getElementById('bOTAProgress'); - if (el) el.textContent = pct + '%'; + if (chip === 'gd32') { + var overallPct = Math.min(45, Math.round(pct * 0.45)); + 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 () { @@ -1081,7 +1116,7 @@ async function startUpload(chip) { updateUploadButtonLabel(); if (xhr.status === 200) { addLog('I', '上传', '固件上传完成!'); - showToast('固件上传成功', 'success'); + showToast(chip === 'gd32' ? '上传完成,GD32 OTA 已自动开始' : '固件上传成功', 'success'); setProgress(100, '上传完成'); if (chip === 'esp32') { markPhaseDone('esp32Ota'); @@ -1090,9 +1125,8 @@ async function startUpload(chip) { verifyEsp32Firmware(); } else { switchStageFlow('gd32'); - markPhaseDone('gd32Upload'); - setActivePhase('gd32Relay'); - setProgress(0, '等待触发转发...'); + setActivePhase('gd32Ota'); + setProgress(45, '上传完成,已自动触发 GD32 OTA...'); } } else if (xhr.status === 403) { addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试'); @@ -1271,20 +1305,20 @@ async function restartEsp32() { showToast('重启失败', 'error'); } } -// ==================== 触发 OTA 中继 (真实状态驱动) ==================== +// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ==================== async function triggerRelay() { - addLog('I', 'OTA', '触发 GD32 OTA 中继...'); - showToast('正在触发 OTA 中继', 'success'); + addLog('I', 'OTA', '手动触发 GD32 OTA...'); + showToast('正在触发 GD32 OTA', 'success'); try { var resp = await fetch(getBaseUrl() + '/ota/relay', { method: 'POST', signal: AbortSignal.timeout(5000) }); if (resp.ok) { - addLog('I', 'OTA', 'OTA 中继已触发,等待 GD32 响应...'); + addLog('I', 'OTA', 'GD32 OTA 已触发,等待设备响应...'); // 不再使用假的 setTimeout 推进阶段。 // pollStatus 的 updateSystemStatusUi 会从 ota.relay_state // 自动更新 stage flow 和 header 状态。 - showToast('中继已触发,等待设备响应', 'success'); + showToast('GD32 OTA 已触发,等待设备响应', 'success'); } else { addLog('E', 'OTA', '触发失败: HTTP ' + resp.status); showToast('触发失败', 'error'); @@ -1463,4 +1497,3 @@ setTimeout(function () { }); } }, 500); - diff --git a/tools/ble_ota.js b/tools/ble_ota.js index 8229333..5d053aa 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -15,6 +15,15 @@ const BLE_OTA_NOTIFY_FINISH = 0xB1; const BLE_OTA_NOTIFY_SHA256 = 0xC1; 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 = { gd32: { @@ -63,6 +72,9 @@ lastAckSeq: -1, lastAckBytes: 0, ackWaiter: null, + otaResultWaiter: null, + otaRunId: 0, + lastEsp32DoneSession: null, fileSha256: null, deviceSha256: null, expectedReboot: false, // 发送 0xFA 后置 true,重连后清 @@ -185,6 +197,71 @@ 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) { var panel = document.getElementById('stageVerifyInfo'); if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; } @@ -479,22 +556,71 @@ const stage = bytes[1]; const value = (bytes[2]) | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24); const finishMap = { - 0x02: '[FINISH] ESP32 OTA 收尾完成', - 0x07: '[FINISH] ESP32 即将重启', - 0x81: `[FINISH] ESP32 OTA 收尾失败: err=${value}`, - 0x83: '[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝', - 0x84: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`, - 0x85: '[FINISH] ESP32 OTA SHA-256 校验失败', + [BLE_OTA_FINISH_ESP32_DONE]: '[FINISH] ESP32 OTA 收尾完成', + [BLE_OTA_FINISH_REBOOT_NOW]: '[FINISH] ESP32 即将重启', + [BLE_OTA_FINISH_GD32_RELAY_START]: '[FINISH] GD32 OTA 转发已开始', + [BLE_OTA_FINISH_GD32_RELAY_DONE]: '[FINISH] GD32 OTA 转发完成', + [BLE_OTA_FINISH_ERR_FINALIZE]: `[FINISH] ESP32 OTA 收尾失败: err=${value}`, + [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}`; bleLog('FINISH', finishText); 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 || '-', '等待 GD32 APP 启动确认...', { 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 || '-', 'GD32 APP 已确认启动', { 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 - if (stage === 0x02) { + if (stage === BLE_OTA_FINISH_ESP32_DONE) { + if (!finishBelongsToWaiter('esp32', value)) return; + state.lastEsp32DoneSession = finishSessionId(value); setBleStage(finalizeStage, '收尾完成', null); 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; setBleStage(finalizeStage, '设备重启', null); setBleStageText('ESP32 即将重启'); @@ -503,30 +629,45 @@ } // OTA 结果 toast 提示(用户可见,不只是在日志里) - if (stage === 0x83) { - showToast('ESP32 OTA 被其他通道占用,请稍后重试', 'error'); + if (stage === BLE_OTA_FINISH_ERR_MUTEX) { + if (hasCurrentWaiter('esp32') && !finishBelongsToWaiter('esp32', value)) return; + if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return; + showToast('OTA 被其他通道占用,请稍后重试', 'error'); setProgress(0, 'OTA 被其他通道占用'); state.lastUploadStatus = 'failed'; 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'); setProgress(0, 'GD32 固件太大,无法刷写'); state.lastUploadStatus = 'failed'; 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'); setProgress(0, '刷写失败'); state.lastUploadStatus = 'failed'; setBleStageError('OTA 刷写失败'); showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true }); - } else if (stage === 0x85) { - showToast('ESP32 OTA 校验失败,未触发重启', 'error'); + rejectOtaResult('ESP32 OTA 刷写失败', { stage: stage, value: value }); + } 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 校验失败'); state.lastUploadStatus = 'failed'; - setBleStage(5, 'SHA-256 ✗', null); + setBleStage(flowTarget().verifyStage, 'SHA-256 ✗', null); setBleStageError('设备端 SHA-256 校验失败'); - showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '设备端校验失败,已中止 OTA', err: true }); - } else if (stage === 0x07) { + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: msg, err: true }); + 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'); // If device SHA-256 hasn't arrived yet, show what we have var devSha = state.deviceSha256 || '设备未上报'; @@ -556,6 +697,14 @@ // SHA-256 verification from device (after transfer complete) 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) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase(); @@ -735,7 +884,63 @@ 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'); if (file && shaElem) { shaElem.textContent = '计算中...'; @@ -761,31 +966,13 @@ shaElem.textContent = '-'; 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 元素同步,避免切标签页回来误清空 if (state.selectedFileHandle) return; const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null; - updateFileInfo(file); + await updateFileInfo(file); } // 通过 File System Access API 选择文件(支持热重载) @@ -803,7 +990,7 @@ const handle = handles[0]; const file = await handle.getFile(); state.selectedFileHandle = handle; - updateFileInfo(file); + await updateFileInfo(file); bleLog('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name); return true; } @@ -856,13 +1043,14 @@ if (state.selectedFileHandle) { try { const freshFile = await state.selectedFileHandle.getFile(); - updateFileInfo(freshFile); - bleLog('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')'); + await updateFileInfo(freshFile); + var actualFile = state.selectedFile || freshFile; + bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); } catch (e) { bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); } } else { - syncSelectedFileFromInput(); + await syncSelectedFileFromInput(); } if (!state.selectedFile || !state.dataInChar) return false; @@ -874,12 +1062,15 @@ state.abortRequested = false; state.uploading = true; state.lastUploadStatus = 'uploading'; + const otaSessionId = nextOtaSessionId(); + clearOtaResultWaiter(); switchStageFlow('ble'); state.lastAckCmd = null; state.lastAckSeq = -1; state.lastAckBytes = 0; state.ackGeneration = 0; state.deviceSha256 = null; + state.lastEsp32DoneSession = null; if (state.ackWaiter) { clearTimeout(state.ackWaiter.timer); state.ackWaiter = null; @@ -1056,8 +1247,7 @@ // Stage 4 done setBleStage(4, '完成', null); - setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`); - state.lastUploadStatus = 'success'; + setProgress(cfg.label === 'GD32' ? 45 : 90, `[${cfg.label}] 固件数据已全部写入 DATA_IN`); // Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对 if (cfg.label === 'ESP32') { @@ -1067,9 +1257,12 @@ for (let i = 0; i < 32; i++) { 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.set(frontShaBytes, 1); + trigger[33] = otaSessionId & 0xFF; + trigger[34] = (otaSessionId >> 8) & 0xFF; + const resultPromise = waitForOtaResult('esp32', otaSessionId, 45000); showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' }); setBleStage(5, '发送校验...', 'active'); setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...'); @@ -1077,6 +1270,7 @@ await state.ctrlChar.writeValue(trigger); bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验'); } catch (e) { + clearOtaResultWaiter(); state.lastUploadStatus = 'failed'; setProgress(0, `0xF8 发送失败: ${e.message}`); bleLog('OTA', `0xF8 发送失败: ${e.message}`, 'E'); @@ -1086,20 +1280,44 @@ setBleStage(5, '等待设备端校验', null); setBleStage(6, '设备端收尾中', 'active'); setBleStageText('0xF8 已发,等待设备端完成 OTA 收尾...'); + try { + await resultPromise; + } catch (e) { + state.lastUploadStatus = 'failed'; + setProgress(0, e.message); + setBleStageError(e.message); + clearOtaResultWaiter(); + return false; + } 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'); - setBleStageText('正在发送触发命令...'); + setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...'); + showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待 GD32 缓存校验' }); await sleep(30); 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 { await state.ctrlChar.writeValue(cmd); setBleStage(5, '已触发', null); - bleLog('OTA', `触发命令已发送: ${toHex(cmd)}`); + setBleStage(6, '等待设备端校验', 'active'); + bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`); } catch (_) { + clearOtaResultWaiter(); state.lastUploadStatus = 'failed'; setProgress(0, `触发命令发送失败: ${toHex(cmd)}`); bleLog('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E'); @@ -1107,24 +1325,18 @@ return false; } - // Stage 6: SHA-256 verification (GD32 mode) - setBleStage(6, 'GD32 无回传', null); - setBleStageText('GD32 OTA 中继已触发'); - state.deviceSha256 = null; - showBleVerifyInfo(fileSha256.toUpperCase(), 'GD32 中继不回传 SHA', { text: 'GD32 模式无设备端校验(仅前端 SHA-256)' }); - - bleLog('OTA', `✅ 固件刷写完成!`); - bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`); - - // 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'; + setBleStageText('GD32 缓存校验已发起,等待 SPI 转发流程...'); + try { + await resultPromise; + } catch (e) { + state.lastUploadStatus = 'failed'; + setProgress(0, e.message); + setBleStageError(e.message); + clearOtaResultWaiter(); + return false; } - var bar = document.getElementById('stageStatusBar'); - if (bar) bar.className = 'stage-status-bar ok'; - setBleStageText('刷写完成'); - + bleLog('OTA', `✅ GD32 OTA 刷写完成!`); + bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`); return true; } catch (err) { state.lastUploadStatus = 'failed'; @@ -1140,6 +1352,9 @@ clearTimeout(state.ackWaiter.timer); state.ackWaiter = null; } + if (state.lastUploadStatus === 'failed') { + clearOtaResultWaiter(); + } state.uploading = false; state.uploadTargetKey = null; setConnectionState(!!state.server); @@ -1155,19 +1370,22 @@ // (name/size/lastModified 任一不同即视为变化) // 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志 const cached = state.selectedFile; + // HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex, + // 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换 + const isHex = freshFile.name.toLowerCase().endsWith('.hex'); const changed = !cached || - cached.name !== freshFile.name || - cached.size !== freshFile.size || + (isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) || cached.lastModified !== freshFile.lastModified; if (changed) { - updateFileInfo(freshFile); - bleLog('FILE', '已重新读取本地文件(已变更): ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')'); + await updateFileInfo(freshFile); + var actualFile = state.selectedFile || freshFile; + bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); } } catch (e) { bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); } } else { - syncSelectedFileFromInput(); + await syncSelectedFileFromInput(); } if (!state.selectedFile || !state.server) return; @@ -1180,11 +1398,12 @@ bleLog('AUTO', '一键刷写中止', 'W'); return; } - setProgress(100, `[${cfg.label}] 刷写完成`); if (cfg.label === 'ESP32') { + setProgress(100, '[ESP32] OTA 完成,等待设备重启'); bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启'); } else { - bleLog('AUTO', 'GD32 OTA 中继已触发'); + setProgress(100, '[GD32] OTA 转发完成,APP 已启动'); + bleLog('AUTO', 'GD32 OTA 完成'); } } catch (err) { bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E'); @@ -1226,10 +1445,10 @@ }, disconnect: disconnectBle, showToast: showToast, - notifyFileChange: function(file, handle) { + notifyFileChange: async function(file, handle) { if (file) { if (handle) state.selectedFileHandle = handle; - updateFileInfo(file); + await updateFileInfo(file); } }, clearFile: clearSelectedFile, diff --git a/tools/index.html b/tools/index.html index be3ff52..fd30ae8 100644 --- a/tools/index.html +++ b/tools/index.html @@ -4,7 +4,7 @@ ESP32 OTA 中继控制台 - +
@@ -162,11 +162,8 @@