feat(ota): 实现GD32 OTA包间背压机制,优化传输节奏控制

移除固定的包间延迟配置 `OTA_PACKET_DELAY_MS`,采用基于SPI Link v2
CREDIT背压的动态节奏控制,在SpiMaster中通过remoteCredit==0时wait/拒发
实现流量控制,提高OTA传输稳定性。

refactor(stack): 统一任务栈大小配置并添加CPU统计功能

- 在app_config.h中集中定义所有任务栈大小常量,基于实测水位优化分配
- 新增monitor/cpu_stats.cpp组件,提供1Hz FreeRTOS CPU占用采样
- 更新main.cpp中的任务创建使用新的栈大小常量
- 调整health_monitor和ota_relay等任务的栈分配

perf(ota): 改进ESP32和GD32 OTA分区预擦策略

- ESP32 OTA从边写边擦改为整分区预擦模式,避免写入时的扇区擦除阻塞
- GD32 OTA在prepare阶段进行整分区预擦,提升后续写入吞吐量
- 优化OTA协调器状态管理,修复FINALIZING状态残留问题

fix(log): 改进UDP日志发送的堆内存管理

- 当堆内存不足时记录UDP发送跳过的告警信息到ring buffer
- 降低UDP发送跳过的内存阈值从10KB到6KB
- 降低日志排水最小内存阈值从8KB到4KB

docs(readme): 更新OTA配置参数文档说明

