fix: 修复:改进OTA错误处理和BLE OTA性能

- 重构app.js中的OTA错误处理,以准确报告获取失败而不假设重启,从而实现正确的状态轮询。
- 通过将握手状态封装在SpiLinkState类中来增强SPI主状态管理,提高清晰度并减少直接的状态操作。
 引入otaSendAndPoll方法,以更好地处理SPI通信中的命令发送和响应轮询。
- 调整BLE OTA数据包确认逻辑,防止导致固件录变慢的虚假确认,确保仅在实际取得进展时才发送确认。
- 修改确认超时设置,以更好地适应闪存写入速度,减少不重传。
This commit is contained in:
小龙虾
2026-07-09 02:49:24 +08:00
parent 661ae54424
commit fedb08e215
16 changed files with 432 additions and 1369 deletions
@@ -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` 探测到设备无响应 → 降速诊断 → 用户看到"未连接"而非"重启中"
@@ -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 包"的稳定模式(需用户运行时测试)
@@ -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
@@ -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 相互独立,可并行实现
-100
View File
@@ -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 <stdio.h> 的 FILE 类型不暴露而报错(与本次修改无关的 newlib 兼容性问题)。
# 故保留原始 -std=gnu++26,仅追加 -fsyntax-only -Wall;-Werror 原始已有,可严格检查本文件。
# 注意2:freertos/esp_hw_support 头文件链引用 FILE,但独立 syntax check 时 newlib <stdio.h>
# 的 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)
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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"
+5 -6
View File
@@ -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<unsigned>(ch),
static_cast<unsigned>(page),
static_cast<unsigned long>(s_work[ch].stats.snapshot_seq),
static_cast<unsigned>(s_work[ch].stats.mode),
static_cast<unsigned long>(s_work[ch].stats.rx_count),
static_cast<unsigned long>(s_work[ch].stats.tx_count),
static_cast<unsigned long>(s_work[ch].stats.rx_drop_count),
static_cast<unsigned long>(s_work[ch].stats.error_count));
}
}
+2 -2
View File
@@ -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 检查:
+19 -14
View File
@@ -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,继续下一包
}
+3 -3
View File
@@ -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) {
+32 -33
View File
@@ -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<int>(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<int>(copied_len);
}
if (recv_buf == nullptr || recv_max == 0U) {
return static_cast<int>(resp_len);
}
const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max;
return static_cast<int>(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<can_rx_mode_t>(resp_buf[0]);
}
return 0;
}
if (actual != nullptr) {
*actual = static_cast<can_rx_mode_t>(resp_buf[0]);
}
return 0;
return -1;
}
+87 -44
View File
@@ -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;
}
+61 -2
View File
@@ -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,
+14 -11
View File
@@ -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('应用请求失败');
}
}
+1 -1
View File
@@ -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');
}