Compare commits

..
3 Commits
Author SHA1 Message Date
小龙虾 fedb08e215 fix: 修复:改进OTA错误处理和BLE OTA性能
- 重构app.js中的OTA错误处理,以准确报告获取失败而不假设重启,从而实现正确的状态轮询。
- 通过将握手状态封装在SpiLinkState类中来增强SPI主状态管理,提高清晰度并减少直接的状态操作。
 引入otaSendAndPoll方法,以更好地处理SPI通信中的命令发送和响应轮询。
- 调整BLE OTA数据包确认逻辑,防止导致固件录变慢的虚假确认,确保仅在实际取得进展时才发送确认。
- 修改确认超时设置,以更好地适应闪存写入速度,减少不重传。
2026-07-09 02:49:24 +08:00
小龙虾 661ae54424 feat(ota): 优化 GD32 OTA 流程和 CAN 反馈机制
- 实现 GD32 主动推送 CAN 反馈架构,替代 ESP32 1Hz 轮询方式,
  减少 SPI 总线占用提升带宽效率

- 添加 GD32 Bootloader 就绪检测机制,通过 PB6 硬件信号等待
  保证 OTA 过程稳定可靠

- 集成 CREDIT 感知功能,当 GD32 写 Flash 缓冲时自动等待,
  避免数据包丢失提高传输成功率

- 重构 canFdPollFeedback 为 canFdParseFeedbackPage 函数,
  支持被动解析 GD32 主动推送的数据页

