feat(ota): 解耦ESP32 OTA核心逻辑到独立模块并增强BLE控制功能
- 新增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哈希信息
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <string.h>
|
||||
#include <ctype.h>
|
||||
#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 已中止并清理");
|
||||
}
|
||||
@@ -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 <stddef.h>
|
||||
#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
|
||||
+133
-290
@@ -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));
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt));
|
||||
}
|
||||
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_DONE_OTA_END, 0);
|
||||
sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0);
|
||||
esp_restart();
|
||||
} 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);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -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:首个分块时创建存储元数据 ---
|
||||
|
||||
+7
-15
@@ -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
|
||||
+47
-216
@@ -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\"}",
|
||||
"{\"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;
|
||||
}
|
||||
} 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);
|
||||
|
||||
// ── 完整模式: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);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
// 正常不会走到这里(reboot=true 时 core 会 esp_restart)
|
||||
char resp[256] = {0};
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"status\": \"ok\", \"message\": \"OTA finished\"}");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
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;
|
||||
|
||||
// 失败: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));
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 非verify_only模式:设置启动分区并重启 ──
|
||||
if (verify_only) {
|
||||
// 没有前端SHA或验证被跳过,在verify_only下也返回ok
|
||||
char ok_resp[128] = {0};
|
||||
snprintf(ok_resp, sizeof(ok_resp),
|
||||
"{\"status\": \"ok\", \"message\": \"verify skipped, no SHA-256 provided\"}");
|
||||
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...");
|
||||
|
||||
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]);
|
||||
}
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", \"sha256\": \"%s\"}",
|
||||
device_sha_hex);
|
||||
} 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));
|
||||
|
||||
+2
-12
@@ -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<bool> m_staConnected{false};
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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。页面会使用 <b>cmd=0x01</b> 发送数据,<mark>写入完成后会先发 0xF9 让设备端读回算 SHA-256</mark>,前端比对一致后才发 0xF8 触发收尾(end + set_boot + reboot)。'"
|
||||
new_hint = " hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,写入完成后发 <b>0xF8</b> + 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)
|
||||
+25
@@ -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。页面会使用 <b>cmd=0x01</b> 发送数据,<mark>写入完成后会先发 0xF9 让设备端读回算 SHA-256</mark>,前端比对一致后才发 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)
|
||||
@@ -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:]))
|
||||
+1
-5
@@ -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);
|
||||
|
||||
+175
-23
@@ -35,6 +35,7 @@
|
||||
</div>
|
||||
<div class="sub-actions">
|
||||
<button id="versionBtn" class="btn btn-outline" disabled>读取版本</button>
|
||||
<button id="restartBtn" class="btn btn-outline" disabled title="确认后会重启 ESP32,重连后页面会自动恢复">🔄 重启设备</button>
|
||||
<a class="btn btn-outline" href="index.html" target="_blank">回 WiFi 页面 ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,7 +48,7 @@
|
||||
<label class="form-label" for="targetChip">目标芯片</label>
|
||||
<select id="targetChip">
|
||||
<option value="gd32">GD32(写入后自动触发中继)</option>
|
||||
<option value="esp32">ESP32(写入后重启升级)</option>
|
||||
<option value="esp32" selected>ESP32(写入后重启升级)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
@@ -149,8 +150,8 @@
|
||||
<div class="stage-node" id="bs2"><div class="stage-dot">2</div><div class="stage-label">GATT 准备</div><div class="stage-detail" id="bsd2"></div></div>
|
||||
<div class="stage-node" id="bs3"><div class="stage-dot">3</div><div class="stage-label">文件准备</div><div class="stage-detail" id="bsd3"></div></div>
|
||||
<div class="stage-node" id="bs4"><div class="stage-dot">4</div><div class="stage-label">数据写入</div><div class="stage-detail" id="bsd4"></div></div>
|
||||
<div class="stage-node" id="bs5"><div class="stage-dot">5</div><div class="stage-label">完成触发</div><div class="stage-detail" id="bsd5"></div></div>
|
||||
<div class="stage-node" id="bs6"><div class="stage-dot">6</div><div class="stage-label">校验比对</div><div class="stage-detail" id="bsd6"></div></div>
|
||||
<div class="stage-node" id="bs5"><div class="stage-dot">5</div><div class="stage-label" id="bsl5">校验比对</div><div class="stage-detail" id="bsd5"></div></div>
|
||||
<div class="stage-node" id="bs6"><div class="stage-dot">6</div><div class="stage-label" id="bsl6">完成触发</div><div class="stage-detail" id="bsd6"></div></div>
|
||||
</div>
|
||||
<div class="stage-status-bar" id="bleStageBar">
|
||||
<span class="ssb-dot"></span>
|
||||
@@ -197,7 +198,11 @@
|
||||
ctrlHex: 'F7',
|
||||
recommendedPayload: 480,
|
||||
finishText: '自动触发中继',
|
||||
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。'
|
||||
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。',
|
||||
verifyStage: 6,
|
||||
finalizeStage: 5,
|
||||
stage5Label: '完成触发',
|
||||
stage6Label: '校验比对'
|
||||
},
|
||||
esp32: {
|
||||
label: 'ESP32',
|
||||
@@ -205,7 +210,11 @@
|
||||
ctrlHex: 'F8',
|
||||
recommendedPayload: 480,
|
||||
finishText: '自动完成 OTA',
|
||||
hint: '当前目标:<b>ESP32</b>。页面会使用 <b>cmd=0x01</b> 发送数据,最后一包到齐后由设备端自动完成 OTA 并重启。'
|
||||
hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,写入完成后发 <b>0xF8</b> + 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:<b>${cfg.recommendedPayload}B</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload</b>。<br><b>传输机制:</b>每 <b>${BLE_OTA_ACK_WINDOW}</b> 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? ' <br><b>ESP32 模式:</b>最后一包到齐后由设备端自动完成 OTA 并重启。': '<br><b>GD32 模式:</b>数据写入后自动触发 SPI OTA 中继。'}`;
|
||||
els.protocolHint.innerHTML = `${cfg.hint} 推荐 payload:<b>${cfg.recommendedPayload}B</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload</b>。<br><b>传输机制:</b>每 <b>${BLE_OTA_ACK_WINDOW}</b> 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? ' <br><b>ESP32 模式:</b>数据写入完成后发送 <mark>0xF8 + 32B 文件 SHA-256</mark> 触发设备端收尾;设备端自行读回 OTA 分区、完成 SHA-256 比对,并决定是否 end + set_boot + reboot。': '<br><b>GD32 模式:</b>数据写入后自动触发 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);
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn btn-outline btn-block" onclick="restartEsp32()">重启 ESP32</button>
|
||||
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260509-0046" target="_blank" style="margin-top:10px;text-decoration:none;">
|
||||
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260614-ota-order-fix" target="_blank" style="margin-top:10px;text-decoration:none;">
|
||||
切换到 BLE 蓝牙刷写 ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user