feat(protocol): 更新SPI Link协议至v2版本并优化CAN通信

- 将SPI OTA帧格式升级为SPI Link v2,采用新的帧格式:
  | SOF0 0xA5 | SOF1 0x5A | VER 0x02 | FLAGS | SESSION 4B LE |
  TX_SEQ | ACK_SEQ | CREDIT | TYPE | LEN 2B LE | RSV 2B | PAYLOAD |
  CRC32 4B LE |
- 新增V2_OVERHEAD=20字节开销,APP最大payload为236字节,BOOT为108字节
- 修改帧类型定义:ENTER_BOOT从0xB5合并至0xB0,新增CAN业务类型0xC0~C3
- 更新CAN协议文档,支持SPI Link v2-only模式,调整最大业务payload计算
- 扩展0xC1帧功能,支持双向通信和1Hz主动推送ID快照
- 添加0xC3完整ID快照帧类型,包含seq/rx_count等完整字段
- 更新协议文档中的错误码定义和反馈payload格式

refactor(main): 重构主程序入口并添加CAN反馈任务

- 在main.cpp中添加CAN反馈任务,更新启动流程注释
- 创建createCriticalTask辅助函数,统一关键任务创建逻辑
- 调整SPI初始化顺序,确保CAN反馈任务依赖正确
- 优化错误处理机制,关键任务失败时立即重启
- 将GD32版本探测移至ota_relay任务处理
- 更新版本号至0.3.48,调整编译时间和Git哈希

config: 更新SPI和BLE配置参数

