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 相互独立,可并行实现