diff --git a/.trae/documents/fix-ota-fetch-error-misjudgment.md b/.trae/documents/fix-ota-fetch-error-misjudgment.md new file mode 100644 index 0000000..ceb4347 --- /dev/null +++ b/.trae/documents/fix-ota-fetch-error-misjudgment.md @@ -0,0 +1,121 @@ +# 修复 OTA 校验请求异常误判为"设备已重启"Plan + +## Summary +当前端在 OTA 校验/应用阶段(`POST /ota/update`)发生 fetch 网络异常(如 "Failed to fetch")时,代码无条件假设"设备已重启"并进入 60 秒重启等待。但服务端实际要先完成 `finalizeForReboot`(SHA 计算可能耗时数十秒)才发送响应、再延迟 500ms 重启。fetch 异常发生时设备可能仍在 finalize、可能崩溃、也可能只是网络抖动——并非一定是正常重启。这种误判掩盖了真实失败原因,让用户长时间等待一个不会发生的"重启完成"。 + +需将 catch 块改为:**如实报告请求失败,不臆测设备状态,由后续 `pollStatus` 探测设备真实状态**。 + +## Current State Analysis + +### 问题代码位置 +- `tools/app.js` L1616-1623:`verifyEsp32Firmware()` 的 catch 块 +- `tools/app.js` L1676-1681:`applyEsp32Firmware()` 的 catch 块(相同误判模式) + +### 当前 catch 块行为(两处一致) +```javascript +} catch (e) { + addLog('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message); + // 设备在收到请求后即会重启,网络中断属于正常现象,直接进入重启等待 + showVerifyActions('none'); + isEsp32Rebooting = true; // ← 无条件假设重启 + updateEsp32StagesFromPhase(5); // ← UI 切到"重启中" + rebootStartTime = Date.now(); +} +``` + +### 服务端实际时序(`http_handlers.cpp` L137-153) +1. 收到 `/ota/update` → 调用 `finalizeForReboot()`(含 `computeShaFromPartition` 读回整分区算 SHA,大固件耗时数秒到十几秒) +2. finalize 成功 → 发送 HTTP 响应 `{"status":"ok",...}` +3. `vTaskDelay(500ms)` → `restartEsp32()` → `esp_restart()` + +**只有第 2 步成功发送响应后设备才会重启**。fetch 异常说明响应未到达前端,设备大概率未正常重启。 + +### 误判的连锁影响 +- 设置 `isEsp32Rebooting = true` → `pollStatus`(L1953-1963)进入 60 秒"重启中"等待分支 +- 跳过 L1939 的"5 次失败诊断"逻辑,不会降速、不会显示"未连接" +- 60 秒后才报"ESP32 重启超时"——用户白等一分钟,且看不到真实失败原因 +- 若设备实际仍在 finalize 或已崩溃,`/system/status` 也无法响应,但 UI 一直显示"重启中" + +### 对照:上传阶段的正确处理(L1532-1537) +```javascript +xhr.addEventListener('error', function() { + cleanupAfterOtaFailure('网络错误'); +}); +``` +上传阶段网络错误会调用 `cleanupAfterOtaFailure`,如实标记失败。校验/应用阶段应保持一致。 + +## Proposed Changes + +### 文件:`g:\Robot_v2\DCU\ESP32_COMM\tools\app.js` + +#### 改动 1:`verifyEsp32Firmware()` catch 块(L1616-1623) + +**What**: 移除"假设设备已重启"的逻辑,改为如实报告请求失败,并启动轮询探测设备真实状态。 + +**Why**: fetch 异常只代表请求未成功完成,不能推断设备状态。设备可能仍在 finalize、可能已崩溃、可能网络抖动。应由 `pollStatus` 探测后给出准确结论。 + +**How**: 替换为: +```javascript +} catch (e) { + // fetch 异常仅代表请求未完成,不能推断设备已重启: + // 设备可能仍在 finalize(SHA 计算耗时)、可能已崩溃、可能网络抖动。 + // 如实报告失败,由 pollStatus 探测设备真实状态。 + addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)'); + showToast('应用请求失败: ' + e.message, 'error'); + var resultEl = document.getElementById('sviResult'); + if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; } + showVerifyActions('retry'); + cleanupAfterOtaFailure('应用请求失败'); +} +``` + +要点: +- 日志级别 W → E(请求失败是错误,不是警告) +- 日志文案去掉"设备可能已重启"臆测,改为"设备状态未知,等待轮询探测" +- 不再设置 `isEsp32Rebooting = true`,让 `pollStatus` 走正常失败诊断路径 +- 调用 `cleanupAfterOtaFailure` 标记失败态(与上传阶段错误处理一致) +- 显示 retry 按钮(`showVerifyActions('retry')`)让用户可重试 + +#### 改动 2:`applyEsp32Firmware()` catch 块(L1676-1681) + +**What**: 同样的修复,移除重启假设。 + +**Why**: 与改动 1 相同的误判逻辑,需保持一致。 + +**How**: 替换为: +```javascript +} catch (e) { + // fetch 异常仅代表请求未完成,不能推断设备已重启。 + addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)'); + showToast('应用请求失败: ' + e.message, 'error'); + cleanupAfterOtaFailure('应用请求失败'); +} +``` + +要点: +- 日志级别 W → E +- 不再设置 `isEsp32Rebooting = true` +- 调用 `cleanupAfterOtaFailure` 标记失败态 + +## Assumptions & Decisions + +1. **不保留任何"假设重启"的快速路径**:即使用户确实遇到设备正常重启的场景,`pollStatus` 也会在设备恢复后正确识别(L1912-1917 的 `isEsp32Rebooting` 分支虽然不再被这两处设置,但其他正常路径如 `data.status === 'ok'` 仍会设置)。去掉误判路径不会影响正常重启的检测。 + +2. **依赖 `pollStatus` 探测真实状态**:`pollStatus` 已有完善的设备状态探测逻辑(L1939 的 5 次失败诊断、L1964 的 `lastDeviceBusy` 判断)。fetch 异常后让轮询接管,能在设备真重启时通过 `lastSysStateCode === 7`(ESP32_REBOOTING)或 `lastDeviceBusy` 保持 1s 轮询,在设备真断联时降速诊断。 + +3. **`showVerifyActions('retry')`**:调研报告提到 L179-182 的 `showVerifyActions('retry')` 函数体可能已空(按钮移除)。即使按钮不存在,调用也是安全的(DOM 查询返回 null 时不会报错)。保留调用以备未来恢复按钮。 + +4. **不修改服务端**:服务端的 finalize→响应→重启时序是合理的(先响应再重启,确保前端收到结果)。问题在前端错误推断。 + +5. **不改动 `verifyEsp32Firmware()` 成功路径(L1583-1606)的 `isEsp32Rebooting = true`**:成功路径下设备确实会重启(服务端已发送 ok 响应并执行 restart),此时设置重启等待是正确的。 + +## Verification + +1. **代码审查**:确认两处 catch 块均已移除 `isEsp32Rebooting = true`、`rebootStartTime = Date.now()`、`updateEsp32StagesFromPhase(5)`,改为调用 `cleanupAfterOtaFailure`。 + +2. **成功路径不受影响**:确认 L1583-1606(校验通过)和 L1663-1669(应用成功)的成功路径仍正常设置 `isEsp32Rebooting`,设备正常重启时前端仍能正确显示重启等待。 + +3. **运行时验证**: + - 正常 OTA 升级:校验通过 → 设备重启 → `pollStatus` 检测到设备恢复 → 显示"ESP32 已恢复在线"(验证成功路径未受影响) + - 模拟网络中断(如 OTA 期间拔网线/关 WiFi):fetch 异常 → 日志显示"应用请求失败: ...(设备状态未知,等待轮询探测)" → `pollStatus` 走失败诊断路径 → 不再显示误导性的"重启中 Xs" + - 模拟设备 finalize 阶段崩溃:fetch 异常 → 同上 → `pollStatus` 探测到设备无响应 → 降速诊断 → 用户看到"未连接"而非"重启中" diff --git a/.trae/specs/fix-ble-ota-spurious-ack/checklist.md b/.trae/specs/fix-ble-ota-spurious-ack/checklist.md new file mode 100644 index 0000000..6d20086 --- /dev/null +++ b/.trae/specs/fix-ble-ota-spurious-ack/checklist.md @@ -0,0 +1,13 @@ +# Checklist + +- [x] ESP32 路径:`process_esp32_packet()` 中窗口边界 ACK 触发条件包含 `progressMade` 检查 +- [x] ESP32 路径:重复包(seq < expected)到达窗口边界时不发送 ACK +- [x] ESP32 路径:乱序缓存包(seq > expected,存入 reorder buffer)到达窗口边界时不发送 ACK +- [x] ESP32 路径:按序包写入 + flush 后 `getReceived()` 增长时,正常发送 ACK +- [x] ESP32 路径:`transferComplete` 和 `isLastPacket` 的 ACK 仍在实际写入时触发 +- [x] GD32 路径:重复包(seq <= last_seq)不发送任何 ACK,仅跳过 +- [x] GD32 路径:重复包的 DEBUG_DBG 日志保留 +- [x] 发送端:首轮窗口 ACK 超时为 5000ms +- [x] 发送端:续发重试路径 ACK 超时保持 3000ms +- [x] 编译通过:ESP32 固件编译无错误(代码语法验证通过,ESP-IDF 环境需用户本地编译确认) +- [ ] 逻辑验证:BLE OTA 刷写 ESP32 时不再出现每窗口"丢失 32 包"的稳定模式(需用户运行时测试) diff --git a/.trae/specs/fix-ble-ota-spurious-ack/spec.md b/.trae/specs/fix-ble-ota-spurious-ack/spec.md new file mode 100644 index 0000000..2e1ac6a --- /dev/null +++ b/.trae/specs/fix-ble-ota-spurious-ack/spec.md @@ -0,0 +1,52 @@ +# 修复 BLE OTA 虚假 ACK 导致刷写极慢 Spec + +## Why +BLE 刷写 ESP32 自身固件时速度极慢,每个 32 包窗口都报"丢失 32 包"并触发重发。根因是 `process_esp32_packet()` 中 ACK 触发条件仅检查 `(seq+1)%32==0`,未区分包是否真正写入 flash。重复包(来自发送端超时重发)在窗口边界会触发虚假 ACK,携带陈旧的 `getReceived()` 值,被发送端误判为丢包,导致每个窗口都产生不必要的重发。 + +## What Changes +- **ESP32 路径** (`process_esp32_packet`):在调用 `esp32_write_ordered()` 前后对比 `getReceived()`,仅在实际写入新字节时才触发窗口边界 ACK,避免重复包/乱序缓存包发送陈旧 ACK +- **GD32 路径** (`process_gd32_packet`):移除重复包在窗口边界的 ACK 发送(L1233-1235),重复包静默跳过,发送端已持有上次有效 ACK 的进度 +- **ACK 超时调整**:发送端首次窗口 ACK 超时从 3000ms 提升至 5000ms,适应设备端 flash 写入速度(实测约 3.4s/窗口),避免首轮超时产生重复包 + +## Impact +- Affected code: + - `main/server/ble_ota.cpp` — `process_esp32_packet()` ACK 触发逻辑 (L1146-1172) + - `main/server/ble_ota.cpp` — `process_gd32_packet()` 重复包 ACK 逻辑 (L1229-1236) + - `tools/ble_ota.js` — `uploadFirmware()` ACK 超时参数 (L1305, L1370) + +## ADDED Requirements + +### Requirement: ACK 触发需基于实际写入进度 +ESP32 OTA 接收端在处理数据包时,窗口边界 ACK 的触发必须以"实际写入 flash 的字节数增长"为前提,而非仅依据包序号是否落在窗口边界。 + +#### Scenario: 重复包到达窗口边界 +- **WHEN** 发送端因超时重发,重复包(seq < expected)到达设备端,且该 seq 处于窗口边界((seq+1)%32==0) +- **THEN** 设备端不发送 ACK(该包未产生新的 flash 写入) + +#### Scenario: 乱序缓存包到达窗口边界 +- **WHEN** 乱序包(seq > expected)被缓存到 reorder buffer,且该 seq 处于窗口边界 +- **THEN** 设备端不发送 ACK(该包未产生 flash 写入,仅暂存) + +#### Scenario: 正常写入触发 flush 跨越窗口边界 +- **WHEN** 按序包到达后触发 reorder buffer flush,`getReceived()` 增长 +- **THEN** 设备端正常发送 ACK,携带最新的 `getReceived()` 值 + +## MODIFIED Requirements + +### Requirement: GD32 重复包处理 +GD32 OTA 接收端遇到重复包(seq <= last_seq)时,静默跳过,不发送 ACK。发送端已通过上一窗口的有效 ACK 获知设备端进度,重复包的 ACK 只会干扰当前窗口的 waitForAck 判断。 + +#### Scenario: 重复包到达(GD32) +- **WHEN** GD32 OTA 收到重复包(seq <= last_seq) +- **THEN** 跳过写入,不发送任何 ACK,继续处理队列中的后续包 + +### Requirement: BLE OTA 发送端 ACK 超时 +发送端首轮窗口 ACK 等待超时从 3000ms 调整为 5000ms,以适应设备端 flash 写入速度(实测约 3.4s/32 包窗口),减少首轮不必要的超时重发。续发重试路径的超时保持 3000ms 不变(此时设备端已在处理,ACK 会更快返回)。 + +#### Scenario: 首轮窗口 ACK 等待 +- **WHEN** 发送端发送一个新窗口的 32 个包并等待 ACK +- **THEN** 等待超时为 5000ms,若设备端在 5s 内完成处理并返回 ACK,无需重发 + +#### Scenario: 续发重试 ACK 等待 +- **WHEN** 发送端因丢包检测而续发部分包并等待 ACK +- **THEN** 等待超时保持 3000ms diff --git a/.trae/specs/fix-ble-ota-spurious-ack/tasks.md b/.trae/specs/fix-ble-ota-spurious-ack/tasks.md new file mode 100644 index 0000000..ef5434f --- /dev/null +++ b/.trae/specs/fix-ble-ota-spurious-ack/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +- [x] Task 1: 修复 ESP32 路径虚假 ACK(process_esp32_packet) + - [x] SubTask 1.1: 在 `process_esp32_packet()` 中,调用 `esp32_write_ordered()` 前记录 `beforeReceived = getReceived()` + - [x] SubTask 1.2: 调用后记录 `afterReceived = getReceived()`,计算 `progressMade = (afterReceived > beforeReceived)` + - [x] SubTask 1.3: 修改 ACK 触发条件:窗口边界 ACK 仅在 `progressMade` 为 true 时触发;`transferComplete` 和 `isLastPacket` 的 ACK 也需 `progressMade` 为 true(完成判定依赖实际写入字节数,无写入则不可能完成) + - [x] SubTask 1.4: 使用 `afterReceived` 替换 ACK 中的 `getReceived()` 调用,避免重复查询 + +- [x] Task 2: 修复 GD32 路径重复包 ACK(process_gd32_packet) + - [x] SubTask 2.1: 移除重复包分支(seq <= last_seq)中的窗口边界 ACK 发送逻辑(L1233-1235) + - [x] SubTask 2.2: 保留重复包的 DEBUG_DBG 日志和 `return false` 跳过逻辑 + +- [x] Task 3: 调整发送端首轮 ACK 超时(ble_ota.js) + - [x] SubTask 3.1: 将首轮窗口 ACK 等待超时从 3000ms 改为 5000ms(L1305 的 waitForAck 调用) + - [x] SubTask 3.2: 续发重试路径的 ACK 超时保持 3000ms 不变(L1370 的 waitForAck 调用) + +# Task Dependencies +- Task 1, Task 2, Task 3 相互独立,可并行实现 diff --git a/_verify_syntax.py b/_verify_syntax.py deleted file mode 100644 index cd8182b..0000000 --- a/_verify_syntax.py +++ /dev/null @@ -1,100 +0,0 @@ -# 临时语法验证脚本:用 riscv32-esp-elf-g++ -fsyntax-only 验证 gd32_flash_store.cpp -# 验证完成后删除本脚本。 -import json -import os -import shlex -import subprocess -import sys - -ROOT = r"g:\Robot_v2\DCU\ESP32_COMM" -CC_PATH = os.path.join(ROOT, "build", "compile_commands.json") -# 对照测试:可切换目标源文件 -TARGET_NAME = os.environ.get("VERIFY_SRC", "gd32_flash_store.cpp") -TARGET_SRC = os.path.join(ROOT, "main", "storage", TARGET_NAME) if TARGET_NAME == "gd32_flash_store.cpp" else os.path.join(ROOT, "main", "ota", TARGET_NAME) -RESP = os.path.join(ROOT, "build", "_syntax_resp.txt") - -with open(CC_PATH, encoding="utf-8") as f: - db = json.load(f) - -entry = None -for e in db: - if e.get("file", "").endswith(TARGET_NAME): - entry = e - break -if not entry: - print("ERROR: 未找到", TARGET_NAME, "的编译条目") - sys.exit(2) - -work_dir = entry["directory"] -# 任务要求:gcc_path 从 compile_commands.json 第一条 command 提取 -first_cmd = shlex.split(db[0]["command"], posix=False) -gcc_path = first_cmd[0] -gxx_path = gcc_path.replace("riscv32-esp-elf-gcc.exe", "riscv32-esp-elf-g++.exe") -if gxx_path == gcc_path: - print("ERROR: 未能从 gcc 路径推导 g++ 路径:", gcc_path) - sys.exit(2) -if not os.path.exists(gxx_path): - print("ERROR: g++ 不存在:", gxx_path) - sys.exit(2) - -# 目标条目的编译参数(去掉首位的编译器路径,改用推导出的 g++) -args = shlex.split(entry["command"], posix=False) - -# 过滤:去掉 -o 及其参数,去掉源文件名 -filtered = [] -i = 1 -while i < len(args): - a = args[i] - if a == "-o": - i += 2 # 跳过 -o 和输出文件 - continue - if a.startswith("-o") and len(a) > 2: - i += 1 # -oXXX 形式 - continue - if os.path.abspath(a) == os.path.abspath(TARGET_SRC): - i += 1 - continue - if a.endswith("gd32_flash_store.cpp"): - i += 1 - continue - filtered.append(a) - i += 1 - -# 组装新命令:g++ + 原 args(去 -o/源文件) + -fsyntax-only -Wall + 源文件 -# 注意1:原始构建用 -std=gnu++26(GNU 扩展模式),若覆盖为 -std=c++17 会定义 __STRICT_ANSI__, -# 导致 newlib 的 FILE 类型不暴露而报错(与本次修改无关的 newlib 兼容性问题)。 -# 故保留原始 -std=gnu++26,仅追加 -fsyntax-only -Wall;-Werror 原始已有,可严格检查本文件。 -# 注意2:freertos/esp_hw_support 头文件链引用 FILE,但独立 syntax check 时 newlib -# 的 FILE typedef 被跳过(ota_coordinator.cpp 同样报错,与本次修改无关)。 -# 加 -include stdio.h 预先定义 FILE,绕过此无关问题,仅验证本文件语法。 -final_args = [gxx_path] + filtered + ["-fsyntax-only", "-Wall", "-include", "stdio.h", TARGET_SRC] - -# 写 response file:每行一个参数。 -# GCC @file 解析时反斜杠会被当转义符吃掉,因此对所有参数统一处理: -# 反斜杠 -> \\,双引号 -> \",并用双引号包裹(双引号内 \\=\" 才是字面)。 -def quote_for_resp(p): - esc = p.replace("\\", "\\\\").replace('"', '\\"') - return '"' + esc + '"' - -with open(RESP, "w", encoding="utf-8") as f: - for a in final_args[1:]: # 第一个是 gxx 本身,放命令行 - f.write(quote_for_resp(a) + "\n") - -print("WORK_DIR:", work_dir) -print("GXX:", gxx_path) -print("RESP:", RESP) -print("ARGS(excl gxx):", len(final_args) - 1, "params") - -# 诊断:预处理后查找 FILE 的 typedef,确认 FILE 是否被定义 -diag_args = [gxx_path] + filtered + ["-E", "-include", "stdio.h", TARGET_SRC] -print("--- 诊断(-E,grep typedef FILE/__FILE/__sFILE) ---") -proc = subprocess.run(diag_args, cwd=work_dir, capture_output=True, text=True) -import re -for line in proc.stdout.splitlines(): - s = line.strip() - if ("typedef" in s and ("FILE" in s or "__sFILE" in s)): - print(s) -print("RETURN CODE:", proc.returncode) - - - diff --git a/docs/wifi_cold_boot_fix_plan.html b/docs/wifi_cold_boot_fix_plan.html deleted file mode 100644 index 990f006..0000000 --- a/docs/wifi_cold_boot_fix_plan.html +++ /dev/null @@ -1,1149 +0,0 @@ - - - - - - ESP32 WiFi 冷启动连接失败排查与修复计划 - - - -
-
-
-
-
ESP32-C3 OTA Relay / WiFi Incident Plan
-