- 添加SPI主机相关配置参数,包括CS GPIO、READY GPIO等引脚定义
- 配置SPI缓冲区大小:APP为256字节,BOOT为128字节
- 更新BLE滑动窗口参数,ACK窗口设为32,数据队列深度为72
- 调整SPI工作队列大小至5120字节,增加任务堆栈监控说明
This commit is contained in:
小龙虾
2026-07-16 23:00:15 +08:00
parent 51c89b3065
commit 5db3f66b8e
57 changed files with 4728 additions and 2098 deletions
+11 -39
View File
@@ -172,36 +172,9 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
return beginCleanupAndReturn(ESP_FAIL);
}
/* ★ 整分区预擦(非 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();
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));
/* ★ 边写边擦(OTA_WITH_SEQUENTIAL_WRITES),不再整分区预擦:
* 旧方案在 HTTP 只收到 JSON 头后同步擦 1.25MB,浏览器 TCP 窗口堵死、进度卡开头。
* sequential:begin 立即返回,擦除摊到 esp_ota_write,与收流并行(对 WiFi 更友好)。 */
#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) || defined(CONFIG_APP_ROLLBACK_ENABLE)
{
esp_err_t inv = esp_ota_invalidate_inactive_ota_data_slot();
@@ -212,17 +185,16 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
}
#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();
const int64_t t0 = esp_timer_get_time();
esp_err_t err = esp_ota_begin(m_partition, OTA_WITH_SEQUENTIAL_WRITES, &m_handle);
const int64_t t1 = esp_timer_get_time();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_resume 失败: %s (耗时 %lld ms)",
esp_err_to_name(err), (long long)((t2 - t1) / 1000));
DEBUG_ERR(TAG, "esp_ota_begin(SEQUENTIAL) 失败: %s (耗时 %lld ms)",
esp_err_to_name(err), (long long)((t1 - t0) / 1000));
return beginCleanupAndReturn(err);
}
DEBUG_INFO(TAG, "esp_ota_resume 完成 耗时 %lld ms free=%lu",
(long long)((t2 - t1) / 1000),
DEBUG_INFO(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu",
(long long)((t1 - t0) / 1000),
(unsigned long)esp_get_free_heap_size());
m_received = 0;
@@ -231,7 +203,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 字节 (full-partition erase)",
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase-on-write)",
m_partition->label, (unsigned)total_size);
return ESP_OK;
}
+1 -1
View File
@@ -5,7 +5,7 @@
* begin → write*(N) → finalize / abort
*
* 统一以 WiFi 流程为基准:
* - begin:整分区预擦后 esp_ota_resume(写路径不再边写边擦)
* - begin:esp_ota_begin(OTA_WITH_SEQUENTIAL_WRITES) 边写边擦(避免 HTTP 头卡预擦)
* - 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
+84 -103
View File
@@ -9,7 +9,6 @@
#include "esp32_ota_core.h"
#include "relay/ota_relay.h"
#include "storage/gd32_flash_store.h"
#include "transport/spi_master.h"
#include "server/ble_ota.h"
#include "monitor/debug_log.h"
#include "esp_system.h"
@@ -57,8 +56,8 @@ bool OtaCoordinator::lock() const
if (!m_mutex) return false;
bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE);
if (!ok) {
DEBUG_WARN(TAG, "lock() 超时 (%u ms),本次状态变更被跳过",
(unsigned)pdTICKS_TO_MS(kLockTimeout));
DEBUG_ERR(TAG, "lock() 超时 (%u ms),本次状态变更被跳过",
(unsigned)pdTICKS_TO_MS(kLockTimeout));
}
return ok;
}
@@ -76,16 +75,16 @@ void OtaCoordinator::unlock(bool handle) const
// ============================================================
void OtaCoordinator::watchdogCheckLocked()
{
// 仅对"忙"状态(可能卡死的)做检查
// 仅对"忙"状态(可能卡死的)做检查。
// REBOOTING:已 set_boot,即将 esp_restart,不入狗,避免 abort 成 IDLE 与重启竞态。
switch (m_sysState) {
case OTA_SYS_GD32_STORING:
case OTA_SYS_GD32_RELAYING:
case OTA_SYS_ESP32_RECEIVING:
case OTA_SYS_ESP32_FINALIZING:
case OTA_SYS_ESP32_REBOOTING:
break;
default:
return; // IDLE/STORED/COMPLETE/ERROR 无需检查
return; // IDLE/STORED/COMPLETE/ERROR/REBOOTING 无需检查
}
if (m_busySinceTick == 0) {
@@ -162,9 +161,7 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
return ESP_ERR_INVALID_STATE;
}
DEBUG_WARN(TAG, "GD32 存储仍在进行中(同通道),中止旧存储并重新开始");
Gd32FlashStore::instance().metaClear();
m_sysState = OTA_SYS_IDLE;
m_busySinceTick = 0;
doAbortLocked(); // 清 meta + IDLE,与 abort 路径一致
// epoch 将在下方设置 GD32_STORING 后递增
}
@@ -221,27 +218,17 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t
return err;
}
// 3. 整分区预擦(不再边写边擦):prepare 阶段擦完 gd32_ota 分区,
// 收流时 flashWriteChunk 仅 write,吞吐更稳。会阻塞数秒~数十秒。
// 3. 复位擦除水位;不在 prepare 整区预擦。
// 旧方案在 HTTP JSON 头后同步擦 gd32_ota,TCP 堵在开头。
// 现由 flashWriteChunk → ensureErasedForWrite 按需擦扇区,与收流交织。
Gd32FlashStore::instance().beginWriteSession();
{
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: 整分区预擦完成");
if (Gd32FlashStore::instance().getPartition() == nullptr) {
DEBUG_ERR(TAG, "prepareGd32Storage: 找不到 gd32_ota 分区");
abortCurrent();
return ESP_ERR_NOT_FOUND;
}
DEBUG_INFO(TAG, "prepareGd32Storage: 会话就绪 fw=%" PRIu32 "(按需擦除,无整区预擦)",
total_size);
// 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0)
clearGd32Meta();
@@ -284,55 +271,39 @@ esp_err_t OtaCoordinator::finishGd32StorageAfterReceive()
return finalizeGd32Checksum();
}
// ============================================================
// GD32 存储操作(传输层统一入口)
// ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到
// Gd32FlashStore::instance(),消除跨 TU 调用开销。
// ============================================================
// ============================================================
// GD32 中继阶段
// ============================================================
esp_err_t OtaCoordinator::triggerGd32Relay()
{
bool h = lock();
if (!h) {
DEBUG_ERR(TAG, "triggerGd32Relay: lock 超时");
return ESP_ERR_INVALID_STATE;
}
// ★ 卡死自愈检查
if (h) watchdogCheckLocked();
watchdogCheckLocked();
// 校验:必须处于"已存储"或"已中继完成"状态
if (h && m_sysState != OTA_SYS_GD32_STORED && m_sysState != OTA_SYS_IDLE) {
if (m_sysState == OTA_SYS_GD32_STORING) {
DEBUG_WARN(TAG, "GD32 正在存储中,拒绝触发中继");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
if (m_sysState == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "GD32 中继已在进行中");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "ESP32 OTA 进行中,拒绝中继触发");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
/* 白名单:仅 STORED / IDLE(NVS 有完整固件)/ COMPLETE(允许再中继)可触发 */
if (m_sysState != OTA_SYS_GD32_STORED &&
m_sysState != OTA_SYS_IDLE &&
m_sysState != OTA_SYS_GD32_COMPLETE) {
DEBUG_WARN(TAG, "拒绝触发中继: 当前状态 %s (%d) 不在白名单",
stateToString(m_sysState), (int)m_sysState);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 验证固件是否确实已存储(通过 NVS 元数据)
ota_meta_t meta;
if (h && (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK ||
if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK ||
meta.total_size == 0 ||
meta.stored_size < meta.total_size)) {
meta.stored_size < meta.total_size) {
DEBUG_WARN(TAG, "GD32 固件未完整存储,拒绝触发中继");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 检查 OtaRelay 是否空闲
if (h && OtaRelay::getState() != RELAY_IDLE &&
if (OtaRelay::getState() != RELAY_IDLE &&
OtaRelay::getState() != RELAY_DONE &&
OtaRelay::getState() != RELAY_ERROR) {
DEBUG_WARN(TAG, "OtaRelay 正忙 (state=%d),拒绝触发", (int)OtaRelay::getState());
@@ -340,47 +311,42 @@ esp_err_t OtaCoordinator::triggerGd32Relay()
return ESP_ERR_INVALID_STATE;
}
// 向 OtaRelay task 发送启动消息
QueueHandle_t dq = OtaRelay::getDispatchQueue();
if (h && !dq) {
if (!dq) {
DEBUG_ERR(TAG, "OtaRelay dispatchQueue 未就绪");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
if (!h) {
unlock(h);
return ESP_ERR_INVALID_STATE;
}
m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
/* 先入队再标 RELAYING:失败时状态与 CAN 反馈都不变 */
pauseCanFeedbackTask(true);
DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING");
uint32_t cmd = 1;
BaseType_t ret = xQueueSend(dq, &cmd, pdMS_TO_TICKS(100));
if (ret != pdTRUE) {
DEBUG_ERR(TAG, "向 OtaRelay 发送启动消息失败");
m_sysState = OTA_SYS_GD32_STORED;
m_busySinceTick = 0; // 回退到 STORED(非忙)
pauseCanFeedbackTask(false);
unlock(h);
return ESP_FAIL;
}
m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount();
DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING");
unlock(h);
return ESP_OK;
}
/** @brief 通知协调器:GD32 中继已开始,更新系统状态为 GD32_RELAYING。 */
/** @brief 中继 task 已真正开始:若 trigger 已标 RELAYING 则只补 pause;否则兜底设状态。 */
void OtaCoordinator::notifyRelayStarted()
{
bool h = lock();
if (h && m_sysState != OTA_SYS_GD32_RELAYING) {
m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
if (h) {
if (m_sysState != OTA_SYS_GD32_RELAYING) {
m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount();
DEBUG_INFO(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING");
}
pauseCanFeedbackTask(true);
DEBUG_INFO(TAG, "中继已开始, 系统状态 → GD32_RELAYING");
}
unlock(h);
}
@@ -466,9 +432,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
return ESP_ERR_INVALID_STATE;
}
DEBUG_WARN(TAG, "ESP32 OTA 仍在接收中(同通道),中止旧会话并重新开始");
Esp32OtaCore::instance().abort();
m_sysState = OTA_SYS_IDLE;
m_busySinceTick = 0;
doAbortLocked(); // abort core + IDLE + 恢复 CAN 反馈
// epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效
}
@@ -558,6 +522,12 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe
void OtaCoordinator::setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg)
{
m_sysState = s;
/* REBOOTING 不参与 busy watchdog,清零起点避免误中止 */
if (s == OTA_SYS_ESP32_REBOOTING) {
m_busySinceTick = 0;
} else if (s == OTA_SYS_ESP32_FINALIZING && m_busySinceTick == 0) {
m_busySinceTick = xTaskGetTickCount();
}
DEBUG_INFO(TAG, "%s", dbg_msg);
}
@@ -667,12 +637,24 @@ void OtaCoordinator::healOrphanStates()
bool need_resume = false;
bool h = lock();
if (h) {
watchdogCheckLocked();
ota_system_state_t before = m_sysState;
watchdogCheckLocked(); // 超时则 doAbortLocked → IDLE
// finalize 失败后 core 已空、协调器仍 FINALIZING 的假占用:status 轮询即可自愈
if (m_sysState == OTA_SYS_ESP32_FINALIZING &&
!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "status 路径发现 ESP32_FINALIZING 残留,自愈 → IDLE");
doAbortLocked();
}
/* ERROR 非忙:status 轮询软清到 IDLE,避免 UI 长期卡 ERROR(新 begin 本也可覆盖) */
if (m_sysState == OTA_SYS_ERROR) {
DEBUG_INFO(TAG, "status 路径 ERROR → IDLE(软清)");
m_sysState = OTA_SYS_IDLE;
m_busySinceTick = 0;
m_gd32StorageDone = false;
pauseCanFeedbackTask(false);
}
// 任意自愈到 IDLE:恢复 WiFi OTA 期间挂起的 BLE(watchdog 旧路径漏 resume)
if (before != OTA_SYS_IDLE && m_sysState == OTA_SYS_IDLE) {
need_resume = true;
}
unlock(h);
@@ -806,28 +788,27 @@ OtaSnapshot OtaCoordinator::getSnapshot() const
// ============================================================
// GD32 APP 探测
// ============================================================
// 注:本函数与 OtaRelay::probeAndCacheAppAlive 逻辑相似,但因依赖方向
// (OtaCoordinator → OtaRelay)不应调用 OtaRelay 的内部实现细节,
// 故此处保留独立实现。
esp_err_t OtaCoordinator::probeGd32AppOnce(gd32_version_t *out_version, uint32_t *out_uptime)
{
/* 统一走 OtaRelay 公开探测,避免与 probeAndCacheAppAlive 双份逻辑 */
uint32_t uptime = 0;
gd32_version_t ver = {};
esp_err_t err = SpiMaster::instance().appPing(500, &uptime, &ver);
if (err == ESP_OK) {
OtaRelay::setGd32AppAlive(true);
OtaRelay::setGd32Version(ver);
if (out_version) *out_version = ver;
if (out_uptime) *out_uptime = uptime;
if (OtaRelay::probeAndCacheAppAlive(500, &uptime)) {
if (out_version) {
*out_version = OtaRelay::getSnapshot().version;
}
if (out_uptime) {
*out_uptime = uptime;
}
DEBUG_INFO(TAG, "GD32 APP 探测成功: alive, uptime=%" PRIu32 "s", uptime);
} else {
OtaRelay::setGd32AppAlive(false);
if (out_version) *out_version = ver;
if (out_uptime) *out_uptime = 0;
DEBUG_WARN(TAG, "GD32 APP 探测失败: %s", esp_err_to_name(err));
return ESP_OK;
}
return err;
if (out_version) {
*out_version = gd32_version_t{};
}
if (out_uptime) {
*out_uptime = 0;
}
return ESP_FAIL;
}
// ============================================================
@@ -835,12 +816,12 @@ esp_err_t OtaCoordinator::probeGd32AppOnce(gd32_version_t *out_version, uint32_t
// ============================================================
void OtaCoordinator::reset()
{
// ★ 与 abortCurrent 共用 doAbortLocked:清 Esp32OtaCore / relay / meta,勿只改枚举
bool h = lock();
if (h) {
m_sysState = OTA_SYS_IDLE;
m_gd32StorageDone = false;
m_busySinceTick = 0; // ★ 复位
DEBUG_INFO(TAG, "协调器已完全复位");
doAbortLocked();
DEBUG_INFO(TAG, "协调器已完全复位(含子系统 abort)");
}
unlock(h);
wifiOtaResumeBleIfNeeded();
}
+11 -11
View File
@@ -124,7 +124,8 @@ public:
esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel);
/**
* @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 整分区预擦 + 清空 meta + 写入初始 meta。
* @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位擦除水位 + 清空 meta + 写入初始 meta。
* 扇区擦除由 flashWriteChunk/ensureErasedForWrite 按需进行(避免 HTTP 头卡整区预擦)。
* 收流时 write 路径只写 Flash(ensureErased 仅作水位兜底)。
* WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。
* @param total_size 固件总字节数。
@@ -337,9 +338,9 @@ public:
/**
* @brief 自愈孤儿忙态(供 /system/status 等轮询路径调用)。
* - 跑忙状态 watchdog(超时完整 abort → IDLE)
* - ESP32_FINALIZING 但 Esp32OtaCore 已空闲:判定为 SHA 失败等残留,复位 IDLE
* 不影响真正正在 finalize(core.isActive())的会话。
* - 忙状态 watchdog(超时完整 abort → IDLE;不含 REBOOTING)
* - ESP32_FINALIZING 但 core 已空闲:残留 → IDLE
* - ERROR → IDLE 软清,避免 UI 长期卡错误态
*/
void healOrphanStates();
@@ -476,9 +477,7 @@ private:
bool lock() const;
void unlock(bool handle) const;
// ★ 卡死自愈检查:若忙状态停留超过 watchdog 超时,强制转为 ERROR。
// 用于在中继 task 因任何原因(崩溃/SPI 死锁/看门狗复位残留)未能回调
// notifyRelayComplete/notifyEsp32*Complete 时,让协调器自动脱离卡死状态。
// ★ 卡死自愈检查:忙状态超时则 doAbortLocked → IDLE(不含 REBOOTING)。
// 必须在已持锁状态下调用。
void watchdogCheckLocked();
@@ -513,11 +512,12 @@ private:
// BLE 共存监听器(由 WifiOta 实现并注册)
IBleCoexListener *m_bleCoexListener = nullptr;
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100);
static constexpr TickType_t kLockTimeout =
pdMS_TO_TICKS(OTA_COORD_LOCK_TIMEOUT_MS);
// ★ 忙状态 watchdog 超时。GD32 中继理论最大耗时 ≈ 60s(全局超时) + 15s(APP存活轮询) + 1s(bootloader) ≈ 76s,
// 取 90 s,正常流程远不会触及,仅在 task 卡死时触发自愈。
static constexpr TickType_t kBusyWatchdogTicks = pdMS_TO_TICKS(90U * 1000U);
// ★ 忙状态 watchdog:须覆盖最长中继预算;数值见 app_config.h
static constexpr TickType_t kBusyWatchdogTicks =
pdMS_TO_TICKS(OTA_COORD_BUSY_WATCHDOG_MS);
};
#endif // OTA_COORDINATOR_H