- 更新版本号从 0.37 到 0.3.38 并添加 Kconfig 配置选项支持
2026-07-08 02:51:38 +08:00
小龙虾 069ce8e6a5 feat(transport): 添加SPI Ready握手引脚支持并优化GPIO驱动依赖
添加ESP32-C3 GPIO驱动组件以支持SPI Ready握手机制,新增
SPI_READY_GPIO(10)引脚配置用于监测GD32 PB6就绪状态,避免SPI
通信时从机未准备好的丢帧问题。
2026-07-06 19:14:13 +08:00
33 changed files with 1528 additions and 1911 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 相互独立,可并行实现
+2 -1
View File
@@ -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)
-100
View File
@@ -1,100 +0,0 @@
# 临时语法验证脚本:用 riscv32-esp-elf-g++ -fsyntax-only 验证 gd32_flash_store.cpp
# 验证完成后删除本脚本。
import json
import os
import shlex
import subprocess
import sys
ROOT = r"g:\Robot_v2\DCU\ESP32_COMM"
CC_PATH = os.path.join(ROOT, "build", "compile_commands.json")
# 对照测试:可切换目标源文件
TARGET_NAME = os.environ.get("VERIFY_SRC", "gd32_flash_store.cpp")
TARGET_SRC = os.path.join(ROOT, "main", "storage", TARGET_NAME) if TARGET_NAME == "gd32_flash_store.cpp" else os.path.join(ROOT, "main", "ota", TARGET_NAME)
RESP = os.path.join(ROOT, "build", "_syntax_resp.txt")
with open(CC_PATH, encoding="utf-8") as f:
db = json.load(f)
entry = None
for e in db:
if e.get("file", "").endswith(TARGET_NAME):
entry = e
break
if not entry:
print("ERROR: 未找到", TARGET_NAME, "的编译条目")
sys.exit(2)
work_dir = entry["directory"]
# 任务要求:gcc_path 从 compile_commands.json 第一条 command 提取
first_cmd = shlex.split(db[0]["command"], posix=False)
gcc_path = first_cmd[0]
gxx_path = gcc_path.replace("riscv32-esp-elf-gcc.exe", "riscv32-esp-elf-g++.exe")
if gxx_path == gcc_path:
print("ERROR: 未能从 gcc 路径推导 g++ 路径:", gcc_path)
sys.exit(2)
if not os.path.exists(gxx_path):
print("ERROR: g++ 不存在:", gxx_path)
sys.exit(2)
# 目标条目的编译参数(去掉首位的编译器路径,改用推导出的 g++)
args = shlex.split(entry["command"], posix=False)
# 过滤:去掉 -o 及其参数,去掉源文件名
filtered = []
i = 1
while i < len(args):
a = args[i]
if a == "-o":
i += 2 # 跳过 -o 和输出文件
continue
if a.startswith("-o") and len(a) > 2:
i += 1 # -oXXX 形式
continue
if os.path.abspath(a) == os.path.abspath(TARGET_SRC):
i += 1
continue
if a.endswith("gd32_flash_store.cpp"):
i += 1
continue
filtered.append(a)
i += 1
# 组装新命令:g++ + 原 args(去 -o/源文件) + -fsyntax-only -Wall + 源文件
# 注意1:原始构建用 -std=gnu++26(GNU 扩展模式),若覆盖为 -std=c++17 会定义 __STRICT_ANSI__,
# 导致 newlib <stdio.h> 的 FILE 类型不暴露而报错(与本次修改无关的 newlib 兼容性问题)。
# 故保留原始 -std=gnu++26,仅追加 -fsyntax-only -Wall;-Werror 原始已有,可严格检查本文件。
# 注意2:freertos/esp_hw_support 头文件链引用 FILE,但独立 syntax check 时 newlib <stdio.h>
# 的 FILE typedef 被跳过(ota_coordinator.cpp 同样报错,与本次修改无关)。
# 加 -include stdio.h 预先定义 FILE,绕过此无关问题,仅验证本文件语法。
final_args = [gxx_path] + filtered + ["-fsyntax-only", "-Wall", "-include", "stdio.h", TARGET_SRC]
# 写 response file:每行一个参数。
# GCC @file 解析时反斜杠会被当转义符吃掉,因此对所有参数统一处理:
# 反斜杠 -> \\,双引号 -> \",并用双引号包裹(双引号内 \\=\" 才是字面)。
def quote_for_resp(p):
esc = p.replace("\\", "\\\\").replace('"', '\\"')
return '"' + esc + '"'
with open(RESP, "w", encoding="utf-8") as f:
for a in final_args[1:]: # 第一个是 gxx 本身,放命令行
f.write(quote_for_resp(a) + "\n")
print("WORK_DIR:", work_dir)
print("GXX:", gxx_path)
print("RESP:", RESP)
print("ARGS(excl gxx):", len(final_args) - 1, "params")
# 诊断:预处理后查找 FILE 的 typedef,确认 FILE 是否被定义
diag_args = [gxx_path] + filtered + ["-E", "-include", "stdio.h", TARGET_SRC]
print("--- 诊断(-E,grep typedef FILE/__FILE/__sFILE) ---")
proc = subprocess.run(diag_args, cwd=work_dir, capture_output=True, text=True)
import re
for line in proc.stdout.splitlines():
s = line.strip()
if ("typedef" in s and ("FILE" in s or "__sFILE" in s)):
print(s)
print("RETURN CODE:", proc.returncode)
+1
View File
@@ -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 # 高精度定时器
+3 -2
View File
@@ -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
+42
View File
@@ -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
+8 -2
View File
@@ -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 -4
View File
@@ -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"
+64 -44
View File
@@ -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",
/* 取出 checkGd32Request 存入的 type/payload */
if (!SpiMaster::instance().takeRingRx(&ring_type, ring_payload,
sizeof(ring_payload), &ring_len)) {
continue;
}
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 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));
}
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;
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 监听已恢复");
}
}
+19 -4
View File
@@ -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);
}
+20 -33
View File
@@ -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)
{
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;
} 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;
health_state_t next = SYS_NORMAL;
if (rssi < WIFI_RSSI_CRITICAL) {
next = SYS_CRITICAL;
} else if (rssi < WIFI_RSSI_NORMAL) {
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) {
// 仅在状态发生转换时打印一次,避免同一阈值下反复刷屏。
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);
logEvent("WIFI恢复", rssi);
}
}
if (next == SYS_CRITICAL) return HEALTH_MON_CRITICAL;
if (next == SYS_WARNING) return HEALTH_MON_WARNING;
return HEALTH_MON_OK;
}
-2
View File
@@ -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();
+2
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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 认证失败。
+18 -13
View File
@@ -1144,6 +1144,7 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
}
// ★ 使用带序号顺序保护的写入:拒绝乱序包并立即 ACK 通知发送端
uint32_t beforeReceived = (uint32_t)Esp32OtaCore::instance().getReceived();
esp_err_t err = esp32_write_ordered(seq, cmd, &buf[data_offset], data_len);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s",
@@ -1152,23 +1153,29 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
return true;
}
DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u",
seq, (unsigned int)Esp32OtaCore::instance().getReceived());
// ★ 仅在实际写入新字节时才触发 ACK:重复包/乱序缓存包不写入 flash,
// 若在窗口边界触发 ACK 会携带陈旧字节数,被发送端误判为丢包。
uint32_t afterReceived = (uint32_t)Esp32OtaCore::instance().getReceived();
bool progressMade = (afterReceived > beforeReceived);
DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u progress=%d",
seq, (unsigned int)afterReceived, progressMade ? 1 : 0);
// 完成判定:以实际按序写入字节数为准。
bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD);
bool transferComplete = (total_size > 0 &&
Esp32OtaCore::instance().getReceived() >= total_size);
bool transferComplete = (total_size > 0 && afterReceived >= total_size);
// ACK 触发条件
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
// ACK 触发条件:仅在实际写入新字节时触发窗口边界 ACK,
// 避免重复包/乱序缓存包在窗口边界发送陈旧 ACK
if (progressMade &&
((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
transferComplete ||
isLastPacket) {
isLastPacket)) {
if (isLastPacket || transferComplete) {
DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32,
seq, data_len, (unsigned int)Esp32OtaCore::instance().getReceived(), total_size);
seq, data_len, (unsigned int)afterReceived, total_size);
}
sendWindowAck(cmd, seq, (uint32_t)Esp32OtaCore::instance().getReceived());
sendWindowAck(cmd, seq, afterReceived);
}
if (transferComplete) {
@@ -1226,13 +1233,11 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
// 策略与 ESP32 fallback 路径一致(参考 esp32_write_ordered 行 459-477)。
if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) {
// 重复包:seq <= last_seq(已写入过)
// ★ 重复包静默跳过,不发送 ACK:发送端已持有上一窗口的有效 ACK 进度,
// 重复包的 ACK 会携带未增长的字节数,被发送端误判为当前窗口丢包。
if (seq <= meta.last_seq) {
DEBUG_DBG(TAG, "GD32 重复包: seq=%u <= last_seq=%" PRIu32 ",跳过",
seq, meta.last_seq);
// 重复包不触发 ACK 风暴,只在窗口边界发一次
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) {
sendWindowAck(cmd, (uint16_t)meta.last_seq, meta.stored_size);
}
return false; // 外层推进 offset,继续下一包
}
+3 -3
View File
@@ -805,7 +805,7 @@ esp_err_t can_mode_handler(httpd_req_t *req)
httpd_resp_send(req, resp, len);
} else {
can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS;
int rc = canFdSetRxMode(CAN_RX_MODE_ALL_PASS, &actual, 500);
int rc = canFdSetRxMode(CAN_RX_MODE_ALL_PASS, &actual, 2000);
char resp[64];
int len = snprintf(resp, sizeof(resp),
"{\"mode\":%u,\"source\":\"spi\",\"ok\":%s}",
@@ -847,7 +847,7 @@ esp_err_t can_mode_handler(httpd_req_t *req)
target = CAN_RX_MODE_BRIDGE_0_TO_12;
}
can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS;
int rc = canFdSetRxMode(target, &actual, 500);
int rc = canFdSetRxMode(target, &actual, 2000);
char resp[96];
int len = snprintf(resp, sizeof(resp),
@@ -909,7 +909,7 @@ esp_err_t can_send_handler(httpd_req_t *req)
uint8_t resp_data[CAN_MAX_DLC] = {0};
int resp_len = canFdSend(ch, ext_id, tx_data, tx_dlc,
resp_data, sizeof(resp_data), 1000);
resp_data, sizeof(resp_data), 2000);
char resp[512];
if (resp_len >= 0) {
+25 -8
View File
@@ -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));
}
}
+69 -69
View File
@@ -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 (ret != ESP_OK) {
DEBUG_WARN("CAN", "CAN_SEND SPI失败: ch=%u id=0x%08lX err=%s",
(unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret));
return -2; /* SPI 通信失败 */
}
if (resp_type == SPI_OTA_RSP_ACK) {
if (recv_buf == nullptr || recv_max == 0U) {
return static_cast<int>(resp_len);
}
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,62 +102,58 @@ 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) {
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) {
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];
*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 = resp_buf[3];
if (entryCount == 0U) break;
/* 解析本页条目并追加 */
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 > resp_len) break;
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(&resp_buf[offset]);
e.dlc = resp_buf[offset + 4U];
const uint8_t flags = resp_buf[offset + 5U];
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;
@@ -158,27 +161,21 @@ int canFdPollFeedback(can_channel_t channel,
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;
if (offset + entrySize > len) break;
memcpy(e.data, &resp_buf[offset + 8U], dataLen);
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++;
(*entryIdx)++;
}
if (entryIdx >= out->total_count) break;
}
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;
}
return -1;
}
+13 -8
View File
@@ -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 的接收过滤模式。
File diff suppressed because it is too large Load Diff
+157 -96
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1302,7 +1302,7 @@
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize });
await waitForAck(cfg.packetCmd, endSeq, 5000, { expectedTotalSize: totalSize, payloadSize });
} catch (e) {
bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W');
}
+15 -6
View File
@@ -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();
})();
+9 -1
View File
@@ -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
View File
@@ -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();