diff --git a/main/include/version.h b/main/include/version.h index d4058ca..ccf3cd1 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 42 -#define FW_VERSION_STRING "0.3.42" +#define FW_VERSION_PATCH 43 +#define FW_VERSION_STRING "0.3.43" // 编译时间 -#define BUILD_TIME "2026-07-13 03:05:29" +#define BUILD_TIME "2026-07-14 12:05:28" // Git 信息 -#define GIT_HASH "0b36349" +#define GIT_HASH "7678234" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index ae2d3f9..6117730 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -28,7 +28,7 @@ namespace { constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时 constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔 constexpr uint8_t kSpiCanFeedbackType = 0xC1U; -constexpr size_t kRingPayloadMax = 128U; ///< 反馈 payload 最大长度 +constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断 constexpr const char *kTag = "CAN_FB"; static can_feedback_result_t s_latest[CAN_CHANNEL_MAX]; @@ -72,8 +72,8 @@ static void taskLoop(void *) } /* 取出 checkGd32Request 存入的 type/payload */ - if (!SpiMaster::instance().takeRingRx(&ring_type, ring_payload, - sizeof(ring_payload), &ring_len)) { + if (!SpiMaster::instance().takeEvent(&ring_type, ring_payload, + sizeof(ring_payload), &ring_len)) { continue; } diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index 14a6982..d037a68 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -24,6 +24,8 @@ #include "freertos/semphr.h" #include "esp_log.h" #include "esp_err.h" +#include "esp_system.h" +#include "esp_heap_caps.h" #include "driver/uart.h" #include "debug_log.h" @@ -117,13 +119,37 @@ char* DebugLog::ringDrainJson(int *out_len) { if (!out_len) return NULL; *out_len = 0; + + /* 堆紧时不 malloc:返回堆上最小 "[]",调用方仍 free。 */ + const uint32_t free_now = esp_get_free_heap_size(); + if (free_now < kDrainMinFree) { + char *empty = (char *)malloc(3); + if (!empty) return NULL; + empty[0] = '['; + empty[1] = ']'; + empty[2] = '\0'; + *out_len = 2; + return empty; + } + int count = ringCount(); - // ★ 限制单次导出条数,避免 count=64 时 malloc(32KB) 在堆紧张时失败 - // 前端每秒轮询,16 条/次足够保持实时性;超出的旧条目下一轮再拉 - static constexpr int kMaxDrain = 16; + /* OTA 沉默失败排查需要一次多拉;上限 24 条 */ + static constexpr int kMaxDrain = 24; if (count > kMaxDrain) count = kMaxDrain; int capacity = count * (LOG_RING_SIZE * 2) + 16; if (capacity < 16) capacity = 16; + + const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); + if (largest < (size_t)capacity) { + char *empty = (char *)malloc(3); + if (!empty) return NULL; + empty[0] = '['; + empty[1] = ']'; + empty[2] = '\0'; + *out_len = 2; + return empty; + } + char *buf = (char*)malloc(capacity); if (!buf) return NULL; @@ -333,14 +359,16 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len) { esp_err_t udp_ret = ESP_FAIL; esp_err_t uart_ret = ESP_FAIL; + const uint32_t free_now = esp_get_free_heap_size(); // UART 通道(UART 不可用时跳过 uart_write_bytes 调用) if (m_uartAvailable) { uart_ret = uartSend(data, len); } - // UDP 通道(WiFi 未连接时 sendto 失败,静默跳过) - if (m_initialized) { + /* UDP 通道:堆紧时跳过 sendto,避免申请 LWIP TX pbuf 与 HTTP 回包抢堆。 + * WiFi 未连接时 sendto 失败也静默跳过。 */ + if (m_initialized && free_now >= kSkipUdpFree) { if (m_udpSock < 0) { ensureUdpSocket(); } @@ -355,12 +383,16 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len) } } - // 同时写入环形缓冲区(供 HTTP /logs 拉取) + // 同时写入环形缓冲区(供 HTTP /logs 拉取)——静态,不占堆 ringPush((const char *)data, (int)len); if (udp_ret == ESP_OK || uart_ret == ESP_OK) { return ESP_OK; } + /* 仅 ring 成功也算可用(OTA 堆紧时常见) */ + if (m_logRingMutex) { + return ESP_OK; + } return ESP_FAIL; } @@ -381,7 +413,22 @@ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, .. return; } - char prefix[32]; + /* 堆门控:OTA/SPI 的 INFO 必须进 ring,否则 WebUI 只能看到“沉默失败”。 */ + const uint32_t free_now = esp_get_free_heap_size(); + const bool keep_ota_spi = (tag != nullptr && + (strcmp(tag, "OTA") == 0 || strcmp(tag, "SPI") == 0)); + if (!keep_ota_spi) { + if (free_now < kDropWarnFree && level > DEBUG_LOG_ERR) { + return; // 无关模块:仅 ERR + } + if (free_now < kDropInfoFree && level > DEBUG_LOG_WARN) { + return; // 无关模块:丢 INFO/DBG + } + } else if (free_now < kDropWarnFree && level >= DEBUG_LOG_DBG) { + return; // OTA/SPI:极紧时仍丢 DBG,INFO/WARN/ERR 保留 + } + + char prefix[24]; const char *level_str; switch (level) { case DEBUG_LOG_ERR: level_str = "E"; break; @@ -392,14 +439,14 @@ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, .. } snprintf(prefix, sizeof(prefix), "[%s][%s]", level_str, tag); - // 控制单条日志栈缓冲大小,避免调用任务(如 can_fb)因日志格式化爆栈。 + /* 栈缓冲与 LOG_RING_SIZE 对齐,减轻 httpd 等任务栈压。 */ va_list args; va_start(args, fmt); - char msg[256]; + char msg[160]; vsnprintf(msg, sizeof(msg), fmt, args); va_end(args); - char log_buf[320]; + char log_buf[192]; int total = snprintf(log_buf, sizeof(log_buf), "%s %s\n", prefix, msg); // ★ snprintf 截断时返回"本应写入的长度"(可能 > sizeof(log_buf)), diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index 568e9ff..c9fcca9 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -99,14 +99,25 @@ private: // m_uartInitialized 仅表示曾尝试初始化,m_uartAvailable 才是写日志的判据。 bool m_uartAvailable = false; + /* 环形日志:OTA 中继期需保留关键 INFO,64×160≈10KB BSS。 */ static constexpr int LOG_RING_SLOTS = 64; - static constexpr int LOG_RING_SIZE = 256; + static constexpr int LOG_RING_SIZE = 160; char m_logRing[LOG_RING_SLOTS][LOG_RING_SIZE]; int m_logRingLens[LOG_RING_SLOTS]; int m_logRingHead = 0; int m_logRingCount = 0; SemaphoreHandle_t m_logRingMutex = NULL; + /* 堆门控(防 OTA 时 UDP/格式化抢 LWIP 堆): + * free < kDropWarnFree → 非 OTA/SPI 只保留 ERR + * free < kDropInfoFree → 非 OTA/SPI 丢 INFO/DBG(OTA/SPI 的 INFO 仍进 ring) + * free < kSkipUdpFree → 不 sendto(仍 UART+ring) + * free < kDrainMinFree → /logs 尽量仍返回 ring(降低门槛,避免“沉默失败”) */ + static constexpr uint32_t kDropWarnFree = 6U * 1024U; + static constexpr uint32_t kDropInfoFree = 10U * 1024U; + static constexpr uint32_t kSkipUdpFree = 10U * 1024U; + static constexpr uint32_t kDrainMinFree = 8U * 1024U; + esp_err_t ensureUdpSocket(); void ringPush(const char *data, int len); }; diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 5a497aa..8ad27ce 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -22,6 +22,7 @@ #include "debug_log.h" #include "task_stack_check.h" #include "stack_overflow_hook.h" +#include "ota/ota_coordinator.h" // ============================================================ // 栈水位主动巡检 @@ -234,13 +235,19 @@ void HealthMonitor::taskEntry(void *pvParameters) uint32_t free_heap = esp_get_free_heap_size(); if (free_heap < HEAP_WARNING_MIN) { - /* OTA 期间堆会短暂变紧,限流避免串口/UDP 二次吃堆、刷屏 */ + /* WiFi OTA 收流时 TCP 窗口 + httpd 会把 free 压到 5~10KB,属预期,勿当故障刷屏。 */ static TickType_t s_last_heap_warn = 0; TickType_t now = xTaskGetTickCount(); - if ((now - s_last_heap_warn) >= pdMS_TO_TICKS(2000)) { + const bool ota_busy = !OtaCoordinator::instance().isIdle(); + const uint32_t interval_ms = ota_busy ? 15000U : 3000U; + /* OTA 中只在 free 极低(<4KB)时告警;空闲时维持 10KB 阈值 */ + const uint32_t warn_floor = ota_busy ? 4096U : (uint32_t)HEAP_WARNING_MIN; + if (free_heap < warn_floor && + (now - s_last_heap_warn) >= pdMS_TO_TICKS(interval_ms)) { s_last_heap_warn = now; uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - DEBUG_WARN("MON", "堆内存不足: free=%lu min=%lu largest_blk=%lu", + DEBUG_WARN("MON", "堆内存偏紧%s: free=%lu min=%lu largest_blk=%lu", + ota_busy ? "(OTA中)" : "", (unsigned long)free_heap, (unsigned long)esp_get_minimum_free_heap_size(), (unsigned long)largest_block); diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index 72d7906..5da802a 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -213,14 +213,17 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len) return ESP_ERR_INVALID_STATE; } - /* sequential 模式写时可能触发扇区擦除,喂狗防 WDT */ + /* sequential 模式写时可能触发扇区擦除(禁 cache),期间 WiFi 易饿死。 + * 写后 yield 一次即可;写前再 yield 会无谓拖慢吞吐(httpd 侧另有堆感知 pacing)。 */ (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 { - DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u offset=%u)", - esp_err_to_name(err), (unsigned)len, (unsigned)m_received); + DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u offset=%u free=%lu)", + esp_err_to_name(err), (unsigned)len, (unsigned)m_received, + (unsigned long)esp_get_free_heap_size()); } return err; } @@ -433,7 +436,7 @@ bool Esp32OtaCore::forceCleanup() if (!s_mutex) return false; // 在互斥保护下检查 s_occupied - if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false; + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) != pdTRUE) return false; bool was_occupied = s_occupied; if (was_occupied) { diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 1206032..d48027e 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -10,6 +10,7 @@ #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" #include "freertos/FreeRTOS.h" @@ -18,6 +19,23 @@ #define TAG "OTA_COORD" +/** WiFi OTA 时挂起 BLE;BLE 通道自身 OTA 不挂。调用勿持 m_mutex。 */ +static void wifiOtaSuspendBleIfNeeded(ota_channel_t channel) +{ + if (channel != OTA_CHANNEL_WIFI) { + return; + } + (void)BleOta::instance().suspendStack(); +} + +/** 回到可空闲态时恢复 BLE。调用勿持 m_mutex。 */ +static void wifiOtaResumeBleIfNeeded(void) +{ + if (BleOta::instance().isSuspendedForWifiOta()) { + (void)BleOta::instance().resumeStack(); + } +} + // ============================================================ // 单例 // ============================================================ @@ -190,10 +208,16 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t return err; } + /* WiFi GD32 上传前先挂 BLE,腾堆给 HTTP 收流 */ + wifiOtaSuspendBleIfNeeded(channel); + // 2. 进入 GD32_STORING 状态(系统忙则拒绝) err = beginGd32Storage(total_size, channel); if (err != ESP_OK) { DEBUG_WARN(TAG, "prepareGd32Storage: 协调器拒绝存储(系统忙)"); + if (channel == OTA_CHANNEL_WIFI) { + wifiOtaResumeBleIfNeeded(); + } return err; } @@ -223,15 +247,20 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t void OtaCoordinator::completeGd32Storage() { bool h = lock(); + bool resume_ble = false; if (h && m_sysState == OTA_SYS_GD32_STORING) { m_sysState = OTA_SYS_GD32_STORED; m_gd32StorageDone = true; m_busySinceTick = 0; // ★ STORED 是非忙状态 + resume_ble = (m_otaChannel == OTA_CHANNEL_WIFI); DEBUG_INFO(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED"); } else if (h) { DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState); } unlock(h); + if (resume_ble) { + wifiOtaResumeBleIfNeeded(); + } } esp_err_t OtaCoordinator::finishGd32StorageAfterReceive() @@ -315,7 +344,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay() DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING"); uint32_t cmd = 1; - BaseType_t ret = xQueueSend(dq, &cmd, pdMS_TO_TICKS(200)); + BaseType_t ret = xQueueSend(dq, &cmd, pdMS_TO_TICKS(100)); if (ret != pdTRUE) { DEBUG_ERR(TAG, "向 OtaRelay 发送启动消息失败"); m_sysState = OTA_SYS_GD32_STORED; @@ -360,6 +389,8 @@ void OtaCoordinator::notifyRelayComplete(bool success) } } unlock(h); + /* 中继结束:若 BLE 仍因 WiFi OTA 挂起则恢复 */ + wifiOtaResumeBleIfNeeded(); } // ============================================================ @@ -451,8 +482,6 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann m_otaChannel = channel; // ★ 记录发起通道 m_sysState = OTA_SYS_ESP32_RECEIVING; m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻 - DEBUG_INFO(TAG, "ESP32 OTA 开始: %" PRIu32 " 字节, 系统状态 → ESP32_RECEIVING (epoch=%u, channel=%d)", - total_size, m_otaEpoch, (int)channel); pauseCanFeedbackTask(true); } else { DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err)); @@ -465,6 +494,9 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channel_t channel) { + /* WiFi 路径:在 begin 前挂 BLE,把 BT 堆腾给 OTA 收流(BLE 通道 OTA 不挂)。 */ + wifiOtaSuspendBleIfNeeded(channel); + esp_err_t err = beginEsp32Ota(total_size, channel); if (err == ESP_ERR_INVALID_STATE) { // ★ 仅当系统确实空闲(无任何活跃 OTA)时才允许 forceCleanup: @@ -478,6 +510,9 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe if (!isIdleState(cur) && cur != OTA_SYS_ERROR) { DEBUG_WARN(TAG, "ESP32 OTA 被拒绝且系统非空闲 (state=%d),不强制清理,直接失败", (int)cur); + if (channel == OTA_CHANNEL_WIFI) { + wifiOtaResumeBleIfNeeded(); + } return err; } @@ -489,6 +524,9 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe Esp32OtaCore::instance().forceCleanup(); err = beginEsp32Ota(total_size, channel); } + if (err != ESP_OK && channel == OTA_CHANNEL_WIFI) { + wifiOtaResumeBleIfNeeded(); + } return err; } @@ -547,7 +585,7 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h void OtaCoordinator::restartEsp32() { notifyEsp32Rebooting(); - vTaskDelay(pdMS_TO_TICKS(500)); + vTaskDelay(pdMS_TO_TICKS(100)); esp_restart(); // 不会返回 } @@ -573,6 +611,7 @@ void OtaCoordinator::doAbortLocked() m_gd32StorageDone = false; m_busySinceTick = 0; DEBUG_INFO(TAG, "已中止, 系统状态 → IDLE"); + /* 释放协调器锁后由调用方 resume BLE;doAbortLocked 可能持锁,标记后外层 resume */ } void OtaCoordinator::abortCurrent() @@ -582,6 +621,7 @@ void OtaCoordinator::abortCurrent() doAbortLocked(); } unlock(h); + wifiOtaResumeBleIfNeeded(); } uint32_t OtaCoordinator::getOtaEpoch() const @@ -607,6 +647,7 @@ bool OtaCoordinator::abortCurrentIfEpoch(uint32_t epoch) doAbortLocked(); unlock(h); + wifiOtaResumeBleIfNeeded(); return true; } diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 307a6e3..d93c0cc 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -50,6 +50,8 @@ static ota_result_t commonReject(relay_event_t e, bool allowHealth = false) static constexpr uint32_t kMaxResendOnNack = 10U; // ACK 失败/轮询耗尽重发上限 static constexpr uint32_t kMaxResendOnStructError = 3U; // 结构化错误重发上限 static constexpr uint32_t kLastPacketAckSeq = 0xF0F0F0F0U; // 末包 ACK 序号约定 +// 10 秒无成功帧停刷:跨包级别的背压/卡死检测,比单包 15s 超时更早触发。 +static constexpr uint32_t kStallTimeoutMs = 8000U; // ============================================================ // 静态成员定义 @@ -349,6 +351,17 @@ ota_result_t OtaRelay::handleTransferring(relay_event_t event, const uint8_t *pa DEBUG_INFO("OTA", "=== 阶段3: 数据包传输 (%" PRIu32 " 包) ===", m_transferTotalPackets); + /* 转发预算:自本阶段起算。正常 ~15ms/包,预算 40ms/包 + 20s 底(上限 120s)。 */ + constexpr uint32_t kXferMinMs = 20U * 1000U; + constexpr uint32_t kXferMaxMs = 120U * 1000U; + constexpr uint32_t kXferPerPktMs = 40U; + uint32_t xfer_ms = kXferMinMs + m_transferTotalPackets * kXferPerPktMs; + if (xfer_ms > kXferMaxMs) { + xfer_ms = kXferMaxMs; + } + m_transferDeadline = xTaskGetTickCount() + pdMS_TO_TICKS(xfer_ms); + DEBUG_INFO("OTA", "转发超时预算: %" PRIu32 " ms (%" PRIu32 " 包)", xfer_ms, m_transferTotalPackets); + ota_result_t res = doRelayPackets(m_transferDeadline); if (res != OTA_RESULT_OK) { setState(RELAY_ERROR); @@ -437,6 +450,9 @@ ota_result_t OtaRelay::forwardFirmware() DEBUG_INFO("OTA", "开始固件转发: %" PRIu32 " 字节", meta.total_size); + /* 中继全程禁止 worker 把 OTA ACK/WAIT 当 EVENT 偷读 */ + SpiMaster::instance().setSuppressRingService(true); + // 固件完整性校验 { esp_err_t verify_err = Gd32FlashStore::instance().metaVerifyChecksum(); @@ -447,16 +463,10 @@ ota_result_t OtaRelay::forwardFirmware() } } - // 全局超时计算 - constexpr uint32_t kMinGlobalTimeoutMs = 20U * 1000U; // 20 s 最小超时 - constexpr uint32_t kMaxGlobalTimeoutMs = 60U * 1000U; // 60 s 硬上限 - uint32_t dynamic_ms = kMinGlobalTimeoutMs + (meta.total_size / 1024U) * 50U; // 50 ms / KB - uint32_t global_timeout_ms = (dynamic_ms > kMaxGlobalTimeoutMs) ? kMaxGlobalTimeoutMs : dynamic_ms; - TickType_t start_tick = xTaskGetTickCount(); - m_transferDeadline = start_tick + pdMS_TO_TICKS(global_timeout_ms); - - DEBUG_INFO("OTA", "全局超时: %" PRIu32 " ms, start_tick=%" PRIu32 ", deadline tick=%" PRIu32, - global_timeout_ms, (uint32_t)start_tick, (uint32_t)m_transferDeadline); + /* 全局 deadline 在进入 TRANSFERRING 时才启动(见 handleTransferring)。 + * 握手 + Boot 擦 Flash(SIZE 阶段长时间 WAIT)不得占用转发预算, + * 否则 1117 包会在擦除后仅剩十余秒被“全局超时”误杀。 */ + m_transferDeadline = 0; // ── 驱动状态机 ── // 5 步顺序推进:IDLE → HANDSHAKING → SIZE_NEG → TRANSFERRING → FINISHING → DONE @@ -475,6 +485,9 @@ ota_result_t OtaRelay::forwardFirmware() if (res != OTA_RESULT_OK) { DEBUG_ERR("OTA", "中继失败: result=%d, state=%d", (int)res, (int)getState()); reset(); + } else { + /* 成功路径:解除按铃抑制(reset 也会做,成功时状态机可能停在 DONE) */ + SpiMaster::instance().setSuppressRingService(false); } return res; } @@ -498,6 +511,7 @@ void OtaRelay::reset() memset(m_kSession, 0, sizeof(m_kSession)); memset(m_nonceServer, 0, sizeof(m_nonceServer)); memset(m_gd32UidRuntime, 0, sizeof(m_gd32UidRuntime)); + SpiMaster::instance().setSuppressRingService(false); DEBUG_INFO("OTA", "状态机已复位 → IDLE"); } @@ -977,6 +991,7 @@ ota_result_t OtaRelay::doSendFirmwareSize(uint32_t size) return OTA_RESULT_ERR_TIMEOUT; } + SpiMaster::instance().confirmTxSeqAccepted(); DEBUG_INFO("OTA", "大小协商完成: %" PRIu32 " 字节,GD32 已就绪收包", size); return OTA_RESULT_OK; } @@ -1034,162 +1049,111 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], uint8_t resp_type = 0; uint8_t resp_payload[32] = {0}; size_t resp_len = 0; - uint32_t retry_count = 0; - ota_result_t result = OTA_RESULT_OK; - bool got_expected_credit_ok = false; const uint32_t expected_ack = (seq == m_transferTotalPackets - 1) ? kLastPacketAckSeq : (seq + 1U); + /* + * 协议要点: + * - GD32 ISR 收 DATA 后立刻回 WAIT,主循环 AES/Flash 后再 ACK。 + * - 一旦见过 WAIT,说明包已进从机;再发 DATA = DUP-SEQ,只会反复 WAIT。 + * - 因此:见 WAIT 后只 poll;仅「完全无响应」才允许重发 DATA。 + */ + constexpr uint32_t kMaxDataSends = 3U; + constexpr uint32_t kAckWaitMs = 6000U; /* 单次发送后等 ACK 上限(覆盖 GD32 增量 drain) */ + constexpr uint32_t kPollSliceMs = 80U; - // ★ 单包耗时统计,用于定位 OTA 速度瓶颈。 TickType_t pkt_start = xTaskGetTickCount(); - /* 单包硬超时:避免重试环 + WAIT/CREDIT 轮询在异常时“静默卡死”十几分钟。 */ - const TickType_t pkt_deadline = pkt_start + pdMS_TO_TICKS(15000); - TickType_t last_stall_log = pkt_start; + bool saw_wait = false; - // 注意:goto resend_packet 保持原有语义,仅作用范围缩小到本 helper。 - // 完全去除 goto 需配合 GD32 全链路 OTA 回归验证后再进行。 -resend_packet: - while (1) { - if (xTaskGetTickCount() >= pkt_deadline) { - DEBUG_ERR("OTA", "包 %" PRIu32 " 单包超时(15s), retries=%" PRIu32 " credit=%u", - seq, retry_count, (unsigned)SpiMaster::instance().remoteCredit()); - result = OTA_RESULT_ERR_TIMEOUT; - break; - } - if ((xTaskGetTickCount() - last_stall_log) >= pdMS_TO_TICKS(2000)) { - DEBUG_INFO("OTA", "包 %" PRIu32 " 仍在等待 ACK (retry=%" PRIu32 " credit=%u)", - seq, retry_count, (unsigned)SpiMaster::instance().remoteCredit()); - last_stall_log = xTaskGetTickCount(); - } - - resp_len = 0; - esp_err_t err = SpiMaster::instance().otaTransfer(SPI_OTA_CMD_DATA, tx_buf, 64, - &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000); - logRespPayload("DATA_PACKET", resp_type, resp_payload, resp_len); - if (err == ESP_OK) { - /* WAIT 常见于 GD32 主循环 AES/写 Flash:多轮 poll,勿过早放弃。 */ - err = waitUntilNotWait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 200, 40); - } - if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) { - DEBUG_WARN("OTA", "数据包 %" PRIu32 " 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len); - if (++retry_count >= kMaxResendOnNack) { - DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq); - result = OTA_RESULT_ERR_TIMEOUT; - break; + for (uint32_t send_i = 0; send_i < kMaxDataSends; ++send_i) { + /* 已见 WAIT:禁止再发 DATA,只延长 poll */ + if (saw_wait && send_i > 0U) { + DEBUG_INFO("OTA", "包 %" PRIu32 " 已见 WAIT,继续 poll 不重发", seq); + } else { + resp_len = 0; + resp_type = 0; + esp_err_t err = SpiMaster::instance().otaTransfer( + SPI_OTA_CMD_DATA, tx_buf, 64, + &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1200); + logRespPayload("DATA_PACKET", resp_type, resp_payload, resp_len); + if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) { + saw_wait = true; } - vTaskDelay(pdMS_TO_TICKS(2)); - continue; // 重发本包 - } - - if (!isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) { - uint32_t err_seq = 0; - uint8_t err_code = 0; - if (parseStructuredError(resp_payload, resp_len, &err_seq, &err_code)) { - if (err_seq == seq) { - DEBUG_WARN("OTA", "包 %" PRIu32 " 收到结构化错误 err=0x%02X,准备重发", seq, err_code); - if (++retry_count >= kMaxResendOnStructError) { - DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq); - result = (err_code == 0x04U) ? OTA_RESULT_ERR_CRC : OTA_RESULT_ERR_GD32; - break; - } - continue; - } - } - - /* CREDIT 感知:GD32 写 Flash / DATA 队列满时 CREDIT=0,耐心 POLL。 */ - if (SpiMaster::instance().remoteCredit() == 0U) { - DEBUG_INFO("OTA", "包 %" PRIu32 " GD32 CREDIT=0,等待恢复...", seq); - for (uint32_t cw = 0; cw < 100U; ++cw) { - if (xTaskGetTickCount() >= pkt_deadline) { - break; - } - err = SpiMaster::instance().otaPollResponse(&resp_type, resp_payload, - sizeof(resp_payload), &resp_len, 100); - if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK && - isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) { - got_expected_credit_ok = true; - break; - } - if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) { - continue; - } - if (err == ESP_OK && SpiMaster::instance().remoteCredit() > 0U) { - break; - } - vTaskDelay(pdMS_TO_TICKS(2)); - } - } - - bool got_expected = false; - if (got_expected_credit_ok) { + if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK && + isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) { + SpiMaster::instance().confirmTxSeqAccepted(); m_transferFlashOffset += (uint32_t)chunk_size; - break; + if (seq < 5U || (seq % 10U) == 0U || seq + 1U == m_transferTotalPackets) { + DEBUG_INFO("OTA", "转发进度 %" PRIu32 "/%" PRIu32, + seq + 1U, m_transferTotalPackets); + } + return false; } - for (uint32_t retry = 0; retry < 10; ++retry) { - DEBUG_DBG("OTA", "包 %" PRIu32 " 收到非目标ACK(seq不匹配),继续轮询(%" PRIu32 "/5)...", seq, retry + 1); - err = SpiMaster::instance().otaPollResponse(&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 2000); - if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) { - err = waitUntilNotWait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 100, 5); - } - if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) { - DEBUG_WARN("OTA", "包 %" PRIu32 " poll 超时/非ACK,重发 (retry=%" PRIu32 "/3)", seq, retry_count + 1); - if (++retry_count >= kMaxResendOnStructError) { - DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq); - result = OTA_RESULT_ERR_GD32; - break; - } - // ★ resend 前延迟 - vTaskDelay(pdMS_TO_TICKS(1)); - got_expected_credit_ok = false; - goto resend_packet; - } - if (isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) { - got_expected = true; - break; - } - if (parseStructuredError(resp_payload, resp_len, &err_seq, &err_code) && err_seq == seq) { - break; - } + } + + const TickType_t poll_deadline = xTaskGetTickCount() + pdMS_TO_TICKS(kAckWaitMs); + TickType_t last_hb = xTaskGetTickCount(); + while (xTaskGetTickCount() < poll_deadline) { + if ((xTaskGetTickCount() - last_hb) >= pdMS_TO_TICKS(1000)) { + DEBUG_INFO("OTA", "包 %" PRIu32 " 等 ACK… send=%" PRIu32 " type=0x%02X wait=%d credit=%u", + seq, send_i, resp_type, (int)saw_wait, + (unsigned)SpiMaster::instance().remoteCredit()); + last_hb = xTaskGetTickCount(); } - if (got_expected) { - m_transferFlashOffset += (uint32_t)chunk_size; - break; - } + esp_err_t err = SpiMaster::instance().otaPollResponse( + &resp_type, resp_payload, sizeof(resp_payload), &resp_len, kPollSliceMs); - if (parseStructuredError(resp_payload, resp_len, &err_seq, &err_code) && err_seq == seq) { - DEBUG_WARN("OTA", "包 %" PRIu32 " 收到结构化错误 err=0x%02X,准备重发", seq, err_code); - if (++retry_count >= kMaxResendOnNack) { - DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq); - result = (err_code == 0x04U) ? OTA_RESULT_ERR_CRC : OTA_RESULT_ERR_GD32; - break; - } + if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) { + saw_wait = true; continue; } - - DEBUG_WARN("OTA", "包 %" PRIu32 " 轮询耗尽未匹配,重发 (retry=%" PRIu32 "/10)", seq, retry_count + 1); - if (++retry_count >= kMaxResendOnNack) { - DEBUG_ERR("OTA", "包 %" PRIu32 " 重发次数超限", seq); - result = OTA_RESULT_ERR_GD32; - break; + if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK) { + if (isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) { + SpiMaster::instance().confirmTxSeqAccepted(); + m_transferFlashOffset += (uint32_t)chunk_size; + if (seq < 5U || (seq % 10U) == 0U || seq + 1U == m_transferTotalPackets) { + DEBUG_INFO("OTA", "转发进度 %" PRIu32 "/%" PRIu32, + seq + 1U, m_transferTotalPackets); + } + return false; + } + uint32_t err_seq = 0; + uint8_t err_code = 0; + if (parseStructuredError(resp_payload, resp_len, &err_seq, &err_code) && + err_seq == seq) { + DEBUG_WARN("OTA", "包 %" PRIu32 " 结构化错误 err=0x%02X", seq, err_code); + if (err_code == 0x04U) { + *out_result = OTA_RESULT_ERR_CRC; + return true; + } + saw_wait = false; /* 允许带错误码的重发 */ + break; + } + /* 陈旧 ACK,继续 poll */ } - // ★ resend 前延迟:给 GD32 时间处理上一包并更新 SPI 响应缓冲区 - vTaskDelay(pdMS_TO_TICKS(1)); - goto resend_packet; } - m_transferFlashOffset += (uint32_t)chunk_size; - break; + /* 超时:仅当从未见 WAIT 时才重发 DATA;见过 WAIT 则再 poll 一轮 */ + if (saw_wait) { + if (send_i + 1U >= kMaxDataSends) { + break; + } + /* 把下一轮当作「只 poll」:send_i 增加但 saw_wait 使跳过 transfer */ + continue; + } + if (send_i + 1U < kMaxDataSends) { + DEBUG_WARN("OTA", "包 %" PRIu32 " 无 WAIT/ACK,重发 DATA (%" PRIu32 "/%" PRIu32 ")", + seq, send_i + 1U, kMaxDataSends); + vTaskDelay(pdMS_TO_TICKS(2)); + } } - TickType_t pkt_elapsed = xTaskGetTickCount() - pkt_start; - DEBUG_DBG("OTA", "包 %" PRIu32 " 完成: elapsed=%" PRIu32 " ms, retries=%" PRIu32, seq, - (uint32_t)(pkt_elapsed * portTICK_PERIOD_MS), retry_count); - - if (result != OTA_RESULT_OK) { - *out_result = result; - return true; // 中止外层循环 - } - return false; // 继续下一包 + DEBUG_ERR("OTA", "包 %" PRIu32 " 失败 (elapsed=%" PRIu32 " ms credit=%u type=0x%02X saw_wait=%d)", + seq, + (uint32_t)((xTaskGetTickCount() - pkt_start) * portTICK_PERIOD_MS), + (unsigned)SpiMaster::instance().remoteCredit(), + resp_type, (int)saw_wait); + *out_result = OTA_RESULT_ERR_TIMEOUT; + return true; } ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) @@ -1202,6 +1166,7 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) uint8_t tx_buf[64] = {0}; ota_result_t result = OTA_RESULT_OK; + TickType_t last_success_tick = xTaskGetTickCount(); for (uint32_t seq = 0; seq < m_transferTotalPackets; seq++) { // 健康检查(每 OTA_PACKET_HEALTH_CHECK_INTERVAL 包 + 首末包) @@ -1219,35 +1184,31 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) return OTA_RESULT_ERR_USER_ABORT; } - // 准备单包:读 Flash + 加密 + 组装 + // 停刷检测:自上一包成功 ACK 起超过 kStallTimeoutMs 则中止 + if ((xTaskGetTickCount() - last_success_tick) >= pdMS_TO_TICKS(kStallTimeoutMs)) { + DEBUG_ERR("OTA", "10 秒无成功帧, 中止转发: seq=%" PRIu32 "/%" PRIu32, seq, m_transferTotalPackets); + return OTA_RESULT_ERR_TIMEOUT; + } size_t chunk_size = 0; result = prepareRelayPacket(seq, meta, tx_buf, &chunk_size); if (result != OTA_RESULT_OK) { return result; } + /* 先写入「正在发第 N 包」,前端 /system/status 能看到卡在哪 */ + setRelaySeq(seq); + // 发送单包并等待 ACK(内含重试循环) if (sendRelayPacketWithRetry(seq, tx_buf, chunk_size, &result)) { + DEBUG_ERR("OTA", "中止于包 %" PRIu32 "/%" PRIu32 " result=%d", + seq, m_transferTotalPackets, (int)result); return result; // 中止 } setRelaySeq(seq + 1); - // 进度只写 relay_seq 供 /system/status;不打文字日志(前端进度条负责展示)。 - - // ★ 包间延迟:给 GD32 写 Flash + 让低优先级 httpd 能应答 /system/status。 - // ota_relay 优先级高于 wifi_ota:若不主动让出,前端只能看到 SIZE→完成,中间全跳过。 - if (seq < m_transferTotalPackets - 1) { - uint32_t gap_ms = CONFIG_DCU_OTA_PACKET_INTERVAL_MS; - if (gap_ms < 1U) { - gap_ms = 1U; - } - /* 每 16 包额外让出 15ms,保证 status/logs 轮询有机会跑 */ - if (((seq + 1U) % 16U) == 0U) { - gap_ms = (gap_ms > 15U) ? gap_ms : 15U; - } - vTaskDelay(pdMS_TO_TICKS(gap_ms)); - } + last_success_tick = xTaskGetTickCount(); } + DEBUG_INFO("OTA", "全部数据包已转发: %" PRIu32 " 包", m_transferTotalPackets); return result; } @@ -1301,7 +1262,7 @@ ota_result_t OtaRelay::doSendFinishOta() logRespPayload("FINISH_OTA", resp_type, resp_payload, resp_len); if (err != ESP_OK) { DEBUG_WARN("OTA", "FINISH_OTA 传输失败, err=%d, retry=%" PRIu32 "/10", err, retry + 1); - vTaskDelay(pdMS_TO_TICKS(100)); + vTaskDelay(pdMS_TO_TICKS(10)); continue; } @@ -1310,7 +1271,7 @@ ota_result_t OtaRelay::doSendFinishOta() 1000, 10, 8, OtaRelay::isFinishPlainAck); if (err != ESP_OK) { DEBUG_WARN("OTA", "FINISH_OTA poll 失败, err=%d, retry=%" PRIu32 "/10", err, retry + 1); - vTaskDelay(pdMS_TO_TICKS(100)); + vTaskDelay(pdMS_TO_TICKS(10)); continue; } @@ -1320,7 +1281,7 @@ ota_result_t OtaRelay::doSendFinishOta() if (resp_type == SPI_OTA_RSP_WAIT) { DEBUG_INFO("OTA", "FINISH_OTA 收到 WAIT, GD32 正在排空 Flash 缓冲, retry=%" PRIu32 "/10", retry + 1); - vTaskDelay(pdMS_TO_TICKS(200)); + vTaskDelay(pdMS_TO_TICKS(20)); continue; } @@ -1328,13 +1289,13 @@ ota_result_t OtaRelay::doSendFinishOta() uint8_t err_code = 0; if (parseStructuredError(resp_payload, resp_len, &err_seq, &err_code)) { DEBUG_ERR("OTA", "FINISH_OTA 收到错误帧 err=0x%02X, retry=%" PRIu32 "/10", err_code, retry + 1); - vTaskDelay(pdMS_TO_TICKS(100)); + vTaskDelay(pdMS_TO_TICKS(10)); continue; } DEBUG_WARN("OTA", "FINISH_OTA 响应异常, resp_type=0x%02X, resp_len=%zu, retry=%" PRIu32 "/10", resp_type, resp_len, retry + 1); - vTaskDelay(pdMS_TO_TICKS(100)); + vTaskDelay(pdMS_TO_TICKS(10)); } if (resp_type != SPI_OTA_RSP_ACK || !isFinishPlainAck(resp_payload, resp_len)) { @@ -1351,8 +1312,24 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms) // ★ APP 存活验证 setGd32AppAlive(false); + // GD32 刚跳转到新 APP,SPI v2 session_id 已重置为 0。 + // 必须先 hello() 建立新 session,否则 appPing 的帧被 otaFindFrame + // 以 SESSION mismatch 拒绝(旧 session_id vs 新 APP session_id=0)。 + bool session_established = false; + for (uint32_t attempt = 0; attempt < 10; ++attempt) { vTaskDelay(pdMS_TO_TICKS(500)); + + if (!session_established) { + esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP); + if (hr == ESP_OK) { + session_established = true; + } else { + DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/10)", attempt + 1); + continue; + } + } + if (probeAndCacheAppAlive(timeout_ms)) { break; } diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 529a5fe..9365ccf 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -31,6 +31,7 @@ #include "nimble/nimble_port_freertos.h" #include "host/ble_hs.h" #include "host/ble_gap.h" +#include "nimble/ble.h" /* BLE_ERR_REM_USER_CONN_TERM */ #include "services/gap/ble_svc_gap.h" #include "services/gatt/ble_svc_gatt.h" #include "host/ble_att.h" @@ -829,6 +830,10 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, (void)attr_handle; (void)arg; + if (BleOta::instance().isSuspendedForWifiOta()) { + return BLE_ATT_ERR_WRITE_NOT_PERMITTED; + } + if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { return BLE_ATT_ERR_REQ_NOT_SUPPORTED; } @@ -1093,6 +1098,10 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, (void)attr_handle; (void)arg; + if (BleOta::instance().isSuspendedForWifiOta()) { + return BLE_ATT_ERR_WRITE_NOT_PERMITTED; + } + if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { return BLE_ATT_ERR_REQ_NOT_SUPPORTED; } @@ -1490,9 +1499,10 @@ static void ble_ota_data_worker_task(void *pvParameters) continue; } - // 若 BLE 已断开,丢弃队列中的残留数据 - if (!BleOta::instance().isConnected()) { - DEBUG_DBG(TAG, "BLE 已断开,丢弃 %zu 字节队列数据", item.len); + // 若 BLE 已断开或 WiFi OTA 挂起,丢弃队列中的残留数据 + if (!BleOta::instance().isConnected() || + BleOta::instance().isSuspendedForWifiOta()) { + DEBUG_DBG(TAG, "BLE 不可用/挂起,丢弃 %zu 字节队列数据", item.len); free(item.data); continue; } @@ -1640,6 +1650,15 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) case BLE_GAP_EVENT_CONNECT: if (event->connect.status == 0) { + if (BleOta::instance().isSuspendedForWifiOta()) { + /* WiFi OTA 中拒绝 BLE 连接,立刻踢掉 */ + DEBUG_WARN(TAG, "WiFi OTA 中,拒绝 BLE 连接 handle=%d", + event->connect.conn_handle); + (void)ble_gap_terminate(event->connect.conn_handle, + BLE_ERR_REM_USER_CONN_TERM); + BleOta::instance().m_bleConnected = false; + return 0; + } // 连接成功 BleOta::instance().m_bleConnected = true; BleOta::instance().m_connHandle = event->connect.conn_handle; @@ -1673,6 +1692,13 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) BleOta::instance().m_bleConnected = false; BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE; s_conn_fallback_requested = false; + + /* WiFi OTA 软挂起期间:主动踢连触发的 disconnect 不得 abort WiFi OTA,也不得再开广播 */ + if (BleOta::instance().isSuspendedForWifiOta()) { + DEBUG_INFO(TAG, "WiFi OTA 挂起中:忽略 BLE 断开对 OTA 状态的影响"); + return 0; + } + // ★ BLE 断开时恢复 WiFi 射频 ble_ota_restore_wifi_ps(); @@ -1688,16 +1714,24 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) { DEBUG_WARN(TAG, "BLE 断开但 %s 进行中,保留状态等待自然结束", OtaCoordinator::stateToString(cur)); - } else { - if (cur == OTA_SYS_GD32_STORING) { - DEBUG_WARN(TAG, "BLE 断开,中止未完成的 GD32 固件存储"); - } else if (cur == OTA_SYS_ESP32_RECEIVING || - cur == OTA_SYS_ESP32_FINALIZING) { - DEBUG_WARN(TAG, "BLE 断开,中止未完成的 ESP32 OTA"); + } else if (cur == OTA_SYS_ESP32_RECEIVING || + cur == OTA_SYS_ESP32_FINALIZING || + cur == OTA_SYS_GD32_STORING) { + /* 仅当当前 OTA 通道是 BLE 时才因断连中止;WiFi 通道的 RECEIVING 不能被误杀 */ + ota_channel_t ch = OTA_CHANNEL_WIFI; + { + /* get channel via snapshot if available */ + OtaSnapshot snap = OtaCoordinator::instance().getSnapshot(); + ch = snap.channel; } - // ble_ota_session_reset() 统一负责 abortCurrent + core.abort 兜底 + - // 会话状态复位(m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending/ - // reorder/s_gd32_ooo_count) + if (ch == OTA_CHANNEL_BLE) { + DEBUG_WARN(TAG, "BLE 断开,中止 BLE 通道 OTA (state=%s)", + OtaCoordinator::stateToString(cur)); + ble_ota_session_reset(); + } else { + DEBUG_INFO(TAG, "BLE 断开但当前为 WiFi OTA,不中止会话"); + } + } else { ble_ota_session_reset(); } } @@ -1706,6 +1740,9 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) return 0; case BLE_GAP_EVENT_ADV_COMPLETE: + if (BleOta::instance().isSuspendedForWifiOta()) { + return 0; /* WiFi OTA 中不要自动再开广播 */ + } DEBUG_INFO(TAG, "广播周期结束,重新开始"); ble_ota_start_advertising(); return 0; @@ -1750,6 +1787,11 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) */ static void ble_ota_start_advertising(void) // kept as free function for BLE callback compatibility { + if (BleOta::instance().isSuspendedForWifiOta()) { + DEBUG_DBG(TAG, "WiFi OTA 挂起中,跳过广播"); + return; + } + int rc; // 自动推断可用地址类型(优先 Public,不可用则生成 Random) @@ -1886,6 +1928,11 @@ esp_err_t BleOta::serverStart() { int rc; + if (m_stackRunning) { + DEBUG_INFO(TAG, "serverStart: 栈已在运行,跳过"); + return ESP_OK; + } + DEBUG_INFO(TAG, "=== 初始化 BLE OTA GATT 服务器 ==="); DEBUG_INFO(TAG, " Device: %s", BLE_DEVICE_NAME); DEBUG_INFO(TAG, " MTU: %" PRIu16, BLE_MTU_SIZE); @@ -1935,10 +1982,80 @@ esp_err_t BleOta::serverStart() nimble_port_freertos_init(ble_ota_nimble_host_task); // s_data_out_val_handle 由 on_sync 回调填充,此处不复制 + BleOta::instance().m_stackRunning = true; + BleOta::instance().m_suspendedForWifiOta = false; DEBUG_INFO(TAG, "BLE OTA 服务器初始化完成"); return ESP_OK; } +esp_err_t BleOta::suspendStack() +{ + /* + * ★ 软挂起 only(ESP32-C3 WiFi/BLE 共用射频)。 + * 禁止在此调用 nimble_port_stop / deinit / controller_disable: + * - 会从 httpd 任务阻塞甚至打挂 WiFi,表现为「一点击上传就失联」; + * - C3 上 deinit BT 控制器与 WiFi 共存状态机冲突。 + * 软挂起:停广播、断连、拒收新数据,NimBLE host 仍跑、WiFi 不受影响。 + */ + if (m_suspendedForWifiOta) { + DEBUG_DBG(TAG, "suspendStack: 已挂起,跳过"); + return ESP_OK; + } + + + if (m_stackRunning) { + (void)ble_gap_adv_stop(); + if (m_bleConnected && m_connHandle != 0xFFFFU) { + (void)ble_gap_terminate(m_connHandle, BLE_ERR_REM_USER_CONN_TERM); + /* 短等即可,勿长时间阻塞 httpd */ + vTaskDelay(pdMS_TO_TICKS(30)); + } + } + + m_bleConnected = false; + m_connHandle = 0xFFFFU; + /* 保持 m_bleReady / m_stackRunning:栈仍在,仅业务拒绝连接与写 */ + m_suspendedForWifiOta = true; + + if (s_otaDataQueue) { + BleOtaDataBatch drop; + while (xQueueReceive(s_otaDataQueue, &drop, 0) == pdTRUE) { + free(drop.data); + } + } + + return ESP_OK; +} + +esp_err_t BleOta::resumeStack() +{ + if (!m_suspendedForWifiOta) { + return ESP_OK; + } + + m_suspendedForWifiOta = false; + + if (!m_stackRunning) { + /* 仅当历史上曾 hard-deinit 过才需要完整 serverStart */ + DEBUG_INFO(TAG, "WiFi OTA 结束:BLE 栈未运行,尝试 serverStart free=%lu", + (unsigned long)esp_get_free_heap_size()); + esp_err_t err = serverStart(); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "resumeStack/serverStart 失败: %s", esp_err_to_name(err)); + m_suspendedForWifiOta = true; + return err; + } + return ESP_OK; + } + + /* 软挂起恢复:重新开广播即可 */ + DEBUG_INFO(TAG, "WiFi OTA 结束:恢复 BLE 广播 free=%lu", + (unsigned long)esp_get_free_heap_size()); + m_bleReady = true; + ble_ota_start_advertising(); + return ESP_OK; +} + /** * @brief 通过 BLE Notify 将 GD32 响应推送给上位机。 * @param data 待推送的数据指针。 diff --git a/main/server/ble_ota.h b/main/server/ble_ota.h index 668c634..21f1373 100644 --- a/main/server/ble_ota.h +++ b/main/server/ble_ota.h @@ -31,6 +31,23 @@ public: * @return ESP_OK 成功,其他失败。 */ esp_err_t serverStart(); + + /** + * @brief WiFi OTA 期间软挂起 BLE(停广播、断连、拒收写;不 deinit 控制器)。 + * ESP32-C3 上 hard deinit 会打挂 WiFi 共存,导致上传立刻失联。 + * @return ESP_OK 已挂起或本就未运行。 + */ + esp_err_t suspendStack(); + + /** + * @brief WiFi OTA 结束后恢复 BLE 广播(栈仍在则只重新 adv)。 + * @return ESP_OK 成功或无需恢复。 + */ + esp_err_t resumeStack(); + + /** @brief 当前是否因 WiFi OTA 挂起 BLE。 */ + bool isSuspendedForWifiOta() const { return m_suspendedForWifiOta; } + /** * @brief 通过 BLE Notify 推送响应数据。 * @param data 待推送的数据指针。 @@ -60,6 +77,8 @@ public: uint16_t m_esp32LastSeq = 0xFFFFU; ///< 最后一次数据包序号(用于补发 ACK) TaskHandle_t m_taskHandle = nullptr; ///< BLE OTA 守护任务句柄 TaskHandle_t m_workerTaskHandle = nullptr; ///< BLE OTA 数据工作线程句柄 + bool m_stackRunning = false; ///< NimBLE 已 init 且 host 在跑 + bool m_suspendedForWifiOta = false; ///< 因 WiFi OTA 主动挂起 }; #endif // BLE_OTA_H diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index fabdcb2..3615743 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -8,6 +8,7 @@ #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "freertos/semphr.h" #include "esp_http_server.h" #include "esp_wifi.h" #include "esp_netif.h" @@ -121,7 +122,7 @@ esp_err_t esp32_ota_update_handler(httpd_req_t *req) httpd_resp_send(req, resp, strlen(resp)); DEBUG_INFO("OTA", "响应已发送,等待 TCP 刷新..."); - vTaskDelay(pdMS_TO_TICKS(500)); + vTaskDelay(pdMS_TO_TICKS(100)); OtaCoordinator::instance().restartEsp32(); return ESP_OK; } @@ -172,21 +173,16 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size) return total; } +/* OTA 上传静态收块:避免 OTA 中途再 malloc(2KB) 与 TCP 窗口抢堆。 */ +static uint8_t s_ota_upload_recv[OTA_BLOCK_SIZE]; +static uint8_t s_ota_json_scratch[256]; +static uint8_t s_ota_preread[256]; + esp_err_t ota_upload_handler(httpd_req_t *req) { http_set_cors_headers(req); - // per-request 接收块:2KB,避免大块 malloc 失败 / 碎片 - uint8_t *recv_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); - if (!recv_buf) { - DEBUG_ERR("WIFI", "/ota/upload malloc(%u) 失败 free=%lu largest=%lu", - (unsigned)OTA_BLOCK_SIZE, - (unsigned long)esp_get_free_heap_size(), - (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "alloc failed"); - return ESP_FAIL; - } - + uint8_t *recv_buf = s_ota_upload_recv; DEBUG_WARN("WIFI", "=== HTTP: POST /ota/upload === content_len=%d free=%lu largest=%lu", req->content_len, (unsigned long)esp_get_free_heap_size(), @@ -201,35 +197,21 @@ esp_err_t ota_upload_handler(httpd_req_t *req) int peek_ret = httpd_req_recv(req, &peek, 1); if (peek_ret <= 0) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "接收失败"); - free(recv_buf); return ESP_FAIL; } bool is_esp32_ota = false; uint32_t total_size = 0; int json_header_len = 0; - // ★ 改为堆分配:pre_read_buf[256]+json_buf[256] 同时在栈上占 512B, - // 上传 100% 后 GD32 路径调用 computeFlashSha256(栈上 mbedtls_sha256_context - // ~108B)+ 日志格式化 608B,叠加逼近 8192 栈极限 → 栈溢出卡死。 - // 移到堆上释放栈压力。json_buf 在解析完成后立即释放。 - uint8_t *pre_read_buf = (uint8_t *)calloc(256, 1); - if (!pre_read_buf) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "alloc failed"); - free(recv_buf); - free(pre_read_buf); - return ESP_FAIL; - } + /* 静态 scratch:OTA 期间少两次 256B heap 分配 */ + uint8_t *pre_read_buf = s_ota_preread; + memset(pre_read_buf, 0, 256); int pre_read_len = 1; pre_read_buf[0] = (uint8_t)peek; if (peek == '{') { - char *json_buf = (char *)calloc(256, 1); - if (!json_buf) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "alloc failed"); - free(recv_buf); - free(pre_read_buf); - return ESP_FAIL; - } + char *json_buf = (char *)s_ota_json_scratch; + memset(json_buf, 0, 256); json_buf[0] = peek; json_header_len = recv_json_header(req, json_buf + 1, 255); if (json_header_len > 0) { @@ -245,7 +227,6 @@ esp_err_t ota_upload_handler(httpd_req_t *req) if (type_item && cJSON_IsString(type_item) && strcmp(cJSON_GetStringValue(type_item), "esp32") == 0) { is_esp32_ota = true; - DEBUG_INFO("WIFI", "检测到 ESP32 OTA, JSON 头长度: %d 字节", json_header_len); } cJSON *size_item = cJSON_GetObjectItem(root, "total_size"); if (size_item) { @@ -254,17 +235,17 @@ esp_err_t ota_upload_handler(httpd_req_t *req) cJSON_Delete(root); } } - free(json_buf); // JSON 解析完成,立即释放 } if (is_esp32_ota) { if (total_size == 0) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing total_size"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } + /* ESP32 路径不再需要 pre_read(JSON 已消费,固件从流继续读) */ + pre_read_len = 0; + esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_WIFI); if (err != ESP_OK) { if (err == ESP_ERR_INVALID_STATE) { @@ -272,8 +253,6 @@ esp_err_t ota_upload_handler(httpd_req_t *req) } else { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); } - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } @@ -286,76 +265,90 @@ esp_err_t ota_upload_handler(httpd_req_t *req) size_t total_firmware_written = 0; size_t total_received = json_header_len; - size_t last_log_recv = 0; - DEBUG_INFO("WIFI", "ESP32 OTA 开始收流: content_len=%d json_hdr=%d", - req->content_len, json_header_len); + DEBUG_INFO("WIFI", "ESP32 OTA 开始收流: content_len=%d json_hdr=%d fw_expect=%d", + req->content_len, json_header_len, + (req->content_len > json_header_len) + ? (req->content_len - json_header_len) : 0); - while (total_received < req->content_len) { - size_t to_read = req->content_len - total_received; - if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + /* 浏览器 upload 进度到 100% 只表示 TCP 发完;设备仍在 recv+Flash。 + * Flash 扇区擦除期间 cache 暂停,必须让 WiFi/LWIP 有机会跑,否则 TCP 卡死。 + * C3 上 WiFi+BLE 双栈堆极紧:free 过低时 pbuf 申请失败 → 半路卡住/超时。 + * 因此 pacing 按 free heap 自适应,而不是固定每 N 块 delay。 */ + const int64_t t_recv0 = esp_timer_get_time(); + uint32_t write_chunks = 0U; + + while (total_received < (size_t)req->content_len) { + size_t to_read = (size_t)req->content_len - total_received; + if (to_read > OTA_BLOCK_SIZE) { + to_read = OTA_BLOCK_SIZE; + } + + /* 堆过紧时先让 LWIP 回收 TCP 窗口,再 recv,避免 pbuf 分配失败 */ + { + const uint32_t free_pre = esp_get_free_heap_size(); + if (free_pre < 8192U) { + (void)esp_task_wdt_reset(); + vTaskDelay(pdMS_TO_TICKS(free_pre < 4096U ? 5U : 2U)); + } + } int ret = httpd_req_recv(req, (char *)fw_buf, to_read); if (ret <= 0) { - DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA recv=%u/%d", - ret, (unsigned)total_received, req->content_len); + DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA recv=%u/%d free=%lu", + ret, (unsigned)total_received, req->content_len, + (unsigned long)esp_get_free_heap_size()); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } err = core.write(fw_buf, (size_t)ret); if (err != ESP_OK) { - DEBUG_ERR("WIFI", "OTA write failed: %s @%u", - esp_err_to_name(err), (unsigned)total_firmware_written); + DEBUG_ERR("WIFI", "OTA write failed: %s @%u free=%lu", + esp_err_to_name(err), (unsigned)total_firmware_written, + (unsigned long)esp_get_free_heap_size()); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } total_firmware_written += (size_t)ret; total_received += (size_t)ret; + ++write_chunks; - /* 长写 Flash:喂狗 + 进度日志,避免“浏览器 100% 而设备仍在写”时假死无日志 */ (void)esp_task_wdt_reset(); - if (total_received - last_log_recv >= (128U * 1024U) || - total_received >= (size_t)req->content_len) { - last_log_recv = total_received; - DEBUG_INFO("WIFI", "ESP32 OTA 收流进度: %u / %d (written=%u)", - (unsigned)total_received, req->content_len, - (unsigned)total_firmware_written); - } + } - DEBUG_INFO("WIFI", "ESP32 OTA 存储完成: %u 字节 (跳过 JSON 头 %d 字节)", - (unsigned int)core.getReceived(), json_header_len); - DEBUG_INFO("WIFI", "堆(写完): free=%lu largest=%lu", - (unsigned long)esp_get_free_heap_size(), - (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); - - /* ★ 先释放上传缓冲再回包:堆碎时 largest~1KB,先 free 4KB/2KB 再 send, - * 避免 LWIP/httpd 发响应时 malloc pbuf 失败导致客户端卡在 100%。 */ - free(recv_buf); - recv_buf = nullptr; - free(pre_read_buf); - pre_read_buf = nullptr; + /* 回包前不打 INFO:堆紧时 DEBUG_INFO→UDP 会与 httpd_resp_send 抢 TX pbuf。 + * 先让 LWIP 回收,成功后再记一条。 */ vTaskDelay(pdMS_TO_TICKS(10)); (void)esp_task_wdt_reset(); + taskYIELD(); - const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", \"target\": \"esp32\"}"; + const char resp[] = + "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", " + "\"target\": \"esp32\"}"; httpd_resp_set_type(req, "application/json"); - esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp)); + httpd_resp_set_hdr(req, "Connection", "close"); + esp_err_t send_err = httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); if (send_err != ESP_OK) { DEBUG_ERR("WIFI", "ESP32 OTA 响应发送失败: %s (free=%lu largest=%lu)", esp_err_to_name(send_err), (unsigned long)esp_get_free_heap_size(), (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); + /* 再试一次极简回包 */ + send_err = httpd_resp_send(req, "{\"status\":\"ok\",\"target\":\"esp32\"}", + HTTPD_RESP_USE_STRLEN); + if (send_err != ESP_OK) { + DEBUG_ERR("WIFI", "ESP32 OTA 简短响应仍失败: %s", esp_err_to_name(send_err)); + } } else { - DEBUG_INFO("WIFI", "ESP32 OTA 上传响应已发送 (free=%lu)", + DEBUG_INFO("WIFI", + "ESP32 OTA 存储+响应完成: %u B elapsed=%lld ms free=%lu", + (unsigned int)core.getReceived(), + (long long)((esp_timer_get_time() - t_recv0) / 1000), (unsigned long)esp_get_free_heap_size()); } return (send_err == ESP_OK) ? ESP_OK : ESP_FAIL; @@ -379,22 +372,16 @@ esp_err_t ota_upload_handler(httpd_req_t *req) DEBUG_ERR("WIFI", "firmware size invalid: valid=%u max=%u", (unsigned)gd32_valid_size, (unsigned)OTA_MAX_FIRMWARE_SIZE); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "firmware too large or alignment overflow"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } else if (prep_err == ESP_ERR_INVALID_STATE) { DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)"); httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "system busy with another OTA operation"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } else if (prep_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); char err_msg[96]; snprintf(err_msg, sizeof(err_msg), "prepare gd32_ota failed: %s", esp_err_to_name(prep_err)); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } @@ -411,7 +398,6 @@ esp_err_t ota_upload_handler(httpd_req_t *req) DEBUG_INFO("WIFI", "GD32 raw upload start: %u bytes", expected_fw_size_raw); - size_t last_log_recv = (size_t)received; if (json_header_len == 0 && pre_read_len > 0) { int first_chunk_len = pre_read_len; @@ -425,8 +411,6 @@ esp_err_t ota_upload_handler(httpd_req_t *req) // ★ 首块写入失败:用 abortCurrentIfEpoch 避免误中止新会话 OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } received = first_chunk_len; @@ -448,8 +432,6 @@ esp_err_t ota_upload_handler(httpd_req_t *req) // ★ 客户端断开(刷新网页/关浏览器/网络掉):用 abortCurrentIfEpoch 避免误中止新会话 OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } @@ -460,8 +442,6 @@ esp_err_t ota_upload_handler(httpd_req_t *req) // ★ 写入失败:同上,用 abortCurrentIfEpoch OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); - free(recv_buf); - free(pre_read_buf); return ESP_FAIL; } @@ -469,46 +449,28 @@ esp_err_t ota_upload_handler(httpd_req_t *req) gd32_offset += (uint32_t)ret; (void)esp_task_wdt_reset(); - if ((size_t)received - last_log_recv >= (64U * 1024U) || - (size_t)received >= (size_t)req->content_len) { - last_log_recv = (size_t)received; - DEBUG_INFO("WIFI", "GD32 OTA 收流进度: %d / %d (written=%u)", - received, req->content_len, (unsigned)gd32_offset); - } } OtaCoordinator::instance().updateGd32Meta(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); - DEBUG_INFO("WIFI", "GD32 upload done: %u / %u bytes", (unsigned)gd32_offset, expected_fw_size_raw); - ota_meta_t final_meta; OtaCoordinator::instance().loadGd32Meta(&final_meta); if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) { - /* 先释放上传缓冲再算 SHA,避免与 2KB recv 并存 */ - free(recv_buf); - recv_buf = nullptr; - free(pre_read_buf); - pre_read_buf = nullptr; + /* 先让出再算 SHA;回包前不打 INFO,避免与 httpd_resp_send 抢堆 */ vTaskDelay(pdMS_TO_TICKS(10)); (void)esp_task_wdt_reset(); - DEBUG_INFO("WIFI", "GD32 firmware fully stored, pre-computing SHA-256 (free=%lu)...", - (unsigned long)esp_get_free_heap_size()); // 与 BLE 共用 finishGd32StorageAfterReceive:complete + SHA esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); if (sha_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); } - } else { - free(recv_buf); - recv_buf = nullptr; - free(pre_read_buf); - pre_read_buf = nullptr; } const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\", " "\"message\": \"stored, call POST /ota/relay with sha256 to trigger\"}"; httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Connection", "close"); esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp)); if (send_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 OTA 响应发送失败: %s (free=%lu largest=%lu)", @@ -516,7 +478,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req) (unsigned long)esp_get_free_heap_size(), (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); } else { - DEBUG_INFO("WIFI", "GD32 OTA 上传响应已发送 (free=%lu)", + DEBUG_INFO("WIFI", "GD32 OTA 存储+响应完成: %u / %u free=%lu", + (unsigned)gd32_offset, expected_fw_size_raw, (unsigned long)esp_get_free_heap_size()); } return (send_err == ESP_OK) ? ESP_OK : ESP_FAIL; @@ -886,7 +849,7 @@ esp_err_t spi_send_handler(httpd_req_t *req) uint8_t resp_type = 0; uint8_t resp_payload[SPI_OTA_FRAME_MAX] = {0}; size_t resp_len = 0; - esp_err_t spi_err = SpiMaster::instance().otaTransfer(frame_type, payload_data, payload_len, + esp_err_t spi_err = SpiMaster::instance().transact(frame_type, payload_data, payload_len, &resp_type, resp_payload, sizeof(resp_payload), &resp_len, timeout_ms); @@ -913,8 +876,24 @@ esp_err_t spi_send_handler(httpd_req_t *req) } static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; -static constexpr size_t SYSTEM_STATUS_JSON_CAP = 8192; -static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 24; +/* 状态 JSON 用静态缓冲:OTA 后 largest free 常 <2KB,calloc(8KB) 会 500 "no memory"。 */ +static constexpr size_t SYSTEM_STATUS_JSON_CAP = 6144; +static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 16; +static char s_status_json[SYSTEM_STATUS_JSON_CAP]; +static SemaphoreHandle_t s_status_json_mux = nullptr; +static portMUX_TYPE s_status_json_init_mux = portMUX_INITIALIZER_UNLOCKED; + +static SemaphoreHandle_t statusJsonMutex(void) +{ + if (s_status_json_mux == nullptr) { + portENTER_CRITICAL(&s_status_json_init_mux); + if (s_status_json_mux == nullptr) { + s_status_json_mux = xSemaphoreCreateMutex(); + } + portEXIT_CRITICAL(&s_status_json_init_mux); + } + return s_status_json_mux; +} struct CpuTaskSample { UBaseType_t task_number; @@ -1124,6 +1103,39 @@ esp_err_t system_status_handler(httpd_req_t *req) { http_set_cors_headers(req); + /* 堆极紧时仍返回精简 JSON,禁止 500(前端轮询会刷红)。 */ + const size_t largest_now = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); + if (largest_now < 512U) { + char tiny[192]; + int n = snprintf(tiny, sizeof(tiny), + "{\"version\":\"%s\",\"free_heap\":%lu,\"largest_free_block\":%lu," + "\"wifi\":{\"connected\":%s},\"ota\":{\"sys_state\":\"degraded\"}," + "\"cpu\":{\"available\":false,\"valid\":false}}", + FW_VERSION, + (unsigned long)esp_get_free_heap_size(), + (unsigned long)largest_now, + (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && + WifiOta::instance().getStaConnected()) ? "true" : "false"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, tiny, n > 0 ? n : 0); + return ESP_OK; + } + + SemaphoreHandle_t mux = statusJsonMutex(); + if (mux) { + if (xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { + /* 并发 status 冲突:仍回简包,避免 200 */ + char busy[160]; + snprintf(busy, sizeof(busy), + "{\"version\":\"%s\",\"ota\":{\"sys_state\":\"busy\"}," + "\"cpu\":{\"available\":false,\"valid\":false}}", + FW_VERSION); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, busy, HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + } + char ip_str[16] = "0.0.0.0"; int8_t rssi = -127; bool connected = false; @@ -1162,20 +1174,25 @@ esp_err_t system_status_handler(httpd_req_t *req) task_metrics[i].handle = xTaskGetCurrentTaskHandle(); } } - CpuUsageSnapshot* cpu = (CpuUsageSnapshot*)calloc(1, sizeof(CpuUsageSnapshot)); - if (!cpu) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); - return ESP_FAIL; - } - sample_cpu_usage(*cpu); - char* resp = (char*)calloc(1, SYSTEM_STATUS_JSON_CAP); - if (!resp) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); - free(cpu); - return ESP_FAIL; + /* CPU 采样在堆上;失败则跳过(available=false),不再 500 */ + CpuUsageSnapshot cpu_local = {}; + CpuUsageSnapshot *cpu = &cpu_local; + CpuUsageSnapshot *cpu_heap = nullptr; + if (largest_free_block >= (sizeof(CpuUsageSnapshot) + 1024U)) { + cpu_heap = (CpuUsageSnapshot *)calloc(1, sizeof(CpuUsageSnapshot)); + if (cpu_heap) { + cpu = cpu_heap; + sample_cpu_usage(*cpu); + } + } else { + cpu_local.available = false; + cpu_local.valid = false; } + char *resp = s_status_json; + memset(resp, 0, SYSTEM_STATUS_JSON_CAP); + int len = snprintf(resp, SYSTEM_STATUS_JSON_CAP, "{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, " "\"free_heap\": %lu, \"min_free_heap\": %lu, \"largest_free_block\": %lu, " @@ -1219,7 +1236,8 @@ esp_err_t system_status_handler(httpd_req_t *req) len = append_cpu_status_json(resp, SYSTEM_STATUS_JSON_CAP, len, *cpu); - /* SPI Link v2 统计(SPEC §12.1) */ + /* SPI Link v2 统计(SPEC §12.1)— linkStats 用 5ms 有界超时取 SPI 锁, + * OTA 中继期间拿不到锁时返回零值统计,绝不阻塞 /system/status 响应。 */ { SpiMaster::SpiLinkStats ls = SpiMaster::instance().linkStats(); const char *role_str = "unknown"; @@ -1263,9 +1281,13 @@ esp_err_t system_status_handler(httpd_req_t *req) len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, "}}"); httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, len); - free(resp); - free(cpu); + httpd_resp_send(req, resp, len > 0 ? len : 0); + if (cpu_heap) { + free(cpu_heap); + } + if (mux) { + xSemaphoreGive(mux); + } return ESP_OK; } diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 05fb3f1..95fa8de 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -424,8 +424,10 @@ esp_err_t WifiOta::httpServerStart() /* 上传中途若再开 status 连接,LRU 会踢掉 /ota/upload → 浏览器卡在 100%。 * 关闭 LRU 踢连接:宁可新连接失败,也不拆正在写 Flash 的长传。 */ config.lru_purge_enable = false; - config.recv_wait_timeout = 60; // 1MB OTA 写 Flash 可能超过 30s 空闲读 - config.send_wait_timeout = 30; + /* 1MB sequential 擦写时,httpd 在 write 阶段可能 >60s 不调 recv; + * 超时后 handler 内会重试,但过短会放大“卡 100%”观感。 */ + config.recv_wait_timeout = 120; + config.send_wait_timeout = 60; DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ==="); DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port); diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 3902cbd..9e0d84b 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -32,7 +32,7 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, size_t resp_len = 0U; /* 两步法:otaTransfer 发命令(丢弃延迟一拍的过期响应)+ otaPollResponse 读真实响应。 * CAN 帧非幂等,命令只发一次,不重发。 */ - esp_err_t ret = SpiMaster::instance().otaSendAndPoll( + esp_err_t ret = SpiMaster::instance().transact( kSpiCanFrameType, tx_buf, tx_len, &resp_type, recv_buf, recv_max, &resp_len, timeout_ms); @@ -175,7 +175,7 @@ int canFdSetRxMode(can_rx_mode_t mode, /* 两步法破解延迟一拍:发命令一次 + poll 真实响应。 * 0xC2 幂等,但两步法避免无效重试。 */ - esp_err_t ret = SpiMaster::instance().otaSendAndPoll( + esp_err_t ret = SpiMaster::instance().transact( kSpiCanSetModeType, &req, 1U, &resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms); diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index d5f07b9..499fa45 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -11,6 +11,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" +#include "freertos/queue.h" #include "driver/spi_master.h" #include "driver/gpio.h" #include "soc/gpio_reg.h" @@ -29,17 +30,18 @@ static constexpr size_t kSpiTransferMaxBytes = 256U; /** - * @brief 计算本笔 CS 实际时钟字节数。 - * 不再固定打满 256B:GD32 BOOT 旧 RBNE 路径会 RX-OVR;即使 RX-DMA 后也浪费带宽。 - * 取 max(帧长, 最小响应窗口),上限 SPI_OTA_FRAME_MAX。 + * @brief 发送路径 CS 时钟字节数。 + * Boot TX DMA 按满缓冲预装(kTxBufSize=128),主机过短时钟会: + * - 截断延迟一拍的响应; + * - 两段变长读若中途释放 CS,Boot 会把 s_txPending 当已交付清掉 → 后续全 0xFF。 + * 下限 64:覆盖 HELLO_ACK(~26)/短 ACK;上限 SPI_OTA_FRAME_MAX。 + * App 大帧仍可由 poll 路径按 128/256 读。 */ static size_t spiXferBytes(size_t frame_len) { - /* HELLO_ACK ~26B;OTA data ~84B。与 BOOT kTxBufSize=128 对齐,默认 128。 - * 过短会截断响应;过长(256)曾导致从机 RX-OVR。 */ size_t n = frame_len; - if (n < 128U) { - n = 128U; + if (n < 64U) { + n = 64U; } if (n > SPI_OTA_FRAME_MAX) { n = SPI_OTA_FRAME_MAX; @@ -47,6 +49,12 @@ static size_t spiXferBytes(size_t frame_len) return n; } +/** @brief 读响应时单 CS 时钟预算(Boot 满缓冲 128;App 可达 256)。 */ +static size_t spiRespReadBudget(void) +{ + return 128U; +} + /** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。 * 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。 */ @@ -55,6 +63,10 @@ static int IRAM_ATTR readReadyLevelRaw(void) return (REG_READ(GPIO_IN_REG) >> SPI_READY_GPIO) & 0x1; } +/* 前向声明:waitSpiDataReady 定义在文件下方(waitSpiReady 之后), + * 但 serviceRingOnce / otaPollResponseUnlocked 等更早出现的方法需要它。 */ +static esp_err_t waitSpiDataReady(uint32_t timeout_ms); + // ============================================================ // 单例 // ============================================================ @@ -68,6 +80,267 @@ SpiMaster& SpiMaster::instance() return inst; } +// ============================================================ +// L4 SPI Worker:唯一持 SPI 的任务;ISR 只通知本任务 +// ============================================================ + +bool SpiMaster::onWorker() const +{ + return m_workerTask != nullptr && xTaskGetCurrentTaskHandle() == m_workerTask; +} + +bool SpiMaster::pushEvent(uint8_t type, const uint8_t *payload, size_t len) +{ + if (m_eventCount >= kEventSlots) { + /* 队列满:丢最旧一帧,保证新事件可进 */ + m_eventTail = (uint8_t)((m_eventTail + 1U) % kEventSlots); + m_eventCount--; + if (m_eventSem) { + (void)xSemaphoreTake(m_eventSem, 0); + } + } + EventSlot &slot = m_eventQ[m_eventHead]; + slot.type = type; + slot.len = (uint16_t)((len > sizeof(slot.data)) ? sizeof(slot.data) : len); + if (payload && slot.len > 0U) { + memcpy(slot.data, payload, slot.len); + } + m_eventHead = (uint8_t)((m_eventHead + 1U) % kEventSlots); + m_eventCount++; + /* 兼容单槽 takeRingRx */ + m_ringRxType = type; + m_ringRxLen = slot.len; + if (slot.len > 0U) { + memcpy(m_ringRxBuf, slot.data, slot.len); + } + if (m_eventSem) { + (void)xSemaphoreGive(m_eventSem); + } + return true; +} + +bool SpiMaster::popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len) +{ + if (m_eventCount == 0U) { + return false; + } + EventSlot &slot = m_eventQ[m_eventTail]; + if (type) { + *type = slot.type; + } + size_t copy_len = slot.len; + if (copy_len > max_len) { + copy_len = max_len; + } + if (len) { + *len = copy_len; + } + if (payload && copy_len > 0U) { + memcpy(payload, slot.data, copy_len); + } + m_eventTail = (uint8_t)((m_eventTail + 1U) % kEventSlots); + m_eventCount--; + return true; +} + +void SpiMaster::setSuppressRingService(bool suppress) +{ + m_suppressRingService = suppress; + if (suppress) { + /* 丢弃已锁存的按铃标志,避免解除后立刻误读残留边沿 */ + m_gd32RequestPending = false; + } +} + +void SpiMaster::confirmTxSeqAccepted() +{ + /* 业务 ACK 已确认:保证下一包使用 lastSentSeq+1,即使头 ack_seq 曾失配 */ + m_txSeq = (uint8_t)(m_lastSentSeq + 1U); + m_rxAckedSeq = m_lastSentSeq; + m_lastRxAckMatched = true; +} + +esp_err_t SpiMaster::serviceRingOnce() +{ + /* OTA 中继期间禁止按铃读:OTA ACK/WAIT 也拉低 PB6,偷读会导致 poll 空等。 */ + if (m_suppressRingService) { + m_gd32RequestPending = false; + return ESP_ERR_NOT_FOUND; + } + + /* 仅 worker 调用。有 pending 或 DATA_READY 低时读一帧 EVENT。 */ + if (!m_gd32RequestPending) { + /* 无 ISR 标志时,若线已低仍尝试读(丢边沿兜底) */ + if (readReadyLevelRaw() != 0) { + return ESP_ERR_NOT_FOUND; + } + } + m_gd32RequestPending = false; + m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING); + + if (waitSpiDataReady(5U) != ESP_OK) { + m_linkState.exitBusy(); + return ESP_ERR_NOT_FOUND; + } + + driveReadyLow(); + if (SPI_READY_DRIVE_DELAY_US > 0U) { + esp_rom_delay_us(SPI_READY_DRIVE_DELAY_US); + } + + uint8_t rx_raw[SPI_OTA_FRAME_MAX]; + size_t rx_len = 0U; + esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, 500U); + releaseReady(); + m_linkState.exitBusy(); + + if (ret != ESP_OK) { + return ret; + } + + uint8_t found_type = 0; + const uint8_t *found_payload = nullptr; + size_t found_len = 0; + size_t frame_offset = 0; + if (!otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, + &found_len, &frame_offset)) { + return ESP_ERR_INVALID_RESPONSE; + } + if (found_type == V2_TYPE_CREDIT_UPDATE) { + DEBUG_DBG("SPI", "worker CREDIT_UPDATE credit=%u", (unsigned)m_remoteCredit); + return ESP_ERR_NOT_FOUND; + } + /* RESPONSE(OTA ACK/WAIT/ERROR)绝不能当 EVENT 入队;否则 OTA poll 丢响应。 + * 此处帧已被时钟读走,只能记统计;真正防护靠 m_suppressRingService。 */ + if (found_type == SPI_OTA_RSP_ACK || found_type == SPI_OTA_RSP_WAIT || + found_type == SPI_OTA_RSP_ERROR || found_type == V2_TYPE_RSP_ACK || + found_type == V2_TYPE_RSP_WAIT || found_type == V2_TYPE_RSP_ERROR) { + static uint32_t s_steal_logs = 0U; + if ((++s_steal_logs % 16U) == 1U) { + DEBUG_WARN("SPI", "serviceRing 读到 RESPONSE type=0x%02X(应禁止,log#%lu)", + found_type, (unsigned long)s_steal_logs); + } + return ESP_ERR_INVALID_RESPONSE; + } + { + LockGuard lock(m_mutex); + (void)pushEvent(found_type, found_payload, found_len); + } + return ESP_OK; +} + +void SpiMaster::executeJob(Job &job) +{ + /* 已在 worker 上下文;下列 public API 见 onWorker() 后走实现体,不再二次入队。 */ + switch (job.kind) { + case JobKind::Transact: + job.result = otaSendAndPoll(job.type, + job.payload_len ? job.payload : nullptr, + job.payload_len, + job.out_type, job.out_payload, job.out_max, + job.out_len, job.timeout_ms); + break; + case JobKind::Poll: + job.result = otaPollResponse(job.out_type, job.out_payload, job.out_max, + job.out_len, job.timeout_ms); + break; + case JobKind::Hello: + job.result = hello(job.timeout_ms, job.role_hint); + break; + case JobKind::EnterBoot: + job.result = appRequestBootloader(); + break; + case JobKind::Ping: + job.result = appPing(job.timeout_ms, job.out_uptime, job.out_version); + break; + case JobKind::WaitBoot: + job.result = waitForBootloaderReady(job.timeout_ms); + break; + case JobKind::ServiceRing: + job.result = serviceRingOnce(); + break; + default: + job.result = ESP_ERR_INVALID_ARG; + break; + } +} + +esp_err_t SpiMaster::submitJob(Job &job) +{ + if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) { + executeJob(job); + return job.result; + } + + StaticSemaphore_t done_buf; + job.done = xSemaphoreCreateBinaryStatic(&done_buf); + if (job.done == nullptr) { + executeJob(job); + return job.result; + } + (void)xSemaphoreTake(job.done, 0); + + Job *ptr = &job; + const TickType_t qwait = pdMS_TO_TICKS( + (job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 2000U); + if (xQueueSend(m_jobQueue, &ptr, qwait) != pdTRUE) { + DEBUG_WARN("SPI", "worker job queue full/timeout kind=%u", (unsigned)job.kind); + return ESP_ERR_TIMEOUT; + } + /* 唤醒可能阻塞在 notify wait 的 worker */ + xTaskNotify(m_workerTask, 0x10U, eSetBits); + + const TickType_t dwait = pdMS_TO_TICKS( + (job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 10000U); + if (xSemaphoreTake(job.done, dwait) != pdTRUE) { + DEBUG_ERR("SPI", "worker job done timeout kind=%u", (unsigned)job.kind); + return ESP_ERR_TIMEOUT; + } + return job.result; +} + +void SpiMaster::workerEntry(void *arg) +{ + static_cast(arg)->workerLoop(); +} + +void SpiMaster::workerLoop() +{ + m_workerTask = xTaskGetCurrentTaskHandle(); + m_spiTaskHandle = m_workerTask; + DEBUG_INFO("SPI", "SPI worker started (L4), handle=%p", (void *)m_workerTask); + + for (;;) { + /* ★ 只在 ISR 置位 m_gd32RequestPending 时读 EVENT。 + * 禁止见 DATA_READY 低就 serviceRing:命令 HELLO_ACK/OTA 响应也会拉低 + * PB6,会被偷走当事件 → 业务 poll 全 0xFF,OTA/ENTER_BOOT 全挂。 + * OTA 中继期间 m_suppressRingService 屏蔽按铃读。 */ + if (m_gd32RequestPending && !m_suppressRingService) { + (void)serviceRingOnce(); + } else if (m_suppressRingService) { + m_gd32RequestPending = false; + } + + Job *job = nullptr; + if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(20)) == pdTRUE && job != nullptr) { + executeJob(*job); + if (job->done) { + xSemaphoreGive(job->done); + } + continue; + } + + /* 无 job:等 GPIO / 提交唤醒 */ + uint32_t bits = 0U; + (void)xTaskNotifyWait(0U, 0xFFFFFFFFU, &bits, pdMS_TO_TICKS(50)); + if (m_suppressRingService) { + m_gd32RequestPending = false; + } else if ((bits & 0x01U) || m_gd32RequestPending) { + (void)serviceRingOnce(); + } + } +} + /** * @brief 下拉 SPIready(v2 DATA_READY 模式下 ESP32 不再操作 PB6)。 * 保留函数体兼容调用方,实际为空操作。 @@ -102,7 +375,9 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) int level = readReadyLevelRaw(); if (level == 0) { /* 下降沿 */ - if (self->m_linkState.acceptRing()) { + if (self->m_suppressRingService) { + /* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */ + } else if (self->m_linkState.acceptRing()) { /* GD32 按铃:仅 IDLE 态接受 */ self->m_gd32RequestPending = true; if (self->m_spiTaskHandle) { @@ -158,13 +433,14 @@ esp_err_t SpiMaster::waitSpiReady(uint32_t timeout_ms) if (readReadyLevelRaw() == 1) { return ESP_OK; } /* PB6 低:可能有未读响应。短等升高;BOOT 修好交付清 pending 后应很快。 - * 超时仍 OK,避免卡死(随后 CS 本身可互斥推进)。 */ + * L3:超时返回 ESP_ERR_TIMEOUT,禁止调用方假装“已就绪”后盲发。 */ const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); while (xTaskGetTickCount() < deadline) { if (readReadyLevelRaw() == 1) { return ESP_OK; } vTaskDelay(pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1); } - return ESP_OK; + m_stats.ready_timeouts++; + return ESP_ERR_TIMEOUT; } /** @@ -200,6 +476,13 @@ static esp_err_t waitSpiDataReady(uint32_t timeout_ms) esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, uint8_t *rx_buf, size_t rx_len, uint32_t timeout_ms) +{ + return transferClockedFlags(tx_buf, tx_len, rx_buf, rx_len, timeout_ms, 0U); +} + +esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len, + uint8_t *rx_buf, size_t rx_len, + uint32_t timeout_ms, uint32_t trans_flags) { if (m_spiDevice == NULL) { return ESP_ERR_INVALID_STATE; @@ -237,13 +520,18 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, } spi_transaction_t t = {}; + t.flags = trans_flags; t.length = total_len * 8U; t.rxlength = (rx_len > 0U) ? (total_len * 8U) : 0U; t.tx_buffer = tx_ptr; t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL; - /* timeout handled via spi_device_get_trans_result below */ - esp_err_t ret = spi_device_queue_trans(m_spiDevice, &t, portMAX_DELAY) /* always wait for queue slot */; + /* 禁止 portMAX_DELAY:SPI 硬件/从机异常时 worker 会永久卡死, + * 表现即 OTA「转发进度 1/1117 后完全沉默」。所有等待必须有上界。 */ + const uint32_t effective_ms = (timeout_ms == 0U) ? 500U + : (timeout_ms < 50U ? 50U : timeout_ms); + const TickType_t queue_wait = pdMS_TO_TICKS(effective_ms); + esp_err_t ret = spi_device_queue_trans(m_spiDevice, &t, queue_wait); if (ret != ESP_OK) { DEBUG_WARN("SPI", "spi_device_queue_trans fail: %s len=%u", esp_err_to_name(ret), (unsigned)(total_len * 8U)); @@ -251,17 +539,31 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, } spi_transaction_t *ret_trans = NULL; - ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, pdMS_TO_TICKS(500)) /* DMA completion timeout */; + /* 结果等待:调用方超时 + 余量,硬上限 2s,绝不用无限等 */ + uint32_t result_ms = effective_ms + 200U; + if (result_ms < 100U) { + result_ms = 100U; + } + if (result_ms > 2000U) { + result_ms = 2000U; + } + const TickType_t result_wait = pdMS_TO_TICKS(result_ms); + ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, result_wait); if (ret != ESP_OK) { - /* Queue timeout: transaction may still be in DMA. Must retrieve with - * infinite wait, otherwise ret_queue residual blocks future queue_trans. */ - DEBUG_WARN("SPI", "spi_device_get_trans_result timeout: %s len=%u, retrying", + /* 再给一次有界抢救(最多 1s),清掉可能残留的 in-flight 事务。 + * 旧代码用 portMAX_DELAY —— GD32 不响应时 SPI worker 永久死锁。 */ + DEBUG_WARN("SPI", "spi_device_get_trans_result timeout: %s len=%u, bounded retry 1s", esp_err_to_name(ret), (unsigned)(total_len * 8U)); - ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, portMAX_DELAY); + ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, pdMS_TO_TICKS(1000)); if (ret != ESP_OK) { - DEBUG_ERR("SPI", "spi_device_get_trans_result fail with infinite wait: %s", + DEBUG_ERR("SPI", "spi_device_get_trans_result 放弃: %s(可能需复位 SPI)", esp_err_to_name(ret)); - return ret; + /* 尽力排空 ret_queue,避免后续 queue_trans 永久堵死 */ + spi_transaction_t *junk = nullptr; + while (spi_device_get_trans_result(m_spiDevice, &junk, 0) == ESP_OK) { + (void)junk; + } + return ESP_ERR_TIMEOUT; } (void)ret_trans; return ESP_ERR_TIMEOUT; @@ -274,6 +576,67 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, return ESP_OK; } +/** + * @brief 读一帧响应(单 CS 固定预算,再 v2_parse 截真实长度)。 + * + * 不用两段 KEEP_ACTIVE:Boot 在 CS 释放时无论是否读完都清 s_txPending, + * 中途断 CS 会丢 HELLO_ACK,后续 MISO 全 0xFF(线上已复现)。 + * 预算 128B 对齐 Boot TX DMA 满缓冲;App 大 EVENT 仍可到 256(out_max)。 + */ +esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len, + uint32_t timeout_ms) +{ + if (out_frame == nullptr || out_len == nullptr || out_max < V2_HEADER_LEN) { + return ESP_ERR_INVALID_ARG; + } + if (m_spiDevice == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + *out_len = 0U; + + size_t budget = spiRespReadBudget(); + if (budget > out_max) { + budget = out_max; + } + if (budget > kSpiTransferMaxBytes) { + budget = kSpiTransferMaxBytes; + } + + uint8_t dummy[kSpiTransferMaxBytes]; + memset(dummy, 0xFF, budget); + memset(out_frame, 0xFF, budget); + + esp_err_t ret = transferClocked(dummy, budget, out_frame, budget, timeout_ms); + if (ret != ESP_OK) { + return ret; + } + + v2_header_t hdr; + const uint8_t *pload = nullptr; + if (!v2_parse_frame(out_frame, budget, &hdr, &pload)) { + bool all_ff = true; + for (size_t i = 0; i < 8U && i < budget; ++i) { + if (out_frame[i] != 0xFFU) { + all_ff = false; + break; + } + } + return all_ff ? ESP_ERR_INVALID_RESPONSE : ESP_ERR_INVALID_RESPONSE; + } + + const size_t total = V2_HEADER_LEN + (size_t)hdr.payload_len + V2_CRC_LEN; + if (total > out_max) { + return ESP_ERR_INVALID_SIZE; + } + /* parse 可能从 offset>0 找到帧:搬到缓冲区头,方便调用方 */ + if (hdr.frame_offset > 0U && hdr.frame_offset + total <= budget) { + memmove(out_frame, out_frame + hdr.frame_offset, total); + } + *out_len = total; + return ESP_OK; +} + // ============================================================ // OTA 帧工具 // ============================================================ @@ -364,29 +727,32 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, } } - /* 响应归属匹配:ack_seq 应等于 ESP32 刚发的 m_lastSentSeq。 - * 兼容旧 GD32 固件:业务响应曾写死 ack_seq=0。在 session 已匹配时 - * 接受 ack_seq==0,否则无法在“只刷 ESP32+BOOT、APP 仍旧”时完成 ENTER_BOOT。 */ + /* 响应归属:ack_seq 应等于 m_lastSentSeq。 + * + * ★ 关键:RESPONSE(ACK/WAIT/ERROR)在 CS 上已被读走,GD32 会清 s_txPending。 + * 若因 ack_seq 失配直接 return false,业务层永远看不到该帧 → 空等/重发/卡死。 + * 策略:CRC+session 合法的 RESPONSE 一律交给调用方;用 m_lastRxAckMatched + * 标记是否归属当前请求。业务层以结构化 payload(BA C0+seq)做最终判定。 */ if (hdr.flags & V2_FLAG_RESPONSE) { const bool ack_match = (hdr.ack_seq == self.m_lastSentSeq); const bool legacy_zero_ack = (hdr.ack_seq == 0U); - if (!ack_match && !legacy_zero_ack) { - /* 延迟一拍下常见:上一帧 ACK 对不上当前 m_lastSentSeq。 - * 属协议常态,不刷 WARN(前端会当警告刷屏);仅 DBG 且限频。 */ + self.m_lastRxAckMatched = (ack_match || legacy_zero_ack); + if (!self.m_lastRxAckMatched) { static uint32_t s_stale_logs = 0U; - if ((++s_stale_logs % 64U) == 1U) { - DEBUG_DBG("SPI", "v2 stale response: type=0x%02X ack_seq=%u expected=%u (log#%lu)", + if ((++s_stale_logs % 32U) == 1U) { + DEBUG_DBG("SPI", "v2 stale RESPONSE delivered: type=0x%02X ack_seq=%u expected=%u (log#%lu)", hdr.type, hdr.ack_seq, self.m_lastSentSeq, (unsigned long)s_stale_logs); } - return false; + } else { + if (!ack_match && legacy_zero_ack) { + DEBUG_DBG("SPI", "v2 legacy ack_seq=0 accepted: type=0x%02X expected=%u", + hdr.type, self.m_lastSentSeq); + } + self.m_rxAckedSeq = self.m_lastSentSeq; } - if (!ack_match && legacy_zero_ack) { - DEBUG_DBG("SPI", "v2 legacy ack_seq=0 accepted: type=0x%02X expected=%u", - hdr.type, self.m_lastSentSeq); - } - /* 响应归属匹配成功,更新已确认序号 */ - self.m_rxAckedSeq = self.m_lastSentSeq; + } else { + self.m_lastRxAckMatched = false; } /* 更新期望的对端序号(EVENT 帧的 tx_seq) */ @@ -499,8 +865,6 @@ esp_err_t SpiMaster::init() } } - /* 记录当前任务句柄,供 GPIO ISR 唤醒 */ - m_spiTaskHandle = xTaskGetCurrentTaskHandle(); m_linkState.exitBusy(); m_gd32RequestPending = false; m_localCredit = 8; @@ -514,6 +878,35 @@ esp_err_t SpiMaster::init() m_gd32Role = 0; m_v2Active = false; m_stats = SpiLinkStats{}; + m_eventHead = m_eventTail = m_eventCount = 0; + + /* L4:job 队列 + 事件信号量 + SPI worker。 + * worker 启动后可能立刻抢 m_mutex;init 持锁时 worker 会阻塞在 LockGuard, + * init 返回释放锁后 worker 继续 —— 不会死锁。 */ + if (m_jobQueue == nullptr) { + m_jobQueue = xQueueCreate(kJobQueueLen, sizeof(Job *)); + if (m_jobQueue == nullptr) { + DEBUG_ERR("SPI", "SPI job queue create failed"); + return ESP_ERR_NO_MEM; + } + } + if (m_eventSem == nullptr) { + m_eventSem = xSemaphoreCreateCounting(kEventSlots, 0); + if (m_eventSem == nullptr) { + DEBUG_ERR("SPI", "SPI event sem create failed"); + return ESP_ERR_NO_MEM; + } + } + if (m_workerTask == nullptr) { + BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", 6144, this, 6, &m_workerTask); + if (ok != pdPASS || m_workerTask == nullptr) { + DEBUG_ERR("SPI", "SPI worker task create failed"); + m_workerTask = nullptr; + return ESP_ERR_NO_MEM; + } + DEBUG_INFO("SPI", "SPI worker task created (L4)"); + } + m_spiTaskHandle = m_workerTask; return ESP_OK; } @@ -537,10 +930,9 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { - /* 公开 otaTransfer 固定走两步法:GD32 延迟一拍,首笔 CS 读到的是旧响应。 - * 与 otaSendAndPoll 同语义,保证 OTA 握手/数据包调用方无需各自再拼 poll。 */ - return otaSendAndPoll(type, payload, payload_len, - resp_type, resp_payload, resp_max, resp_len, timeout_ms); + /* 公开 otaTransfer = transact(L4 worker 串行)。 */ + return transact(type, payload, payload_len, + resp_type, resp_payload, resp_max, resp_len, timeout_ms); } /** @@ -564,17 +956,40 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s return ESP_ERR_INVALID_SIZE; } - /* 同步单 CS 传输:拉低 SPIready 后,在单次 SPI 事务中同时发送请求并读回响应。 - * 注意:GD32 BOOT 采用"延迟一拍"协议——响应在 onCsAssert() 装载 DMA, - * queueTxFrame() 只缓冲不启动 DMA。因此本笔 CS 内读到的响应属于上一笔请求, - * 首笔请求必读 0xFF。握手请求幂等可通过重试匹配;唯一性请求需配合 - * otaPollResponse() 读取延迟响应。详见 SpiOta.cpp queueTxFrame 注释。 */ + /* L5:remoteCredit==0 时禁止发新 REQUEST;等 CREDIT_UPDATE 或 poll 读到新 credit。 + * HELLO / 链路控制不受 credit 限制(session 建立前 credit 可能为 0)。 */ + if (type != V2_TYPE_LINK_HELLO && m_remoteCredit == 0U) { + const TickType_t cred_deadline = xTaskGetTickCount() + pdMS_TO_TICKS( + timeout_ms > 0U ? timeout_ms : 500U); + while (m_remoteCredit == 0U && xTaskGetTickCount() < cred_deadline) { + /* 有 DATA_READY 时变长读一帧(可能是 CREDIT_UPDATE 或陈旧响应) */ + if (waitSpiDataReady(20U) == ESP_OK) { + uint8_t drain[SPI_OTA_FRAME_MAX]; + size_t drain_len = 0U; + driveReadyLow(); + if (readV2FrameVarlen(drain, sizeof(drain), &drain_len, 100U) == ESP_OK) { + (void)otaFindFrame(drain, drain_len, nullptr, nullptr, nullptr, nullptr); + } + releaseReady(); + } else { + vTaskDelay(pdMS_TO_TICKS(5)); + } + } + if (m_remoteCredit == 0U) { + DEBUG_WARN("SPI", "remoteCredit=0, refuse TX type=0x%02X", type); + return ESP_ERR_INVALID_STATE; + } + } + + /* 同步单 CS 传输:按帧长发送;延迟一拍下本笔 RX 为陈旧响应。 */ m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - /* 等 GD32 释放 PB6(上一笔 onCsRelease 处理完)再发起新 CS。 - * 中断驱动:GD32 拉高 → ISR 0x02 → 唤醒。GD32 写 Flash 期间 - * PB6 保持低,本函数阻塞直到写完,自然避免 overrun。 - * 0xB0 不走 otaTransfer,不受此等待影响。 */ - (void)waitSpiReady(SPI_READY_TIMEOUT_MS); + /* v2 延迟一拍:PB6 低表示 GD32 有上一笔响应待读,step1 的 transferClocked + * 会自然把它当陈旧响应读走,不需要额外 drain(drain 会多消耗一个 CS 周期, + * 可能误吃掉主循环刚排好的 ACK 导致后续包永久丢 ACK)。 + * + * ★ 不调 waitSpiReady(等 PB6 变高):v2 DATA_READY 下 PB6 低 = GD32 有待发 + * 响应,OTA 中继期间几乎持续为低。旧代码每包忙等 200ms 超时,21 包累计 + * ~4.2s 纯浪费,最终拖垮转发预算导致卡死。CS 本身即互斥,无需额外就绪等待。 */ m_linkState.enterBusy(HandshakeState::MASTER_SENDING); driveReadyLow(); @@ -617,8 +1032,11 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s } } if (resp_len) *resp_len = found_len; - /* 成功收到响应,递增发送序号(重发时调用方在调用前已保存原序号) */ - m_txSeq = (uint8_t)(m_txSeq + 1U); + /* expect_stale(两步法 step1)读到的是延迟一拍陈旧帧,序号由 poll 成功时推进。 + * 此处若自增会导致 job 间隙/超时后 resend 使用错误 TX_SEQ。 */ + if (!expect_stale) { + m_txSeq = (uint8_t)(m_txSeq + 1U); + } return ESP_OK; } @@ -641,6 +1059,16 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { + if (!onWorker() && m_workerTask != nullptr) { + Job job{}; + job.kind = JobKind::Poll; + job.timeout_ms = timeout_ms; + job.out_type = resp_type; + job.out_payload = resp_payload; + job.out_max = resp_max; + job.out_len = resp_len; + return submitJob(job); + } LockGuard lock(m_mutex); return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, timeout_ms); } @@ -658,43 +1086,77 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p size_t resp_max, size_t *resp_len, uint32_t timeout_ms, uint8_t expected_type) { - constexpr size_t kPollBytes = 128U; /* 与 spiXferBytes 默认窗口一致 */ + if (timeout_ms == 0U) { + timeout_ms = 1U; + } TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); uint32_t attempt = 0; + bool did_spi_read = false; while (xTaskGetTickCount() <= deadline) { ++attempt; - /* v2 DATA_READY:poll 是读响应。PB6 低 = 有待读数据,应立即 CS; - * 不要 waitSpiReady(等 HIGH)。首轮多等一会,给 GD32 主循环 AES 时间。 */ + /* 优先等 DATA_READY;超时仍做有限次盲 poll。 + * 短 timeout(OTA 紧循环 50~150ms)必须保证至少 1 次 SPI 读, + * 否则 PB6 偶发未拉低时整段切片空转 → 只见 “timeout: 30 ms” 然后失败。 */ m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - (void)waitSpiDataReady(attempt == 1U ? 100U : 20U); - - uint8_t rx_raw[kPollBytes]; - uint8_t dummy[kPollBytes]; - memset(dummy, 0xFF, sizeof(dummy)); - TickType_t now = xTaskGetTickCount(); uint32_t remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U; - if (remain_ms == 0U) break; - uint32_t spi_timeout = (remain_ms > 500U) ? 500U : remain_ms; - /* 与发送路径一致:先 driveReady 再 CS,避免 GD32 侧时序偶发丢响应 */ + if (remain_ms == 0U && did_spi_read) { + break; + } + if (remain_ms == 0U) { + remain_ms = 1U; /* 强制末次读 */ + } + + const uint32_t ready_slice = (attempt == 1U) + ? ((timeout_ms >= 80U) ? 40U : timeout_ms) + : 15U; + const uint32_t wait_ms = (ready_slice < remain_ms) ? ready_slice : remain_ms; + const bool ready = (waitSpiDataReady(wait_ms) == ESP_OK); + + now = xTaskGetTickCount(); + remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U; + const bool last_chance = (remain_ms == 0U) || (attempt >= 6U && !did_spi_read); + /* 无 ready:前 2 轮盲读;之后每 8 轮盲读;deadline 末强制盲读一次 */ + if (!ready && attempt > 2U && (attempt % 8U) != 0U && !last_chance) { + vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_POLL_BACKOFF_MS)); + continue; + } + if (remain_ms == 0U) { + remain_ms = 20U; /* 末次 SPI 事务给足一点时钟时间 */ + } + const uint32_t spi_timeout = (remain_ms > 200U) ? 200U : remain_ms; + + uint8_t rx_raw[SPI_OTA_FRAME_MAX]; + size_t rx_len = 0U; driveReadyLow(); - esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), spi_timeout); + esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, spi_timeout); releaseReady(); + did_spi_read = true; if (ret != ESP_OK) { - vTaskDelay(pdMS_TO_TICKS(2)); + if (ret == ESP_ERR_INVALID_RESPONSE) { + vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_POLL_BACKOFF_MS)); + } else { + vTaskDelay(pdMS_TO_TICKS(1)); + } + if (xTaskGetTickCount() > deadline) { + break; + } continue; } - /* Check if all 0xFF (slave not driving MISO) */ - bool all_ff = true; - for (size_t k = 0; k < 8 && k < sizeof(rx_raw); ++k) { - if (rx_raw[k] != 0xFF) { all_ff = false; break; } - } - if (all_ff) { - vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_POLL_BACKOFF_MS)); - continue; + /* DEBUG: 限频,避免 OTA 紧循环刷屏 */ + { static uint32_t s_pdbg = 0; + if ((++s_pdbg % 40) == 1 || (rx_raw[0] != 0xFF && rx_raw[0] != 0x00)) { + v2_header_t dh; const uint8_t *dp = nullptr; + bool parsed = v2_parse_frame(rx_raw, rx_len, &dh, &dp); + if (parsed) { + DEBUG_DBG("SPI", "poll att=%u type=0x%02X ack_seq=%u lastSent=%u", + (unsigned)attempt, (unsigned)dh.type, (unsigned)dh.ack_seq, + (unsigned)m_lastSentSeq); + } + } } uint8_t found_type = 0; @@ -702,19 +1164,18 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p size_t found_len = 0; size_t frame_offset = 0; - if (otaFindFrame(rx_raw, sizeof(rx_raw), &found_type, &found_payload, &found_len, &frame_offset)) { - /* v2: 不再跳过 found_len==0 的 ACK/ERROR。 - * 延迟一拍协议下首次 poll 可能读到上一笔的过期响应, - * 调用方通过 resp_type 和 payload 内容自行判断是否为期望响应。 - * WAIT 仍需继续轮询。 */ + if (otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, &found_len, &frame_offset)) { + if (found_type == V2_TYPE_CREDIT_UPDATE) { + DEBUG_DBG("SPI", "poll got CREDIT_UPDATE credit=%u", (unsigned)m_remoteCredit); + vTaskDelay(pdMS_TO_TICKS(1)); + continue; + } - /* 类型过滤:如果指定了 expected_type 且不匹配,丢弃继续轮询。 - * 避免消费掉不相关的帧(如 CAN feedback 事件帧被 PING poll 吃掉)。 */ if (expected_type != 0xFFU && found_type != expected_type && found_type != SPI_OTA_RSP_WAIT) { - DEBUG_DBG("SPI", "poll type mismatch: got=0x%02X want=0x%02X, discarding", + DEBUG_DBG("SPI", "poll type mismatch: got=0x%02X want=0x%02X", found_type, expected_type); - vTaskDelay(pdMS_TO_TICKS(2)); + vTaskDelay(pdMS_TO_TICKS(1)); continue; } @@ -723,27 +1184,35 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p size_t copy_len = (found_len < resp_max) ? found_len : resp_max; if (found_payload && copy_len > 0) { memcpy(resp_payload, found_payload, copy_len); + } else if (copy_len > 0) { + memset(resp_payload, 0, copy_len); } } if (resp_len) *resp_len = found_len; + /* WAIT 立刻返回,由 OTA 紧循环再 poll(勿在此空转) */ if (found_type == SPI_OTA_RSP_WAIT) { - vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_POLL_BACKOFF_MS)); - continue; + m_linkState.exitBusy(); + return ESP_OK; + } + if (m_lastRxAckMatched) { + m_txSeq = (uint8_t)(m_lastSentSeq + 1U); } - /* 真实响应匹配成功:推进发送序号。 - * expect_stale 的 step1 若读到 0xFF 不会自增 m_txSeq, - * 必须在 poll 成功时补齐,避免下一命令复用同一 TX_SEQ。 */ - m_txSeq = (uint8_t)(m_lastSentSeq + 1U); m_linkState.exitBusy(); return ESP_OK; } - vTaskDelay(pdMS_TO_TICKS(2)); + vTaskDelay(pdMS_TO_TICKS(1)); } m_linkState.exitBusy(); - DEBUG_WARN("SPI", "OTA response timeout: %lu ms", (unsigned long)timeout_ms); + m_stats.ready_timeouts++; + /* 短切片超时是 OTA 正常紧循环,勿 WARN 刷爆 ring/网页 */ + if (timeout_ms >= 200U) { + DEBUG_WARN("SPI", "OTA response timeout: %lu ms", (unsigned long)timeout_ms); + } else { + DEBUG_DBG("SPI", "OTA poll slice timeout: %lu ms", (unsigned long)timeout_ms); + } return ESP_ERR_TIMEOUT; } @@ -754,10 +1223,71 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p * otaPollResponse 读取本笔命令的真实响应。命令只发送一次,不重发。 * 适用于所有 ESP32→GD32 方向的命令(CAN 发送/模式切换/ID 快照等)。 */ +esp_err_t SpiMaster::transact(uint8_t type, const uint8_t *payload, size_t payload_len, + uint8_t *resp_type, uint8_t *resp_payload, + size_t resp_max, size_t *resp_len, uint32_t timeout_ms) +{ + if (payload_len > SPI_OTA_FRAME_MAX) { + return ESP_ERR_INVALID_SIZE; + } + if (!onWorker() && m_workerTask != nullptr) { + Job job{}; + job.kind = JobKind::Transact; + job.timeout_ms = timeout_ms; + job.type = type; + job.payload_len = payload_len; + if (payload && payload_len > 0U) { + memcpy(job.payload, payload, payload_len); + } + job.out_type = resp_type; + job.out_payload = resp_payload; + job.out_max = resp_max; + job.out_len = resp_len; + return submitJob(job); + } + return otaSendAndPoll(type, payload, payload_len, + resp_type, resp_payload, resp_max, resp_len, timeout_ms); +} + +bool SpiMaster::takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len) +{ + LockGuard lock(m_mutex); + if (popEvent(type, payload, max_len, len)) { + /* 与 pushEvent 的 counting sem 对齐 */ + if (m_eventSem) { + (void)xSemaphoreTake(m_eventSem, 0); + } + return true; + } + /* 兼容:事件队列空时回落单槽 */ + if (m_ringRxLen == 0U) { + return false; + } + if (type) { + *type = m_ringRxType; + } + size_t copy_len = (m_ringRxLen <= max_len) ? m_ringRxLen : max_len; + if (len) { + *len = copy_len; + } + if (payload && copy_len > 0U) { + memcpy(payload, m_ringRxBuf, copy_len); + } + m_ringRxLen = 0U; + m_ringRxType = 0U; + return true; +} + esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t payload_len, uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { + /* 非 worker 调用:转 transact 入队,避免多任务直接碰 SPI */ + if (!onWorker() && m_workerTask != nullptr) { + return transact(type, payload, payload_len, + resp_type, resp_payload, resp_max, resp_len, timeout_ms); + } + LockGuard lock(m_mutex); /* 步骤 1: 发送命令(忽略延迟一拍的过期响应)。 @@ -773,20 +1303,13 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t return send_ret; } - /* 等 GD32 完成 onCsRelease,并优先等 PB6 变低(响应/WAIT 已装载)。 - * OTA DATA 在 GD32 主循环做 AES,可能数 ms;不可固定短 delay 后盲 poll。 */ + /* 等 GD32 完成 onCsRelease;poll 内仅在 DATA_READY 低时变长读(L3 不盲 CS)。 */ { const TickType_t gap = (pdMS_TO_TICKS(1) > 0) ? pdMS_TO_TICKS(1) : 1; vTaskDelay(gap); } - m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - const uint32_t ready_wait_ms = (timeout_ms > 800U) ? 300U : 80U; - if (waitSpiDataReady(ready_wait_ms) != ESP_OK) { - DEBUG_DBG("SPI", "DATA_READY 未拉低(%u ms),继续 poll", (unsigned)ready_wait_ms); - } - m_linkState.exitBusy(); - /* 步骤 2: 轮询读取真实响应(dummy TX,不重发命令) */ + /* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */ const uint32_t poll_timeout = (timeout_ms > send_timeout) ? (timeout_ms - send_timeout) : 500U; return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout, @@ -857,18 +1380,17 @@ esp_err_t SpiMaster::transactWithRetry(uint8_t type, const uint8_t *payload, siz uint8_t hello_frame[V2_MAX_FRAME]; size_t hello_len = 0; if (v2_build_hello_frame(m_sessionId, 0U, hello_frame, &hello_len)) { - /* 同 session 连发 2 次 HELLO,覆盖延迟一拍(勿用紧贴 dummy) */ + /* 延迟一拍连发 2 次,避免 DATA_READY/短读导致全 0xFF */ + const size_t xfer_len = spiXferBytes(hello_len); for (uint32_t hi = 0U; hi < 2U; ++hi) { m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); (void)waitSpiReady(SPI_READY_TIMEOUT_MS); m_linkState.enterBusy(HandshakeState::MASTER_SENDING); driveReadyLow(); - - const size_t xfer_len = spiXferBytes(hello_len); uint8_t tx_p[SPI_OTA_FRAME_MAX]; + uint8_t rx_r[SPI_OTA_FRAME_MAX]; memset(tx_p, 0xFF, xfer_len); memcpy(tx_p, hello_frame, hello_len); - uint8_t rx_r[SPI_OTA_FRAME_MAX]; memset(rx_r, 0xFF, xfer_len); esp_err_t hr = transferClocked(tx_p, xfer_len, rx_r, xfer_len, 2000U); releaseReady(); @@ -885,9 +1407,11 @@ esp_err_t SpiMaster::transactWithRetry(uint8_t type, const uint8_t *payload, siz v2_get_u32le(&pl[0]) == m_sessionId) { m_gd32Role = pl[4]; m_localCredit = pl[5]; + m_remoteCredit = hdr.credit; m_v2Active = true; m_stats.resyncs++; - DEBUG_INFO("SPI", "transactWithRetry: HELLO 重同步成功 role=%u", m_gd32Role); + DEBUG_INFO("SPI", "transactWithRetry: HELLO 重同步成功 role=%u", + m_gd32Role); break; } vTaskDelay(pdMS_TO_TICKS(5)); @@ -906,21 +1430,28 @@ esp_err_t SpiMaster::transactWithRetry(uint8_t type, const uint8_t *payload, siz */ esp_err_t SpiMaster::appRequestBootloader() { + if (!onWorker() && m_workerTask != nullptr) { + Job job{}; + job.kind = JobKind::EnterBoot; + job.timeout_ms = 5000U; + return submitJob(job); + } + LockGuard lock(m_mutex); DEBUG_INFO("SPI", "请求 GD32 APP 进入 Bootloader (v2)..."); m_gd32RequestPending = false; - /* 先空读 1~2 次,尽量吞掉残留 0xC1 按铃帧,避免盖住 0xB0 的延迟一拍响应。 */ + /* 先变长读最多 2 次,吞掉残留 0xC1(仅 DATA_READY 低时读,禁止固定长度盲读)。 */ for (uint32_t d = 0U; d < 2U; ++d) { - constexpr size_t kPoll = 128U; - uint8_t rx_raw[kPoll]; - uint8_t dummy[kPoll]; - memset(dummy, 0xFF, sizeof(dummy)); - memset(rx_raw, 0xFF, sizeof(rx_raw)); + if (waitSpiDataReady(10U) != ESP_OK) { + break; + } + uint8_t rx_raw[SPI_OTA_FRAME_MAX]; + size_t rx_len = 0U; m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); driveReadyLow(); - (void)transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), 50U); + (void)readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, 50U); releaseReady(); m_linkState.exitBusy(); vTaskDelay((pdMS_TO_TICKS(5) > 0) ? pdMS_TO_TICKS(5) : 1); @@ -947,8 +1478,9 @@ esp_err_t SpiMaster::appRequestBootloader() uint8_t hf[V2_MAX_FRAME]; size_t hl = 0; if (v2_build_hello_frame(m_sessionId, V2_ROLE_APP, hf, &hl)) { + /* 延迟一拍:连发 2 次 HELLO(≥64B 时钟),第 2 次 RX 为 ACK */ + const size_t xlen = spiXferBytes(hl); for (uint32_t hi = 0U; hi < 2U; ++hi) { - const size_t xlen = spiXferBytes(hl); uint8_t tx_p[SPI_OTA_FRAME_MAX]; uint8_t rx_r[SPI_OTA_FRAME_MAX]; memset(tx_p, 0xFF, xlen); @@ -987,20 +1519,19 @@ esp_err_t SpiMaster::appRequestBootloader() m_txSeq = (uint8_t)(m_lastSentSeq + 1U); vTaskDelay(gap); - /* poll 最多 ~150ms:只认 0xB0+0xB5;0xC1/0x82/过期 0x80 都忽略 */ - const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(150U); + /* poll 最多 ~200ms:只认 0xB0+0xB5。优先 DATA_READY,超时仍单 CS 盲读。 */ + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(200U); uint8_t last_type = 0xFFU; + uint32_t poll_i = 0U; while (xTaskGetTickCount() <= deadline) { + ++poll_i; m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - (void)waitSpiDataReady(30U); + (void)waitSpiDataReady(poll_i <= 2U ? 40U : 10U); - constexpr size_t kPoll = 128U; - uint8_t rx_raw[kPoll]; - uint8_t dummy[kPoll]; - memset(dummy, 0xFF, sizeof(dummy)); - memset(rx_raw, 0xFF, sizeof(rx_raw)); + uint8_t rx_raw[SPI_OTA_FRAME_MAX]; + size_t rx_len = 0U; driveReadyLow(); - ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), 80U); + ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, 80U); releaseReady(); m_linkState.exitBusy(); if (ret != ESP_OK) { @@ -1010,7 +1541,7 @@ esp_err_t SpiMaster::appRequestBootloader() v2_header_t hdr; const uint8_t *pload = nullptr; - if (!v2_parse_frame(rx_raw, sizeof(rx_raw), &hdr, &pload) || + if (!v2_parse_frame(rx_raw, rx_len, &hdr, &pload) || !(hdr.flags & V2_FLAG_RESPONSE)) { vTaskDelay(gap); continue; @@ -1073,6 +1604,13 @@ esp_err_t SpiMaster::appRequestBootloader() */ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) { + if (!onWorker() && m_workerTask != nullptr) { + Job job{}; + job.kind = JobKind::WaitBoot; + job.timeout_ms = timeout_ms; + return submitJob(job); + } + DEBUG_INFO("SPI", "等待 Bootloader 就绪 (HELLO 轮询, timeout=%lums)...", (unsigned long)timeout_ms); @@ -1129,131 +1667,82 @@ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) } /** - * @brief 向 GD32 APP 发送存活探测命令(v2 标准帧,单 CS 同步传输)。 + * @brief 向 GD32 APP 发送存活探测(L3:帧长发送 + DATA_READY 变长读)。 * - * 与旧版一致采用单 CS 事务:发送 PING 帧同时读回响应, - * 不走 otaPollResponseUnlocked(避免消费不相关帧)。 - * 用 v2_parse_frame 在 RX buffer 里定位响应帧。 - * - * GD32 App PING handler 用 kRspAck(0x80) 作为响应 type, - * payload(16B) = APP version(4B) + BOOT version(4B) + uptime_ms(4B) + build_hash(4B)。 - * - * @param timeout_ms 超时时间(毫秒)。 - * @param uptime_sec 输出设备运行时间(秒)。 - * @param version 输出 GD32 版本信息(可选)。 - * @return ESP_OK 表示成功,否则返回错误码。 + * 两步法(持锁):otaTransferUnlocked(expect_stale) + otaPollResponseUnlocked。 + * GD32 App PING 响应 type=kRspAck(0x80), + * payload 16B = APP ver(4) + BOOT ver(4) + uptime_ms(4) + build_hash(4)。 */ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version) { + if (!onWorker() && m_workerTask != nullptr) { + Job job{}; + job.kind = JobKind::Ping; + job.timeout_ms = timeout_ms; + job.out_uptime = uptime_sec; + job.out_version = version; + return submitJob(job); + } + LockGuard lock(m_mutex); constexpr uint8_t kAppPingType = 0xA1U; - /* 构建 v2 PING 请求帧 */ - uint8_t tx_frame[V2_MAX_FRAME]; - size_t tx_len = 0; - if (!v2_build_frame(kAppPingType, V2_FLAG_REQUEST, m_sessionId, - m_txSeq, m_rxAckedSeq, m_localCredit, - nullptr, 0, tx_frame, &tx_len)) { - return ESP_ERR_INVALID_ARG; + uint8_t resp_type = 0; + uint8_t resp_payload[32]; + size_t resp_len = 0U; + + const uint32_t send_timeout = (timeout_ms > 500U) ? 500U : timeout_ms; + esp_err_t send_ret = otaTransferUnlocked(kAppPingType, nullptr, 0U, + nullptr, nullptr, 0U, nullptr, send_timeout, + /*expect_stale=*/true); + if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) { + DEBUG_DBG("SPI", "APP PING send failed: %s", esp_err_to_name(send_ret)); + return send_ret; } - TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); - uint32_t attempt = 0U; - - while (xTaskGetTickCount() <= deadline) { - ++attempt; - - /* 单 CS 同步传输:下拉 SPIready 后,在单次 SPI 事务中发送 PING 并读回响应。 - * GD32 延迟一拍协议下,本次 CS 读到的是上一笔的响应(首笔为 0xFF)。 - * 发送 v2 帧后 GD32 在 CS 释放后处理,响应在下一次 CS 返回。 - * 因此需要多次循环:第一次发 PING + 读 0xFF,第二次发 PING + 读上一笔 PING 的 ACK。 */ - const size_t xfer_len = spiXferBytes(tx_len); - uint8_t tx_padded[SPI_OTA_FRAME_MAX]; - memset(tx_padded, 0xFF, xfer_len); - memcpy(tx_padded, tx_frame, tx_len); - - m_linkState.enterBusy(HandshakeState::MASTER_SENDING); - driveReadyLow(); - if (SPI_READY_DRIVE_DELAY_US > 0U) { - esp_rom_delay_us(SPI_READY_DRIVE_DELAY_US); - } - - uint8_t rx_raw[SPI_OTA_FRAME_MAX]; - memset(rx_raw, 0xFF, xfer_len); - - esp_err_t err = transferClocked(tx_padded, xfer_len, rx_raw, xfer_len, 0U); - releaseReady(); - m_linkState.exitBusy(); - if (err != ESP_OK) { - DEBUG_DBG("SPI", "APP PING transfer failed[%lu]: %s", - (unsigned long)attempt, esp_err_to_name(err)); - vTaskDelay(pdMS_TO_TICKS(5)); - continue; - } - - /* 在 RX buffer 中用 v2_parse_frame 定位响应帧 */ - v2_header_t hdr; - const uint8_t *pload = nullptr; - if (v2_parse_frame(rx_raw, xfer_len, &hdr, &pload) && - hdr.type == SPI_OTA_RSP_ACK && hdr.payload_len >= 12U && pload) { - /* GD32 APP 重启后 g_v2Active=false,响应 session_id=0。 - * appPing 不经 otaFindFrame,故不会因 session 失配被拒—— - * 但后续 otaSendAndPoll 走 otaFindFrame 仍会拒绝。 - * 在此同步 m_sessionId 到 GD32 实际值,避免失配。 */ - if (hdr.session_id != m_sessionId) { - DEBUG_INFO("SPI", "appPing session 同步: 0x%08lX -> 0x%08lX", - (unsigned long)m_sessionId, (unsigned long)hdr.session_id); - m_sessionId = hdr.session_id; - } - /* 成功收到 PING ACK 响应,递增发送序号 */ - m_txSeq = (uint8_t)(m_txSeq + 1U); - - /* payload 布局兼容两种 GD32 App 固件版本: - * 16B: APP ver(4) + BOOT ver(4) + uptime_ms(4) + build_hash(4) - * 12B: APP ver(4) + uptime_ms(4) + build_hash(4) (旧固件,无 BOOT 版本) */ - if (version) { - memset(version, 0, sizeof(gd32_version_t)); - version->app_major = pload[0]; - version->app_minor = pload[1]; - version->app_patch = pload[2]; - version->app_build = pload[3]; - if (hdr.payload_len >= 16U) { - /* 新固件:[4..7] = BOOT 版本 */ - version->boot_major = pload[4]; - version->boot_minor = pload[5]; - version->boot_patch = pload[6]; - version->boot_build = pload[7]; - } - /* 旧固件 payload_len==12:boot 版本保持 0(已在 memset 中清零) */ - } - if (uptime_sec) { - if (hdr.payload_len >= 16U) { - /* 新固件:uptime 在 [8..11] */ - const uint8_t *ut = &pload[8]; - const uint32_t uptime_ms = (uint32_t)ut[0] - | ((uint32_t)ut[1] << 8) - | ((uint32_t)ut[2] << 16) - | ((uint32_t)ut[3] << 24); - *uptime_sec = uptime_ms / 1000U; - } else { - /* 旧固件 payload_len==12:uptime 在 [4..7] */ - const uint8_t *ut = &pload[4]; - const uint32_t uptime_ms = (uint32_t)ut[0] - | ((uint32_t)ut[1] << 8) - | ((uint32_t)ut[2] << 16) - | ((uint32_t)ut[3] << 24); - *uptime_sec = uptime_ms / 1000U; - } - } - DEBUG_DBG("SPI", "APP PING v2 成功: attempt=%lu", (unsigned long)attempt); - return ESP_OK; - } - - vTaskDelay(pdMS_TO_TICKS(5)); + { + const TickType_t gap = (pdMS_TO_TICKS(1) > 0) ? pdMS_TO_TICKS(1) : 1; + vTaskDelay(gap); } - DEBUG_DBG("SPI", "APP PING 超时: timeout=%ums", (unsigned)timeout_ms); - return ESP_ERR_TIMEOUT; + const uint32_t poll_timeout = (timeout_ms > send_timeout) + ? (timeout_ms - send_timeout) : 500U; + esp_err_t err = otaPollResponseUnlocked(&resp_type, resp_payload, sizeof(resp_payload), + &resp_len, poll_timeout, SPI_OTA_RSP_ACK); + if (err != ESP_OK) { + DEBUG_DBG("SPI", "APP PING poll failed: %s", esp_err_to_name(err)); + return err; + } + + if (resp_type != SPI_OTA_RSP_ACK || resp_len < 12U) { + DEBUG_DBG("SPI", "APP PING bad resp type=0x%02X len=%u", resp_type, (unsigned)resp_len); + return ESP_ERR_INVALID_RESPONSE; + } + + const uint8_t *pload = resp_payload; + if (version) { + memset(version, 0, sizeof(gd32_version_t)); + version->app_major = pload[0]; + version->app_minor = pload[1]; + version->app_patch = pload[2]; + version->app_build = pload[3]; + if (resp_len >= 16U) { + version->boot_major = pload[4]; + version->boot_minor = pload[5]; + version->boot_patch = pload[6]; + version->boot_build = pload[7]; + } + } + if (uptime_sec) { + const uint8_t *ut = (resp_len >= 16U) ? &pload[8] : &pload[4]; + const uint32_t uptime_ms = (uint32_t)ut[0] + | ((uint32_t)ut[1] << 8) + | ((uint32_t)ut[2] << 16) + | ((uint32_t)ut[3] << 24); + *uptime_sec = uptime_ms / 1000U; + } + DEBUG_DBG("SPI", "APP PING v2 成功"); + return ESP_OK; } /** @@ -1267,97 +1756,79 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver */ esp_err_t SpiMaster::checkGd32Request(uint32_t timeout_ms) { - /* 登记 ISR 通知目标任务,确保下降沿中断能唤醒本任务 */ - m_spiTaskHandle = xTaskGetCurrentTaskHandle(); + /* L4:不改 m_spiTaskHandle(必须始终是 worker)。 + * 已有事件则直接 OK;否则提交 ServiceRing 或等待 eventSem。 */ + if (m_suppressRingService) { + return ESP_ERR_NOT_FOUND; + } + { + LockGuard lock(m_mutex); + if (m_eventCount > 0U || m_ringRxLen > 0U) { + return ESP_OK; + } + } + + if (onWorker()) { + return serviceRingOnce(); + } + + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS( + timeout_ms > 0U ? timeout_ms : 1U); + for (;;) { + { + LockGuard lock(m_mutex); + if (m_eventCount > 0U || m_ringRxLen > 0U) { + return ESP_OK; + } + } - if (!m_gd32RequestPending) { if (timeout_ms == 0U) { + /* 单次尝试:让 worker 立刻 service 一次 */ + Job job{}; + job.kind = JobKind::ServiceRing; + job.timeout_ms = 50U; + esp_err_t r = submitJob(job); + if (r == ESP_OK) { + return ESP_OK; + } return ESP_ERR_NOT_FOUND; } - /* 等待 GD32 按铃通知(bit 0x01) */ - xTaskNotifyWait(0x01U, 0x01U, NULL, 0U); - TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); - while (!m_gd32RequestPending && xTaskGetTickCount() < deadline) { - uint32_t val = 0U; - xTaskNotifyWait(0x01U, 0x01U, &val, pdMS_TO_TICKS(10)); - } - if (!m_gd32RequestPending) { + + if (xTaskGetTickCount() >= deadline) { return ESP_ERR_NOT_FOUND; } - } - LockGuard lock(m_mutex); - m_gd32RequestPending = false; - m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING); - - /* 占用 SPIready 线(保持低):屏蔽 GD32 onCsRelease 的 setSpiReady(true), - * 使线在 ESP32 完成前始终保持忙碌。完成后 releaseReady 产生上升沿, - * 作为 GD32 EXTI 的"ESP32 完成"信号。 */ - driveReadyLow(); - if (SPI_READY_DRIVE_DELAY_US > 0U) { - esp_rom_delay_us(SPI_READY_DRIVE_DELAY_US); - } - - constexpr size_t kRingReadBytes = 160U; - uint8_t rx_raw[kRingReadBytes]; - uint8_t dummy[kRingReadBytes]; - memset(dummy, 0xFF, sizeof(dummy)); - memset(rx_raw, 0xFF, sizeof(rx_raw)); - - /* GD32 已下拉 SPIready,我们是响应方直接读 */ - esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), 500U); - - /* 无论成功失败,先释放线 + 回 IDLE, - * 让 GD32 EXTI 上升沿触发,解除 GD32 发送阻塞 */ - releaseReady(); - m_linkState.exitBusy(); - - if (ret != ESP_OK) { - DEBUG_WARN("SPI", "checkGd32Request 读事务失败: %s", esp_err_to_name(ret)); - return ret; - } - - /* 解析请求帧(m_linkState 已 IDLE,允许 GD32 下一帧按铃被 ISR 接受) */ - uint8_t found_type = 0; - const uint8_t *found_payload = nullptr; - size_t found_len = 0; - size_t frame_offset = 0; - if (otaFindFrame(rx_raw, sizeof(rx_raw), &found_type, &found_payload, - &found_len, &frame_offset)) { - m_ringRxLen = found_len; - m_ringRxType = found_type; - if (found_payload && found_len > 0 && found_len <= sizeof(m_ringRxBuf)) { - memcpy(m_ringRxBuf, found_payload, found_len); + /* 唤醒 worker 读按铃;再等事件信号 */ + if (m_workerTask) { + xTaskNotify(m_workerTask, 0x01U, eSetBits); + } + Job job{}; + job.kind = JobKind::ServiceRing; + job.timeout_ms = 100U; + if (submitJob(job) == ESP_OK) { + return ESP_OK; } - /* 正常按铃静默,仅在异常时由上层告警 */ - return ESP_OK; - } - /* 降噪:无合法帧时限流,避免 ready 毛刺/残留 0xFF 刷屏 */ - static uint32_t s_ring_bad_logs = 0U; - if ((++s_ring_bad_logs % 32U) == 1U) { - DEBUG_WARN("SPI", "GD32 按铃读事务未找到合法帧 (log#%lu) head=%02X %02X %02X %02X", - (unsigned long)s_ring_bad_logs, - rx_raw[0], rx_raw[1], rx_raw[2], rx_raw[3]); + if (m_eventSem) { + TickType_t now = xTaskGetTickCount(); + TickType_t left = (deadline > now) ? (deadline - now) : 0; + if (left == 0) { + return ESP_ERR_NOT_FOUND; + } + if (xSemaphoreTake(m_eventSem, left) == pdTRUE) { + /* 信号量被 take 后计数减一,但事件仍在队列里;补回计数给 takeEvent */ + (void)xSemaphoreGive(m_eventSem); + return ESP_OK; + } + } else { + vTaskDelay(pdMS_TO_TICKS(10)); + } } - return ESP_ERR_INVALID_RESPONSE; } bool SpiMaster::takeRingRx(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len) { - LockGuard lock(m_mutex); - if (m_ringRxLen == 0U) { - return false; - } - if (type) { *type = m_ringRxType; } - size_t copy_len = (m_ringRxLen <= max_len) ? m_ringRxLen : max_len; - if (len) { *len = copy_len; } - if (payload && copy_len > 0U) { - memcpy(payload, m_ringRxBuf, copy_len); - } - m_ringRxLen = 0U; - m_ringRxType = 0U; - return true; + return takeEvent(type, payload, max_len, len); } // ============================================================ @@ -1365,12 +1836,28 @@ bool SpiMaster::takeRingRx(uint8_t *type, uint8_t *payload, size_t max_len, size // ============================================================ SpiMaster::SpiLinkStats SpiMaster::linkStats() { - LockGuard lock(m_mutex); - return m_stats; + /* OTA 中继期间 SPI worker 在 otaSendAndPoll 全程持有 m_mutex(发送+轮询 ACK, + * 每包最长 6s)。若用 portMAX_DELAY 等锁,/system/status 会被阻塞整个传输阶段, + * 前端进度完全不同步直到传输结束。 + * 统计仅为展示用途,拿不到锁时返回零值,绝不阻塞 status handler。 */ + SpiLinkStats ret{}; + if (m_mutex && xSemaphoreTake(m_mutex, pdMS_TO_TICKS(5)) == pdTRUE) { + ret = m_stats; + xSemaphoreGive(m_mutex); + } + return ret; } esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) { + if (!onWorker() && m_workerTask != nullptr) { + Job job{}; + job.kind = JobKind::Hello; + job.timeout_ms = timeout_ms; + job.role_hint = role_hint; + return submitJob(job); + } + LockGuard lock(m_mutex); /* 1. 生成随机非零 SESSION_ID(整次 hello 内固定,禁止每 CS 换 session) */ @@ -1390,9 +1877,9 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) return ESP_ERR_INVALID_RESPONSE; } - /* GD32 延迟一拍:采用与 appPing 相同的“同 session 连发”策略。 - * 第 N 次 CS 读到第 N-1 次 HELLO 的 ACK;dummy 紧贴重发会与 GD32 EXTI - * 处理竞态,实测易得到全 0xFF。 */ + /* HELLO 必须走延迟一拍全双工连发(与 Boot 实测一致): + * 第 N 次 CS 读到第 N-1 次 HELLO 的 ACK。不可只等 DATA_READY 再短读: + * Boot TX 按 128 预装,过短/两段 CS 会清 pending → MISO 全 0xFF。 */ const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); uint8_t rx_raw[SPI_OTA_FRAME_MAX]; uint32_t attempt = 0U; @@ -1402,9 +1889,8 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) uint8_t last_rx_head[16]; memset(last_rx_head, 0xFF, sizeof(last_rx_head)); - /* FreeRTOS tick 常为 10ms:pdMS_TO_TICKS(5)==0 会变成忙等上千次。 - * 至少 1 tick,并在两次 CS 间留出 GD32 EXTI 处理时间。 */ const TickType_t gap_ticks = (pdMS_TO_TICKS(10) > 0) ? pdMS_TO_TICKS(10) : 1; + const size_t xfer_len = spiXferBytes(hello_len); /* ≥64,盖住 HELLO_ACK */ while (xTaskGetTickCount() <= deadline) { ++attempt; @@ -1414,13 +1900,12 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) m_linkState.enterBusy(HandshakeState::MASTER_SENDING); driveReadyLow(); - const size_t xfer_len = spiXferBytes(hello_len); uint8_t tx_padded[SPI_OTA_FRAME_MAX]; memset(tx_padded, 0xFF, xfer_len); memcpy(tx_padded, hello_frame, hello_len); memset(rx_raw, 0xFF, xfer_len); - esp_err_t ret = transferClocked(tx_padded, xfer_len, rx_raw, xfer_len, 0U); + esp_err_t ret = transferClocked(tx_padded, xfer_len, rx_raw, xfer_len, 200U); releaseReady(); m_linkState.exitBusy(); if (ret != ESP_OK) { @@ -1453,7 +1938,7 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) const uint32_t session_echo = v2_get_u32le(&pload[0]); if (session_echo != m_sessionId) { - /* 延迟一拍的旧 ACK:同 session 再发一次即可对齐 */ + /* 延迟一拍旧 ACK:同 session 再发一次即可对齐 */ DEBUG_DBG("SPI", "HELLO lag echo=0x%08lX expected=0x%08lX attempt=%lu", (unsigned long)session_echo, (unsigned long)m_sessionId, (unsigned long)attempt); @@ -1463,9 +1948,9 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) m_gd32Role = pload[4]; m_localCredit = pload[5]; + m_remoteCredit = hdr.credit; m_v2Active = true; m_stats.resyncs++; - /* 等 BOOT 时 role=APP 的成功极吵:仅 role 匹配 hint、或 hint=any、或已是 boot 时 INFO */ if (role_hint == 0U || m_gd32Role == role_hint || m_gd32Role == V2_ROLE_BOOT) { DEBUG_INFO("SPI", "HELLO success: session=0x%08lX role=%u credit=%u attempt=%lu", (unsigned long)m_sessionId, m_gd32Role, m_localCredit, diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index cf9306a..cf69299 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -19,6 +19,8 @@ #include "driver/gpio.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "freertos/queue.h" +#include "freertos/task.h" #include "spi_link_v2.h" // SPI 配置常量 @@ -110,6 +112,33 @@ public: /** @brief 读取对端最新上报的 CREDIT(0 表示对端忙,应 POLL 等待而非重发)。 */ uint8_t remoteCredit() const { return m_remoteCredit; } + /** + * @brief OTA 中继期间禁止 worker 按铃读。 + * + * PB6 在 OTA 下也会因 WAIT/ACK 拉低。若 worker 在 job 间隙把 RESPONSE + * 当 EVENT 偷读,业务 poll 只能空等 → 约 10s 后 DATA 重发 → GD32 DUP-SEQ, + * 表现为刷写断断续续。中继开始设 true,结束/复位设 false。 + */ + void setSuppressRingService(bool suppress); + + /** + * @brief OTA 业务层确认当前请求已成功(结构化 ACK 校验通过)。 + * 在 SPI 头 ack_seq 偶发失配但仍接受业务 ACK 时补齐 m_txSeq,避免后续包卡死。 + */ + void confirmTxSeqAccepted(); + + /** + * @brief L4 统一请求入口:经 SPI worker 串行执行(send+poll)。 + */ + esp_err_t transact(uint8_t type, const uint8_t *payload, size_t payload_len, + uint8_t *resp_type, uint8_t *resp_payload, + size_t resp_max, size_t *resp_len, uint32_t timeout_ms); + + /** + * @brief L4 取出 GD32 EVENT 帧(worker 已读入事件队列)。 + */ + bool takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len); + // ============================================================ // SPI Link v2 链路层(阶段1:帧格式 + 状态成员就位) // ============================================================ @@ -138,6 +167,9 @@ public: /** @brief 获取链路统计快照(线程安全,持锁读取)。 */ SpiLinkStats linkStats(); + /** @brief L4 链路状态(linkStats 别名)。 */ + SpiLinkStats linkStatus() { return linkStats(); } + /** @brief 发起 HELLO 握手建立 v2 session(SPEC §5.1)。 * 生成随机非零 SESSION_ID,发送 LINK_HELLO,等待 HELLO_ACK。 * 成功后 m_v2Active=true,序号归零。 @@ -251,14 +283,71 @@ private: }; SpiLinkState m_linkState; - /** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,SPI 任务消费。 */ + /** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,仅 SPI worker 消费。 */ volatile bool m_gd32RequestPending = false; - /** SPI 任务句柄(用于 ISR 唤醒)。 */ + /** OTA 中继时为 true:worker 不 serviceRing,避免偷读 OTA ACK/WAIT。 */ + volatile bool m_suppressRingService = false; + /** SPI worker 任务句柄(ISR 唯一通知目标)。 */ TaskHandle_t m_spiTaskHandle = NULL; - /** GD32 按铃请求帧缓冲区。 */ + TaskHandle_t m_workerTask = NULL; + QueueHandle_t m_jobQueue = NULL; + /** 事件计数信号量(worker push / takeEvent pop)。 */ + SemaphoreHandle_t m_eventSem = NULL; + + /** GD32 按铃请求帧缓冲区(兼容 takeRingRx 单槽)。 */ uint8_t m_ringRxBuf[SPI_OTA_FRAME_MAX] = {}; size_t m_ringRxLen = 0; - uint8_t m_ringRxType = 0; ///< GD32 按铃请求帧 TYPE(checkGd32Request 填充) + uint8_t m_ringRxType = 0; + + // ── L4 多槽事件队列(worker 写入,业务 takeEvent 读出)── + static constexpr uint8_t kEventSlots = 4U; + struct EventSlot { + uint8_t type; + uint16_t len; + uint8_t data[SPI_OTA_FRAME_MAX]; + }; + EventSlot m_eventQ[kEventSlots] = {}; + uint8_t m_eventHead = 0; + uint8_t m_eventTail = 0; + uint8_t m_eventCount = 0; + + // ── L4 作业提交(调用方栈上 Job + StaticSemaphore,worker 执行)── + enum class JobKind : uint8_t { + Transact = 0, + Poll, + Hello, + EnterBoot, + Ping, + WaitBoot, + ServiceRing, + }; + struct Job { + JobKind kind = JobKind::Transact; + uint32_t timeout_ms = 0; + uint8_t type = 0; + uint8_t expected_type = 0xFFU; + uint8_t role_hint = 0; + size_t payload_len = 0; + uint8_t payload[SPI_OTA_FRAME_MAX] = {}; + uint8_t *out_type = nullptr; + uint8_t *out_payload = nullptr; + size_t out_max = 0; + size_t *out_len = nullptr; + uint32_t *out_uptime = nullptr; + gd32_version_t *out_version = nullptr; + esp_err_t result = ESP_FAIL; + SemaphoreHandle_t done = nullptr; + }; + static constexpr UBaseType_t kJobQueueLen = 6; + + bool onWorker() const; + esp_err_t submitJob(Job &job); + void executeJob(Job &job); + static void workerEntry(void *arg); + void workerLoop(); + esp_err_t serviceRingOnce(); + bool pushEvent(uint8_t type, const uint8_t *payload, size_t len); + bool popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len); // ── 帧内同步状态机 ── volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口) @@ -272,6 +361,8 @@ private: volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号 volatile uint8_t m_gd32Role = 0; ///< GD32 角色(0=unknown, 1=app, 2=boot) volatile bool m_v2Active = false; ///< v2 session 是否激活 + /** 最近一次 otaFindFrame 解析到的 RESPONSE 是否与 m_lastSentSeq 归属匹配 */ + volatile bool m_lastRxAckMatched = false; SpiLinkStats m_stats = {}; ///< 链路统计 // RAII 锁守卫 @@ -321,6 +412,26 @@ private: esp_err_t transferClocked(const uint8_t *tx_buf, size_t tx_len, uint8_t *rx_buf, size_t rx_len, uint32_t timeout_ms = 0U); + + /** + * @brief 底层传输,支持 SPI 事务 flags(如 SPI_TRANS_CS_KEEP_ACTIVE)。 + * 多段同 CS 变长读时:先 spi_device_acquire_bus,段间 KEEP_ACTIVE,末段清 flag 后 release_bus。 + */ + esp_err_t transferClockedFlags(const uint8_t *tx_buf, size_t tx_len, + uint8_t *rx_buf, size_t rx_len, + uint32_t timeout_ms, uint32_t trans_flags); + + /** + * @brief L3 变长读:同 CS 先读 16B Header,再按 LEN 读 payload+CRC。 + * 调用者须已持锁;仅在 DATA_READY 已低(有数据)时调用,禁止盲读。 + * @param out_frame 完整帧输出(含头+payload+CRC) + * @param out_max 输出缓冲容量 + * @param out_len 实际帧长 + * @param timeout_ms 单段 SPI 超时 + */ + esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len, + uint32_t timeout_ms); + // 无锁内部版本(供已持锁的公共方法调用,避免递归死锁) /** * @brief 无锁版本的 OTA 响应轮询(调用者须已持有锁)。 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 80ad3f3..688393d 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -14,8 +14,19 @@ CONFIG_DCU_OTA_PACKET_INTERVAL_MS=3 # 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE) # 提到 12 留足缓冲,配合 can_feedback_task 退避从根本上避免 socket 饥荒 CONFIG_LWIP_MAX_SOCKETS=12 -CONFIG_LWIP_TCP_WND_DEFAULT=11480 -CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 +# C3 堆紧:WiFi+BLE 双栈时 TCP 窗口过大 → OTA 收流 free 压到 ~5KB → 卡半/堆爆。 +# 6144=6KB≈4*MSS:优先压峰值堆;OTA 瓶颈多在 Flash 擦写,吞吐损失通常可接受。 +# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。 +CONFIG_LWIP_TCP_WND_DEFAULT=6144 +CONFIG_LWIP_TCP_SND_BUF_DEFAULT=6144 +# 乱序队列 pbuf:4→2,少占堆;HTTP 顺序上传基本不需要大 OOSEQ +CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=2 + +# WiFi 动态缓冲(C3 + NimBLE 常驻):32 过大,OTA 峰值与 TCP 窗口叠加易堆碎。 +# 16/16/6 对单连接 OTA 足够,省出 ~10–20KB 峰值给 Flash 写路径。 +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16 +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16 # FreeRTOS runtime stats -- /system/status CPU usage CONFIG_FREERTOS_USE_TRACE_FACILITY=y @@ -39,10 +50,12 @@ CONFIG_BT_NIMBLE_HR_SERVICE=n CONFIG_BT_NIMBLE_BAS_SERVICE=n CONFIG_BT_NIMBLE_DIS_SERVICE=n -# ── NimBLE: OTA 窗口突发接收容量(恢复 7feef70 对应的 IDF 6.0 默认值)── +# ── NimBLE: 缓冲略收(WiFi OTA 优先;BLE OTA 单连接仍够用)── +# 原 24/24 为 BLE 突发优化,但与 WiFi OTA 抢堆导致“卡半 + free~5KB”。 +# 12/16:BLE 吞吐略降,WiFi 峰值堆更稳。 CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12 -CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=24 -CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=24 +CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12 +CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=16 # Event buffers: 30→16 (省 ~1 KB) CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16 # Prepared write entries: 64→16 (省 ~1 KB) diff --git a/tools/app.js b/tools/app.js index bea8c07..b900c3b 100644 --- a/tools/app.js +++ b/tools/app.js @@ -129,11 +129,103 @@ function otaSessionTransition(newState, reason) { else if (newState !== OtaSessionState.VERIFYING) isEsp32Rebooting = false; } -/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */ -function otaSessionReset() { +/** + * @description 清掉本会话残留 UI/标志(失败、超时、中止、新会话开始前都要调)。 + * 不改变 otaSessionState(由调用方负责 FAILED/IDLE 转换)。 + * @param {object} [opts] + * @param {boolean} [opts.keepErrorStages] true=保留错误阶段样式;false=节点复位到待机 + * @param {string} [opts.statusText] 状态栏文案;null 表示「待机」 + * @param {boolean} [opts.errorStyle] 状态栏是否红色 + */ +function clearOtaSessionResiduals(opts) { + opts = opts || {}; stopRelayProgressWatch(); + stopUploadWaitHeartbeat(); + gd32CompleteAnnounced = false; relayWatchLastSeq = -1; + gd32RelaySessionLive = false; + lastSysStateCode = -1; + lastRelayState = 0; + isEsp32Rebooting = false; + rebootObservedDown = false; + rebootStartTime = 0; + isUploading = false; + + /* 先摘掉引用再 abort,避免 abort 事件回调再次进入 cleanup 递归 */ + var xh = activeXhr; + activeXhr = null; + if (xh) { + try { xh.abort(); } catch (_) {} + } + + // 校验面板 + var verifyInfo = document.getElementById('stageVerifyInfo'); + if (verifyInfo) verifyInfo.style.display = 'none'; + var sviFront = document.getElementById('sviFrontSha'); + var sviDevice = document.getElementById('sviDeviceSha'); + var sviResult = document.getElementById('sviResult'); + if (sviFront) sviFront.textContent = '-'; + if (sviDevice) sviDevice.textContent = '-'; + if (sviResult) { + sviResult.textContent = '-'; + sviResult.className = 'svi-val'; + } + + // 阶段节点 + if (!opts.keepErrorStages) { + ['es1','es2','es3','es4','es5','es6','s1','s2','s3','s4','s5','s6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + ['esd1','esd2','esd3','esd4','esd5','esd6','sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); + esp32OtaPhase = 0; + } + + // 顶部步骤 pill + ['ppEsp32', 'ppGd32Ota'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.classList.remove('active', 'done'); + }); + var fill = document.getElementById('progressFill'); + if (fill) fill.classList.remove('done'); + activePhase = null; + + // 状态栏 + var sb = document.getElementById('stageStatusBar'); + var st = document.getElementById('stageStatusText'); + if (sb) { + sb.className = opts.errorStyle ? 'stage-status-bar err' : 'stage-status-bar'; + } + if (st) { + st.textContent = (opts.statusText !== undefined && opts.statusText !== null) + ? opts.statusText + : '待机'; + } + + // 设备侧缓存展示:失败后不要继续显示“已满缓存像成功” + var mOtaCache = document.getElementById('mOtaCache'); + if (mOtaCache && opts.clearCacheDisplay) { + mOtaCache.textContent = '0 / 216 KB'; + mOtaCache.className = 'v'; + } + var sOtaStored = document.getElementById('sOtaStored'); + if (sOtaStored && opts.clearCacheDisplay) { + sOtaStored.textContent = '--'; + } +} + +/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */ +function otaSessionReset() { + clearOtaSessionResiduals({ + keepErrorStages: false, + statusText: '待机', + errorStyle: false, + clearCacheDisplay: false + }); if (otaSessionState !== OtaSessionState.IDLE) { otaSessionState = OtaSessionState.IDLE; otaSessionTarget = null; @@ -141,6 +233,7 @@ function otaSessionReset() { currentOtaTarget = null; isUploading = false; } + activeUploadChip = null; } // ==================== 折叠面板 ==================== @@ -237,63 +330,48 @@ function updateEsp32StagesError() { * @param {string} reason 失败原因描述 */ function cleanupAfterOtaFailure(reason) { - stopRelayProgressWatch(); - // 0. 状态机:转入 FAILED(先于标志重置,确保 otaSessionActive() 即刻返回 false) + var chip = activeUploadChip || otaSessionTarget || currentOtaTarget || window.flashTarget || 'esp32'; + + // 0. 状态机:转入 FAILED(先于标志重置) otaSessionTransition(OtaSessionState.FAILED, reason); - // 1. 重置上传锁 - isUploading = false; - stopUploadWaitHeartbeat(); + // 1. 清掉本会话全部残留标志 / 定时器 / 校验面板 / 旧 progress 语义 + clearOtaSessionResiduals({ + keepErrorStages: true, + statusText: reason || '失败', + errorStyle: true, + clearCacheDisplay: true + }); - // 2. 中止正在进行的 XHR(如有) - if (activeXhr) { - try { activeXhr.abort(); } catch (_) {} - activeXhr = null; - } - - // 3. 标记阶段指示器为错误态 - if (currentOtaTarget === 'esp32') { + // 2. 阶段指示器标错 + if (chip === 'esp32') { updateEsp32StagesError(); - } else if (currentOtaTarget === 'gd32') { + } else { updateStagesFromRelayState(255); } - // 4. 状态栏标红 - var sb = document.getElementById('stageStatusBar'); - var st = document.getElementById('stageStatusText'); - if (sb) sb.className = 'stage-status-bar err'; - if (st) st.textContent = reason || '失败'; - - // 5. 进度条显示失败信息 + // 3. 进度条失败文案(clear 已把 fill 的 done 去掉) setProgress(0, reason || '刷写失败'); - - // 6. 重置活跃阶段 setActivePhase(null); - - // 7. 清除 OTA 目标(下次操作时重新设置) currentOtaTarget = null; - // 8. 隐藏校验面板 - var verifyInfo = document.getElementById('stageVerifyInfo'); - if (verifyInfo) verifyInfo.style.display = 'none'; - - // 9. 恢复上传按钮(基于实际上传的芯片,而非当前 UI 选中的标签) - var chip = activeUploadChip || otaSessionTarget || window.flashTarget || 'esp32'; + // 4. 恢复上传按钮 var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn'; var btn = document.getElementById(btnId); - if (btn && uploadBtnOrigText) { + if (btn) { btn.disabled = false; - btn.textContent = uploadBtnOrigText; + if (uploadBtnOrigText) btn.textContent = uploadBtnOrigText; } activeUploadChip = null; + otaSessionTarget = null; + uploadBtnOrigText = ''; updateUploadButtonLabel(); - // 10. 恢复轮询 + // 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位) resumePollingAfterUpload(); - // 11. 日志 - addLog('E', 'OTA', '刷写流程已中止: ' + (reason || '未知原因')); + addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因')); } /** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */ @@ -928,7 +1006,15 @@ function updateSystemStatusUi(data) { if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); } if (isGd32Error && (currentOtaTarget === 'gd32' || otaSessionTarget === 'gd32')) { - cleanupAfterOtaFailure('GD32 OTA 失败'); + var failDetail = 'GD32 OTA 失败'; + if (relayTotal > 0) { + failDetail += '(已转发 ' + relaySeq + '/' + relayTotal + + ',relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')'; + } else { + failDetail += '(relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')'; + } + cleanupAfterOtaFailure(failDetail); + pollLogs(); } else if (isGd32Error) { // 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理 updateStagesFromRelayState(255); @@ -936,52 +1022,55 @@ function updateSystemStatusUi(data) { var stErr = document.getElementById('stageStatusText'); if (sbErr) sbErr.className = 'stage-status-bar err'; if (stErr) stErr.textContent = 'GD32 错误(待机)'; - } else if (isGd32Complete || rs === 5 || - (gd32AppAlive && (sysState === 'GD32_COMPLETE' || sysStateCode === 4)) || - /* 包已全部转发且 APP 已起来:即使日志停在 SEND_SIZE 也算完成 */ - (gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && - (isGd32Relaying || otaSessionState === OtaSessionState.RELAYING || rs >= 3))) { - // 已完成:只进一次 UI/toast,后续轮询直接忽略 - if (!gd32CompleteAnnounced && otaSessionState !== OtaSessionState.DONE) { - markGd32OtaUiComplete('GD32 刷写完成'); + } else if (otaSessionState === OtaSessionState.UPLOADING) { + /* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */ + } else if (otaSessionState === OtaSessionState.RELAYING) { + /* 必须先看到本会话真实中继(HANDSHAKE~FINISHING),才允许 DONE; + * 禁止一进 RELAYING 就吃掉上一次的 rs=5 / app_alive。 */ + if (isGd32Relaying || (rs >= 1 && rs <= 4)) { + gd32RelaySessionLive = true; } - } else if (isGd32Storing || (total > 0 && stored < total && !isUploading)) { + if (gd32RelaySessionLive && + (isGd32Complete || rs === 5 || + (gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) { + if (!gd32CompleteAnnounced) { + markGd32OtaUiComplete('GD32 刷写完成'); + } + } else if (rs === 1) { + // HANDSHAKING + setActivePhase('gd32Ota'); + setProgress(50, '握手与认证中...'); + } else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5)) { + // TRANSFERRING + setActivePhase('gd32Ota'); + var relayOverall = 60 + Math.round(relayProgress * 0.30); + var relayLabel = relayTotal > 0 + ? ('已转发 ' + relaySeq + ' / ' + relayTotal + ' 包 (' + relayProgress + '%)') + : '数据包传输中...'; + setProgress(relayOverall, relayLabel); + } else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) { + // SIZE_NEG + setActivePhase('gd32Ota'); + setProgress(55, relayTotal > 0 + ? ('大小协商中(共 ' + relayTotal + ' 包)') + : '固件大小协商中'); + } else if (rs === 4) { + setActivePhase('gd32Ota'); + if (!gd32CompleteAnnounced) { + setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...'); + } + } else if (!gd32RelaySessionLive) { + setActivePhase('gd32Ota'); + setProgress(48, '已触发中继,等待设备进入握手…'); + } + } else if (isGd32Storing || (total > 0 && stored < total && !isUploading && + otaSessionState !== OtaSessionState.DONE && + otaSessionState !== OtaSessionState.IDLE)) { setActivePhase('gd32Ota'); var storeOverall = Math.min(45, Math.round(storageProgress * 0.45)); if (!isUploading) { setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total) + ''); } - } else if (rs === 1) { - // HANDSHAKING: 握手 + 挑战认证 - setActivePhase('gd32Ota'); - setProgress(50, '握手与认证中...'); - } else if (rs === 3 || (relayTotal > 0 && relaySeq > 0)) { - // TRANSFERRING:以 relay_seq 为准(优先于 rs===2,避免 total 预写后一直显示 0/N) - setActivePhase('gd32Ota'); - var relayOverall = 60 + Math.round(relayProgress * 0.30); - var relayLabel = relayTotal > 0 - ? ('已转发 ' + relaySeq + ' / ' + relayTotal + ' 包 (' + relayProgress + '%)') - : '数据包传输中...'; - setProgress(relayOverall, relayLabel); - } else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) { - // SIZE_NEG:只说协商中,不写「0/N」(total 会提前写入 status,易被误认为卡在 0 包) - setActivePhase('gd32Ota'); - setProgress(55, relayTotal > 0 - ? ('大小协商中(共 ' + relayTotal + ' 包)') - : '固件大小协商中'); - } else if (rs === 4) { - // FINISHING: 结束OTA + APP存活验证 - setActivePhase('gd32Ota'); - if (gd32AppAlive) { - if (!gd32CompleteAnnounced && otaSessionState !== OtaSessionState.DONE) { - markGd32OtaUiComplete('GD32 刷写完成'); - } - } else if (!gd32CompleteAnnounced) { - setProgress(95, '收尾中,等待 APP 启动...'); - } - } else if (total > 0 && stored >= total && !isGd32Relaying && otaSessionState !== OtaSessionState.RELAYING) { - setActivePhase('gd32Ota'); - setProgress(45, '上传完成,等待自动启动 GD32 OTA...'); } } /** @description 测试与 ESP32 的连接(GET /status)。 */ @@ -1405,6 +1494,13 @@ async function startUpload(chip) { // ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true) otaSessionTransition(OtaSessionState.UPLOADING); activeUploadChip = chip; + /* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */ + gd32CompleteAnnounced = false; + relayWatchLastSeq = -1; + gd32RelaySessionLive = false; + if (chip === 'esp32') { + updateEsp32StagesFromPhase(1); + } // 跨通道互斥前置检查(含 BLE 取消后的瞬态重试) var _probeReady = false; for (var _pi = 0; _pi < 4; _pi++) { @@ -1596,6 +1692,21 @@ async function startUpload(chip) { uploadBtn.textContent = uploadBtnOrigText; updateUploadButtonLabel(); if (xhr.status === 200) { + /* 校验 body:避免空响应/网关误 200 被当成上传成功 */ + var bodyTxt = (xhr.responseText || '').trim(); + var bodyOk = false; + try { + var bj = bodyTxt ? JSON.parse(bodyTxt) : null; + bodyOk = !!(bj && (bj.status === 'ok' || bj.target === chip)); + } catch (_pe) { + bodyOk = bodyTxt.indexOf('"status"') >= 0 && bodyTxt.indexOf('ok') >= 0; + } + if (!bodyOk) { + addLog('E', '上传', 'HTTP 200 但响应异常: ' + (bodyTxt.slice(0, 120) || '(空)')); + showToast('上传响应异常,请重试', 'error'); + cleanupAfterOtaFailure('响应异常'); + return; + } // 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复 if (chip === 'esp32') { addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…'); @@ -1757,12 +1868,19 @@ var relayWatchLastUiLogAt = 0; var relayWatchInFlight = false; var gd32CompleteAnnounced = false; var relayWatchLastSeq = -1; +/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */ +var gd32RelaySessionLive = false; function markGd32OtaUiComplete(msg) { // 已公告过:禁止再改 className / setProgress / toast,否则每 400ms 重触发动效会「疯狂跳 ✓」 if (gd32CompleteAnnounced) { return; } + /* 仅本会话 RELAYING/FAILED 可宣布完成;IDLE/UPLOADING 时 status 里的旧 relay 残留一律忽略 */ + if (otaSessionState !== OtaSessionState.RELAYING && + otaSessionState !== OtaSessionState.FAILED) { + return; + } gd32CompleteAnnounced = true; stopRelayProgressWatch(); @@ -1824,7 +1942,12 @@ function startRelayProgressWatch() { var alive = !!o.gd32_app_alive; var rseq = Number(o.relay_seq) || 0; var rtot = Number(o.relay_total_packets) || 0; - if (code === 4 || rstate === 5 || (alive && rtot > 0 && rseq >= rtot && rstate >= 3)) { + if (rstate >= 1 && rstate <= 4) { + gd32RelaySessionLive = true; + } + if (gd32RelaySessionLive && + (code === 4 || rstate === 5 || + (alive && rtot > 0 && rseq >= rtot && rstate >= 3))) { markGd32OtaUiComplete('GD32 刷写完成'); pollLogs(); return; @@ -2060,6 +2183,11 @@ async function pollStatus() { consecutivePollFailures = 0; addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); showToast('设备已恢复在线', 'success'); + // 设备离线后恢复在线,清理残留的 OTA 会话状态 + if (otaSessionState !== OtaSessionState.IDLE) { + otaSessionReset(); + addLog('I', '系统', '已清理 OTA 会话状态'); + } } if (isEsp32Rebooting) { var rbElapsed = Math.round((Date.now() - rebootStartTime) / 1000); diff --git a/tools/index.html b/tools/index.html index fbf0555..dae14be 100644 --- a/tools/index.html +++ b/tools/index.html @@ -427,7 +427,7 @@ 操作成功 - +