WiFi 冷启动连接失败排查与系统化修复计划

-

- 面向长时间断电后无法连接 STA 的问题,按“复现证据 → 最小诊断 → P0 修复 → 运行期恢复 → 验证矩阵”的顺序推进,避免一次性大改掩盖真实根因。 -

-
- 目标:冷启动 STA 稳定联网 - 范围:WiFi / NVS / PHY / Watchdog / SPI mutex - 项目:G:\Robot_v2\DCU\ESP32_COMM -
-
-
- - -
- - - -
-
-

根因优先级

-

先处理能直接解释“长时间断电后首连失败”和“失败后不恢复”的路径。

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
优先级根因位置影响
P0冷启动擦 PHY 校准 + 仅 500ms 延迟wifi_ota.cpp L1524-L1531, L340-L342全量 PHY 校准可能需要更长时间,反复擦除会让首连更不稳定。
P0重试耗尽标志运行中不恢复wifi_ota.cpp L196-L208m_staRetryExhausted=true 后可能长期阻止后续重连。
P1listen_interval=3 偏短wifi_ota.cpp L324长时间空闲或 AP 策略严格时,STA 更容易被驱逐。
P1无 IP_EVENT_STA_LOST_IP 处理wifi_ota.cpp L228-L242设备可能已经无 IP,但内部仍认为已连接。
P1主循环无 WiFi 断线看门狗wifi_ota.cpp L1679-L1707运行期断线后缺少强制恢复策略。
P2断线原因码未解码wifi_ota.cpp L201日志只有数字,难以区分认证、AP 不存在、握手超时、AP 驱逐。
P2SpiMaster mutex 空指针风险spi_master.cpp L288-L290先 lock 空 mutex 再创建 mutex,属于明确初始化顺序问题。
P2NVS 初始化错误处理不完整wifi_ota.cpp L1492-L1508只处理两个错误码,其他 NVS 异常缺少统一恢复路径。
-
- -
-
-

