Compare commits
3
Commits
2fe5393769
...
fedb08e215
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fedb08e215 | ||
|
|
661ae54424 | ||
|
|
069ce8e6a5 |
@@ -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 相互独立,可并行实现
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
# ESP32-C3 OTA 中继项目 - 根构建文件
|
||||
# ESP32-C3 OTA 中继项目 - 根构建文件
|
||||
# 要求 ESP-IDF v6.0+,使用 idf.py build 编译
|
||||
# Kconfig.projbuild 提供 DCU_SPI_CLOCK_HZ 等可配置项
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ idf_component_register(
|
||||
"../../main/include" # app_config.h
|
||||
REQUIRES
|
||||
esp_driver_spi # SPI 主设备驱动
|
||||
esp_driver_gpio # GPIO 驱动(SPI Ready 握手引脚)
|
||||
freertos # FreeRTOS 内核
|
||||
mbedtls # AES 加密
|
||||
esp_timer # 高精度定时器
|
||||
|
||||
@@ -17,8 +17,9 @@ CONFIG_ESP32C3_REV_MIN_0=y
|
||||
CONFIG_ESP32C3_REV_MIN=0
|
||||
|
||||
# FreeRTOS
|
||||
CONFIG_FREERTOS_UNICORE=n
|
||||
CONFIG_FREERTOS_NUMBER_OF_CORES=2
|
||||
# ESP32-C3 为单核芯片,多核配置会让 IDF 在启动时校验失败或回退
|
||||
CONFIG_FREERTOS_UNICORE=y
|
||||
CONFIG_FREERTOS_NUMBER_OF_CORES=1
|
||||
CONFIG_FREERTOS_HZ=1000
|
||||
|
||||
# WiFi
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
menu "DCU OTA Configuration"
|
||||
|
||||
config DCU_SPI_CLOCK_HZ
|
||||
int "SPI clock frequency for GD32 (Hz)"
|
||||
default 4000000
|
||||
range 1000000 20000000
|
||||
help
|
||||
SPI clock speed between ESP32-C3 and GD32E50x.
|
||||
Higher value speeds up GD32 OTA relay, but must be
|
||||
validated against actual wiring length/quality and GD32
|
||||
SPI slave timing.
|
||||
|
||||
config DCU_OTA_PACKET_INTERVAL_MS
|
||||
int "Delay between GD32 OTA packets (ms)"
|
||||
default 1
|
||||
range 0 50
|
||||
help
|
||||
Idle delay inserted after each successfully acknowledged
|
||||
firmware packet before sending the next one.
|
||||
Lower value speeds up OTA, but if GD32 cannot finish
|
||||
flash write / prepare next ACK in time, set it back.
|
||||
|
||||
config DCU_OTA_POLL_BACKOFF_MS
|
||||
int "Polling backoff when GD32 is not ready (ms)"
|
||||
default 2
|
||||
range 0 50
|
||||
help
|
||||
Delay between SPI response polls when GD32 returns WAIT
|
||||
or does not drive MISO yet. Lower value reduces per-packet
|
||||
idle time and can significantly speed up OTA, but if GD32
|
||||
cannot finish flash write before the next poll, increase it.
|
||||
|
||||
config DCU_OTA_FINISH_TIMEOUT_MS
|
||||
int "Timeout for FINISH_OTA command (ms)"
|
||||
default 10000
|
||||
range 3000 60000
|
||||
help
|
||||
How long ESP32 waits for GD32 to respond to the FINISH_OTA
|
||||
command. GD32 may need extra time to drain flash buffers,
|
||||
verify firmware, or prepare the APP jump.
|
||||
|
||||
endmenu
|
||||
@@ -54,16 +54,23 @@ inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; ///< AP 模
|
||||
// ============================================================
|
||||
inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服务器监听端口
|
||||
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; ///< HTTP 服务器任务栈大小(字节)
|
||||
// ★ 曾降至 6142 省 2KB,但 system_status_handler→sample_cpu_usage() 在栈上分配
|
||||
// TaskStatus_t tasks[24](~864B)+previous_samples[24](192B)+CpuUsageSnapshot(~608B)≈1.7KB,
|
||||
// 加上 spi_send_handler 的 buf[512]+resp[1024]+SPI 调用链 ≈3.5KB,
|
||||
// 再加 httpd 框架开销 ~2KB → 总需求逼近 6KB,6144 栈导致栈溢出 → TCB 被破坏
|
||||
// → "Stack protection fault"(栈边界被 "http" ASCII 覆盖)。恢复 8192 留足余量。
|
||||
|
||||
// ============================================================
|
||||
// OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 8192;
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< OTA 上传静态接收缓冲区,从 8192 降到 4096 省 4KB
|
||||
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB = 221184B = 0x35F90,固件大小上限
|
||||
inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷
|
||||
inline constexpr uint32_t OTA_POLL_RETRIES = 40; // 每包 ACK 轮询次数
|
||||
inline constexpr uint32_t OTA_RESEND_RETRIES = 10; // 每包重发次数上限
|
||||
inline constexpr uint32_t OTA_PACKET_DELAY_MS = 5; // 每包发送后延时(ms),给 GD32 处理时间
|
||||
inline constexpr uint32_t OTA_POST_SEND_DELAY_MS = 3; // 发送请求后首次轮询前的延迟(ms),无 READY 拉低时防止读到 stale 数据
|
||||
inline constexpr uint32_t OTA_POLL_NO_FRAME_DELAY_MS = 8; // 轮询未找到帧时的重试延迟(ms),无 READY 拉低时需更长
|
||||
|
||||
// ============================================================
|
||||
// 健康监控阈值
|
||||
@@ -127,4 +134,3 @@ inline constexpr uint8_t GD32_CHIP_UID[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
|
||||
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 3
|
||||
#define FW_VERSION_PATCH 36
|
||||
#define FW_VERSION_STRING "0.3.36"
|
||||
#define FW_VERSION_PATCH 39
|
||||
#define FW_VERSION_STRING "0.3.39"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-07-02 01:59:27"
|
||||
#define BUILD_TIME "2026-07-09 02:43:44"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "6335d05"
|
||||
#define GIT_HASH "661ae54"
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/*
|
||||
* can_feedback_task.cpp - 周期性轮询 GD32 三路 CAN 1Hz 反馈快照
|
||||
* can_feedback_task.cpp - 被动监听 GD32 主动推送的 CAN 反馈快照
|
||||
*
|
||||
* 通过 SPI 协议帧 0xC1 拉取 GD32 端的通道统计与最近一帧信息,
|
||||
* 用调试日志输出;其它消费者(Web/WebSocket)可直接读取
|
||||
* CanFeedbackTask::snapshot(channel) 拷贝最新一份数据。
|
||||
* 架构(GD32 主动推送,ESP32 被动接收):
|
||||
* GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页,
|
||||
* 通过 requestEsp32Read(0xC1) 下拉 SPIready 主动按铃,将每页反馈 payload
|
||||
* 推送给 ESP32。ESP32 本任务调用 checkGd32Request() 阻塞等待按铃,读到
|
||||
* 一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再
|
||||
* publish() 到 s_latest[channel] 供 Web/WebSocket 读取。
|
||||
*
|
||||
* 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。
|
||||
*
|
||||
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。
|
||||
*/
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
#include "transport/canfd_bridge.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "monitor/can_feedback_task.h"
|
||||
|
||||
@@ -17,17 +25,20 @@
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kPollIntervalMs = 1000U; ///< 1Hz 拉取节拍
|
||||
constexpr uint32_t kPollTimeoutMs = 200U; ///< 单次 SPI 事务超时
|
||||
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
|
||||
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
|
||||
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
|
||||
constexpr size_t kRingPayloadMax = 128U; ///< 反馈 payload 最大长度
|
||||
constexpr const char *kTag = "CAN_FB";
|
||||
|
||||
static can_feedback_result_t s_latest[CAN_CHANNEL_MAX];
|
||||
static bool s_valid [CAN_CHANNEL_MAX];
|
||||
static SemaphoreHandle_t s_mutex = nullptr;
|
||||
static volatile bool s_paused = false;
|
||||
static can_channel_feedback_t s_prev_stats[CAN_CHANNEL_MAX];
|
||||
static bool s_prev_valid[CAN_CHANNEL_MAX];
|
||||
static bool s_prev_poll_ok[CAN_CHANNEL_MAX];
|
||||
|
||||
/* 累积工作缓冲:跨 page 0/1 累积每通道的完整反馈 */
|
||||
static can_feedback_result_t s_work [CAN_CHANNEL_MAX];
|
||||
static uint8_t s_workIdx[CAN_CHANNEL_MAX];
|
||||
|
||||
static void publish(uint8_t ch, const can_feedback_result_t &fb)
|
||||
{
|
||||
@@ -41,55 +52,62 @@ static void publish(uint8_t ch, const can_feedback_result_t &fb)
|
||||
|
||||
static void taskLoop(void *)
|
||||
{
|
||||
DEBUG_INFO(kTag, "CAN 反馈轮询任务启动 (1Hz, 0xC1)");
|
||||
DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)");
|
||||
|
||||
uint8_t ring_type = 0U;
|
||||
uint8_t ring_payload[kRingPayloadMax] = {};
|
||||
size_t ring_len = 0U;
|
||||
|
||||
can_feedback_result_t fb{};
|
||||
for (;;) {
|
||||
/* OTA 进行中时暂停 CAN 轮询,避免抢占 SPI 导致 OTA 握手失败 */
|
||||
if (s_paused) {
|
||||
vTaskDelay(pdMS_TO_TICKS(kPollIntervalMs));
|
||||
vTaskDelay(pdMS_TO_TICKS(kPauseDelayMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (uint8_t ch = 0U; ch < CAN_CHANNEL_MAX; ++ch) {
|
||||
const int rc = canFdPollFeedback(static_cast<can_channel_t>(ch),
|
||||
&fb, kPollTimeoutMs);
|
||||
if (rc == 0) {
|
||||
publish(ch, fb);
|
||||
/* 阻塞等待 GD32 按铃并读取一帧(超时则重试) */
|
||||
esp_err_t ret = SpiMaster::instance().checkGd32Request(kWaitRingMs);
|
||||
if (ret != ESP_OK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fb.stats.error_count != 0U) {
|
||||
DEBUG_INFO(kTag,
|
||||
"CH%u seq=%lu mode=%u rx=%lu tx=%lu drop=%lu err=%lu ids=%u",
|
||||
static_cast<unsigned>(ch),
|
||||
static_cast<unsigned long>(fb.stats.snapshot_seq),
|
||||
static_cast<unsigned>(fb.stats.mode),
|
||||
static_cast<unsigned long>(fb.stats.rx_count),
|
||||
static_cast<unsigned long>(fb.stats.tx_count),
|
||||
static_cast<unsigned long>(fb.stats.rx_drop_count),
|
||||
static_cast<unsigned long>(fb.stats.error_count),
|
||||
static_cast<unsigned>(fb.valid));
|
||||
}
|
||||
/* 取出 checkGd32Request 存入的 type/payload */
|
||||
if (!SpiMaster::instance().takeRingRx(&ring_type, ring_payload,
|
||||
sizeof(ring_payload), &ring_len)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
s_prev_stats[ch] = fb.stats;
|
||||
s_prev_valid[ch] = true;
|
||||
s_prev_poll_ok[ch] = true;
|
||||
} else {
|
||||
if (s_prev_poll_ok[ch]) {
|
||||
DEBUG_WARN(kTag, "CH%u 轮询失败", static_cast<unsigned>(ch));
|
||||
}
|
||||
s_prev_stats[ch] = {};
|
||||
s_prev_valid[ch] = false;
|
||||
s_prev_poll_ok[ch] = false;
|
||||
if (ring_type != kSpiCanFeedbackType || ring_len < 28U) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uint8_t ch = ring_payload[0];
|
||||
if (ch >= CAN_CHANNEL_MAX) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 解析本页并累积到工作缓冲,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_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 long>(s_work[ch].stats.error_count));
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(kPollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief 启动 1Hz CAN 反馈轮询任务(幂等)。
|
||||
* @brief 启动 CAN 反馈被动监听任务(幂等)。
|
||||
* @return true 表示任务已存在或创建成功。
|
||||
*/
|
||||
bool startCanFeedbackTask()
|
||||
@@ -106,6 +124,8 @@ bool startCanFeedbackTask()
|
||||
}
|
||||
memset(s_latest, 0, sizeof(s_latest));
|
||||
memset(s_valid, 0, sizeof(s_valid));
|
||||
memset(s_work, 0, sizeof(s_work));
|
||||
memset(s_workIdx, 0, sizeof(s_workIdx));
|
||||
|
||||
BaseType_t xRet = xTaskCreatePinnedToCore(
|
||||
taskLoop,
|
||||
@@ -127,9 +147,9 @@ void pauseCanFeedbackTask(bool pause)
|
||||
{
|
||||
s_paused = pause;
|
||||
if (pause) {
|
||||
DEBUG_INFO(kTag, "CAN 轮询已暂停(OTA 进行中)");
|
||||
DEBUG_INFO(kTag, "CAN 监听已暂停(OTA 进行中)");
|
||||
} else {
|
||||
DEBUG_INFO(kTag, "CAN 轮询已恢复");
|
||||
DEBUG_INFO(kTag, "CAN 监听已恢复");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,10 +118,12 @@ char* DebugLog::ringDrainJson(int *out_len)
|
||||
if (!out_len) return NULL;
|
||||
*out_len = 0;
|
||||
int count = ringCount();
|
||||
// ★ 限制单次导出条数,避免 count=64 时 malloc(32KB) 在堆紧张时失败
|
||||
// 前端每秒轮询,16 条/次足够保持实时性;超出的旧条目下一轮再拉
|
||||
static constexpr int kMaxDrain = 16;
|
||||
if (count > kMaxDrain) count = kMaxDrain;
|
||||
int capacity = count * (LOG_RING_SIZE * 2) + 16;
|
||||
if (capacity < 16) capacity = 16;
|
||||
// 当 count 较大(如 64)时 capacity 可能达 32KB+,单次 malloc 有风险。
|
||||
// 当前为简化实现保留单次 malloc;若后续日志量增大,可改为按 8KB 分块 realloc。
|
||||
char *buf = (char*)malloc(capacity);
|
||||
if (!buf) return NULL;
|
||||
|
||||
@@ -390,14 +392,27 @@ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, ..
|
||||
}
|
||||
snprintf(prefix, sizeof(prefix), "[%s][%s]", level_str, tag);
|
||||
|
||||
// 控制单条日志栈缓冲大小,避免调用任务(如 can_fb)因日志格式化爆栈。
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
char msg[512];
|
||||
char msg[256];
|
||||
vsnprintf(msg, sizeof(msg), fmt, args);
|
||||
va_end(args);
|
||||
|
||||
char log_buf[600];
|
||||
char log_buf[320];
|
||||
int total = snprintf(log_buf, sizeof(log_buf), "%s %s\n", prefix, msg);
|
||||
|
||||
// ★ snprintf 截断时返回"本应写入的长度"(可能 > sizeof(log_buf)),
|
||||
// 直接传给 send() 会越界读取栈外内存 → 破坏相邻任务 TCB → "Stack protection fault"
|
||||
// (崩溃栈中出现 HTTP User-Agent / "/ota/relay" 等字符串即此症状)。
|
||||
// 截断到实际缓冲区大小,避免越界读。
|
||||
if (total < 0) {
|
||||
return;
|
||||
}
|
||||
if ((size_t)total >= sizeof(log_buf)) {
|
||||
total = (int)sizeof(log_buf) - 1;
|
||||
log_buf[total] = '\0';
|
||||
}
|
||||
|
||||
send((const uint8_t *)log_buf, total);
|
||||
}
|
||||
|
||||
@@ -224,19 +224,6 @@ void HealthMonitor::taskEntry(void *pvParameters)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 健康事件日志
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 记录一条健康事件日志。
|
||||
* @param event 事件名称。
|
||||
* @param value 事件值。
|
||||
*/
|
||||
void HealthMonitor::logEvent(const char *event, int value)
|
||||
{
|
||||
DEBUG_INFO("HEALTH", "[健康事件] %s: %d", event, value);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OTA 健康检查
|
||||
// ============================================================
|
||||
@@ -257,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;
|
||||
@@ -316,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 检查:
|
||||
@@ -353,30 +340,30 @@ void HealthMonitor::setWifiConnected(bool connected)
|
||||
*/
|
||||
health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
|
||||
{
|
||||
health_state_t cur = getState();
|
||||
health_state_t next = SYS_NORMAL;
|
||||
if (rssi < WIFI_RSSI_CRITICAL) {
|
||||
setState(SYS_CRITICAL);
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL);
|
||||
logEvent("WIFI危险", rssi);
|
||||
return HEALTH_MON_CRITICAL;
|
||||
next = SYS_CRITICAL;
|
||||
} else if (rssi < WIFI_RSSI_NORMAL) {
|
||||
health_state_t cur = getState();
|
||||
if (cur < SYS_WARNING) {
|
||||
setState(SYS_WARNING);
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
logEvent("WIFI警告", rssi);
|
||||
}
|
||||
return HEALTH_MON_WARNING;
|
||||
next = SYS_WARNING;
|
||||
}
|
||||
|
||||
// RSSI 已恢复正常(>= WIFI_RSSI_NORMAL):从 WARNING 或 CRITICAL 任一状态降级到 NORMAL。
|
||||
// 历史缺陷:原仅当 cur == SYS_WARNING 时降级,导致 CRITICAL 状态下 RSSI 恢复正常无法降级。
|
||||
// 注意:SAFE_SHUTDOWN 不应被 RSSI 恢复降级,仅处理 WARNING/CRITICAL。
|
||||
health_state_t cur = getState();
|
||||
if ((cur == SYS_WARNING || cur == SYS_CRITICAL) && rssi >= WIFI_RSSI_NORMAL) {
|
||||
setState(SYS_NORMAL);
|
||||
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm (从 %d 降级)", rssi, WIFI_RSSI_NORMAL, (int)cur);
|
||||
logEvent("WIFI恢复", rssi);
|
||||
// 仅在状态发生转换时打印一次,避免同一阈值下反复刷屏。
|
||||
if (next != cur) {
|
||||
if (next == SYS_CRITICAL) {
|
||||
setState(SYS_CRITICAL);
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL);
|
||||
} else if (next == SYS_WARNING) {
|
||||
setState(SYS_WARNING);
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
} else { // next == SYS_NORMAL,从 WARNING/CRITICAL 恢复
|
||||
setState(SYS_NORMAL);
|
||||
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm (从 %d 降级)", rssi, WIFI_RSSI_NORMAL, (int)cur);
|
||||
}
|
||||
}
|
||||
|
||||
if (next == SYS_CRITICAL) return HEALTH_MON_CRITICAL;
|
||||
if (next == SYS_WARNING) return HEALTH_MON_WARNING;
|
||||
|
||||
return HEALTH_MON_OK;
|
||||
}
|
||||
|
||||
@@ -65,8 +65,6 @@ public:
|
||||
/** @brief OTA 中继转发阶段执行健康检查(跳过 WiFi 检查)。 */
|
||||
health_status_t checkBeforeOtaRelay();
|
||||
|
||||
/** @brief 记录一条健康事件日志。 */
|
||||
void logEvent(const char *event, int value);
|
||||
|
||||
/** @brief 获取当前系统健康状态(线程安全)。 */
|
||||
health_state_t getCurrent();
|
||||
|
||||
@@ -456,6 +456,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 开始: %" PRIu32 " 字节, 系统状态 → ESP32_RECEIVING (epoch=%u, channel=%d)",
|
||||
total_size, m_otaEpoch, (int)channel);
|
||||
pauseCanFeedbackTask(true);
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
@@ -582,6 +583,7 @@ void OtaCoordinator::doAbortLocked()
|
||||
Esp32OtaCore::instance().abort();
|
||||
}
|
||||
|
||||
pauseCanFeedbackTask(false);
|
||||
m_sysState = OTA_SYS_IDLE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0;
|
||||
|
||||
+57
-17
@@ -278,6 +278,8 @@ ota_result_t OtaRelay::handleHandshaking(relay_event_t event, const uint8_t *pay
|
||||
DEBUG_INFO("OTA", "=== 阶段1: 握手与认证开始 ===");
|
||||
|
||||
// 步骤1: 请求 GD32 进入 Bootloader
|
||||
// 0xB0 对 APP:触发写标志+复位 → BOOT 启动
|
||||
// 0xB0 对 BOOT:返回 0xB5 + resetToIdle()(IDLE 状态下无害)
|
||||
esp_err_t err = requestGd32AppEnterBootloader();
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN("OTA", "GD32 进入 Bootloader 请求未确认,继续尝试握手");
|
||||
@@ -778,31 +780,35 @@ void OtaRelay::calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48,
|
||||
esp_err_t OtaRelay::requestGd32AppEnterBootloader()
|
||||
{
|
||||
DEBUG_INFO("OTA", "尝试通知 GD32 APP 进入 Bootloader OTA 窗口");
|
||||
esp_err_t err = SpiMaster::instance().appRequestBootloader();
|
||||
if (err == ESP_OK) {
|
||||
DEBUG_INFO("OTA", "GD32 已确认进入 Bootloader,等待就绪");
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
return ESP_OK;
|
||||
SpiMaster::instance().appRequestBootloader();
|
||||
|
||||
/* 方案A: 通过 PB6 LOW→HIGH 序列等待 Bootloader 就绪
|
||||
* APP 模式: 复位前拉低 PB6 → BOOT 极早拉低 → init 完成释放 HIGH
|
||||
* BOOT 模式: PB6 一直 HIGH,Phase1 超时后直接握手
|
||||
* 超时退化为固定延时(容错旧固件) */
|
||||
esp_err_t wait_err = SpiMaster::instance().waitForBootloaderReady(3000);
|
||||
if (wait_err != ESP_OK) {
|
||||
DEBUG_WARN("OTA", "硬件就绪信号超时,退化为固定延时");
|
||||
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||
}
|
||||
DEBUG_WARN("OTA", "GD32 APP 未确认进入 Bootloader: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/** @brief 协议子步骤:发送握手请求(0x00 0xAC)并等待 GD32 回显 ACK。 */
|
||||
ota_result_t OtaRelay::doHandshakeReq()
|
||||
/** @brief 协议子步骤:发送握手请求(0x00 0xAC)并等待 GD32 回显 ACK。
|
||||
* @param attempts 最大尝试次数,默认 20。先用 3 次快速探测 GD32 是否已在 BOOT。 */
|
||||
ota_result_t OtaRelay::doHandshakeReq(uint32_t attempts)
|
||||
{
|
||||
DEBUG_INFO("OTA", "--- 握手请求 ---");
|
||||
DEBUG_INFO("OTA", "--- 握手请求 (max=%" PRIu32 ") ---", attempts);
|
||||
|
||||
uint8_t payload[] = {0x00, 0xAC};
|
||||
uint8_t last_resp_type = 0U;
|
||||
size_t last_resp_len = 0U;
|
||||
esp_err_t last_err = ESP_OK;
|
||||
constexpr uint32_t kHandshakeAttempts = 10U;
|
||||
constexpr uint32_t kHandshakeReqTimeoutMs = 200U;
|
||||
constexpr uint32_t kHandshakePollTimeoutMs = 200U;
|
||||
constexpr uint32_t kHandshakeRetryGapMs = 100U;
|
||||
|
||||
for (uint32_t attempt = 0U; attempt < kHandshakeAttempts; ++attempt) {
|
||||
for (uint32_t attempt = 0U; attempt < attempts; ++attempt) {
|
||||
uint8_t resp_type = 0U;
|
||||
uint8_t resp_payload[64] = {0};
|
||||
size_t resp_len = 0U;
|
||||
@@ -830,7 +836,7 @@ ota_result_t OtaRelay::doHandshakeReq()
|
||||
}
|
||||
|
||||
DEBUG_WARN("OTA", "HANDSHAKE_REQ 未就绪,重试 %" PRIu32 "/%" PRIu32 ": err=%d resp_type=0x%02X resp_len=%u",
|
||||
attempt + 1U, kHandshakeAttempts, last_err, last_resp_type, (unsigned)last_resp_len);
|
||||
attempt + 1U, attempts, last_err, last_resp_type, (unsigned)last_resp_len);
|
||||
vTaskDelay(pdMS_TO_TICKS(kHandshakeRetryGapMs));
|
||||
}
|
||||
|
||||
@@ -961,7 +967,8 @@ ota_result_t OtaRelay::prepareRelayPacket(uint32_t seq, const ota_meta_t &meta,
|
||||
return OTA_RESULT_ERR_STORAGE;
|
||||
}
|
||||
|
||||
tx_buf[0] = 0x00; tx_buf[1] = 0x00; tx_buf[2] = 0x00; tx_buf[3] = 0x00;
|
||||
/* session_num = nonce_server 前 4 字节,GD32 端做校验防重放/乱序 */
|
||||
memcpy(&tx_buf[0], m_nonceServer, 4);
|
||||
tx_buf[4] = (uint8_t)(seq & 0xFFU);
|
||||
tx_buf[5] = (uint8_t)((seq >> 8) & 0xFFU);
|
||||
tx_buf[6] = 0x00;
|
||||
@@ -995,8 +1002,12 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
|
||||
size_t resp_len = 0;
|
||||
uint32_t retry_count = 0;
|
||||
ota_result_t result = OTA_RESULT_OK;
|
||||
bool got_expected_credit_ok = false;
|
||||
const uint32_t expected_ack = (seq == m_transferTotalPackets - 1) ? kLastPacketAckSeq : (seq + 1U);
|
||||
|
||||
// ★ 单包耗时统计,用于定位 OTA 速度瓶颈。
|
||||
TickType_t pkt_start = xTaskGetTickCount();
|
||||
|
||||
// 注意:goto resend_packet 保持原有语义,仅作用范围缩小到本 helper。
|
||||
// 完全去除 goto 需配合 GD32 全链路 OTA 回归验证后再进行。
|
||||
resend_packet:
|
||||
@@ -1034,7 +1045,30 @@ resend_packet:
|
||||
}
|
||||
}
|
||||
|
||||
/* CREDIT 感知:若 GD32 报 CREDIT=0,说明它在写 Flash 缓冲,
|
||||
* 此时重发只会被丢弃。改为耐心 POLL 等待 CREDIT 恢复,最多等 500ms。 */
|
||||
if (SpiMaster::instance().remoteCredit() == 0U) {
|
||||
DEBUG_DBG("OTA", "包 %" PRIu32 " GD32 CREDIT=0(写Flash中),POLL 等待...", seq);
|
||||
for (uint32_t cw = 0; cw < 50U; ++cw) {
|
||||
err = SpiMaster::instance().otaPollResponse(&resp_type, resp_payload,
|
||||
sizeof(resp_payload), &resp_len, 100);
|
||||
if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK &&
|
||||
isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) {
|
||||
got_expected_credit_ok = true;
|
||||
break;
|
||||
}
|
||||
if (err == ESP_OK && SpiMaster::instance().remoteCredit() > 0U) {
|
||||
break; /* CREDIT 恢复,可以继续轮询或重发 */
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
}
|
||||
}
|
||||
|
||||
bool got_expected = false;
|
||||
if (got_expected_credit_ok) {
|
||||
m_transferFlashOffset += (uint32_t)chunk_size;
|
||||
break;
|
||||
}
|
||||
for (uint32_t retry = 0; retry < 10; ++retry) {
|
||||
DEBUG_DBG("OTA", "包 %" PRIu32 " 收到非目标ACK(seq不匹配),继续轮询(%" PRIu32 "/5)...", seq, retry + 1);
|
||||
err = SpiMaster::instance().otaPollResponse(&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 2000);
|
||||
@@ -1050,6 +1084,7 @@ resend_packet:
|
||||
}
|
||||
// ★ resend 前延迟
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
got_expected_credit_ok = false;
|
||||
goto resend_packet;
|
||||
}
|
||||
if (isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) {
|
||||
@@ -1091,6 +1126,10 @@ resend_packet:
|
||||
break;
|
||||
}
|
||||
|
||||
TickType_t pkt_elapsed = xTaskGetTickCount() - pkt_start;
|
||||
DEBUG_DBG("OTA", "包 %" PRIu32 " 完成: elapsed=%" PRIu32 " ms, retries=%" PRIu32, seq,
|
||||
(uint32_t)(pkt_elapsed * portTICK_PERIOD_MS), retry_count);
|
||||
|
||||
if (result != OTA_RESULT_OK) {
|
||||
*out_result = result;
|
||||
return true; // 中止外层循环
|
||||
@@ -1151,8 +1190,8 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick)
|
||||
|
||||
// ★ 包间延迟:给 GD32 Bootloader 充足时间完成 flash 写入并准备下一包 ACK。
|
||||
// 无此延迟时,ESP32 立即 poll 会读到上一包的 stale ACK,导致误判为丢包。
|
||||
if (seq < m_transferTotalPackets - 1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
if (seq < m_transferTotalPackets - 1 && CONFIG_DCU_OTA_PACKET_INTERVAL_MS > 0) {
|
||||
vTaskDelay(pdMS_TO_TICKS(CONFIG_DCU_OTA_PACKET_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -1202,7 +1241,8 @@ ota_result_t OtaRelay::doSendFinishOta()
|
||||
memset(resp_payload, 0, sizeof(resp_payload));
|
||||
|
||||
esp_err_t err = SpiMaster::instance().otaTransfer(SPI_OTA_CMD_DATA, finish_payload, sizeof(finish_payload),
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
CONFIG_DCU_OTA_FINISH_TIMEOUT_MS);
|
||||
logRespPayload("FINISH_OTA", resp_type, resp_payload, resp_len);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN("OTA", "FINISH_OTA 传输失败, err=%d, retry=%" PRIu32 "/10", err, retry + 1);
|
||||
|
||||
@@ -317,7 +317,7 @@ private:
|
||||
* @brief 执行握手请求(发送 HANDSHAKE_REQ).
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_TIMEOUT 超时。
|
||||
*/
|
||||
ota_result_t doHandshakeReq();
|
||||
ota_result_t doHandshakeReq(uint32_t attempts = 20U);
|
||||
/**
|
||||
* @brief 执行挑战认证.
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_AUTH 认证失败。
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -418,7 +418,7 @@ esp_err_t WifiOta::httpServerStart()
|
||||
config.server_port = HTTP_SERVER_PORT;
|
||||
config.stack_size = HTTP_SERVER_STACK_SIZE;
|
||||
config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检
|
||||
config.max_open_sockets = 4; // LWIP_MAX_SOCKETS 默认 7,HTTP 内部占 3,最多 4
|
||||
config.max_open_sockets = 4; // 配合 LWIP_MAX_SOCKETS=12: 4(httpd) + 1(UDP) + ~3(LWIP内部) = 8, 余量 4; 空闲时前端 status/logs/上传可能并发,3 个易被 LRU 踢掉导致连接中断
|
||||
config.lru_purge_enable = true; // socket 满时自动清理最久未用的连接
|
||||
config.recv_wait_timeout = 30; // GD32 上传前会擦除 Flash,5 秒默认值过紧
|
||||
config.send_wait_timeout = 30;
|
||||
@@ -734,6 +734,30 @@ void WifiOta::taskEntry(void *pvParameters)
|
||||
DEBUG_ERR("WIFI", "HTTP 服务器启动失败");
|
||||
}
|
||||
|
||||
// ★ 开机清理:如果 NVS 中有不完整的 OTA 元数据(上次 OTA 中断),
|
||||
// GD32 可能还卡在 Bootloader 中,导致 SPI 全面超时 → HTTP socket 饥荒。
|
||||
// 主动清除残留元数据并尝试恢复 GD32 APP。
|
||||
{
|
||||
ota_meta_t boot_meta;
|
||||
if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK &&
|
||||
boot_meta.total_size > 0 && boot_meta.stored_size < boot_meta.total_size) {
|
||||
DEBUG_WARN("WIFI", "检测到不完整 OTA 缓存 (stored=%u total=%u),清除残留状态",
|
||||
(unsigned)boot_meta.stored_size, (unsigned)boot_meta.total_size);
|
||||
OtaCoordinator::instance().clearGd32Meta();
|
||||
OtaCoordinator::instance().abortCurrent();
|
||||
// 给 GD32 时间从 Bootloader 超时退出并重启到 APP
|
||||
vTaskDelay(pdMS_TO_TICKS(3000));
|
||||
// 尝试探测 GD32 APP 是否恢复
|
||||
gd32_version_t ver = {};
|
||||
if (OtaCoordinator::instance().probeGd32AppOnce(&ver, nullptr) == ESP_OK) {
|
||||
DEBUG_INFO("WIFI", "GD32 APP 已恢复: v%d.%d.%d.%d",
|
||||
ver.app_major, ver.app_minor, ver.app_patch, ver.app_build);
|
||||
} else {
|
||||
DEBUG_WARN("WIFI", "GD32 APP 未恢复,SPI 链路可能不可用");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ WiFi 已连接,UDP 从这里开始能通
|
||||
// 等待网络栈稳定(ARP 表建立、路由就绪),避免首批 UDP 包丢失
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
@@ -772,16 +796,9 @@ void WifiOta::taskEntry(void *pvParameters)
|
||||
int8_t rssi = WifiOta::getRssi();
|
||||
if (rssi != -127) {
|
||||
HealthMonitor::instance().updateFromWifiRssi(rssi);
|
||||
|
||||
if (rssi < WIFI_RSSI_CRITICAL) {
|
||||
DEBUG_WARN("WIFI", "WiFi RSSI 危险: %d dBm (阈值: %d)", rssi, WIFI_RSSI_CRITICAL);
|
||||
} else if (rssi < WIFI_RSSI_WARNING) {
|
||||
DEBUG_WARN("WIFI", "WiFi RSSI 较弱: %d dBm", rssi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <string.h>
|
||||
#include "canfd_bridge.h"
|
||||
#include "spi_master.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -29,25 +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) {
|
||||
return -1;
|
||||
}
|
||||
tx_buf, tx_len,
|
||||
&resp_type, recv_buf, recv_max, &resp_len, timeout_ms);
|
||||
|
||||
if (recv_buf == nullptr || recv_max == 0U) {
|
||||
return static_cast<int>(resp_len);
|
||||
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 通信失败 */
|
||||
}
|
||||
|
||||
const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max;
|
||||
return static_cast<int>(copied_len);
|
||||
if (resp_type == SPI_OTA_RSP_ACK) {
|
||||
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;
|
||||
@@ -67,6 +73,7 @@ static inline uint32_t readU32Le(const uint8_t *p) {
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应。
|
||||
* 通过 SPI 协议帧发送给 GD32 APP,并读取 ACK/ERROR 响应。
|
||||
* 返回值: >=0 成功(接收数据长度), -1 参数错误, -2 SPI通信失败, -3 GD32拒绝。
|
||||
*/
|
||||
int canFdSend(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
@@ -95,90 +102,80 @@ int canFdSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
|
||||
|
||||
/**
|
||||
* @brief 拉取一路 CAN 通道的 1Hz 反馈 + ID 快照表(GD32 SPI 帧类型 0xC1,分页)。
|
||||
* 连续拉取 page=0 和 page=1,合并为完整结果。
|
||||
* @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。
|
||||
*
|
||||
* GD32 APP 通过 requestEsp32Read(0xC1) 主动推送每页反馈,ESP32 收到后
|
||||
* 调用本函数解析:
|
||||
* page=0 → 重置 out,填充 28 字节固定头 + 首批条目;
|
||||
* page=1 → 在已有条目后追加。
|
||||
* 调用方按通道维护 entryIdx,page=0 时会被重置为 0。
|
||||
*/
|
||||
int canFdPollFeedback(can_channel_t channel,
|
||||
can_feedback_result_t *out,
|
||||
uint32_t timeout_ms)
|
||||
int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
|
||||
can_feedback_result_t *out, uint8_t *entryIdx)
|
||||
{
|
||||
if (channel >= CAN_CHANNEL_MAX || out == nullptr) {
|
||||
if (payload == nullptr || out == nullptr || entryIdx == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out->valid = 0;
|
||||
uint8_t entryIdx = 0U;
|
||||
|
||||
constexpr uint8_t kFixedHeader = 28U; /* ch+page+total+cnt+mode+seq+rx+tx+drop+err+tec+rec+bus_state */
|
||||
|
||||
for (uint8_t page = 0U; page < 2U; ++page) {
|
||||
uint8_t req[2] = { static_cast<uint8_t>(channel & CAN_CHANNEL_MASK), page };
|
||||
uint8_t resp_buf[kFeedbackPayloadLen] = {};
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().otaTransfer(
|
||||
kSpiCanFeedbackType,
|
||||
req, 2U,
|
||||
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms);
|
||||
if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < kFixedHeader) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 第一页时解析固定头 */
|
||||
if (page == 0U) {
|
||||
out->stats.channel = resp_buf[0];
|
||||
out->stats.mode = resp_buf[4];
|
||||
out->stats.snapshot_seq = readU32Le(&resp_buf[5]);
|
||||
out->stats.rx_count = readU32Le(&resp_buf[9]);
|
||||
out->stats.tx_count = readU32Le(&resp_buf[13]);
|
||||
out->stats.rx_drop_count = readU32Le(&resp_buf[17]);
|
||||
out->stats.error_count = readU32Le(&resp_buf[21]);
|
||||
out->stats.tec = resp_buf[25];
|
||||
out->stats.rec = resp_buf[26];
|
||||
out->stats.bus_state = resp_buf[27];
|
||||
out->total_count = resp_buf[2];
|
||||
}
|
||||
|
||||
const uint8_t entryCount = resp_buf[3];
|
||||
if (entryCount == 0U) break;
|
||||
|
||||
uint16_t offset = kFixedHeader;
|
||||
for (uint8_t i = 0U; i < entryCount && entryIdx < CANFD_ID_SNAPSHOT_MAX; ++i) {
|
||||
if (offset + 8U > resp_len) break;
|
||||
|
||||
id_snapshot_entry_t &e = out->entries[entryIdx];
|
||||
e.id = readU32Le(&resp_buf[offset]);
|
||||
e.dlc = resp_buf[offset + 4U];
|
||||
const uint8_t flags = resp_buf[offset + 5U];
|
||||
e.fd_flag = (flags & 0x01U) ? 1U : 0U;
|
||||
e.brs = (flags & 0x02U) ? 1U : 0U;
|
||||
e.esi = (flags & 0x04U) ? 1U : 0U;
|
||||
|
||||
const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX)
|
||||
? e.dlc : static_cast<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
|
||||
const uint16_t entrySize = static_cast<uint16_t>(8U + dataLen);
|
||||
if (offset + entrySize > resp_len) break;
|
||||
|
||||
memcpy(e.data, &resp_buf[offset + 8U], dataLen);
|
||||
for (uint8_t d = dataLen; d < CANFD_ID_SNAPSHOT_DATA_MAX; ++d) {
|
||||
e.data[d] = 0U;
|
||||
}
|
||||
|
||||
offset += entrySize;
|
||||
entryIdx++;
|
||||
}
|
||||
|
||||
if (entryIdx >= out->total_count) break;
|
||||
constexpr uint8_t kFixedHeader = 28U;
|
||||
if (len < kFixedHeader) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const uint8_t channel = payload[0];
|
||||
const uint8_t page = payload[1];
|
||||
if (channel >= CAN_CHANNEL_MAX) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* page=0:重置并解析固定头 */
|
||||
if (page == 0U) {
|
||||
*entryIdx = 0U;
|
||||
out->stats.channel = channel;
|
||||
out->stats.mode = payload[4];
|
||||
out->stats.snapshot_seq = readU32Le(&payload[5]);
|
||||
out->stats.rx_count = readU32Le(&payload[9]);
|
||||
out->stats.tx_count = readU32Le(&payload[13]);
|
||||
out->stats.rx_drop_count = readU32Le(&payload[17]);
|
||||
out->stats.error_count = readU32Le(&payload[21]);
|
||||
out->stats.tec = payload[25];
|
||||
out->stats.rec = payload[26];
|
||||
out->stats.bus_state = payload[27];
|
||||
out->total_count = payload[2];
|
||||
}
|
||||
|
||||
/* 解析本页条目并追加 */
|
||||
const uint8_t entryCount = payload[3];
|
||||
uint16_t offset = kFixedHeader;
|
||||
for (uint8_t i = 0U; i < entryCount && *entryIdx < CANFD_ID_SNAPSHOT_MAX; ++i) {
|
||||
if (offset + 8U > len) break;
|
||||
|
||||
id_snapshot_entry_t &e = out->entries[*entryIdx];
|
||||
e.id = readU32Le(&payload[offset]);
|
||||
e.dlc = payload[offset + 4U];
|
||||
const uint8_t flags = payload[offset + 5U];
|
||||
e.fd_flag = (flags & 0x01U) ? 1U : 0U;
|
||||
e.brs = (flags & 0x02U) ? 1U : 0U;
|
||||
e.esi = (flags & 0x04U) ? 1U : 0U;
|
||||
|
||||
const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX)
|
||||
? e.dlc : static_cast<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
|
||||
const uint16_t entrySize = static_cast<uint16_t>(8U + dataLen);
|
||||
if (offset + entrySize > len) break;
|
||||
|
||||
memcpy(e.data, &payload[offset + 8U], dataLen);
|
||||
for (uint8_t d = dataLen; d < CANFD_ID_SNAPSHOT_DATA_MAX; ++d) {
|
||||
e.data[d] = 0U;
|
||||
}
|
||||
|
||||
offset += entrySize;
|
||||
(*entryIdx)++;
|
||||
}
|
||||
|
||||
out->valid = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 切换 GD32 三路 CAN 接收过滤模式(GD32 SPI 帧类型 0xC2)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief 拉取指定通道的不重复 ID 快照表(GD32 SPI 帧类型 0xC3)。
|
||||
* 分页拉取,每页 8 条,最多拉 2 页(16 条)。
|
||||
@@ -201,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);
|
||||
@@ -262,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;
|
||||
}
|
||||
|
||||
@@ -121,15 +121,20 @@ typedef struct {
|
||||
} can_feedback_result_t;
|
||||
|
||||
/**
|
||||
* @brief 拉取一路 CAN 通道的 1Hz 反馈快照。
|
||||
* @param channel 目标通道 0/1/2。
|
||||
* @param out 输出快照(不可为 nullptr)。
|
||||
* @param timeout_ms SPI 事务超时。
|
||||
* @return 0 成功,<0 失败(ERROR 应答、payload 长度不足或 SPI 错误)。
|
||||
* @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。
|
||||
*
|
||||
* GD32 APP 通过 requestEsp32Read(0xC1) 主动推送每页反馈,ESP32 收到后
|
||||
* 调用本函数解析。page=0 时重置 out 并填充固定头 + 首批条目;
|
||||
* page=1 时在已有条目后追加。每页解析后置 out->valid=1。
|
||||
*
|
||||
* @param payload GD32 推送帧的 payload([channel, page, total, count, mode, seq, ...])。
|
||||
* @param len payload 长度。
|
||||
* @param out 累积结果(跨 page 0/1 累积)。
|
||||
* @param entryIdx 当前已填充条目数(page 0 时被重置为 0,调用方按通道维护)。
|
||||
* @return 0 成功,<0 失败(payload 过短或通道非法)。
|
||||
*/
|
||||
int canFdPollFeedback(can_channel_t channel,
|
||||
can_feedback_result_t *out,
|
||||
uint32_t timeout_ms);
|
||||
int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
|
||||
can_feedback_result_t *out, uint8_t *entryIdx);
|
||||
|
||||
/**
|
||||
* @brief 切换 GD32 三路 CAN 的接收过滤模式。
|
||||
|
||||
+594
-255
File diff suppressed because it is too large
Load Diff
+157
-96
@@ -3,27 +3,39 @@
|
||||
* CANFD 3通道发送接口 + 底层 SPI 总线
|
||||
*
|
||||
* SpiMaster 类封装 SPI 总线初始化、CANFD 通信和 SPI OTA 协议。
|
||||
*
|
||||
* 握手架构(线与 SPIready,GPIO10<->GD32 PB6 开漏单线):
|
||||
* - 空闲态线 HIGH(双方释放,靠上拉)。
|
||||
* - ESP32 主动发起:下拉 SPIready -> 立刻 CS 发请求 -> CS 释放后保持下拉,
|
||||
* GD32 在 onCsRelease() ISR 内完成帧处理+响应装载后释放 SPIready(高),
|
||||
* ESP32 GPIO 上升沿中断唤醒后发起 CS#2 读响应。
|
||||
* - GD32 主动按铃:下拉 SPIready -> ESP32 下降沿中断唤醒后发起读事务。
|
||||
*/
|
||||
#ifndef SPI_MASTER_H
|
||||
#define SPI_MASTER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "driver/spi_master.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
// SPI 配置常量
|
||||
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
|
||||
#define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
|
||||
#define SPI_CLOCK_HZ CONFIG_DCU_SPI_CLOCK_HZ // 默认 4MHz,可在 menuconfig 手动提速
|
||||
#define SPI_MODE (0) // CPOL=0, CPHA=0
|
||||
#define SPI_QUEUE_SIZE (1) ///< SPI 事务队列深度
|
||||
#define SPI_OTA_FRAME_MAX (133) ///< SPI OTA 单帧最大字节数
|
||||
#define SPI_QUEUE_SIZE (4) ///< SPI 事务队列深度
|
||||
#define SPI_READY_GPIO (10) // 线与 SPIready 握手引脚(开漏,GPIO10<->GD32 PB6)
|
||||
#define SPI_READY_TIMEOUT_MS (200) // 等待 SPIready 上升沿超时(毫秒)
|
||||
#define SPI_OTA_FRAME_MAX (256) ///< SPI 同步帧最大字节数
|
||||
// ESP32 下拉 SPIready 后到拉 CS 之间的微秒延时(零延时起步,按需测算调大)
|
||||
#define SPI_READY_DRIVE_DELAY_US (0)
|
||||
|
||||
// GD32 SPI OTA 帧类型
|
||||
#define SPI_OTA_SOF (0xA5)
|
||||
// GD32 SPI 同步帧类型
|
||||
#define SPI_SYNC_SOF0 (0xA5)
|
||||
#define SPI_SYNC_SOF1 (0x5A)
|
||||
#define SPI_OTA_CMD_REQ (0x01)
|
||||
#define SPI_OTA_CMD_DATA (0x02)
|
||||
#define SPI_OTA_CMD_CANCEL (0x03)
|
||||
#define SPI_APP_ENTER_BOOT_CMD (0xB0)
|
||||
#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5) // GD32 APP 固件须同步修改 ACK 字节为 0xB5
|
||||
#define SPI_OTA_RSP_ACK (0x80)
|
||||
@@ -60,21 +72,6 @@ public:
|
||||
/** @brief 初始化 SPI2 总线与目标从设备。 */
|
||||
esp_err_t init();
|
||||
|
||||
// ============================================================
|
||||
// 底层 SPI 传输
|
||||
// ============================================================
|
||||
|
||||
/** @brief 执行一次同步 SPI 事务,先发送请求,再读取响应字节。 */
|
||||
esp_err_t syncTransfer(uint8_t cmd, const uint8_t *payload,
|
||||
size_t payload_len, uint8_t *recv_buf,
|
||||
size_t recv_len, uint32_t timeout_ms);
|
||||
|
||||
/** @brief 仅发送 SPI 请求数据,不关心返回内容。 */
|
||||
esp_err_t send(uint8_t cmd, const uint8_t *payload, size_t len);
|
||||
|
||||
/** @brief 仅接收 SPI 数据,必要时先发送命令字节。 */
|
||||
esp_err_t recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms);
|
||||
|
||||
// ============================================================
|
||||
// SPI OTA 协议
|
||||
// ============================================================
|
||||
@@ -88,21 +85,70 @@ 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; }
|
||||
|
||||
/** @brief 通知 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。 */
|
||||
esp_err_t appRequestBootloader();
|
||||
|
||||
/** @brief 方案A: 通过 PB6 LOW→HIGH 序列等待 Bootloader 就绪。
|
||||
* Phase1 轮询等待 LOW (APP 复位前拉低 / Bootloader 极早拉低);
|
||||
* Phase2 轮询等待 HIGH 并确认稳定 10ms (Bootloader init 完成释放)。
|
||||
* 超时返回 ESP_ERR_TIMEOUT,调用方应退化为固定延时。 */
|
||||
esp_err_t waitForBootloaderReady(uint32_t timeout_ms);
|
||||
|
||||
/** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */
|
||||
esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr);
|
||||
|
||||
/** @brief 执行带重试的 SPI 同步传输。 */
|
||||
esp_err_t transferWithRetry(uint8_t cmd, const uint8_t *payload,
|
||||
size_t payload_len, uint8_t *recv_buf,
|
||||
size_t recv_len, int max_retries);
|
||||
|
||||
// ── CANFD 接口已迁移至 transport/canfd_bridge.h(M4 解耦)──
|
||||
|
||||
/** @brief 获取 SPI 设备句柄(供外部状态查询)。 */
|
||||
spi_device_handle_t deviceHandle() const { return m_spiDevice; }
|
||||
/**
|
||||
* @brief 检查 GD32 是否有主动按铃请求,若有则发起读事务读取请求帧。
|
||||
*
|
||||
* GD32 下拉 SPIready 表示「我有请求帧要给你读」。此函数检测到该状态后
|
||||
* 发起一次 SPI 读事务(发 dummy 字节,读回 GD32 准备的请求帧)。
|
||||
* 读取成功后按帧 TYPE 交给上层处理(如 CAN feedback 轮询、重启请求等)。
|
||||
*
|
||||
* @param timeout_ms 等待 GD32 请求的超时(毫秒),0 表示不等待仅检查。
|
||||
* @return ESP_OK 成功读到请求帧;ESP_ERR_NOT_FOUND 当前无请求;其余为错误码。
|
||||
*
|
||||
* @note 应在主循环或 OTA 空闲时周期调用,以响应 GD32 主动上报。
|
||||
*/
|
||||
esp_err_t checkGd32Request(uint32_t timeout_ms = 0U);
|
||||
|
||||
/**
|
||||
* @brief 取出最近一次 GD32 按铃请求帧(线程安全,取出后清空内部缓冲)。
|
||||
*
|
||||
* checkGd32Request() 成功读取一帧后将其 type/payload 存入内部缓冲,
|
||||
* 上层调用本函数取出并消费。取出后缓冲清空,避免重复消费。
|
||||
*
|
||||
* @param[out] type 请求帧 TYPE(如 0xC1)。
|
||||
* @param[out] payload 输出 payload 拷贝缓冲。
|
||||
* @param[in] max_len payload 缓冲最大长度。
|
||||
* @param[out] len 实际 payload 长度。
|
||||
* @return true 表示有数据被取出;false 表示当前无数据。
|
||||
*/
|
||||
bool takeRingRx(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len);
|
||||
|
||||
private:
|
||||
/**
|
||||
@@ -125,34 +171,95 @@ private:
|
||||
spi_device_handle_t m_spiDevice = NULL;
|
||||
SemaphoreHandle_t m_mutex = NULL;
|
||||
|
||||
// ── 线与 SPIready 握手状态机 ──
|
||||
/** 握手状态机枚举。 */
|
||||
enum class HandshakeState : uint8_t {
|
||||
IDLE = 0, ///< 空闲,线 HIGH
|
||||
MASTER_SENDING, ///< ESP32 已下拉 SPIready,正在 CS#1 发送请求
|
||||
WAITING_RESPONSE, ///< CS#1 完成,等待 GD32 释放 SPIready(上升沿唤醒)
|
||||
SERVING_GD32_RING, ///< 响应 GD32 按铃,正在读 GD32 请求帧
|
||||
WAITING_BOOT_READY, ///< 方案A: 等待 Bootloader 就绪(LOW→HIGH 序列)
|
||||
};
|
||||
|
||||
/**
|
||||
* @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 唤醒)。 */
|
||||
TaskHandle_t m_spiTaskHandle = NULL;
|
||||
/** GD32 按铃请求帧缓冲区。 */
|
||||
uint8_t m_ringRxBuf[SPI_OTA_FRAME_MAX] = {};
|
||||
size_t m_ringRxLen = 0;
|
||||
uint8_t m_ringRxType = 0; ///< GD32 按铃请求帧 TYPE(checkGd32Request 填充)
|
||||
|
||||
// ── 帧内同步状态机 ──
|
||||
volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口)
|
||||
volatile uint8_t m_remoteCredit = 8; ///< 对端上报的剩余信用
|
||||
|
||||
// RAII 锁守卫
|
||||
class LockGuard {
|
||||
public:
|
||||
/**
|
||||
* @brief 构造加锁守卫,获取互斥锁。
|
||||
* @param mutex 互斥锁句柄。
|
||||
*/
|
||||
explicit LockGuard(SemaphoreHandle_t mutex) : m_lock(mutex) {
|
||||
explicit LockGuard(SemaphoreHandle_t lock) : m_lock(lock) {
|
||||
if (m_lock) { xSemaphoreTake(m_lock, portMAX_DELAY); }
|
||||
}
|
||||
/**
|
||||
* @brief 析构解锁守卫,释放互斥锁。
|
||||
*/
|
||||
~LockGuard() {
|
||||
if (m_lock) { xSemaphoreGive(m_lock); }
|
||||
}
|
||||
/**
|
||||
* @brief 禁止复制构造。
|
||||
*/
|
||||
LockGuard(const LockGuard&) = delete;
|
||||
/**
|
||||
* @brief 禁止复制赋值。
|
||||
*/
|
||||
LockGuard& operator=(const LockGuard&) = delete;
|
||||
private:
|
||||
SemaphoreHandle_t m_lock;
|
||||
};
|
||||
|
||||
/** @brief 下拉 SPIready(开漏驱动低)。 */
|
||||
void driveReadyLow();
|
||||
/** @brief 释放 SPIready(开漏高阻,靠上拉拉高)。 */
|
||||
void releaseReady();
|
||||
/**
|
||||
* @brief 等 GD32 释放 SPIready(上升沿),中断驱动非忙等。
|
||||
*
|
||||
* 调用前须将 m_linkState 置为 WAITING_RESPONSE(enterBusy),ISR 上升沿检测到该状态
|
||||
* 后发 0x02 通知唤醒本任务。GD32 写 Flash 期间保持 PB6 低,本函数阻塞,
|
||||
* 写完拉高 → 唤醒 → 继续,自然避免 overrun。
|
||||
*
|
||||
* @param timeout_ms 超时(毫秒),0 表示不等待。
|
||||
* @return ESP_OK 线已高;ESP_ERR_TIMEOUT 超时。
|
||||
*/
|
||||
esp_err_t waitSpiReady(uint32_t timeout_ms);
|
||||
|
||||
/** @brief GPIO 边沿中断回调(注册到 ESP-IDF GPIO ISR 服务)。 */
|
||||
static void IRAM_ATTR onReadyEdgeISR(void *arg);
|
||||
|
||||
// 内部 SPI 传输
|
||||
/**
|
||||
* @brief 执行底层 SPI 时钟同步传输。
|
||||
@@ -166,21 +273,6 @@ private:
|
||||
esp_err_t transferClocked(const uint8_t *tx_buf, size_t tx_len,
|
||||
uint8_t *rx_buf, size_t rx_len,
|
||||
uint32_t timeout_ms = 0U);
|
||||
/**
|
||||
* @brief 执行 SPI 请求-响应组合传输。
|
||||
* @param cmd 命令字节(0 表示无命令)。
|
||||
* @param payload 负载数据缓冲区。
|
||||
* @param payload_len 负载数据长度。
|
||||
* @param rx_buf 接收数据缓冲区。
|
||||
* @param rx_len 期望接收的数据长度。
|
||||
* @param timeout_ms 超时时间(毫秒),0 表示无限等待。
|
||||
* @return ESP_OK 表示成功,否则返回错误码。
|
||||
*/
|
||||
esp_err_t transferRequestResponse(uint8_t cmd, const uint8_t *payload, size_t payload_len,
|
||||
uint8_t *rx_buf, size_t rx_len,
|
||||
uint32_t timeout_ms = 0U);
|
||||
|
||||
|
||||
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
|
||||
/**
|
||||
* @brief 无锁版本的 OTA 响应轮询(调用者须已持有锁)。
|
||||
@@ -193,51 +285,20 @@ 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 帧工具(静态,无状态)
|
||||
/**
|
||||
* @brief 计算 OTA 帧的 8 位 CRC 校验和。
|
||||
* @param data 数据缓冲区。
|
||||
* @param len 数据长度。
|
||||
* @return 计算得到的 CRC 值。
|
||||
*/
|
||||
static uint8_t otaCrc8(const uint8_t *data, size_t len);
|
||||
/**
|
||||
* @brief 构建 OTA 协议帧。
|
||||
* @param type 帧类型。
|
||||
* @param payload 负载数据缓冲区。
|
||||
* @param payload_len 负载数据长度。
|
||||
* @param out 输出帧缓冲区。
|
||||
* @param out_len 输出实际帧长度。
|
||||
* @return true 表示构建成功,false 表示失败。
|
||||
*/
|
||||
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,
|
||||
uint8_t *out, size_t *out_len);
|
||||
/**
|
||||
* @brief 解析 OTA 协议帧。
|
||||
* @param frame 帧数据缓冲区。
|
||||
* @param frame_len 帧数据长度。
|
||||
* @param type 输出解析得到的帧类型。
|
||||
* @param payload 输出指向负载数据的指针。
|
||||
* @param payload_len 输出负载数据长度。
|
||||
* @return true 表示解析成功,false 表示失败。
|
||||
*/
|
||||
static bool otaParseFrame(const uint8_t *frame, size_t frame_len,
|
||||
uint8_t *type, const uint8_t **payload, size_t *payload_len);
|
||||
/**
|
||||
* @brief 在数据缓冲区中搜索并解析 OTA 帧。
|
||||
* @param buf 数据缓冲区。
|
||||
* @param buf_len 数据缓冲区长度。
|
||||
* @param type 输出帧类型。
|
||||
* @param payload 输出指向负载数据的指针。
|
||||
* @param payload_len 输出负载数据长度。
|
||||
* @param frame_offset 输出帧在缓冲区中的偏移位置。
|
||||
* @return true 表示找到并成功解析,false 表示未找到。
|
||||
*/
|
||||
static bool otaFindFrame(const uint8_t *buf, size_t buf_len,
|
||||
uint8_t *type, const uint8_t **payload, size_t *payload_len,
|
||||
size_t *frame_offset);
|
||||
};
|
||||
|
||||
#endif // SPI_MASTER_H
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -1,9 +1,19 @@
|
||||
# WiFi perf -- OTA acceleration
|
||||
CONFIG_ESP_WIFI_RX_BA_WIN=6
|
||||
CONFIG_ESP_WIFI_TX_BA_WIN=6
|
||||
CONFIG_DCU_OTA_POLL_BACKOFF_MS=5
|
||||
CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000
|
||||
|
||||
# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 = 7 够用
|
||||
CONFIG_LWIP_MAX_SOCKETS=7
|
||||
# DCU SPI clock -- default 4 MHz, can be raised via menuconfig to speed up GD32 OTA
|
||||
CONFIG_DCU_SPI_CLOCK_HZ=4000000
|
||||
|
||||
# DCU OTA packet interval -- default 1 ms, can be lowered to speed up GD32 OTA
|
||||
CONFIG_DCU_OTA_PACKET_INTERVAL_MS=1
|
||||
|
||||
# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 + UDP debug log + 余量
|
||||
# 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE)
|
||||
# 提到 12 留足缓冲,配合 can_feedback_task 退避从根本上避免 socket 饥荒
|
||||
CONFIG_LWIP_MAX_SOCKETS=12
|
||||
CONFIG_LWIP_TCP_WND_DEFAULT=11480
|
||||
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
|
||||
|
||||
|
||||
+21
-23
@@ -1,24 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
serial_monitor.py - ESP32 串口日志采集工具
|
||||
import serial, time, sys
|
||||
|
||||
用法: python serial_monitor.py
|
||||
依赖: pip install pyserial
|
||||
功能: 监听 COM15 串口 (115200 波特率),将输出记录到 serial_log.txt,
|
||||
最多运行 5 分钟。
|
||||
"""
|
||||
import serial, sys, time
|
||||
ser = serial.Serial('COM15', 115200, timeout=1) # COM 口和波特率根据实际修改
|
||||
outfile = open('G:\\Robot_v2\\DCU\\ESP32_COMM\\serial_log.txt', 'w', encoding='utf-8') # 输出文件路径
|
||||
start = time.time()
|
||||
while time.time() - start < 300: # 最多 5 分钟
|
||||
line = ser.readline()
|
||||
if line:
|
||||
try:
|
||||
text = line.decode('utf-8', errors='replace').rstrip()
|
||||
outfile.write(text + '\n')
|
||||
outfile.flush()
|
||||
except:
|
||||
pass
|
||||
outfile.close()
|
||||
ser.close()
|
||||
port = 'COM15'
|
||||
baud = 115200
|
||||
outfile = r'G:\Robot_v2\DCU\ESP32_COMM\build\monitor_log.txt'
|
||||
|
||||
print('Monitoring %s at %d baud...' % (port, baud), flush=True)
|
||||
|
||||
try:
|
||||
s = serial.Serial(port, baud, timeout=1)
|
||||
with open(outfile, 'w') as f:
|
||||
while True:
|
||||
data = s.readline()
|
||||
if data:
|
||||
line = data.decode('utf-8', errors='replace').rstrip()
|
||||
ts = time.strftime('%H:%M:%S')
|
||||
msg = '[%s] %s' % (ts, line)
|
||||
print(msg, flush=True)
|
||||
f.write(msg + '\n')
|
||||
f.flush()
|
||||
except Exception as e:
|
||||
print('Error: %s' % e, flush=True)
|
||||
+124
-34
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* app.js - ESP32 OTA 中继控制台主脚本(WiFi OTA 通道)
|
||||
*
|
||||
* 职责:
|
||||
@@ -27,6 +27,7 @@ let latestSystemStatus = null;
|
||||
let isEsp32Rebooting = false;
|
||||
let rebootStartTime = 0;
|
||||
let pollingPausedForUpload = false;
|
||||
let uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
|
||||
let activeXhr = null; // 当前上传 XHR,用于 abort
|
||||
let uploadBtnOrigText = ''; // 上传按钮原始文字
|
||||
let activeUploadChip = null; // 当前上传目标芯片 ('esp32' | 'gd32' | null)
|
||||
@@ -588,6 +589,9 @@ function updateHeapAndTaskStatusUi(data) {
|
||||
|
||||
setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : ''));
|
||||
setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--');
|
||||
// 缓存最近一次成功的 sys_state_code / relay_state,供 pollStatus 失败时判断设备是否在忙
|
||||
if (Number.isFinite(ota.sys_state_code)) lastSysStateCode = ota.sys_state_code;
|
||||
if (Number.isFinite(ota.relay_state)) lastRelayState = ota.relay_state;
|
||||
var storedText = '--';
|
||||
if (ota.total_size || ota.stored_size) {
|
||||
storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0);
|
||||
@@ -795,6 +799,7 @@ function updateSystemStatusUi(data) {
|
||||
if (stErr) stErr.textContent = 'GD32 错误(待机)';
|
||||
} else if (isGd32Complete || rs === 5 || (gd32AppAlive && sysState === 'GD32_COMPLETE')) {
|
||||
markPhaseDone('gd32Ota');
|
||||
currentOtaTarget = null; // GD32 OTA 完成,允许切换目标
|
||||
setOtaProgressBadge(100);
|
||||
setProgress(100, 'GD32 刷写完成');
|
||||
var sbDone = document.getElementById('stageStatusBar');
|
||||
@@ -830,6 +835,7 @@ function updateSystemStatusUi(data) {
|
||||
setOtaProgressBadge(95);
|
||||
if (gd32AppAlive) {
|
||||
markPhaseDone('gd32Ota');
|
||||
currentOtaTarget = null; // GD32 OTA 完成,允许切换目标
|
||||
setProgress(100, 'GD32 刷写完成');
|
||||
var sb = document.getElementById('stageStatusBar');
|
||||
var st = document.getElementById('stageStatusText');
|
||||
@@ -854,9 +860,12 @@ async function testConnection() {
|
||||
restorePolling();
|
||||
}
|
||||
// 立即发一次请求检测
|
||||
// 手动检测使用独立的 AbortController,避免打断递归调度的 pollStatus
|
||||
var testAbort = new AbortController();
|
||||
var testTimer = setTimeout(function() { try { testAbort.abort(); } catch(_e) {} }, 3000);
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
signal: testAbort.signal
|
||||
});
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
@@ -878,6 +887,8 @@ async function testConnection() {
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
showToast('无法连接: ' + diag, 'error');
|
||||
} finally {
|
||||
clearTimeout(testTimer);
|
||||
}
|
||||
}
|
||||
async function refreshSystemStatusNow(event) {
|
||||
@@ -885,10 +896,12 @@ async function refreshSystemStatusNow(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (pollingPausedForUpload) return;
|
||||
// 手动刷新使用独立的 AbortController,避免与递归调度的 pollStatus 互相干扰
|
||||
var manualAbort = new AbortController();
|
||||
var manualTimer = setTimeout(function() { try { manualAbort.abort(); } catch(_e) {} }, 3000);
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
signal: manualAbort.signal
|
||||
});
|
||||
if (!resp.ok) {
|
||||
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
|
||||
@@ -904,6 +917,8 @@ async function refreshSystemStatusNow(event) {
|
||||
} catch (e) {
|
||||
addLog('E', '状态', '刷新失败: ' + e.message);
|
||||
showToast('状态刷新失败', 'error');
|
||||
} finally {
|
||||
clearTimeout(manualTimer);
|
||||
}
|
||||
}
|
||||
// ==================== CAN 帧发送 ====================
|
||||
@@ -1599,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('应用请求失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1659,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('应用请求失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1749,6 +1767,11 @@ var logsTimerId = null;
|
||||
var pollSlowMode = false; // 降速模式(10s 一次)
|
||||
var lastDiagTime = 0; // 上次诊断时间
|
||||
var diagResult = ''; // 最近一次诊断结果
|
||||
var lastSysStateCode = -1; // 上次成功的 sys_state_code,用于失败时判断设备是否在忙
|
||||
var lastRelayState = 0; // 上次成功的 relay_state
|
||||
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用)
|
||||
var statusPollIntervalMs = 1000; // 状态轮询目标间隔
|
||||
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
|
||||
// 从设备 IP 推导网关 IP(取前三段 + .1)
|
||||
function getGatewayIp() {
|
||||
var parts = getEspIp().split('.');
|
||||
@@ -1789,61 +1812,93 @@ async function diagnoseNetwork() {
|
||||
diagResult = result.join(';');
|
||||
return diagResult;
|
||||
}
|
||||
/** @description 调度下一次 /system/status 轮询(递归 setTimeout)。
|
||||
* 上一次请求完成(或 3s 超时)后才调度下一次,避免 setInterval 把慢响应
|
||||
* 直接 abort 而导致堆/任务栈/CPU 等卡片刷新率不稳。
|
||||
*/
|
||||
function scheduleStatusPoll(delayMs) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
statusTimerId = setTimeout(function() {
|
||||
statusTimerId = null;
|
||||
pollStatus();
|
||||
}, delayMs !== undefined ? delayMs : statusPollIntervalMs);
|
||||
}
|
||||
|
||||
/** @description 启动定时轮询(状态 + 日志)。 */
|
||||
|
||||
function startPolling() {
|
||||
consecutivePollFailures = 0;
|
||||
pollSlowMode = false;
|
||||
if (statusTimerId) clearInterval(statusTimerId);
|
||||
statusPollIntervalMs = 1000;
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
statusTimerId = setInterval(pollStatus, 1000);
|
||||
scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
function slowDownPolling() {
|
||||
pollSlowMode = true;
|
||||
if (statusTimerId) clearInterval(statusTimerId);
|
||||
statusPollIntervalMs = 10000;
|
||||
// 不中断正在进行的请求;它完成后会按新的 10s 间隔调度下一次
|
||||
if (!statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
scheduleStatusPoll(statusPollIntervalMs);
|
||||
}
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
statusTimerId = setInterval(pollStatus, 10000);
|
||||
logsTimerId = setInterval(pollLogs, 10000);
|
||||
}
|
||||
function restorePolling() {
|
||||
if (pollSlowMode) {
|
||||
pollSlowMode = false;
|
||||
consecutivePollFailures = 0;
|
||||
if (statusTimerId) clearInterval(statusTimerId);
|
||||
statusPollIntervalMs = 1000;
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
statusTimerId = setInterval(pollStatus, 1000);
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
}
|
||||
// 如果当前没有请求在飞,立即触发一次,确保 UI 尽快恢复
|
||||
if (!statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
scheduleStatusPoll(0);
|
||||
}
|
||||
}
|
||||
function pausePollingForUpload() {
|
||||
if (pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = true;
|
||||
uploadPollChip = activeUploadChip || window.flashTarget || null;
|
||||
consecutivePollFailures = 0;
|
||||
if (statusTimerId) clearInterval(statusTimerId);
|
||||
statusPollIntervalMs = uploadPollChip === "gd32" ? 2500 : 3000;
|
||||
// ★ ESP32/GD32 上传期间均保持轮询,让用户在网页上看到完整 OTA 进展
|
||||
// ESP32 用 3s 间隔(上传为主),GD32 用 2.5s(中继进度依赖 status)
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
statusTimerId = null;
|
||||
logsTimerId = null;
|
||||
scheduleStatusPoll(statusPollIntervalMs);
|
||||
logsTimerId = setInterval(pollLogs, statusPollIntervalMs);
|
||||
}
|
||||
function resumePollingAfterUpload() {
|
||||
if (!pollingPausedForUpload) return;
|
||||
pollingPausedForUpload = false;
|
||||
uploadPollChip = null;
|
||||
consecutivePollFailures = 0;
|
||||
if (pollSlowMode) {
|
||||
statusTimerId = setInterval(pollStatus, 10000);
|
||||
logsTimerId = setInterval(pollLogs, 10000);
|
||||
statusPollIntervalMs = pollSlowMode ? 10000 : 1000;
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
logsTimerId = setInterval(pollLogs, statusPollIntervalMs);
|
||||
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
|
||||
if (statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
} else {
|
||||
statusTimerId = setInterval(pollStatus, 1000);
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
scheduleStatusPoll(0);
|
||||
}
|
||||
pollStatus();
|
||||
pollLogs();
|
||||
}
|
||||
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
|
||||
async function pollStatus() {
|
||||
if (pollingPausedForUpload) return;
|
||||
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
|
||||
if (statusPollInFlight) return;
|
||||
statusPollInFlight = true;
|
||||
statusAbortCtrl = new AbortController();
|
||||
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 3000);
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
signal: statusAbortCtrl.signal
|
||||
});
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
@@ -1865,12 +1920,26 @@ async function pollStatus() {
|
||||
}
|
||||
try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
|
||||
}
|
||||
clearTimeout(pollTimer);
|
||||
} catch (e) {
|
||||
clearTimeout(pollTimer);
|
||||
// 自身 AbortController 触发的取消不算失败,静默返回
|
||||
if (e && e.name === "AbortError") return;
|
||||
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
|
||||
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
|
||||
if (pageVisible) consecutivePollFailures++;
|
||||
// 判断设备是否处于"已知忙/重启"状态(依据最近一次成功轮询的 sys_state_code):
|
||||
// 1=GD32_STORING, 3=GD32_RELAYING, 5=ESP32_RECEIVING,
|
||||
// 6=ESP32_FINALIZING, 7=ESP32_REBOOTING
|
||||
// 也包含 relay_state 非 IDLE(1-4 表示握手/协商/传输/收尾)
|
||||
var lastDeviceBusy =
|
||||
lastSysStateCode === 1 || lastSysStateCode === 3 ||
|
||||
lastSysStateCode === 5 || lastSysStateCode === 6 ||
|
||||
lastSysStateCode === 7 ||
|
||||
(lastRelayState >= 1 && lastRelayState <= 4);
|
||||
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget) {
|
||||
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget && !lastDeviceBusy) {
|
||||
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
@@ -1895,10 +1964,25 @@ async function pollStatus() {
|
||||
} else {
|
||||
applyRebootingRuntimeState();
|
||||
}
|
||||
} else if (lastDeviceBusy) {
|
||||
// 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙"
|
||||
// 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导)
|
||||
var pc = document.getElementById('pillConn');
|
||||
var pct = document.getElementById('pillConnTxt');
|
||||
if (pc) pc.className = 'pill processing';
|
||||
if (pct) pct.textContent = '设备忙';
|
||||
var cs = document.getElementById('connStatus');
|
||||
var cst = document.getElementById('connStatusTxt');
|
||||
if (cs) cs.className = 'conn-indicator warn';
|
||||
if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询';
|
||||
updateConnectionUi(true, '设备忙');
|
||||
} else if (!pollSlowMode) {
|
||||
updateConnectionUi(false, '未连接');
|
||||
applyDisconnectedRuntimeState();
|
||||
}
|
||||
} finally {
|
||||
statusPollInFlight = false;
|
||||
scheduleStatusPoll();
|
||||
}
|
||||
}
|
||||
startPolling();
|
||||
@@ -1910,7 +1994,13 @@ document.addEventListener('visibilitychange', function () {
|
||||
pageVisible = !document.hidden;
|
||||
if (!pageVisible) return;
|
||||
consecutivePollFailures = 0;
|
||||
pollStatus();
|
||||
// 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排
|
||||
if (statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
} else {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
scheduleStatusPoll(0);
|
||||
}
|
||||
pollLogs();
|
||||
// 之前若在慢速模式(10s),切回前台先用 1s 观察一会儿
|
||||
if (pollSlowMode) restorePolling();
|
||||
@@ -1919,7 +2009,7 @@ document.addEventListener('visibilitychange', function () {
|
||||
var lastLogTimestamp = 0;
|
||||
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
|
||||
async function pollLogs() {
|
||||
if (pollingPausedForUpload) return;
|
||||
// ★ ESP32 上传期间也允许日志轮询
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/logs', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
|
||||
+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');
|
||||
}
|
||||
|
||||
+15
-6
@@ -1039,12 +1039,6 @@
|
||||
return json;
|
||||
}
|
||||
|
||||
function buildSendResponseText(result) {
|
||||
if (result.status === 'ok') {
|
||||
return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据';
|
||||
}
|
||||
return '发送失败: code=' + result.code;
|
||||
}
|
||||
|
||||
function updateResponseBox(box, textEl, result, mode) {
|
||||
if (!box || !textEl) return;
|
||||
@@ -1478,5 +1472,20 @@
|
||||
setTimeout(testConnection, 150);
|
||||
}
|
||||
|
||||
var CAN_SEND_ERR_DESC = {
|
||||
'-1': '参数错误(通道/ID/DLC 无效)',
|
||||
'-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)',
|
||||
'-3': 'GD32 CAN 总线拒绝(BUS_OFF / TEC 过高 / 邮箱满)',
|
||||
};
|
||||
|
||||
function buildSendResponseText(result) {
|
||||
if (result.status === 'ok') {
|
||||
return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据';
|
||||
}
|
||||
var code = String(result.code);
|
||||
var desc = CAN_SEND_ERR_DESC[code] || '未知错误';
|
||||
return '发送失败: ' + desc + ' (code=' + result.code + ')';
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
|
||||
@@ -53,8 +53,12 @@
|
||||
|
||||
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
|
||||
async function pollFeedback() {
|
||||
// 取消上一次 in-flight 请求,防止 SPI 慢响应堆积导致 UI 瞬间刷新多次
|
||||
if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch(_e) {} }
|
||||
state.fbAbortCtrl = new AbortController();
|
||||
var fbPollTimer = setTimeout(function() { try { state.fbAbortCtrl.abort(); } catch(_e) {} }, 3000);
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) });
|
||||
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal });
|
||||
if (!resp.ok) return;
|
||||
var data = await resp.json();
|
||||
var channels = data.channels || [];
|
||||
@@ -86,7 +90,11 @@
|
||||
el.fbPollStatus.className = 'pill online';
|
||||
}
|
||||
setConnState(true, '已连接');
|
||||
clearTimeout(fbPollTimer);
|
||||
} catch (e) {
|
||||
clearTimeout(fbPollTimer);
|
||||
// 自身 AbortController 触发的取消不算失败,静默返回
|
||||
if (e && e.name === "AbortError") return;
|
||||
if (el.fbPollStatus) {
|
||||
el.fbPollStatus.textContent = '轮询: 离线';
|
||||
el.fbPollStatus.className = 'pill offline';
|
||||
|
||||
+9
-2
@@ -1,4 +1,4 @@
|
||||
<!--
|
||||
<!--
|
||||
index.html - ESP32 OTA 中继控制台主页面
|
||||
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
|
||||
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA), style.css (UI 样式)
|
||||
@@ -427,7 +427,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260626a"></script>
|
||||
<script src="app.js?v=20260705b"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
@@ -484,6 +484,13 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
};
|
||||
|
||||
window.selectFlashTarget = function (target) {
|
||||
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
|
||||
if (window.isUploading) {
|
||||
var active = (window.activeUploadChip || 'OTA').toUpperCase();
|
||||
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
|
||||
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
|
||||
return;
|
||||
}
|
||||
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
|
||||
applySharedModeUi();
|
||||
syncBleFileFromSelectedTarget();
|
||||
|
||||
Reference in New Issue
Block a user