fix: 修复:改进OTA错误处理和BLE OTA性能
- 重构app.js中的OTA错误处理,以准确报告获取失败而不假设重启,从而实现正确的状态轮询。 - 通过将握手状态封装在SpiLinkState类中来增强SPI主状态管理,提高清晰度并减少直接的状态操作。 引入otaSendAndPoll方法,以更好地处理SPI通信中的命令发送和响应轮询。 - 调整BLE OTA数据包确认逻辑,防止导致固件录变慢的虚假确认,确保仅在实际取得进展时才发送确认。 - 修改确认超时设置,以更好地适应闪存写入速度,减少不重传。
This commit is contained in:
@@ -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 相互独立,可并行实现
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,继续下一包
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user