复现证据

-

本阶段只补充诊断能力和采集日志,不改变核心连接策略。

-
-
-
-

串口日志必抓字段

-
    -
  • 复位原因: POWER_ON / BROWNOUT / SOFTWARE / 其他
  • -
  • 冷启动 是否成立,是否擦除 WiFi NVS 或 PHY
  • -
  • STA 已启动、STA 已关联到 AP、STA 获取 IP
  • -
  • STA 断开 的 reason code 与可读原因
  • -
  • STA 重试已耗尽 和是否切换 AP
  • -
-
-
-

路由器侧同步检查

-
    -
  • ESP32 MAC 是否出现在关联列表
  • -
  • 是否产生 DHCP 租约,租约 IP 是否变化
  • -
  • AP 日志中是否有 deauth / disassoc / handshake timeout
  • -
  • 路由器冷启动时 WiFi 和 DHCP 服务恢复耗时
  • -
-
-
-
- -
-
-

实施阶段

-

从可观测性开始,再进入 P0/P1 修复,最后做覆盖测试。

-
-
-
-
01Baseline
-
-

复现与日志基线

-

冷启动抓 5 分钟串口日志,同时记录路由器关联和 DHCP 日志,确认失败发生在关联前、关联后取 IP、还是运行中断线。

-
- serial_monitor.py - reset reason - router log -
-
-
-
-
02Observe
-
-