移除已废弃的OTA_PACKET_DELAY_MS参数,更新包间节奏控制说明为
基于CREDIT背压机制的描述。
This commit is contained in:
小龙虾
2026-07-16 01:24:12 +08:00
parent a47a027178
commit 51c89b3065
52 changed files with 1006 additions and 628 deletions
+48 -15
View File
@@ -25,7 +25,7 @@
#define TAG "OTA_CORE"
// ============================================================
// 分区互斥锁(M1: 内聚自 ota_mutex.h 全局变量)
// 分区互斥锁
// ============================================================
SemaphoreHandle_t Esp32OtaCore::s_mutex = NULL;
volatile bool Esp32OtaCore::s_occupied = false;
@@ -172,22 +172,57 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
return beginCleanupAndReturn(ESP_FAIL);
}
/* ★ 用 OTA_WITH_SEQUENTIAL_WRITES:边写边按扇区擦除,begin 几乎瞬时返回。
* 旧逻辑按 64KB 对齐预擦 ~1MB,会在 /ota/upload 开头阻塞几十秒~数分钟:
* 浏览器 upload 进度已到 100%,设备还在擦 Flash → 表现为“卡在 100% 很久才成功”。 */
DEBUG_INFO(TAG, "esp_ota_begin(SEQUENTIAL) 目标=%s size=%u free=%lu...",
m_partition->label, (unsigned)total_size,
/* ★ 整分区预擦(非 SEQUENTIAL 边写边擦):
* 1) 扇区循环 erase + 喂狗/yield,避免 TASK_WDT 与 WiFi 饿死
* 2) esp_ota_resume(erase_size≠SEQUENTIAL) → need_erase=false,写路径纯 write
* 3) 显式 invalidate inactive slot(resume 不像 begin 那样自动做)
* 预擦会在 begin 阶段占用数秒~数十秒,之后收流写 Flash 更快更稳。 */
const size_t part_size = m_partition->size;
const size_t sector = m_partition->erase_size ? m_partition->erase_size : 4096U;
DEBUG_INFO(TAG, "整分区预擦: %s size=%u sector=%u free=%lu...",
m_partition->label, (unsigned)part_size, (unsigned)sector,
(unsigned long)esp_get_free_heap_size());
const int64_t t0 = esp_timer_get_time();
esp_err_t err = esp_ota_begin(m_partition, OTA_WITH_SEQUENTIAL_WRITES, &m_handle);
for (size_t off = 0; off < part_size; ) {
size_t chunk = sector;
if (off + chunk > part_size) {
chunk = part_size - off;
}
esp_err_t erase_err = esp_partition_erase_range(m_partition, off, chunk);
if (erase_err != ESP_OK) {
DEBUG_ERR(TAG, "整分区预擦失败 @%u: %s",
(unsigned)off, esp_err_to_name(erase_err));
return beginCleanupAndReturn(erase_err);
}
off += chunk;
(void)esp_task_wdt_reset();
taskYIELD();
}
const int64_t t1 = esp_timer_get_time();
DEBUG_INFO(TAG, "整分区预擦完成 耗时 %lld ms",
(long long)((t1 - t0) / 1000));
#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) || defined(CONFIG_APP_ROLLBACK_ENABLE)
{
esp_err_t inv = esp_ota_invalidate_inactive_ota_data_slot();
if (inv != ESP_OK && inv != ESP_ERR_NOT_SUPPORTED) {
DEBUG_WARN(TAG, "invalidate inactive OTA slot: %s(继续)",
esp_err_to_name(inv));
}
}
#endif
/* erase_size 传 0(≠ OTA_WITH_SEQUENTIAL_WRITES)→ 写时不再擦扇区 */
esp_err_t err = esp_ota_resume(m_partition, /*erase_size=*/0,
/*image_offset=*/0, &m_handle);
const int64_t t2 = esp_timer_get_time();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s (耗时 %lld ms)",
esp_err_to_name(err), (long long)((t1 - t0) / 1000));
DEBUG_ERR(TAG, "esp_ota_resume 失败: %s (耗时 %lld ms)",
esp_err_to_name(err), (long long)((t2 - t1) / 1000));
return beginCleanupAndReturn(err);
}
DEBUG_INFO(TAG, "esp_ota_begin 完成 耗时 %lld ms free=%lu",
(long long)((t1 - t0) / 1000),
DEBUG_INFO(TAG, "esp_ota_resume 完成 耗时 %lld ms free=%lu",
(long long)((t2 - t1) / 1000),
(unsigned long)esp_get_free_heap_size());
m_received = 0;
@@ -196,7 +231,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
m_ended = false;
memset(m_sha256, 0, sizeof(m_sha256));
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase)",
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (full-partition erase)",
m_partition->label, (unsigned)total_size);
return ESP_OK;
}
@@ -213,11 +248,9 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len)
return ESP_ERR_INVALID_STATE;
}
/* sequential 模式写时可能触发扇区擦除(禁 cache),期间 WiFi 易饿死。
* 写后 yield 一次即可;写前再 yield 会无谓拖慢吞吐(httpd 侧另有堆感知 pacing)。 */
/* 整分区已预擦:此处为纯 write,不再扇区擦除。 */
(void)esp_task_wdt_reset();
esp_err_t err = esp_ota_write(m_handle, data, len);
taskYIELD();
if (err == ESP_OK) {
m_received += len;
} else {
+3 -4
View File
@@ -5,7 +5,7 @@
* begin → write*(N) → finalize / abort
*
* 统一以 WiFi 流程为基准:
* - esp_ota_begin 第二参数传 total_size
* - begin:整分区预擦后 esp_ota_resume(写路径不再边写边擦)
* - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算)
* - finalize 顺序: 读回 SHA-256 → 比对前端 SHA → esp_ota_end → esp_ota_set_boot_partition → 可选重启
* - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle
@@ -102,16 +102,15 @@ public:
/** @brief 获取已接收的字节数。 */
size_t getReceived() const { return m_received; }
// ── 分区互斥锁管理(M1: 从 ota_mutex.h 全局变量内聚到此类)──
// ── 分区互斥锁管理 ──
/**
* @brief 初始化 ESP32 OTA 分区互斥锁(在 app_main 中调用)。
* 替代旧的全局 g_esp32OtaMutex / g_esp32OtaOccupied。
*/
static void initMutex();
private:
Esp32OtaCore() = default;
// ── 分区互斥锁(M1: 内聚自 ota_mutex.h)──
// ── 分区互斥锁 ──
static SemaphoreHandle_t s_mutex; ///< ESP32 OTA 分区互斥锁
static volatile bool s_occupied; ///< 分区占用标志
static bool acquireMutex(uint32_t timeout_ms);
+75 -25
View File
@@ -98,13 +98,13 @@ void OtaCoordinator::watchdogCheckLocked()
return; // 未超时
}
// ★ 超时自愈:强制转 ERROR。
// ERROR 不在任何 beginXxx() 的拒绝列表中,下次请求会自动从 ERROR 恢复。
// 清理由各子系统自行负责(abortCurrent 中会按 ERROR 不做实际清理,仅复位状态)。
DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制自愈 → ERROR",
// ★ 超时自愈:完整中止到 IDLE(释放 Esp32OtaCore / 中继 / GD32 meta 等)。
// 旧逻辑只标 ERROR 不清理子系统:若卡在 FINALIZING 且 core 已半释放,
// 前端仍长时间判“占用”;若 core 仍持互斥,ERROR 后 begin 还要靠 forceCleanup。
// REBOOTING 本应已 esp_restart;超时则同样中止,避免永久卡死。
DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制中止 → IDLE",
(int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks));
m_sysState = OTA_SYS_ERROR;
m_busySinceTick = 0;
doAbortLocked();
}
// ============================================================
@@ -124,7 +124,7 @@ void OtaCoordinator::init()
m_gd32StorageDone = false;
m_busySinceTick = 0;
// ★ H4 解耦:注册协调器为 OtaRelay 的中继事件监听器
// 注册协调器为 OtaRelay 的中继事件监听器
OtaRelay::setRelayListener(this);
// ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态,
@@ -221,12 +221,27 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t
return err;
}
// 3. 复位按需擦除水位(不在此处整段预擦)
// 旧实现在 prepare 阶段 erase 整包固件对应分区:HTTP 主线程阻塞数秒~数十秒,
// 期间不 httpd_req_recv → TCP 窗口塞满后浏览器进度先到 100%,再空等擦+写+SHA。
// 现改为 flashWriteChunk 内按扇区 ensureErased,边收边擦写,进度与真实处理对齐。
// 3. 整分区预擦(不再边写边擦):prepare 阶段擦完 gd32_ota 分区,
// 收流时 flashWriteChunk 仅 write,吞吐更稳。会阻塞数秒~数十秒。
Gd32FlashStore::instance().beginWriteSession();
DEBUG_INFO(TAG, "prepareGd32Storage: 按需擦除会话已复位 (fw=%" PRIu32 ")", total_size);
{
const esp_partition_t *gd32_part = Gd32FlashStore::instance().getPartition();
const uint32_t erase_bytes = gd32_part ? (uint32_t)gd32_part->size : 0U;
if (erase_bytes == 0U) {
DEBUG_ERR(TAG, "prepareGd32Storage: 找不到 gd32_ota 分区");
abortCurrent();
return ESP_ERR_NOT_FOUND;
}
DEBUG_INFO(TAG, "prepareGd32Storage: 整分区预擦 %u 字节 (fw=%" PRIu32 ")...",
(unsigned)erase_bytes, total_size);
err = Gd32FlashStore::instance().erasePartition(erase_bytes);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "prepareGd32Storage: 整分区预擦失败: %s", esp_err_to_name(err));
abortCurrent();
return err;
}
DEBUG_INFO(TAG, "prepareGd32Storage: 整分区预擦完成");
}
// 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0)
clearGd32Meta();
@@ -270,7 +285,7 @@ esp_err_t OtaCoordinator::finishGd32StorageAfterReceive()
}
// ============================================================
// GD32 存储操作(传输层统一入口,解决 H3 绕过协调器问题)
// GD32 存储操作(传输层统一入口)
// ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到
// Gd32FlashStore::instance(),消除跨 TU 调用开销。
// ============================================================
@@ -394,7 +409,7 @@ void OtaCoordinator::notifyRelayComplete(bool success)
}
// ============================================================
// IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)
// IRelayListener 接口实现
// ============================================================
/** @brief IRelayListener 回调:中继已开始,委托给 notifyRelayStarted()。 */
void OtaCoordinator::onRelayStarted()
@@ -409,7 +424,7 @@ void OtaCoordinator::onRelayComplete(bool success)
}
// ============================================================
// BLE 射频共存管理(L1: 消除 ble_ota → wifi_ota 直接依赖)
// BLE 射频共存管理
// ============================================================
void OtaCoordinator::setBleCoexListener(IBleCoexListener *listener)
{
@@ -443,7 +458,6 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
// FINALIZING/REBOOTING 是不可逆阶段,也拒绝。
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "ESP32 OTA 接收中(通道=%d),拒绝跨通道请求(通道=%d)",
@@ -458,14 +472,23 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 收尾/重启中,均拒绝。
// ★ 必须显式拒绝 ESP32_FINALIZING/REBOOTING:
// 若放行,本调用会落到 Esp32OtaCore::begin 去抢全局互斥失败而返回 INVALID_STATE,
// 随后 beginEsp32OtaWithRetry 的 forceCleanup 会把另一通道的活跃会话
// (esp_ota_abort + 释放互斥)误中止 → 两通道并发写同一 OTA 分区。
// ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器
// 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。
// core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "ESP32_FINALIZING 残留(core 已空闲),强制复位后允许新会话");
doAbortLocked();
} else {
DEBUG_WARN(TAG, "系统忙 (ESP32_FINALIZING, core 活跃),拒绝 ESP32 OTA");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 重启中,均拒绝。
if (h && (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING)) {
DEBUG_WARN(TAG, "系统忙 (state=%d, %s),拒绝 ESP32 OTA",
(int)m_sysState, stateToString(m_sysState));
@@ -586,6 +609,13 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h
r.device_sha_hex);
if (r.err == ESP_OK) {
notifyEsp32FinalizeComplete();
} else {
// ★ SHA 不匹配 / end 失败:Esp32OtaCore 已 abort+释放互斥,但协调器若仍停在
// ESP32_FINALIZING,WiFi 前端会一直被“设备 OTA 占用”拒绝(BLE 路径曾单独
// 补 abortCurrent;统一放到这里,WiFi/BLE 共用)。
DEBUG_ERR(TAG, "ESP32 finalize 失败: %s,复位协调器 → IDLE",
esp_err_to_name(r.err));
abortCurrent();
}
return r;
}
@@ -632,6 +662,26 @@ void OtaCoordinator::abortCurrent()
wifiOtaResumeBleIfNeeded();
}
void OtaCoordinator::healOrphanStates()
{
bool need_resume = false;
bool h = lock();
if (h) {
watchdogCheckLocked();
// finalize 失败后 core 已空、协调器仍 FINALIZING 的假占用:status 轮询即可自愈
if (m_sysState == OTA_SYS_ESP32_FINALIZING &&
!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "status 路径发现 ESP32_FINALIZING 残留,自愈 → IDLE");
doAbortLocked();
need_resume = true;
}
unlock(h);
}
if (need_resume) {
wifiOtaResumeBleIfNeeded();
}
}
uint32_t OtaCoordinator::getOtaEpoch() const
{
bool h = lock();
@@ -665,7 +715,7 @@ bool OtaCoordinator::abortCurrentIfEpoch(uint32_t epoch)
ota_system_state_t OtaCoordinator::getSystemState() const
{
// 对于纯读取,短暂加锁保证一致性
// ★ M1/L3: m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
// m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
bool h = lock();
ota_system_state_t s = m_sysState;
unlock(h);
@@ -720,7 +770,7 @@ bool OtaCoordinator::getGd32AppAlive() const
}
// ============================================================
// OTA 状态快照(PR1: 收口状态读取)
// OTA 状态快照
// ============================================================
OtaSnapshot OtaCoordinator::getSnapshot() const
{
@@ -754,7 +804,7 @@ OtaSnapshot OtaCoordinator::getSnapshot() const
}
// ============================================================
// GD32 APP 探测(PR2: 从 /system/status 移出)
// GD32 APP 探测
// ============================================================
// 注:本函数与 OtaRelay::probeAndCacheAppAlive 逻辑相似,但因依赖方向
// (OtaCoordinator → OtaRelay)不应调用 OtaRelay 的内部实现细节,
+24 -14
View File
@@ -21,7 +21,7 @@
#include "esp_err.h"
#include "app_config.h" // OTA_MAX_FIRMWARE_SIZE / GD32_OTA_PACKET_SIZE(validateGd32Size 用)
#include "storage/gd32_flash_store.h"
#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot(H4 解耦)
#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot
// ============================================================
// 系统级 OTA 状态枚举
@@ -47,7 +47,7 @@ typedef enum {
} ota_channel_t;
// ============================================================
// OTA 系统全量只读快照(PR1: 收口状态读取)
// OTA 系统全量只读快照
// ============================================================
/**
* @brief OTA 系统全量只读快照。
@@ -64,11 +64,11 @@ struct OtaSnapshot {
};
// ============================================================
// IBleCoexListener - BLE 射频共存通知接口(解决 L1 横向耦合)
// IBleCoexListener - BLE 射频共存通知接口
// ============================================================
// WifiOta 实现此接口并注册到 OtaCoordinator,
// BLE OTA 开始/结束时通过协调器通知 WiFi 层,
// 消除 ble_ota → wifi_ota 的直接依赖。
// 避免 ble_ota → wifi_ota 的直接依赖。
/**
* @brief BLE 射频共存监听器接口。
* BLE OTA 开始/结束时,协调器通过此接口通知 WiFi 层暂停/恢复射频活动。
@@ -124,8 +124,8 @@ public:
esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel);
/**
* @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位按需擦除水位 + 清空 meta + 写入初始 meta。
* 扇区擦除在 writeGd32Chunk/flashWriteChunk 内按写入范围推进(非整段预擦)。
* @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 整分区预擦 + 清空 meta + 写入初始 meta。
* 收流时 write 路径只写 Flash(ensureErased 仅作水位兜底)。
* WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。
* @param total_size 固件总字节数。
* @param channel 发起通道(WiFi/BLE)。
@@ -146,7 +146,7 @@ public:
*/
esp_err_t finishGd32StorageAfterReceive();
// ── GD32 存储操作(传输层通过协调器操作 Flash,解决 H3 绕过问题)──
// ── GD32 存储操作(传输层通过协调器操作 Flash)──
/**
* @brief 向 GD32 Flash 分区写入固件数据块。
* 传输层(WiFi/BLE)通过此接口写入,不再直接调用 Gd32FlashStore。
@@ -312,7 +312,9 @@ public:
/**
* @brief ESP32 OTA 收尾(统一封装 BLE/WiFi 两端重复的收尾序列)。
* 步骤:notifyEsp32ReceiveComplete → Esp32OtaCore::finalize(reboot=false)
* → 成功则 notifyEsp32FinalizeComplete。
* → 成功则 notifyEsp32FinalizeComplete;
* → 失败(SHA 不匹配 / end 失败等)则 abortCurrent() 复位到 IDLE,
* 避免协调器卡在 ESP32_FINALIZING 导致后续上传被假拒绝。
* 不重启;调用方负责把 device_sha_hex 上报前端后再调 restartEsp32()。
* @param frontend_sha_hex 前端 64 字符 hex SHA-256,nullptr 表示跳过比对。
* @return Esp32FinalizeResult,err 为 ESP_OK/ESP_ERR_INVALID_VERSION/其他。
@@ -333,6 +335,14 @@ public:
*/
void abortCurrent();
/**
* @brief 自愈孤儿忙态(供 /system/status 等轮询路径调用)。
* - 跑忙状态 watchdog(超时完整 abort → IDLE)
* - ESP32_FINALIZING 但 Esp32OtaCore 已空闲:判定为 SHA 失败等残留,复位 IDLE
* 不影响真正正在 finalize(core.isActive())的会话。
*/
void healOrphanStates();
/**
* @brief 获取当前 OTA 会话的 epoch(代次)。
* 每次 beginEsp32Ota/beginGd32Storage 成功时递增。
@@ -354,7 +364,7 @@ public:
// ── 状态查询 ──
/**
* @brief 一次性读取系统 OTA 全量状态快照(PR1: 收口状态读取)。
* @brief 一次性读取系统 OTA 全量状态快照。
* coordinator mutex + relay mutex 各取一次,
* 供 /system/status 等只读接口消费。
* @return 包含 sys_state、meta、relay 进度的完整快照。
@@ -362,7 +372,7 @@ public:
OtaSnapshot getSnapshot() const;
/**
* @brief 单次 SPI 探测 GD32 APP 是否存活(PR2: 从 status 移出)。
* @brief 单次 SPI 探测 GD32 APP 是否存活。
* 成功时更新 OtaRelay 缓存的 alive/version,
* 失败时清除 alive 标志。
* @param out_version 输出版本信息,可为 nullptr。
@@ -421,7 +431,7 @@ public:
*/
void reset();
// ── IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)──
// ── IRelayListener 接口实现 ──
/**
* @brief IRelayListener 回调:中继已开始。
* 由 OtaRelay task 在 forwardFirmware() 前通过 s_relayListener 调用。
@@ -435,7 +445,7 @@ public:
*/
void onRelayComplete(bool success) override;
// ── BLE 射频共存管理(L1: 替代 ble_ota → wifi_ota 直接调用)──
// ── BLE 射频共存管理 ──
/**
* @brief 注册 BLE 共存监听器(由 main 在初始化时调用)。
* @param listener WifiOta 实现的监听器指针,nullptr 取消注册。
@@ -498,10 +508,10 @@ private:
// 仅在同类型活跃状态(ESP32_RECEIVING / GD32_STORING)下有意义。
ota_channel_t m_otaChannel = OTA_CHANNEL_WIFI;
// 互斥锁(M1/L3: mutable 允许在 const 方法中加锁,消除 const_cast)
// 互斥锁(mutable 允许在 const 方法中加锁,无需 const_cast)
mutable SemaphoreHandle_t m_mutex = nullptr;
// BLE 共存监听器(L1: 由 WifiOta 实现并注册)
// BLE 共存监听器(由 WifiOta 实现并注册)
IBleCoexListener *m_bleCoexListener = nullptr;
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100);