From 9ca53ae66ec9022231fbafaa92e6a85d2116060d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Sun, 14 Jun 2026 23:03:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(ota):=20=E8=A7=A3=E8=80=A6ESP32=20OTA?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E9=80=BB=E8=BE=91=E5=88=B0=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=B9=B6=E5=A2=9E=E5=BC=BABLE=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增ota目录和esp32_ota_core.cpp实现ESP32 OTA核心功能,支持begin/write/finalize操作 - 修改CMakeLists.txt添加ota源文件和包含目录 - 重构ble_ota.cpp将OTA写入/收尾逻辑委托给Esp32OtaCore,移除mbedtls直接引用 - 新增BLE_OTA_CTRL_RESTART_ESP32(0xFA)命令用于重启ESP32设备 - 更新BLE_OTA_CTRL_TRIGGER_ESP32(0xF8)逻辑,要求前端提供SHA-256校验并比对 - 添加SHA-256校验不匹配错误码BLE_OTA_FINISH_ERR_SHA_MISMATCH(0x85) - 优化OTA收尾流程,设备端读取分区计算SHA-256并与前端提供的哈希比对 - 添加BLE_OTA_NOTIFY_SHA256通知类型用于上报设备端计算的SHA-256值 - 更新app_config.h中的BLE OTA命令描述文档 - 移除BleOta类中的OTA状态变量,仅保留BLE传输层相关状态 - 更新版本构建时间和Git哈希信息 --- main/CMakeLists.txt | 2 + main/include/app_config.h | 3 +- main/include/version.h | 4 +- main/ota/esp32_ota_core.cpp | 301 ++++++++++++++++++++++++++ main/ota/esp32_ota_core.h | 82 +++++++ main/server/ble_ota.cpp | 421 +++++++++++------------------------- main/server/ble_ota.h | 24 +- main/server/wifi_ota.cpp | 275 +++++------------------ main/server/wifi_ota.h | 16 +- tmp_patch_ble_cpp.py | 304 ++++++++++++++++++++++++++ tmp_patch_ble_cpp2.py | 147 +++++++++++++ tmp_patch_css.py | 37 ++++ tmp_patch_html.py | 20 ++ tmp_test.py | 25 +++ tmp_test2.py | 20 ++ tools/app.js | 6 +- tools/ble_ota.html | 198 +++++++++++++++-- tools/index.html | 2 +- tools/style.css | 23 ++ 19 files changed, 1338 insertions(+), 572 deletions(-) create mode 100644 main/ota/esp32_ota_core.cpp create mode 100644 main/ota/esp32_ota_core.h create mode 100644 tmp_patch_ble_cpp.py create mode 100644 tmp_patch_ble_cpp2.py create mode 100644 tmp_patch_css.py create mode 100644 tmp_patch_html.py create mode 100644 tmp_test.py create mode 100644 tmp_test2.py diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index bfcc950..410b96a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( SRCS "core/main.cpp" + "ota/esp32_ota_core.cpp" "relay/ota_relay.cpp" "transport/spi_master.cpp" "transport/protocol.cpp" @@ -11,6 +12,7 @@ INCLUDE_DIRS "." "core" + "ota" "relay" "transport" "server" diff --git a/main/include/app_config.h b/main/include/app_config.h index 06c1dcb..4a072e2 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -96,7 +96,8 @@ inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003; inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004; inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_GD32 = 0xF7; inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_ESP32 = 0xF8; -inline constexpr uint8_t BLE_OTA_CTRL_VERIFY_SHA256 = 0xF9; // SHA-256哈希验证命令 +inline constexpr uint8_t BLE_OTA_CTRL_VERIFY_SHA256 = 0xF9; // SHA-256哈希验证命令(仅从分区读回算 SHA-256,前端比对后再决定是否触发) +inline constexpr uint8_t BLE_OTA_CTRL_RESTART_ESP32 = 0xFA; // 重启 ESP32 命令(配套前端 expectedReboot 自动重连) inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; diff --git a/main/include/version.h b/main/include/version.h index e07dfc6..c1fa8fc 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -8,7 +8,7 @@ #define FW_VERSION_STRING "0.3.12" // 编译时间 -#define BUILD_TIME "2026-06-14 17:05:57" +#define BUILD_TIME "2026-06-14 22:46:58" // Git 信息 -#define GIT_HASH "6a6bc9d" +#define GIT_HASH "2caf10b" diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp new file mode 100644 index 0000000..3e4b8f3 --- /dev/null +++ b/main/ota/esp32_ota_core.cpp @@ -0,0 +1,301 @@ +/* + * esp32_ota_core.cpp - ESP32 本机 OTA 核心流程实现(WiFi / BLE 通道共用) + * + * 设计依据 WiFi 原流程(wifi_ota.cpp 旧实现),修复已知 bug: + * - 旧 wifi_ota.cpp:625 在 esp_ota_end 成功后又调 esp_ota_abort → 用 m_ended 标志避免 + * - SHA 统一从分区读回计算,与 BLE 增量计算方式统一为本风格 + */ +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_system.h" +#include "esp_ota_ops.h" +#include "esp_partition.h" + +#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS +#include "mbedtls/private/sha256.h" + +#include "esp32_ota_core.h" +#include "ota_mutex.h" +#include "app_config.h" +#include "monitor/debug_log.h" + +#define TAG "OTA_CORE" + +Esp32OtaCore& Esp32OtaCore::instance() +{ + static Esp32OtaCore inst; + return inst; +} + +void Esp32OtaCore::resetState() +{ + m_active = false; + m_handle = 0; + m_partition = nullptr; + m_received = 0; + m_shaComputed = false; + memset(m_sha256, 0, sizeof(m_sha256)); + m_ended = false; +} + +esp_err_t Esp32OtaCore::begin(size_t total_size) +{ + // 若本核心已有未完成会话,先中止并释放全局互斥 + if (m_active) { + DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA 会话,先中止旧会话"); + if (!m_ended) { + esp_ota_abort(m_handle); + } + esp32_ota_mutex_release(); + resetState(); + } + + // ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用 + if (!esp32_ota_mutex_acquire(1000)) { + DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin"); + return ESP_ERR_INVALID_STATE; + } + + const esp_partition_t *boot_partition = esp_ota_get_running_partition(); + DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx", + boot_partition->label, boot_partition->size, boot_partition->address); + + // 选择非当前启动的 OTA 分区(OTA_0 → OTA_1 fallback) + m_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); + if (m_partition == NULL || + m_partition->address == boot_partition->address) { + m_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); + } + + if (m_partition == NULL) { + DEBUG_ERR(TAG, "找不到可用的 OTA 分区"); + esp32_ota_mutex_release(); + return ESP_FAIL; + } + + DEBUG_INFO(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx", + m_partition->label, m_partition->size, m_partition->address); + + if (total_size > m_partition->size) { + DEBUG_ERR(TAG, "固件太大: %u > 分区大小: %u", + (unsigned)total_size, (unsigned)m_partition->size); + m_partition = nullptr; + esp32_ota_mutex_release(); + return ESP_FAIL; + } + + // 统一传 total_size 给 esp_ota_begin(WiFi 风格) + esp_err_t err = esp_ota_begin(m_partition, total_size, &m_handle); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err)); + m_partition = nullptr; + m_handle = 0; + esp32_ota_mutex_release(); + return err; + } + + m_received = 0; + m_active = true; + m_shaComputed = false; + m_ended = false; + memset(m_sha256, 0, sizeof(m_sha256)); + + DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节", + m_partition->label, (unsigned)total_size); + return ESP_OK; +} + +esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len) +{ + if (!m_active || m_partition == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = esp_ota_write(m_handle, data, len); + if (err == ESP_OK) { + m_received += len; + } else { + DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u)", esp_err_to_name(err), (unsigned)len); + } + return err; +} + +esp_err_t Esp32OtaCore::computeShaFromPartition() +{ + if (m_partition == NULL || m_received == 0) { + return ESP_ERR_INVALID_STATE; + } + + DEBUG_INFO(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received); + + mbedtls_sha256_context ctx; + mbedtls_sha256_init(&ctx); + mbedtls_sha256_starts(&ctx, 0); + + uint8_t *read_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); + if (!read_buf) { + DEBUG_ERR(TAG, "SHA 计算: malloc 失败"); + mbedtls_sha256_free(&ctx); + return ESP_ERR_NO_MEM; + } + + size_t offset = 0; + while (offset < m_received) { + size_t to_read = m_received - offset; + if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + esp_err_t rd_err = esp_partition_read(m_partition, offset, read_buf, to_read); + if (rd_err != ESP_OK) { + DEBUG_ERR(TAG, "SHA 计算: 分区读失败 @offset %u: %s", + (unsigned)offset, esp_err_to_name(rd_err)); + free(read_buf); + mbedtls_sha256_free(&ctx); + return rd_err; + } + mbedtls_sha256_update(&ctx, read_buf, to_read); + offset += to_read; + } + + free(read_buf); + mbedtls_sha256_finish(&ctx, m_sha256); + mbedtls_sha256_free(&ctx); + m_shaComputed = true; + + char hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02x", m_sha256[i]); + } + DEBUG_INFO(TAG, "设备端 SHA-256: %s", hex); + return ESP_OK; +} + +esp_err_t Esp32OtaCore::computeDeviceSha(char *out_device_sha_hex) +{ + if (!m_active || m_partition == NULL) { + return ESP_ERR_INVALID_STATE; + } + // 若已计算过,直接复用 + if (!m_shaComputed) { + esp_err_t err = computeShaFromPartition(); + if (err != ESP_OK) return err; + } + if (out_device_sha_hex) { + for (int i = 0; i < 32; ++i) { + snprintf(&out_device_sha_hex[i * 2], 65 - i * 2, "%02x", m_sha256[i]); + } + out_device_sha_hex[64] = '\0'; + } + return ESP_OK; +} + +esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, + char *out_device_sha_hex) +{ + if (!m_active) { + DEBUG_ERR(TAG, "finalize: OTA 未激活"); + return ESP_ERR_INVALID_STATE; + } + + // ── esp_ota_end ── + DEBUG_INFO(TAG, "[FINALIZE] esp_ota_end..."); + esp_err_t err = esp_ota_end(m_handle); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "esp_ota_end 失败: %s", esp_err_to_name(err)); + // end 失败时仍可 abort(未真正 end) + esp_ota_abort(m_handle); + m_ended = false; + esp32_ota_mutex_release(); + resetState(); + return err; + } + m_ended = true; // ★ end 成功,后续不得再 abort + DEBUG_INFO(TAG, "[FINALIZE] esp_ota_end 完成"); + + // ── SHA-256 校验(若前端提供)── + if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) { + esp_err_t sha_err = computeShaFromPartition(); + if (sha_err != ESP_OK) { + DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err)); + esp32_ota_mutex_release(); + resetState(); + return sha_err; + } + + char device_hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02x", m_sha256[i]); + } + + bool match = true; + for (int i = 0; i < 64; i++) { + if (tolower((unsigned char)frontend_sha_hex[i]) != + tolower((unsigned char)device_hex[i])) { + match = false; + break; + } + } + + if (out_device_sha_hex) { + strncpy(out_device_sha_hex, device_hex, 64); + out_device_sha_hex[64] = '\0'; + } + + if (!match) { + DEBUG_ERR(TAG, "SHA-256 不匹配! frontend=%s device=%s,中止", + frontend_sha_hex, device_hex); + esp32_ota_mutex_release(); + resetState(); + return ESP_ERR_INVALID_VERSION; // 调用方据此返回错误给前端 + } + DEBUG_INFO(TAG, "SHA-256 校验通过"); + } else if (out_device_sha_hex) { + // 未提供前端 SHA,但调用方仍想拿到设备端 SHA + if (computeShaFromPartition() == ESP_OK) { + for (int i = 0; i < 32; ++i) { + snprintf(&out_device_sha_hex[i * 2], 65 - i * 2, "%02x", m_sha256[i]); + } + out_device_sha_hex[64] = '\0'; + } + } + + // ── 设置启动分区 ── + DEBUG_INFO(TAG, "[FINALIZE] esp_ota_set_boot_partition..."); + err = esp_ota_set_boot_partition(m_partition); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err)); + esp32_ota_mutex_release(); + resetState(); + return err; + } + DEBUG_INFO(TAG, "[FINALIZE] esp_ota_set_boot_partition 完成"); + + esp32_ota_mutex_release(); + DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received); + + // 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理 + // (此处不 reset,以便 BLE finish task 在重启前可查询状态) + + if (reboot) { + DEBUG_INFO(TAG, "[FINALIZE] 500ms 后重启..."); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + // 不会返回 + } + return ESP_OK; +} + +void Esp32OtaCore::abort() +{ + if (!m_active) { + return; + } + if (!m_ended) { + esp_ota_abort(m_handle); + } + esp32_ota_mutex_release(); + resetState(); + DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理"); +} diff --git a/main/ota/esp32_ota_core.h b/main/ota/esp32_ota_core.h new file mode 100644 index 0000000..7892015 --- /dev/null +++ b/main/ota/esp32_ota_core.h @@ -0,0 +1,82 @@ +/* + * esp32_ota_core.h - ESP32 本机 OTA 核心流程(WiFi / BLE 通道共用) + * + * 将 ESP32 自身固件升级的完整生命周期封装为单例: + * begin → write*(N) → finalize / abort + * + * 统一以 WiFi 流程为基准: + * - esp_ota_begin 第二参数传 total_size + * - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算) + * - finalize 顺序: esp_ota_end → 比对前端 SHA → esp_ota_set_boot_partition → 可选重启 + * - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle + * + * WiFi 与 BLE 仅传输方式不同(HTTP / GATT),OTA 写入与收尾逻辑完全复用本核心。 + */ +#ifndef ESP32_OTA_CORE_H +#define ESP32_OTA_CORE_H + +#include +#include "esp_err.h" +#include "esp_ota_ops.h" +#include "esp_partition.h" + +class Esp32OtaCore { +public: + static Esp32OtaCore& instance(); + + // ── 阶段 1:开启 OTA 会话 ── + // total_size: 固件总大小(字节),用于分区大小校验和传给 esp_ota_begin + // 返回 ESP_OK / ESP_ERR_INVALID_STATE(被其它通道占用)/ ESP_FAIL + esp_err_t begin(size_t total_size); + + // ── 阶段 2:写入固件数据(可在 begin 后多次调用)── + // 纯 esp_ota_write,不做增量 SHA(SHA 在 finalize 时统一从分区读回计算) + esp_err_t write(const uint8_t *data, size_t len); + + // ── 阶段 3:收尾(end → 校验 SHA → set_boot → 可选重启)── + // frontend_sha_hex: 前端提供的 64 字符 hex SHA-256,nullptr 表示跳过校验直接应用 + // reboot: true 则 set_boot 成功后延时 500ms 调 esp_restart() + // out_device_sha_hex: 输出设备端计算出的 SHA hex(缓冲区至少 65 字节),可为 nullptr + // 返回: + // ESP_OK 成功(若 reboot=true,本调用不会返回) + // ESP_ERR_INVALID_VERSION SHA 不匹配(已 abort 并清理) + // 其他 end/set_boot 失败(已清理) + esp_err_t finalize(const char *frontend_sha_hex, bool reboot, + char *out_device_sha_hex); + + // ── 仅计算设备端 SHA(不 end、不 set_boot、不重启)── + // 供前端只读校验场景(如 WiFi verify_only 模式) + // out_device_sha_hex: 输出缓冲区,至少 65 字节 + // 返回 ESP_OK / 失败 + esp_err_t computeDeviceSha(char *out_device_sha_hex); + + // ── 中止并清理(abort + 释放 mutex + 状态复位)── + // 若 esp_ota_end 已成功(m_ended=true),则跳过 esp_ota_abort 仅复位状态 + void abort(); + + // ── 状态查询 ── + bool isActive() const { return m_active; } + size_t getReceived() const { return m_received; } + bool isRebootPending() const { return m_rebootPending; } + void setRebootPending(bool v) { m_rebootPending = v; } + +private: + Esp32OtaCore() = default; + + // 从 OTA 分区读回计算 SHA-256(WiFi 风格),结果写入 m_sha256,置 m_shaComputed=true + esp_err_t computeShaFromPartition(); + + // 状态全复位(不调 esp_ota_abort,由调用方根据 m_ended 决定) + void resetState(); + + bool m_active = false; + esp_ota_handle_t m_handle = 0; + const esp_partition_t *m_partition = nullptr; + size_t m_received = 0; + uint8_t m_sha256[32] = {0}; + bool m_shaComputed = false; + bool m_ended = false; // ★ esp_ota_end 成功后置 true,防 abort 误用 + bool m_rebootPending = false; // BLE 收尾任务去重用 +}; + +#endif // ESP32_OTA_CORE_H diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 81d167c..79f0536 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升级 + * - CTRL (0x0001, Write): 0xF7=触发GD32中继, 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): 固件版本 @@ -34,13 +34,12 @@ #include "services/gatt/ble_svc_gatt.h" #include "host/ble_att.h" -// SHA-256 哈希计算 -#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS -#include "mbedtls/private/sha256.h" +// SHA-256 计算已抽离到 Esp32OtaCore,本文件不再直接引用 mbedtls #include "app_config.h" #include "ota_mutex.h" #include "ble_ota.h" +#include "esp32_ota_core.h" #include "ota_relay.h" #include "health_monitor.h" #include "monitor/debug_log.h" @@ -48,6 +47,7 @@ #define TAG "BLE_OTA" #define BLE_OTA_NOTIFY_ACK 0xA1 #define BLE_OTA_NOTIFY_FINISH 0xB1 +#define BLE_OTA_NOTIFY_SHA256 0xC1 // 设备端 SHA-256 上报([0xC1] + [32字节]) #define BLE_OTA_FINISH_BEGIN_OTA_END 0x01 #define BLE_OTA_FINISH_DONE_OTA_END 0x02 #define BLE_OTA_FINISH_BEGIN_SET_BOOT 0x03 @@ -61,6 +61,7 @@ #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 不匹配 // ============================================================ // 前向声明 @@ -79,110 +80,91 @@ BleOta& BleOta::instance() // File-scope val_handle for GATT service definition (struct literal needs compile-time address) static uint16_t s_data_out_val_handle = 0; -// ESP32 自身 OTA 状态 +// ESP32 自身 OTA 收尾任务(OTA 写入/校验/重启逻辑委托给 Esp32OtaCore) /** * @brief 异步完成 ESP32 OTA 收尾并触发重启。 + * + * 实际的 esp_ota_end / SHA 校验 / esp_ota_set_boot_partition / esp_restart 全部 + * 由 Esp32OtaCore::finalize 完成。本任务仅负责:补发 ACK → 调 finalize → 上报 SHA → 通知前端。 * @param pvParameters 任务参数,当前未使用。 */ -static void ble_esp32_finish_and_reboot_task(void *pvParameters) +/** + * @brief 0xFA 重启任务:为了不卡 NimBLE host task,仅在这里推送 FINISH 后 500ms 重启。 + * @param pvParameters 任务参数,未使用。 + */ +static void ble_esp32_restart_task(void *pvParameters) { (void)pvParameters; + DEBUG_INFO(TAG, "[RESTART] 推送 FINISH_REBOOT_NOW 后 500ms 重启"); + sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + // 不会返回 +} + +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; DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动"); + Esp32OtaCore &core = Esp32OtaCore::instance(); + + // 补发最终 ACK,让前端尽快得知最后一包已收到 if (BleOta::instance().m_esp32LastSeq != 0xFFFFU) { - sendWindowAck(BleOta::instance().m_esp32LastCmd, BleOta::instance().m_esp32LastSeq, (uint32_t)BleOta::instance().m_esp32Received); + sendWindowAck(BleOta::instance().m_esp32LastCmd, + BleOta::instance().m_esp32LastSeq, + (uint32_t)core.getReceived()); DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq); } vTaskDelay(pdMS_TO_TICKS(150)); - if (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) { + if (!core.isActive()) { DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启"); + if (frontend_sha_hex) free(frontend_sha_hex); BleOta::instance().m_esp32RebootPending = false; vTaskDelete(NULL); return; } - DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节, 设置启动分区...", (unsigned int)BleOta::instance().m_esp32Received); + DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); - // 完成 SHA-256 哈希计算 - if (BleOta::instance().m_sha256Ctx != nullptr) { - mbedtls_sha256_finish(BleOta::instance().m_sha256Ctx, BleOta::instance().m_esp32Sha256); - mbedtls_sha256_free(BleOta::instance().m_sha256Ctx); - BleOta::instance().m_esp32HashComputed = true; - - // 打印SHA-256哈希值 - char hex[65] = {0}; - for (int i = 0; i < 32; ++i) { - snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", BleOta::instance().m_esp32Sha256[i]); - } - DEBUG_INFO(TAG, "ESP32 固件 SHA-256: %s", hex); - } - - int64_t t0_us = esp_timer_get_time(); - DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_end()"); + // 通知前端:开始 finalize sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); - esp_err_t err = esp_ota_end(BleOta::instance().m_esp32OtaHandle); - int64_t t1_us = esp_timer_get_time(); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "[FINISH] esp_ota_end 失败: %s (耗时=%lld ms)", - esp_err_to_name(err), (long long)((t1_us - t0_us) / 1000)); + + char device_sha_hex[65] = {0}; + esp_err_t err = core.finalize(frontend_sha_hex, + /*reboot=*/true, + device_sha_hex); + // reboot=true 时 finalize 正常不会返回(core 内部 esp_restart) + + if (err == ESP_OK) { + // 极端情况:reboot 未执行(理论上不会发生),仍上报并标记 + if (device_sha_hex[0] != '\0') { + uint8_t sha_pkt[33] = {0}; + sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; + 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; + } + BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); + } + sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0); + sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); + } else if (err == ESP_ERR_INVALID_VERSION) { + DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); + sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0); + BleOta::instance().m_esp32RebootPending = false; + } else { + DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err)); sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err); - esp_ota_abort(BleOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - BleOta::instance().m_esp32OtaActive = false; - BleOta::instance().m_esp32OtaHandle = 0; - BleOta::instance().m_esp32UpdatePartition = NULL; - BleOta::instance().m_esp32Received = 0; BleOta::instance().m_esp32RebootPending = false; - vTaskDelete(NULL); - return; } - DEBUG_INFO(TAG, "[FINISH] esp_ota_end 完成 (耗时=%lld ms)", - (long long)((t1_us - t0_us) / 1000)); - sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, (uint32_t)((t1_us - t0_us) / 1000)); - int64_t t2_us = esp_timer_get_time(); - DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_set_boot_partition()"); - sendFinishEvent(BLE_OTA_FINISH_BEGIN_SET_BOOT, 0); - err = esp_ota_set_boot_partition(BleOta::instance().m_esp32UpdatePartition); - int64_t t3_us = esp_timer_get_time(); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "[FINISH] 设置启动分区失败: %s (耗时=%lld ms)", - esp_err_to_name(err), (long long)((t3_us - t2_us) / 1000)); - sendFinishEvent(BLE_OTA_FINISH_ERR_SET_BOOT, (uint32_t)err); - esp32_ota_mutex_release(); - BleOta::instance().m_esp32OtaActive = false; - BleOta::instance().m_esp32OtaHandle = 0; - BleOta::instance().m_esp32UpdatePartition = NULL; - BleOta::instance().m_esp32Received = 0; - BleOta::instance().m_esp32RebootPending = false; - vTaskDelete(NULL); - return; - } - DEBUG_INFO(TAG, "[FINISH] esp_ota_set_boot_partition 完成 (耗时=%lld ms)", - (long long)((t3_us - t2_us) / 1000)); - sendFinishEvent(BLE_OTA_FINISH_DONE_SET_BOOT, (uint32_t)((t3_us - t2_us) / 1000)); - - esp32_ota_mutex_release(); - BleOta::instance().m_esp32OtaActive = false; - BleOta::instance().m_esp32OtaHandle = 0; - BleOta::instance().m_esp32UpdatePartition = NULL; - BleOta::instance().m_esp32Received = 0; - // SHA-256哈希已在之前计算完成,保留供前端读取 - - DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,800ms 后重启..."); - int64_t t4_us = esp_timer_get_time(); - DEBUG_INFO(TAG, "[FINISH] 即将进入 800ms 重启前等待"); - sendFinishEvent(BLE_OTA_FINISH_BEGIN_DELAY, 800); - vTaskDelay(pdMS_TO_TICKS(800)); - int64_t t5_us = esp_timer_get_time(); - DEBUG_INFO(TAG, "[FINISH] 800ms 等待结束 (实际耗时=%lld ms)", - (long long)((t5_us - t4_us) / 1000)); - sendFinishEvent(BLE_OTA_FINISH_DONE_DELAY, (uint32_t)((t5_us - t4_us) / 1000)); - DEBUG_INFO(TAG, "ESP32 即将重启"); - sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); - esp_restart(); + if (frontend_sha_hex) free(frontend_sha_hex); + vTaskDelete(NULL); } // ============================================================ @@ -203,131 +185,37 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len) return (int)copy_len; } -/** - * @brief 清理 SHA-256 上下文和哈希状态 - */ -static void ble_esp32_ota_cleanup_sha() -{ - if (BleOta::instance().m_sha256Ctx != nullptr) { - mbedtls_sha256_free(BleOta::instance().m_sha256Ctx); - delete BleOta::instance().m_sha256Ctx; - BleOta::instance().m_sha256Ctx = nullptr; - } - BleOta::instance().m_esp32HashComputed = false; - memset(BleOta::instance().m_esp32Sha256, 0, sizeof(BleOta::instance().m_esp32Sha256)); -} +// ───────────────────────────────────────────────────────────── +// ESP32 OTA 薄包装:实际 begin/write/finalize 已抽离到 Esp32OtaCore +// 此处仅保留 BLE 侧的状态复位与日志,供 data_in_access 调用。 +// ───────────────────────────────────────────────────────────── /** - * @brief 初始化 ESP32 本机 OTA 写入会话并选择目标分区。 + * @brief 初始化 ESP32 本机 OTA 写入会话。 + * 委托 Esp32OtaCore::begin,并复位 BLE 传输相关状态。 * @param total_size 固件总大小(字节)。 * @return ESP_OK 成功,其他为失败错误码。 */ static esp_err_t ble_esp32_ota_begin(size_t total_size) { - // ★ 如果 BLE 自身已有未完成的 OTA 会话,先中止并释放全局互斥 - if (BleOta::instance().m_esp32OtaActive) { - DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA,会先中止旧会话"); - esp_ota_abort(BleOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - BleOta::instance().m_esp32OtaActive = false; - BleOta::instance().m_esp32OtaHandle = 0; - BleOta::instance().m_esp32UpdatePartition = NULL; - BleOta::instance().m_esp32Received = 0; - BleOta::instance().m_esp32RebootPending = false; - BleOta::instance().m_esp32LastSeq = 0xFFFFU; - } - - // ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用 - if (!esp32_ota_mutex_acquire(1000)) { - DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 BLE OTA begin"); - return ESP_ERR_INVALID_STATE; - } - - const esp_partition_t *boot_partition = esp_ota_get_running_partition(); - DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx", - boot_partition->label, boot_partition->size, boot_partition->address); - - BleOta::instance().m_esp32UpdatePartition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); - if (BleOta::instance().m_esp32UpdatePartition == NULL || - BleOta::instance().m_esp32UpdatePartition->address == boot_partition->address) { - BleOta::instance().m_esp32UpdatePartition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); - } - - if (BleOta::instance().m_esp32UpdatePartition == NULL) { - DEBUG_ERR(TAG, "找不到可用的 OTA 分区"); - esp32_ota_mutex_release(); - return ESP_FAIL; - } - - DEBUG_INFO(TAG, "ESP32 OTA 目标分区: %s, size=%u, address=0x%lx", - BleOta::instance().m_esp32UpdatePartition->label, - BleOta::instance().m_esp32UpdatePartition->size, - BleOta::instance().m_esp32UpdatePartition->address); - - if (total_size > BleOta::instance().m_esp32UpdatePartition->size) { - DEBUG_ERR(TAG, "ESP32 固件太大: %u > 分区大小: %u", - total_size, BleOta::instance().m_esp32UpdatePartition->size); - BleOta::instance().m_esp32UpdatePartition = NULL; - esp32_ota_mutex_release(); - return ESP_FAIL; - } - - // 用分区大小初始化 OTA,而非包头声明的 total_size - // esp_ota_end 会从固件镜像头读取实际大小来校验,不依赖此处传入的 size - esp_err_t err = esp_ota_begin(BleOta::instance().m_esp32UpdatePartition, - BleOta::instance().m_esp32UpdatePartition->size, - &BleOta::instance().m_esp32OtaHandle); + esp_err_t err = Esp32OtaCore::instance().begin(total_size); if (err != ESP_OK) { - DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err)); - BleOta::instance().m_esp32UpdatePartition = NULL; - BleOta::instance().m_esp32OtaHandle = 0; - esp32_ota_mutex_release(); return err; } - - BleOta::instance().m_esp32Received = 0; - BleOta::instance().m_esp32OtaActive = true; + // 复位 BLE 传输相关状态 BleOta::instance().m_esp32RebootPending = false; BleOta::instance().m_esp32LastCmd = 0; BleOta::instance().m_esp32LastSeq = 0xFFFFU; - - // 初始化 SHA-256 计算上下文 - if (BleOta::instance().m_sha256Ctx == nullptr) { - BleOta::instance().m_sha256Ctx = new mbedtls_sha256_context(); - } - mbedtls_sha256_init(BleOta::instance().m_sha256Ctx); - mbedtls_sha256_starts(BleOta::instance().m_sha256Ctx, 0); - BleOta::instance().m_esp32HashComputed = false; - memset(BleOta::instance().m_esp32Sha256, 0, sizeof(BleOta::instance().m_esp32Sha256)); - - DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s", BleOta::instance().m_esp32UpdatePartition->label); return ESP_OK; } /** - * @brief 向 ESP32 OTA 分区写入一段固件数据。 - * @param data 待写入数据指针。 - * @param len 写入长度(字节)。 - * @return ESP_OK 成功,其他为失败错误码。 + * @brief 中止 ESP32 OTA 会话并清理(薄包装)。 */ -static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len) +static void ble_esp32_ota_abort() { - if (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) { - return ESP_ERR_INVALID_STATE; - } - - // 更新 SHA-256 哈希计算 - if (BleOta::instance().m_sha256Ctx != nullptr) { - mbedtls_sha256_update(BleOta::instance().m_sha256Ctx, data, len); - } - - esp_err_t err = esp_ota_write(BleOta::instance().m_esp32OtaHandle, data, len); - if (err == ESP_OK) { - BleOta::instance().m_esp32Received += len; - } - return err; + Esp32OtaCore::instance().abort(); + BleOta::instance().m_esp32RebootPending = false; } /** @@ -407,14 +295,14 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, return BLE_ATT_ERR_REQ_NOT_SUPPORTED; } - uint8_t buf[4] = {0}; + uint8_t buf[36] = {0}; int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); if (len < 1) { DEBUG_WARN(TAG, "CTRL 数据为空"); return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; } - DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X", buf[0]); + DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len); if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) { // 检查固件是否存储完毕 → 触发 GD32 OTA 中继 @@ -431,7 +319,7 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, meta.stored_size, meta.total_size); } } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { - if (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) { + if (!Esp32OtaCore::instance().isActive()) { DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); return 0; } @@ -440,66 +328,63 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, return 0; } + // 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对, + // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 + if (len < 33) { + DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发"); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_ARG); + return 0; + } + + char *frontend_sha_hex = (char *)malloc(65); + if (!frontend_sha_hex) { + DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败"); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_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]); + } + frontend_sha_hex[64] = '\0'; + DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); + BleOta::instance().m_esp32RebootPending = true; BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, "ble_ota_finish", 4096, - NULL, + frontend_sha_hex, // heap string; task 负责 free 5, NULL); if (ok != pdPASS) { DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + free(frontend_sha_hex); BleOta::instance().m_esp32RebootPending = false; } else { - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(由设备端异步完成)"); + DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); } - } else if (buf[0] == BLE_OTA_CTRL_VERIFY_SHA256) { - // SHA-256 哈希验证命令 - // 期望数据格式: [cmd(1)] + [sha256(32)] - if (len < 33) { - DEBUG_WARN(TAG, "SHA-256 验证数据长度不足: %d (需要33)", len); - return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; - } - - // 提取前端发送的SHA-256哈希 - uint8_t frontend_sha[32]; - memcpy(frontend_sha, &buf[1], 32); - - // 打印前端SHA-256 - char frontend_hex[65] = {0}; - for (int i = 0; i < 32; ++i) { - snprintf(&frontend_hex[i * 2], sizeof(frontend_hex) - i * 2, "%02X", frontend_sha[i]); - } - DEBUG_INFO(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - DEBUG_INFO(TAG, "📊 SHA-256 哈希对比验证"); - DEBUG_INFO(TAG, " 前端计算: %s", frontend_hex); - - // 检查设备端是否已计算SHA-256 - if (!BleOta::instance().m_esp32HashComputed) { - DEBUG_WARN(TAG, "⚠️ 设备端尚未完成SHA-256计算"); - sendFinishEvent(0xA0, 0); // 自定义事件:哈希未就绪 + } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) { + // 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host + DEBUG_INFO(TAG, "收到 0xFA 重启请求,创建 restart task(500ms 后 esp_restart())"); + if (BleOta::instance().m_esp32RebootPending) { + DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL"); return 0; } - - // 打印设备端SHA-256 - char device_hex[65] = {0}; - for (int i = 0; i < 32; ++i) { - snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02X", BleOta::instance().m_esp32Sha256[i]); - } - DEBUG_INFO(TAG, " 设备计算: %s", device_hex); - - // 对比哈希值 - if (memcmp(frontend_sha, BleOta::instance().m_esp32Sha256, 32) == 0) { - DEBUG_INFO(TAG, "✅ SHA-256 验证通过!固件传输完整无误"); - DEBUG_INFO(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - sendFinishEvent(0xA1, 1); // 自定义事件:验证成功 - } else { - DEBUG_ERR(TAG, "❌ SHA-256 验证失败!固件传输过程中出现错误"); - DEBUG_ERR(TAG, " 请检查BLE链路质量并重试"); - DEBUG_ERR(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - sendFinishEvent(0xA2, 0); // 自定义事件:验证失败 + BleOta::instance().m_esp32RebootPending = true; + BaseType_t ok = xTaskCreate(ble_esp32_restart_task, + "ble_ota_restart", + 2048, + NULL, + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 0xFA 重启任务失败"); + BleOta::instance().m_esp32RebootPending = false; } } + // 0xF7 = 触发 GD32 中继 + // 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对, + // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启) + // 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连) return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0 } @@ -590,22 +475,7 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size); return BLE_ATT_ERR_UNLIKELY; } - const esp_partition_t *boot_partition = esp_ota_get_running_partition(); - const esp_partition_t *check_partition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); - if (check_partition == NULL || check_partition->address == boot_partition->address) { - check_partition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); - } - if (check_partition == NULL) { - DEBUG_ERR(TAG, "找不到 ESP32 OTA 目标分区"); - return BLE_ATT_ERR_UNLIKELY; - } - if (total_size > check_partition->size) { - DEBUG_ERR(TAG, "ESP32 固件太大: %" PRIu32 " > OTA分区 %u", - total_size, check_partition->size); - return BLE_ATT_ERR_UNLIKELY; - } + // 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成 esp_err_t err = ble_esp32_ota_begin(total_size); if (err != ESP_OK) { if (err == ESP_ERR_INVALID_STATE) { @@ -616,66 +486,39 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, } } - esp_err_t err = ble_esp32_ota_write(&buf[data_offset], data_len); + esp_err_t err = Esp32OtaCore::instance().write(&buf[data_offset], data_len); if (err != ESP_OK) { DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s", seq, esp_err_to_name(err)); - esp_ota_abort(BleOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - BleOta::instance().m_esp32OtaActive = false; - BleOta::instance().m_esp32OtaHandle = 0; - BleOta::instance().m_esp32UpdatePartition = NULL; - ble_esp32_ota_cleanup_sha(); // 清理SHA状态 + ble_esp32_ota_abort(); // core 内部完成 abort + mutex release + 状态复位 return BLE_ATT_ERR_UNLIKELY; } DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u", - seq, (unsigned int)BleOta::instance().m_esp32Received); + seq, (unsigned int)Esp32OtaCore::instance().getReceived()); // 尾包检测: 期望 payload < 标准负载说明是真正的最后一包 // 使用 expected_payload 而非 data_len,避免缓冲区截断导致误触发重启 bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD); if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || - (total_size > 0 && BleOta::instance().m_esp32Received >= total_size) || + (total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) { if (isLastPacket) { DEBUG_INFO(TAG, "ESP32 尾包检测: seq=%u data_len=%zu received=%u total=%" PRIu32, - seq, data_len, (unsigned int)BleOta::instance().m_esp32Received, total_size); + seq, data_len, (unsigned int)Esp32OtaCore::instance().getReceived(), total_size); } - sendWindowAck(cmd, seq, (uint32_t)BleOta::instance().m_esp32Received); + sendWindowAck(cmd, seq, (uint32_t)Esp32OtaCore::instance().getReceived()); } - if ((total_size > 0 && BleOta::instance().m_esp32Received >= total_size) || isLastPacket) { + if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) { DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", - (unsigned int)BleOta::instance().m_esp32Received, total_size, seq + 1, + (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1, isLastPacket ? " [尾包]" : ""); BleOta::instance().m_esp32LastCmd = cmd; BleOta::instance().m_esp32LastSeq = seq; - - if (!BleOta::instance().m_esp32RebootPending) { - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - 4096, - NULL, - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - esp_ota_abort(BleOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - BleOta::instance().m_esp32OtaActive = false; - BleOta::instance().m_esp32OtaHandle = 0; - BleOta::instance().m_esp32UpdatePartition = NULL; - BleOta::instance().m_esp32RebootPending = false; - ble_esp32_ota_cleanup_sha(); // 清理SHA状态 - return BLE_ATT_ERR_UNLIKELY; - } - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启"); - } else { - DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发"); - } + // 注:与 WiFi 流程对齐,传输完成后由前端发送 0xF8 + SHA-256 触发 finalize, + // 设备端不再自动创建收尾任务。 } } // --- GD32 OTA:首个分块时创建存储元数据 --- diff --git a/main/server/ble_ota.h b/main/server/ble_ota.h index 35f3d19..6013e86 100644 --- a/main/server/ble_ota.h +++ b/main/server/ble_ota.h @@ -1,6 +1,9 @@ /* * ble_ota.h - BLE OTA GATT 服务器 * T5.1 - T5.5 + * + * ESP32 本机 OTA 的写入/收尾逻辑已抽离到 Esp32OtaCore(WiFi/BLE 共用)。 + * 本类仅保留 BLE 传输层相关状态(连接、收尾任务去重、ACK 窗口)。 */ #ifndef BLE_OTA_H #define BLE_OTA_H @@ -9,9 +12,6 @@ #include "esp_err.h" #include "esp_ota_ops.h" -// 前向声明 mbedtls_sha256_context -typedef struct mbedtls_sha256_context mbedtls_sha256_context; - class BleOta { public: static BleOta& instance(); @@ -27,18 +27,10 @@ public: bool m_bleConnected = false; uint16_t m_connHandle = 0xFFFF; - bool m_esp32OtaActive = false; - esp_ota_handle_t m_esp32OtaHandle = 0; - const esp_partition_t *m_esp32UpdatePartition = NULL; - size_t m_esp32Received = 0; - bool m_esp32RebootPending = false; - uint8_t m_esp32LastCmd = 0; - uint16_t m_esp32LastSeq = 0xFFFFU; - - // SHA-256 哈希校验 - mbedtls_sha256_context *m_sha256Ctx = nullptr; // SHA-256计算上下文 - uint8_t m_esp32Sha256[32] = {0}; // 计算得到的SHA-256哈希值 - bool m_esp32HashComputed = false; // 是否已计算哈希 + // BLE 传输相关(OTA 写入状态已移至 Esp32OtaCore) + bool m_esp32RebootPending = false; // 收尾任务去重标志 + uint8_t m_esp32LastCmd = 0; // 最后一次数据包命令(用于补发 ACK) + uint16_t m_esp32LastSeq = 0xFFFFU; // 最后一次数据包序号(用于补发 ACK) }; -#endif // BLE_OTA_H \ No newline at end of file +#endif // BLE_OTA_H diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 9e135c5..a24c1e1 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -28,6 +28,7 @@ #include "app_config.h" #include "ota_mutex.h" #include "wifi_ota.h" +#include "esp32_ota_core.h" #include "ota_relay.h" #include "health_monitor.h" #include "monitor/debug_log.h" @@ -427,89 +428,25 @@ void WifiOta::setStaticIp() // ---------------------------------------- // ESP32 OTA 内部函数 +// 注:begin/write/finalize/abort 已抽离到 Esp32OtaCore(main/ota/esp32_ota_core.{h,cpp}) +// WiFi 与 BLE 通道共用同一核心,本文件仅负责 HTTP 传输层。 -/** - * @brief 清理 WiFi OTA 的 SHA-256 上下文和哈希状态 - */ -static void wifi_esp32_ota_cleanup_sha() -{ - WifiOta::instance().m_esp32HashComputed = false; - memset(WifiOta::instance().m_esp32Sha256, 0, sizeof(WifiOta::instance().m_esp32Sha256)); - WifiOta::instance().m_hasFrontendSha256 = false; - memset(WifiOta::instance().m_frontendSha256, 0, sizeof(WifiOta::instance().m_frontendSha256)); -} -/** - * @brief 初始化 ESP32 自身的 OTA 写入流程。 - * @param total_size - * @return ESP_OK 成功,其他失败。 - */ -static esp_err_t esp32_ota_begin(size_t total_size) -{ - // ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用 - if (!esp32_ota_mutex_acquire(1000)) { - DEBUG_ERR("OTA", "ESP32 OTA 被其他通道占用,拒绝 WiFi OTA begin"); - return ESP_ERR_INVALID_STATE; - } - - const esp_partition_t *boot_partition = esp_ota_get_running_partition(); - DEBUG_INFO("OTA", "当前启动分区: %s, size=%u, address=0x%lx", - boot_partition->label, boot_partition->size, boot_partition->address); - - WifiOta::instance().m_esp32UpdatePartition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); - if (WifiOta::instance().m_esp32UpdatePartition == NULL || - WifiOta::instance().m_esp32UpdatePartition->address == boot_partition->address) { - WifiOta::instance().m_esp32UpdatePartition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); - } - - if (WifiOta::instance().m_esp32UpdatePartition == NULL) { - DEBUG_ERR("OTA", "找不到可用的 OTA 分区"); - esp32_ota_mutex_release(); - return ESP_FAIL; - } - - DEBUG_INFO("OTA", "OTA 目标分区: %s, size=%u, address=0x%lx", - WifiOta::instance().m_esp32UpdatePartition->label, - WifiOta::instance().m_esp32UpdatePartition->size, - WifiOta::instance().m_esp32UpdatePartition->address); - - if (total_size > WifiOta::instance().m_esp32UpdatePartition->size) { - DEBUG_ERR("OTA", "固件太大: %u > 分区大小: %u", - total_size, WifiOta::instance().m_esp32UpdatePartition->size); - esp32_ota_mutex_release(); - return ESP_FAIL; - } - - esp_err_t err = esp_ota_begin(WifiOta::instance().m_esp32UpdatePartition, total_size, &WifiOta::instance().m_esp32OtaHandle); - if (err != ESP_OK) { - DEBUG_ERR("OTA", "esp_ota_begin 失败: %s", esp_err_to_name(err)); - WifiOta::instance().m_esp32UpdatePartition = NULL; - esp32_ota_mutex_release(); - return err; - } - WifiOta::instance().m_esp32Received = 0; - WifiOta::instance().m_esp32OtaActive = true; - - // 清理旧状态(SHA-256 将在重启前从分区读回计算,上传期间不再计算) - wifi_esp32_ota_cleanup_sha(); - - DEBUG_INFO("OTA", "ESP32 OTA 开始,目标分区: %s", WifiOta::instance().m_esp32UpdatePartition->label); - return ESP_OK; -} /** * @brief HTTP POST /ota/update - 完成 ESP32 OTA 并重启 * verify_only=true: 仅校验 SHA-256,不设置启动分区,不重启 * verify_only=false或缺省: 完整流程(校验 + 设置启动分区 + 重启) + * + * 实际的 end/verify/set_boot/restart 逻辑全部委托给 Esp32OtaCore。 * @param req * @return */ static esp_err_t esp32_ota_update_handler(httpd_req_t *req) { http_set_cors_headers(req); - if (!WifiOta::instance().m_esp32OtaActive) { + Esp32OtaCore &core = Esp32OtaCore::instance(); + if (!core.isActive()) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA not started"); return ESP_FAIL; } @@ -532,9 +469,6 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req) strncpy(frontend_sha_hex, sha_str, 64); frontend_sha_hex[64] = '\0'; has_frontend_sha = true; - strncpy(WifiOta::instance().m_frontendSha256, sha_str, 64); - WifiOta::instance().m_frontendSha256[64] = '\0'; - WifiOta::instance().m_hasFrontendSha256 = true; DEBUG_INFO("OTA", "Frontend SHA-256: %s", frontend_sha_hex); } } @@ -547,144 +481,54 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req) } } - if (verify_only) { - DEBUG_INFO("OTA", "ESP32 OTA verify-only mode, %u bytes", (unsigned)WifiOta::instance().m_esp32Received); - } else { - DEBUG_INFO("OTA", "ESP32 OTA complete, %u bytes, setting boot partition...", (unsigned)WifiOta::instance().m_esp32Received); + char device_sha_hex[65] = {0}; - esp_err_t err = esp_ota_end(WifiOta::instance().m_esp32OtaHandle); - if (err != ESP_OK) { - DEBUG_ERR("OTA", "esp_ota_end failed: %s", esp_err_to_name(err)); - esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - WifiOta::instance().m_esp32OtaActive = false; - WifiOta::instance().m_esp32OtaHandle = 0; - wifi_esp32_ota_cleanup_sha(); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); + // ── verify_only 模式:只计算并返回设备端 SHA,不 end/set_boot/reboot ── + if (verify_only) { + DEBUG_INFO("OTA", "ESP32 OTA verify-only mode, %u bytes", (unsigned)core.getReceived()); + esp_err_t sha_err = core.computeDeviceSha(device_sha_hex); + if (sha_err != ESP_OK) { + DEBUG_ERR("OTA", "verify-only: SHA 计算失败: %s", esp_err_to_name(sha_err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(sha_err)); return ESP_FAIL; } - } - - // ── SHA-256 验证:从 OTA 分区读回并计算哈希 ── - if (has_frontend_sha && WifiOta::instance().m_esp32UpdatePartition != NULL) { - DEBUG_INFO("OTA", "Verifying: reading OTA partition for SHA-256..."); - const esp_partition_t *part = WifiOta::instance().m_esp32UpdatePartition; - mbedtls_sha256_context verify_ctx; - mbedtls_sha256_init(&verify_ctx); - mbedtls_sha256_starts(&verify_ctx, 0); - - uint8_t *read_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); - if (!read_buf) { - DEBUG_ERR("OTA", "SHA-256 verify: malloc failed"); - mbedtls_sha256_free(&verify_ctx); - } else { - size_t offset = 0; - while (offset < WifiOta::instance().m_esp32Received) { - size_t to_read = WifiOta::instance().m_esp32Received - offset; - if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; - esp_err_t rd_err = esp_partition_read(part, offset, read_buf, to_read); - if (rd_err != ESP_OK) { - DEBUG_ERR("OTA", "SHA-256 verify: partition read failed at offset %u", (unsigned int)offset); - break; - } - mbedtls_sha256_update(&verify_ctx, read_buf, to_read); - offset += to_read; - } - free(read_buf); - mbedtls_sha256_finish(&verify_ctx, WifiOta::instance().m_esp32Sha256); - mbedtls_sha256_free(&verify_ctx); - WifiOta::instance().m_esp32HashComputed = true; - - char device_sha_hex[65] = {0}; - for (int i = 0; i < 32; ++i) { - snprintf(&device_sha_hex[i * 2], sizeof(device_sha_hex) - i * 2, "%02x", WifiOta::instance().m_esp32Sha256[i]); - } - DEBUG_INFO("OTA", "Device SHA-256: %s", device_sha_hex); - - bool match = true; - for (int i = 0; i < 64; i++) { - if (tolower((unsigned char)frontend_sha_hex[i]) != tolower((unsigned char)device_sha_hex[i])) { - match = false; - break; - } - } - if (match) { - DEBUG_INFO("OTA", "SHA-256 verified OK"); - if (verify_only) { - char ok_resp[256] = {0}; - snprintf(ok_resp, sizeof(ok_resp), - "{\"status\": \"ok\", \"message\": \"SHA-256 verified\", \"sha256\": \"%s\"}", - device_sha_hex); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, ok_resp, strlen(ok_resp)); - return ESP_OK; - } - } else { - DEBUG_ERR("OTA", "SHA-256 mismatch! frontend=%s device=%s, abort", frontend_sha_hex, device_sha_hex); - if (!verify_only) { - esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); - } - wifi_esp32_ota_cleanup_sha(); - esp32_ota_mutex_release(); - WifiOta::instance().m_esp32OtaActive = false; - WifiOta::instance().m_esp32OtaHandle = 0; - WifiOta::instance().m_esp32UpdatePartition = NULL; - WifiOta::instance().m_esp32Received = 0; - char err_resp[256] = {0}; - snprintf(err_resp, sizeof(err_resp), - "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}", - frontend_sha_hex, device_sha_hex); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, err_resp, strlen(err_resp)); - return ESP_FAIL; - } - } - } - - // ── 非verify_only模式:设置启动分区并重启 ── - if (verify_only) { - // 没有前端SHA或验证被跳过,在verify_only下也返回ok - char ok_resp[128] = {0}; + char ok_resp[256] = {0}; snprintf(ok_resp, sizeof(ok_resp), - "{\"status\": \"ok\", \"message\": \"verify skipped, no SHA-256 provided\"}"); + "{\"status\": \"ok\", \"message\": \"SHA-256 computed\", \"sha256\": \"%s\"}", + device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, ok_resp, strlen(ok_resp)); return ESP_OK; } - esp_err_t err = esp_ota_set_boot_partition(WifiOta::instance().m_esp32UpdatePartition); - if (err != ESP_OK) { - DEBUG_ERR("OTA", "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err)); - esp32_ota_mutex_release(); - WifiOta::instance().m_esp32OtaActive = false; - wifi_esp32_ota_cleanup_sha(); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); - return ESP_FAIL; - } - esp32_ota_mutex_release(); - WifiOta::instance().m_esp32OtaActive = false; - DEBUG_INFO("OTA", "ESP32 OTA finished, rebooting..."); + // ── 完整模式:finalize(end → 校验 → set_boot)后由 core 内部重启 ── + DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived()); + esp_err_t err = core.finalize(has_frontend_sha ? frontend_sha_hex : nullptr, + /*reboot=*/true, + device_sha_hex); - char resp[256] = {0}; - if (WifiOta::instance().m_esp32HashComputed) { - char device_sha_hex[65] = {0}; - for (int i = 0; i < 32; ++i) { - snprintf(&device_sha_hex[i * 2], sizeof(device_sha_hex) - i * 2, "%02x", WifiOta::instance().m_esp32Sha256[i]); - } + if (err == ESP_OK) { + // 正常不会走到这里(reboot=true 时 core 会 esp_restart) + char resp[256] = {0}; snprintf(resp, sizeof(resp), - "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", \"sha256\": \"%s\"}", - device_sha_hex); + "{\"status\": \"ok\", \"message\": \"OTA finished\"}"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; + } + + // 失败:core 已内部清理(abort + mutex release) + if (err == ESP_ERR_INVALID_VERSION) { + char err_resp[256] = {0}; + snprintf(err_resp, sizeof(err_resp), + "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}", + frontend_sha_hex, device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, err_resp, strlen(err_resp)); } else { - snprintf(resp, sizeof(resp), - "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\"}"); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); } - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - - vTaskDelay(pdMS_TO_TICKS(500)); - esp_restart(); - return ESP_OK; + return ESP_FAIL; } /** @@ -717,11 +561,10 @@ static esp_err_t esp32_ota_status_handler(httpd_req_t *req) { http_set_cors_headers(req); char resp[128]; - if (WifiOta::instance().m_esp32OtaActive) { + if (Esp32OtaCore::instance().isActive()) { snprintf(resp, sizeof(resp), - "{\"active\": true, \"received\": %u, \"partition\": \"%s\"}", - (unsigned int)WifiOta::instance().m_esp32Received, - WifiOta::instance().m_esp32UpdatePartition ? WifiOta::instance().m_esp32UpdatePartition->label : "null"); + "{\"active\": true, \"received\": %u}", + (unsigned int)Esp32OtaCore::instance().getReceived()); } else { snprintf(resp, sizeof(resp), "{\"active\": false}"); } @@ -820,7 +663,8 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) return ESP_FAIL; } - esp_err_t err = esp32_ota_begin(total_size); + Esp32OtaCore &core = Esp32OtaCore::instance(); + esp_err_t err = core.begin(total_size); if (err != ESP_OK) { if (err == ESP_ERR_INVALID_STATE) { httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); @@ -833,11 +677,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) // 堆分配缓冲区,避免栈溢出(HTTP server 栈仅 8KB) uint8_t *fw_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); if (!fw_buf) { - esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - WifiOta::instance().m_esp32OtaActive = false; - WifiOta::instance().m_esp32OtaHandle = 0; - wifi_esp32_ota_cleanup_sha(); + core.abort(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败"); return ESP_FAIL; } @@ -854,31 +694,22 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) if (ret <= 0) { DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret); free(fw_buf); - esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - WifiOta::instance().m_esp32OtaActive = false; - WifiOta::instance().m_esp32OtaHandle = 0; - wifi_esp32_ota_cleanup_sha(); + core.abort(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); return ESP_FAIL; } - err = esp_ota_write(WifiOta::instance().m_esp32OtaHandle, fw_buf, (size_t)ret); + err = core.write(fw_buf, (size_t)ret); if (err != ESP_OK) { - DEBUG_ERR("WIFI", "OTA write failed"); + DEBUG_ERR("WIFI", "OTA write failed: %s", esp_err_to_name(err)); free(fw_buf); - esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); - esp32_ota_mutex_release(); - WifiOta::instance().m_esp32OtaActive = false; - WifiOta::instance().m_esp32OtaHandle = 0; - wifi_esp32_ota_cleanup_sha(); + core.abort(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); return ESP_FAIL; } total_firmware_written += ret; total_received += ret; - WifiOta::instance().m_esp32Received += ret; if (total_size > 0) { int percent = (int)((total_firmware_written * 100) / total_size); @@ -891,7 +722,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) } free(fw_buf); - DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节 (跳过 JSON 头 %d 字节)", (unsigned int)WifiOta::instance().m_esp32Received, 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\"}"; httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index b76a6a2..2c6a77d 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -3,11 +3,11 @@ * T4.1 - T4.5 * * WifiOta 类封装 WiFi 初始化、HTTP OTA 服务器和网络状态查询。 + * ESP32 本机 OTA 的写入/收尾逻辑已抽离到 Esp32OtaCore(WiFi/BLE 共用)。 */ #ifndef WIFI_OTA_H #define WIFI_OTA_H -#include "ota_relay.h" #include "esp_ota_ops.h" #include "esp_http_server.h" #include "esp_event.h" @@ -31,7 +31,7 @@ public: static int8_t getRssi(); static bool isConnected(); - // ── WiFi 连接状态(WiFi 事件回调 / HTTP / reconnect 任务共享,需 mutex 保护)── + // ── WiFi 连接状态(WiFi 事件回调 / HTTP / reconnect task 共享,需 mutex 保护)── bool getStaConnected() const { return m_staConnected.load(); } void setStaConnected(bool v) { m_staConnected.store(v); } int getStaRetryCount() const { return m_staRetryCount.load(); } @@ -55,18 +55,8 @@ public: SemaphoreHandle_t m_staGotIpSem = NULL; TaskHandle_t m_reconnectTask = nullptr; - bool m_esp32OtaActive = false; - esp_ota_handle_t m_esp32OtaHandle = 0; - const esp_partition_t *m_esp32UpdatePartition = NULL; - size_t m_esp32Received = 0; httpd_handle_t m_httpServer = NULL; - // ── SHA-256 哈希校验 ── - uint8_t m_esp32Sha256[32] = {0}; // 重启前验证计算的SHA-256哈希值 - bool m_esp32HashComputed = false; // 是否已计算哈希 - char m_frontendSha256[65] = {0}; // 前端提供的SHA-256(上传时存储) - bool m_hasFrontendSha256 = false; // 前端是否提供了SHA-256 - private: // ── 跨任务共享状态(atomic + m_stateMutex 保护)── std::atomic m_staConnected{false}; @@ -86,4 +76,4 @@ private: esp_err_t waitStaConnected(char *ip_str, size_t ip_len); }; -#endif // WIFI_OTA_H \ No newline at end of file +#endif // WIFI_OTA_H diff --git a/tmp_patch_ble_cpp.py b/tmp_patch_ble_cpp.py new file mode 100644 index 0000000..a8897ba --- /dev/null +++ b/tmp_patch_ble_cpp.py @@ -0,0 +1,304 @@ +import sys + +with open('main/server/ble_ota.cpp','r',encoding='utf-8-sig') as f: + data=f.read() + +# 1. 添加 SHA_MISMATCH 错误码宏 +old='#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小' +new='#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小\n#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 // ESP32 OTA SHA-256 不匹配' +assert old in data, 'macro old not found' +data=data.replace(old,new) + +# 2. 修改 ble_esp32_finish_and_reboot_task 接受 frontend SHA 参数 +old_task='''static void ble_esp32_finish_and_reboot_task(void *pvParameters) +{ + (void)pvParameters; + DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动"); + + Esp32OtaCore &core = Esp32OtaCore::instance(); + + // 补发最终 ACK,让前端尽快得知最后一包已收到 + if (BleOta::instance().m_esp32LastSeq != 0xFFFFU) { + sendWindowAck(BleOta::instance().m_esp32LastCmd, + BleOta::instance().m_esp32LastSeq, + (uint32_t)core.getReceived()); + DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq); + } + vTaskDelay(pdMS_TO_TICKS(150)); + + if (!core.isActive()) { + DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启"); + BleOta::instance().m_esp32RebootPending = false; + vTaskDelete(NULL); + return; + } + + DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); + + // 通知前端:开始 finalize + sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); + + // 委托核心完成: esp_ota_end → SHA 计算 → set_boot_partition → esp_restart + // BLE 不传前端 SHA(BLE 协议无 finalize 前校验机制),直接应用;SHA 通过 0xC1 上报供前端比对 + char device_sha_hex[65] = {0}; + esp_err_t err = core.finalize(/*frontend_sha_hex=*/nullptr, + /*reboot=*/true, + device_sha_hex); + // reboot=true 时 finalize 正常不会返回(core 内部 esp_restart) + + if (err == ESP_OK) { + // 极端情况:reboot 未执行(理论上不会发生),仍上报并标记 + if (device_sha_hex[0] != '\\0') { + // 上报设备端 SHA-256 供前端比对显示([0xC1] + [32 字节]) + uint8_t sha_pkt[33] = {0}; + sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; + 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; + } + BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); + } + sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0); + sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); + } else { + DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err); + BleOta::instance().m_esp32RebootPending = false; + } + + vTaskDelete(NULL); +}''' + +new_task='''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; + DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动"); + + Esp32OtaCore &core = Esp32OtaCore::instance(); + + // 补发最终 ACK,让前端尽快得知最后一包已收到 + if (BleOta::instance().m_esp32LastSeq != 0xFFFFU) { + sendWindowAck(BleOta::instance().m_esp32LastCmd, + BleOta::instance().m_esp32LastSeq, + (uint32_t)core.getReceived()); + DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq); + } + vTaskDelay(pdMS_TO_TICKS(150)); + + if (!core.isActive()) { + DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启"); + if (frontend_sha_hex) free(frontend_sha_hex); + BleOta::instance().m_esp32RebootPending = false; + vTaskDelete(NULL); + return; + } + + DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); + + // 通知前端:开始 finalize + sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); + + char device_sha_hex[65] = {0}; + esp_err_t err = core.finalize(frontend_sha_hex, + /*reboot=*/true, + device_sha_hex); + // reboot=true 时 finalize 正常不会返回(core 内部 esp_restart) + + if (err == ESP_OK) { + // 极端情况:reboot 未执行(理论上不会发生),仍上报并标记 + if (device_sha_hex[0] != '\\0') { + uint8_t sha_pkt[33] = {0}; + sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; + 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; + } + BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); + } + sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0); + sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); + } else if (err == ESP_ERR_INVALID_VERSION) { + DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); + sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0); + BleOta::instance().m_esp32RebootPending = false; + } else { + DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err); + BleOta::instance().m_esp32RebootPending = false; + } + + if (frontend_sha_hex) free(frontend_sha_hex); + vTaskDelete(NULL); +}''' +assert old_task in data, 'old_task not found' +data=data.replace(old_task,new_task) + +# 3. 修改 ble_ota_ctrl_access: 扩展 buf 到 36,解析前端 SHA +old_ctrl=''' uint8_t buf[4] = {0}; + int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); + if (len < 1) { + DEBUG_WARN(TAG, "CTRL 数据为空"); + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + + DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X", buf[0]); + + if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) { + // 检查固件是否存储完毕 → 触发 GD32 OTA 中继 + 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 " 字节),触发中继", + meta.stored_size, meta.total_size); + BleOta::instance().signalRelay(); + } else { + DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")", + meta.stored_size, meta.total_size); + } + } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { + if (!Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); + return 0; + } + if (BleOta::instance().m_esp32RebootPending) { + DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); + return 0; + } + + BleOta::instance().m_esp32RebootPending = true; + BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, + "ble_ota_finish", + 4096, + NULL, + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + BleOta::instance().m_esp32RebootPending = false; + } else { + DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(由设备端异步完成)"); + } + } + // 注: BLE_OTA_CTRL_VERIFY_SHA256 (0xF9) 路径已移除。 + // SHA-256 现由 Esp32OtaCore 在 finalize 时统一从分区读回计算, + // 并通过 BLE_OTA_NOTIFY_SHA256 (0xC1) 通知前端比对,校验逻辑与 WiFi 完全一致。''' + +new_ctrl=''' uint8_t buf[36] = {0}; + int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); + if (len < 1) { + DEBUG_WARN(TAG, "CTRL 数据为空"); + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + + DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len); + + if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) { + // 检查固件是否存储完毕 → 触发 GD32 OTA 中继 + 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 " 字节),触发中继", + meta.stored_size, meta.total_size); + BleOta::instance().signalRelay(); + } else { + DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")", + meta.stored_size, meta.total_size); + } + } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { + if (!Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); + return 0; + } + if (BleOta::instance().m_esp32RebootPending) { + DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); + return 0; + } + + // 解析前端 SHA-256: [0xF8] + [32 bytes binary SHA] + char *frontend_sha_hex = nullptr; + if (len >= 33) { + frontend_sha_hex = (char *)malloc(65); + if (frontend_sha_hex) { + for (int i = 0; i < 32; ++i) { + snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]); + } + frontend_sha_hex[64] = '\\0'; + DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); + } + } else { + DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33,无法获取前端 SHA-256"); + } + + 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 + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + if (frontend_sha_hex) free(frontend_sha_hex); + BleOta::instance().m_esp32RebootPending = false; + } else { + DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); + } + } + // 注: BLE_OTA_CTRL_VERIFY_SHA256 (0xF9) 路径已移除。 + // SHA-256 现由 Esp32OtaCore 在 finalize 时统一从分区读回计算, + // 并通过 BLE_OTA_NOTIFY_SHA256 (0xC1) 通知前端比对,校验逻辑与 WiFi 完全一致。''' +assert old_ctrl in data, 'old_ctrl not found' +data=data.replace(old_ctrl,new_ctrl) + +# 4. 移除 DATA_IN 中的自动收尾代码(ESP32 收到最后一包后自动创建 finish task) +old_auto=''' if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) { + DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", + (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1, + isLastPacket ? " [尾包]" : ""); + BleOta::instance().m_esp32LastCmd = cmd; + BleOta::instance().m_esp32LastSeq = seq; + + if (!BleOta::instance().m_esp32RebootPending) { + BleOta::instance().m_esp32RebootPending = true; + BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, + "ble_ota_finish", + 4096, + NULL, + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + ble_esp32_ota_abort(); // core 内部完成 abort + mutex release + 状态复位 + BleOta::instance().m_esp32RebootPending = false; + return BLE_ATT_ERR_UNLIKELY; + } + DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启"); + } else { + DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发"); + } + } + }''' + +new_auto=''' if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) { + DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", + (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1, + isLastPacket ? " [尾包]" : ""); + BleOta::instance().m_esp32LastCmd = cmd; + BleOta::instance().m_esp32LastSeq = seq; + // 注:与 WiFi 流程对齐,传输完成后由前端发送 0xF8 + SHA-256 触发 finalize, + // 设备端不再自动创建收尾任务。 + } + }''' +assert old_auto in data, 'old_auto not found' +data=data.replace(old_auto,new_auto) + +with open('main/server/ble_ota.cpp','w',encoding='utf-8-sig') as f: + f.write(data) +print('ble_ota.cpp updated') diff --git a/tmp_patch_ble_cpp2.py b/tmp_patch_ble_cpp2.py new file mode 100644 index 0000000..c6a59e2 --- /dev/null +++ b/tmp_patch_ble_cpp2.py @@ -0,0 +1,147 @@ +import sys + +path = r'main\server\ble_ota.cpp' +with open(path, 'r', encoding='utf-8-sig') as f: + data = f.read() + +# 1. 文件头注释:补充 0xFA 说明 +old = ' * - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级\n' +new = ' * - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级(含 32B SHA-256 校验并 reboot),0xFA=重启ESP32\n' +assert old in data, 'header old not found' +data = data.replace(old, new, 1) + +# 2. 0xF8 分支:移除 "无 SHA 跳过比对" 路径,强制要求 32B SHA +old_f8 = ''' } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { + if (!Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); + return 0; + } + if (BleOta::instance().m_esp32RebootPending) { + DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); + return 0; + } + + // 解析前端 SHA-256: [0xF8] + [32 bytes binary SHA] + char *frontend_sha_hex = nullptr; + if (len >= 33) { + frontend_sha_hex = (char *)malloc(65); + if (frontend_sha_hex) { + for (int i = 0; i < 32; ++i) { + snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]); + } + frontend_sha_hex[64] = '\\0'; + DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); + } + } else { + // 前端未传 SHA,说明已经走过 0xF9 先校验、收尾不再重复比对 + DEBUG_INFO(TAG, "CTRL 0xF8 未传入 SHA-256,finalize 将跳过 SHA 比对与重启前控制流"); + } + + 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 + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + if (frontend_sha_hex) free(frontend_sha_hex); + BleOta::instance().m_esp32RebootPending = false; + } else { + DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); + } + } else if (buf[0] == BLE_OTA_CTRL_VERIFY_SHA256) { + // 仅校验:从 OTA 分区读回计算 SHA-256,通过 0xC1 上报。不 end / 不 set_boot / 不重启。 + Esp32OtaCore &core = Esp32OtaCore::instance(); + if (!core.isActive()) { + DEBUG_WARN(TAG, "0xF9 请求被拒绝:ESP32 OTA 会话未激活"); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_STATE); + return 0; + } + char device_sha_hex[65] = {0}; + esp_err_t sha_err = core.computeDeviceSha(device_sha_hex); + if (sha_err != ESP_OK) { + DEBUG_ERR(TAG, "0xF9 请求:从 OTA 分区计算 SHA-256 失败: %s", esp_err_to_name(sha_err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)sha_err); + return 0; + } + uint8_t sha_pkt[33] = {0}; + sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; + 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; + } + esp_err_t notify_err = BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); + if (notify_err != ESP_OK) { + DEBUG_WARN(TAG, "0xF9 请求:上报 SHA-256 notify 失败: %s", esp_err_to_name(notify_err)); + } else { + DEBUG_INFO(TAG, "0xF9 请求:已上报设备端 SHA-256,前端可继续比对,%s%s", + device_sha_hex, BleOta::instance().m_esp32RebootPending ? "" : ""); + } + } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {''' + +new_f8 = ''' } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { + if (!Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); + return 0; + } + if (BleOta::instance().m_esp32RebootPending) { + DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); + return 0; + } + + // 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对, + // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 + if (len < 33) { + DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发"); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_ARG); + return 0; + } + + char *frontend_sha_hex = (char *)malloc(65); + if (!frontend_sha_hex) { + DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败"); + sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_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]); + } + frontend_sha_hex[64] = '\\0'; + DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); + + 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 + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + free(frontend_sha_hex); + BleOta::instance().m_esp32RebootPending = false; + } else { + DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); + } + } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {''' +assert old_f8 in data, '0xF8 block old not found' +data = data.replace(old_f8, new_f8, 1) + +# 3. 结尾协议说明注释:删除 0xF9 行 +old_tail = ''' // 0xF7 = 触发 GD32 中继 + // 0xF8 = 触发 ESP32 本机 OTA 收尾(end + set_boot + reboot,默认不重复校验 SHA,前端需先走 0xF9 确认) + // 0xF9 = 仅从 OTA 分区读回计算 SHA-256,通过 0xC1 notify 上报前端(不 end / 不 reboot) + // 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)''' +new_tail = ''' // 0xF7 = 触发 GD32 中继 + // 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对, + // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启) + // 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)''' +assert old_tail in data, 'tail old not found' +data = data.replace(old_tail, new_tail, 1) + +with open(path, 'w', encoding='utf-8-sig') as f: + f.write(data) +print('ble_ota.cpp updated') diff --git a/tmp_patch_css.py b/tmp_patch_css.py new file mode 100644 index 0000000..e815444 --- /dev/null +++ b/tmp_patch_css.py @@ -0,0 +1,37 @@ +path = r'tools\style.css' +with open(path, 'r', encoding='utf-8-sig') as f: + css = f.read() + +# 在 .svi-val.err 之后追加 stage-result 样式 +old = """.svi-val.err { color: var(--red); font-weight: 600; }""" +new = """.svi-val.err { color: var(--red); font-weight: 600; } + +/* Stage result panel - 上位机确认 0x07/0x85 收尾 */ +.stage-result { + margin-top: 12px; padding: 14px 16px; + border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border); + display: flex; flex-direction: column; gap: 10px; +} +.stage-result.ok { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); } +.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); } +.stage-result .sr-head { display: flex; align-items: center; gap: 10px; } +.stage-result .sr-ico { + width: 32px; height: 32px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 18px; font-weight: 700; color: #07121c; +} +.stage-result.ok .sr-ico { background: var(--green); } +.stage-result.err .sr-ico { background: var(--red); } +.stage-result .sr-title { font-size: 16px; font-weight: 700; } +.stage-result.ok .sr-title { color: var(--green); } +.stage-result.err .sr-title { color: var(--red); } +.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.5; } +.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; }""" + +assert old in css, 'svi-val.err CSS not found' +css = css.replace(old, new, 1) + +with open(path, 'w', encoding='utf-8-sig') as f: + f.write(css) +print('style.css updated') diff --git a/tmp_patch_html.py b/tmp_patch_html.py new file mode 100644 index 0000000..c6c6e5b --- /dev/null +++ b/tmp_patch_html.py @@ -0,0 +1,20 @@ +path = r'tools\ble_ota.html' +with open(path, 'r', encoding='utf-8-sig') as f: + html = f.read() + +old_hint = " hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后会先发 0xF9 让设备端读回算 SHA-256,前端比对一致后才发 0xF8 触发收尾(end + set_boot + reboot)。'" +new_hint = " hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后发 0xF8 + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。'" +print('old in html:', old_hint in html) +html2 = html.replace(old_hint, new_hint, 1) +print('changed:', html2 != html) +print('new still present:', new_hint in html2) +with open(path, 'w', encoding='utf-8-sig') as f: + f.write(html2) +print('written, file len:', len(html2)) + +# verify +with open(path, 'r', encoding='utf-8-sig') as f: + reread = f.read() +print('re-read len:', len(reread)) +print('new in reread:', new_hint in reread) +print('old in reread:', old_hint in reread) diff --git a/tmp_test.py b/tmp_test.py new file mode 100644 index 0000000..a83f1c1 --- /dev/null +++ b/tmp_test.py @@ -0,0 +1,25 @@ +import os +os.chdir(r'G:\Robot_v2\DCU\ESP32_COMM') +path = 'tools/ble_ota.html' + +with open(path, 'r', encoding='utf-8-sig') as f: + html = f.read() + +# 计数 +print('0xF9 BEFORE:', html.count('0xF9')) +print('shaWaiter BEFORE:', html.count('shaWaiter')) + +# 1) TARGETS esp32 hint +old_hint = " hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后会先发 0xF9 让设备端读回算 SHA-256,前端比对一致后才发 0xF8 触发收尾(end + set_boot + reboot)。'" +new_hint = " hint: 'TEST NEW'" +print('hint in html:', old_hint in html) +html = html.replace(old_hint, new_hint, 1) + +with open(path, 'w', encoding='utf-8-sig') as f: + f.write(html) + +# 验证 +with open(path, 'r', encoding='utf-8-sig') as f: + reread = f.read() +print('TEST NEW present:', 'TEST NEW' in reread) +print('old hint present:', old_hint in reread) diff --git a/tmp_test2.py b/tmp_test2.py new file mode 100644 index 0000000..87b16e7 --- /dev/null +++ b/tmp_test2.py @@ -0,0 +1,20 @@ +import os +os.chdir(r'G:\Robot_v2\DCU\ESP32_COMM') +with open('tools/ble_ota.html', 'r', encoding='utf-8-sig') as f: + text = f.read() + +# Try a small, single line replace +old = " fileSha256: null," +print('old line found:', text.count(old)) + +# Now do a multi-line block test +old_block = """ fileSha256: null, + deviceSha256: null, + expectedReboot: false, // 发送 0xFA 后置 true,重连后清 + reconnecting: false, // 自动重连中 + reconnectAttempts: 0, // 重连尝试次数 + shaWaiter: null, // {resolve, reject, timer} 0xC1 等待者 + };""" + +print('block found:', old_block in text) +print('block repr (last 200):', repr(old_block[-200:])) diff --git a/tools/app.js b/tools/app.js index af7a44d..a709b27 100644 --- a/tools/app.js +++ b/tools/app.js @@ -1390,17 +1390,13 @@ async function pollStatus() { updateEsp32StagesFromPhase(6); // 启动完成 addLog('I', '系统', 'ESP32 已恢复在线'); showToast('ESP32 已恢复在线', 'success'); - } else if (currentOtaTarget === 'esp32' && esp32OtaPhase >= 3 && esp32OtaPhase < 6) { - updateEsp32StagesFromPhase(6); - addLog('I', '系统', 'ESP32 已恢复在线'); - showToast('ESP32 已恢复在线', 'success'); } updateSystemStatusUi(data); } } catch (e) { consecutivePollFailures++; // 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的) - if (consecutivePollFailures === 5 && !pollSlowMode && !isUploading) { + if (consecutivePollFailures === 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) { addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); var diag = await diagnoseNetwork(); addLog('W', '诊断', diag); diff --git a/tools/ble_ota.html b/tools/ble_ota.html index f9b9432..cee6a0f 100644 --- a/tools/ble_ota.html +++ b/tools/ble_ota.html @@ -35,6 +35,7 @@
+ 回 WiFi 页面 ↗
@@ -47,7 +48,7 @@
@@ -149,8 +150,8 @@
2
GATT 准备
3
文件准备
4
数据写入
-
5
完成触发
-
6
校验比对
+
5
校验比对
+
6
完成触发
@@ -197,7 +198,11 @@ ctrlHex: 'F7', recommendedPayload: 480, finishText: '自动触发中继', - hint: '当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。' + hint: '当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。', + verifyStage: 6, + finalizeStage: 5, + stage5Label: '完成触发', + stage6Label: '校验比对' }, esp32: { label: 'ESP32', @@ -205,7 +210,11 @@ ctrlHex: 'F8', recommendedPayload: 480, finishText: '自动完成 OTA', - hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,最后一包到齐后由设备端自动完成 OTA 并重启。' + hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后发 0xF8 + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。', + verifyStage: 5, + finalizeStage: 6, + stage5Label: '校验比对', + stage6Label: '完成触发' } }; @@ -231,6 +240,10 @@ ackWaiter: null, fileSha256: null, deviceSha256: null, + expectedReboot: false, // 发送 0xFA 后置 true,重连后清 + reconnecting: false, // 自动重连中 + reconnectAttempts: 0, // 重连尝试次数 + uploadTargetKey: null, }; const $ = (id) => document.getElementById(id); @@ -258,6 +271,7 @@ otaState: $('otaState'), fileInfo: $('fileInfo'), clearFileBtn: $('clearFileBtn'), + restartBtn: $('restartBtn'), progressFill: $('progressFill'), progressText: $('progressText'), progressPercent: $('progressPercent'), @@ -273,6 +287,10 @@ return TARGETS[els.targetChip.value] || TARGETS.gd32; } + function flowTarget() { + return TARGETS[state.uploadTargetKey] || currentTarget(); + } + const MAX_LOG_LINES = 1000; function log(tag, message, level = 'I') { @@ -298,6 +316,14 @@ // ====== BLE OTA Stage Flow Management ====== var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6']; var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6']; + var bleStageLabelIds = ['bsl5', 'bsl6']; + + function updateBleStageLabels(cfg) { + var stage5 = document.getElementById(bleStageLabelIds[0]); + var stage6 = document.getElementById(bleStageLabelIds[1]); + if (stage5) stage5.textContent = cfg.stage5Label || '完成触发'; + if (stage6) stage6.textContent = cfg.stage6Label || '校验比对'; + } function resetBleStages() { bleStageIds.forEach(function(id) { @@ -442,6 +468,10 @@ } els.disconnectBtn.disabled = !connected; els.versionBtn.disabled = !connected; + els.targetChip.disabled = state.uploading; + els.payloadSize.disabled = state.uploading; + els.ctrlHex.disabled = state.uploading; + if (els.restartBtn) els.restartBtn.disabled = !connected || state.reconnecting || state.expectedReboot || state.uploading; els.uploadBtn.disabled = !idle; els.autoFlashBtn.disabled = !idle; els.abortBtn.disabled = !state.uploading; @@ -502,9 +532,10 @@ function refreshTargetUi() { const cfg = currentTarget(); + updateBleStageLabels(cfg); els.ctrlHex.value = cfg.ctrlHex; els.payloadSize.value = String(cfg.recommendedPayload); - els.protocolHint.innerHTML = `${cfg.hint} 推荐 payload:${cfg.recommendedPayload}B。协议格式:cmd(1) + seq(2) + total_size(4) + payload。
传输机制:每 ${BLE_OTA_ACK_WINDOW} 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? '
ESP32 模式:最后一包到齐后由设备端自动完成 OTA 并重启。': '
GD32 模式:数据写入后自动触发 SPI OTA 中继。'}`; + els.protocolHint.innerHTML = `${cfg.hint} 推荐 payload:${cfg.recommendedPayload}B。协议格式:cmd(1) + seq(2) + total_size(4) + payload。
传输机制:每 ${BLE_OTA_ACK_WINDOW} 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? '
ESP32 模式:数据写入完成后发送 0xF8 + 32B 文件 SHA-256 触发设备端收尾;设备端自行读回 OTA 分区、完成 SHA-256 比对,并决定是否 end + set_boot + reboot。': '
GD32 模式:数据写入后自动触发 SPI OTA 中继。'}`; if (state.selectedFile) updateFileInfo(state.selectedFile); setConnectionState(!!state.server); log('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`); @@ -528,7 +559,7 @@ }); state.device = device; - state.device.addEventListener('gattserverdisconnected', onDisconnected); + state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true }); els.deviceName.textContent = device.name || '未知设备'; els.deviceId.textContent = device.id || '-'; @@ -567,10 +598,20 @@ function onDisconnected() { log('BLE', '设备已断开', 'W'); + var wasExpected = state.expectedReboot; resetUiAfterDisconnect(); + if (wasExpected && state.device) { + // 是主动重启引起的断开,启动自动重连 + state.reconnecting = true; + state.reconnectAttempts = 0; + tryReconnect(); + } } async function disconnectBle() { + // 用户主动断开:取消任何进行中的重连 / expectedReboot + state.expectedReboot = false; + state.reconnecting = false; try { if (state.dataOutChar && state.notificationsEnabled) { await state.dataOutChar.stopNotifications(); @@ -653,17 +694,17 @@ }; const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`; log('FINISH', finishText); + const finalizeStage = TARGETS.esp32.finalizeStage; // Update BLE stage flow based on FINISH notifications if (stage >= 0x01 && stage <= 0x04) { - setBleStage(5, 'OTA 刷写中...', null); + setBleStage(finalizeStage, 'OTA 刷写中...', null); setBleStageText(finishText.replace('[FINISH] ', '')); } else if (stage >= 0x05 && stage <= 0x06) { - setBleStage(5, '等待重启...', null); + setBleStage(finalizeStage, '等待重启...', null); setBleStageText(finishText.replace('[FINISH] ', '')); } else if (stage === 0x07) { - setBleStage(5, '即将重启', null); - setBleStage(6, '设备重启', null); + setBleStage(finalizeStage, '设备重启', null); setBleStageText('ESP32 即将重启'); var bar = document.getElementById('bleStageBar'); if (bar) bar.className = 'stage-status-bar ok'; @@ -686,6 +727,13 @@ state.lastUploadStatus = 'failed'; setBleStageError('OTA 刷写失败'); showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true }); + } else if (stage === 0x85) { + showToast('ESP32 OTA 校验失败,未触发重启', 'error'); + setProgress(0, '设备端 SHA-256 校验失败'); + state.lastUploadStatus = 'failed'; + setBleStage(5, 'SHA-256 ✗', null); + setBleStageError('设备端 SHA-256 校验失败'); + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '设备端校验失败,已中止 OTA', err: true }); } else if (stage === 0x07) { showToast('ESP32 OTA 刷写完成,等待重启', 'success'); // If device SHA-256 hasn't arrived yet, show what we have @@ -716,21 +764,22 @@ state.deviceSha256 = devSha; log('VERIFY', '设备端 SHA-256: ' + devSha); - // Compare with front-end SHA-256 + // 设备端上报 SHA-256 后,前端仅做展示与状态同步。 var frontSha = state.fileSha256; + var verifyStage = flowTarget().verifyStage; if (frontSha && frontSha.toUpperCase() === devSha) { log('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I'); showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true }); - setBleStage(6, 'SHA-256 ✓', null); + setBleStage(verifyStage, 'SHA-256 ✓', null); setBleStageText('校验通过 — 固件完整性已确认'); } else if (frontSha) { log('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E'); showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true }); - setBleStage(6, 'SHA-256 ✗', null); + setBleStage(verifyStage, 'SHA-256 ✗', null); setBleStageError('SHA-256 不一致'); } else { showBleVerifyInfo(undefined, devSha, { text: '前端 SHA-256 不可用,无法比对' }); - setBleStage(6, '无法比对', null); + setBleStage(verifyStage, '无法比对', null); } return; } @@ -739,6 +788,88 @@ log('NOTIFY', hex); } + + // ===== 重启设备 + 自动重连 ===== + async function restartEsp32() { + if (!state.ctrlChar || !state.device) return; + if (!confirm('确定要重启 ESP32 吗?\n\n重启后页面会自动重连。')) return; + if (state.expectedReboot) return; + state.expectedReboot = true; + state.reconnectAttempts = 0; + els.connState.textContent = '重启中'; + if (els.connState.parentElement) els.connState.parentElement.className = 'pill processing'; + if (els.otaState) { els.otaState.textContent = '重启中'; els.otaState.className = 'v warn'; } + setConnectionState(!!state.server); + log('SYS', '=== 触发 ESP32 重启 (0xFA) ==='); + showToast('ESP32 重启中...', 'success'); + try { + await state.ctrlChar.writeValue(new Uint8Array([0xFA])); + log('SYS', '0xFA 已发,等待设备端推送 FINISH_REBOOT_NOW 后断开'); + } catch (e) { + log('SYS', '0xFA 发送失败: ' + e.message, 'E'); + showToast('0xFA 发送失败', 'error'); + state.expectedReboot = false; + setConnectionState(!!state.server); + } + } + + async function tryReconnect() { + if (!state.device || !state.expectedReboot) { + state.reconnecting = false; + return; + } + if (!state.device.gatt) { + // 设备引用已丧失,重连不了 + state.reconnecting = false; + state.expectedReboot = false; + showToast('设备引用丧失,请手动重连', 'error'); + return; + } + state.reconnecting = true; + state.reconnectAttempts += 1; + var attempt = state.reconnectAttempts; + // 退避:1s, 2s, 4s, 8s, 上限 10s + var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1)); + if (backoff > 200) await sleep(backoff); + els.connState.textContent = '重连中(' + attempt + ')'; + if (els.connState.parentElement) els.connState.parentElement.className = 'pill processing'; + if (els.otaState) { els.otaState.textContent = '重连中'; els.otaState.className = 'v warn'; } + setConnectionState(!!state.server); + log('BLE', '自动重连第 ' + attempt + ' 次' + (backoff > 0 ? '(等待 ' + Math.round(backoff) + 'ms)' : '') + '...', 'W'); + try { + var server = await state.device.gatt.connect(); + state.server = server; + state.service = await server.getPrimaryService(UUIDS.service); + state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl); + state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn); + state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut); + state.versionChar = await state.service.getCharacteristic(UUIDS.version); + state.notificationsEnabled = false; + await enableNotifications(); + state.expectedReboot = false; + state.reconnecting = false; + state.reconnectAttempts = 0; + // 重连后重注 onDisconnected({once: true},旧的已被上次断开时消耗) + state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true }); + log('BLE', '✅ 自动重连成功', 'I'); + showToast('BLE 已重连', 'success'); + setConnectionState(true); + // 重连后默认刷新一下版本 + try { await readVersion(); } catch (_) {} + } catch (e) { + log('BLE', '重连第 ' + attempt + ' 次失败: ' + e.message, 'W'); + if (state.expectedReboot && attempt < 12) { + tryReconnect(); + } else { + state.reconnecting = false; + state.expectedReboot = false; + showToast('重连失败,请手动连接', 'error'); + log('BLE', '自动重连放弃,请点击「连接设备」重连', 'E'); + setConnectionState(!!state.server); + } + } + } + function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) { const gen = state.ackGeneration; if (state.lastAckCmd === cmd && state.lastAckSeq >= expectedSeq && gen === state.ackGeneration) { @@ -937,6 +1068,7 @@ if (!state.selectedFile || !state.dataInChar) return false; const cfg = currentTarget(); + state.uploadTargetKey = els.targetChip.value; const payloadSize = Math.max(32, Math.min(498, Number(els.payloadSize.value) || 480)); const file = state.selectedFile; @@ -1079,15 +1211,33 @@ setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`); state.lastUploadStatus = 'success'; - // Stage 5: Completion trigger + // Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对 if (cfg.label === 'ESP32') { - setBleStage(5, '自动完成', null); - setBleStage(5, '完成', null); - setBleStage(6, '等待设备校验...', null); - setBleStageText('ESP32 OTA 数据写入完成,等待设备端处理与校验'); - // Show verify info — wait for device SHA-256 state.deviceSha256 = null; - showBleVerifyInfo(fileSha256.toUpperCase(), '等待设备上报...', { text: '等待设备端处理并上报 SHA-256' }); + 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); + } + const trigger = new Uint8Array(33); + trigger[0] = 0xF8; + trigger.set(frontShaBytes, 1); + showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' }); + setBleStage(5, '发送校验...', 'active'); + setBleStageText('发送 0xF8 + SHA-256,等待设备端收尾...'); + try { + await state.ctrlChar.writeValue(trigger); + log('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端比对并收尾'); + } catch (e) { + state.lastUploadStatus = 'failed'; + setProgress(0, `0xF8 发送失败: ${e.message}`); + log('OTA', `0xF8 发送失败: ${e.message}`, 'E'); + setBleStageError('0xF8 发送失败'); + return false; + } + setBleStage(5, '等待设备端校验', null); + setBleStage(6, '设备端收尾中', 'active'); + setBleStageText('0xF8 已发,等待设备端 SHA 比对与 end+set_boot+reboot...'); return true; } @@ -1140,6 +1290,7 @@ state.ackWaiter = null; } state.uploading = false; + state.uploadTargetKey = null; setConnectionState(!!state.server); } } @@ -1190,7 +1341,8 @@ els.connectBtn.addEventListener('click', connectBle); els.disconnectBtn.addEventListener('click', disconnectBle); - els.versionBtn.addEventListener('click', readVersion); + if (els.versionBtn) els.versionBtn.addEventListener('click', readVersion); + if (els.restartBtn) els.restartBtn.addEventListener('click', restartEsp32); els.uploadBtn.addEventListener('click', uploadFirmware); els.autoFlashBtn.addEventListener('click', autoFlash); els.abortBtn.addEventListener('click', abortTask); diff --git a/tools/index.html b/tools/index.html index c9dcdb8..f606b75 100644 --- a/tools/index.html +++ b/tools/index.html @@ -128,7 +128,7 @@
diff --git a/tools/style.css b/tools/style.css index 7f42faa..372e04c 100644 --- a/tools/style.css +++ b/tools/style.css @@ -474,6 +474,29 @@ select option { background: var(--bg-panel); color: var(--text); } .svi-val.ok { color: var(--green); font-weight: 600; } .svi-val.err { color: var(--red); font-weight: 600; } +/* Stage result panel - 上位机确认 0x07/0x85 收尾 */ +.stage-result { + margin-top: 12px; padding: 14px 16px; + border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border); + display: flex; flex-direction: column; gap: 10px; +} +.stage-result.ok { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); } +.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); } +.stage-result .sr-head { display: flex; align-items: center; gap: 10px; } +.stage-result .sr-ico { + width: 32px; height: 32px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 18px; font-weight: 700; color: #07121c; +} +.stage-result.ok .sr-ico { background: var(--green); } +.stage-result.err .sr-ico { background: var(--red); } +.stage-result .sr-title { font-size: 16px; font-weight: 700; } +.stage-result.ok .sr-title { color: var(--green); } +.stage-result.err .sr-title { color: var(--red); } +.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.5; } +.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; } + /* === LOG PANEL === */ .log-panel { display: flex; flex-direction: column; min-height: 380px; } .log-panel .card-body { flex: 1; display: flex; flex-direction: column; padding: 0; }