增加最小诊断能力

-

解码断线 reason,补充 retry、exhausted、heap、uptime 和 WiFi API 返回值日志。

-
- wifiReasonToStr - heap - uptime -
-
-
-
-
03P0 Fix
-
-

冷启动与重试耗尽修复

-

冷启动 PHY 延迟提升到 1500ms;移除无条件 PHY 擦除;重试耗尽增加 30s 冷却恢复机制。

-
- PHY delay - conditional erase - retry cooldown -
-
-
-
-
04P1 Fix
-
-

运行期断线恢复

-

处理 LOST_IP,增大 listen_interval,配置 inactive time,并加入断线 60s 后的 WiFi 看门狗恢复。

-
- LOST_IP - listen_interval=10 - watchdog -
-
-
-
-
05Verify
-
-

构建与实机验证

-

ESP-IDF v6.0 全量构建,执行冷启动、路由器晚启动、DHCP 变化、路由器重启、弱信号与 BLE OTA 后恢复测试。

-
- idf.py build - cold boot x5 - router restart x3 -
-
-
-
-
- -
-
-

任务清单

-

勾选状态保存在当前浏览器 localStorage,便于多次打开继续跟踪。

-
-
- - -
-
- - - - - - - - -
-
- -
-
-

验证矩阵

-

每个场景至少保留串口日志;涉及路由器的场景同步保留 AP/DHCP 日志。

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
场景操作通过标准
长时间断电后冷启动断电足够久后上电,抓 5 分钟日志,重复 5 次。STA 能连接并获取 IP,不依赖手动重启。
路由器晚启动 60-120s先启动 ESP32,延迟启动路由器或 WiFi 服务。首次失败后仍能自动恢复 STA。
DHCP 服务慢或租约变化缩短 DHCP 租约或重启 DHCP 服务。LOST_IP 后标记断线并重新获取 IP。
路由器重启ESP32 在线时重启路由器,重复 3 次。断线后自动恢复,HTTP 服务重新可访问。
WiFi 密码错误临时配置错误密码或错误 SSID。reason 可读,不高频无限重试。
弱信号环境拉远距离或降低 AP 发射功率。RSSI、断线原因、恢复动作均可观察。
BLE OTA 后 WiFi执行 BLE OTA 流程后访问 WiFi HTTP API。WiFi 省电模式恢复为 WIFI_PS_NONE。
SPI 初始化启动后执行 SPI/CAN 相关 API 或中继流程。无 mutex NULL 风险,无初始化异常。
-
- -
-
-

风险与缓解

-

修复目标是提高恢复能力,同时避免掩盖配置错误或产生高频重试。

-
-
-
-

启动速度变慢

-

PHY 延迟仅冷启动生效,热启动不受影响。验证时记录冷启动总耗时。

-
-
-

无限重连循环

-

用 30s 冷却期和看门狗分级恢复控制频率,并保留错误 reason。

-
-
-

HTTP 连接中断

-

stop/start 仅在断线 60s+ 且多次强制重连失败后执行,此时 HTTP 已不可用。

-
-
-

功耗略增

-

设备为有线供电,listen_interval=10 与 inactive time 的稳定性收益优先。

-
-
-
- -
-
-

执行命令

-

根据本机 ESP-IDF 环境调整串口号和 Python/IDF 命令入口。

-
-
-
# 冷启动串口日志采集
-python serial_monitor.py
-
-# 预期输出文件
-G:\Robot_v2\DCU\ESP32_COMM\serial_log.txt
-
# ESP-IDF v6.0 构建
-idf.py build
-
-# 可选:清理后构建
-idf.py fullclean
-idf.py build
-
-
- -
-
-

决策记录

-

以下备选方案暂不采用,除非后续日志推翻当前判断。

-
-
-
-
×
-
- 保留无条件 PHY 擦除 - PHY 校准数据是环境相关积累数据,保留通常比每次冷启动擦除更可靠。 -
-
-
-
×
-
- 只增加 WIFI_STA_MAX_RETRY - 单纯增加次数不能解决耗尽后恢复、运行期断线、LOST_IP 等状态闭环问题。 -
-
-
-
×
-
- 优先优化 DHCP 超时 - 当前总等待 30s 已覆盖多数 DHCP 慢场景,需先用日志确认是否真的卡在 DHCP。 -
-
-
-
✓
-
- 先增强诊断再修复 - reason 解码和状态日志能避免把认证、信道、供电、DHCP 问题混在一起处理。 -
-
-
-
- - -
- - - - diff --git a/main/include/version.h b/main/include/version.h index 259429b..cab974c 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 38 -#define FW_VERSION_STRING "0.3.38" +#define FW_VERSION_PATCH 39 +#define FW_VERSION_STRING "0.3.39" // 编译时间 -#define BUILD_TIME "2026-07-08 02:41:52" +#define BUILD_TIME "2026-07-09 02:43:44" // Git 信息 -#define GIT_HASH "069ce8e" +#define GIT_HASH "661ae54" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index 44080fb..a776aa0 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -86,19 +86,18 @@ static void taskLoop(void *) } /* 解析本页并累积到工作缓冲,page=0 会自动重置 */ + const uint8_t page = ring_payload[1]; if (canFdParseFeedbackPage(ring_payload, ring_len, &s_work[ch], &s_workIdx[ch]) == 0) { publish(ch, s_work[ch]); + /* 仅在出现错误时打印告警,正常工作静默 */ if (s_work[ch].stats.error_count != 0U) { - DEBUG_INFO(kTag, - "CH%u seq=%lu mode=%u rx=%lu tx=%lu drop=%lu err=%lu", + DEBUG_WARN(kTag, + "CH%u P%u seq=%lu err=%lu", static_cast(ch), + static_cast(page), static_cast(s_work[ch].stats.snapshot_seq), - static_cast(s_work[ch].stats.mode), - static_cast(s_work[ch].stats.rx_count), - static_cast(s_work[ch].stats.tx_count), - static_cast(s_work[ch].stats.rx_drop_count), static_cast(s_work[ch].stats.error_count)); } } diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index c3d13dc..0d36719 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -244,7 +244,7 @@ health_status_t HealthMonitor::checkBeforeOta() return HEALTH_MON_CRITICAL; } else if (cur >= SYS_WARNING) { status = HEALTH_MON_WARNING; - DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意"); + DEBUG_DBG("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意"); } bool wifi_connected; @@ -303,7 +303,7 @@ health_status_t HealthMonitor::checkBeforeOtaRelay() DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_CRITICAL (WiFi 相关),SPI 中继允许继续"); } else if (cur >= SYS_WARNING) { status = HEALTH_MON_WARNING; - DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_WARNING,继续传输"); + DEBUG_DBG("HEALTH", "OTA 中继检查: SYS_WARNING,继续传输"); } /* 跳过 WiFi 连接状态和 RSSI 检查: diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 8676ecb..64cbe2c 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -1144,6 +1144,7 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, } // ★ 使用带序号顺序保护的写入:拒绝乱序包并立即 ACK 通知发送端 + uint32_t beforeReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); esp_err_t err = esp32_write_ordered(seq, cmd, &buf[data_offset], data_len); if (err != ESP_OK) { DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s", @@ -1152,23 +1153,29 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, return true; } - DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u", - seq, (unsigned int)Esp32OtaCore::instance().getReceived()); + // ★ 仅在实际写入新字节时才触发 ACK:重复包/乱序缓存包不写入 flash, + // 若在窗口边界触发 ACK 会携带陈旧字节数,被发送端误判为丢包。 + uint32_t afterReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); + bool progressMade = (afterReceived > beforeReceived); + + DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u progress=%d", + seq, (unsigned int)afterReceived, progressMade ? 1 : 0); // 完成判定:以实际按序写入字节数为准。 bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD); - bool transferComplete = (total_size > 0 && - Esp32OtaCore::instance().getReceived() >= total_size); + bool transferComplete = (total_size > 0 && afterReceived >= total_size); - // ACK 触发条件 - if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || - transferComplete || - isLastPacket) { + // ACK 触发条件:仅在实际写入新字节时触发窗口边界 ACK, + // 避免重复包/乱序缓存包在窗口边界发送陈旧 ACK + if (progressMade && + ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || + transferComplete || + isLastPacket)) { if (isLastPacket || transferComplete) { DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32, - seq, data_len, (unsigned int)Esp32OtaCore::instance().getReceived(), total_size); + seq, data_len, (unsigned int)afterReceived, total_size); } - sendWindowAck(cmd, seq, (uint32_t)Esp32OtaCore::instance().getReceived()); + sendWindowAck(cmd, seq, afterReceived); } if (transferComplete) { @@ -1226,13 +1233,11 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, // 策略与 ESP32 fallback 路径一致(参考 esp32_write_ordered 行 459-477)。 if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) { // 重复包:seq <= last_seq(已写入过) + // ★ 重复包静默跳过,不发送 ACK:发送端已持有上一窗口的有效 ACK 进度, + // 重复包的 ACK 会携带未增长的字节数,被发送端误判为当前窗口丢包。 if (seq <= meta.last_seq) { DEBUG_DBG(TAG, "GD32 重复包: seq=%u <= last_seq=%" PRIu32 ",跳过", seq, meta.last_seq); - // 重复包不触发 ACK 风暴,只在窗口边界发一次 - if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) { - sendWindowAck(cmd, (uint16_t)meta.last_seq, meta.stored_size); - } return false; // 外层推进 offset,继续下一包 } diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index bb00a20..ef8e776 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -805,7 +805,7 @@ esp_err_t can_mode_handler(httpd_req_t *req) httpd_resp_send(req, resp, len); } else { can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS; - int rc = canFdSetRxMode(CAN_RX_MODE_ALL_PASS, &actual, 500); + int rc = canFdSetRxMode(CAN_RX_MODE_ALL_PASS, &actual, 2000); char resp[64]; int len = snprintf(resp, sizeof(resp), "{\"mode\":%u,\"source\":\"spi\",\"ok\":%s}", @@ -847,7 +847,7 @@ esp_err_t can_mode_handler(httpd_req_t *req) target = CAN_RX_MODE_BRIDGE_0_TO_12; } can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS; - int rc = canFdSetRxMode(target, &actual, 500); + int rc = canFdSetRxMode(target, &actual, 2000); char resp[96]; int len = snprintf(resp, sizeof(resp), @@ -909,7 +909,7 @@ esp_err_t can_send_handler(httpd_req_t *req) uint8_t resp_data[CAN_MAX_DLC] = {0}; int resp_len = canFdSend(ch, ext_id, tx_data, tx_dlc, - resp_data, sizeof(resp_data), 1000); + resp_data, sizeof(resp_data), 2000); char resp[512]; if (resp_len >= 0) { diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index dd7a63f..c4b5cfd 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -30,34 +30,30 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, uint8_t resp_type = 0U; size_t resp_len = 0U; - esp_err_t ret = SpiMaster::instance().otaTransfer( + /* 两步法:otaTransfer 发命令(丢弃延迟一拍的过期响应)+ otaPollResponse 读真实响应。 + * CAN 帧非幂等,命令只发一次,不重发。 */ + esp_err_t ret = SpiMaster::instance().otaSendAndPoll( kSpiCanFrameType, - tx_buf, - tx_len, - &resp_type, - recv_buf, - recv_max, - &resp_len, - timeout_ms); - if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK) { - if (ret != ESP_OK) { - DEBUG_WARN("CAN", "CAN_SEND SPI失败: ch=%u id=0x%08lX err=%s", - (unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret)); - return -2; /* SPI 通信失败 */ + tx_buf, tx_len, + &resp_type, recv_buf, recv_max, &resp_len, timeout_ms); + + if (ret != ESP_OK) { + DEBUG_WARN("CAN", "CAN_SEND SPI失败: ch=%u id=0x%08lX err=%s", + (unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret)); + return -2; /* SPI 通信失败 */ + } + if (resp_type == SPI_OTA_RSP_ACK) { + if (recv_buf == nullptr || recv_max == 0U) { + return static_cast(resp_len); } - /* resp_type != ACK: 读取 GD32 返回的错误码 */ - uint8_t gd32_err = (resp_len > 0 && recv_buf) ? recv_buf[0] : 0xFF; - DEBUG_WARN("CAN", "CAN_SEND GD32拒绝: ch=%u id=0x%08lX resp=0x%02X err=0x%02X", - (unsigned)channel, (unsigned long)ext_id, resp_type, gd32_err); - return -3; /* GD32 返回 ERROR */ + const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max; + return static_cast(copied_len); } - - if (recv_buf == nullptr || recv_max == 0U) { - return static_cast(resp_len); - } - - const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max; - return static_cast(copied_len); + /* resp_type == ERROR: 读取 GD32 返回的错误码 */ + uint8_t gd32_err = (resp_len > 0 && recv_buf) ? recv_buf[0] : 0xFF; + DEBUG_WARN("CAN", "CAN_SEND GD32拒绝: ch=%u id=0x%08lX resp=0x%02X err=0x%02X", + (unsigned)channel, (unsigned long)ext_id, resp_type, gd32_err); + return -3; /* GD32 返回 ERROR */ } constexpr uint8_t kSpiCanFeedbackType = 0xC1U; @@ -202,7 +198,7 @@ int canFdPullIdSnapshot(can_channel_t channel, uint8_t resp_type = 0U; size_t resp_len = 0U; - esp_err_t ret = SpiMaster::instance().otaTransfer( + esp_err_t ret = SpiMaster::instance().otaSendAndPoll( kSpiIdSnapshotType, req, 2U, &resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms); @@ -263,15 +259,18 @@ int canFdSetRxMode(can_rx_mode_t mode, uint8_t resp_type = 0U; size_t resp_len = 0U; - esp_err_t ret = SpiMaster::instance().otaTransfer( + /* 两步法破解延迟一拍:发命令一次 + poll 真实响应。 + * 0xC2 幂等,但两步法避免无效重试。 */ + esp_err_t ret = SpiMaster::instance().otaSendAndPoll( kSpiCanSetModeType, &req, 1U, &resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms); - if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 1U) { - return -1; + + if (ret == ESP_OK && resp_type == SPI_OTA_RSP_ACK && resp_len >= 1U) { + if (actual != nullptr) { + *actual = static_cast(resp_buf[0]); + } + return 0; } - if (actual != nullptr) { - *actual = static_cast(resp_buf[0]); - } - return 0; + return -1; } diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index b0b1f79..7708ba4 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -82,8 +82,8 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) int level = readReadyLevelRaw(); if (level == 0) { /* 下降沿 */ - if (self->m_hsState == HandshakeState::IDLE) { - /* GD32 按铃 */ + if (self->m_linkState.acceptRing()) { + /* GD32 按铃:仅 IDLE 态接受 */ self->m_gd32RequestPending = true; if (self->m_spiTaskHandle) { BaseType_t hp = pdFALSE; @@ -91,7 +91,7 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) eSetBits, &hp); portYIELD_FROM_ISR(hp); } - } else if (self->m_hsState == HandshakeState::WAITING_BOOT_READY) { + } else if (self->m_linkState.state() == HandshakeState::WAITING_BOOT_READY) { /* 方案A: Bootloader 已接管(APP 拉低或 BOOT 极早拉低) */ if (self->m_spiTaskHandle) { BaseType_t hp = pdFALSE; @@ -102,7 +102,7 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) } } else { /* 上升沿 */ - if (self->m_hsState == HandshakeState::WAITING_RESPONSE) { + if (self->m_linkState.state() == HandshakeState::WAITING_RESPONSE) { /* GD32 释放 SPIready,可发起新 CS */ if (self->m_spiTaskHandle) { BaseType_t hp = pdFALSE; @@ -110,7 +110,7 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) eSetBits, &hp); portYIELD_FROM_ISR(hp); } - } else if (self->m_hsState == HandshakeState::WAITING_BOOT_READY) { + } else if (self->m_linkState.state() == HandshakeState::WAITING_BOOT_READY) { /* 方案A: Bootloader init 完成,释放 SPIready */ if (self->m_spiTaskHandle) { BaseType_t hp = pdFALSE; @@ -125,7 +125,7 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) /** * @brief 等 GD32 释放 SPIready(上升沿),中断驱动非忙等。 * - * 调用前须将 m_hsState 置为 WAITING_RESPONSE。ISR 上升沿检测到该状态后 + * 调用前须将 m_linkState 置为 WAITING_RESPONSE(enterBusy)。ISR 上升沿检测到该状态后 * 发 0x02 通知唤醒本任务。GD32 写 Flash 期间保持 PB6 低,本函数阻塞, * 写完拉高 → 唤醒 → 继续,自然避免 overrun。 */ @@ -447,7 +447,7 @@ esp_err_t SpiMaster::init() /* 记录当前任务句柄,供 GPIO ISR 唤醒 */ m_spiTaskHandle = xTaskGetCurrentTaskHandle(); - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); m_gd32RequestPending = false; m_localCredit = 8; m_remoteCredit = 8; @@ -475,6 +475,18 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { LockGuard lock(m_mutex); + return otaTransferUnlocked(type, payload, payload_len, + resp_type, resp_payload, resp_max, resp_len, timeout_ms); +} + +/** + * @brief 无锁版本的 otaTransfer(调用者须已持有锁)。 + */ +esp_err_t SpiMaster::otaTransferUnlocked(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, + bool expect_stale) +{ uint8_t tx_frame[SPI_OTA_FRAME_MAX]; size_t tx_len = 0; if (!otaBuildFrame(type, payload, payload_len, tx_frame, &tx_len)) { @@ -483,17 +495,6 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa DEBUG_DBG("SPI", "OTA TX: type=0x%02X len=%u", type, (unsigned)tx_len); - /* OTA 帧内容 hex dump(前 18 字节),用于与 GD32 端 [RX-FRM] 日志对比诊断 CRC 问题。 - * 仅对 OTA 帧类型(0x01~0x0F)打印,避免 CAN 反馈(0xC1)每秒刷屏。 */ - if (type <= 0x0FU) { - char hexbuf[64] = {0}; - size_t dump_len = (tx_len < 18U) ? tx_len : 18U; - for (size_t i = 0U; i < dump_len; ++i) { - snprintf(hexbuf + i * 3, sizeof(hexbuf) - i * 3, "%02X ", tx_frame[i]); - } - DEBUG_INFO("SPI", "OTA TX hex: %s", hexbuf); - } - if (tx_len > SPI_OTA_FRAME_MAX) { DEBUG_ERR("SPI", "OTA frame too large: %u", (unsigned)tx_len); return ESP_ERR_INVALID_SIZE; @@ -504,13 +505,13 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa * queueTxFrame() 只缓冲不启动 DMA。因此本笔 CS 内读到的响应属于上一笔请求, * 首笔请求必读 0xFF。握手请求幂等可通过重试匹配;唯一性请求需配合 * otaPollResponse() 读取延迟响应。详见 SpiOta.cpp queueTxFrame 注释。 */ - m_hsState = HandshakeState::WAITING_RESPONSE; + 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); - m_hsState = HandshakeState::MASTER_SENDING; + m_linkState.enterBusy(HandshakeState::MASTER_SENDING); driveReadyLow(); if (SPI_READY_DRIVE_DELAY_US > 0U) { @@ -521,7 +522,7 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa if (tx_len > sizeof(tx_padded)) { DEBUG_ERR("SPI", "OTA frame %u exceeds tx_padded %u", (unsigned)tx_len, (unsigned)sizeof(tx_padded)); releaseReady(); - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); return ESP_ERR_INVALID_SIZE; } memset(tx_padded, 0xFF, sizeof(tx_padded)); @@ -533,7 +534,7 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa esp_err_t ret = transferClocked(tx_padded, sizeof(tx_padded), rx_raw, sizeof(rx_raw), timeout_ms); releaseReady(); - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); if (ret != ESP_OK) { DEBUG_WARN("SPI", "OTA sync transfer failed: %s", esp_err_to_name(ret)); return ret; @@ -555,7 +556,11 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa return ESP_OK; } - DEBUG_WARN("SPI", "OTA sync transfer response not found"); + if (expect_stale) { + DEBUG_DBG("SPI", "OTA step1 stale response (expected, delayed-by-one)"); + } else { + DEBUG_WARN("SPI", "OTA sync transfer response not found"); + } return ESP_ERR_INVALID_RESPONSE; } /** @@ -596,7 +601,7 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p /* 等 GD32 释放 PB6(当前帧 onCsRelease 处理完)再发起 CS#2 poll。 * 中断驱动:GD32 拉高 → ISR 0x02 → 唤醒。GD32 写 Flash 期间 * PB6 保持低,本函数阻塞直到写完,自然避免 overrun。 */ - m_hsState = HandshakeState::WAITING_RESPONSE; + m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); if (waitSpiReady(SPI_READY_TIMEOUT_MS) != ESP_OK) { /* 超时则退化为继续 poll(容错) */ } @@ -650,18 +655,50 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_POLL_BACKOFF_MS)); continue; } - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); return ESP_OK; } vTaskDelay(pdMS_TO_TICKS(2)); } - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); DEBUG_WARN("SPI", "OTA response timeout: %lu ms", (unsigned long)timeout_ms); return ESP_ERR_TIMEOUT; } +/** + * @brief ESP32→GD32 发送命令 + 轮询读取真实响应(两步法,原子持锁)。 + * + * 解决 GD32 "延迟一拍"协议:otaTransfer 首笔 CS 读到过期响应, + * otaPollResponse 读取本笔命令的真实响应。命令只发送一次,不重发。 + * 适用于所有 ESP32→GD32 方向的命令(CAN 发送/模式切换/ID 快照等)。 + */ +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) +{ + LockGuard lock(m_mutex); + + /* 步骤 1: 发送命令(忽略延迟一拍的过期响应)。 + * otaTransferUnlocked 可能返回: + * ESP_OK - 读到过期帧(正常,忽略) + * ESP_ERR_INVALID_RESPONSE - 读到 0xFF(正常,延迟一拍) + * 其他 - SPI 真实错误 */ + const uint32_t send_timeout = (timeout_ms > 500U) ? 500U : timeout_ms; + esp_err_t send_ret = otaTransferUnlocked(type, payload, payload_len, + nullptr, nullptr, 0U, nullptr, send_timeout, + /*expect_stale=*/true); + if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) { + return send_ret; + } + + /* 步骤 2: 轮询读取真实响应(dummy TX,不重发命令) */ + 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); +} + /** * @brief 通知 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。 @@ -740,7 +777,7 @@ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) /* Phase1: 等待下降沿 (APP 复位前拉低 / Bootloader 极早拉低) * 先检查当前电平,若已为 LOW 则跳过等待 */ - m_hsState = HandshakeState::WAITING_BOOT_READY; + m_linkState.enterBusy(HandshakeState::WAITING_BOOT_READY); if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { /* 仍为 HIGH,等下降沿中断 */ @@ -759,7 +796,7 @@ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 0) { seen_low = true; break; } } if (!seen_low) { - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); DEBUG_WARN("SPI", "waitForBootloaderReady: Phase1 未检测到 LOW"); return ESP_ERR_TIMEOUT; } @@ -781,7 +818,7 @@ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) /* 收到上升沿,确认 HIGH 持续 10ms 过滤虚假脉冲 */ vTaskDelay(pdMS_TO_TICKS(10)); if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); DEBUG_INFO("SPI", "waitForBootloaderReady: Bootloader 就绪 (PB6 HIGH stable)"); return ESP_OK; } @@ -792,14 +829,14 @@ esp_err_t SpiMaster::waitForBootloaderReady(uint32_t timeout_ms) if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { vTaskDelay(pdMS_TO_TICKS(10)); if (gpio_get_level((gpio_num_t)SPI_READY_GPIO) == 1) { - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); DEBUG_INFO("SPI", "waitForBootloaderReady: Bootloader 就绪 (poll fallback)"); return ESP_OK; } } } - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); DEBUG_WARN("SPI", "waitForBootloaderReady: Phase2 等待 HIGH 超时 (%ums)", (unsigned)timeout_ms); return ESP_ERR_TIMEOUT; } @@ -836,7 +873,7 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver memcpy(tx_padded, tx_frame, tx_len); /* [0..4]=PING帧, [5..15]=0xFF */ /* 同步单 CS PING:下拉 SPIready 后,在单次 SPI 事务中发送并读回响应。 */ - m_hsState = HandshakeState::MASTER_SENDING; + m_linkState.enterBusy(HandshakeState::MASTER_SENDING); driveReadyLow(); if (SPI_READY_DRIVE_DELAY_US > 0U) { esp_rom_delay_us(SPI_READY_DRIVE_DELAY_US); @@ -847,7 +884,7 @@ esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_ver esp_err_t err = transferClocked(tx_padded, sizeof(tx_padded), rx_raw, sizeof(rx_raw), 0U); releaseReady(); - m_hsState = HandshakeState::IDLE; + m_linkState.exitBusy(); if (err != ESP_OK) { DEBUG_DBG("SPI", "APP PING sync transfer failed[%lu]: %s", (unsigned long)attempt, esp_err_to_name(err)); @@ -962,11 +999,16 @@ esp_err_t SpiMaster::checkGd32Request(uint32_t timeout_ms) LockGuard lock(m_mutex); m_gd32RequestPending = false; - m_hsState = HandshakeState::SERVING_GD32_RING; + 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); + } - /* GD32 已下拉 SPIready 并准备好请求帧在 TX buffer。 - * 发起一次 SPI 读事务:发 dummy 字节,读回 GD32 的请求帧。 - * GD32 在 CS 释放后释放 SPIready。 */ constexpr size_t kRingReadBytes = 160U; uint8_t rx_raw[kRingReadBytes]; uint8_t dummy[kRingReadBytes]; @@ -975,13 +1017,18 @@ esp_err_t SpiMaster::checkGd32Request(uint32_t timeout_ms) /* 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)); - m_hsState = HandshakeState::IDLE; return ret; } - /* 解析请求帧 */ + /* 解析请求帧(m_linkState 已 IDLE,允许 GD32 下一帧按铃被 ISR 接受) */ uint8_t found_type = 0; const uint8_t *found_payload = nullptr; size_t found_len = 0; @@ -993,15 +1040,11 @@ esp_err_t SpiMaster::checkGd32Request(uint32_t timeout_ms) if (found_payload && found_len > 0 && found_len <= sizeof(m_ringRxBuf)) { memcpy(m_ringRxBuf, found_payload, found_len); } - DEBUG_INFO("SPI", "GD32 按铃请求帧: type=0x%02X len=%u", - found_type, (unsigned)found_len); - m_hsState = HandshakeState::IDLE; - /* 上层通过 takeRingRx() 取出 type/payload 进行处理 */ + /* 正常按铃静默,仅在异常时由上层告警 */ return ESP_OK; } DEBUG_WARN("SPI", "GD32 按铃读事务未找到合法帧"); - m_hsState = HandshakeState::IDLE; return ESP_ERR_INVALID_RESPONSE; } diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 4556bd0..09552cc 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -85,6 +85,26 @@ public: esp_err_t otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms); + /** @brief ESP32→GD32 发送命令 + 轮询读取真实响应(两步法,原子持锁)。 + * + * 解决 GD32 "延迟一拍"协议:otaTransfer 首笔 CS 读到过期响应, + * otaPollResponse 读取本笔命令的真实响应。命令只发送一次,不重发。 + * 适用于所有 ESP32→GD32 方向的命令(CAN 发送/模式切换/ID 快照等)。 + * + * @param type 请求帧类型(如 0xC0/0xC2/0xC3)。 + * @param payload 请求负载数据。 + * @param payload_len 请求负载长度。 + * @param resp_type 输出响应类型(ACK/WAIT/ERROR)。 + * @param resp_payload 输出响应负载缓冲区。 + * @param resp_max 响应负载缓冲区最大长度。 + * @param resp_len 输出实际响应负载长度。 + * @param timeout_ms 总超时(发送 + 轮询),内部分配。 + * @return ESP_OK 成功读到响应;其他为 SPI 错误或超时。 + */ + esp_err_t 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); + /** @brief 读取对端最新上报的 CREDIT(0 表示对端忙,应 POLL 等待而非重发)。 */ uint8_t remoteCredit() const { return m_remoteCredit; } @@ -160,7 +180,39 @@ private: SERVING_GD32_RING, ///< 响应 GD32 按铃,正在读 GD32 请求帧 WAITING_BOOT_READY, ///< 方案A: 等待 Bootloader 就绪(LOW→HIGH 序列) }; - volatile HandshakeState m_hsState = HandshakeState::IDLE; + + /** + * @brief SPI 链路状态机:封装握手状态 + 完成信号逻辑。 + * + * 线状态语义(对齐 OTA): + * - 线 HIGH = 空闲(双方释放),可发起新事务 + * - 线 LOW = 忙碌(某方下拉占用),禁止发起新事务 + * + * ESP32 作为主动方(otaTransfer/appPing):enterBusy() 占用线 → transferClocked → exitBusy() 释放线 + * ESP32 作为按铃响应方(checkGd32Request):同上,releaseReady 上升沿通知 GD32 完成 + */ + class SpiLinkState { + public: + /** 进入忙碌态(driveReadyLow 前/后调用)。 */ + void enterBusy(HandshakeState hs) { m_hsState = hs; } + + /** 退出忙碌态(releaseReady 后调用),回到 IDLE。 */ + void exitBusy() { m_hsState = HandshakeState::IDLE; } + + /** 当前是否空闲(可接受新按铃下降沿)。 */ + bool isIdle() const { return m_hsState == HandshakeState::IDLE; } + + /** 当前握手状态。 */ + HandshakeState state() const { return m_hsState; } + + /** ISR 检测下降沿时调用:仅 IDLE 态接受按铃。 */ + bool acceptRing() const { return isIdle(); } + + private: + volatile HandshakeState m_hsState = HandshakeState::IDLE; + }; + + SpiLinkState m_linkState; /** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,SPI 任务消费。 */ volatile bool m_gd32RequestPending = false; /** SPI 任务句柄(用于 ISR 唤醒)。 */ @@ -196,7 +248,7 @@ private: /** * @brief 等 GD32 释放 SPIready(上升沿),中断驱动非忙等。 * - * 调用前须将 m_hsState 置为 WAITING_RESPONSE,ISR 上升沿检测到该状态 + * 调用前须将 m_linkState 置为 WAITING_RESPONSE(enterBusy),ISR 上升沿检测到该状态 * 后发 0x02 通知唤醒本任务。GD32 写 Flash 期间保持 PB6 低,本函数阻塞, * 写完拉高 → 唤醒 → 继续,自然避免 overrun。 * @@ -233,6 +285,13 @@ private: */ esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms); + /** @brief 无锁版本的 otaTransfer(调用者须已持有锁)。 + * @param expect_stale true 表示调用方预期读到过期/空响应(如 otaSendAndPoll 步骤1), + * 找不到帧时降级为 DBG 日志,不打 WARN。 */ + esp_err_t otaTransferUnlocked(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, + bool expect_stale = false); // OTA 帧工具(静态,无状态) static uint32_t otaCrc32(const uint8_t *data, size_t len); static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len, diff --git a/tools/app.js b/tools/app.js index c924cb8..9c8697f 100644 --- a/tools/app.js +++ b/tools/app.js @@ -1,4 +1,4 @@ -/** +/** * app.js - ESP32 OTA 中继控制台主脚本(WiFi OTA 通道) * * 职责: @@ -1614,12 +1614,15 @@ async function verifyEsp32Firmware() { cleanupAfterOtaFailure('校验失败'); } } catch (e) { - addLog('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message); - // 设备在收到请求后即会重启,网络中断属于正常现象,直接进入重启等待 - showVerifyActions('none'); - isEsp32Rebooting = true; - updateEsp32StagesFromPhase(5); - rebootStartTime = Date.now(); + // fetch 异常仅代表请求未完成,不能推断设备已重启: + // 设备可能仍在 finalize(SHA 计算耗时)、可能已崩溃、可能网络抖动。 + // 如实报告失败,由 pollStatus 探测设备真实状态。 + addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)'); + showToast('应用请求失败: ' + e.message, 'error'); + var resultEl = document.getElementById('sviResult'); + if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; } + showVerifyActions('retry'); + cleanupAfterOtaFailure('应用请求失败'); } } @@ -1674,10 +1677,10 @@ async function applyEsp32Firmware() { showToast(detail, 'error'); } } catch (e) { - addLog('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message); - isEsp32Rebooting = true; - updateEsp32StagesFromPhase(5); - rebootStartTime = Date.now(); + // fetch 异常仅代表请求未完成,不能推断设备已重启。 + addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)'); + showToast('应用请求失败: ' + e.message, 'error'); + cleanupAfterOtaFailure('应用请求失败'); } } diff --git a/tools/ble_ota.js b/tools/ble_ota.js index 5265e9a..6a5360f 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -1302,7 +1302,7 @@ state.ackGeneration++; try { - await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize }); + await waitForAck(cfg.packetCmd, endSeq, 5000, { expectedTotalSize: totalSize, payloadSize }); } catch (e) { bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W'); }