Compare commits
55
Commits
6f4be9189c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff590d8996 | ||
|
|
af1f68222e | ||
|
|
1b50ce1657 | ||
|
|
84f496c22c | ||
|
|
5315f91475 | ||
|
|
ebb3dcdf38 | ||
|
|
ef6d3a4be3 | ||
|
|
f9cbcc396b | ||
|
|
04a118219f | ||
|
|
e9cb7342f6 | ||
|
|
74eb68b140 | ||
|
|
33b41fd50a | ||
|
|
2138a572ee | ||
|
|
770ae14156 | ||
|
|
00b716c8db | ||
|
|
b807645a20 | ||
|
|
1b10f89d8c | ||
|
|
6364eacfb1 | ||
|
|
5db3f66b8e | ||
|
|
51c89b3065 | ||
|
|
a47a027178 | ||
|
|
5a89cec34b | ||
|
|
164855a36c | ||
|
|
adb3386da4 | ||
|
|
76782349a9 | ||
|
|
0b363492ed | ||
|
|
c0ca4e2a9f | ||
|
|
fedb08e215 | ||
|
|
661ae54424 | ||
|
|
069ce8e6a5 | ||
|
|
2fe5393769 | ||
|
|
6335d05198 | ||
|
|
a93dba60d2 | ||
|
|
6ef97307c3 | ||
|
|
7a41eac526 | ||
|
|
1491e9a3de | ||
|
|
00ce8dbbb5 | ||
|
|
3fff959a2b | ||
|
|
59c729d77a | ||
|
|
7feef70750 | ||
|
|
41fee936df | ||
|
|
2586582e57 | ||
|
|
98b5cc1ba1 | ||
|
|
5b2c5f8ff9 | ||
|
|
5060db0a8e | ||
|
|
2875ef2145 | ||
|
|
929faf1d38 | ||
|
|
9ca53ae66e | ||
|
|
2caf10b9de | ||
|
|
6a6bc9dab4 | ||
|
|
d8e4222fec | ||
|
|
a59364e239 | ||
|
|
c4d78bdfbc | ||
|
|
1586a81b97 | ||
|
|
56f26fa0d1 |
@@ -0,0 +1,58 @@
|
||||
# SPI Link v2 PC 自测 CI 门禁
|
||||
#
|
||||
# 当 spi_link 协议实现变更时,在 PC 上编译运行 SPI_LINK_V2_SELFTEST 自测,
|
||||
# 自动阻断帧格式 / CRC / 最大 payload / 版本回归。
|
||||
#
|
||||
# 布局约定:spi_link/ 是三工程(ESP32_COMM、DCU_APP、GD32E50X_BOOT)的共享
|
||||
# 协议兄弟目录,与各仓库同级。本仓库通过相对路径 ../../../spi_link/ 引用它。
|
||||
# 因此本 workflow 检出本仓库后,需在同一次检出中提供 spi_link/。
|
||||
#
|
||||
# 触发:push / PR 触及 spi_link 兄弟目录(由调用方检出)或本仓库内的 SPI Link
|
||||
# 相关文件。由于 spi_link 在本仓库之外,路径过滤仅能覆盖本仓库侧的引用改动。
|
||||
name: SPI Link v2 Self-Test
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'components/transport/**'
|
||||
- '.github/workflows/spi-link-selftest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'components/transport/**'
|
||||
- '.github/workflows/spi-link-selftest.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
selftest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout ESP32_COMM
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout shared spi_link(兄弟协议目录)
|
||||
# spi_link/ 是独立维护的共享协议源,与三工程同级。
|
||||
# 在 CI 中通过同源仓库的另一路径提供;若 spi_link 独立成仓,请改为
|
||||
# actions/checkout 指向该仓库并放到 ../spi_link。
|
||||
run: |
|
||||
# 期望运行器在 checkout 本仓前已将 spi_link 放到上级目录;
|
||||
# 退路:若上级无 spi_link,则从本仓 components 引用的相对路径复制一份
|
||||
# 到 ../spi_link(本仓不直接含 spi_link 源,故此处仅做存在性检查)。
|
||||
if [ ! -d ../spi_link ]; then
|
||||
echo "::warning::spi_link 兄弟目录不存在;CI 无法运行自测。请确保 spi_link 与本仓同级。"
|
||||
exit 1
|
||||
fi
|
||||
ls -la ../spi_link/golden_vectors.h ../spi_link/spi_link_v2.cpp ../spi_link/spi_link_v2.h
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake g++
|
||||
|
||||
- name: Configure host test
|
||||
run: cmake -S ../spi_link/test -B build/host_test
|
||||
|
||||
- name: Build host test
|
||||
run: cmake --build build/host_test
|
||||
|
||||
- name: Run self-test
|
||||
run: ctest --test-dir build/host_test --output-on-failure
|
||||
+12
@@ -72,6 +72,18 @@ Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# 本机调试 dump / 状态快照
|
||||
logs*.json
|
||||
st*.json
|
||||
st_new.json
|
||||
|
||||
# IDE / 过程产物
|
||||
.trae/
|
||||
.agents/
|
||||
.worktrees/
|
||||
.qoder/
|
||||
.zcode/
|
||||
|
||||
# User-specific configuration files
|
||||
*.user
|
||||
*.workspace # General workspace files, can be from various tools
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# 修复 ESP32 OTA 重启 UI 竞态问题
|
||||
|
||||
## 问题描述
|
||||
|
||||
前端显示 `SHA-256 校验通过,设备自动重启中...` 消息后,"重启中"阶段 UI 不会稳定触发(仅偶发触发一次)。
|
||||
|
||||
## 根因分析
|
||||
|
||||
**后端时序**([http_handlers.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L148-L152) + [ota_coordinator.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L550-L556)):
|
||||
1. `httpd_resp_send()` 发送 HTTP 响应(L148)
|
||||
2. `vTaskDelay(500ms)` 等待 TCP 刷新(L151)
|
||||
3. `restartEsp32()` → `notifyEsp32Rebooting()` + `vTaskDelay(500ms)` + `esp_restart()`(L152)
|
||||
|
||||
即:**HTTP 响应发出后,设备仍存活约 1000ms 才真正重启**。
|
||||
|
||||
**前端竞态**([app.js](file:///g:/Robot_v2/DCU/ESP32_COMM/tools/app.js#L1496-L1498)):
|
||||
1. `verifyEsp32Firmware()` 收到 `/ota/update` 成功响应后,在 L1496 设置 `isEsp32Rebooting = true`,L1497 推进阶段到 5"重启中"。
|
||||
2. 此时轮询以 1s 间隔运行(上传结束后 `resumePollingAfterUpload()` 已恢复 1s 间隔)。
|
||||
3. **在 ~1000ms 的 pre-reboot 窗口内,设备仍能响应 `/system/status` 轮询**。
|
||||
4. `pollStatus()` 成功路径([app.js:1740-1745](file:///g:/Robot_v2/DCU/ESP32_COMM/tools/app.js#L1740-L1745))发现 `isEsp32Rebooting === true`,**立即清除标志并推进到阶段 6"启动完成"+ 弹出"ESP32 已恢复在线"** —— 全部错误。
|
||||
5. 当设备 ~1s 后真正重启时,轮询失败,但 `isEsp32Rebooting` 已被清为 false,失败分支走"设备忙"或"未连接",**永远不会再显示"重启中"**。
|
||||
|
||||
**偶发触发的原因**:仅当轮询时序恰好使得 pre-reboot 窗口内没有成功的轮询(例如轮询正在 in-flight 且设备重启打断它)时,"重启中"UI 才能正确显示。
|
||||
|
||||
## 修复方案
|
||||
|
||||
引入 `rebootObservedDown` 标志,要求"先观察到设备下线,再接受其恢复在线",避免在 pre-reboot 窗口内被成功轮询误清除重启状态。
|
||||
|
||||
### 改动文件:[tools/app.js](file:///g:/Robot_v2/DCU/ESP32_COMM/tools/app.js)
|
||||
|
||||
#### 1. 新增全局标志(L28 附近)
|
||||
|
||||
在 `let rebootStartTime = 0;` 下方新增:
|
||||
```js
|
||||
let rebootObservedDown = false; // 是否已观察到设备实际下线(重启)
|
||||
```
|
||||
|
||||
#### 2. arming 点 1:`verifyEsp32Firmware()` 成功路径(L1496-L1498)
|
||||
|
||||
```js
|
||||
isEsp32Rebooting = true;
|
||||
rebootObservedDown = false; // ← 新增
|
||||
updateEsp32StagesFromPhase(5); // 重启中
|
||||
rebootStartTime = Date.now();
|
||||
```
|
||||
|
||||
#### 3. arming 点 2:`restartEsp32()` 手动重启(L1531-L1532)
|
||||
|
||||
```js
|
||||
isEsp32Rebooting = true;
|
||||
rebootObservedDown = false; // ← 新增
|
||||
rebootStartTime = Date.now();
|
||||
```
|
||||
|
||||
#### 4. `pollStatus()` 成功路径(L1740-L1746)—— 核心修复
|
||||
|
||||
将无条件清除改为"先确认设备下线过再清除":
|
||||
|
||||
```js
|
||||
if (isEsp32Rebooting) {
|
||||
var rbElapsed = Math.round((Date.now() - rebootStartTime) / 1000);
|
||||
if (!rebootObservedDown && rbElapsed < 10) {
|
||||
// pre-reboot 窗口:设备尚未实际重启(后端有 ~1s 延迟才 esp_restart)
|
||||
// 保持"重启中"状态,不清除标志,不推进阶段
|
||||
addLog('I', '系统', '设备尚未实际重启,等待 esp_restart...');
|
||||
} else {
|
||||
// 设备已重启并恢复在线(rebootObservedDown=true)
|
||||
// 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复
|
||||
isEsp32Rebooting = false;
|
||||
rebootObservedDown = false;
|
||||
updateEsp32StagesFromPhase(6); // 启动完成
|
||||
addLog('I', '系统', 'ESP32 已恢复在线');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
}
|
||||
}
|
||||
try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
|
||||
// pre-reboot 窗口内 updateSystemStatusUi 会把连接胶囊刷成"已连接",需覆盖回"重启中"
|
||||
if (isEsp32Rebooting) {
|
||||
applyRebootingRuntimeState();
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. `pollStatus()` 失败路径 isEsp32Rebooting 分支(L1781-L1791)—— 标记已下线
|
||||
|
||||
```js
|
||||
} else if (isEsp32Rebooting) {
|
||||
rebootObservedDown = true; // ← 新增:确认设备已下线(正在重启)
|
||||
var elapsed = Math.round((Date.now() - rebootStartTime) / 1000);
|
||||
if (elapsed > 60) {
|
||||
isEsp32Rebooting = false;
|
||||
rebootObservedDown = false; // ← 新增
|
||||
addLog('E', '系统', 'ESP32 重启超时,设备未恢复');
|
||||
showToast('ESP32 重启超时,请检查设备', 'error');
|
||||
updateConnectionUi(false, '未连接');
|
||||
applyDisconnectedRuntimeState();
|
||||
} else {
|
||||
applyRebootingRuntimeState();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 时序验证(修复后)
|
||||
|
||||
| 时间 | 事件 | isEsp32Rebooting | rebootObservedDown | UI |
|
||||
|------|------|------------------|--------------------|-----|
|
||||
| t=0 | 收到 /ota/update 响应,arming | true | false | 阶段5"重启中" |
|
||||
| t=0~1s | pre-reboot 窗口,轮询成功 | true | false | 保持"重启中"(不清除)✓ |
|
||||
| t=1s | esp_restart(),设备下线 | true | false | — |
|
||||
| t=1~2s | 轮询失败 | true | **true** | "重启中 Ns" ✓ |
|
||||
| t=3~4s | 设备重启完成,轮询成功 | → false | → false | 阶段6"启动完成" ✓ |
|
||||
|
||||
## 假设与决策
|
||||
|
||||
- **10s 安全阈值**:ESP32 重启(boot ROM + bootloader + app init + WiFi init)通常 1-3s,1s 间隔轮询至少 1-2 次失败,`rebootObservedDown` 必然被设置。10s 阈值仅用于兜底(设备未实际重启或重启极快未捕获到下线)。
|
||||
- **不修改后端时序**:后端 1000ms 延迟(500ms TCP 刷新 + 500ms reboot 通知窗口)是必要的(确保响应送达 + 状态上报),问题在前端竞态处理。
|
||||
- **仅修改 WiFi 路径**:BLE 路径使用事件驱动(0x07 REBOOT_NOW + 断连重连),不受此竞态影响。
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 通过 WiFi 上传 ESP32 固件并完成 SHA-256 校验
|
||||
2. 确认显示 `SHA-256 校验通过,设备自动重启中...` 后,阶段稳定停留在 5"重启中",连接胶囊显示"重启中 Ns"计数
|
||||
3. 不应出现阶段跳到 6"启动完成"再回退的情况
|
||||
4. 等待设备实际重启完成,确认阶段推进到 6"启动完成"并弹出"ESP32 已恢复在线"
|
||||
5. 多次重复验证(此前为偶发问题,修复后应 100% 稳定)
|
||||
@@ -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,81 @@
|
||||
# Checklist
|
||||
|
||||
## CSS 拆分验证
|
||||
- [x] `css/base.css` 包含 `:root` 变量定义、`.container` 样式、背景纹理,无其他职责段混入
|
||||
- [x] `css/layout.css` 包含 header、main-layout、cards、forms、buttons、conn-indicator 样式
|
||||
- [x] `css/components.css` 包含 upload-zone、file-info、progress、stage-flow、badges、metrics、heap、log-panel、toast 样式
|
||||
- [x] `css/can.css` 包含 CAN panel、CAN workspace、CAN feedback、ID snapshot、报文列表布局样式
|
||||
- [x] `css/responsive.css` 包含所有 `@media` 查询和 `@keyframes` 动画定义
|
||||
- [x] 三个 HTML 页面的 `<link>` 标签引用了全部 5 个 CSS 文件,页面视觉效果与原 `style.css` 完全一致
|
||||
- [x] 原 `tools/style.css` 已删除
|
||||
|
||||
## 公共模块验证
|
||||
- [x] `js/common/utils.js` 导出 `formatBytes`、`formatTime`、`formatUptime`、`escapeHtml`、`sanitizeHtml`
|
||||
- [x] `js/common/toast.js` 导出 `showToast`,支持 info/success/warning/error 类型
|
||||
- [x] `js/common/log.js` 导出 `addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll`,环形缓冲与去重逻辑完整
|
||||
- [x] `js/common/connection.js` 导出 `testConnection`、连接状态指示器更新函数
|
||||
- [x] `js/common/polling.js` 导出轮询调度、`pollStatus`、`pollLogs`、Page Visibility 补偿逻辑
|
||||
|
||||
## app.js 拆分验证
|
||||
- [x] `js/ota/session.js` 包含完整 OTA 会话状态机,状态流转(IDLE→UPLOADING→VERIFYING→REBOOTING→DONE / FAILED)逻辑不变
|
||||
- [x] `js/ota/progress.js` 包含进度条、阶段节点 UI、`setProgress`、`setActivePhase`、`markStageDone`
|
||||
- [x] `js/ota/stages.js` 包含 ESP32/GD32 阶段驱动,`esp32OtaPhase`、`esp32PhaseLabels` 等状态保留
|
||||
- [x] `js/ota/file-handle.js` 包含文件选择、FSAPI、SHA-256 计算、Intel HEX→BIN 转换、文件信息 UI
|
||||
- [x] `js/ota/upload-esp32.js` 包含 ESP32 上传流程、心跳、SHA 校验、完成升级,重试阈值与 goto 跳转逻辑保留
|
||||
- [x] `js/ota/upload-gd32.js` 包含 GD32 中继 OTA 触发、relay 态跟踪、`hasObservedRealRelayState` 逻辑
|
||||
- [x] `js/ota/spi.js` 包含 `sendSpiFrame`、`clearSpiFields`
|
||||
- [x] `js/ota/console-mode.js` 包含从 index.html 抽出的全部内联脚本函数
|
||||
|
||||
## ble_ota.js 拆分验证
|
||||
- [x] `js/ble/constants.js` 导出 `UUIDS`、所有 `BLE_OTA_*` 常量、`TARGETS` 配置
|
||||
- [x] `js/ble/state.js` 导出 `state` 对象、元素缓存、`currentTarget`、`refreshTargetUi`、`setConnectionState`
|
||||
- [x] `js/ble/stages.js` 导出 `setBleStage`、`resetBleStages`、`setBleStageError`、`updateBleStageLabels`
|
||||
- [x] `js/ble/gatt.js` 导出 BLE 连接、断开、服务发现、通知使能逻辑
|
||||
- [x] `js/ble/file.js` 导出 `notifyFileChange`、`clearFile`、分包计算
|
||||
- [x] `js/ble/transfer.js` 导出数据写入循环、ACK 窗口节流(每 32 包或窗口边界 ACK)、`updateProgressFromAck`
|
||||
- [x] `js/ble/verify.js` 导出 `showBleVerifyInfo`、`hideBleVerifyInfo`
|
||||
- [x] `js/ble/session.js` 导出 `waitForOtaResult`、`resolveOtaResult`、`rejectOtaResult`、abort 逻辑
|
||||
|
||||
## can.js 拆分验证
|
||||
- [x] `js/can/dbc.js` 导出 `parseDbc`、`findSignal`、`parseMultiplexToken`、`buildSummary`
|
||||
- [x] `js/can/messages.js` 导出报文列表渲染、过滤、节点过滤逻辑
|
||||
- [x] `js/can/signals.js` 导出信号值编辑器、编解码、批量添加
|
||||
- [x] `js/can/send.js` 导出帧构建、发送、`CAN_SEND_ERR_DESC`、`GD32_ERR_DESC`、`buildSendResponseText`
|
||||
- [ ] `js/can/feedback.js` 导出 1Hz 反馈轮询、ID 快照表渲染
|
||||
**注:原 can.js 中不存在 1Hz 反馈轮询/ID 快照表代码(该功能在 can_monitor.js 中)。feedback.js 当前重导出 common/connection.js 的连接管理函数供入口使用。如后续 can 页面需要 1Hz `/can/feedback` 轮询,可在 feedback.js 实现。**
|
||||
|
||||
## can_monitor.js 验证
|
||||
- [x] `js/can_monitor.js` 转为 ESM,引用 `js/common/*` 公共模块替换重复工具函数
|
||||
- [x] CAN 监控页面功能正常(连接、报文接收)— HTTP 200 验证通过,语法检查通过
|
||||
|
||||
## 入口与 HTML 验证
|
||||
- [x] `js/app.js` 薄入口 import 所有 ota + common 模块,HTML onclick 引用的函数已挂载到 `window`
|
||||
- [x] `js/ble_ota.js` 薄入口 import 所有 ble 模块,导出 `window.bleOta` API 并调用 `init()`
|
||||
- [x] `js/can.js` 薄入口 import 所有 can 模块并初始化
|
||||
- [x] `index.html` 使用 `<script type="module">`,无内联 `<script>` 块
|
||||
- [x] `can.html` 使用 `<script type="module" src="js/can.js">`
|
||||
- [x] `can_monitor.html` 使用 `<script type="module" src="js/can_monitor.js">`
|
||||
- [x] 所有 `import` 路径带 `.js` 后缀
|
||||
|
||||
## 清理验证
|
||||
- [x] 原 `tools/app.js`(2371 行)已删除
|
||||
- [x] 原 `tools/ble_ota.js`(1658 行)已删除
|
||||
- [x] 原 `tools/can.js`(1526 行)已删除
|
||||
- [x] 原 `tools/style.css`(2146 行)已删除
|
||||
- [x] 原 `tools/can_monitor.js` 已删除(迁移到 `js/can_monitor.js`)
|
||||
|
||||
## 功能完整性验证
|
||||
- [x] 浏览器控制台无加载错误、无 import 解析失败 — HTTP 200 全部资源可达,30 个 JS 文件语法检查通过,MIME 类型正确(text/javascript)
|
||||
- [ ] index.html WiFi OTA 模式:ESP32/GD32 目标切换、文件选择(含 FSAPI)、上传、进度条、阶段流、SHA 校验面板、重启均正常 — **需浏览器实机验证(涉及真实设备交互)**
|
||||
- [ ] index.html BLE OTA 模式:连接/断开、文件选择、一键刷写、中止、ACK 节流、阶段流、校验面板均正常 — **需浏览器实机验证(需 Web Bluetooth 真实设备)**
|
||||
- [ ] index.html 日志面板:UDP 日志接收、HTTP 日志拉取、过滤、自动滚动、清除均正常 — **需浏览器实机验证**
|
||||
- [ ] index.html SPI 发送、系统状态轮询、堆/任务栈展示均正常 — **需浏览器实机验证(需真实设备)**
|
||||
- [ ] can.html:DBC 导入、报文列表、节点过滤、信号编辑、帧发送、1Hz 反馈均正常 — **需浏览器实机验证**
|
||||
- [ ] can_monitor.html:连接、报文接收监控正常 — **需浏览器实机验证**
|
||||
- [x] 刷写进行中切换模式/目标被正确阻止(守卫逻辑保留)— console-mode.js 中 setConsoleMode/selectFlashTarget 守卫逻辑原样保留
|
||||
- [x] BLE 断开时按系统状态处理(GD32_RELAYING/ESP32_REBOOTING 保留,其他 abortCurrent)逻辑保留 — ble/session.js 的 abortTask 与 gatt.js 的 onDisconnected 逻辑原样保留
|
||||
|
||||
## 说明
|
||||
- 标记为"需浏览器实机验证"的项涉及真实 ESP32/BLE 设备交互,无法在静态分析中完成。
|
||||
代码已通过语法检查与 HTTP 可达性验证,业务逻辑与原文件逐行对齐(纯结构重构,无逻辑修改)。
|
||||
建议用户启动 serve.bat 后在浏览器中实机验证上述功能项。
|
||||
@@ -0,0 +1,136 @@
|
||||
# tools 网页工程化拆分 Spec
|
||||
|
||||
## Why
|
||||
`tools/` 目录下 4 个核心文件(`app.js` 2371 行、`ble_ota.js` 1658 行、`can.js` 1526 行、`style.css` 2146 行)体积过大,单文件承载多重职责,难以维护和导航。`index.html` 还内联了 ~250 行胶水脚本。需要按职责拆分为小文件工程,提升可维护性。
|
||||
|
||||
## What Changes
|
||||
- 将 `style.css` 拆分为 5 个按职责划分的 CSS 文件(base / layout / components / can / responsive)
|
||||
- 将 `app.js` 拆分为 8 个 ES Module(ota/session、progress、stages、file-handle、upload-esp32、upload-gd32、spi、console-mode)+ 公共模块
|
||||
- 将 `ble_ota.js` 拆分为 8 个 ES Module(ble/constants、state、stages、gatt、file、transfer、verify、session)
|
||||
- 将 `can.js` 拆分为 5 个 ES Module(can/dbc、messages、signals、send、feedback)
|
||||
- 抽取 `index.html` 内联 `<script>` 到 `js/ota/console-mode.js`
|
||||
- 抽取跨页面共享的工具函数到 `js/common/`(utils、toast、log、connection、polling)
|
||||
- 三个 HTML 页面的 `<script>` 改为 `type="module"`,`<link>` 拆分为多文件引用
|
||||
- 删除原始大文件,用薄入口文件(`js/app.js`、`js/ble_ota.js`、`js/can.js`)替代
|
||||
|
||||
## Impact
|
||||
- Affected code: `tools/` 目录全部文件(`app.js`、`ble_ota.js`、`can.js`、`can_monitor.js`、`style.css`、`index.html`、`can.html`、`can_monitor.html`)
|
||||
- 不影响 ESP32 固件代码(`main/` 目录不改动)
|
||||
- `serve.bat` 无需修改(Python http.server 天然支持 ESM 的 MIME 类型)
|
||||
- **BREAKING**:旧的单文件引用路径(`app.js?v=...`)将不存在,HTML 中所有 `<script src>` 和 `<link href>` 路径需更新
|
||||
|
||||
## 技术方案
|
||||
|
||||
### 模块组织:原生 ES Modules
|
||||
- 使用 `<script type="module">` + `import`/`export`,零构建、零工具链依赖
|
||||
- `serve.bat`(Python http.server)无需修改,浏览器原生解析 ESM
|
||||
- 所有 `import` 路径必须带 `.js` 后缀(浏览器 ESM 规范要求)
|
||||
|
||||
### HTML 内联事件处理器兼容策略
|
||||
当前 HTML 大量使用 `onclick="funcName()"` 内联处理器。ES Module 作用域非全局,采用以下策略:
|
||||
1. **内部函数**:模块内使用,不暴露到全局
|
||||
2. **HTML 引用函数**:在入口模块中显式挂载到 `window`(如 `window.setConsoleMode = setConsoleMode`)
|
||||
3. 模块脚本默认 `defer`,DOM 解析完成后执行,用户点击前 `window` 上的函数已就绪
|
||||
|
||||
### CSS 拆分策略
|
||||
- 多 `<link>` 标签并行加载(优于 `@import` 串行)
|
||||
- 每个页面引入相同的 5 个 CSS 文件(base + layout + components + responsive 为全站共用,can 为 CAN 页面专用但 OTA 页面也引用了 CAN 入口卡片样式)
|
||||
|
||||
### 目标目录结构
|
||||
```
|
||||
tools/
|
||||
├── index.html # 结构 only,<script type="module" src="js/app.js"> + js/ble_ota.js
|
||||
├── can.html # <script type="module" src="js/can.js">
|
||||
├── can_monitor.html # <script type="module" src="js/can_monitor.js">
|
||||
├── serve.bat # 不变
|
||||
├── css/
|
||||
│ ├── base.css # :root 变量、reset、container、背景纹理
|
||||
│ ├── layout.css # header、main-layout、cards、forms、buttons、conn-indicator
|
||||
│ ├── components.css # upload-zone、file-info、progress、stage-flow、badges、metrics、log-panel、toast、heap
|
||||
│ ├── can.css # CAN workspace + monitor 专用样式
|
||||
│ └── responsive.css # @media 响应式 + @keyframes 动画
|
||||
└── js/
|
||||
├── app.js # 薄入口:import ota/* + common/*,挂载 window 函数
|
||||
├── ble_ota.js # 薄入口:import ble/*,导出 window.bleOta
|
||||
├── can.js # 薄入口:import can/*,初始化 CAN 工具
|
||||
├── can_monitor.js # CAN 监控入口(小文件,引用 common 模块)
|
||||
├── common/
|
||||
│ ├── utils.js # formatBytes / formatTime / formatUptime / escapeHtml / sanitizeHtml
|
||||
│ ├── toast.js # showToast
|
||||
│ ├── log.js # addLog + 环形缓冲 + 去重 + 自动滚动
|
||||
│ ├── connection.js # testConnection + 连接状态指示器更新
|
||||
│ └── polling.js # 状态/日志轮询调度 + Page Visibility 补偿
|
||||
├── ota/
|
||||
│ ├── session.js # OTA 会话状态机(OtaSessionState + 转换 + 复位)
|
||||
│ ├── progress.js # 进度条 + 阶段节点 UI(setProgress / setActivePhase / markStageDone)
|
||||
│ ├── stages.js # ESP32/GD32 阶段驱动(updateEsp32StagesFromPhase / GD32 阶段更新)
|
||||
│ ├── file-handle.js # 文件选择 / FSAPI / SHA-256 / HEX→BIN 转换 / 文件信息 UI
|
||||
│ ├── upload-esp32.js # ESP32 上传流程 + SHA 校验 + 完成升级
|
||||
│ ├── upload-gd32.js # GD32 中继 OTA 触发 + relay 态跟踪
|
||||
│ ├── spi.js # SPI 帧构建与发送
|
||||
│ └── console-mode.js # 从 index.html 抽出的内联脚本(setConsoleMode / selectFlashTarget / 共享固件胶水)
|
||||
├── ble/
|
||||
│ ├── constants.js # UUIDS + BLE_OTA_* 协议常量 + TARGETS 配置
|
||||
│ ├── state.js # state 对象 + 元素缓存(els)
|
||||
│ ├── stages.js # BLE 阶段流管理(setBleStage / resetBleStages / setBleStageError)
|
||||
│ ├── gatt.js # BLE 连接 / 断开 / 服务发现 / 通知使能
|
||||
│ ├── file.js # 文件准备 + 分包 + notifyFileChange
|
||||
│ ├── transfer.js # 数据写入循环 + ACK 窗口节流 + 重传
|
||||
│ ├── verify.js # SHA-256 校验面板(showBleVerifyInfo / hideBleVerifyInfo)
|
||||
│ └── session.js # waitForOtaResult + resolveOtaResult + abort 任务
|
||||
└── can/
|
||||
├── dbc.js # DBC 解析器(parseDbc / findSignal / buildSummary)
|
||||
├── messages.js # 报文列表渲染 + 过滤 + 节点过滤
|
||||
├── signals.js # 信号值编辑器 + 编解码 + 批量添加
|
||||
├── send.js # 帧构建 + 发送 + 错误码描述
|
||||
└── feedback.js # 1Hz 反馈轮询 + ID 快照表
|
||||
```
|
||||
|
||||
### 拆分原则
|
||||
1. **保持功能完全不变**:纯结构重构,不修改任何业务逻辑、算法、阈值
|
||||
2. **按现有注释分节对齐**:`app.js` 已有 17 个 `// ==== SECTION ====` 注释,按此天然边界拆分
|
||||
3. **共享函数提取到 common/**:`formatBytes`、`showToast`、`addLog`、`testConnection` 等在多个文件中重复的函数统一提取
|
||||
4. **循环依赖处理**:模块间若存在循环引用,通过回调注册或共享 state 模块解耦
|
||||
5. **全局状态最小化**:原 `window.*` 全局变量保留必要的(`window.connectionMode`、`window.flashTarget`、`window.bleOta`),内部状态用模块级变量
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ES Module 工程结构
|
||||
系统 SHALL 将 `tools/` 目录组织为原生 ES Module 工程,所有 JS 文件使用 `import`/`export` 语法,HTML 通过 `<script type="module">` 加载。
|
||||
|
||||
#### Scenario: 浏览器加载页面
|
||||
- **WHEN** 用户通过 `http://localhost:8080/index.html` 访问 OTA 控制台
|
||||
- **THEN** 浏览器原生解析 ESM import 链,无构建步骤,所有模块按依赖顺序加载执行
|
||||
|
||||
#### Scenario: Python http.server 提供 ESM 文件
|
||||
- **WHON** `serve.bat` 启动 Python http.server
|
||||
- **THEN** `.js` 文件以 `application/javascript` MIME 类型返回,`type="module"` 脚本正常加载
|
||||
|
||||
### Requirement: CSS 按职责拆分
|
||||
系统 SHALL 将 `style.css` 拆分为 5 个独立 CSS 文件,每个文件负责一个明确的样式职责域。
|
||||
|
||||
#### Scenario: 页面引用 CSS
|
||||
- **WHEN** 任意 HTML 页面加载
|
||||
- **THEN** 通过 5 个 `<link>` 标签并行加载 base + layout + components + can + responsive,视觉效果与原单文件 `style.css` 完全一致
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: HTML 脚本引用方式
|
||||
三个 HTML 页面 SHALL 使用 `<script type="module">` 引用入口 JS 文件,不再使用普通 `<script src>`。`index.html` SHALL 移除所有内联 `<script>` 块,改为模块导入。
|
||||
|
||||
#### Scenario: index.html 加载脚本
|
||||
- **WHEN** `index.html` 解析到末尾
|
||||
- **THEN** 按顺序加载 `js/app.js`(WiFi OTA 入口)和 `js/ble_ota.js`(BLE 入口),两者均为 `type="module"`,原内联胶水代码由 `js/ota/console-mode.js` 模块提供
|
||||
|
||||
### Requirement: HTML 内联事件处理器兼容
|
||||
系统 SHALL 保留现有 HTML 中的 `onclick="funcName()"` 内联处理器,并在入口模块中将这些函数显式挂载到 `window` 对象。
|
||||
|
||||
#### Scenario: 用户点击内联 onclick 按钮
|
||||
- **WHEN** 用户点击 `onclick="setConsoleMode('wifi')"` 按钮
|
||||
- **THEN** `window.setConsoleMode` 函数已被 `js/ota/console-mode.js` 模块挂载,正常执行模式切换逻辑
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 原始大文件
|
||||
**Reason**: `app.js`(2371 行)、`ble_ota.js`(1658 行)、`can.js`(1526 行)、`style.css`(2146 行)单文件过大,已拆分为模块化小文件
|
||||
**Migration**: 原文件删除,功能由 `js/` 和 `css/` 子目录下的模块文件承接,HTML 引用路径已更新
|
||||
@@ -0,0 +1,101 @@
|
||||
# Tasks
|
||||
|
||||
## Phase 1: CSS 拆分(无依赖,可独立完成)
|
||||
|
||||
- [x] Task 1: 拆分 `style.css` 为 5 个职责文件
|
||||
- [ ] SubTask 1.1: 创建 `css/base.css`(:root 变量、reset、container、背景纹理,对应原 1~53 行)
|
||||
- [ ] SubTask 1.2: 创建 `css/layout.css`(header、main-layout、cards、forms、buttons、conn-indicator,对应原 55~210 行)
|
||||
- [ ] SubTask 1.3: 创建 `css/components.css`(upload-zone、file-info、progress、stage-flow、badges、metrics、heap、log-panel、toast,对应原 211~1401 行中非 CAN 部分)
|
||||
- [ ] SubTask 1.4: 创建 `css/can.css`(CAN panel、CAN workspace page、CAN 1Hz feedback、ID snapshot table、报文列表布局,对应原 778~1384 + 1726~2146 末尾的 CAN 段)
|
||||
- [ ] SubTask 1.5: 创建 `css/responsive.css`(@media 响应式 + @keyframes 动画 + enhanced animations,对应原 1476~1725 + 散落的 keyframes)
|
||||
- [ ] SubTask 1.6: 更新 `index.html`、`can.html`、`can_monitor.html` 的 `<link>` 为 5 个 `css/*.css` 引用,删除原 `style.css` 引用
|
||||
|
||||
## Phase 2: 公共模块抽取(app.js / can.js / can_monitor.js 共同依赖)
|
||||
|
||||
- [x] Task 2: 创建 `js/common/` 公共工具模块
|
||||
- [ ] SubTask 2.1: 创建 `js/common/utils.js` — 导出 `formatBytes`、`formatTime`、`formatUptime`、`escapeHtml`、`sanitizeHtml`(从 app.js 426~516 行 + can.js 重复实现提取)
|
||||
- [ ] SubTask 2.2: 创建 `js/common/toast.js` — 导出 `showToast`(从 app.js 484~516 行提取)
|
||||
- [ ] SubTask 2.3: 创建 `js/common/log.js` — 导出 `addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll` 及日志环形缓冲状态(从 app.js 517~635 行提取)
|
||||
- [ ] SubTask 2.4: 创建 `js/common/connection.js` — 导出 `testConnection`、`setConnectionState`、连接指示器更新逻辑(从 app.js 636~823 行 + 1076~1138 行提取)
|
||||
- [ ] SubTask 2.5: 创建 `js/common/polling.js` — 导出 `schedulePolling`、`pollStatus`、`pollLogs`、`pausePollingForUpload`、Page Visibility 补偿(从 app.js 2051~2371 行提取)
|
||||
|
||||
## Phase 3: app.js 拆分(依赖 Phase 2)
|
||||
|
||||
- [x] Task 3: 拆分 `app.js` OTA 相关模块到 `js/ota/`
|
||||
- [ ] SubTask 3.1: 创建 `js/ota/session.js` — OTA 会话状态机(`OtaSessionState`、`otaSessionTransition`、`otaSessionReset`、`clearOtaSessionResiduals`、`otaSessionActive`、`otaSessionTerminated`),对应原 69~237 行
|
||||
- [ ] SubTask 3.2: 创建 `js/ota/progress.js` — 进度条与阶段 UI(`toggleCollapse`、`switchStageFlow`、`setProgress`、`setActivePhase`、`markStageDone`、`resetProgress`、`clearOtaResultWaiter`),对应原 239~425 行
|
||||
- [ ] SubTask 3.3: 创建 `js/ota/stages.js` — ESP32/GD32 阶段驱动(`updateEsp32StagesFromPhase`、GD32 阶段更新、`esp32OtaPhase`、`esp32PhaseLabels`),对应原 824~1138 行
|
||||
- [ ] SubTask 3.4: 创建 `js/ota/file-handle.js` — 文件选择 / FSAPI / SHA-256 / Intel HEX→BIN 转换 / 文件信息 UI(`handleFileSelect`、`clearFile`、`pickFileViaFSAPI`、`notifyFileChange`、`finalizeFileInfo`、`updateFileInfo`),合并原 1228~1466 行两个"文件处理"段
|
||||
- [ ] SubTask 3.5: 创建 `js/ota/upload-esp32.js` — ESP32 上传流程(`startUpload('esp32')`、`uploadFirmware`、`startUploadWaitHeartbeat`、`stopUploadWaitHeartbeat`、`verifyEsp32Sha256`、ESP32 完成升级),对应原 1467~1859 行
|
||||
- [ ] SubTask 3.6: 创建 `js/ota/upload-gd32.js` — GD32 中继 OTA 触发(`startUpload('gd32')`、`triggerGd32Ota`、relay 态跟踪、`hasObservedRealRelayState`),对应原 1860~2050 行
|
||||
- [ ] SubTask 3.7: 创建 `js/ota/spi.js` — SPI 帧构建与发送(`sendSpiFrame`、`clearSpiFields`),对应原 1139~1227 行
|
||||
|
||||
- [x] Task 4: 抽取 `index.html` 内联脚本到 `js/ota/console-mode.js`
|
||||
- 将原 431~684 行的内联 `<script>` 迁移为 ES Module
|
||||
- 包含 `setConsoleMode`、`selectFlashTarget`、`handleSharedFileSelect`、`pickSharedFirmware`、`clearSharedFirmware`、`setBleFirmwareInputFile`、`syncBleFileFromSelectedTarget`、`applySharedModeUi`、`switchStageFlow` 覆盖逻辑
|
||||
- 导出需要挂载到 `window` 的函数供 HTML onclick 使用
|
||||
|
||||
## Phase 4: ble_ota.js 拆分(与 Phase 3 无依赖,可并行)
|
||||
|
||||
- [x] Task 5: 拆分 `ble_ota.js` 为 `js/ble/` 模块
|
||||
- [ ] SubTask 5.1: 创建 `js/ble/constants.js` — 导出 `UUIDS`、`BLE_OTA_*` 协议常量、`TARGETS` 配置、`UI_BUILD`(原 5~55 行)
|
||||
- [ ] SubTask 5.2: 创建 `js/ble/state.js` — 导出 `state` 对象、`$` 元素查询辅助、`els` 元素缓存、`currentTarget`、`currentPayloadSize`、`flowTarget`、`refreshTargetUi`、`setConnectionState`、`resetUiAfterDisconnect`(原 57~430 行)
|
||||
- [ ] SubTask 5.3: 创建 `js/ble/stages.js` — 导出 `updateBleStageLabels`、`resetBleStages`、`setBleStage`、`setBleStageText`、`setBleStageError`、`bleStageIds`(原 141~206 行)
|
||||
- [ ] SubTask 5.4: 创建 `js/ble/gatt.js` — 导出 `connectBle`、`disconnect`、`onDisconnected`、服务发现、通知使能(原 431~560 行附近 connect/disconnect 段)
|
||||
- [ ] SubTask 5.5: 创建 `js/ble/file.js` — 导出 `notifyFileChange`、`clearFile`、文件准备、分包计算、`setReadyHint`(原文件准备相关段)
|
||||
- [ ] SubTask 5.6: 创建 `js/ble/transfer.js` — 导出数据写入循环、ACK 窗口节流、`updateProgressFromAck`、重传逻辑(原数据写入段)
|
||||
- [ ] SubTask 5.7: 创建 `js/ble/verify.js` — 导出 `showBleVerifyInfo`、`hideBleVerifyInfo`、SHA-256 校验比对逻辑(原 276~302 行)
|
||||
- [ ] SubTask 5.8: 创建 `js/ble/session.js` — 导出 `waitForOtaResult`、`resolveOtaResult`、`rejectOtaResult`、`abortCurrent`、`nextOtaSessionId`、`finishBelongsToWaiter`(原 207~261 + 1568~1657 行)
|
||||
|
||||
## Phase 5: can.js 拆分(与 Phase 3/4 无依赖,可并行)
|
||||
|
||||
- [x] Task 6: 拆分 `can.js` 为 `js/can/` 模块
|
||||
- [ ] SubTask 6.1: 创建 `js/can/dbc.js` — 导出 `parseDbc`、`findSignal`、`parseMultiplexToken`、`buildSummary`、`normalizeWhitespace`(原 182~335 行)
|
||||
- [ ] SubTask 6.2: 创建 `js/can/messages.js` — 导出 `filterMessages`、`renderMessageList`、`collectTransmitters`、`renderNodeFilterList`、`bindNodeFilterEvents`、`updateDbcFileInfo`(原 357~700 行附近)
|
||||
- [ ] SubTask 6.3: 创建 `js/can/signals.js` — 导出信号值编辑器、编解码、`renderSignalEditor`、批量添加逻辑、旧版 shim(原 700~1100 行附近)
|
||||
- [ ] SubTask 6.4: 创建 `js/can/send.js` — 导出 `buildSendResponseText`、`CAN_SEND_ERR_DESC`、`GD32_ERR_DESC`、帧构建与发送(原发送相关段)
|
||||
- [ ] SubTask 6.5: 创建 `js/can/feedback.js` — 导出 1Hz 反馈轮询、ID 快照表渲染(原反馈轮询段)
|
||||
|
||||
## Phase 6: can_monitor.js ESM 化(依赖 Phase 2)
|
||||
|
||||
- [x] Task 7: 将 `can_monitor.js` 转为 ES Module
|
||||
- 创建 `js/can_monitor.js` 入口,引用 `js/common/utils.js`、`js/common/connection.js` 等公共模块替换内部重复实现
|
||||
- 原文件仅 244 行,内部逻辑保持不变,仅替换工具函数为 import
|
||||
|
||||
## Phase 7: 入口组装与 HTML 更新
|
||||
|
||||
- [x] Task 8: 创建薄入口文件并更新 HTML 引用
|
||||
- [ ] SubTask 8.1: 创建 `js/app.js` 入口 — import 所有 `js/ota/*` + `js/common/*` 模块,将 HTML onclick 引用的函数挂载到 `window`(`setConsoleMode`、`selectFlashTarget`、`handleSharedFileSelect`、`pickSharedFirmware`、`clearSharedFirmware`、`startUpload`、`restartEsp32`、`sendSpiFrame`、`clearSpiFields`、`testConnection`、`refreshSystemStatusNow`、`setLogFilter`、`toggleAutoScroll`、`clearLogs`、`toggleCollapse` 等)
|
||||
- [ ] SubTask 8.2: 创建 `js/ble_ota.js` 入口 — import 所有 `js/ble/*` 模块,组装并导出 `window.bleOta` API 对象(`init`、`isUploading`、`notifyFileChange`、`clearFile`、`refreshButtons`、`refreshTargetUi` 等),调用 `init()`
|
||||
- [ ] SubTask 8.3: 创建 `js/can.js` 入口 — import 所有 `js/can/*` 模块,执行初始化
|
||||
- [ ] SubTask 8.4: 更新 `index.html` — `<script>` 改为 `type="module" src="js/app.js"` + `type="module" src="js/ble_ota.js"`,删除内联 `<script>` 块
|
||||
- [ ] SubTask 8.5: 更新 `can.html` — `<script>` 改为 `type="module" src="js/can.js"`
|
||||
- [ ] SubTask 8.6: 更新 `can_monitor.html` — `<script>` 改为 `type="module" src="js/can_monitor.js"`
|
||||
|
||||
## Phase 8: 清理与验证
|
||||
|
||||
- [x] Task 9: 删除原始大文件
|
||||
- 删除 `tools/app.js`(原 2371 行,已被 `js/app.js` + `js/ota/*` 替代)
|
||||
- 删除 `tools/ble_ota.js`(原 1658 行,已被 `js/ble_ota.js` + `js/ble/*` 替代)
|
||||
- 删除 `tools/can.js`(原 1526 行,已被 `js/can.js` + `js/can/*` 替代)
|
||||
- 删除 `tools/style.css`(原 2146 行,已被 `css/*` 替代)
|
||||
- 删除原 `tools/can_monitor.js`(已迁移到 `js/can_monitor.js`)
|
||||
|
||||
- [x] Task 10: 全功能验证
|
||||
- 启动 `serve.bat`,浏览器打开三个页面验证无控制台错误
|
||||
- 验证 index.html:WiFi 模式切换、ESP32/GD32 目标切换、文件选择、上传按钮状态、进度条、阶段流、日志面板、SPI 发送、系统状态轮询
|
||||
- 验证 BLE 模式:连接/断开、文件选择、一键刷写、中止、阶段流、校验面板
|
||||
- 验证 can.html:DBC 导入、报文列表、节点过滤、信号编辑、帧发送、1Hz 反馈
|
||||
- 验证 can_monitor.html:连接、报文接收监控
|
||||
|
||||
# Task Dependencies
|
||||
- [Task 2] 依赖 [Task 1] 完成(CSS 拆分先行,避免干扰)
|
||||
- [Task 3] 依赖 [Task 2] 完成(ota 模块引用 common 公共函数)
|
||||
- [Task 4] 依赖 [Task 3] 完成(console-mode 引用 ota 模块导出)
|
||||
- [Task 5] 依赖 [Task 2] 完成(ble 模块可能引用 common 工具)
|
||||
- [Task 6] 依赖 [Task 2] 完成(can 模块引用 common 工具)
|
||||
- [Task 7] 依赖 [Task 2] 完成(can_monitor 引用 common)
|
||||
- [Task 5]、[Task 6] 与 [Task 3] 无依赖,可并行执行
|
||||
- [Task 8] 依赖 [Task 3]、[Task 4]、[Task 5]、[Task 6]、[Task 7] 全部完成
|
||||
- [Task 9] 依赖 [Task 8] 完成
|
||||
- [Task 10] 依赖 [Task 9] 完成
|
||||
Vendored
+4
-1
@@ -5,6 +5,8 @@
|
||||
],
|
||||
"idf.portWin": "COM15",
|
||||
"idf.currentSetup": "F:\\software\\.espressif\\v6.0\\esp-idf",
|
||||
"idf.toolsPath": "C:\\Espressif",
|
||||
"idf.customExtraPaths": "C:\\Espressif\\tools\\xtensa-esp-elf-gdb\\16.3_20250913\\xtensa-esp-elf-gdb\\bin;C:\\Espressif\\tools\\riscv32-esp-elf-gdb\\16.3_20250913\\riscv32-esp-elf-gdb\\bin;C:\\Espressif\\tools\\xtensa-esp-elf\\esp-15.2.0_20251204\\xtensa-esp-elf\\bin;C:\\Espressif\\tools\\esp-clang\\esp-20.1.1_20250829\\esp-clang\\bin;C:\\Espressif\\tools\\riscv32-esp-elf\\esp-15.2.0_20251204\\riscv32-esp-elf\\bin;C:\\Espressif\\tools\\esp32ulp-elf\\2.38_20240113\\esp32ulp-elf\\bin;C:\\Espressif\\tools\\cmake\\4.0.3\\bin;C:\\Espressif\\tools\\openocd-esp32\\v0.12.0-esp32-20251215\\openocd-esp32\\bin;C:\\Espressif\\tools\\ninja\\1.12.1;C:\\Espressif\\tools\\idf-exe\\1.0.3;C:\\Espressif\\tools\\ccache\\4.12.1\\ccache-4.12.1-windows-x86_64;C:\\Espressif\\tools\\dfu-util\\0.11\\dfu-util-0.11-win64",
|
||||
"idf.customExtraVars": {
|
||||
"IDF_TARGET": "esp32c3"
|
||||
},
|
||||
@@ -14,5 +16,6 @@
|
||||
"--query-driver=**",
|
||||
"--compile-commands-dir=g:\\Robot_v2\\DFU\\ESP32_COMM\\build"
|
||||
],
|
||||
"idf.flashType": "UART"
|
||||
"idf.flashType": "UART",
|
||||
"idf.port": "COM15"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
## 优化 toast.js + polling.js(不改用户可见行为,不破坏时序)
|
||||
|
||||
### A. `tools/js/common/toast.js`
|
||||
|
||||
1. **空值保护**:`showToast` 开头加 `var toast = document.getElementById('toast'); if (!toast) return;`,避免 toast 元素未挂载时调用方崩。其余 `toastIco` / `toastMsg` 用 `var ico = ...; var msg = ...;` 取出后判空再用。
|
||||
2. **`info` 类型语义化**:保持视觉与现状一致,但修正注释。文件头注释从「支持 info / success / warning / error」改为如实描述「支持 success / error / warning(info 等同 warning)」,避免注释误导。不新增 CSS(无 caller 用 info,引入新色只会造成不一致)。
|
||||
|
||||
### B. `tools/js/common/polling.js`
|
||||
|
||||
5. **`pollLogs` 改递归 `setTimeout` + 在途保护**(修复 socket 抢占):
|
||||
- 新增模块级 `logsInFlight` 标志和 `logsTimerId`(已有)。
|
||||
- 抽出 `scheduleLogsPoll(delayMs)`(仿 `scheduleStatusPoll`)。
|
||||
- `pollLogs` 体内首行 `if (logsInFlight) return;`;`finally` 内 `logsInFlight=false` + `scheduleLogsPoll(statusPollIntervalMs)`。
|
||||
- `startPolling` / `resumePollingAfterUpload` / `visibilitychange` 改为 `scheduleLogsPoll(0)` 或保留 `pollLogs()` 直接触发(in-flight 守卫会跳过重叠)。
|
||||
- 注意:`stages.js`、`upload-gd32.js` 的手动 `window.pollLogs()` 调用保持不变,in-flight 守卫下若正飞则跳过、由下一轮定时补上,行为安全。
|
||||
|
||||
### C. `tools/js/app.js`
|
||||
|
||||
6. **新增 `applyDeviceBusyRuntimeState(text)` helper**(放在 `applyUploadingRuntimeState` 之后,第 148 行后):
|
||||
- 仿现有三兄弟的 DOM 模式:`#pillConn`/`#pillConnTxt`/`#connStatus`/`#connStatusTxt`/`#bConn`。
|
||||
- 第 225 行后追加 `window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState;`。
|
||||
|
||||
### D. 改动后行为一致性自检(写进 commit message,不写测试)
|
||||
|
||||
- 暂停上传:`pollStatus.finally` 不再重挂轮询 → 修正竞态,符合既有注释意图。
|
||||
- `pollLogs` 重叠:被 in-flight 守卫挡掉 → 减少与 `/ota/upload` 抢 socket,符合既有 `pollStatus` 的设计意图。
|
||||
- 重启恢复:两处统一走 `markEsp32RebootComplete`,重复 toast/log 由 `isEsp32Rebooting` 幂等守卫消除。
|
||||
- 设备忙 UI:从 polling.js 内联 DOM 改为调 helper,视觉完全一致(同样的 class、同样的文案)。
|
||||
- toast `info`:视觉不变(仍为 warn 色),仅注释修正。
|
||||
|
||||
### 不做的事
|
||||
|
||||
- 不改后端协议、不改 `/system/status`、`/logs` URL 或请求字段。
|
||||
- 不改 `pollStatus` 的成功分支业务逻辑(重启恢复仅抽出函数,条件不变)。
|
||||
- 不新增 toast CSS / 不引入 `info` 专属配色。
|
||||
- 不动 `connection.js`、`log.js`、`ota/*`、`stages.js`、`upload-gd32.js`(仅 app.js 加一个 helper)。
|
||||
- 不改 `scheduleStatusPoll` 公共签名、不改导出列表。
|
||||
|
||||
### 验证
|
||||
|
||||
- `git diff` 人工核对 polling.js 重构后两处重启恢复分支与原逻辑逐行等价。
|
||||
- 浏览器加载 `tools/index.html`,走一遍 ESP32 OTA(上传→中继→重启→恢复),确认:
|
||||
- 上传期间无 `/system/status` 请求(DevTools Network)。
|
||||
- `/logs` 不出现重叠并发请求。
|
||||
- 重启恢复只弹一次「ESP32 已恢复在线」。
|
||||
- 设备忙时连接胶囊显示「设备忙」,与改前一致。
|
||||
- 跑一次 GD32 OTA(中继期 400ms 轮询),确认 `setStatusPollIntervalMs` 路径未受影响。
|
||||
- 无单元测试框架,不新增测试。
|
||||
|
||||
### 影响面
|
||||
|
||||
- 修改文件:`tools/js/common/toast.js`、`tools/js/common/polling.js`、`tools/js/app.js`(仅 +1 helper + 1 window 挂载)。
|
||||
- 风险点:`pollLogs` 改递归 setTimeout 后,若某轮抛出未走 finally 会停转——`pollLogs` 全程 `try/catch/finally`,与 `pollStatus` 同模式,风险等价。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 死函数清理与兼容标记计划
|
||||
|
||||
## 目标
|
||||
逐函数扫描 `main/`(C++)、`tools/`(JS)及根目录 Python 脚本,识别:
|
||||
1. **死函数**:已定义但无调用、未注册、未绑定,直接清理。
|
||||
2. **旧版本兼容函数**:仍被调用但无实际作用,或注释/命名表明为兼容保留,进行明确标记(保留并加注释,或连同调用点一起移除)。
|
||||
|
||||
扫描已完成,共覆盖约 349 个 C++ 定义、~130 个 JS 函数、2 个 Python 脚本。
|
||||
|
||||
---
|
||||
|
||||
## 一、C++ 死函数(建议删除)
|
||||
|
||||
| # | 函数/方法 | 文件 | 行 | 说明 |
|
||||
|---|-----------|------|----|------|
|
||||
| 1 | `esp_err_t ota_prepare_handler(httpd_req_t *req)` | `main/server/http_handlers.cpp` | 202 | 完整实现,但 `wifi_ota.cpp` 的 `kHttpRoutes` 未注册 `/ota/prepare`,无任何调用。 |
|
||||
| 2 | `bool WifiOta::getGd32StorageDone() const` | `main/server/wifi_ota.h` | 73 | 内联 getter;`setGd32StorageDone()` 在用,getter 无调用。 |
|
||||
| 3 | `bool OtaRelay::StateLockGuard::isLocked() const` | `main/relay/ota_relay.h` | 241 | 公共嵌套类方法,无调用。 |
|
||||
| 4 | `int canFdSendNoReply(...)` | `main/transport/canfd_bridge.cpp/h` | 93/61 | 无调用者。 |
|
||||
| 5 | `int canFdPullIdSnapshot(...)` | `main/transport/canfd_bridge.cpp/h` | 183/158 | 无调用者。 |
|
||||
| 6 | `bool getLatestIdSnapshot(...)` | `main/monitor/can_feedback_task.cpp/h` | 156/44 | 无调用者;`getLatestCanFeedback()` 已覆盖同一需求。 |
|
||||
| 7 | `const char* OtaCoordinator::getSystemStateString() const` | `main/ota/ota_coordinator.cpp/h` | 711/438 | 注释已写明“保持向后兼容”,但无任何调用。 |
|
||||
| 8 | `int OtaCoordinator::esp32FinalizeErrCategory(esp_err_t err)` | `main/ota/ota_coordinator.cpp/h` | 563/466 | 声明、定义齐全,无调用。 |
|
||||
| 9 | `bool OtaCoordinator::isGd32Busy() const` | `main/ota/ota_coordinator.cpp/h` | 680/424 | 无调用;`isGd32BusyState()` 仍保留供内部使用。 |
|
||||
| 10 | `bool OtaCoordinator::isEsp32Busy() const` | `main/ota/ota_coordinator.cpp/h` | 686/431 | 无调用;`isEsp32BusyState()` 仍保留。 |
|
||||
| 11 | `bool OtaCoordinator::isGd32StorageDone() const` | `main/ota/ota_coordinator.cpp/h` | 723/? | 无调用;`getSnapshot()` 直接读取 `m_gd32StorageDone`。 |
|
||||
| 12 | `static void OtaRelay::getGd32Version(gd32_version_t *out)` | `main/relay/ota_relay.cpp/h` | 144/156 | 无调用;`getSnapshot()` 已包含 version。 |
|
||||
| 13 | `static uint32_t OtaRelay::getRelaySeq()` | `main/relay/ota_relay.cpp/h` | 126/141 | 无调用;`getSnapshot()` 已包含 seq。 |
|
||||
| 14 | `static uint32_t OtaRelay::getRelayTotalPackets()` | `main/relay/ota_relay.cpp/h` | 132/146 | 无调用;`getSnapshot()` 已包含 total_packets。 |
|
||||
|
||||
### C++ 清理动作
|
||||
- 删除上述函数的实现与对应头文件声明。
|
||||
- 删除 `ota_prepare_handler` 后,检查并清理其专用的 `clear_esp32_prepare` / `clear_gd32_prepare` 静态状态变量是否仍被其他 handler 使用;若仅由 `ota_prepare_handler` 使用则一并移除。
|
||||
- 删除 `getLatestIdSnapshot` 后,若 `id_snapshot_cache_t` 类型不再被使用,保留在头文件中(作为公共类型)或同步删除。
|
||||
|
||||
---
|
||||
|
||||
## 二、C++ 旧版本兼容标记(建议保留并强化注释)
|
||||
|
||||
| 函数/成员 | 文件 | 说明 |
|
||||
|-----------|------|------|
|
||||
| `ble_ota_start_advertising(void)` | `main/server/ble_ota.cpp` | 注释已写“kept as free function for BLE callback compatibility”,确实被注册使用,保留并维持注释。 |
|
||||
| `volatile bool m_wifiConnected` | `main/monitor/health_monitor.h` | 注释已写“保留为历史兼容”,保留。 |
|
||||
| CANFD/SPI/Flash 迁移注释 | `protocol.cpp`、`spi_master.h/.cpp`、`ota_relay.h` | 已是历史迁移说明,无需改动。 |
|
||||
| NVS 兼容 fallback | `gd32_flash_store.cpp` | 注释“视为空元数据,保持兼容”,保留。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、JS 死函数(建议删除)
|
||||
|
||||
### `tools/app.js`
|
||||
| 函数 | 行 | 说明 |
|
||||
|------|----|------|
|
||||
| `sendCanFrame()` | 925 | 引用已移除的 `#canChannel/#canId/#canDlc/#canData`。 |
|
||||
| `clearCanFields()` | 968 | 同上。 |
|
||||
| `updateCanHint()` | 376 | 同上;事件绑定也指向已移除 DOM。 |
|
||||
| `setHelper(id, text, type)` | 370 | 仅被 `updateCanHint()` 调用。 |
|
||||
| `retryEsp32Upload()` | 1629 | 无按钮/调用者。 |
|
||||
| `cancelEsp32Ota()` | 1643 | 无按钮/调用者。 |
|
||||
| `applyEsp32Firmware()` | 1655 | 当前流程由 `verifyEsp32Firmware()` 直接 `/ota/update` 应用。 |
|
||||
| `triggerRelay()` | 1759 | 仅转发 `triggerGd32Relay()`,注释写“旧页面兜底”。 |
|
||||
|
||||
### `tools/app.js` 兼容/空壳处理
|
||||
| 函数 | 行 | 建议 |
|
||||
|------|----|------|
|
||||
| `showVerifyActions(type)` | 179 | 空壳,被 `verifyEsp32Firmware()` 等调用。删除函数体及所有调用点(因为 UI 按钮已移除)。 |
|
||||
| `setOtaProgressBadge()` | 782 | 空壳局部函数,被多处调用。删除函数及所有调用点。 |
|
||||
|
||||
### `tools/ble_ota.js`
|
||||
| 函数 | 行 | 说明 |
|
||||
|------|----|------|
|
||||
| `restartEsp32()` | 837 | IIFE 内部定义,无绑定/调用;全局 `restartEsp32()` 来自 `app.js` 已绑定按钮。 |
|
||||
|
||||
### `tools/can.js`
|
||||
| 函数 | 行 | 说明 |
|
||||
|------|----|------|
|
||||
| `metaCard(label, value)` | 747 | 从未调用。 |
|
||||
| `renderSignalGrid(/* message */)` | 751 | 空壳,无调用者。 |
|
||||
| `renderSelectedMessage()` | 742 | 空壳但**仍被 `selectMessage` 等多个调用点调用**,是兼容 shim。保留并强化注释“旧版信号编辑面板兼容 shim”。 |
|
||||
|
||||
### `tools/index.html`
|
||||
| 函数 | 行 | 说明 |
|
||||
|------|----|------|
|
||||
| `window.attachSharedFirmwareDropBridge` | 639 | 定义但从未调用;`app.js` 的 `setupDropZone()` 已处理 drop。 |
|
||||
|
||||
---
|
||||
|
||||
## 四、Python 脚本
|
||||
|
||||
- `ota_upload_test.py`、`serial_monitor.py` 均为纯顺序脚本,无函数定义,无需改动。
|
||||
|
||||
---
|
||||
|
||||
## 五、验证步骤
|
||||
|
||||
1. 修改后执行 `idf.py build`(或等价的 `idf.py fullclean` + `build`)验证 C++ 无编译/链接错误。
|
||||
2. 浏览器打开 `tools/index.html`、`tools/can.html`,验证控制台无 `xxx is not defined` 报错。
|
||||
3. 检查 C++ 中是否产生新的 `-Wunused-function` / `-Wunused-variable` 警告。
|
||||
4. 对存疑项做二次 grep 确认无调用。
|
||||
|
||||
---
|
||||
|
||||
## 六、风险与保留项
|
||||
|
||||
- `canFdSendNoReply` / `canFdPullIdSnapshot` 是公共 API,当前无调用。若你计划后续通过 HTTP/SPI 暴露它们,可以改为加 `[[maybe_unused]]` 或保留注释;否则按死函数删除。
|
||||
- `ota_prepare_handler` 实现完整,若你打算未来启用 `/ota/prepare` 端点,应改为注册而非删除。本次计划按“当前死代码”删除处理。
|
||||
- `renderSelectedMessage()` 仍被调用,保留作为明确的旧版兼容 shim。
|
||||
|
||||
---
|
||||
|
||||
## 七、交付物
|
||||
|
||||
1. 修改后的 C++ 源文件与头文件(死函数已删除,兼容项已标记/保留)。
|
||||
2. 修改后的 JS 文件(死函数已删除,空壳 shim 已清理)。
|
||||
3. 一份简短变更摘要,列出删除的函数及保留的兼容 shim。
|
||||
4. `idf.py build` 成功输出。
|
||||
@@ -0,0 +1,28 @@
|
||||
## 目标
|
||||
重新生成 `G:/Robot_v2/DCU/SPEC/code_optimization_audit_2026-07-19.html`,让它反映当前工作树的代码状态(已修改的 `health_monitor.*`、`spi_master.*`、`ble_ota_data.cpp`、`version.h`、`tools/js/common/connection.js` 等)。
|
||||
|
||||
## 步骤
|
||||
1. 在 `G:/Robot_v2/DCU` 下运行生成器(不带 `--verify`,按你刚才的选择):
|
||||
```bash
|
||||
python tools/gen_code_optimization_audit.py
|
||||
```
|
||||
- 默认输出到 `SPEC/code_optimization_audit_<今天日期>.html`,即 `code_optimization_audit_2026-07-20.html`(今天 2026-07-20)。
|
||||
- 注意:现有文件名是 `..._2026-07-19.html`,新生成的会是 `..._2026-07-20.html`,不会覆盖旧文件。
|
||||
|
||||
2. 检查脚本输出:
|
||||
- 看 `[audit] files=... lines=...`、findings 数量、是否出现 `重复 finding ID` 或锚点漂移错误。
|
||||
- 脚本内置 HTML 结构与内部链接自校验,失败会返回非 0。
|
||||
|
||||
3. 如有锚点漂移(某个 finding 引用的行号在改动后对不上原证据),脚本会报错并指出哪个 finding;此时需要我先核对对应源码再决定是调整脚本里的锚点还是接受新行号——会先汇报给你,不会私自改 finding 逻辑。
|
||||
|
||||
4. 生成成功后,简要汇报:
|
||||
- 新报告路径
|
||||
- findings 数量与 P0/P1/P2 分布是否变化
|
||||
- 与旧报告的关键差异(例如新增/消失的 finding、行号变化)
|
||||
- 旧报告 `2026-07-19.html` 是否保留(默认保留)
|
||||
|
||||
## 不做的事
|
||||
- 不带 `--verify`(不跑 ESP32/GD32 构建、SPI 自测、前端语法检查)
|
||||
- 不删除旧报告 `2026-07-19.html`
|
||||
- 不改生成器脚本本身
|
||||
- 不做 git 提交
|
||||
@@ -0,0 +1,116 @@
|
||||
## 目标
|
||||
修复 heap alloc hook 与 health_mon 任务之间共享诊断状态的「无原子采样协议」问题:hook 写多个 volatile 字段组成的「最后一击」快照,reader(`maybeRecordHeapLowWater`)在 FreeRTOS mutex 下读取——但 hook 不获取该 mutex,因此 mutex 不提供与 hook 写的互斥,多字段快照可被撕裂(`hook_fired=1` 配上上一次的 `hook_alloc_size`/`caller`/`free_after`)。同时 `g_est_free` 是只减不增的近似值,命名上未与精确实测区分。
|
||||
|
||||
## 现状(已确认)
|
||||
- ESP32-C3 **单核** RISC-V(`CONFIG_FREERTOS_UNICORE=y`),无 SPIRAM,`CONFIG_HEAP_USE_HOOKS=y`。
|
||||
- hook (`esp_heap_trace_alloc_hook`, `health_monitor.cpp:77-115`) 在 ESP-IDF heap 分配器**自带的临界区内**被调用(heap spinlock 已禁中断),且可能从 IRAM/cache-off 上下文进入 → **禁止 FreeRTOS/flash 调用**,禁止在 hook 内再加锁。
|
||||
- hook 写入的共享状态(`health_monitor.cpp:50-68`):
|
||||
- `g_est_free`(近似空闲,只减不增,周期 resync)
|
||||
- 「最后一击」快照:`g_hook_min_est` / `g_hook_alloc_size` / `g_hook_caller_addr` / `g_hook_free_after` / `g_hook_fired`
|
||||
- 环形 `g_recent_allocs[4]` + `g_recent_alloc_w` / `g_recent_alloc_n`
|
||||
- reader 路径:`maybeRecordHeapLowWater()` 在 `LockGuard lock(m_mutex)`(FreeRTOS mutex)内读取上述所有字段(`689-704`)。**`m_mutex` 不能阻断 hook**(hook 不获取它,且 hook 可能在中断禁用上下文)→ 撕裂读。
|
||||
- reader 还有 `dumpHeapLowWaterJson()`(httpd 任务)读 `m_heapLowWater`:这条路径已由 `m_mutex` 正确保护(writer 也是 `maybeRecordHeapLowWater` 在同一 `m_mutex` 下写 `m_heapLowWater`),无撕裂问题,**不需改**。
|
||||
- 现有同类模式:`CpuStats::getSnapshot()`(`cpu_stats.cpp:67-69`)用 `portENTER_CRITICAL(&m_mux)` 做多字段快照,**reader-only 临界区**,与 writer(也是任务上下文)互斥。本库无 seqlock / std::atomic / memory fence,统一依赖单核字宽原子性 + 临界区。
|
||||
|
||||
## 设计:reader-only `portENTER_CRITICAL` 快照(与 CpuStats 一致)
|
||||
|
||||
### 关键约束分析
|
||||
- 单核 + hook 已在 heap 临界区内(禁中断)→ reader 用 `portENTER_CRITICAL` 禁中断即可与 hook 互斥:reader 持有临界区期间,任何 `malloc`(从而 hook)无法打断(因为 malloc 需取 heap spinlock,而 heap spinlock 也禁中断;但更直接:reader 禁中断后,hook 所在的 malloc 调用路径在 reader 释放前无法进入——单核上禁中断即阻止所有任务/ISR 抢占)。
|
||||
- reader 在临界区内**只做 volatile 拷贝,不分配、不调任何 FreeRTOS/flash 函数**,保持极短热路径(与 hook 约束对称)。
|
||||
- hook 端**不动**:不加锁、不改逻辑,保持 IRAM 安全。hook 写多个字段天然在 heap 临界区内 = 对 reader 而言是「原子写」(reader 禁中断期间 hook 跑不了;hook 跑时 reader 也进不来)。
|
||||
|
||||
### 实施步骤
|
||||
|
||||
#### 1. `health_monitor.h` — 新增 portMUX 与 reader 快照结构
|
||||
- 在 `HealthMonitor` private 区新增静态 spinlock:
|
||||
```cpp
|
||||
static portMUX_TYPE s_hookSnapMux; ///< 保护 hook 共享状态的多字段读快照(单核 = 禁中断)
|
||||
```
|
||||
- 声明一个私有 reader 快照结构(把 hook 端多字段一次性拷出来),供 `maybeRecordHeapLowWater` 使用:
|
||||
```cpp
|
||||
struct HookLastHitSnap {
|
||||
uint32_t alloc_size;
|
||||
uint32_t caller_addr;
|
||||
uint32_t free_after;
|
||||
uint32_t fired;
|
||||
uint32_t est_free;
|
||||
uint32_t min_est;
|
||||
uint32_t recent_n;
|
||||
RecentAllocSnap recent[kRecentAllocCap];
|
||||
};
|
||||
```
|
||||
(`RecentAllocSnap` / `kRecentAllocCap` 当前在 .cpp 匿名 namespace,需提到头文件或在 .cpp 内做快照——**选择后者**:快照结构与 `s_hookSnapMux` 的定义都留在 .cpp 匿名 namespace,reader 在 .cpp 内取快照,不污染头文件。`portMUX_TYPE` 是 FreeRTOS 类型,头文件已 include `freertos/FreeRTOS.h`,但 spinlock 实例可放 .cpp 匿名 namespace。)
|
||||
|
||||
**修正**:`s_hookSnapMux` 放 .cpp 匿名 namespace(与现有 `g_hook_*` 同作用域),头文件零改动。快照结构也放 .cpp 匿名 namespace。
|
||||
|
||||
#### 2. `health_monitor.cpp` — reader 临界区快照
|
||||
- 匿名 namespace 新增:
|
||||
```cpp
|
||||
static portMUX_TYPE s_hookMux = portMUX_INITIALIZER_UNLOCKED;
|
||||
```
|
||||
- 新增 reader 辅助函数(**任务上下文专用**,禁中断 + 极短):
|
||||
```cpp
|
||||
HookSnap captureHookSnapshot()
|
||||
{
|
||||
HookSnap s = {};
|
||||
portENTER_CRITICAL(&s_hookMux);
|
||||
s.alloc_size = g_hook_alloc_size;
|
||||
s.caller_addr = g_hook_caller_addr;
|
||||
s.free_after = g_hook_free_after;
|
||||
s.fired = g_hook_fired;
|
||||
s.est_free = g_est_free;
|
||||
s.min_est = g_hook_min_est;
|
||||
s.recent_n = g_recent_alloc_n;
|
||||
for (uint32_t i = 0; i < s.recent_n && i < kRecentAllocCap; ++i) {
|
||||
s.recent[i] = g_recent_allocs[i];
|
||||
}
|
||||
portEXIT_CRITICAL(&s_hookMux);
|
||||
return s;
|
||||
}
|
||||
```
|
||||
- 新增 writer 辅助(在 reader 临界区内做 hook 状态的「确认消费」):
|
||||
```cpp
|
||||
void consumeHookFired(uint32_t new_min_est)
|
||||
{
|
||||
// 必须在 portENTER_CRITICAL(&s_hookMux) 内调用
|
||||
g_hook_min_est = new_min_est;
|
||||
g_hook_fired = 0;
|
||||
}
|
||||
```
|
||||
- 修改 `maybeRecordHeapLowWater` 的 hook 块(`688-730`):把 `LockGuard lock(m_mutex)` 内的裸 volatile 读 + `g_hook_min_est`/`g_hook_fired` 写,替换为:
|
||||
1. 在 `m_mutex` 外、进入快照写入前,调用 `captureHookSnapshot()` 取一份一致快照(`HookSnap hs`)。
|
||||
2. 用 `hs.*` 字段填充 `rec.hook_*` 与 `big_*` 搜索(不再裸读 `g_hook_*` / `g_recent_allocs`)。
|
||||
3. 在 `m_mutex` 内写完 `rec` 后,再用一个 `portENTER_CRITICAL(&s_hookMux)` 块调用 `consumeHookFired(g_est_free)` 重置 `g_hook_min_est` / `g_hook_fired`(保持原语义:消费后把 min_est 抬到当前 est,fired 清零,避免同一窗口重复记)。
|
||||
- 注意 `g_est_free` 在 reader 临界区外已被 `hookResyncEstFree(free_heap)` 更新过(`336`),`consumeHookFired` 读它时需在 `s_hookMux` 内读以保证与 hook 写一致——因此 `consumeHookFired` 内读 `g_est_free`。
|
||||
|
||||
#### 3. 命名区分 estimated vs measured + 置信度标注
|
||||
- `HeapLowWaterRecord.hook_free_after`(`health_monitor.h:84`)注释已说明「est,非精确实测」。增强为在 JSON 输出与字段名上显式区分:
|
||||
- JSON 字段 `hook_free_after` → 改名为 `hook_est_free_after`(明确 est);前端 schema 同步说明。
|
||||
- 在 `dumpHeapLowWaterJson` 的字段输出中,紧随其后加一个 `hook_confidence` 字段:值为字符串 `"est_low_watermark"`(hook 命中时)或 `"none"`(未命中),明确告知前端这是近似值、非精确实测。
|
||||
- `health_monitor.h` 注释补充:`hook_free_after`(保留成员名以减小 diff)注释行加「**置信度:est 低水位近似值**,误差 = resync 周期内 free 路径未记账量;前端应标注 estimated」。
|
||||
- `g_est_free` 注释(`56` 行)补充:「**estimated**,非 measured;health_mon 周期用 `esp_get_free_heap_size()` 实测纠偏。误差上界 ≈ resync 周期(5s)内总 alloc 字节」。
|
||||
- 在文件头 `46-48` 的「记录策略」注释块补一段「**采样协议**:reader(`maybeRecordHeapLowWater`)用 `portENTER_CRITICAL(&s_hookMux)` 禁中断取一致快照,与 hook(在 heap 临界区内写)互斥;hook 端不加锁、保持 IRAM 安全」。
|
||||
|
||||
#### 4. 文档化 hook 可调用 API / IRAM 约束
|
||||
- 文件头 `36-48` 注释块已说明「禁止 FreeRTOS/flash」。补充明确契约条目:
|
||||
```
|
||||
// ★ hook 可调用约束(IRAM 安全契约):
|
||||
// - 禁止:FreeRTOS API、flash/rodata 访问、mutex/semaphore、长函数。
|
||||
// - 允许:DRAM volatile 标量读写、__builtin_return_address、纯算术。
|
||||
// - 写共享状态天然原子:hook 已在 heap 临界区内(禁中断),reader 用 s_hookMux 互斥。
|
||||
// - 嵌套分配:hook 内禁止任何可能触发 malloc 的调用(否则递归进 hook,armed 标志不防递归——
|
||||
// 当前 hook 体内无分配调用,保持之)。
|
||||
```
|
||||
|
||||
## 验收
|
||||
- 编译通过(ESP-IDF build),无新 warning。
|
||||
- 并发压力下(WiFi + OTA + CAN 并发)多次抓取 `/system/heap_low_log`:`hook_fired=1` 的记录中 `hook_alloc_size`/`hook_caller_addr`/`hook_est_free_after` 三者一致(来自同一次 alloc),不再出现「fired=1 但 size=上一次值」的撕裂。
|
||||
- JSON 输出含 `hook_est_free_after` 与 `hook_confidence` 字段,前端可明确区分 estimated vs measured。
|
||||
- hook 热路径长度不变(未加锁、未加函数调用),IRAM 安全契约保持。
|
||||
- reader 临界区极短(仅 volatile 拷贝,~10 个字段 + ≤4 条 recent),不持 `m_mutex` 同时持 `s_hookMux`(`captureHookSnapshot` 在 `m_mutex` 外调用),避免锁序问题。
|
||||
|
||||
## 影响范围
|
||||
- 仅改 `main/monitor/health_monitor.cpp`(hook 块、`maybeRecordHeapLowWater`、注释、新增匿名 namespace 辅助)与 `main/monitor/health_monitor.h`(`hook_free_after` 注释增强、JSON 字段名说明)。
|
||||
- `dumpHeapLowWaterJson`:JSON 字段 `hook_free_after` → `hook_est_free_after`,新增 `hook_confidence`。前端(IndexedDB 累积端)需同步字段名——属可观测性 schema 变更,需告知前端。
|
||||
- 不动 hook 函数体逻辑、不动 `m_mutex` 保护的 `m_heapLowWater` 写入路径、不动 ESP-IDF 构建配置。
|
||||
- 不引入 seqlock / std::atomic(与库现有单核 volatile + 临界区惯例一致)。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 修复:WiFi 上传阻塞 HTTP 主线程导致日志挤压/内存爆满
|
||||
|
||||
## 根本原因
|
||||
|
||||
`esp_http_server`(IDF v6.0)是**单线程 select 模型**:一个主线程在 `select()` 循环里依次处理所有 socket。`ota_upload_handler` 在 HTTP 主线程里同步阻塞执行整个上传(GD32 含 Flash 擦除可达数十秒,ESP32 同样)。期间主线程不回 `select()`,导致:
|
||||
|
||||
1. 前端 `pausePollingForUpload()` 仍以 2.5–3s 发 `/system/status`、`/logs`,但 HTTP 主线程无法 accept/处理 → 连接在 lwIP 侧堆积,pbuf 占用堆内存 → 内存爆满
|
||||
2. `/logs` 无法被 drain,日志在 ring buffer(64×256,有上限但前端看不到)→ 体现为"LOG挤压"
|
||||
3. `max_open_sockets=4` + `lru_purge` 也无效:线程不回 `select()`,LRU 清理不触发
|
||||
|
||||
## 方案:异步上传 handler
|
||||
|
||||
用 IDF v6.0 的 `httpd_req_async_handler_begin/complete` API(已确认可用,`examples/protocols/http_server/async_handlers` 即此模式),把上传移到独立 worker 任务。HTTP 主线程立即返回 `select()` 循环,继续服务 `/logs`、`/system/status`。
|
||||
|
||||
### 改动文件
|
||||
|
||||
#### 1. `main/server/http_handlers.h`(新增声明)
|
||||
- 新增 `esp_err_t ota_upload_handler_async(httpd_req_t *req);`(新的上传入口,注册替代原 `ota_upload_handler`)
|
||||
- 保留原 `ota_upload_handler` 的实现,但改为 static 内部函数,仅供 async worker 调用
|
||||
|
||||
#### 2. `main/server/http_handlers.cpp`(核心改动)
|
||||
- 新增 async worker 基础设施(文件内 static):
|
||||
- `static QueueHandle_t s_uploadQueue`(长度 1,元素为 `httpd_req_t*`)
|
||||
- `static SemaphoreHandle_t s_workerReady`(binary,初值 0)
|
||||
- `static TaskHandle_t s_uploadWorker`(栈 8192,优先级 5,固定 core 0 与现有任务一致)
|
||||
- `static void uploadWorkerTask(void*)`:循环 `xSemaphoreGive(s_workerReady)` → `xQueueReceive` → 调用原上传逻辑(用 async req 句柄)→ `httpd_req_async_handler_complete()`
|
||||
- `static esp_err_t ensureUploadWorker()`:懒启动 worker(首次上传请求时创建,幂等)
|
||||
- 新增 `ota_upload_handler_async(httpd_req_t *req)`:
|
||||
1. `ensureUploadWorker()`
|
||||
2. `httpd_req_t *async_req; httpd_req_async_handler_begin(req, &async_req)` —— 把 socket 标记 `for_async_req`,主线程不再处理它
|
||||
3. `if (xSemaphoreTake(s_workerReady, 0) == false)` —— worker 忙(已有上传在进行),立即 `httpd_req_async_handler_complete(async_req)` 并返回 503:
|
||||
```
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
httpd_resp_sendstr(req, "{\"status\":\"error\",\"message\":\"upload in progress\"}");
|
||||
return ESP_OK;
|
||||
```
|
||||
注意:503 要发在**原 req** 上(begin 之后 async req 才能发响应,但此处用原 req 发错误更简单——实际上 begin 后原 req 的 remaining_len 被清零,发响应需用 async_req。所以错误路径用 async_req 发)。
|
||||
4. `xQueueSend(s_uploadQueue, &async_req, portMAX_DELAY)`
|
||||
5. 返回 `ESP_OK`(主线程已完成投递,立即回 select)
|
||||
- 把原 `ota_upload_handler` 改名 `static esp_err_t ota_upload_handler_impl(httpd_req_t *req)`,逻辑不变(它内部已用 `httpd_req_recv`/`httpd_resp_send`,async 句柄完全兼容)
|
||||
- **保留 CORS 头设置**:`http_set_cors_headers` 在 `ota_upload_handler_async` 里对原 req 设置(begin 之前),async 路径的响应头也需在 worker 里设置——实际上 `httpd_req_async_handler_begin` 会拷贝 resp_hdrs,所以 CORS 头在 begin 前对原 req 设置即可被子拷贝
|
||||
|
||||
#### 3. `main/server/wifi_ota.cpp`(路由注册)
|
||||
- `kHttpRoutes[]` 中 `/ota/upload` 的 handler 从 `ota_upload_handler` 改为 `ota_upload_handler_async`
|
||||
- OPTIONS 预检不变(仍走 `http_options_handler`)
|
||||
|
||||
### 不改动的部分
|
||||
- **前端 app.js 不改**:`pausePollingForUpload` 仍降速轮询,但现在能真正拿到 `/logs` 和 `/system/status` 响应(主线程不再阻塞)
|
||||
- **日志系统不改**:ring buffer 已有上限,异步化后 `/logs` 能被正常 drain,不再积压
|
||||
- **OtaCoordinator 不改**:已有 `ESP_ERR_INVALID_STATE` 拒绝并发会话,worker 的 503 是更早的防线
|
||||
- **OTA_BLOCK_SIZE、栈大小不改**
|
||||
|
||||
### 内存影响
|
||||
- 新增 1 个 worker 任务栈 8192 字节(从现有 `free_heap` 出,ESP32-C3 内部堆约 200KB+,可接受)
|
||||
- 队列 1×`sizeof(httpd_req_t*)`=4 字节,信号量 ~80 字节,忽略不计
|
||||
- **收益**:上传期间不再堆积 lwIP pbuf(每个未 accept 的连接可达数 KB),消除内存爆满主因
|
||||
|
||||
### 验证方式
|
||||
1. 编译通过(`idf.py build`)
|
||||
2. 功能验证:WiFi 上传 GD32/ESP32 固件,上传期间网页 `/logs` 持续刷新、`/system/status` 能返回(不再超时)
|
||||
3. 内存验证:`/system/status` 的 `free_heap` 在上传期间不再持续下降
|
||||
4. 并发验证:上传进行中再发一个 `/ota/upload`,立即返回 503 而非阻塞
|
||||
@@ -0,0 +1,117 @@
|
||||
## 实施计划:DCU 初始化时通过 PC9 复位 ESP32
|
||||
|
||||
### 背景确认
|
||||
- 目标仓库:`G:/Robot_v2/DCU/DCU_APP/GD32E508_DCU_APP`(GD32 侧 DCU 固件)。
|
||||
- PC9 当前在 DCU_APP 中未被使用,不会与 USART0/CAN/SPI/EXMC 等冲突。
|
||||
- ESP32 EN/RESET 为低电平复位、高电平释放;你给出的 50ms 低电平保持后释放逻辑正确。
|
||||
- 项目风格:配置集中在 `sys/config.h`,外设封装为 `device/*.h` 中的 C++ class,延迟统一用 `SysTickTimer::delayMs()`,GPIO 清 0 用 `gpio_bit_reset()`。
|
||||
|
||||
### 修改点
|
||||
|
||||
#### 1. `sys/config.h`:增加 ESP32 复位引脚配置
|
||||
在 SPI Ready 配置附近新增:
|
||||
```cpp
|
||||
/** @brief ESP32 硬件复位控制端口(PC9 接 ESP32 EN/RESET)。 */
|
||||
constexpr uint32_t CONFIG_ESP32_RESET_PORT = GPIOC;
|
||||
/** @brief ESP32 硬件复位控制引脚。 */
|
||||
constexpr uint32_t CONFIG_ESP32_RESET_PIN = GPIO_PIN_9;
|
||||
/** @brief ESP32 复位低电平保持时间,单位 ms。 */
|
||||
constexpr uint32_t CONFIG_ESP32_RESET_HOLD_MS = 50U;
|
||||
```
|
||||
|
||||
#### 2. 新增 `device/esp32_reset.h`
|
||||
按现有 `device/watchdog.h` 风格编写:
|
||||
```cpp
|
||||
#ifndef DEVICE_ESP32_RESET_H
|
||||
#define DEVICE_ESP32_RESET_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
class Esp32Reset {
|
||||
public:
|
||||
/** @brief 初始化 PC9 为推挽输出,先拉低 50ms 再释放 ESP32。 */
|
||||
static void init();
|
||||
|
||||
/** @brief 主动拉低 ESP32 RESET(进入复位)。 */
|
||||
static void assertReset();
|
||||
|
||||
/** @brief 释放 ESP32 RESET(高电平,ESP32 启动)。 */
|
||||
static void releaseReset();
|
||||
};
|
||||
|
||||
#endif /* DEVICE_ESP32_RESET_H */
|
||||
```
|
||||
|
||||
#### 3. 新增 `device/esp32_reset.cpp`
|
||||
按现有 `device/watchdog.cpp` 风格编写:
|
||||
```cpp
|
||||
#include "device/esp32_reset.h"
|
||||
#include "sys/config.h"
|
||||
#include "sys/systick.h"
|
||||
#include "main.h"
|
||||
|
||||
void Esp32Reset::init()
|
||||
{
|
||||
/* 1. 使能 GPIOC 时钟 */
|
||||
rcu_periph_clock_enable(RCU_GPIOC);
|
||||
|
||||
/* 2. 先把输出数据寄存器清零,确保配置成输出时就是低电平 */
|
||||
gpio_bit_reset(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN);
|
||||
|
||||
/* 3. 配置 PC9 为推挽输出 */
|
||||
gpio_init(CONFIG_ESP32_RESET_PORT, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, CONFIG_ESP32_RESET_PIN);
|
||||
|
||||
/* 4. 保持复位 50ms,等电源和其他 IO 稳定 */
|
||||
SysTickTimer::delayMs(CONFIG_ESP32_RESET_HOLD_MS);
|
||||
|
||||
/* 5. 释放 ESP32 */
|
||||
gpio_bit_set(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN);
|
||||
}
|
||||
|
||||
void Esp32Reset::assertReset()
|
||||
{
|
||||
gpio_bit_reset(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN);
|
||||
}
|
||||
|
||||
void Esp32Reset::releaseReset()
|
||||
{
|
||||
gpio_bit_set(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. `sys/initial.cpp`:在初始化序列中加入 ESP32 复位
|
||||
- 新增 `#include "device/esp32_reset.h"`。
|
||||
- 在 `SysTickTimer::initMs()` 之后、`Usart0::configurePins()` 之前调用 `Esp32Reset::init()`,原因:
|
||||
- 需要 SysTick 才能做 50ms 延迟;
|
||||
- 尽早释放 ESP32,让它在 DCU 继续初始化 USART/CAN/SPI 期间完成自身启动,等 SPI 命令总线初始化时 ESP32 已经可用。
|
||||
|
||||
修改后顺序:
|
||||
```cpp
|
||||
nvic_priority_group_set(...);
|
||||
SysTickTimer::initMs();
|
||||
Esp32Reset::init(); // 新增
|
||||
Usart0::configurePins();
|
||||
Usart0::init();
|
||||
...
|
||||
```
|
||||
|
||||
#### 5. `GD32E508_DCU_APP.cproject.yml`:把新文件加入构建
|
||||
在 `groups → device` 中增加:
|
||||
```yaml
|
||||
- file: ./device/esp32_reset.h
|
||||
- file: ./device/esp32_reset.cpp
|
||||
```
|
||||
|
||||
### 代码适配说明
|
||||
- 你原代码中的 `GPIO_BC(GPIOA) = GPIO_PIN_9` 等价于 `gpio_bit_reset(...)`,项目里统一用后一个 API。
|
||||
- `delay_ms(50)` 替换为 `SysTickTimer::delayMs(CONFIG_ESP32_RESET_HOLD_MS)`,与项目中其他复位/ADC 稳定延迟一致。
|
||||
- 端口从 `GPIOA/PA9` 改为 `GPIOC/PC9` 的 `CONFIG_*` 常量,便于后续硬件变更。
|
||||
|
||||
### 验证
|
||||
- 重新生成并编译 CMSIS csolution 工程(`cbuild DCU_APP.csolution.yml` 或 Keil Studio),确认新文件被编译、无重复定义、无未定义符号。
|
||||
- 若编译失败,根据错误输出修正头文件包含或 cproject.yml 路径。
|
||||
|
||||
### 风险/注意事项
|
||||
- `Esp32Reset::init()` 调用时 SysTick 已就绪,因此 50ms 阻塞等待是安全的(此时中断已使能但尚未初始化其他外设)。
|
||||
- 若后续需要在运行期再次复位 ESP32,可直接调用 `Esp32Reset::assertReset()` + `SysTickTimer::delayMs(...)` + `Esp32Reset::releaseReset()`。
|
||||
- 本次修改不改变 PA9 的 USART0_TX 功能,调试串口保持正常。
|
||||
@@ -0,0 +1,81 @@
|
||||
## 方案:/can/feedback 改为请求驱动架构
|
||||
|
||||
### 目标
|
||||
- ESP32 不再被动缓存 GD32 的 0xC1 推送,改为前端请求时主动向 GD32 查询
|
||||
- GD32 停止主动推送 0xC1(降低 SPI 资源占用)
|
||||
- 删除 `s_latest[3]`/`s_work[3]` 常驻 BSS(省 ~7.8KB)+ handler 栈上 `results[3]`(省 ~3.9KB 栈)
|
||||
|
||||
### 依据
|
||||
- GD32 `handleFeedback` 已支持 0xC1 请求查询(`[channel, page]` → ACK + 页数据),无需新增协议
|
||||
- ESP32 `transact(0xC1, ...)` 是现成请求/响应机制,`canFdSend`/`canFdSetRxMode` 已在用
|
||||
- `canFdParseFeedbackPage` 可直接复用(只解析 payload 字节,不关心来源)
|
||||
- 0xC4/0xC5/0xC6/0xD2 推送独立,不受影响
|
||||
|
||||
### 改动清单
|
||||
|
||||
#### 1. ESP32 侧 — `main/transport/canfd_bridge.cpp` / `.h`
|
||||
新增 `canFdQueryFeedback(channel, page, can_feedback_result_t *out, uint8_t *entryIdx)`:
|
||||
```cpp
|
||||
int canFdQueryFeedback(uint8_t channel, uint8_t page,
|
||||
can_feedback_result_t *out, uint8_t *entryIdx);
|
||||
```
|
||||
- 发 `transact(0xC1, {channel, page}, ...)`,超时 200ms
|
||||
- 成功(resp_type==ACK)→ 调 `canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx)`
|
||||
- 返回 0=成功,-1=SPI/参数失败,-2=GD32 拒绝(ERROR)
|
||||
|
||||
#### 2. ESP32 侧 — `main/server/http_handlers.cpp` `can_feedback_handler`
|
||||
- 删除 `results[CAN_CHANNEL_MAX]` 栈数组 + `getLatestCanFeedback` 循环
|
||||
- 改为:对每个使能通道,调 `canFdQueryFeedback(ch, 0, &result, &entryIdx)` + `canFdQueryFeedback(ch, 1, &result, &entryIdx)`,在栈上用单个 `can_feedback_result_t result`(1.3KB,用完即释放)
|
||||
- lite 路径:只需 page 0(统计字段在 page0 header),1 次 SPI 查询即可
|
||||
- full 路径:page 0 + page 1,2 次查询,然后序列化
|
||||
- 查询失败 → 该通道 `valid=false`(与原来无快照时行为一致)
|
||||
- `pauseCanFeedbackTask` 调用保留(send_batch 仍需独占 SPI)
|
||||
|
||||
#### 3. ESP32 侧 — `can_mode_handler` GET
|
||||
- 原来 `getLatestCanFeedback(0, &fb)` 读缓存 mode → 改为 `canFdQueryFeedback(0, 0, &fb, &entryIdx)` 取 page0 的 mode 字段
|
||||
- 原来的 fallback `canFdSetRxMode` 保留(查询也失败时兜底)
|
||||
|
||||
#### 4. ESP32 侧 — `main/monitor/can_feedback_task.cpp`
|
||||
- **删除** `s_latest[3]`、`s_valid[3]`、`s_work[3]`、`s_workIdx[3]`、`s_lastLoggedError[3]`、`publish()`、`getLatestCanFeedback()`
|
||||
- **删除** taskLoop 里的 0xC1 分支(`canFdParseFeedbackPage` + `publish`)
|
||||
- **保留** 0xC4/0xC5/0xC6/0xD2 事件分发(这些仍走推送)
|
||||
- **保留** `pauseCanFeedbackTask`(send_batch / OTA 仍需暂停事件消费)
|
||||
- `s_mutex` 删除(仅保护 s_latest/s_valid,不再需要)
|
||||
|
||||
#### 5. ESP32 侧 — `main/monitor/can_feedback_task.h`
|
||||
- 删除 `getLatestCanFeedback` 声明
|
||||
- 保留 `startCanFeedbackTask`、`pauseCanFeedbackTask`
|
||||
|
||||
#### 6. GD32 侧 — `app_spi_cmd_can_feedback.cpp`
|
||||
- `canFeedbackStartRound()`:改为空函数(或加 `s_pushEnabled=false` 永久禁用推送)
|
||||
- `canFeedbackPushStep()`:开头加 `if (!s_pushEnabled) return;` 或直接 return
|
||||
- **保留** `handleFeedback`(请求查询处理程序不动)
|
||||
- **保留** `buildFeedbackPayload`(查询和推送共用)
|
||||
|
||||
#### 7. GD32 侧 — `task_runner.cpp`
|
||||
- `canFeedbackStartRound()` 调用可保留(变成空函数无副作用)或删除
|
||||
|
||||
### 内存收益
|
||||
| 项 | 改前 | 改后 | 省 |
|
||||
|---|---|---|---|
|
||||
| `s_latest[3]` BSS | 3.9KB | 0 | 3.9KB |
|
||||
| `s_work[3]` BSS | 3.9KB | 0 | 3.9KB |
|
||||
| handler 栈 `results[3]` | 3.9KB | 1.3KB(单通道) | 2.6KB 栈 |
|
||||
| **合计常驻 BSS** | | | **~7.8KB** |
|
||||
|
||||
### 性能影响
|
||||
- 原来读缓存:<1ms
|
||||
- 改后查询:lite 路径 1 次 SPI 往返 ~2-4ms;full 路径 6 次往返(3ch×2page)~12-24ms
|
||||
- 前端 1Hz 轮询,24ms 完全可接受(httpd 单线程最坏阻塞 <50ms)
|
||||
- GD32 不再 1Hz 推送 6 帧,省下 SPI 带宽和 PB6 中断
|
||||
|
||||
### 风险与缓解
|
||||
- **SPI 查询失败**:GD32 不响应时 `transact` 超时返回,handler 返回 valid=false,前端显示"--"(与原无快照行为一致)
|
||||
- **OTA 期间查询**:send_batch/OTA 已 `pauseCanFeedbackTask`,且 SPI worker 串行化,查询不会与 OTA 数据流冲突
|
||||
- **can_monitor.html full 路径延迟**:6 次 SPI 查询 ~24ms + JSON 序列化,总 <50ms,远小于 1s 轮询间隔
|
||||
|
||||
### 不改动的部分
|
||||
- 0xC4 ADC、0xC5 motor state、0xC6 motor version、0xD2 motor OTA 推送机制不变
|
||||
- `canFdParseFeedbackPage` 解析逻辑不变
|
||||
- GD32 `handleFeedback` 查询处理程序不变
|
||||
- 前端 JS 不变(API 响应格式不变)
|
||||
@@ -0,0 +1,58 @@
|
||||
## 删除"断线后切换慢速轮询"机制
|
||||
|
||||
**文件:** `G:\Robot_v2\DCU\ESP32_COMM\tools\app.js`
|
||||
|
||||
**范围:** 只删除"降速到 10s"的行为,保留 5 次失败触发的网络诊断(diagnoseNetwork)和断线 UI 提示。删除后断线时保持 1s 轮询,仅显示"未连接"并按需做网络诊断。
|
||||
|
||||
### 改动点
|
||||
|
||||
1. **删除变量 `pollSlowMode`(1600 行)**
|
||||
- 移除 `var pollSlowMode = false; // 降速模式(10s 一次)`
|
||||
|
||||
2. **删除函数 `slowDownPolling()`(1671-1681 行)**
|
||||
- 整个函数移除
|
||||
|
||||
3. **删除函数 `restorePolling()`(1682-1695 行)**
|
||||
- 整个函数移除(保留 `startPolling`、`pausePollingForUpload`、`resumePollingAfterUpload`)
|
||||
|
||||
4. **简化 `startPolling()`(1662-1670 行)**
|
||||
- 删除 `pollSlowMode = false;` 这一行(其余保留)
|
||||
|
||||
5. **简化 `resumePollingAfterUpload()`(1714 行)**
|
||||
- `statusPollIntervalMs = pollSlowMode ? 10000 : 1000;` → `statusPollIntervalMs = 1000;`
|
||||
|
||||
6. **简化 `pollStatus()` 成功分支(1738-1747 行)**
|
||||
- 移除 `wasSlow` / `restorePolling()` 调用,仅保留 `consecutivePollFailures = 0;`(保留"恢复在线"的 toast 体验需要另判断;改为:若 `consecutivePollFailures > 0` 则提示已恢复并清零)
|
||||
|
||||
7. **简化 `pollStatus()` 失败分支的 5-fail 块(1789-1800 行)**
|
||||
- 删除 `!pollSlowMode` 条件判断中的 `pollSlowMode` 部分
|
||||
- 删除 `slowDownPolling();` 调用和"已切换为慢速轮询"日志
|
||||
- 保留 `diagnoseNetwork()` 诊断、`updateConnectionUi(false, '未连接')`、`applyDisconnectedRuntimeState()` 和诊断文本显示
|
||||
|
||||
8. **简化 `pollStatus()` 失败分支末尾(1828 行)**
|
||||
- `} else if (!pollSlowMode) {` → `} else {`(该 else 分支总是显示未连接状态)
|
||||
|
||||
9. **清理 `testConnection()`(817-820 行)**
|
||||
- 删除 `if (pollSlowMode) { addLog(...); restorePolling(); }` 块
|
||||
|
||||
10. **清理 `refreshSystemStatusNow()`(874 行)**
|
||||
- 删除 `if (pollSlowMode) restorePolling();`
|
||||
|
||||
11. **清理 `visibilitychange` 监听(1854-1855 行)**
|
||||
- 删除 `// 之前若在慢速模式...` 注释和 `if (pollSlowMode) restorePolling();`
|
||||
|
||||
12. **清理 IP 输入 change 监听(1887-1892 行)**
|
||||
- 简化为:IP 变化时清零失败计数并立即拉一次状态(不再依赖 restorePolling)
|
||||
- 或直接移除 `if (pollSlowMode) { ... restorePolling(); }` 块,保留监听器做基本的失败计数清零
|
||||
|
||||
### 不删除的部分
|
||||
- `consecutivePollFailures` 变量及其计数逻辑(诊断仍需要)
|
||||
- `diagnoseNetwork()` 网络诊断函数
|
||||
- `lastDiagTime` / `diagResult` 诊断状态变量
|
||||
- `applyDisconnectedRuntimeState()` 断线 UI 更新
|
||||
- `statusPollIntervalMs` 变量(被 upload 暂停/恢复机制复用,保持 1000 默认值)
|
||||
|
||||
### 验证
|
||||
- 浏览器打开 `tools/index.html`,确认页面加载无 JS 错误
|
||||
- 模拟 ESP32 断线:连续 5 次失败后应仍触发网络诊断并显示"未连接",但轮询间隔保持 1s
|
||||
- 恢复 ESP32:应清零失败计数并提示"恢复在线"
|
||||
@@ -0,0 +1,77 @@
|
||||
## 目标
|
||||
把电机 OTA 从"收固件+触发喂数据"一步式,改成 BLE 刷 GD32 那样的两段式:**先收完固件写 flash,再单独触发 SPI START + 喂数据**。拆分后收固件阶段不碰 SPI,避免与 GD32 按铃推送竞态;触发阶段只发一个短小的 SPI 0xD0。
|
||||
|
||||
## 改动点(共 4 个文件)
|
||||
|
||||
### 1. `main/server/http_handlers.cpp` — 拆分上传 + 新增 relay 触发
|
||||
|
||||
**`motor_ota_upload_handler`(line 2057)改:**
|
||||
- 保留:互斥检查、`beginWriteSession`、流式收固件写 flash(`flashWriteChunk` 循环)、`beginWriteSession` 复位
|
||||
- **删除**:末尾的 `motorOtaStart(...)` 调用(line 2128)
|
||||
- 收完后写 meta:`metaSave({total_size=content_len, stored_size=received})`,让 relay 端点能确认固件完整
|
||||
- 返回改为 `{"status":"stored","message":"call POST /motor/ota/relay?ch=X to trigger","channel":ch,"size":received}`
|
||||
|
||||
**新增 `motor_ota_relay_handler`:**
|
||||
- 从 query 读 `ch`(和上传端点一致)
|
||||
- `Gd32FlashStore::metaLoad` 确认 `stored_size >= total_size`,否则返回 400 `firmware not fully stored`
|
||||
- `motorOtaIsActive()` 检查,已在跑则 409
|
||||
- 调 `motorOtaStart(meta.total_size, ch)`:
|
||||
- 0 → `{"status":"ok","message":"OTA started"}`
|
||||
- -2 → 409 `already in progress`
|
||||
- -3 → 500 `SPI start failed`(保留原日志 DEBUG_ERR)
|
||||
- 成功触发后**清 meta**(`metaClear`),固件已交给 feedTask,避免残留
|
||||
|
||||
### 2. `main/server/wifi_ota.cpp:482` — 注册新路由
|
||||
路由表加一行:
|
||||
```cpp
|
||||
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
|
||||
```
|
||||
(紧挨现有三个 motor/ota 路由,CORS preflight 复用现有循环自动处理)
|
||||
|
||||
### 3. `main/server/http_handlers.h` — 声明新 handler
|
||||
加 `esp_err_t motor_ota_relay_handler(httpd_req_t *req);`
|
||||
|
||||
### 4. `tools/motor.html` — 前端两段式调用
|
||||
|
||||
**`startOta`(line 2986-3001)改:**
|
||||
```
|
||||
// 步骤2:POST /motor/ota 只收固件
|
||||
const upResp = await fetch("/motor/ota?ch=${ch}", { POST, body: ota.bytes, timeout 15s });
|
||||
const upJson = await upResp.json();
|
||||
if (!upResp.ok) throw new Error(upJson.error);
|
||||
otaLog("[上传] 成功,触发 GD32 转发…");
|
||||
|
||||
// 步3:POST /motor/ota/relay 触发喂数据
|
||||
const rResp = await fetch("/motor/ota/relay?ch=${ch}", { POST, timeout 5000 });
|
||||
const rJson = await rResp.json();
|
||||
if (!rResp.ok) throw new Error(rJson.error);
|
||||
otaLog("[转发] GD32 开始握手…");
|
||||
startOtaPoll(); // 轮询不变
|
||||
```
|
||||
超时:上传 15s(48KB),触发 5s(一个 SPI 事务)。
|
||||
|
||||
**`cancelOta`:** 不用改。`/motor/ota/cancel` 两阶段都有效:触发前 motorOtaCancel 是空操作(feedTask 没跑),但仍清 GD32 状态;可顺便让 cancel handler 也清 meta(见下)。
|
||||
|
||||
### 5.(附带)`motor_ota_cancel_handler` 加清 meta
|
||||
cancel 时 `motorOtaCancel()` 后补一行 `Gd32FlashStore::instance().metaClear()`,覆盖"上传完未触发就取消"场景,清掉残留固件 meta。触发后取消也清 meta 无害(feedTask 已停)。
|
||||
|
||||
## 状态机与轮询(不变)
|
||||
- `/motor/ota/status` 复用,返回 GD32 pushStatus 的 state/packets_acked
|
||||
- 触发前 status = IDLE;触发后进 HANDSHAKE/TRANSFER/FINISHED/ERROR
|
||||
- 前端在 relay 请求成功后才 `startOtaPoll`,避免触发前空轮询
|
||||
|
||||
## 关键收益
|
||||
- **消除竞态**:收固件纯 HTTP 流式写 flash,不碰 SPI/MISO 缓冲,不与 GD32 按铃推送争抢;触发阶段只发一个短 SPI 0xD0,窗口极小
|
||||
- **语义清晰**:和 BLE 刷 GD32 的 `prepare+收流 → /ota/relay` 两段式对齐
|
||||
- **改动最小**:feedTask、GD32 侧、状态轮询全部不动;只拆 HTTP 端点和前端调用顺序
|
||||
|
||||
## 风险
|
||||
- 前端两次请求间若用户取消:cancel 已覆盖两阶段
|
||||
- 上传成功但触发失败:meta 仍存,用户可重试 relay(relay 端点先检查 `motorOtaIsActive` 再触发);或 cancel 清 meta 重来
|
||||
- 固件占 flash 未触发:metaClear 在 cancel/relay 成功后执行,残留风险低
|
||||
|
||||
## 验证
|
||||
- 编译 ESP32_COMM(idf.py build)
|
||||
- 手动测:选固件→开始,看日志先"[上传] 成功"再"[转发] GD32 开始握手",确认两次 HTTP 分开
|
||||
- 观察 SPI 按铃竞态是否消失(原"SPI start failed"概率应降到极低)
|
||||
- feedTask 终态判定(之前加的)继续生效
|
||||
@@ -0,0 +1,84 @@
|
||||
## 目标
|
||||
把 BLE OTA DATA_IN 路径的 OOM/队列满从“静默丢包”变为“可恢复协议反馈”,让前端在一个窗口内收到缺口反馈并按 `completed_bytes` 续传退避,避免整窗口 8s 超时重发风暴。仅做方案 A(负 ACK 反馈),不上固定缓冲池;节流复用现有窗口节流思路。
|
||||
|
||||
## 现状核对(已核实)
|
||||
- 平台 ESP32-C3,无 PSRAM,堆紧张;稳态堆水位需实测后才能评估是否上池,故本计划不涉及池。
|
||||
- `ble_ota_data.cpp:597` `malloc(recv_len)` 失败仅 `DEBUG_ERR`,回调仍返回 0(ATT 成功)。
|
||||
- `ble_ota_data.cpp:602` `xQueueSend` 失败同样静默:`free(heap_buf)` + `DEBUG_WARN("...丢...等重传")`,无协议反馈。两条路径都需修,不只 malloc 路径。
|
||||
- 客户端窗口内 seq≥1 用 `writeValueWithoutResponse`(即发即忘),**收不到 ATT Error Response**;只有 seq=0 用 `writeValueWithResponse`。因此“返回 ATT 错误码”对窗口内丢包无效,必须靠 DATA_OUT 通知通道发窗口 ACK。原报告“返回 ATT 错误”建议在窗口路径不成立,本计划以通知 ACK 为主。
|
||||
- 客户端 `transfer.js` 已支持按 `completed_bytes` 续传(`resumeSeq = ceil(lastAckBytes / payloadSize)`,最多 3 次重试,窗口 64 包),所以设备端多发一个缺口 ACK 不会破坏客户端语义,只会让续传更早开始。
|
||||
- 现有 `sendWindowAck(cmd, seq, completed_bytes)` 在 `ble_ota_data.cpp:440` 已封装好,seq 取 `s_reorder.expected - 1`(最近连续落盘序号),负 ACK 直接复用即可,无需新协议字段、无需改客户端。
|
||||
- 现有节流是“进度触发 + 每 `BLE_OTA_ACK_WINDOW`(64) 包边界”(见 process_esp32_packet:691-712),不是时间间隔。丢包路径发生在写入前、无进度,无法复用边界判定,需为负 ACK 引入轻量节流(避免重传叠加时 ACK 通知打爆 NimBLE mbuf 池)。
|
||||
|
||||
## 改动点(集中在 `main/server/ble_ota_data.cpp`)
|
||||
|
||||
### 1. 新增丢包计数(可观测性,满足“池耗尽/丢包有计数”验收)
|
||||
- 在文件静态区新增:
|
||||
- `static uint32_t s_ota_drop_malloc_fail = 0;` // malloc 失败累计
|
||||
- `static uint32_t s_ota_drop_queue_full = 0;` // 队列满累计
|
||||
- `static uint32_t s_neg_ack_sent = 0;` // 负 ACK 发送累计
|
||||
- 在 `ble_ota_session_reset()`(已有复位点,约 line 414)中清零,与 reorder 状态同生命周期。
|
||||
- 通过现有 health monitor 或 DEBUG_DBG 暴露(按现有日志风格,不新增上报通道)。
|
||||
|
||||
### 2. 新增负 ACK 节流状态
|
||||
- 复用“每 64 包/最小间隔”思路,但丢包路径无 `completedPackets`,故用包计数 + 最小时间间隔双约束:
|
||||
- `static uint32_t s_neg_ack_count = 0;` // 自上次发负 ACK 以来累计丢包数
|
||||
- `static int64_t s_last_neg_ack_us = 0;` // 上次负 ACK 时刻(`esp_timer_get_time()`)
|
||||
- 节流规则(在丢包路径触发):
|
||||
- 累计丢包 ≥ `BLE_OTA_ACK_WINDOW`(64) **或** 距上次 ≥ 200ms 时,发一次负 ACK 并复位计数/时间。
|
||||
- 否则只计数、不发,避免重传风暴期间 ACK 通知打爆 mbuf 池。
|
||||
- 常量定义在文件内 `static constexpr`(与 `BLE_OTA_BUF_SIZE` 同位置,约 line 580),不污染 app_config.h。
|
||||
- 时间用 `esp_timer_get_time()`(已是项目内惯用 API,见 reorder/health 用法)。
|
||||
|
||||
### 3. 新增负 ACK 发送辅助函数
|
||||
- 在 `sendWindowAck`(line 460 后)下方新增:
|
||||
```cpp
|
||||
// 丢包路径专用:发缺口 ACK 让前端按 completed_bytes 续传退避,而非等 8s 超时。
|
||||
// 节流:每 BLE_OTA_ACK_WINDOW 个丢包或 200ms 最多一次,防止 ACK 通知打爆 mbuf 池。
|
||||
static void sendNegativeAckIfDue(uint8_t cmd);
|
||||
```
|
||||
- 实现:
|
||||
- 读取 `s_reorder.expected`:>0 时 `ackSeq = expected - 1`,`completed = Esp32OtaCore::getReceived()`;expected==0(首包都没落盘)时 ackSeq=0、completed=0(等价于让前端从头续发,客户端已支持此语义)。
|
||||
- 节流判定通过后调用 `sendWindowAck(cmd, ackSeq, completed)` 并 `s_neg_ack_sent++`。
|
||||
- GD32 路径同样适用(`sendWindowAck` 已按 cmd 区分),但 GD32 的 expected 状态在 `s_ble_gd32_meta`,需在函数内按 cmd 分支取 expected/received;为简单起见,先实现 ESP32 路径,GD32 路径仅计数不发 ACK(GD32 是中继转发,缺口由 GD32 侧自行反馈),并在注释中标注。
|
||||
|
||||
### 4. 改造 `ble_ota_data_in_access` 丢包路径(line 596-605)
|
||||
- malloc 失败分支:`s_ota_drop_malloc_fail++` → `sendNegativeAckIfDue(cmd)`(cmd 从 batch 头解析,见下)→ 保留 `DEBUG_ERR`。
|
||||
- 队列满分支:`s_ota_drop_queue_full++` → `sendNegativeAckIfDue(cmd)` → 保留 `DEBUG_WARN`。
|
||||
- **cmd 解析问题**:当前 line 566-596 的回调只把 mbuf 拷到 `buf`,未解析 cmd。负 ACK 需要 cmd 字节。两种做法:
|
||||
- (a) 回调里解析前 7 字节头取 cmd(与 worker 中 `ble_ota_process_data_batch` 解析逻辑一致,可抽一个小 helper `parse_cmd_from_batch(buf, recv_len)`)。
|
||||
- (b) 不解析,统一用 `BLE_OTA_CMD_ESP32_DATA`(0x01)(C3 自身 OTA 是主路径,GD32 经 BLE 中继较少)。
|
||||
- **选 (a)**:抽 helper,正确性优先,避免误报 cmd 让前端续传走错分支。helper 放在 `ble_ota_data.cpp` 内 static,不暴露到头文件。
|
||||
|
||||
### 5. 不改动的部分
|
||||
- 回调返回值仍为 0(窗口内 write-without-response 返回错误对端也收不到,且会污染 ATT 层;负 ACK 走通知通道才是正反馈)。
|
||||
- 不引入固定缓冲池(C3 无 PSRAM,池规模需实测,本计划范围内不做)。
|
||||
- 不改客户端 `tools/js/ble/*`(现有续传语义已兼容)。
|
||||
- 不改协议字段/UUID/通知格式。
|
||||
|
||||
## 风险与权衡
|
||||
- **负 ACK 抖动**:极端低堆时丢包密集,节流会让部分丢包延迟反馈,客户端可能仍等部分超时。权衡已用 200ms 兜底,可接受;若实测仍风暴,再调常量。
|
||||
- **首包未落盘时负 ACK**:expected==0 时发 `ackSeq=0, completed=0`,等价“一切重来”。客户端 seq=0 用 write-with-response,本就等首包 ACK;此时负 ACK 不会误导。
|
||||
- **GD32 路径**:本计划 GD32 仅计数不发负 ACK,避免改动 GD32 中继反馈语义;如需对称可后续扩展。
|
||||
- **节流状态并发**:`s_neg_ack_*` 仅在 NimBLE host task 回调中读写(单线程),worker task 不写,无需锁。
|
||||
|
||||
## 验收
|
||||
1. 构造低堆注入:临时在 malloc 前插 `if (s_inject_fail) { heap_buf=nullptr; }` 开关(测试桩,提交前移除),或用 `heap_caps_set_minimum_size` 压低堆水位。
|
||||
2. 触发 OTA 上传,观察:
|
||||
- 日志出现 `DATA_IN 堆内存不足` / `DATA_IN满` + `sendNegativeAckIfDue` 发送记录。
|
||||
- 客户端在窗口内(≤200ms~8s)收到缺口 ACK,`lastAckBytes` 推进,从 `resumeSeq` 续传,**不出现整窗口 8s 超时**。
|
||||
- `s_ota_drop_*` 计数器增长,负 ACK 后传输恢复,无永久卡死。
|
||||
3. 正常路径回归:不注入失败时 ACK 行为、传输耗时、收尾 SHA256 不变。
|
||||
4. 池耗尽有计数:`s_ota_drop_queue_full` / `s_ota_drop_malloc_fail` 在 health monitor/日志可查。
|
||||
|
||||
## 实施顺序
|
||||
1. 加丢包计数 + `ble_ota_session_reset` 清零。
|
||||
2. 加负 ACK 节流状态 + `sendNegativeAckIfDue`(ESP32 路径)。
|
||||
3. 抽 `parse_cmd_from_batch` helper。
|
||||
4. 改造 `ble_ota_data_in_access` 两条丢包路径接入负 ACK。
|
||||
5. 低堆注入测试验收,移除注入桩。
|
||||
6. (可选)GD32 路径对称负 ACK,视后续需要。
|
||||
|
||||
## 涉及文件
|
||||
- `main/server/ble_ota_data.cpp`(主要改动:line 414 reset、line 460 后新增函数、line 596-605 丢包路径、line 580 附近常量)
|
||||
- 不改:`ble_ota_priv.h`、`ble_ota.cpp`、`app_config.h`、客户端 JS。
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
# 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)
|
||||
project(ESP32_COMM)
|
||||
project(ESP32_COMM) # 项目名:ESP32 通信模块
|
||||
|
||||
@@ -1,149 +1,126 @@
|
||||
# ESP32-C3 OTA Relay
|
||||
# ESP32_COMM · DCU 通信与 OTA 中枢固件
|
||||
|
||||
ESP32-C3FH4 OTA 中继固件,通过 WiFi 接收上位机 OTA 固件包,通过 SPI 转发给下位机(GD32)。
|
||||
> **文档同步版本:v0.5.2(git 770ae14)· 2026-10-11**
|
||||
> 本 README 是 [docs/project_introduction.html](docs/project_introduction.html)(完整白皮书,含 21 幅 SVG 原理图)的**浓缩版**,两者须同步更新(见文末「文档同步说明」)。
|
||||
|
||||
## 硬件平台
|
||||
ESP32-C3FH4 固件,作为 GD32E508 DCU 板的通信与 OTA 中枢:
|
||||
|
||||
- **芯片:** ESP32-C3FH4 (RISC-V, 单核)
|
||||
- **Flash:** 4MB
|
||||
- **编译工具链:** ESP-IDF v6.0, riscv32-esp-elf
|
||||
```
|
||||
[上位机 PC/手机网页] --WiFi HTTP / BLE GATT--> [ESP32-C3FH4] --SPI Link v2 (13.33MHz, 5线)--> [GD32E508]
|
||||
| |
|
||||
gd32_ota 252KB 固件缓存 3×CANFD 收发器(电机总线)
|
||||
app0/app1 AB 自升级 电机 OTA CAN 二次中继
|
||||
```
|
||||
|
||||
## 功能模块
|
||||
- **OTA 中继**:GD32 固件(六阶段安全握手 + AES-128 逐包加密转发)、ESP32 自身 AB 分区升级、电机节点经 GD32 CAN 二次烧写
|
||||
- **SPI Link v2 主站**:会话 / 序号 / 信用流控 / CRC32 / HELLO 重同步 / 透明重试
|
||||
- **CAN 控制面**:单帧、批量、跨通道同步突发(0xC7)、只听模式(0xC8)、遥测回收(0xC4/C5/C6)
|
||||
|
||||
| 模块 | 描述 |
|
||||
## 硬件与分区
|
||||
|
||||
| 项 | 值 |
|
||||
|------|------|
|
||||
| `health_monitor` | 温度 + WiFi RSSI 监控 |
|
||||
| `ota_relay` | OTA 数据包接收与转发 |
|
||||
| `wifi_ota` | WiFi OTA HTTP 服务器 + CAN 帧转发 |
|
||||
| `spi_master` | SPI 主站,与 GD32 通信 |
|
||||
| `protocol` | CRC16 校验 + AES-128-CBC 解密(硬件加速) |
|
||||
| 芯片 | ESP32-C3FH4(RISC-V 单核 160MHz)· 4MB Flash(DIO) |
|
||||
| SPI | MOSI=5, MISO=7, SCLK=6, CS=4, DATA_READY=10;13.33MHz Mode 0(`CONFIG_DCU_SPI_CLOCK_HZ`) |
|
||||
| 调试串口 | UART0 TX=21/RX=20,**3 Mbps**(115200 为 sdkconfig 控制台项);UDP 日志 192.168.31.3:6666 |
|
||||
| 欠压 | LVL7(≈2.8V,防长断电半复位) |
|
||||
|
||||
## 目录结构
|
||||
| 分区 | 偏移 | 大小 | 用途 |
|
||||
|------|------|------|------|
|
||||
| nvs | 0x9000 | 24KB | 配置 + OTA 元数据(`ota_meta`:total/stored/last_seq/checksum) |
|
||||
| otadata | 0xF000 | 8KB | AB 启动标志 |
|
||||
| gd32_ota | 0x11000 | 252KB | GD32/电机固件缓存(互斥共用) |
|
||||
| app0 / app1 | 0x50000 / 0x190000 | 各 1.25MB | ESP32 AB 分区(回滚保护) |
|
||||
| spiffs | 0x2D0000 | 1.06MB | 用户数据 |
|
||||
|
||||
## 架构与任务
|
||||
|
||||
```
|
||||
ESP32_COMM/
|
||||
├── CMakeLists.txt
|
||||
├── main/
|
||||
│ ├── CMakeLists.txt
|
||||
│ ├── include/
|
||||
│ │ └── app_config.h # WiFi / OTA 参数配置
|
||||
│ ├── core/main.cpp # 任务创建入口
|
||||
│ ├── relay/ota_relay.cpp # OTA 中继逻辑
|
||||
│ ├── transport/
|
||||
│ │ ├── spi_master.cpp # SPI 主站 + CANFD 3通道发送
|
||||
│ │ └── protocol.cpp # 协议层 (CRC16 / AES-128-CBC)
|
||||
│ ├── server/
|
||||
│ │ └── wifi_ota.cpp # WiFi OTA HTTP 服务器
|
||||
│ └── monitor/
|
||||
│ ├── health_monitor.cpp # 健康监控
|
||||
│ └── debug_log.cpp # UDP 调试日志
|
||||
└── tools/
|
||||
└── ota_host_web.html # 上位机 WebUI(CAN 发送 + OTA 上传)
|
||||
接入层 server/: wifi_ota(STA/AP+路由表) · http_handlers(22 路由) · ble_ota*(GATT/窗口ACK/reorder/池)
|
||||
协调层 ota/: OtaCoordinator(9 态单例, 唯一权威入口, 忙态看门狗 150s, epoch 保护) · Esp32OtaCore
|
||||
执行层 relay/ storage/: OtaRelay(7 态事件状态机) · Gd32FlashStore(擦除水位/NVS meta/SHA/包身份校验)
|
||||
monitor/: MotorOtaRelay(0xD0/D1/D2 喂数)
|
||||
底层 transport/: SpiMaster(Link v2+worker 串行) · protocol(AES/nonce) · canfd_bridge(0xC0~C8)
|
||||
横切 monitor/: health_monitor · debug_log(3 路) · can_feedback_task · cpu_stats · task_stack 等
|
||||
```
|
||||
|
||||
## WiFi 工作模式
|
||||
| 任务 | 优先级 | 栈 | 职责 |
|
||||
|------|-------|------|------|
|
||||
| ota_relay | 23 | 2560 | GD32 转发状态机 |
|
||||
| wifi_ota / ble_ota | 22 | 3072/2560 | WiFi/HTTP 与 NimBLE |
|
||||
| health_mon | 21 | 3072 | 温度 1s + 5s 巡检 |
|
||||
| ble_data_worker | 12 | 2560 | BLE 数据排空 |
|
||||
| spi_worker | 8 | 5120 | **唯一持 SPI**,串行 Job 池(7) |
|
||||
| motor_feed | 7 | 4096 | 电机 OTA 0xD1 喂数 |
|
||||
| wifi_reconnect / can_fb | 5/4 | 2560/4096 | 重连退避 / 0xC5(250ms)·0xC4(1s) 周期查询 |
|
||||
| cpu_stats / uart_rx | 1 | 3072 | 1Hz 统计 / 串口行→/logs |
|
||||
|
||||
支持 AP / STA 两种模式,通过 `app_config.h` 配置:
|
||||
## 核心机制速记(细节见白皮书对应章节)
|
||||
|
||||
```c
|
||||
// AP 模式(ESP32 当热点)
|
||||
CONFIG_WIFI_MODE_IS_STA = 0
|
||||
WIFI_AP_SSID = "ESP32_OTA_RELAY" // 默认热点名称
|
||||
WIFI_AP_PASSWORD = "12345678" // 默认密码
|
||||
// 上位机连接热点后访问: http://192.168.31.1
|
||||
- **SPI Link v2**(§5):帧 = 16B 头(SOF A5 5A/VER 02/FLAGS/SESSION 4LE/TX_SEQ/ACK_SEQ/CREDIT/TYPE/LEN)+ 载荷 ≤236B + CRC32-MPEG2。延迟一拍全双工:transact 两步法(发送丢弃陈旧 RX → poll 按 DATA_READY 边沿读真实响应)。credit 8/8 背压,credit=0 死锁有 PB6 强制放行 + `resetCredit()` 双破除。透明重试 `kTxRetryMax=3`(同 TX_SEQ,GD32 DUP-SEQ 去重兜底)。CS 事务间隔 ≥1ms + 快速类型(0xA1/0xA3/0xC0~C5/C7/D1/F7)禁盲读防撕裂;3 连败熔断 2s;session=0 自动 HELLO 重同步(5s 冷却)。
|
||||
- **安全握手**(§6):GD32 OTA 数据面——0x00 0xAC → 36B challenge(UID 12B + nonce 回显 8B + AES(MASTER_KEY,K_session) 16B)→ `K_session = AES-128-ECB(MASTER_KEY, nonce_server)` → AES(K_session, nonce^0x55) → 0xBA 0xC0。nonce 由 scrambleGenerate(tick/栈址/0x12345678 ⊗ 每开机 RNG 扰码常数)生成。
|
||||
- **GD32 OTA 中继**(§7):RELAY_IDLE→HANDSHAKING(20×1.6s)→SIZE_NEG(poll 500ms×120,GD32 擦 Flash)→TRANSFERRING(64B 包=4B session+2B seq+48B AES+4B if_end+4B CRC32(key 混入);预算 10s+25ms/包 ≤120s;每 32 包健康检查;单包 5 次 DATA×50ms,末包 ACK=0xF0F0F0F0)→FINISHING(0x0A 0xAC×10 重试 + PING 实证 APP 存活)。失败必发 SPI CANCEL 清 GD32 半会话。**包级防呆**:Cortex-M 向量表判 kApp 才放行(BOOT 包禁入 APP 区)。
|
||||
- **ESP32 本机 OTA**(§7.4):`OTA_WITH_SEQUENTIAL_WRITES` 边写边擦;finalize 分区读回算 SHA-256 与前端比对,不符回 `ESP_ERR_INVALID_VERSION`+设备 SHA;`mark_app_valid_cancel_rollback()` 回滚保护;期间 `setSpiPaused(true)`。
|
||||
- **BLE OTA**(§8):GATT 0xFFFF/0x0001~0x0004,MTU 512,载荷 480B;**窗口 ACK 64**(累计字节校验)+ reorder 64 槽 + 负 ACK 续传(64 包/200ms 节流);DATA_IN 池 8×512B(曾 32KB 致收尾 OOM);CTRL 0xF7 触发 GD32 中继 / 0xF8 ESP32 收尾 / 0xFA 重启 / 0xFB 中止(0xF9 预留未实现);BLE 与 WiFi OTA 软挂起共存(不 deinit 协议栈)。
|
||||
- **CANFD 桥接**(§10):0xC0 单帧(最短 5B)/0xC1 统计+ID 快照分页(28B 头+条目 8+dlc,bus_state bit0~5,bit5=Silent)/0xC2 收模式(全收/过滤/CAN0→1/2 桥接)/0xC7 跨通道突发(ISR 内装载全部邮箱后回**一个** ACK,组内偏差 <1ms;单次 ≤16 帧,HTTP ≤24 帧,id=(node<<16)|suffix)/0xC8 收发器 Silent(TCAN1057AV S 脚 PA8/PB14/PB9,接收照常,不持久化,发送回 E6)。错误码 E0~E7:E2/E3(BUS_OFF/TEC>96) fatal 中止批量,E4 邮箱满,E5 电机 OTA 残留(自动 CANCEL→resetCredit→重试 START 自愈),E6 Silent,E7 被电机 OTA 独占。
|
||||
- **电机 OTA**(§11):/motor/ota 上传(整区强制擦除)→ 0xD0 START(fire-and-forget)→ feedTask 0xD1 {绝对 offset+≤232B}(重传幂等;120s 整体超时;8 败/150 WAIT → QUERY)→ 0xD2 进度。
|
||||
- **监控**(§12):健康 4 级(80°C/100°C;RSSI -80/-85/-88;堆 10KB)门控 OTA;日志三路(UART 3M 非阻塞 / UDP / 环 64→16 槽 →/logs)+ UART0 RX 泵;遥测 0xC5 电机状态 27 槽×14 页、0xC4 ADC(VCC=vin×11)、0xC6 电机版本、0xA3 初始化状态 13 模块故障码;CPU/任务栈/堆低水位快照/启动自检 16 位位图全进 /system/status。
|
||||
|
||||
// STA 模式(连接路由器)
|
||||
CONFIG_WIFI_MODE_IS_STA = 1
|
||||
WIFI_STA_SSID = "004舰桥" // 路由器名称
|
||||
WIFI_STA_PASSWORD = "q13362875622" // 路由器密码
|
||||
// 上位机和 ESP32 在同一局域网,通过 DHCP 分配的 IP 访问
|
||||
```
|
||||
|
||||
## CANFD 3通道 SPI 协议
|
||||
|
||||
GD32 通信帧格式:`[通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]`
|
||||
|
||||
| 通道 | 用途 |
|
||||
|------|------|
|
||||
| CH0 | 主通信通道 |
|
||||
| CH1 | 备用通道 1 |
|
||||
| CH2 | 备用通道 2 |
|
||||
|
||||
### GD32 命令 ID 定义
|
||||
|
||||
| 域 | 命令 | ID |
|
||||
|---|---|---|
|
||||
| 系统 | PING / RESET / GET_VERSION / GET_STATUS / SET_STATUS | 0x0001~0x0005 |
|
||||
| Flash | ERASE / WRITE / READ / VERIFY | 0x1001~0x1004 |
|
||||
| OTA | START / DATA / END / ABORT | 0x2001~0x2004 |
|
||||
| 传感器 | READ / CONFIG | 0x3001~0x3002 |
|
||||
| Bootloader | ENTER / JUMP_APP | 0xF001~0xF002 |
|
||||
|
||||
## 上位机 WebUI
|
||||
|
||||
`tools/ota_host_web.html` — 浏览器打开即可使用,无需安装。
|
||||
|
||||
### HTTP API
|
||||
## HTTP API(22 条路由 / 21 路径,无认证,CORS 全开)
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| POST | `/ota/upload` | 上传固件 (.bin) |
|
||||
| POST | `/ota/relay` | 触发 OTA 中继 |
|
||||
| GET | `/ota/status` | 查询 OTA 状态 |
|
||||
| POST | `/can/send` | 发送 CAN 帧到 GD32 |
|
||||
| GET | `/can/status` | CAN 通道状态 |
|
||||
| GET | `/health` | ESP32 健康状态 |
|
||||
| GET | `/wifi/status` | WiFi 连接状态 |
|
||||
| POST | /ota/upload | 固件上传(JSON 头 `{"type":"esp32"\|"gd32","total_size":N}` + 二进制流式) |
|
||||
| POST | /ota/update | ESP32 OTA 收尾 `{sha256, verify_only}`(SHA 比对→set_boot→重启) |
|
||||
| POST | /ota/relay | 触发 GD32 中继(前端 SHA + kApp 身份校验) |
|
||||
| POST | /restart | 重启 |
|
||||
| GET | /logs | 日志环 chunked 流式 |
|
||||
| GET | /system/status | 全量状态(?lite=1 / ?layout=1;含 spi_link 统计、启动自检、GD32 版本/ADC) |
|
||||
| GET | /system/probe · /system/heap_low_log | GD32 存活探测 / 堆低水位快照 |
|
||||
| POST | /can/send_batch · /can/send_burst | 批量发送(≤128 帧)/ 跨通道突发(≤24 帧) |
|
||||
| GET·POST | /can/feedback · /can/mode · /can/silent | 快照查询 / 收模式 / Silent 只听 |
|
||||
| POST | /spi/send | 原始 SPI 帧透传 |
|
||||
| GET | /motor/state(?bin=1) · /motor/version | 电机状态二进制协议 / 版本 |
|
||||
| POST·GET | /motor/ota · /motor/ota/relay · /motor/ota/status · /motor/ota/cancel | 电机 OTA 全流程 |
|
||||
| GET | /spi/speedtest | SPI 性能测试(临时) |
|
||||
|
||||
### CAN 帧发送示例
|
||||
WiFi:STA 优先(SSID「004舰桥」,FAST_SCAN,CN 1-13),冷启动退避 2s×retry≤3 / 否则 1s,5 次耗尽或 30s 超时降级 AP(`ESP32_OTA_RELAY`/`12345678`,192.168.31.1,信道 6,≤4 台)。**无 mDNS**,访问靠固定 IP。
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.31.1/can/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ch": 0, "id": 12345678, "dlc": 8, "data": "0102030405060708"}'
|
||||
## BLE GATT 速查
|
||||
|
||||
| UUID | 特征 | 说明 |
|
||||
|------|------|------|
|
||||
| 0xFFFF | Service | 设备名 ESP32-OTA-RELAY |
|
||||
| 0x0001 | CTRL (Write) | 0xF7/0xF8/0xFA/0xFB(0xF9 预留) |
|
||||
| 0x0002 | DATA_IN (WriteNoRsp) | cmd 1B(0x00 GD32/0x01 ESP32)+seq u16+total u32+payload ≤480B |
|
||||
| 0x0003 | DATA_OUT (Notify) | 0xA1 ACK 8B / 0xB1 FINISH 6B / 0xC1 SHA 35B |
|
||||
| 0x0004 | VERSION (Read) | 版本+构建时间 |
|
||||
|
||||
## 构建与烧录
|
||||
|
||||
```powershell
|
||||
# ESP-IDF v6.0 环境(IDF_TOOLS_PATH/IDF_PATH/PATH,或用 .devcontainer)
|
||||
Set-Location "G:\Robot_v2\DCU\ESP32_COMM"
|
||||
python "F:\.espressif\v6.0\esp-idf\tools\idf.py" build # 产物 build/ESP32_COMM.bin
|
||||
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 flash monitor # 串口烧录(恢复手段)
|
||||
```
|
||||
|
||||
## 任务配置 (单核 ESP32-C3)
|
||||
- **发版**:手动改 `main/include/version.h`(版本号+变更摘要注释+GIT_HASH);构建时间自动生成
|
||||
- **网页升级**:控制台上传 `build/ESP32_COMM.bin` → SHA 比对 → 重启(前端按 banner「OTA Relay … 启动」+ uptime<120s 判定恢复)
|
||||
- **GD32**:上传 .bin/.hex → /ota/relay → 六阶段转发 → PING 确认;**电机**:/motor/ota → /motor/ota/relay
|
||||
|
||||
所有任务固定运行在 CPU 核 0:
|
||||
## 网页控制台
|
||||
|
||||
| 任务 | 优先级 | 栈大小 | 说明 |
|
||||
|------|--------|--------|------|
|
||||
| health_mon | configMAX_PRIORITIES - 4 | 4096 | 健康监控 |
|
||||
| ota_relay | configMAX_PRIORITIES - 2 | 8192 | OTA 中继 |
|
||||
| wifi_ota | configMAX_PRIORITIES - 3 | 8192 | WiFi OTA |
|
||||
`tools/` 已移出仓库(快照 `.cache/ws_era_frontend_backup/`),`tools/serve.bat`(http.server:8080)托管,跨源调 REST API,改网页无需重编固件。页面:**index.html** OTA 控制台(双通道、阶段条、启动自检面板、堆/任务抽屉、日志);**motor.html** 电机控制台(三轮询链路互不阻塞、波形);**can.html / can_monitor.html**(DBC、报文编辑、收发监控)。
|
||||
|
||||
## 调试日志与上位机通信
|
||||
## 安全注意与已知限制
|
||||
|
||||
当前与上位机相关的通信分成两类:
|
||||
- ⚠️ **HTTP 无认证**(Bearer token 方案已设计未实施);WiFi 凭据与 MASTER_KEY 占位值明文入仓(2026-08 审计项,待迁 NVS/Kconfig)
|
||||
- ⚠️ AES-ECB 确定性模式;无固件签名(仅 SHA-256 完整性 + GD32 侧挑战-应答)
|
||||
- Flash 必须锁 DIO(QIO 会致 OTA 读回 SHA 必错);SPI ≥16MHz 全双工不可行
|
||||
- tools/ 不在仓库;`spi_link/` 为三工程共享兄弟目录(`../../../spi_link/`),协议改动需三侧同步
|
||||
|
||||
1. **业务通信:HTTP**
|
||||
- WebUI 通过 HTTP 接口与 ESP32 通信
|
||||
- 典型接口:`/system/status`、`/logs`、`/can/send`、`/ota/*`
|
||||
- 其中日志面板实际通过 **轮询 `GET /logs`** 获取日志
|
||||
## 文档同步说明(HTML ↔ README)
|
||||
|
||||
2. **调试镜像:UART0 + UDP**
|
||||
- 所有 `DEBUG_*` 日志会同时写到 UART0、UDP、以及内部环形缓冲区
|
||||
- UDP 目标端口为 `6666`,适合独立调试接收器使用
|
||||
- 但 **当前 tools WebUI 不直接监听 UDP 6666**
|
||||
|
||||
因此,当前 WebUI 场景下:
|
||||
- `/system/status` 用于在线状态/健康状态展示
|
||||
- `/logs` 用于日志展示
|
||||
- 不再需要额外的 `{"t":"hb"}` 假日志心跳包
|
||||
|
||||
## 编译
|
||||
|
||||
```bash
|
||||
idf.py set-target esp32c3
|
||||
idf.py fullclean
|
||||
idf.py build
|
||||
idf.py flash monitor
|
||||
```
|
||||
|
||||
## 待确认参数
|
||||
|
||||
- AES-128 主密钥 (MASTER_KEY_HERE)
|
||||
- 搅伴算法种子 (SCRAMBLE_CONST_HERE)
|
||||
- 芯片唯一码 (CHIP_UID_HERE)
|
||||
- OTA Service UUID (需与 APP 侧协商)
|
||||
- [docs/project_introduction.html](docs/project_introduction.html) = 完整白皮书(算法字节级细节、状态机、SVG 图解、事故沉淀);本 README = 浓缩速查版,**内容同源**
|
||||
- **同步规则**:凡影响功能清单 / 协议与常量数值 / 状态机 / 端点表 / 实现状态的代码变更,须**同时更新两份文档**;变更摘要写 `main/include/version.h` 注释;细节以 HTML 为准;两文件顶部「文档同步版本」随变更递增(当前 v0.5.2 / 2026-10-11)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
idf_component_register(
|
||||
SRCS "cJSON.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES
|
||||
)
|
||||
|
||||
# ESP-IDF uses no DLL, export all symbols
|
||||
target_compile_definitions(${COMPONENT_LIB} PUBLIC CJSON_HIDE_SYMBOLS=0)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,306 +0,0 @@
|
||||
/*
|
||||
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef cJSON__h
|
||||
#define cJSON__h
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif
|
||||
|
||||
#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
|
||||
#define __WINDOWS__
|
||||
#endif
|
||||
|
||||
#ifdef __WINDOWS__
|
||||
|
||||
/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
|
||||
|
||||
CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
|
||||
CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
|
||||
CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
|
||||
|
||||
For *nix builds that support visibility attribute, you can define similar behavior by
|
||||
|
||||
setting default visibility to hidden by adding
|
||||
-fvisibility=hidden (for gcc)
|
||||
or
|
||||
-xldscope=hidden (for sun cc)
|
||||
to CFLAGS
|
||||
|
||||
then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
|
||||
|
||||
*/
|
||||
|
||||
#define CJSON_CDECL __cdecl
|
||||
#define CJSON_STDCALL __stdcall
|
||||
|
||||
/* export symbols by default, this is necessary for copy pasting the C and header file */
|
||||
#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
|
||||
#define CJSON_EXPORT_SYMBOLS
|
||||
#endif
|
||||
|
||||
#if defined(CJSON_HIDE_SYMBOLS)
|
||||
#define CJSON_PUBLIC(type) type CJSON_STDCALL
|
||||
#elif defined(CJSON_EXPORT_SYMBOLS)
|
||||
#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
|
||||
#elif defined(CJSON_IMPORT_SYMBOLS)
|
||||
#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
|
||||
#endif
|
||||
#else /* !__WINDOWS__ */
|
||||
#define CJSON_CDECL
|
||||
#define CJSON_STDCALL
|
||||
|
||||
#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
|
||||
#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
|
||||
#else
|
||||
#define CJSON_PUBLIC(type) type
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* project version */
|
||||
#define CJSON_VERSION_MAJOR 1
|
||||
#define CJSON_VERSION_MINOR 7
|
||||
#define CJSON_VERSION_PATCH 19
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* cJSON Types: */
|
||||
#define cJSON_Invalid (0)
|
||||
#define cJSON_False (1 << 0)
|
||||
#define cJSON_True (1 << 1)
|
||||
#define cJSON_NULL (1 << 2)
|
||||
#define cJSON_Number (1 << 3)
|
||||
#define cJSON_String (1 << 4)
|
||||
#define cJSON_Array (1 << 5)
|
||||
#define cJSON_Object (1 << 6)
|
||||
#define cJSON_Raw (1 << 7) /* raw json */
|
||||
|
||||
#define cJSON_IsReference 256
|
||||
#define cJSON_StringIsConst 512
|
||||
|
||||
/* The cJSON structure: */
|
||||
typedef struct cJSON
|
||||
{
|
||||
/* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
|
||||
struct cJSON *next;
|
||||
struct cJSON *prev;
|
||||
/* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
|
||||
struct cJSON *child;
|
||||
|
||||
/* The type of the item, as above. */
|
||||
int type;
|
||||
|
||||
/* The item's string, if type==cJSON_String and type == cJSON_Raw */
|
||||
char *valuestring;
|
||||
/* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
|
||||
int valueint;
|
||||
/* The item's number, if type==cJSON_Number */
|
||||
double valuedouble;
|
||||
|
||||
/* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
|
||||
char *string;
|
||||
} cJSON;
|
||||
|
||||
typedef struct cJSON_Hooks
|
||||
{
|
||||
/* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
|
||||
void *(CJSON_CDECL *malloc_fn)(size_t sz);
|
||||
void (CJSON_CDECL *free_fn)(void *ptr);
|
||||
} cJSON_Hooks;
|
||||
|
||||
typedef int cJSON_bool;
|
||||
|
||||
/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
|
||||
* This is to prevent stack overflows. */
|
||||
#ifndef CJSON_NESTING_LIMIT
|
||||
#define CJSON_NESTING_LIMIT 1000
|
||||
#endif
|
||||
|
||||
/* Limits the length of circular references can be before cJSON rejects to parse them.
|
||||
* This is to prevent stack overflows. */
|
||||
#ifndef CJSON_CIRCULAR_LIMIT
|
||||
#define CJSON_CIRCULAR_LIMIT 10000
|
||||
#endif
|
||||
|
||||
/* returns the version of cJSON as a string */
|
||||
CJSON_PUBLIC(const char*) cJSON_Version(void);
|
||||
|
||||
/* Supply malloc, realloc and free functions to cJSON */
|
||||
CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
|
||||
|
||||
/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
|
||||
/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
|
||||
/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
|
||||
/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
|
||||
|
||||
/* Render a cJSON entity to text for transfer/storage. */
|
||||
CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
|
||||
/* Render a cJSON entity to text for transfer/storage without any formatting. */
|
||||
CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
|
||||
/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
|
||||
CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
|
||||
/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
|
||||
/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
|
||||
/* Delete a cJSON entity and all subentities. */
|
||||
CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
|
||||
|
||||
/* Returns the number of items in an array (or object). */
|
||||
CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
|
||||
/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
|
||||
/* Get item "string" from object. Case insensitive. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
|
||||
/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
|
||||
CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
|
||||
|
||||
/* Check item type and return its value */
|
||||
CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
|
||||
CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
|
||||
|
||||
/* These functions check the type of an item */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
|
||||
|
||||
/* These calls create a cJSON item of the appropriate type. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
|
||||
/* raw json */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
|
||||
|
||||
/* Create a string where valuestring references a string so
|
||||
* it will not be freed by cJSON_Delete */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
|
||||
/* Create an object/array that only references it's elements so
|
||||
* they will not be freed by cJSON_Delete */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
|
||||
|
||||
/* These utilities create an Array of count items.
|
||||
* The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
|
||||
|
||||
/* Append item to the specified array/object. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
|
||||
/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
|
||||
* WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
|
||||
* writing to `item->string` */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
|
||||
/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
|
||||
|
||||
/* Remove/Detach items from Arrays/Objects. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
|
||||
CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
|
||||
CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
|
||||
CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
|
||||
|
||||
/* Update array items. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
|
||||
|
||||
/* Duplicate a cJSON item */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
|
||||
/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
|
||||
* need to be released. With recurse!=0, it will duplicate any children connected to the item.
|
||||
* The item->next and ->prev pointers are always zero on return from Duplicate. */
|
||||
/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
|
||||
* case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
|
||||
|
||||
/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
|
||||
* The input pointer json cannot point to a read-only address area, such as a string constant,
|
||||
* but should point to a readable and writable address area. */
|
||||
CJSON_PUBLIC(void) cJSON_Minify(char *json);
|
||||
|
||||
/* Helper functions for creating and adding items to an object at the same time.
|
||||
* They return the added item or NULL on failure. */
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
|
||||
|
||||
/* When assigning an integer value, it needs to be propagated to valuedouble too. */
|
||||
#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
|
||||
/* helper for the cJSON_SetNumberValue macro */
|
||||
CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
|
||||
#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
|
||||
/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
|
||||
CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
|
||||
|
||||
/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
|
||||
#define cJSON_SetBoolValue(object, boolValue) ( \
|
||||
(object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
|
||||
(object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
|
||||
cJSON_Invalid\
|
||||
)
|
||||
|
||||
/* Macro for iterating over an array or object */
|
||||
#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
|
||||
|
||||
/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
|
||||
CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
|
||||
CJSON_PUBLIC(void) cJSON_free(void *object);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,26 @@
|
||||
# transport 组件 - SPI 总线驱动、CANFD 桥接、协议层
|
||||
# 提供底层硬件通信能力,被 main 组件依赖。
|
||||
#
|
||||
# PRIV_INCLUDE_DIRS 仅对本组件编译可见,
|
||||
# 不会将 main/ 的子目录路径泄漏给依赖本组件的其他组件,
|
||||
# 从而在编译层面强制模块边界。
|
||||
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"../../main/transport/spi_master.cpp"
|
||||
"../../../spi_link/spi_link_v2.cpp"
|
||||
"../../main/transport/canfd_bridge.cpp"
|
||||
"../../main/transport/protocol.cpp"
|
||||
INCLUDE_DIRS
|
||||
"../../main/transport" # spi_master.h, canfd_bridge.h, protocol.h
|
||||
"../../../spi_link" # spi_link_v2.h(三工程共享协议头)
|
||||
PRIV_INCLUDE_DIRS
|
||||
"../../main" # monitor/debug_log.h(仅本组件编译时使用)
|
||||
"../../main/include" # app_config.h
|
||||
REQUIRES
|
||||
esp_driver_spi # SPI 主设备驱动
|
||||
esp_driver_gpio # GPIO 驱动(SPI Ready 握手引脚)
|
||||
freertos # FreeRTOS 内核
|
||||
mbedtls # AES 加密
|
||||
esp_timer # 高精度定时器
|
||||
)
|
||||
@@ -1,8 +1,14 @@
|
||||
# ESP32-C3FH4 OTA Relay - Partition Table (4MB Flash)
|
||||
# Name, Type, SubType, Offset, Size, Label
|
||||
# NVS: 系统配置 + OTA 元数据 (24KB)
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
# OTA Data: AB 分区启动标志 (8KB)
|
||||
otadata, data, ota, 0xF000, 0x2000,
|
||||
# GD32 OTA: GD32 固件缓存区 (252KB),使用 spiffs 子类型以便 esp_partition API 直接读写
|
||||
gd32_ota, data, spiffs, 0x11000, 0x3F000,
|
||||
# APP0: ESP32 固件主分区 (1.25MB)
|
||||
app0, app, ota_0, 0x50000, 0x140000,
|
||||
# APP1: ESP32 固件备份分区 (1.25MB),支持 AB 分区回滚
|
||||
app1, app, ota_1, 0x190000, 0x140000,
|
||||
# SPIFFS: 用户数据文件系统 (1.06MB)
|
||||
spiffs, data, spiffs, 0x2D0000, 0x110000,
|
||||
|
@@ -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
|
||||
@@ -35,6 +36,38 @@ CONFIG_BTDM_BLE_MAX_CONN_EFF=1
|
||||
CONFIG_BTDM_CTRL_BLE_MAX_CONN_EFF=1
|
||||
CONFIG_BTDM_BLE_MAX_CONN_EFF_EFF=1
|
||||
|
||||
# BLE 连接事件长度优化(允许单 CE 内传多包,提升 OTA 吞吐)
|
||||
CONFIG_BT_CTRL_CE_LENGTH_TYPE_ORIG=y
|
||||
|
||||
# NimBLE OTA 窗口突发接收容量(恢复 7feef70 对应的 IDF 6.0 默认值)
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=24
|
||||
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=12
|
||||
|
||||
# NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service,省 ~8-15 KB)
|
||||
CONFIG_BT_NIMBLE_PROX_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_ANS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_CTS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HTP_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IPSS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_TPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_LLS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_SPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HR_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_BAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_DIS_SERVICE=n
|
||||
|
||||
# NimBLE: 保留足够 ACL 缓冲承接窗口内突发包
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=24
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
|
||||
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
|
||||
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MIN_CONN_INTERVAL=12
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=16
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200
|
||||
|
||||
# mbedTLS
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT=y
|
||||
@@ -53,6 +86,8 @@ CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y
|
||||
|
||||
# OTA
|
||||
CONFIG_ESP_OTA_IMG_VERIFICATION=y
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
CONFIG_APP_ROLLBACK_ENABLE=y
|
||||
|
||||
# HTTP server
|
||||
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# ESP32-C3FH4 OTA 中继项目 — 程序开发说明
|
||||
|
||||
**委托开发方:** 请完整执行以下所有任务,完成后停止。不要在中途停止。
|
||||
|
||||
**开发范围:** ESP32-C3FH4 固件开发,充当手机 APP/PC 与 GD32E508VET6 之间的 OTA 中继器。
|
||||
|
||||
**核心工作模式:** ESP32 通过 WiFi 或 BLE 接收上位机发送的 GD32 固件 → 存入本地 Flash → 通过 SPI 转发给 GD32 执行 OTA 升级。
|
||||
|
||||
> **重要:** 开发过程中如需与 GD32 固件侧确认接口(如 AES 密钥长度、搅伴算法细节等),请先按设计文档实现接口定义并预留 hook,实际参数值用占位符标注,最终由委托方补充。
|
||||
|
||||
---
|
||||
|
||||
## 参考文件
|
||||
|
||||
| 文件 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 架构设计文档 | `/workzone/esp32_ota_relay/esp32_ota_relay_design.md` | 完整协议定义、模块架构、状态机设计 |
|
||||
| 开发任务清单 | `/workzone/esp32_ota_relay/tasks.md` | 所有 48 条开发任务的详细描述 |
|
||||
|
||||
**开发顺序:** T0 → T1 → T2 → T3 → T4/T5(可并行)→ T6 → T7 → T8
|
||||
|
||||
---
|
||||
|
||||
## 系统架构(必读)
|
||||
|
||||
```
|
||||
[手机APP/PC] --WiFi/BLE--> [ESP32-C3FH4] --SPI--> [GD32E508VET6]
|
||||
|
|
||||
+-- 存储 GD32 固件到 Flash
|
||||
+-- 四阶段安全握手协议
|
||||
+-- 内置温度/WiFi 自检
|
||||
```
|
||||
|
||||
ESP32 的角色是**数据透传 + 存储中继**,GD32 OTA 的加密握手和数据传输逻辑全部由 ESP32 透传到 GD32,ESP32 本身不解析固件内容。
|
||||
|
||||
---
|
||||
|
||||
## 通信协议要点
|
||||
|
||||
### ESP32 ↔ GD32(SPI)
|
||||
|
||||
完整映射 GD32 原有 CANFD OTA 四阶段流程:
|
||||
|
||||
- **阶段一(安全握手):** 0x00 AC 握手 → 16B nonce 挑战 → 握手包响应 → 0xBA C0 完成
|
||||
- **阶段二(传输准备):** 发送固件总大小 → 0xBA C0 + seq 确认
|
||||
- **阶段三(数据分包):** 每包 48B + CRC,循环发送直到最后一包带 IFEND
|
||||
- **阶段四(结束跳转):** 0xBA C0 + FF FF FF FF 完成确认 → 0x0A AC 刷写结束 → GD32 跳转 APP
|
||||
|
||||
帧格式:`SOF(0xEB) + CMD + LEN(2B LE) + PAYLOAD(LEN) + CRC16(2B LE)`
|
||||
|
||||
命令码:0xF0 HANDSHAKE_REQ / 0xF1 CHALLENGE / 0xF2 SESSION_KEY / 0xF3 SEND_SIZE / 0xF4 DATA_PACKET / 0xF5 DATA_END / 0xF6 TRANSFER_DONE / 0xF7 FINISH_OTA
|
||||
|
||||
### APP ↔ ESP32(WiFi HTTP / BLE GATT)
|
||||
|
||||
- WiFi:HTTP POST `/ota/upload`,JSON 响应进度
|
||||
- BLE:4 个 GATT Characteristic(Control Point / Data In / Data Out / Version)
|
||||
- APP→ESP32 使用明文帧格式(加密由 ESP32 在转发给 GD32 时处理)
|
||||
|
||||
---
|
||||
|
||||
## 存储方案(方案 A)
|
||||
|
||||
固件先完整存入 ESP32 Flash,再转发给 GD32:
|
||||
|
||||
- 分区:`gd32_ota`(256KB,用于存 GD32 固件 binary)
|
||||
- 元数据文件:`ota_meta.json`(total_size / stored_size / last_seq)
|
||||
- 断点续传:APP 重连后从 `last_seq + 1` 继续发送
|
||||
- 存储完成后自动触发 SPI 转发流程
|
||||
|
||||
---
|
||||
|
||||
## 自检与保护
|
||||
|
||||
不外挂传感器,使用 ESP32 内置传感器:
|
||||
|
||||
- **芯片温度**(正常 < 80°C / 警告 80-100°C / 停机 > 100°C)— 1s 轮询
|
||||
- **WiFi RSSI**(正常 > -70 / 弱 -70~-85 / 断开 < -85 dBm)— 10s 轮询
|
||||
- **BLE 连接状态** — 事件驱动
|
||||
|
||||
OTA 每包发送前检查健康状态,CRITICAL 时暂停,SAFE_SHUTDOWN 时中止并清除 meta。
|
||||
|
||||
---
|
||||
|
||||
## 开发要求
|
||||
|
||||
1. **使用 ESP-IDF v5.x**,基于 CMake 构建系统,目标芯片 `esp32c3`
|
||||
2. **遵循 FreeRTOS 任务架构**:System Monitor / OTA Relay / WiFi Server / BLE GATT 四个任务
|
||||
3. **AES 使用 mbedTLS**(ESP32 内置),预置主密钥用占位符 `MASTER_KEY_HERE`
|
||||
4. **搅伴算法**实现需与 GD32 固件侧匹配,预留参数配置接口
|
||||
5. **Flash 写入**使用 esp_partition API,追加写入不断点
|
||||
6. **SPI 使用 VSPI (SPI2)**,GPIO6-9,CS=GPIO4,时钟 40MHz,mode=0
|
||||
7. **所有任务完成后**,清理会话密钥、释放内存、返回完成信号
|
||||
8. **不允许中途停止**,必须完成 T0~T8 所有任务后,才能停止运行或退出
|
||||
|
||||
---
|
||||
|
||||
## 开发产出要求
|
||||
|
||||
1. 完整可编译的 ESP32-C3 工程代码(CMake 项目结构)
|
||||
2. 所有代码放在 `/workzone/esp32_ota_relay/` 目录下
|
||||
3. 代码中必须有完整的 `main/` 目录(`main.c` / `app_main.c` / `ota_relay.c` / `spi_master.c` / `wifi_ota.c` / `ble_ota.c` / `protocol.c`)
|
||||
4. 不得使用任何需要外部 API Key 的服务(AES、WiFi、BLE 均使用 ESP-IDF 内置库)
|
||||
5. 完成后更新 `/workzone/esp32_ota_relay/tasks.md` 的进度记录
|
||||
|
||||
---
|
||||
|
||||
## GD32 固件侧依赖说明
|
||||
|
||||
以下接口需 GD32 固件侧提供参数后才能完整验证,请预留 hook 并用占位符标注:
|
||||
|
||||
| 接口 | 占位符 | 说明 |
|
||||
|---|---|---|
|
||||
| 主密钥 | `MASTER_KEY_HERE` | AES-128 主密钥,16 bytes |
|
||||
| 搅伴算法参数 | `SCRAMBLE_CONST_HERE` | 时间戳+内存地址+rand 的固定 seed |
|
||||
| 芯片唯一码 | `CHIP_UID_HERE` | GD32 芯片唯一 ID 用于密钥派生 |
|
||||
| 会话密钥 K_session | - | 由 Step 4/5 双方协商生成,存储在 RAM |
|
||||
| AES 算法模式 | AES-128 CBC | 由 ESP32 和 GD32 双方协商一致即可 |
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
如需向委托方确认接口参数,请在代码中用 `// TODO: confirm with GD32 firmware team` 标注,不得自行假定。
|
||||
|
||||
**开发过程中遇到任何问题,请继续尝试解决,不要停止。**
|
||||
@@ -1,694 +0,0 @@
|
||||
# ESP32-C3FH4 OTA 中继架构设计
|
||||
|
||||
## 1. 系统概述
|
||||
|
||||
```
|
||||
[手机APP/PC] --WiFi--> [ESP32-C3FH4] --SPI--> [GD32E508VET6]
|
||||
|
|
||||
+--BLE---> [手机APP/PC]
|
||||
```
|
||||
|
||||
- **ESP32 角色**:WiFi/BLE 接收端 + SPI 透传(主机)
|
||||
- **GD32 角色**:OTA 执行端(从机),已有完整 OTA 流程
|
||||
- **ESP32 不负责 OTA 逻辑**,只做数据转发
|
||||
|
||||
---
|
||||
|
||||
## 2. 硬件连接
|
||||
|
||||
### SPI 接口
|
||||
|
||||
| ESP32 (Master) | GD32 (Slave) | 说明 |
|
||||
|---|---|---|
|
||||
| GPIO6 (SPIIO0) | PA4 (NSS) | 片选 |
|
||||
| GPIO7 (SPIQ) | PA5 (SCK) | 时钟 |
|
||||
| GPIO8 (SPIWP) | PA6 (MOSI) | 主发从收 |
|
||||
| GPIO9 (SPIHD) | PA7 (MISO) | 主收从发 |
|
||||
|
||||
> ⚠️ GD32E508 使用标准 SPI(PA4-NSS, PA5-SCK, PA6-MOSI, PA7-MISO)
|
||||
> GD32 已内置 BOOT+APP 模式,ESP32 通过 SPI 命令直接控制进入 Bootloader,无需额外 BOOT/RST 控制引脚。
|
||||
|
||||
---
|
||||
|
||||
## 3. 通信协议设计
|
||||
|
||||
ESP32 与 GD32 之间的 SPI 协议完整映射 GD32 原有 CANFD OTA 流程,分为四个阶段。
|
||||
|
||||
### 3.0 帧格式总览
|
||||
|
||||
所有 SPI 帧格式统一为:
|
||||
|
||||
```
|
||||
| SOF (1B) | CMD (1B) | LEN (2B, LE) | PAYLOAD (LEN bytes) | CRC16 (2B, LE) |
|
||||
```
|
||||
|
||||
- **SOF**: 0xEB 固定帧头
|
||||
- **CMD**: 命令码(见下表)
|
||||
- **LEN**: Payload 长度(不含帧头和 CRC)
|
||||
- **CRC16**: Modbus CRC16,低字节在前
|
||||
|
||||
> 注意:CANFD 侧(原协议)的分包为 48 bytes/packet;SPI 中继时,ESP32 接收 APP/PC 的分包可能更大,内部透传时不做分包合并,只做协议映射。
|
||||
|
||||
---
|
||||
|
||||
### 3.1 第一阶段:安全握手与认证(步骤 1-6)
|
||||
|
||||
目的是建立加密安全通道,防止非法刷写。
|
||||
|
||||
#### Step 1 & 2 — 握手发起与响应
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| ESP32 → GD32 | `0x00 AC` | 发起 OTA 请求 |
|
||||
| GD32 → ESP32 | `0x00 AC` | 响应信号,表示准备好进入 OTA 模式 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF0 (HANDSHAKE_REQ), Payload=[] → ESP32 发送 0x00 AC
|
||||
CMD=0xF0 (HANDSHAKE_REQ), Payload=[] ← GD32 回复 0x00 AC
|
||||
```
|
||||
|
||||
#### Step 3 — 主机挑战(发送随机数)
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| ESP32 → GD32 | `nonce_server` (16 bytes) | 16字节随机数,用于生成会话密钥 |
|
||||
|
||||
**搅伴算法**(时间 + 内存地址 + rand → 3轮单字节 → 2轮4字节)由 ESP32 实现,生成方法需与 GD32 预置的主密钥匹配。
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF1 (CHALLENGE), Payload=[16 bytes nonce_server]
|
||||
```
|
||||
|
||||
#### Step 4 — 从机响应与身份验证
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| GD32 → ESP32 | 握手数据包 | 使用主密钥 AES 加密随机数 + 会话密钥加密自身信息 |
|
||||
|
||||
GD32 获取芯片唯一码,使用主密钥通过 **AES 算法**加密随机数,并使用会话密钥加密自身信息,打包发送。
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF1 (CHALLENGE), Payload=[握手数据包] ← GD32 响应
|
||||
```
|
||||
|
||||
Payload 结构(AES 加密后的数据):
|
||||
|
||||
```
|
||||
| AES_encrypt(nonce_server, master_key) (16B) | AES_encrypt(device_info, K_session) (16B) |
|
||||
```
|
||||
|
||||
#### Step 5 — 主机验证与会话密钥分发
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| ESP32 → GD32 | 变换后的随机数 | 使用会话密钥加密后发送 |
|
||||
|
||||
ESP32 验证随机数,解密获取会话密钥 (K_session),对随机数进行变换后用会话密钥加密发送。
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF2 (SESSION_KEY), Payload=[AES_encrypt(transformed_nonce, K_session)]
|
||||
```
|
||||
|
||||
#### Step 6 — 认证完成
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| GD32 → ESP32 | `0xBA C0` | 安全通道建立完成 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF2 (SESSION_KEY), Payload=[] ← GD32 回复 0xBA C0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 第二阶段:传输准备(步骤 7-8)
|
||||
|
||||
#### Step 7 — 发送大小
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| ESP32 → GD32 | `Data Size` (4 bytes, LE) | APP 总数据大小 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF3 (SEND_SIZE), Payload=[size(4B, LE)]
|
||||
```
|
||||
|
||||
#### Step 8 — 确认大小
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| GD32 → ESP32 | `0xBA C0` + 包序号 | 确认收到大小信息 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF3 (SEND_SIZE), Payload=[seq(2B)] ← GD32 回复 0xBA C0 + seq
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 第三阶段:数据分包传输循环(步骤 9-12)
|
||||
|
||||
刷写核心部分,数据加密分包发送。
|
||||
|
||||
#### Step 9 — 发送数据包
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| ESP32 → GD32 | OTA 程序数据包 | 经会话密钥加密,含 CRC 校验码 |
|
||||
|
||||
分包大小约 **48 bytes/packet**(与原 CANFD 协议一致)。
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF4 (DATA_PACKET), Payload=[encrypted_data(48B) + CRC]
|
||||
```
|
||||
|
||||
#### Step 10 — 从机校验与确认
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| GD32 → ESP32 | `0xBA C0` + 包序号 | 校验通过,请求下一包 |
|
||||
| GD32 → ESP32 | `0xBA C0` + 错误指示 | 校验失败,要求重发当前包(限次重试) |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF4 (DATA_PACKET), Payload=[seq(2B)] ← 正常 ACK
|
||||
CMD=0xF4 (DATA_PACKET), Payload=[seq(2B) + 0xFF] ← 错误重发请求
|
||||
```
|
||||
|
||||
#### Step 11 — 继续发送
|
||||
|
||||
主机发送下一包加密数据。
|
||||
|
||||
循环机制:`总循环次数 = 1 + 总APP大小 // 48 Byte`
|
||||
|
||||
#### Step 12 — 最后一包
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| ESP32 → GD32 | 最后一包数据 | IFEND(结束标志位)置位 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF5 (DATA_END), Payload=[encrypted_data(N) + CRC + IFEND flag]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 第四阶段:结束与跳转(步骤 13-17)
|
||||
|
||||
#### Step 13 — 传输完成确认
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| GD32 → ESP32 | `0xBA C0` + `FF FF FF FF` | 所有数据包传输完成并校验通过 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF6 (TRANSFER_DONE), Payload=[0xFF, 0xFF, 0xFF, 0xFF] ← GD32 完成确认
|
||||
```
|
||||
|
||||
#### Step 14 — 刷写结束指令
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| ESP32 → GD32 | `0x0A AC` | 刷写结束信号 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF7 (FINISH_OTA), Payload=[] → ESP32 发送 0x0A AC
|
||||
```
|
||||
|
||||
#### Step 15 — 内部跳转
|
||||
|
||||
GD32 内部执行动作:尝试跳转 APP。
|
||||
|
||||
> ESP32 无需等待此步骤完成,由 GD32 自行处理。
|
||||
|
||||
#### Step 16 — 最终确认
|
||||
|
||||
| 方向 | Signal | 说明 |
|
||||
|---|---|---|
|
||||
| GD32 → ESP32 | `0x0A AC` | 确认 APP 运行有效,回复刷写结束信号 |
|
||||
|
||||
**对应 SPI 帧:**
|
||||
|
||||
```
|
||||
CMD=0xF7 (FINISH_OTA), Payload=[] ← GD32 回复 0x0A AC
|
||||
```
|
||||
|
||||
#### Step 17 — 清理
|
||||
|
||||
流程结束,GD32 清理会话密钥、RAM 缓存等。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 命令码汇总
|
||||
|
||||
| CMD | 名称 | 方向 | Payload | 对应 OTA 阶段 |
|
||||
|---|---|---|---|---|
|
||||
| 0xF0 | `HANDSHAKE_REQ` | Bidirectional | 见 Step 1-2 | 阶段一 |
|
||||
| 0xF1 | `CHALLENGE` | Bidirectional | nonce(16B) / 握手数据包 | 阶段一 |
|
||||
| 0xF2 | `SESSION_KEY` | Bidirectional | 变换后随机数 / 0xBA C0 | 阶段一 |
|
||||
| 0xF3 | `SEND_SIZE` | ESP32 → GD32 | size(4B, LE) | 阶段二 |
|
||||
| 0xF4 | `DATA_PACKET` | ESP32 → GD32 | seq(2B) + data(48B) + CRC | 阶段三 |
|
||||
| 0xF5 | `DATA_END` | ESP32 → GD32 | seq(2B) + data(N) + CRC + IFEND | 阶段三 |
|
||||
| 0xF6 | `TRANSFER_DONE` | GD32 → ESP32 | 0xFF 0xFF 0xFF 0xFF | 阶段四 |
|
||||
| 0xF7 | `FINISH_OTA` | Bidirectional | 0x0A AC | 阶段四 |
|
||||
| 0x80 | `ACK` | GD32 → ESP32 | cmd(1B) + status(1B) | 通用 |
|
||||
| 0x81 | `ERROR` | GD32 → ESP32 | cmd(1B) + err_code(1B) | 通用 |
|
||||
|
||||
---
|
||||
|
||||
### 3.6 时序图
|
||||
|
||||
```
|
||||
ESP32 GD32
|
||||
| |
|
||||
|---- HANDSHAKE_REQ ---->| 0x00 AC (Step 1)
|
||||
|<---- HANDSHAKE_REQ ---| 0x00 AC (Step 2)
|
||||
| |
|
||||
|---- CHALLENGE ------->| nonce_server 16B (Step 3)
|
||||
|<---- CHALLENGE ------| 握手数据包 (Step 4)
|
||||
| |
|
||||
|---- SESSION_KEY ----->| 变换后随机数 (Step 5)
|
||||
|<---- SESSION_KEY -----| 0xBA C0 (Step 6) ← 安全通道建立
|
||||
| |
|
||||
|---- SEND_SIZE -------->| Data Size (Step 7)
|
||||
|<---- SEND_SIZE -------| 0xBA C0 + seq (Step 8)
|
||||
| |
|
||||
|==== DATA LOOP ========>| (Step 9-11, 每包 48B + CRC)
|
||||
|<==== DATA LOOP =======| 0xBA C0 + seq (正常) / 错误重发
|
||||
| |
|
||||
|---- DATA_END --------->| 最后一包 + IFEND (Step 12)
|
||||
| |
|
||||
|<---- TRANSFER_DONE ---| 0xBA C0 + FF FF FF FF (Step 13)
|
||||
| |
|
||||
|---- FINISH_OTA ------->| 0x0A AC (Step 14)
|
||||
|<---- FINISH_OTA ------| 0x0A AC (Step 16) ← GD32 跳转 APP
|
||||
| |
|
||||
* 清理会话密钥 & 缓存 * (Step 17)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 ACK 状态码
|
||||
|
||||
| status | 含义 |
|
||||
|---|---|
|
||||
| 0x00 | OK |
|
||||
| 0x01 | 命令不支持 |
|
||||
| 0x02 | 认证失败 |
|
||||
| 0x03 | 大小超出范围 |
|
||||
| 0x04 | CRC 校验失败 |
|
||||
| 0x05 | 重发次数超限 |
|
||||
| 0x06 | 会话密钥无效 |
|
||||
| 0x07 | Flash 写入失败 |
|
||||
|
||||
---
|
||||
|
||||
### 3.8 重发机制
|
||||
|
||||
- GD32 检测到 CRC 错误时,回复当前包序号 + 错误指示
|
||||
- ESP32 重新发送当前包
|
||||
- 最大重发次数由 GD32 固件定义(建议 3 次)
|
||||
- 超限后 ESP32 终止传输并上报 APP
|
||||
|
||||
---
|
||||
|
||||
## 4. ESP32 软件架构
|
||||
|
||||
### 4.1 目录结构
|
||||
|
||||
```
|
||||
esp32_ota_relay/
|
||||
├── main/
|
||||
│ ├── main.c
|
||||
│ ├── app_main.c
|
||||
│ ├── ota_relay.c # OTA 中继核心逻辑(含四阶段状态机)
|
||||
│ ├── spi_master.c # SPI 驱动
|
||||
│ ├── wifi_ota.c # WiFi OTA 接收
|
||||
│ ├── ble_ota.c # BLE OTA 接收
|
||||
│ └── protocol.c # 帧封装/解析/CRC/AES
|
||||
└── CMakeLists.txt
|
||||
```
|
||||
|
||||
### 4.2 模块职责
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ESP32 OTA Relay │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ WiFi OTA │ │ BLE OTA │ │
|
||||
│ │ Server │ │ GATT │ │
|
||||
│ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ OTA Relay │ ← 四阶段状态机 │
|
||||
│ │ Dispatcher │ 透传加密数据 │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ Protocol │ ← 帧封装/解析/CRC/AES │
|
||||
│ │ Layer │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │SPI Master │ │
|
||||
│ └─────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 OTA Relay Core 状态机
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
// 阶段一:安全握手
|
||||
RELAY_HANDSHAKE_IDLE, // 等待 HANDSHAKE_REQ
|
||||
RELAY_HANDSHAKE_WAIT, // 等待 GD32 响应 0x00 AC
|
||||
RELAY_CHALLENGE_SENT, // 已发送挑战随机数
|
||||
RELAY_CHALLENGE_RESPOND, // 等待 GD32 握手数据包
|
||||
RELAY_SESSION_KEY_SENT, // 已发送会话密钥分发
|
||||
RELAY_AUTH_COMPLETE, // 认证完成,安全通道建立
|
||||
|
||||
// 阶段二:传输准备
|
||||
RELAY_SIZE_SENT, // 已发送 Data Size
|
||||
RELAY_SIZE_ACK, // 收到大小确认
|
||||
|
||||
// 阶段三:数据传输
|
||||
RELAY_RECEIVING, // 接收固件数据
|
||||
RELAY_PACKET_SENT, // 等待 GD32 ACK
|
||||
RELAY_RECEIVE_DONE, // 传输完成(收到 DATA_END ACK)
|
||||
|
||||
// 阶段四:结束跳转
|
||||
RELAY_TRANSFER_DONE, // 收到传输完成确认
|
||||
RELAY_FINISH_SENT, // 已发送刷写结束信号
|
||||
RELAY_DONE, // 全部完成
|
||||
|
||||
RELAY_ERROR
|
||||
} relay_state_t;
|
||||
```
|
||||
|
||||
### 4.4 安全相关说明
|
||||
|
||||
- **AES 加密**:由 ESP32 实现(需预置主密钥),GD32 解密验证
|
||||
- **会话密钥 K_session**:由 Step 4/5 双方协商生成
|
||||
- **搅伴算法**:ESP32 生成 nonce_server 时使用(时间 + 内存地址 + rand → 3轮单字节 → 2轮4字节)
|
||||
- **芯片唯一码**:GD32 提供,ESP32 用于密钥派生
|
||||
|
||||
> ⚠️ 主密钥和 AES 算法细节需由 GD32 固件侧提供,本设计仅做协议映射。
|
||||
|
||||
### 4.6 数据缓冲策略
|
||||
|
||||
**方案 A:存储后转发(先存 ESP32 Flash,再发 SPI)**
|
||||
|
||||
ESP32 将 APP 发送的固件完整存入 Flash,传输完成后再通过 SPI 转发给 GD32。
|
||||
|
||||
#### Flash 分区设计
|
||||
|
||||
假设固件最大 256KB,预留足够空间:
|
||||
|
||||
```yaml
|
||||
# Partitions
|
||||
# Name, Type, SubType, Offset, Size
|
||||
otadata, data, ota, 0x9000, 0x2000 # OTA 数据区标记
|
||||
gd32_ota, data, spiffs, 0xB000, 0x40000 # GD32 固件存储区 (256KB)
|
||||
app0, app, ota_0, 0x4B000, 0x330000 # ESP32 自身 APP
|
||||
app1, app, ota_1, 0x3DC000, 0x330000
|
||||
```
|
||||
|
||||
> `gd32_ota` 分区使用 spiffs 或自定义 data 分区,存放原始固件 binary。
|
||||
|
||||
#### 接收与存储流程
|
||||
|
||||
```
|
||||
APP ESP32 GD32
|
||||
| | |
|
||||
|--- DATA_PACKET (4KB) --->| |
|
||||
| |--- write to Flash ------>| (存 Flash,不发 SPI)
|
||||
|<--- ACK (stored) --------| |
|
||||
| | |
|
||||
|--- DATA_PACKET (4KB) --->| |
|
||||
| |--- write to Flash ------>| (存 Flash,不发 SPI)
|
||||
|<--- ACK (stored) --------| |
|
||||
| | |
|
||||
| ... 循环存完所有包 ... |
|
||||
| | |
|
||||
|--- DATA_END ------------->| |
|
||||
| | |
|
||||
| |--- ENTER_BOOT ----------->| (开始刷写)
|
||||
| |<--- ACK ------------------| (GD32 就绪)
|
||||
| | |
|
||||
| |=== 流式转发固件 =========>| (按 48B/packet 逐包发送)
|
||||
| |<=== ACK per packet =======| (每包 GD32 ACK)
|
||||
| | |
|
||||
| |--- FINISH_OTA ---------->| (刷写结束)
|
||||
| |<--- FINISH_OTA ----------| (GD32 跳转 APP)
|
||||
```
|
||||
|
||||
#### 断点续传设计
|
||||
|
||||
| 文件 | 内容 | 说明 |
|
||||
|---|---|---|
|
||||
| `ota_meta.json` | `{total_size, stored_size, chunk_count, last_seq}` | 记录传输进度 |
|
||||
|
||||
- APP 重连后先读 `ota_meta.json`,获取 `stored_size`
|
||||
- 从 `last_seq + 1` 继续发送剩余包
|
||||
- ESP32 追加写入 Flash(不需要从头擦写)
|
||||
|
||||
#### 存储完成判断
|
||||
|
||||
- APP 发送 `DATA_END` 后,ESP32 更新 `ota_meta.json` 的 `stored_size = total_size`
|
||||
- ESP32 检测到 `stored_size == total_size` 后,自动开始向 GD32 转发
|
||||
|
||||
#### 内存 Buffer
|
||||
|
||||
存储同时使用 2 × 4096 bytes 乒乓 buffer 接收 APP 数据,写入 Flash 后切换 buffer 继续接收。
|
||||
|
||||
---
|
||||
|
||||
## 5. WiFi OTA 实现
|
||||
|
||||
### 5.1 工作模式
|
||||
|
||||
- **AP 模式**:ESP32 作为热点,手机直连
|
||||
- **STA 模式**:ESP32 连接路由器,APP 通过 IP 访问
|
||||
|
||||
### 5.2 HTTP OTA 服务器
|
||||
|
||||
```
|
||||
HTTP POST /ota/upload
|
||||
Content-Type: application/octet-stream
|
||||
Body: <raw firmware binary>
|
||||
|
||||
Response:
|
||||
{"status": "ok", "progress": 100}
|
||||
```
|
||||
|
||||
### 5.3 APP → ESP32 控制协议
|
||||
|
||||
APP 与 ESP32 之间使用简化帧格式:
|
||||
|
||||
```
|
||||
| CMD(1B) | LEN(2B) | PAYLOAD(LEN) | CRC16(2B) |
|
||||
```
|
||||
|
||||
命令码直接对应上述 SPI 命令码(0xF0-0xF7),但数据字段为明文(加密由 ESP32 处理后再转发给 GD32)。
|
||||
|
||||
### 5.4 固件分包(APP → ESP32)
|
||||
|
||||
```
|
||||
| CMD(1B) | SEQ(2B) | SIZE(4B) | DATA(SIZE) | CRC32(4B) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. BLE OTA 实现
|
||||
|
||||
### 6.1 GATT Profile 设计
|
||||
|
||||
| Handle | UUID | 属性 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 0x0001 | - | Primary Service | OTA Service |
|
||||
| 0x0010 | `xxxx-xxxx-xxxx-0001` | Control Point | 写命令/通知(控制帧通道) |
|
||||
| 0x0011 | `xxxx-xxxx-xxxx-0002` | Data In | 写无回应(大规模数据通道) |
|
||||
| 0x0012 | `xxxx-xxxx-xxxx-0003` | Data Out | 通知 GD32 响应 |
|
||||
| 0x0013 | `xxxx-xxxx-xxxx-0004` | Version | 只读,ESP32 版本号 |
|
||||
|
||||
### 6.2 BLE 传输流程
|
||||
|
||||
1. APP 写 `Control Point` (0xF0) → ESP32,发起握手
|
||||
2. ESP32 通过 SPI 转发给 GD32
|
||||
3. GD32 响应通过 `Data Out` 通知 APP
|
||||
4. APP 写 `Data In` 流式发送固件分包(48B/packet)
|
||||
5. 每包完成后,ESP32 回复 ACK 到 `Data Out`
|
||||
6. APP 写 `Control Point` (0xF7) → FINISH
|
||||
7. GD32 响应后,APP 可断开连接
|
||||
|
||||
### 6.3 MTU 设置
|
||||
|
||||
- 建议 MTU ≥ 512 bytes
|
||||
- BLE 每次 `Data In` 最大传输 512 bytes(减去 ATT 头)
|
||||
- 大文件分包多次写入
|
||||
|
||||
---
|
||||
|
||||
## 7. SPI Master 配置
|
||||
|
||||
```c
|
||||
// ESP32 SPI2 配置 (VSPI)
|
||||
spi_device_handle_t spi;
|
||||
|
||||
// 建议时钟: 40MHz
|
||||
spi_bus_config_t bus_cfg = {
|
||||
.mosi_io_num = 8,
|
||||
.miso_io_num = 7,
|
||||
.sclk_io_num = 6,
|
||||
.quadwp_io_num = -1,
|
||||
.quadhd_io_num = -1,
|
||||
};
|
||||
|
||||
spi_device_interface_config_t dev_cfg = {
|
||||
.clock_speed_hz = 40 * 1000 * 1000, // 40MHz
|
||||
.mode = 0, // CPOL=0, CPHA=0
|
||||
.spics_io_num = 4, // GPIO4 as CS
|
||||
.queue_size = 1,
|
||||
.command_bits = 0,
|
||||
.address_bits = 0,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 错误处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| GD32 无响应 ACK | 超时 3s → 重发同一包,最多 3 次 → 报错 |
|
||||
| CRC 校验失败 | GD32 要求重发 → ESP32 重发当前包 |
|
||||
| 重发次数超限 | ESP32 终止传输,通知 APP |
|
||||
| WiFi 断开 | 暂停传输,等待重连 |
|
||||
| BLE 断开 | 通知 APP 重新连接 |
|
||||
| 认证失败 | 终止流程,通知 APP |
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 9. ESP32 自检与保护机制
|
||||
|
||||
ESP32-C3FH4 利用芯片内置传感器实现运行安全监控,不外挂额外传感器。
|
||||
|
||||
### 9.1 检测项
|
||||
|
||||
| 检测项 | 来源 | 阈值范围 | 处理动作 |
|
||||
|---|---|---|---|
|
||||
| 芯片温度 | 内部温度传感器 | 正常: < 80°C / 警告: 80-100°C / 停机: > 100°C | 警告时降频,停机时中止 OTA |
|
||||
| Flash 空间 | 系统 API | 可用 > 16KB(存储分区元数据) | 空间不足时拒绝 OTA 请求 |
|
||||
| WiFi 信号强度 | WiFi RSSI | 正常: > -70 dBm / 弱: -70 ~ -85 dBm / 断开: < -85 dBm | 弱时降低 OTA 速度,断开时暂停 |
|
||||
| BLE 连接状态 | BLE stack | 连接中 / 断开 | 断开时通知 APP 重连 |
|
||||
|
||||
### 9.2 检测频率
|
||||
|
||||
| 检测项 | 频率 | 方式 |
|
||||
|---|---|---|
|
||||
| 芯片温度 | 每 1 秒 | FreeRTOS Timer |
|
||||
| Flash 空间 | OTA 开始前检查 | 一次性 |
|
||||
| WiFi RSSI | 每 10 秒 | 轮询 |
|
||||
| BLE 连接 | 事件驱动 | BLE GAP 回调 |
|
||||
|
||||
### 9.3 本地判断逻辑
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
SYS_NORMAL, // 全部正常
|
||||
SYS_WARNING, // 任一检测项触发警告阈值
|
||||
SYS_CRITICAL, // 任一检测项触发停机阈值
|
||||
SYS_SAFE_SHUTDOWN // 强制停机
|
||||
} sys_health_state_t;
|
||||
|
||||
// 状态机优先级:SAFE_SHUTDOWN > CRITICAL > WARNING > NORMAL
|
||||
// 高优先级状态覆盖低优先级状态
|
||||
```
|
||||
|
||||
### 9.4 异常处理联动
|
||||
|
||||
| ESP32 健康状态 | 对 OTA 的影响 |
|
||||
|---|---|
|
||||
| `SYS_NORMAL` | 正常进行 |
|
||||
| `SYS_WARNING` | 降低 SPI 速率,继续 OTA,记录警告日志 |
|
||||
| `SYS_CRITICAL` | 暂停 OTA 传输,通知 APP 当前状态,等待恢复 |
|
||||
| `SYS_SAFE_SHUTDOWN` | 中止 OTA,发送 ABORT 到 GD32,保护性复位 |
|
||||
|
||||
### 9.5 日志记录
|
||||
|
||||
异常事件写入 Flash 日志分区(`system_log`),包含:
|
||||
|
||||
```
|
||||
[TIMESTAMP] TEMP_WARNING: 85°C
|
||||
[TIMESTAMP] OTA_PAUSED: temp_critical
|
||||
[TIMESTAMP] OTA_RESUMED
|
||||
```
|
||||
|
||||
日志可由 APP 通过 WiFi/BLE 查询。
|
||||
|
||||
### 9.6 模块架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ System Monitor │
|
||||
│ (FreeRTOS Task, Priority High) │
|
||||
├──────────────────────────────────┤
|
||||
│ temp_monitor (1s timer) │
|
||||
│ wifi_monitor (10s timer) │
|
||||
│ ble_monitor (event-driven) │
|
||||
├──────────────────────────────────┤
|
||||
│ health_state_t state │
|
||||
│ → shared with OTA task via mutex│
|
||||
└──────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ OTA Relay Task │
|
||||
│ - checks health_state before │
|
||||
│ each GD32 DATA_PACKET send │
|
||||
│ - pauses/aborts on WARNING/ │
|
||||
│ CRITICAL/SAFE_SHUTDOWN │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 10. ESP32 固件分区(参考)
|
||||
|
||||
```yaml
|
||||
# Partitions
|
||||
# Name, Type, SubType, Offset, Size
|
||||
otadata, data, ota, 0x9000, 0x2000 # OTA 数据区标记
|
||||
gd32_ota, data, spiffs, 0xB000, 0x40000 # GD32 固件存储区 (256KB)
|
||||
app0, app, ota_0, 0x4B000, 0x330000 # ESP32 自身 APP
|
||||
app1, app, ota_1, 0x3DC000, 0x330000
|
||||
spiffs, data, spiffs, 0x670000, 0x190000 # 文件系统(日志等)
|
||||
```
|
||||
|
||||
> **注意:** `gd32_ota` 分区大小根据实际 GD32 固件最大体积调整,建议预留 256KB 以上。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
# docs/review — 评审记录归档
|
||||
|
||||
| 文件 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| [ESP32_COMM_专家评审_2026-10-19.html](ESP32_COMM_专家评审_2026-10-19.html) | 2026-10-19 | 全量专家评审(基线 af1f682 / v0.5.2),含既有评审逐条跟踪 |
|
||||
| [archive/esp32_architecture_review_2026-06-27.md](archive/esp32_architecture_review_2026-06-27.md) | 2026-06-27 | 架构扫描(原 docs/,af1f682 删除,原样恢复) |
|
||||
| [archive/CODE_REVIEW_REPORT_2026-06-13.html](archive/CODE_REVIEW_REPORT_2026-06-13.html) | 2026-06-13 | 高风险代码评审 v0.3.12(原 docs/,5b2c5f8 删除,原样恢复) |
|
||||
| [archive/COMMUNICATION_AUDIT_2026-05-05.md](archive/COMMUNICATION_AUDIT_2026-05-05.md) | 2026-05-05 | 通信梳理(原 docs/,5b2c5f8 删除,原样恢复) |
|
||||
| ../project_introduction.html §19 | 2026-08-16 / 2026-10-19 | 安全审计原文 + 2026-10-19 复核更新 |
|
||||
|
||||
约定:评审记录只增不删;新评审引用并跟踪旧结论。
|
||||
@@ -0,0 +1,970 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32_COMM 高风险代码评审报告</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--bg-card: #1c2128;
|
||||
--border: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--critical: #f85149;
|
||||
--critical-bg: rgba(248,81,73,0.12);
|
||||
--critical-border: rgba(248,81,73,0.4);
|
||||
--high: #d29922;
|
||||
--high-bg: rgba(210,153,34,0.12);
|
||||
--high-border: rgba(210,153,34,0.4);
|
||||
--medium: #58a6ff;
|
||||
--medium-bg: rgba(88,166,255,0.12);
|
||||
--medium-border: rgba(88,166,255,0.4);
|
||||
--accent: #58a6ff;
|
||||
--success: #3fb950;
|
||||
--success-bg: rgba(63,185,80,0.12);
|
||||
--code-bg: #0d1117;
|
||||
--table-stripe: rgba(110,118,129,0.05);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', Helvetica, Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.report-header {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.report-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, #f85149, #d29922, #58a6ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.report-header .subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.report-meta span { display: flex; align-items: center; gap: 0.4rem; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.stat-card .number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-card.critical { border-color: var(--critical-border); }
|
||||
.stat-card.critical .number { color: var(--critical); }
|
||||
.stat-card.high { border-color: var(--high-border); }
|
||||
.stat-card.high .number { color: var(--high); }
|
||||
.stat-card.medium { border-color: var(--medium-border); }
|
||||
.stat-card.medium .number { color: var(--medium); }
|
||||
.stat-card.total { border-color: var(--accent); }
|
||||
.stat-card.total .number { color: var(--accent); }
|
||||
.stat-card.verified { border-color: var(--success); }
|
||||
.stat-card.verified .number { color: var(--success); }
|
||||
|
||||
/* Dimension Table */
|
||||
.section { margin-bottom: 3rem; }
|
||||
|
||||
.section-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.section-title .icon { font-size: 1.2rem; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:nth-child(even) td { background: var(--table-stripe); }
|
||||
|
||||
/* Severity Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.critical { background: var(--critical-bg); color: var(--critical); border: 1px solid var(--critical-border); }
|
||||
.badge.high { background: var(--high-bg); color: var(--high); border: 1px solid var(--high-border); }
|
||||
.badge.medium { background: var(--medium-bg); color: var(--medium); border: 1px solid var(--medium-border); }
|
||||
|
||||
/* Finding Cards */
|
||||
.finding-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.2rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.finding-card:hover { border-color: var(--text-muted); }
|
||||
|
||||
.finding-card.critical { border-left: 4px solid var(--critical); }
|
||||
.finding-card.high { border-left: 4px solid var(--high); }
|
||||
|
||||
.finding-header {
|
||||
padding: 1.2rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.finding-header:hover { background: var(--bg-tertiary); }
|
||||
|
||||
.finding-title-group { flex: 1; }
|
||||
|
||||
.finding-number {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.finding-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.finding-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.finding-body {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.finding-card.open .finding-body { display: block; }
|
||||
|
||||
.finding-body .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-value { color: var(--text-primary); }
|
||||
|
||||
.code-block {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 0.8rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-block .comment { color: #6a9955; }
|
||||
.code-block .keyword { color: #c586c0; }
|
||||
.code-block .type { color: #4ec9b0; }
|
||||
.code-block .string { color: #ce9178; }
|
||||
.code-block .highlight { background: rgba(248,81,73,0.15); display: inline; padding: 0.1em 0.2em; border-radius: 3px; }
|
||||
|
||||
.impact-box {
|
||||
background: rgba(248,81,73,0.06);
|
||||
border: 1px solid rgba(248,81,73,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.impact-box .impact-label {
|
||||
color: var(--critical);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.fix-box {
|
||||
background: rgba(63,185,80,0.06);
|
||||
border: 1px solid rgba(63,185,80,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.fix-box .fix-label {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* Architecture Section */
|
||||
.arch-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.arch-card h4 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.arch-card p { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.7; }
|
||||
|
||||
/* Priority Table */
|
||||
.priority-table { margin-top: 1rem; }
|
||||
|
||||
.priority-table .p0 { color: var(--critical); font-weight: 700; }
|
||||
.priority-table .p1 { color: var(--high); font-weight: 600; }
|
||||
.priority-table .p2 { color: var(--medium); font-weight: 500; }
|
||||
.priority-table .p3 { color: var(--text-secondary); }
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.progress-bar .fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.fill.critical-fill { background: var(--critical); }
|
||||
.fill.high-fill { background: var(--high); }
|
||||
.fill.medium-fill { background: var(--medium); }
|
||||
|
||||
/* Chart */
|
||||
.chart-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1.5rem;
|
||||
height: 200px;
|
||||
padding: 1rem 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-bar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.chart-bar-stack {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
border-radius: 6px 6px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-bar-stack > div {
|
||||
width: 100%;
|
||||
transition: height 0.6s ease;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Expand/Collapse all */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.report-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container { padding: 1rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.finding-header { flex-direction: column; }
|
||||
.finding-badges { justify-content: flex-start; }
|
||||
.chart-container { gap: 0.8rem; }
|
||||
.chart-bar-stack { width: 40px; }
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { background: white; color: black; }
|
||||
.finding-body { display: block !important; }
|
||||
.stat-card, .finding-card, .arch-card { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="report-header">
|
||||
<h1>ESP32_COMM 高风险代码评审报告</h1>
|
||||
<div class="subtitle">多维度并行审查 + 对抗性验证 · 4维度 × 33项确认发现</div>
|
||||
<div class="report-meta">
|
||||
<span>📋 项目: ESP32_COMM (ESP32-C3 OTA Relay)</span>
|
||||
<span>📅 日期: 2026-06-13</span>
|
||||
<span>🔌 固件版本: v0.3.12</span>
|
||||
<span>👥 审查Agent: 4 Review + 36 Verify + 1 Synthesize</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card total">
|
||||
<div class="number">33</div>
|
||||
<div class="label">确认发现</div>
|
||||
</div>
|
||||
<div class="stat-card critical">
|
||||
<div class="number">8</div>
|
||||
<div class="label">CRITICAL</div>
|
||||
</div>
|
||||
<div class="stat-card high">
|
||||
<div class="number">18</div>
|
||||
<div class="label">HIGH</div>
|
||||
</div>
|
||||
<div class="stat-card medium">
|
||||
<div class="number">4</div>
|
||||
<div class="label">MEDIUM</div>
|
||||
</div>
|
||||
<div class="stat-card verified">
|
||||
<div class="number">33/36</div>
|
||||
<div class="label">验证通过率</div>
|
||||
</div>
|
||||
<div class="stat-card total">
|
||||
<div class="number">2</div>
|
||||
<div class="label">已驳回(误报)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dimension Chart -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">📊</span> 各维度发现分布</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">8</div>
|
||||
<div class="chart-bar-stack" style="height:160px;">
|
||||
<div style="height:60px;background:var(--critical);" title="2 CRITICAL"></div>
|
||||
<div style="height:80px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:20px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">内存安全</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">10</div>
|
||||
<div class="chart-bar-stack" style="height:180px;">
|
||||
<div style="height:54px;background:var(--critical);" title="3 CRITICAL"></div>
|
||||
<div style="height:108px;background:var(--high);" title="6 HIGH"></div>
|
||||
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">并发/RTOS</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">9</div>
|
||||
<div class="chart-bar-stack" style="height:170px;">
|
||||
<div style="height:72px;background:var(--critical);" title="4 CRITICAL"></div>
|
||||
<div style="height:72px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">OTA/固件</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">7</div>
|
||||
<div class="chart-bar-stack" style="height:150px;">
|
||||
<div style="height:43px;background:var(--critical);" title="2 CRITICAL"></div>
|
||||
<div style="height:86px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:14px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">SPI/协议</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOP 3 Critical -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🚨</span> TOP 3 最危险 Bug</div>
|
||||
|
||||
<!-- Finding 1 -->
|
||||
<div class="finding-card critical open" id="f1">
|
||||
<div class="finding-header" onclick="toggleFinding('f1')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #1 — CRITICAL</div>
|
||||
<div class="finding-title">SPI 总线无并发保护 — 多任务竞争导致协议损坏</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">并发竞态</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">spi_master.h:73-142, ota_relay.cpp, wifi_ota.cpp</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">并发/RTOS</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<p>SpiMaster 是单例但<strong>没有任何互斥锁</strong>。多个 FreeRTOS 任务直接调用 SPI 方法:ota_relay 任务 (优先级 MAX-2)、wifi_ota 任务 (MAX-3)、health_monitor 任务 (MAX-4)。协议操作是多步非原子的(如 otaTransfer = 发送帧 + 轮询响应,CS在两次传输之间释放),高优先级任务可在协议序列中间抢占。</p>
|
||||
<div class="code-block"><span class="comment">// spi_master.h — 无任何mutex成员</span>
|
||||
<span class="keyword">class</span> <span class="type">SpiMaster</span> {
|
||||
<span class="keyword">private</span>:
|
||||
<span class="type">spi_device_handle_t</span> m_spiDevice = <span class="keyword">NULL</span>; <span class="comment">// ← 唯一成员,无锁</span>
|
||||
};</div>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
在 SpiMaster 中添加 <code>SemaphoreHandle_t m_mutex</code>,所有公共方法入口加锁。对于多步协议操作(otaTransfer等),使用统一的锁粒度保护整个请求-响应序列。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finding 2 -->
|
||||
<div class="finding-card critical" id="f2">
|
||||
<div class="finding-header" onclick="toggleFinding('f2')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #2 — CRITICAL</div>
|
||||
<div class="finding-title">硬编码占位密钥 — 认证体系形同虚设</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">安全/OTA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">app_config.h:107-115</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">OTA/固件完整性</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<p>MASTER_KEY 使用 NIST AES 测试向量,SCRAMBLE_CONST 使用简单递增序列,GD32_CHIP_UID 使用 0x00-0x0B。注释标注"需填入实际密钥"但从未替换。</p>
|
||||
<div class="code-block"><span class="comment">// ⚠️ 请填入实际密钥 ← 注释说"需填入"但从未填入</span>
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> MASTER_KEY[] = {
|
||||
<span class="highlight">0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF</span>, <span class="comment">// ← NIST AES测试向量!</span>
|
||||
<span class="highlight">0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10</span>
|
||||
};
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> SCRAMBLE_CONST[] = {
|
||||
<span class="highlight">0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88</span>, <span class="comment">// ← 简单递增序列</span>
|
||||
<span class="highlight">0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00</span>
|
||||
};
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> GD32_CHIP_UID[] = {
|
||||
<span class="highlight">0x00, 0x01, 0x02, 0x03, 0x04, 0x05</span>, <span class="comment">// ← 0x00-0x0B</span>
|
||||
<span class="highlight">0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B</span>
|
||||
};</div>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
从安全存储(NVS/efuse)读取密钥;移除所有硬编码占位值;添加编译期断言确保非测试密钥;MASTER_KEY和SCRAMBLE_CONST应在首次设备配准时由安全随机数生成。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finding 3 -->
|
||||
<div class="finding-card critical" id="f3">
|
||||
<div class="finding-header" onclick="toggleFinding('f3')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #3 — CRITICAL</div>
|
||||
<div class="finding-title">Flash 写入无对齐检查 — 可能导致固件损坏</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">内存安全/OTA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">ota_relay.cpp:308-320, wifi_ota.cpp:711/740, ble_ota.cpp:575</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">内存安全 + OTA完整性</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="type">esp_err_t</span> OtaRelay::flashWriteChunk(<span class="type">uint32_t</span> offset, <span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len)
|
||||
{
|
||||
<span class="comment">// ... 无对齐检查 ...</span>
|
||||
<span class="keyword">return</span> <span class="highlight">esp_partition_write</span>(instance().m_gd32Partition, offset, data, len); <span class="comment">// ← offset/len可能非4字节对齐</span>
|
||||
}</div>
|
||||
<p>两个调用路径产生非对齐值:(1) WiFi OTA: gd32_offset 从 httpd_req_recv 返回值累加,最后一个chunk是固件余数;(2) BLE OTA: data_len = recv_len - 11(7字节头+4字节CRC),不保证4字节对齐。esp_partition_write 要求 offset 和 size 均4字节对齐。</p>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
非对齐写入导致 ESP_ERR_INVALID_ARG 或触发 read-modify-write 异常,GD32固件最后一个chunk损坏或丢失。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
flashWriteChunk 内部做4字节对齐对齐(不足部分 read-modify-write),或在上层确保只传对齐参数。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other CRITICAL Findings -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🔴</span> 其他 CRITICAL 级发现</div>
|
||||
|
||||
<div class="finding-card critical" id="f4">
|
||||
<div class="finding-header" onclick="toggleFinding('f4')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #4 — CRITICAL</div>
|
||||
<div class="finding-title">BLE/WiFi 双通道 OTA 同时触发 — ESP32 OTA状态冲突</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.h:42-46, ble_ota.h</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<p>BleOta 和 WifiOta 各自维护独立的 m_esp32OtaActive、m_esp32OtaHandle、m_esp32UpdatePartition,但底层 esp_ota_begin/write/end 操作的是同一个OTA分区。两个通道同时触发时:第二个 esp_ota_begin 会失败或覆盖句柄;esp_ota_write 可能交错写入同一分区;导致固件损坏+变砖。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>添加全局 OTA 互斥标志,同一时刻只允许一个OTA源(BLE或WiFi)。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f5">
|
||||
<div class="finding-header" onclick="toggleFinding('f5')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #5 — CRITICAL</div>
|
||||
<div class="finding-title">SPI 协议帧仅用 XOR 伪校验 — 数据损坏不可检测</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">spi_master.cpp:134-141</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="type">uint8_t</span> SpiMaster::otaCrc8(<span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len) {
|
||||
<span class="type">uint8_t</span> crc = <span class="highlight">0</span>;
|
||||
<span class="keyword">for</span> (<span class="type">size_t</span> i = <span class="highlight">0</span>; i < len; ++i)
|
||||
<span class="highlight">crc ^= data[i]</span>; <span class="comment">// ← 简单XOR,非CRC8</span>
|
||||
<span class="keyword">return</span> crc;
|
||||
}</div>
|
||||
<p>XOR校验无法检测:偶数个相同位置的位翻转、字节交换、全零段丢失。固件数据的静默损坏几乎不可检测。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>替换为 CRC-8/MAXIM 或 CRC-16/CCITT,与GD32团队协商。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f6">
|
||||
<div class="finding-header" onclick="toggleFinding('f6')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #6 — CRITICAL</div>
|
||||
<div class="finding-title">SPI_OTA_SOF 与 SPI_APP_ENTER_BOOT_ACK_OK 值碰撞 (0xA5)</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">spi_master.h:21,26</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="keyword">#define</span> <span class="highlight">SPI_OTA_SOF</span> (<span class="highlight">0xA5</span>) <span class="comment">// OTA帧起始标记</span>
|
||||
<span class="keyword">#define</span> <span class="highlight">SPI_APP_ENTER_BOOT_ACK_OK</span> (<span class="highlight">0xA5</span>) <span class="comment">// APP确认进入Bootloader</span></div>
|
||||
<p>同一值 0xA5 表示两种完全不同的语义。GD32在OTA窗口回复ACK时,ESP32可能将ACK字节误解析为OTA帧起始,导致协议状态机混乱。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>修改其中一个常量值,消除歧义。例如将 SPI_APP_ENTER_BOOT_ACK_OK 改为 0xB5。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f7">
|
||||
<div class="finding-header" onclick="toggleFinding('f7')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #7 — CRITICAL</div>
|
||||
<div class="finding-title">OTA 会话无全局超时 — 可无限挂起</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">ota_relay.cpp:76-91,562; ota_relay.h:59</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<p>waitUntilNotWait() 每次调用有 max_wait_rounds 上限,但整个 forwardFirmware() 流程没有全局超时。GD32 持续返回 WAIT 时,单个 SEND_SIZE阶段可挂起 ~78秒,完整流程可挂起数分钟。OTA_RESULT_ERR_USER_ABORT 已定义但从未使用。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>在 forwardFirmware() 入口记录开始时间,每个阶段检查全局超时(如5分钟),超时则中止并清理。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f8">
|
||||
<div class="finding-header" onclick="toggleFinding('f8')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #8 — CRITICAL</div>
|
||||
<div class="finding-title">HTTP CORS 允许所有来源 — 任意网页可触发 OTA</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.cpp:58</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block">httpd_resp_set_hdr(req, <span class="string">"Access-Control-Allow-Origin"</span>, <span class="highlight">"*"</span>);</div>
|
||||
<p>任何网页都可以向 ESP32 发起 OTA 请求。结合占位密钥问题,攻击链完整:恶意网页 → 上传伪造固件 → 通过占位密钥认证 → 烧录恶意GD32固件。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HIGH Findings -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🟠</span> HIGH 级关键发现</div>
|
||||
<div class="toolbar">
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">共 18 项 HIGH 级发现,点击展开详情</span>
|
||||
<button onclick="toggleAll('high')">展开/折叠全部</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>严重度</th><th>文件</th><th>问题</th><th>影响</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>9</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:121</code></td><td>WiFi事件回调中调用 esp_wifi_connect() 重连</td><td>在系统事件任务中调用可能死锁</td></tr>
|
||||
<tr><td>10</td><td><span class="badge high">HIGH</span></td><td><code>health_monitor.cpp</code></td><td>healthState 静态变量无锁跨任务读写</td><td>竞态导致健康判断错误</td></tr>
|
||||
<tr><td>11</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:80-84</code></td><td>5个静态变量(relayState等)无锁跨任务访问</td><td>协议状态不一致</td></tr>
|
||||
<tr><td>12</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.h:33-47</code></td><td>WifiOta 所有成员 public 且无锁</td><td>HTTP回调与WiFi事件回调竞争</td></tr>
|
||||
<tr><td>13</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.h</code></td><td>BleOta 成员无锁,BLE GATT回调与主任务竞争</td><td>BLE OTA 数据损坏</td></tr>
|
||||
<tr><td>14</td><td><span class="badge high">HIGH</span></td><td><code>debug_log.cpp:296</code></td><td>log() 栈分配 msg[512] + log_buf[600] = 1112字节</td><td>低栈任务中栈溢出</td></tr>
|
||||
<tr><td>15</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:1008</code></td><td>spi_send_handler 栈分配 1728+ 字节</td><td>HTTP server栈仅8KB,接近溢出</td></tr>
|
||||
<tr><td>16</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.cpp:456</code></td><td>sendNonceAndAuthenticate() nonce 仅用 esp_random() 一次</td><td>认证会话密钥可预测</td></tr>
|
||||
<tr><td>17</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:25</code></td><td>ota_meta_t.checksum[32] 从未填充/验证</td><td>固件完整性无保障</td></tr>
|
||||
<tr><td>18</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:429-444</code></td><td>ESP32 OTA 完成→重启,无回滚验证</td><td>新固件若无法启动将变砖</td></tr>
|
||||
<tr><td>19</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.cpp:258</code></td><td>syncTransfer/recv 忽略 timeout_ms 参数</td><td>SPI事务可能无限等待</td></tr>
|
||||
<tr><td>20</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:599-629</code></td><td>esp_ota_write 失败后 break,未调用 esp_ota_abort</td><td>OTA分区处于不一致状态</td></tr>
|
||||
<tr><td>21</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.cpp:439-444</code></td><td>BLE OTA 帧预留4字节CRC但减去计算不匹配</td><td>最后几个字节被误截断</td></tr>
|
||||
<tr><td>22</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.h</code></td><td>SPI仅4线,无硬件流控/就绪信号</td><td>高速传输时GD32来不及处理</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Architecture Concerns -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🏗</span> 架构级隐患</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>① 全局状态泛滥,无封装无同步</h4>
|
||||
<p>项目中有 <strong>5个静态全局变量</strong> (relayState, relaySeq, relayTotalPackets, gd32AppAlive, gd32Version) 和 <strong>大量public成员</strong> (WifiOta/BleOta所有状态),跨越3+个FreeRTOS任务和多个ISR/callback上下文访问,<strong>没有任何一个</strong>被mutex/atomic/critical section保护。这是系统性问题,不是个别bug。</p>
|
||||
</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>② 单通道SPI + 多任务无仲裁</h4>
|
||||
<p>SpiMaster 单例是唯一的SPI通信通道,但没有任何仲裁机制。WiFi/BLE/健康监控/OTA中继四个功能模块各自直接调用,协议级操作(请求-响应对)不是原子的。需要从架构层面引入SPI总线仲裁器。</p>
|
||||
</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>③ 安全架构不完整</h4>
|
||||
<p>认证握手存在但密钥硬编码→形同虚设;固件校验字段存在但未填充→形同虚设;CORS开放→任何人可触发OTA。<strong>安全功能"看起来有"但实际上不提供保护</strong>,这种"安全假象"比没有安全功能更危险,因为它给了虚假的信心。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Fix Order -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🛠</span> 优先修复顺序</div>
|
||||
<table class="priority-table">
|
||||
<thead>
|
||||
<tr><th>优先级</th><th>修复项</th><th>工作量</th><th>紧急度</th><th>进度</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>添加 SPI 总线互斥锁</td>
|
||||
<td>小</td>
|
||||
<td>极高 — 生产环境必崩</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>替换硬编码密钥为安全存储</td>
|
||||
<td>中</td>
|
||||
<td>极高 — 安全漏洞</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>Flash 写入对齐检查</td>
|
||||
<td>小</td>
|
||||
<td>极高 — 数据损坏</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>添加全局 OTA 超时+中止机制</td>
|
||||
<td>中</td>
|
||||
<td>高 — 设备挂死</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>ESP32 OTA 添加回滚验证</td>
|
||||
<td>中</td>
|
||||
<td>高 — 变砖风险</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>替换 XOR 为 CRC-8/16 校验</td>
|
||||
<td>小</td>
|
||||
<td>高 — 固件损坏</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>消除 0xA5 值碰撞</td>
|
||||
<td>极小</td>
|
||||
<td>高 — 协议混乱</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>添加 OTA 单源互斥 (BLE/WiFi)</td>
|
||||
<td>中</td>
|
||||
<td>中高</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>为所有跨任务状态添加锁/atomic</td>
|
||||
<td>大</td>
|
||||
<td>中高</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>修复 WiFi 事件回调中的重连调用</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>修复大栈分配问题</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>限制 CORS 来源</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>实现 ota_meta_t 校验填充与验证</td>
|
||||
<td>中</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Dismissed -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">✅</span> 已驳回发现(误报)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>原始严重度</th><th>问题描述</th><th>驳回原因</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="badge critical" style="text-decoration:line-through;opacity:0.5;">CRITICAL</span></td>
|
||||
<td>SPI DMA缓冲区栈分配导致数据损坏/硬Fault</td>
|
||||
<td>ESP-IDF 6.0 的 spi_master 驱动内置 <code>setup_dma_priv_buffer()</code> 函数自动检测并对齐DMA缓冲区,栈分配仅导致性能损失,不引发数据损坏</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge high" style="text-decoration:line-through;opacity:0.5;">HIGH</span></td>
|
||||
<td>TOCTOU: relayState 检查后 reset() 无锁</td>
|
||||
<td>虽然模式存在,但当前代码流程中检查和重置在同一线程同步执行,实际竞态窗口极小,风险被高估</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="report-footer">
|
||||
<p>ESP32_COMM 高风险代码评审报告 · 由 4维并行审查 + 36项对抗性验证 生成 · 2026-06-13</p>
|
||||
<p style="margin-top:0.5rem;">审查维度: 内存安全 · 并发/RTOS · OTA/固件完整性 · SPI通信/协议</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleFinding(id) {
|
||||
const card = document.getElementById(id);
|
||||
if (card) card.classList.toggle('open');
|
||||
}
|
||||
|
||||
let allHighOpen = false;
|
||||
function toggleAll() {
|
||||
allHighOpen = !allHighOpen;
|
||||
document.querySelectorAll('.finding-card').forEach(card => {
|
||||
if (allHighOpen) card.classList.add('open');
|
||||
else card.classList.remove('open');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,301 @@
|
||||
# ESP32 软件架构扫描报告(2026-06-27)
|
||||
|
||||
## 背景
|
||||
上一轮评审(2026-06-25,见 `docs/esp32_architecture_review_2026-06-25.md`)已完成 8 项优化:根目录清理、OtaCoordinator 状态查询单次取值、9 个 GD32 存储 wrapper inline 化、HTTP URI 路由表化、OtaRelay commonReject helper、SpiMaster *Unlocked 折叠、HealthMonitor RAII LockGuard、编译验证。
|
||||
|
||||
本轮扫描基于上述优化完成后的代码,新发现约 74 项可优化点,分三类:
|
||||
- **新发现**(约 65 项):上轮完全未涉及
|
||||
- **遗留**(3 项):上轮评审已提到但当时标"不在本次范围"
|
||||
- **残留**(3 项):上轮已做但未完全覆盖
|
||||
|
||||
按风险/收益分 P0 / P1 / P2 三档。
|
||||
|
||||
---
|
||||
|
||||
## P0 优先(功能正确性 / 安全性 bug)
|
||||
|
||||
### P0-1 metaVerifyChecksum 全零跳过校验是隐性后门
|
||||
- **文件**:[main/storage/gd32_flash_store.cpp:299-306](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L299-L306)
|
||||
- **问题**:checksum 全零时直接返回 `ESP_OK`,注释"旧数据兼容"。meta 被意外清零时校验会"通过",掩盖固件损坏
|
||||
- **建议**:返回 `ESP_ERR_INVALID_STATE`,由调用方决定是否容忍
|
||||
|
||||
### P0-2 RSSI 状态机 CRITICAL→NORMAL 无法降级
|
||||
- **文件**:[main/monitor/health_monitor.cpp:333-357](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L333-L357)
|
||||
- **问题**:"恢复到 NORMAL"只在 `cur == SYS_WARNING` 时触发,CRITICAL 状态下 RSSI 恢复正常不会降级
|
||||
- **建议**:明确状态转换表,补 CRITICAL → NORMAL 直接降级路径
|
||||
|
||||
### P0-3 OTA_MAX_FIRMWARE_SIZE 注释与 0x36000 不一致
|
||||
- **文件**:[main/include/app_config.h:62](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h#L62)
|
||||
- **问题**:`216*1024 = 221184`,注释"对齐 GD32 APP_MAX_SIZE (0x36000)",但 `0x36000 = 221296`,相差 112 字节,并非对齐
|
||||
- **建议**:确认正确值,用十六进制表达 `0x36000` 或修正注释
|
||||
|
||||
### P0-4 凭证硬编码入仓库
|
||||
- **文件**:[main/include/app_config.h:31,48,112-115](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h#L31)
|
||||
- **问题**:WiFi 密码、AP 密码、MASTER_KEY 明文入仓库,README 也标注"⚠️ 占位值,生产环境需更换"
|
||||
- **建议**:迁移到 NVS/Kconfig + env 文件,CI 加凭证扫描规则
|
||||
|
||||
### P0-5 BleEsp32FinishCtx 三处 free 易漏
|
||||
- **文件**:[main/server/ble_ota.cpp:854,158,190,210](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L854)
|
||||
- **问题**:ctx 在 3 个不同分支释放,`if (ctx) free(ctx)` 防御式写法暗示历史 bug;第 190 行后 `restartEsp32()` 不返回,理论上 free 不执行
|
||||
- **建议**:RAII 封装 `BleFinishCtxDeleter`,任务入口 `BleFinishCtxDeleter guard{ctx};`,所有 return 路径自动释放
|
||||
- **备注**:上轮评审 G 项已提到("不是精简,是健壮性,留后续"),本轮方案改为 RAII deleter(上轮原方案是改静态单例)
|
||||
|
||||
### P0-6 GD32 固件大小校验逻辑双通道重复
|
||||
- **文件**:[main/server/ble_ota.cpp:1115-1121](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L1115-L1121) vs [main/server/http_handlers.cpp:302-308](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L302-L308)
|
||||
- **问题**:两处完全相同的校验:`uint32_t rounded = ((total + PKT - 1) / PKT) * PKT; if (total == 0 || total > MAX || rounded > MAX) {...}`,但 BLE 用 `BLE_OTA_FINISH_ERR_GD32_SIZE` 上报,HTTP 用 `HTTPD_500` 返回,错误码语义不对齐
|
||||
- **建议**:抽取 `OtaCoordinator::validateGd32Size(total_size) -> esp_err_t`,两路调用方各自翻译为通道特定错误回报
|
||||
|
||||
### P0-7 doRelayPackets 180 行 + goto + 重试阈值混乱
|
||||
- **文件**:[main/relay/ota_relay.cpp:936-1115](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L936-L1115)
|
||||
- **问题**:约 180 行,`for` → 内嵌 `while(1)` → 内嵌 `for` 重试 → 2 处 `goto resend_packet`(行 1057、1091);重试计数器阈值不一致:行 1015 `>=10U`、行 1032 `>=3U`、行 1050 `>=3U`、行 1075 `>=10U`、行 1084 `>=10U`,同一 `retry_count` 既限 3 又限 10
|
||||
- **建议**:拆分为 `preparePacket(seq)` + `sendPacketWithRetry(seq, tx_buf)` + 外层 `doRelayPackets`;重试阈值提取常量 `kMaxResendOnNack`/`kMaxResendOnStructError`/`kMaxResendOnPollExhaust`
|
||||
|
||||
### P0-8 ble_ota_process_data_batch 245 行
|
||||
- **文件**:[main/server/ble_ota.cpp:975-1219](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L975-L1219)
|
||||
- **问题**:单函数处理 ESP32 与 GD32 两条独立数据流,ESP32 分支内嵌 6 个子逻辑,GD32 分支内嵌 9 个子逻辑,循环内多处 `offset += packet_len; if (offset + 7 > recv_len) break;` 重复推进
|
||||
- **建议**:拆分为 `process_esp32_packet(...)` 与 `process_gd32_packet(...)`,主循环只负责解包 + 分发
|
||||
|
||||
---
|
||||
|
||||
## P1 优先(数据竞争 / 资源泄漏 / 双通道一致性)
|
||||
|
||||
### P1-A OtaRelay 状态成员封装破坏(一组原子改动)
|
||||
|
||||
**头文件**:[main/relay/ota_relay.h:228-237](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.h#L228-L237)
|
||||
|
||||
**问题**:把 `relayState/relaySeq/relayTotalPackets/gd32AppAlive/gd32Version/taskHandle/s_relayListener/s_stateMutex` 全部声明为 `public static`,但 cpp 内部多处直接访问绕过带锁的 setter/getter,与外部带锁读取形成数据竞争:
|
||||
|
||||
| 位置 | 直接访问 |
|
||||
|---|---|
|
||||
| [ota_relay.cpp:331-332](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L331-L332) | `handleSizeNeg` 直接写 `relayTotalPackets/relaySeq` 无锁 |
|
||||
| [ota_relay.cpp:1102,1104-1105](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1102-L1105) | `doRelayPackets` 直接写读 `relaySeq` 无锁 |
|
||||
| [ota_relay.cpp:1189,1197-1198,1209](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1189-L1209) | `doFinishAndVerify` 直接写读 `gd32AppAlive/gd32Version` 无锁 |
|
||||
| [ota_relay.cpp:470,478](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L470-L478) | `forwardFirmware` 直接读 `relayState` 无锁 |
|
||||
| [ota_relay.cpp:1272,1288](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1272) | `taskEntry` 直接访问 `s_relayListener` 绕过 getter |
|
||||
|
||||
**建议**:所有静态状态成员改 `private`,统一通过 setter/getter;relay task 内部若已持锁,在 `StateLockGuard` 块内访问。`s_stateMutex` 改 private,`StateLockGuard` 作为友元或保留 public 嵌套类。
|
||||
|
||||
### P1-9 getSnapshot 双重加锁且可能不一致
|
||||
- **文件**:[main/ota/ota_coordinator.cpp:571-591](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L571-L591)
|
||||
- **问题**:行 576-581 持锁读取 `m_sysState` 填入 `snap.sys_state` 后释放;行 582 调 `getSystemStateString()` 又内部调 `getSystemState()` 二次加锁。两次取值之间状态可能变化,导致 `snap.sys_state`(第一次取的值)与 `snap.sys_state_str`(第二次取的值)描述不同时刻状态,前端展示自相矛盾
|
||||
- **建议**:让 `getSystemStateString` 接受 `ota_system_state_t` 参数(重载 `static const char* stateToString(ota_system_state_t)`),`getSnapshot` 持锁时一次性取值并就地转字符串
|
||||
- **备注**:上轮 A.2 只改了 `isIdle/isGd32Busy/isEsp32Busy`,`getSnapshot` 未覆盖
|
||||
|
||||
### P1-10 getSystemStateString 等仍独立加锁
|
||||
- **文件**:[main/ota/ota_coordinator.cpp:509-549](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L509-L549)
|
||||
- **问题**:`isIdle`、`isGd32Busy`、`isEsp32Busy`、`getSystemStateString` 各自调 `getSystemState()`(加锁取值再解锁)。`if (!isIdle() && !isGd32Busy())` 这类连续调用会加锁 2~3 次
|
||||
- **建议**:提供"取一次状态后多次判断"的接口,或让调用方先 `getSystemState()` 取一次再传给各判断函数
|
||||
- **备注**:上轮 A.2 残留
|
||||
|
||||
### P1-11 esp32_ota_core finalize 3 处清理序列重复
|
||||
- **文件**:[main/ota/esp32_ota_core.cpp:281-288,312-318,332-339](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/esp32_ota_core.cpp#L281-L339)
|
||||
- **问题**:3 处几乎相同:`esp_ota_abort(m_handle); m_ended = false; releaseMutex(); resetState(); return xxx;`(SHA 计算失败 / SHA 不匹配 / esp_ota_end 失败)
|
||||
- **建议**:提取私有方法 `finalizeCleanupAndReturn(esp_err_t code)`,或用 `goto cleanup`
|
||||
|
||||
### P1-12 esp32_ota_core begin 3 处清理序列重复
|
||||
- **文件**:[main/ota/esp32_ota_core.cpp:126-130,135-141,151-158](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/esp32_ota_core.cpp#L126-L158)
|
||||
- **问题**:3 处清理序列:分区找不到 / 固件太大 / `esp_ota_begin` 失败,模式高度相似,差异仅在是否清零 `m_handle` 和返回码
|
||||
- **建议**:提取 `beginCleanupAndReturn(esp_err_t code)`,或统一出错点设置返回码后 `goto begin_fail`
|
||||
|
||||
### P1-13 doFinishAndVerify 100 行双职责
|
||||
- **文件**:[main/relay/ota_relay.cpp:1118-1216](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1118-L1216)
|
||||
- **问题**:单函数承担两个协议阶段:(1) 发送 FINISH_OTA 并等 ACK(1120-1176);(2) APP 存活轮询验证(1184-1213)。函数名含 "Verify",但两个阶段职责不同、超时参数不同、重试策略不同,行 1191 `for (attempt < 30)` 与 FINISH_OTA 的 `for (retry < 10)` 嵌套语义混淆
|
||||
- **建议**:拆分为 `doSendFinishOta()` + `doWaitForAppAlive()`,`doFinishAndVerify` 改为顺序调用两者并做 session 清理
|
||||
|
||||
### P1-14 APP 存活探测模式三处重复
|
||||
- **文件**:[main/relay/ota_relay.cpp:1191-1207,1240-1261](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1191-L1207) + [main/ota/ota_coordinator.cpp:596-615](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L596-L615)
|
||||
- **问题**:同一"调 `SpiMaster::appPing` + 写 `gd32AppAlive`/`gd32Version` + 日志版本号"模式在 3 处重复:(1) `doFinishAndVerify` 轮询(30 次重试);(2) `taskEntry` 启动探测(1 次);(3) `OtaCoordinator::probeGd32AppOnce`(1 次)。日志格式、版本号字段打印、成功/失败处理高度相似但不完全相同
|
||||
- **建议**:提取 `static void OtaRelay::probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t* out_uptime)`,三处调用方分别指定重试次数和超时
|
||||
|
||||
### P1-15 setWifiConnected 未加锁
|
||||
- **文件**:[main/monitor/health_monitor.cpp:320-323,248](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L320-L323)
|
||||
- **问题**:直接写 `volatile bool m_wifiConnected`,未走 `m_mutex`。虽然 `volatile` 保证可见性,但与 `m_healthState` 等其他状态的访问模式不一致。`checkBeforeOta` 行 248 读 `m_wifiConnected` 也未加锁,存在读写竞态
|
||||
- **建议**:统一通过 LockGuard 保护,或显式说明为何此字段例外(如使用 `std::atomic<bool>`)
|
||||
- **备注**:上轮 F 给 HealthMonitor 加了 LockGuard,但 `m_wifiConnected` 漏了
|
||||
|
||||
### P1-16 温度 esp_timer_handle_t 是局部变量,资源无法回收
|
||||
- **文件**:[main/monitor/health_monitor.cpp:182-194](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L182-L194)
|
||||
- **问题**:`esp_timer_handle_t temp_timer` 是局部变量未存为成员。任务若被删除,定时器持续运行,回调仍会访问 `instance()` 的成员
|
||||
- **建议**:将 `temp_timer` 存为成员,提供 `deinit()` 在任务删除时 `esp_timer_stop` + `esp_timer_delete`
|
||||
|
||||
### P1-17 metaUpdate 读-改-写非原子
|
||||
- **文件**:[main/storage/gd32_flash_store.cpp:188-198](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L188-L198)
|
||||
- **问题**:`metaLoad` → 修改 → `metaSave` 中间无锁,若两个任务同时调用 `metaUpdate`,会丢失更新
|
||||
- **建议**:在 Gd32FlashStore 内增加 NVS 互斥锁,或将"由调用方串行化"约束以运行时断言强制
|
||||
|
||||
### P1-18 metaLoad NVS open 失败一律当 OK
|
||||
- **文件**:[main/storage/gd32_flash_store.cpp:142-145](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L142-L145)
|
||||
- **问题**:`nvs_open` 失败时直接 `return ESP_OK;`(注释说"命名空间不存在时返回全零结构"),但 NVS open 失败原因可能不止"不存在"(如 NVS 损坏),一律当 OK 会掩盖真实错误
|
||||
- **建议**:区分 `ESP_ERR_NVS_NOT_FOUND`(OK + 全零)与其他错误(返回 err)
|
||||
|
||||
### P1-19 DebugLog init 不检查 uartInit 返回值
|
||||
- **文件**:[main/monitor/debug_log.cpp:252-272](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L252-L272)
|
||||
- **问题**:`uartInit();`(行 258)返回值被丢弃,UART 初始化失败仍设 `m_initialized = true` 并打印"调试日志已初始化"
|
||||
- **建议**:检查返回值,失败时降级(仅 ring buffer)并在日志中标注 UART 不可用
|
||||
|
||||
### P1-20 ringDrainJson 容量估算与边界检查
|
||||
- **文件**:[main/monitor/debug_log.cpp:116-151](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L116-L151)
|
||||
- **问题**:`capacity = count * (LOG_RING_SIZE * 2) + 16`,count=64、LOG_RING_SIZE=256 → 最大 32784 字节,单次 malloc 在 ESP32-C3(~160KB heap)上风险较大;`for` 循环 `pos < capacity - 4` 的边界检查在 `first` 分支后逗号写入(行 133)未做边界检查,可能溢出
|
||||
- **建议**:分块 realloc,或对单条日志超长时截断并标记
|
||||
|
||||
### P1-21 关键任务创建失败不停止初始化
|
||||
- **文件**:[main/core/main.cpp:69-73,101-105,117-121,133-137](file:///g:/Robot_v2/DCU/ESP32_COMM/main/core/main.cpp#L69-L137)
|
||||
- **问题**:任务创建失败仅 `DEBUG_ERR`,不停止后续初始化,可能导致部分功能缺失但系统"看起来正常启动"
|
||||
- **建议**:关键任务(health_mon、ota_relay)失败时 `ESP_ERROR_CHECK(ESP_FAIL)` 或进入 safe state
|
||||
|
||||
### P1-22 s_ota_recv_buf 全局静态被双 handler 共享
|
||||
- **文件**:[main/server/http_handlers.cpp:36,249,337](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L36)
|
||||
- **问题**:`static uint8_t s_ota_recv_buf[OTA_BLOCK_SIZE];` 在 ESP32 与 GD32 两条路径间共享。httpd 默认单线程不会并发,但若未来 `max_open_sockets > 1` 启用并发请求,两个 `ota_upload_handler` 同时跑会互相覆盖 buffer
|
||||
- **建议**:改为 handler 内栈分配(若 `OTA_BLOCK_SIZE < 4KB` 可行),或改为 per-request 分配
|
||||
|
||||
### P1-23 一次性任务创建失败上报不一致
|
||||
- **文件**:[main/server/ble_ota.cpp:816-821,875-878,896-899](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L816-L899)
|
||||
- **问题**:
|
||||
- ble_gd32_relay_wait_task 创建失败:`free(ctx); sendFinishEvent(ERR_GD32_RELAY, ...);` — 完整上报
|
||||
- ble_esp32_finish_and_reboot_task 创建失败:`free(finish_ctx); m_esp32RebootPending = false;` — **未上报 FINISH 错误事件**,前端会一直等
|
||||
- ble_esp32_restart_task 创建失败:`m_esp32RebootPending = false;` — **未上报任何错误**
|
||||
- **建议**:统一"任务创建失败 → 释放 ctx → 复位 pending 标志 → 上报 FINISH 错误事件"三步
|
||||
|
||||
### P1-24 reorder slot malloc 失败静默丢弃
|
||||
- **文件**:[main/server/ble_ota.cpp:513-518](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L513-L518)
|
||||
- **问题**:malloc 失败时返回 `ESP_OK` 让上层认为已处理,但实际数据丢失。若客户端窗口已用完(无重传预算),固件写入会永久卡在缺口。注释说"靠客户端重传"但客户端无法感知设备端 malloc 失败
|
||||
- **建议**:malloc 失败时至少发一次 `sendWindowAck` 报告当前 `expected`,让客户端立即重传缺口;或返回错误触发 abort
|
||||
|
||||
### P1-25 protocol.h 死代码
|
||||
- **文件**:[main/transport/protocol.h:16-18,25-29](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/protocol.h#L16-L29) + [main/transport/protocol.cpp:96-118](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/protocol.cpp#L96-L118)
|
||||
- **问题**:
|
||||
1. `protocol.h` 仍定义 `FRAME_SOF 0xEB`、`FRAME_MAX_PAYLOAD 64`、`FRAME_OVERHEAD 6`,但 `protocol.cpp` 注释(第 8-10 行)明确说"帧构建、AES-CBC 加解密、CRC16 等中间件代码已移除",这三个常量无任何引用
|
||||
2. `session_ctx_t.iv[16]` 在 `sessionKeyInit` 中被 `memset(iv, 0, 16)` 后再无任何写入;`session_ctx_t.used` 初始化为 0 后再无任何代码置 1。整个 `session_ctx_t` 实际只用到 `key[16]`
|
||||
- **建议**:删除 `FRAME_SOF`/`FRAME_MAX_PAYLOAD`/`FRAME_OVERHEAD`;精简 `session_ctx_t` 为 `{ uint8_t key[16]; }` 或整体移除
|
||||
|
||||
### P1-26 GD32 OTA 写入序列两路径重复
|
||||
- **文件**:[main/server/ble_ota.cpp:1113-1208](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L1113-L1208)(BLE)vs [main/server/http_handlers.cpp:314-413](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L314-L413)(HTTP)
|
||||
- **问题**:两路径都执行相同序列:`beginGd32Storage` → `saveGd32Meta` → 循环 `writeGd32Chunk` + `updateGd32Meta` → `completeGd32Storage` + `finalizeGd32Checksum`。但 HTTP 路径多了 `eraseGd32Partition` 和 `clearGd32Meta`(行 327、335),BLE 路径没有。这暗示 BLE 路径可能依赖前次 HTTP 上传后的已擦除状态,或在 `beginGd32Storage` 内隐式擦除——这种隐式假设是 bug 温床
|
||||
- **建议**:抽取 `OtaCoordinator::storeGd32FirmwareFromStream(...)` 接收"读块回调",BLE 与 HTTP 各自实现回调,统一擦除/写入/meta/完成序列
|
||||
|
||||
---
|
||||
|
||||
## P2 优先(样板抽取 / 配置化 / 风格统一)
|
||||
|
||||
### P2-A 跨模块样板抽取(高 ROI)
|
||||
|
||||
| # | 主题 | 涉及位置 |
|
||||
|---|---|---|
|
||||
| P2-27 | Hex 编解码工具统一 | [http_handlers.cpp:460-463,558-562,629-633](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L460-L633)、[ble_ota.cpp:174-178,642-649,861-863](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L174-L863) 共 6+ 处。**关键 bug**:SHA-256 在 BLE 路径大小写混用(`shaBytesToHexUpper` 大写 vs 行 861-863 `%02x` 小写),HTTP 路径用大写 `%02X`,前端比对 SHA 时大小写敏感会出错 |
|
||||
| P2-28 | SHA-256 → hex 循环 | [esp32_ota_core.cpp:230-234,254-257,290-293,323-325](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/esp32_ota_core.cpp#L230-L325) 4 处 + [gd32_flash_store.cpp:283-286,316-320](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L283-L320) 2 处。提取 `static void sha256ToHex(const uint8_t in[32], char out[65])` |
|
||||
| P2-29 | HTTP JSON body 接收+解析 | [http_handlers.cpp:71-95,436-452,522-535,595-608](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L71-L608) 4 处。缓冲区上限不一(256/512)。提取 `http_recv_json(httpd_req_t*, char*, size_t, cJSON**)` |
|
||||
| P2-30 | HTTP 错误响应样板 | [http_handlers.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp) 17 处 `httpd_resp_send_err + return ESP_FAIL`。提取 `http_send_json_error/http_send_json_ok` |
|
||||
| P2-31 | 忙状态判断 if 链 | [ota_coordinator.cpp:136-140](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L136-L140)(beginGd32Storage) vs [ota_coordinator.cpp:346-350](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L346-L350)(beginEsp32Ota)。提取 `bool isAnyChannelBusy() const` |
|
||||
| P2-32 | 三个校验函数重复 metaLoad 样板 | [gd32_flash_store.cpp:265-275,292-298,331-351](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L265-L351) |
|
||||
|
||||
### P2-B 函数过长 / 高圈复杂度
|
||||
|
||||
| # | 函数 | 行数 | 文件 |
|
||||
|---|---|---|---|
|
||||
| P2-33 | ble_ota_process_data_batch | 245 行 | [ble_ota.cpp:975-1219](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L975-L1219)(见 P0-8) |
|
||||
| P2-34 | ota_upload_handler | 240 行 | [http_handlers.cpp:180-420](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L180-L420)。ESP32/GD32 双路径混杂,共享 `pre_read_buf`/`pre_read_len`。拆为 `handle_esp32_upload`/`handle_gd32_upload` |
|
||||
| P2-35 | ble_ota_ctrl_access | 190 行 | [ble_ota.cpp:717-907](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L717-L907)。3 命令分支长度极不均(0xF7 90 行 / 0xF8 54 行 / 0xFA 19 行),`session_id` 提取重复 |
|
||||
| P2-36 | doRelayPackets | 180 行 + goto | [ota_relay.cpp:936-1115](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L936-L1115)(见 P0-7) |
|
||||
| P2-37 | doFinishAndVerify | 100 行双职责 | [ota_relay.cpp:1118-1216](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1118-L1216)(见 P1-13) |
|
||||
| P2-38 | app_main | 124 行 | [main.cpp:30-154](file:///g:/Robot_v2/DCU/ESP32_COMM/main/core/main.cpp#L30-L154)。拆为 `initEarlyServices` + `createOtaTasks` + `markAppValid` |
|
||||
|
||||
### P2-C 配置化(magic number → app_config.h)
|
||||
|
||||
| # | 主题 | 位置 |
|
||||
|---|---|---|
|
||||
| P2-39 | 任务栈/优先级散落硬编码 | [main.cpp:63,65,95,97,111,113,127,129](file:///g:/Robot_v2/DCU/ESP32_COMM/main/core/main.cpp#L63)、[ble_ota.cpp:812,871,892,1723](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L812)、[wifi_ota.cpp:623](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/wifi_ota.cpp#L623)、[http_handlers.cpp:905-913](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L905-L913)(task_metrics 必须手工同步,已出现一处不对齐风险) |
|
||||
| P2-40 | HTTP server config | [wifi_ota.cpp:419-424](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/wifi_ota.cpp#L419-L424)(`max_uri_handlers=32` 实际只用 18) |
|
||||
| P2-41 | BLE 连接参数 | [ble_ota.cpp:1395-1401](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L1395-L1401)(itvl/latency/supervision) |
|
||||
| P2-42 | AP 静态 IP | [wifi_ota.cpp:234-236,240,258-259](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/wifi_ota.cpp#L234-L259) |
|
||||
| P2-43 | BLE_OTA_ACK_WINDOW 位置 | [ble_ota.cpp:58](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L58) 用 `#define` 应移入 app_config.h 作 `inline constexpr` |
|
||||
| P2-44 | debug_log magic number | [debug_log.cpp:199,317,352,365,369](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L199)(1024/512/600/32) |
|
||||
| P2-45 | 健康巡检周期 | [health_monitor.cpp:190,208](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L190)(1000000/5000) |
|
||||
| P2-46 | 温度采样间隔 | [health_monitor.cpp:190](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L190) |
|
||||
|
||||
### P2-D 死代码 / 未使用配置
|
||||
|
||||
| # | 项 | 位置 |
|
||||
|---|---|---|
|
||||
| P2-47 | `DEBUG_LOG_ENABLED` 全代码库零引用 | [app_config.h:83](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h#L83) |
|
||||
| P2-48 | `GD32_CHIP_UID[]` 占位 | [app_config.h:120-128](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h#L120-L128) |
|
||||
| P2-49 | `BLE_OTA_PACKET_OVERHEAD` 零引用 | [app_config.h:104](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h#L104) |
|
||||
| P2-50 | `WIFI_RSSI_WEAK` 零引用 | [health_monitor.h:33](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.h#L33) |
|
||||
| P2-51 | `DEBUG_LOG_NONE/ALL` 零引用 | [debug_log.h:35,40](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.h#L35) |
|
||||
| P2-52 | `WIFI_STA_USE_STATIC_IP` 临时调试开关 | [app_config.h:36](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h#L36) |
|
||||
| P2-53 | `ota_snapshot.h` 仅聚合无价值 | [ota_snapshot.h](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_snapshot.h) 全文 |
|
||||
|
||||
### P2-E 其他低风险风格类
|
||||
|
||||
| # | 项 | 位置 |
|
||||
|---|---|---|
|
||||
| P2-54 | SpiMaster 公共接口混合多个抽象层 | [spi_master.h:60-100](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/spi_master.h#L60-L100)(上轮 D 残留) |
|
||||
| P2-55 | DebugLog 未用 RAII(与 HealthMonitor 风格不一) | [debug_log.cpp:53-60,78-108,162-174](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L53-L174) |
|
||||
| P2-56 | DebugLog logLevel 暴露在 public | [debug_log.h:86](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.h#L86) |
|
||||
| P2-57 | DebugLog 三路 sink 抽象为列表 | [debug_log.cpp:300-333](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L300-L333)(上轮 E 项遗留) |
|
||||
| P2-58 | DebugLog send() 返回值无人检查,可改 void | [debug_log.cpp:329-332](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L329-L332) |
|
||||
| P2-59 | ensureUdpSocket 失败后每条日志都重试 | [debug_log.cpp:278-292](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L278-L292) |
|
||||
| P2-60 | DebugLog log() 三次缓冲区拼接 | [debug_log.cpp:342-373](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L342-L373) |
|
||||
| P2-61 | DebugLog 头注释与实现不一致(UDP 无开关) | [debug_log.h:6-7](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.h#L6-L7) vs [debug_log.cpp:311-324](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L311-L324) |
|
||||
| P2-62 | DebugLog 重复 include | [debug_log.h:23-24](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.h#L23-L24) vs [debug_log.cpp:20,23](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.cpp#L20) |
|
||||
| P2-63 | health_monitor WIFI_RSSI_NORMAL 别名位置错误 | [health_monitor.h:32-33](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.h#L32-L33) |
|
||||
| P2-64 | health_state_t 与 health_status_t 两套枚举语义重叠 | [health_monitor.h:16-29](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.h#L16-L29) |
|
||||
| P2-65 | health_monitor 阈值检查样板可抽 applyThresholdAlarm | [health_monitor.cpp:129-156,333-357](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L129-L357) |
|
||||
| P2-66 | health_monitor checkBeforeOta 与 checkBeforeOtaRelay 分级样板重复 | [health_monitor.cpp:232-311](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L232-L311) |
|
||||
| P2-67 | health_monitor taskEntry 职责混杂 | [health_monitor.cpp:165-210](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L165-L210) |
|
||||
| P2-68 | health_monitor logEvent 与相邻 DEBUG 重复日志 | [health_monitor.cpp:220-223](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L220-L223) |
|
||||
| P2-69 | health_monitor tempMonitorCallback 注释硬编码温度值 | [health_monitor.cpp:293](file:///g:/Robot_v2/DCU/ESP32_COMM/main/monitor/health_monitor.cpp#L293) |
|
||||
| P2-70 | gd32_flash_store NVS 字段名硬编码 | [gd32_flash_store.cpp:136-185](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L136-L185) |
|
||||
| P2-71 | gd32_flash_store tag "OTA" 与模块名不一致 | [gd32_flash_store.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp) 全文 |
|
||||
| P2-72 | gd32_flash_store SHA 长度 32 裸数字 | [gd32_flash_store.h:26](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.h#L26) 等 |
|
||||
| P2-73 | gd32_flash_store flashWriteChunk 错误清理重复 | [gd32_flash_store.cpp:74-108](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L74-L108) |
|
||||
| P2-74 | gd32_flash_store computeFlashSha256 错误清理重复 | [gd32_flash_store.cpp:224-262](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L224-L262) |
|
||||
| P2-75 | gd32_flash_store 全零 checksum 检查可简化为 memcmp | [gd32_flash_store.cpp:299-302](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L299-L302) |
|
||||
| P2-76 | gd32_flash_store flashWriteChunk 非对齐警告条件恒真 | [gd32_flash_store.cpp:65-72](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L65-L72) |
|
||||
| P2-77 | gd32_flash_store m_partition 懒初始化无线程安全 | [gd32_flash_store.cpp:30-40](file:///g:/Robot_v2/DCU/ESP32_COMM/main/storage/gd32_flash_store.cpp#L30-L40) |
|
||||
| P2-78 | main.cpp 4 处 xTaskCreatePinnedToCore 样板 | [main.cpp:60-137](file:///g:/Robot_v2/DCU/ESP32_COMM/main/core/main.cpp#L60-L137) |
|
||||
| P2-79 | main.cpp 启动横幅分隔线硬编码 4 次 | [main.cpp:50,56,149,153](file:///g:/Robot_v2/DCU/ESP32_COMM/main/core/main.cpp#L50-L153) |
|
||||
| P2-80 | main.cpp xRet 变量重复使用 | [main.cpp:59,69,92,101,108,117,124,133](file:///g:/Robot_v2/DCU/ESP32_COMM/main/core/main.cpp#L59-L133) |
|
||||
| P2-81 | http_handlers.cpp 单文件 TAG 混用 OTA/WIFI/SPI | [http_handlers.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp) 全文 |
|
||||
| P2-82 | canFdSend 返回 -1 无法区分失败模式 | [canfd_bridge.cpp:59-68](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/canfd_bridge.cpp#L59-L68) |
|
||||
| P2-83 | otaCrc8 实为 XOR 校验和,命名误导 | [spi_master.cpp:180-187](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/spi_master.cpp#L180-L187) |
|
||||
| P2-84 | otaParseFrame/otaFindFrame 验证逻辑重复 | [spi_master.cpp:225-279](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/spi_master.cpp#L225-L279) |
|
||||
| P2-85 | CAN 帧 vs SPI OTA 帧两层 framing 未抽象 | [canfd_bridge.cpp:12-51](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/canfd_bridge.cpp#L12-L51) + [spi_master.cpp:198-279](file:///g:/Robot_v2/DCU/ESP32_COMM/main/transport/spi_master.cpp#L198-L279) |
|
||||
| P2-86 | BLE OTA WiFi 共存标志位冗余 | [ble_ota.cpp:226-250](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/ble_ota.cpp#L226-L250) |
|
||||
| P2-87 | http_handlers task_metrics 硬编码任务栈大小 | [http_handlers.cpp:905-913](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L905-L913) |
|
||||
| P2-88 | notifyRelayStarted/Complete 纯转发中间层 | [ota_coordinator.cpp:264-307](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L264-L307) |
|
||||
| P2-89 | notifyEsp32FinalizeComplete 与 notifyEsp32Rebooting 几乎相同 | [ota_coordinator.cpp:409-428](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L409-L428) |
|
||||
| P2-90 | doFinishAndVerify 成功路径与 reset() session 清理重复 | [ota_relay.cpp:1178-1182 vs 487-504](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1178-L1182) |
|
||||
| P2-91 | metaLoad 单次中继被调 3 次 | [ota_coordinator.cpp:213-221](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L213-L221) + [ota_relay.cpp:311-316,438-440](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L311-L440) |
|
||||
| P2-92 | forwardFirmware 中 expected_ack=0xF0F0F0F0 魔法数字 | [ota_relay.cpp:1024](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L1024) |
|
||||
| P2-93 | doRelayPackets:918 WARN 级别输出纯配置信息(调试残留) | [ota_relay.cpp:918](file:///g:/Robot_v2/DCU/ESP32_COMM/main/relay/ota_relay.cpp#L918) |
|
||||
| P2-94 | app_config 配置项类型不一致(uint8_t 应为 bool) | [app_config.h:25,36,83](file:///g:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h#L25) |
|
||||
|
||||
---
|
||||
|
||||
## 不建议改的地方(保留上轮结论)
|
||||
|
||||
- OTA 事件状态机的 5 步驱动顺序、健康检查时机、`OtaCoordinator` 的 watchdog 自愈、`forceCleanup` 仅在 `isIdle()` 启动。
|
||||
- `Esp32OtaCore::s_mutex + s_occupied` 双层 mutex 语义("持锁查占用" + "持续占用期")。
|
||||
- 单例模式已统一(`static XXX inst; return inst;` + `= delete` 拷贝构造/赋值)。
|
||||
- 无 TODO/FIXME/XXX/HACK 标记,无注释掉的死代码。
|
||||
|
||||
---
|
||||
|
||||
## 预估收益
|
||||
|
||||
- 修复 4 项功能性 bug(校验后门 / RSSI 状态机降级 / 固件大小注释 / 凭证安全)。
|
||||
- 消除 6 处数据竞争(OtaRelay 状态访问 + setWifiConnected)。
|
||||
- 6 个超长函数拆分(245/240/190/180/124/100 行),可维护性显著提升。
|
||||
- `main/` 行数预计再减约 400~500 行(去重 + 死代码)。
|
||||
- 双通道(BLE/HTTP)一致性通过 `validateGd32Size` / `storeGd32FirmwareFromStream` 抽取彻底对齐。
|
||||
- protocol.h / app_config.h 死代码清理约减 50+ 行。
|
||||
|
||||
---
|
||||
|
||||
## 推荐落地顺序
|
||||
|
||||
1. **第 1 批(P0)**:P0-1 ~ P0-8。先修功能正确性 / 安全性 bug。P0-7/P0-8 函数拆分风险高,需配合 GD32 全链路 OTA 回归。
|
||||
2. **第 2 批(P1.A)**:OtaRelay 状态成员封装一组(P1-A + P1-15),消除数据竞争,原子改动。
|
||||
3. **第 3 批(P1.B)**
|
||||
4. **第 4 批(P2.A/B)**:样板抽取(P2-27 ~ P2-32)+ 函数拆分(P2-33 ~ P2-38)。
|
||||
5. **第 5 批(P2.C/D/E)**:配置化(P2-39 ~ P2-46)+ 死代码删除(P2-47 ~ P2-53)+ 风格统一(P2-54 ~ P2-94),可随业务变更顺手做。
|
||||
+55
-26
@@ -1,36 +1,65 @@
|
||||
# ESP32-C3 OTA 中继项目 - 主组件构建配置
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"core/main.cpp"
|
||||
"relay/ota_relay.cpp"
|
||||
"transport/spi_master.cpp"
|
||||
"transport/protocol.cpp"
|
||||
"server/wifi_ota.cpp"
|
||||
"server/ble_ota.cpp"
|
||||
"monitor/health_monitor.cpp"
|
||||
"monitor/debug_log.cpp"
|
||||
"core/main.cpp" # 程序入口 (app_main)
|
||||
"core/system_boot.cpp" # 启动模块位图与汇总
|
||||
"core/build_info.cpp" # 真实编译时间(每次构建自动刷新,见文末 gen_build_time)
|
||||
"ota/esp32_ota_core.cpp" # ESP32 本机 OTA 核心(分区写入/SHA验证)
|
||||
"ota/ota_coordinator.cpp" # OTA 多通道协调器(状态机/互斥)
|
||||
"relay/ota_relay.cpp" # GD32 SPI OTA 中继状态机
|
||||
"storage/gd32_flash_store.cpp" # GD32 Flash 存储抽象层(H1 提取)
|
||||
"server/wifi_ota.cpp" # WiFi HTTP OTA 服务器
|
||||
"server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取)
|
||||
"server/ble_ota.cpp" # BLE OTA 公开 API / 收尾 / 共存
|
||||
"server/ble_ota_data.cpp" # BLE OTA 数据路径(reorder/worker/DATA_IN)
|
||||
"server/ble_ota_host.cpp" # BLE OTA GATT/GAP/广播/Host
|
||||
"monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检)
|
||||
"monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务
|
||||
"monitor/can_feedback_task.cpp" # CAN 反馈被动监听(0xC1 takeEvent)+ 分发 0xC4
|
||||
"monitor/gd32_adc_feedback.cpp" # GD32 ADC 调试反馈缓存(0xC4)
|
||||
"monitor/motor_state.cpp" # GD32 多电机状态缓存(0xC5)
|
||||
"monitor/motor_version.cpp" # GD32 多电机版本缓存(0xC6,事件驱动)
|
||||
"monitor/motor_ota_relay.cpp" # 电机 OTA relay(0xD0~0xD2)
|
||||
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
|
||||
"monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子
|
||||
"monitor/spi_speed_test.cpp" # [SPI-SPEEDTEST] 临时:SPI 测速端点(可整行删除)
|
||||
INCLUDE_DIRS
|
||||
"."
|
||||
"core"
|
||||
"ota"
|
||||
"relay"
|
||||
"transport"
|
||||
"storage" # GD32 Flash 存储抽象层头文件
|
||||
"server"
|
||||
"monitor"
|
||||
"include"
|
||||
"include" # 公共头文件(app_config.h, version.h)
|
||||
REQUIRES
|
||||
app_update
|
||||
cjson
|
||||
esp_driver_spi
|
||||
esp_driver_tsens
|
||||
esp_driver_uart
|
||||
esp_wifi
|
||||
esp_netif
|
||||
esp_event
|
||||
esp_partition
|
||||
esp_timer
|
||||
esp_http_server
|
||||
esp_blockdev
|
||||
nvs_flash
|
||||
freertos
|
||||
mbedtls
|
||||
bt
|
||||
transport # M5: 传输层组件(SPI/CANFD/协议)
|
||||
app_update # ESP32 OTA API
|
||||
esp_driver_tsens # 温度传感器驱动
|
||||
esp_driver_uart # UART 驱动(调试日志)
|
||||
esp_wifi # WiFi STA/AP 模式
|
||||
esp_netif # 网络接口层
|
||||
esp_event # 事件循环
|
||||
esp_partition # Flash 分区操作
|
||||
esp_timer # 高精度定时器
|
||||
esp_http_server # HTTP OTA 服务器
|
||||
esp_blockdev # Flash 块设备
|
||||
nvs_flash # NVS 存储(OTA 元数据)
|
||||
freertos # RTOS 内核
|
||||
mbedtls # SHA-256 / AES 加密
|
||||
bt # NimBLE BLE 协议栈
|
||||
)
|
||||
|
||||
# ── 真实编译时间:每次构建重新生成 build_time_gen.h ──
|
||||
# 背景:version.h 的 BUILD_TIME 是手写死值(停在 2026-07-26 20:25),刷新固件后仍显示旧时间,
|
||||
# 被误判为 OTA 回退;__DATE__/__TIME__ 方案在增量编译下同样会过期。
|
||||
# 机制:gen_build_time 无产物依赖 → 每次构建必跑 → 重写头文件 → 仅 core/build_info.cpp 重编+重链。
|
||||
set(BUILD_TIME_HDR "${CMAKE_CURRENT_BINARY_DIR}/gen/build_time_gen.h")
|
||||
add_custom_target(gen_build_time
|
||||
COMMAND ${CMAKE_COMMAND} -D OUT=${BUILD_TIME_HDR} -P ${CMAKE_CURRENT_LIST_DIR}/gen_build_time.cmake
|
||||
BYPRODUCTS ${BUILD_TIME_HDR}
|
||||
COMMENT "Refreshing build_time_gen.h"
|
||||
)
|
||||
add_dependencies(${COMPONENT_LIB} gen_build_time)
|
||||
target_include_directories(${COMPONENT_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/gen")
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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_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. Packet pacing uses SPI Link v2
|
||||
CREDIT backpressure in SpiMaster (remoteCredit==0 → wait/
|
||||
refuse TX), not a fixed inter-packet delay. Lower value
|
||||
reduces poll idle time; increase if GD32 needs more time
|
||||
between polls.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* build_info.cpp - 唯一包含自动生成时间头的编译单元。
|
||||
* build_time_gen.h 每次构建都被 gen_build_time 目标重写 → 仅本文件重编 + 重链接,
|
||||
* 其余增量编译不受影响;其他模块经 fwBuildTime() 取值。
|
||||
*/
|
||||
#include "build_info.h"
|
||||
#include "build_time_gen.h"
|
||||
|
||||
const char *fwBuildTime(void)
|
||||
{
|
||||
return FW_BUILD_TIME_AUTO;
|
||||
}
|
||||
+115
-97
@@ -1,131 +1,149 @@
|
||||
/*
|
||||
* ESP32-C3FH4 OTA 中继 - 程序入口
|
||||
* 三个 FreeRTOS 任务:OTA Relay / WiFi Server / BLE GATT
|
||||
*
|
||||
* 初始化顺序(每位成功后 SystemBoot::mark=1,末尾统一 report):
|
||||
* 1. 冷启动延时
|
||||
* 2. netif / event_loop / debug_log
|
||||
* 3. wifi earlyInit
|
||||
* 4. health_mon + task
|
||||
* 5. cpu_stats(可选)
|
||||
* 6. protocol / ota_coord
|
||||
* 7. spi init + L0 本机自检 → mark spi;HELLO 对端轻检 → mark can_link(非关键)
|
||||
* 8. ota_relay / wifi_ota / ble_ota 任务
|
||||
* 9. can_fb 任务(依赖 spi L0)
|
||||
* 10. ota_mark_valid(可选)
|
||||
* 11. SystemBoot::report()
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_chip_info.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_ota_ops.h"
|
||||
|
||||
#include "system_boot.h"
|
||||
#include "monitor/health_monitor.h"
|
||||
#include "monitor/cpu_stats.h"
|
||||
#include "relay/ota_relay.h"
|
||||
#include "ota/ota_coordinator.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "server/wifi_ota.h"
|
||||
#include "server/ble_ota.h"
|
||||
#include "transport/protocol.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "app_config.h"
|
||||
#include "ota/esp32_ota_core.h"
|
||||
#include "monitor/can_feedback_task.h"
|
||||
#include "monitor/motor_ota_relay.h"
|
||||
|
||||
/** @brief 创建关键任务;成功返回 true。 */
|
||||
static bool createCriticalTask(TaskFunction_t entry, const char *name,
|
||||
uint32_t stack, UBaseType_t prio)
|
||||
{
|
||||
return xTaskCreatePinnedToCore(entry, name, stack, NULL, prio, NULL, 0) == pdPASS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。
|
||||
* @brief 程序主入口:按模块初始化并汇总结果。
|
||||
*/
|
||||
extern "C" void app_main(void)
|
||||
{
|
||||
esp_chip_info_t chip_info;
|
||||
esp_chip_info(&chip_info);
|
||||
SystemBoot::reset();
|
||||
|
||||
// 初始化网络接口 + 事件循环(必须最早)
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
|
||||
// 初始化调试日志(UART0 先就绪,UDP socket 延迟到首次发送时创建)
|
||||
debug_log_init();
|
||||
|
||||
// ★ WiFi 提前初始化(调度器启动前)
|
||||
// 包括:NVS → netif 创建 → event handler 注册 → WiFi driver init → 配置 → connect
|
||||
bool wifi_sta_ok = wifi_early_init();
|
||||
|
||||
health_monitor_init();
|
||||
DEBUG_INFO("MAIN", "健康监控状态已初始化");
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v1.0 启动");
|
||||
DEBUG_INFO("MAIN", "Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip_info.revision);
|
||||
DEBUG_INFO("MAIN", "Free heap: %lu bytes", esp_get_free_heap_size());
|
||||
DEBUG_INFO("MAIN", "CPU Freq: %d MHz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ);
|
||||
DEBUG_INFO("MAIN", "WiFi: %s", wifi_sta_ok ? "STA 连接中" : "将使用 AP 模式");
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
|
||||
// 健康监控任务(温度 + WiFi RSSI)
|
||||
BaseType_t xRet;
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
health_monitor_task,
|
||||
"health_mon",
|
||||
4096,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 4,
|
||||
NULL,
|
||||
0
|
||||
);
|
||||
if (xRet != pdPASS) {
|
||||
DEBUG_ERR("MAIN", "创建 health_monitor_task 失败");
|
||||
} else {
|
||||
DEBUG_INFO("MAIN", "健康监控任务已创建");
|
||||
/* 上电/欠压复位后短延时再初始化子系统 */
|
||||
{
|
||||
esp_reset_reason_t rr = esp_reset_reason();
|
||||
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
}
|
||||
}
|
||||
|
||||
protocol_init();
|
||||
DEBUG_INFO("MAIN", "协议层初始化完成");
|
||||
Esp32OtaCore::initMutex();
|
||||
|
||||
esp_err_t ret = spi_master_init();
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_ERR("MAIN", "SPI 总线初始化失败: %d", ret);
|
||||
} else {
|
||||
DEBUG_INFO("MAIN", "SPI 总线初始化完成");
|
||||
if (esp_netif_init() == ESP_OK) {
|
||||
SystemBoot::mark(BOOT_NETIF);
|
||||
}
|
||||
|
||||
// OTA 中继任务(中高优先级)
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
ota_relay_task,
|
||||
"ota_relay",
|
||||
8192,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 2,
|
||||
NULL,
|
||||
0 // 核0
|
||||
);
|
||||
if (xRet != pdPASS) {
|
||||
DEBUG_ERR("MAIN", "创建 ota_relay_task 失败");
|
||||
} else {
|
||||
DEBUG_INFO("MAIN", "OTA 中继任务已创建");
|
||||
if (esp_event_loop_create_default() == ESP_OK) {
|
||||
SystemBoot::mark(BOOT_EVENT_LOOP);
|
||||
}
|
||||
|
||||
// WiFi OTA 服务器任务
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
wifi_ota_task,
|
||||
"wifi_ota",
|
||||
8192,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 3,
|
||||
NULL,
|
||||
0
|
||||
);
|
||||
if (xRet != pdPASS) {
|
||||
DEBUG_ERR("MAIN", "创建 wifi_ota_task 失败");
|
||||
} else {
|
||||
DEBUG_INFO("MAIN", "WiFi OTA 任务已创建");
|
||||
DebugLog::instance().init();
|
||||
SystemBoot::mark(BOOT_DEBUG_LOG);
|
||||
|
||||
/* earlyInit:true=已启动 STA 连接流程;false=将走 AP */
|
||||
const bool wifi_early_started_sta = WifiOta::instance().earlyInit();
|
||||
SystemBoot::mark(BOOT_WIFI_EARLY);
|
||||
|
||||
HealthMonitor::instance().init();
|
||||
SystemBoot::mark(BOOT_HEALTH_MON);
|
||||
|
||||
if (createCriticalTask(HealthMonitor::taskEntry, "health_mon",
|
||||
TASK_STACK_HEALTH_MON, configMAX_PRIORITIES - 4)) {
|
||||
SystemBoot::mark(BOOT_HEALTH_TASK);
|
||||
}
|
||||
|
||||
// BLE OTA 任务
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
ble_ota_task,
|
||||
"ble_ota",
|
||||
8192,
|
||||
NULL,
|
||||
configMAX_PRIORITIES - 3,
|
||||
NULL,
|
||||
0 // ESP32-C3 单核,固定核0
|
||||
);
|
||||
if (xRet != pdPASS) {
|
||||
DEBUG_ERR("MAIN", "创建 ble_ota_task 失败");
|
||||
} else {
|
||||
DEBUG_INFO("MAIN", "BLE OTA 任务已创建");
|
||||
CpuStats::instance().start();
|
||||
SystemBoot::mark(BOOT_CPU_STATS);
|
||||
|
||||
Protocol::instance().init();
|
||||
SystemBoot::mark(BOOT_PROTOCOL);
|
||||
|
||||
OtaCoordinator::instance().init();
|
||||
OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance());
|
||||
SystemBoot::mark(BOOT_OTA_COORD);
|
||||
|
||||
bool spi_l0_ok = false;
|
||||
{
|
||||
esp_err_t ret = SpiMaster::instance().init();
|
||||
if (ret == ESP_OK && SpiMaster::instance().selfTestL0()) {
|
||||
SystemBoot::mark(BOOT_SPI);
|
||||
spi_l0_ok = true;
|
||||
/* 对端轻检:GD32 可能仍在启动延时,失败不拉垮关键启动 */
|
||||
if (SpiMaster::instance().selfTestPeerL0()) {
|
||||
SystemBoot::mark(BOOT_CAN_LINK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中");
|
||||
DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)",
|
||||
DEBUG_LOG_IP, DEBUG_LOG_PORT);
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
if (createCriticalTask(OtaRelay::taskEntry, "ota_relay",
|
||||
TASK_STACK_OTA_RELAY, configMAX_PRIORITIES - 2)) {
|
||||
SystemBoot::mark(BOOT_OTA_RELAY_TASK);
|
||||
}
|
||||
|
||||
if (createCriticalTask(WifiOta::taskEntry, "wifi_ota",
|
||||
TASK_STACK_WIFI_OTA, configMAX_PRIORITIES - 3)) {
|
||||
SystemBoot::mark(BOOT_WIFI_TASK);
|
||||
}
|
||||
|
||||
if (createCriticalTask(BleOta::taskEntry, "ble_ota",
|
||||
TASK_STACK_BLE_OTA, configMAX_PRIORITIES - 3)) {
|
||||
SystemBoot::mark(BOOT_BLE_TASK);
|
||||
}
|
||||
|
||||
/* CAN 反馈依赖 SPI 本机 L0 */
|
||||
if (spi_l0_ok && startCanFeedbackTask()) {
|
||||
SystemBoot::mark(BOOT_CAN_FB_TASK);
|
||||
}
|
||||
|
||||
motorOtaRelayInit();
|
||||
|
||||
if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) {
|
||||
SystemBoot::mark(BOOT_OTA_VALID);
|
||||
}
|
||||
|
||||
/* ★ banner 文案必须包含「启动」,前端 polling.js 靠
|
||||
* /OTA Relay .*启动/ 匹配本行判定 ESP32 重启完成(阶段 6)。 */
|
||||
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动, heap=%lu, wifi_early=%s",
|
||||
FW_VERSION,
|
||||
(unsigned long)esp_get_free_heap_size(),
|
||||
wifi_early_started_sta ? "STA" : "AP/other");
|
||||
|
||||
const bool critical_ok = SystemBoot::report();
|
||||
|
||||
if (!critical_ok) {
|
||||
DEBUG_ERR("MAIN", "critical init FAIL, restart in 3s");
|
||||
vTaskDelay(pdMS_TO_TICKS(3000));
|
||||
esp_restart();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file system_boot.cpp
|
||||
* @brief 启动模块位图实现。
|
||||
*/
|
||||
#include "system_boot.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
struct BootEntry {
|
||||
uint32_t bit;
|
||||
const char *name;
|
||||
bool critical;
|
||||
};
|
||||
|
||||
static const BootEntry kEntries[] = {
|
||||
{ BOOT_NETIF, "netif", true },
|
||||
{ BOOT_EVENT_LOOP, "event_loop", true },
|
||||
{ BOOT_DEBUG_LOG, "debug_log", true },
|
||||
{ BOOT_WIFI_EARLY, "wifi_early", true },
|
||||
{ BOOT_HEALTH_MON, "health_mon", true },
|
||||
{ BOOT_HEALTH_TASK, "health_task", true },
|
||||
{ BOOT_CPU_STATS, "cpu_stats", false },
|
||||
{ BOOT_PROTOCOL, "protocol", true },
|
||||
{ BOOT_OTA_COORD, "ota_coord", true },
|
||||
{ BOOT_SPI, "spi", true },
|
||||
{ BOOT_OTA_RELAY_TASK, "ota_relay_task", true },
|
||||
{ BOOT_WIFI_TASK, "wifi_task", true },
|
||||
{ BOOT_BLE_TASK, "ble_task", true },
|
||||
{ BOOT_CAN_LINK, "can_link", false },
|
||||
{ BOOT_CAN_FB_TASK, "can_fb_task", false },
|
||||
{ BOOT_OTA_VALID, "ota_mark_valid", false },
|
||||
};
|
||||
|
||||
static uint32_t s_mask = 0U;
|
||||
|
||||
static void evaluate(bool *critical_ok, bool *all_ok, char *fail_list, size_t fail_cap)
|
||||
{
|
||||
*critical_ok = true;
|
||||
*all_ok = true;
|
||||
if (fail_list && fail_cap > 0U) {
|
||||
fail_list[0] = '\0';
|
||||
}
|
||||
size_t fail_len = 0U;
|
||||
|
||||
for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) {
|
||||
const BootEntry &e = kEntries[i];
|
||||
const bool ok = (s_mask & e.bit) != 0U;
|
||||
if (ok) {
|
||||
continue;
|
||||
}
|
||||
*all_ok = false;
|
||||
if (e.critical) {
|
||||
*critical_ok = false;
|
||||
}
|
||||
if (!fail_list || fail_cap == 0U) {
|
||||
continue;
|
||||
}
|
||||
if (fail_len + 2U < fail_cap) {
|
||||
if (fail_len > 0U) {
|
||||
fail_list[fail_len++] = ',';
|
||||
}
|
||||
const char *p = e.name;
|
||||
while (*p != '\0' && fail_len + 1U < fail_cap) {
|
||||
fail_list[fail_len++] = *p++;
|
||||
}
|
||||
fail_list[fail_len] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SystemBoot::reset()
|
||||
{
|
||||
s_mask = 0U;
|
||||
}
|
||||
|
||||
void SystemBoot::mark(SystemBootBit bit)
|
||||
{
|
||||
s_mask |= static_cast<uint32_t>(bit);
|
||||
}
|
||||
|
||||
bool SystemBoot::report()
|
||||
{
|
||||
bool critical_ok = true;
|
||||
bool all_ok = true;
|
||||
char fail_list[192] = {0};
|
||||
evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list));
|
||||
|
||||
if (critical_ok && all_ok) {
|
||||
DEBUG_INFO("BOOT", "init: OK (all modules)");
|
||||
} else if (critical_ok) {
|
||||
DEBUG_WARN("BOOT", "init: OK (critical), skipped/failed: %s",
|
||||
fail_list[0] != '\0' ? fail_list : "?");
|
||||
} else {
|
||||
DEBUG_ERR("BOOT", "init: FAIL, modules: %s",
|
||||
fail_list[0] != '\0' ? fail_list : "?");
|
||||
}
|
||||
return critical_ok;
|
||||
}
|
||||
|
||||
int SystemBoot::appendStatusJson(char *buf, size_t cap, int offset)
|
||||
{
|
||||
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) {
|
||||
return offset;
|
||||
}
|
||||
|
||||
bool critical_ok = true;
|
||||
bool all_ok = true;
|
||||
char fail_list[192] = {0};
|
||||
evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list));
|
||||
|
||||
const char *summary;
|
||||
if (critical_ok && all_ok) {
|
||||
summary = "OK (all modules)";
|
||||
} else if (critical_ok) {
|
||||
summary = "OK (critical)";
|
||||
} else {
|
||||
summary = "FAIL";
|
||||
}
|
||||
|
||||
int n = snprintf(buf + offset, cap - (size_t)offset,
|
||||
",\"boot\":{\"critical_ok\":%s,\"all_ok\":%s,\"mask\":%lu,"
|
||||
"\"summary\":\"%s\",\"failed\":\"%s\",\"modules\":[",
|
||||
critical_ok ? "true" : "false",
|
||||
all_ok ? "true" : "false",
|
||||
(unsigned long)s_mask,
|
||||
summary,
|
||||
fail_list);
|
||||
if (n < 0) {
|
||||
return offset;
|
||||
}
|
||||
if (offset + n >= (int)cap) {
|
||||
buf[cap - 1] = '\0';
|
||||
return (int)(cap - 1);
|
||||
}
|
||||
offset += n;
|
||||
|
||||
for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) {
|
||||
const BootEntry &e = kEntries[i];
|
||||
const bool ok = (s_mask & e.bit) != 0U;
|
||||
n = snprintf(buf + offset, cap - (size_t)offset,
|
||||
"%s{\"name\":\"%s\",\"ok\":%s,\"critical\":%s}",
|
||||
i == 0 ? "" : ",",
|
||||
e.name,
|
||||
ok ? "true" : "false",
|
||||
e.critical ? "true" : "false");
|
||||
if (n < 0) {
|
||||
return offset;
|
||||
}
|
||||
if (offset + n >= (int)cap) {
|
||||
buf[cap - 1] = '\0';
|
||||
return (int)(cap - 1);
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
|
||||
n = snprintf(buf + offset, cap - (size_t)offset, "]}");
|
||||
if (n < 0) {
|
||||
return offset;
|
||||
}
|
||||
if (offset + n >= (int)cap) {
|
||||
buf[cap - 1] = '\0';
|
||||
return (int)(cap - 1);
|
||||
}
|
||||
return offset + n;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file system_boot.h
|
||||
* @brief ESP32 启动阶段模块初始化位图与汇总报告。
|
||||
*
|
||||
* 每个模块 init/建任务成功后 mark(bit)=1,启动末尾 report() 打印整体结果。
|
||||
* /system/status 通过 appendStatusJson() 把同一份结果暴露给网页。
|
||||
*/
|
||||
#ifndef SYSTEM_BOOT_H
|
||||
#define SYSTEM_BOOT_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/** 启动模块位(成功置 1) */
|
||||
enum SystemBootBit : uint32_t {
|
||||
BOOT_NETIF = 1u << 0,
|
||||
BOOT_EVENT_LOOP = 1u << 1,
|
||||
BOOT_DEBUG_LOG = 1u << 2,
|
||||
BOOT_WIFI_EARLY = 1u << 3,
|
||||
BOOT_HEALTH_MON = 1u << 4,
|
||||
BOOT_HEALTH_TASK = 1u << 5,
|
||||
BOOT_CPU_STATS = 1u << 6,
|
||||
BOOT_PROTOCOL = 1u << 7,
|
||||
BOOT_OTA_COORD = 1u << 8,
|
||||
BOOT_SPI = 1u << 9, ///< SPI 主机 L0(本机就绪)
|
||||
BOOT_OTA_RELAY_TASK = 1u << 10,
|
||||
BOOT_WIFI_TASK = 1u << 11,
|
||||
BOOT_BLE_TASK = 1u << 12,
|
||||
BOOT_CAN_LINK = 1u << 13, ///< SPI 对端 L0(HELLO 可达 GD32,非关键)
|
||||
BOOT_CAN_FB_TASK = 1u << 14, ///< CAN 反馈任务(依赖 SPI L0)
|
||||
BOOT_OTA_VALID = 1u << 15,
|
||||
};
|
||||
|
||||
class SystemBoot {
|
||||
public:
|
||||
static void reset();
|
||||
static void mark(SystemBootBit bit);
|
||||
|
||||
/** 串口汇总;返回关键模块是否全部成功 */
|
||||
static bool report();
|
||||
|
||||
/**
|
||||
* @brief 追加 boot JSON 字段(含前导逗号)到 status 缓冲。
|
||||
* @return 新的写入偏移
|
||||
*/
|
||||
static int appendStatusJson(char *buf, size_t cap, int offset);
|
||||
};
|
||||
|
||||
#endif /* SYSTEM_BOOT_H */
|
||||
@@ -0,0 +1,8 @@
|
||||
# 每次构建执行:生成真实编译时间头文件(由 main/CMakeLists.txt 的 gen_build_time 目标调用)。
|
||||
# 手工维护的 version.h BUILD_TIME 曾停留在旧值,导致「刷了新固件仍显示旧时间」被误判为回退。
|
||||
string(TIMESTAMP _build_time "%Y-%m-%d %H:%M:%S")
|
||||
file(WRITE "${OUT}"
|
||||
"/* 自动生成(每次构建刷新),勿手改、勿提交。仅 core/build_info.cpp 包含本文件。 */
|
||||
#pragma once
|
||||
#define FW_BUILD_TIME_AUTO \"${_build_time}\"
|
||||
")
|
||||
+119
-66
@@ -1,97 +1,158 @@
|
||||
/*
|
||||
/*
|
||||
* app_config.h - 统一配置文件(C++ 版本)
|
||||
* ESP32-C3 OTA 中继项目
|
||||
*
|
||||
* 使用 constexpr 代替 #define,提供类型安全
|
||||
* 版本号和编译时间在 version.h 中手动维护
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "version.h"
|
||||
#include "build_info.h"
|
||||
|
||||
// ============================================================
|
||||
// 固件版本
|
||||
// 功能开关(ESP32 本机;GD32 侧开关随 HELLO/PING 身份块上报,无需镜像)
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = "0.2.10";
|
||||
|
||||
// ============================================================
|
||||
// WiFi 工作模式选择
|
||||
// ============================================================
|
||||
// 0 = AP 模式(设备当热点)
|
||||
// 1 = STA 模式(连接路由器)
|
||||
// 0 = AP 热点,1 = STA 连路由器
|
||||
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
|
||||
|
||||
// ============================================================
|
||||
// 固件版本(在 version.h 中手动维护,此处提供 C++ constexpr 访问)
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = FW_VERSION_STRING; ///< 固件版本字符串(手动维护)
|
||||
/* FW_BUILD_TIME 已删:手写死值停在旧时间,刷新固件仍显示 20:25 被误判回退。
|
||||
* 改用 fwBuildTime()(build_info.h,构建系统每次自动生成真实编译时间)。 */
|
||||
inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(手动维护)
|
||||
|
||||
// ============================================================
|
||||
// WiFi STA 模式配置(连接路由器)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_STA_SSID = "004舰桥";
|
||||
inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622";
|
||||
inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000;
|
||||
inline constexpr const char* WIFI_STA_SSID = "004舰桥"; ///< STA 模式目标 WiFi 名称
|
||||
inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622"; ///< STA 模式 WiFi 密码
|
||||
inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000; ///< STA 连接超时(毫秒)
|
||||
inline constexpr uint8_t WIFI_STA_MAX_RETRY = 5; // 最大重连次数,超过后切换 AP 模式
|
||||
|
||||
// STA 静态 IP
|
||||
inline constexpr uint8_t WIFI_STA_USE_STATIC_IP = 0; // 先关闭静态 IP,使用 DHCP 排查连接问题
|
||||
|
||||
// 静态 IP 配置(始终定义,由 WIFI_STA_USE_STATIC_IP 在运行时决定是否使用)
|
||||
inline constexpr const char* WIFI_STA_STATIC_IP = "192.168.31.32";
|
||||
inline constexpr const char* WIFI_STA_STATIC_GW = "192.168.31.1";
|
||||
inline constexpr const char* WIFI_STA_STATIC_NETMASK = "255.255.255.0";
|
||||
inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.31.1";
|
||||
|
||||
// ============================================================
|
||||
// WiFi AP 模式配置(设备当热点)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_AP_SSID = "ESP32_OTA_RELAY";
|
||||
inline constexpr const char* WIFI_AP_PASSWORD = "12345678";
|
||||
inline constexpr uint8_t WIFI_AP_CHANNEL = 6;
|
||||
inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4;
|
||||
inline constexpr const char* WIFI_AP_SSID = "ESP32_OTA_RELAY"; ///< AP 模式热点名称
|
||||
inline constexpr const char* WIFI_AP_PASSWORD = "12345678"; ///< AP 模式热点密码
|
||||
inline constexpr uint8_t WIFI_AP_CHANNEL = 6; ///< AP 模式工作信道
|
||||
inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; ///< AP 模式最大连接数
|
||||
|
||||
// ============================================================
|
||||
// HTTP 服务器配置
|
||||
// ============================================================
|
||||
inline constexpr uint16_t HTTP_SERVER_PORT = 80;
|
||||
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192;
|
||||
inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服务器监听端口
|
||||
|
||||
// ============================================================
|
||||
// 任务栈大小(字节)— 2026-07 按板端 task_stack 水位收敛
|
||||
// 规则:S ≈ peak_used + 1.5~2KB;偏松的收、偏紧的不砍。
|
||||
// 板测峰值量级(常规/OTA):
|
||||
// http~5.2KB, spi~1.9KB, wifi_ota~1.2KB, ble_ota~0.9KB,
|
||||
// ble_worker 1004B, health/cpu~1.8KB, ota_relay 轻载~1.3KB。
|
||||
// 2026-07 余量优化:收 http/spi/wifi/ble* 偏松项,health/cpu/relay 保持。
|
||||
// ============================================================
|
||||
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 7168; ///< httpd:peak~5.2KB + ~2KB → 7KB(原 8/10KB)
|
||||
inline constexpr uint32_t TASK_STACK_WIFI_OTA = 3072; ///< wifi_ota:peak~1.2KB + ~1.8KB → 3KB(原 4/8KB)
|
||||
inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(轻载 peak~1.3KB;转发突发不砍)
|
||||
inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:5KB(覆盖超时重试+日志深层调用栈安全余量)
|
||||
inline constexpr uint32_t TASK_STACK_BLE_OTA = 2560; ///< ble_ota:peak~0.9KB + ~1.5KB → 2.5KB(原 3KB)
|
||||
inline constexpr uint32_t TASK_STACK_BLE_WORKER = 2560; ///< ble_ota_worker:peak 1004B + ~1.5KB → 2.5KB(原 3KB)
|
||||
inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.8KB,余量~1.2KB,不砍
|
||||
inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~1.8KB,余量~1.2KB,不砍
|
||||
inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:轻载,2.5KB 保持
|
||||
inline constexpr uint32_t TASK_STACK_CAN_FB = 4096; ///< can_fb:4KB(覆盖 0xC4/0xC5 周期查询超时与日志调用栈)
|
||||
inline constexpr uint32_t TASK_STACK_BLE_FINISH = 4096; ///< 按需:SHA finalize(保持)
|
||||
inline constexpr uint32_t TASK_STACK_BLE_GD32_WAIT = 3072; ///< 按需:GD32 中继等待
|
||||
inline constexpr uint32_t TASK_STACK_BLE_RESTART = 2048; ///< 按需:延时重启
|
||||
|
||||
// ============================================================
|
||||
// OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 8192;
|
||||
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB,对齐 GD32 APP_MAX_SIZE (0x36000)
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< 上传接收块:4KB(吞吐优先);会话内 malloc,稳态不占 .bss
|
||||
// OTA_BLOCK_SIZE 演进:8KB→2KB(压堆)→4KB(试吞吐)→2KB(2026-07 回退)→4KB(2026-07-20 再提吞吐)。
|
||||
// 2026-09:s_ota_upload_recv 从常驻静态改为 OTA 会话内 RAII malloc,稳态省 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 int32_t WIFI_RSSI_CRITICAL = -85;
|
||||
inline constexpr int32_t WIFI_RSSI_WARNING = -75;
|
||||
inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; // OTA 期间更宽松,避免瞬时波动误判
|
||||
inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; // OTA 最多接受 15 秒内的 RSSI 样本
|
||||
inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; // 每 32 个 48B 包做一次 OTA 健康检查
|
||||
inline constexpr int32_t TEMP_WARNING = 80;
|
||||
inline constexpr int32_t TEMP_CRITICAL = 100;
|
||||
inline constexpr size_t HEAP_WARNING_MIN = 10240;
|
||||
inline constexpr int32_t WIFI_RSSI_CRITICAL = -85; ///< WiFi 信号严重警告阈值(dBm)
|
||||
inline constexpr int32_t WIFI_RSSI_WARNING = -80; ///< WiFi 信号一般警告阈值(dBm)
|
||||
inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; ///< OTA 传输期间 WiFi 信号临界阈值
|
||||
inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; ///< RSSI 采样最大有效期(毫秒)
|
||||
inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; ///< 每 N 包执行一次健康检查
|
||||
inline constexpr int32_t TEMP_WARNING = 80; ///< 芯片温度警告阈值(℃)
|
||||
inline constexpr int32_t TEMP_CRITICAL = 100; ///< 芯片温度严重警告阈值(℃)
|
||||
inline constexpr size_t HEAP_WARNING_MIN = 10240; ///< 堆内存警告阈值(字节)
|
||||
|
||||
// ============================================================
|
||||
// 调试配置(UDP 日志)
|
||||
// GD32 ADC/CAN 等开关:看 HELLO_ACK/PING 身份块 feat 位(GD32 config 顶部)
|
||||
// ============================================================
|
||||
inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; // 设为 0 禁用调试日志
|
||||
inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; // 上位机 IP
|
||||
inline constexpr uint16_t DEBUG_LOG_PORT = 6666;
|
||||
inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; ///< UDP 日志目标 IP
|
||||
inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口
|
||||
|
||||
// ============================================================
|
||||
// SPI 主机(ESP32 ↔ GD32)可调参数
|
||||
// 协议帧格式见 spi_link_v2.h;引脚与超时集中在此便于板级调整
|
||||
// ============================================================
|
||||
inline constexpr int SPI_MASTER_CS_GPIO = 4; ///< CS 片选 GPIO
|
||||
inline constexpr int SPI_READY_GPIO = 10; ///< DATA_READY 通知线 GPIO(↔ GD32 PB6)
|
||||
inline constexpr int SPI_MODE = 0; ///< CPOL=0 CPHA=0
|
||||
inline constexpr int SPI_QUEUE_SIZE = 4; ///< SPI 事务队列深度
|
||||
inline constexpr size_t SPI_OTA_FRAME_MAX = 256; ///< 单 CS 最大帧长(APP 满缓冲)
|
||||
inline constexpr size_t SPI_BOOT_TX_BUF_SIZE = 128; ///< BOOT TX DMA 满缓冲(读预算用)
|
||||
inline constexpr size_t SPI_APP_TX_BUF_SIZE = 256; ///< APP TX DMA 满缓冲(读预算用)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// SPI 调试日志级别(编译期裁剪,改完重编生效;用法见 spi_master.cpp 顶部门)
|
||||
// 3 = 全开(逐事务 DBG + INFO 明细,SPI 联调用)
|
||||
// 2 = 只留 ERR/WARN(★默认:真实故障仍可见,心跳/HELLO/INIT_STATUS 明细静默)
|
||||
// 1 = 只留 ERR 0 = 全静默
|
||||
// GD32 侧对应开关:DCU_APP/GD32E508_DCU_APP/sys/config.h
|
||||
// CONFIG_SPI_APP_LOG_ENABLE / CONFIG_SPI_DIAG_PERIODIC_PRINT
|
||||
// ------------------------------------------------------------
|
||||
#define CONFIG_SPI_DEBUG_LOG_LEVEL 2
|
||||
|
||||
// ============================================================
|
||||
// OTA 协调器
|
||||
// ============================================================
|
||||
inline constexpr uint32_t OTA_COORD_LOCK_TIMEOUT_MS = 100; ///< 协调器 mutex 超时(ms)
|
||||
/** 忙状态自愈:须 > 中继转发预算上限(约 120s)+ 握手/SIZE 余量。 */
|
||||
inline constexpr uint32_t OTA_COORD_BUSY_WATCHDOG_MS = 150000;
|
||||
|
||||
// ============================================================
|
||||
// BLE OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr const char* BLE_DEVICE_NAME = "ESP32-OTA-RELAY";
|
||||
inline constexpr uint16_t BLE_MTU_SIZE = 512;
|
||||
inline constexpr uint16_t BLE_OTA_SERVICE_UUID = 0xFFFF;
|
||||
inline constexpr uint16_t BLE_OTA_CTRL_UUID = 0x0001;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_IN_UUID = 0x0002;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003;
|
||||
inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004;
|
||||
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_GD32 = 0xF7;
|
||||
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_ESP32 = 0xF8;
|
||||
inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00;
|
||||
inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01;
|
||||
inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; // 默认目标 payload;ATT Write Command 可写值≈(MTU-3),当前 512 MTU 下协议 payload 建议 ≤ 498B
|
||||
inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; // cmd(1)+seq(2)+size(4)+crc(4)
|
||||
inline constexpr const char* BLE_DEVICE_NAME = "ESP32-OTA-RELAY"; ///< BLE 广播设备名
|
||||
inline constexpr uint16_t BLE_MTU_SIZE = 512; ///< BLE MTU 协商大小
|
||||
inline constexpr uint16_t BLE_OTA_SERVICE_UUID = 0xFFFF; ///< OTA GATT 服务 UUID
|
||||
inline constexpr uint16_t BLE_OTA_CTRL_UUID = 0x0001; ///< OTA 控制特征 UUID(写入命令)
|
||||
inline constexpr uint16_t BLE_OTA_DATA_IN_UUID = 0x0002; ///< OTA 数据输入特征 UUID(写固件包)
|
||||
inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003; ///< OTA 数据输出特征 UUID(通知/ACK)
|
||||
inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004; ///< OTA 版本信息特征 UUID(只读)
|
||||
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_GD32 = 0xF7; ///< BLE 控制命令:触发 GD32 中继 OTA
|
||||
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_ESP32 = 0xF8; ///< BLE 控制命令:触发 ESP32 本机 OTA
|
||||
inline constexpr uint8_t BLE_OTA_CTRL_VERIFY_SHA256 = 0xF9; ///< BLE 控制命令:SHA-256 哈希验证
|
||||
inline constexpr uint8_t BLE_OTA_CTRL_RESTART_ESP32 = 0xFA; ///< BLE 控制命令:重启 ESP32
|
||||
inline constexpr uint8_t BLE_OTA_CTRL_ABORT_OTA = 0xFB; ///< BLE 控制命令:中止当前 OTA(不断开 BLE)
|
||||
inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; ///< BLE 数据通道:GD32 固件包标识
|
||||
inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; ///< BLE 数据通道:ESP32 固件包标识
|
||||
inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; ///< BLE 单包数据载荷大小(字节)
|
||||
// 滑动 ACK 窗口(须与前端 tools/js/ble/constants.js BLE_OTA_ACK_WINDOW 保持一致)
|
||||
// ★ 64:实测 Windows WebBT 窗口内单包间隔 0.4-2.9ms(2M PHY 多包/事件),
|
||||
// 瓶颈在每窗口的 ACK 往返。窗口 32→64 提速 ~10%(40→44 KB/s),再试 96 无收益
|
||||
// (ACK 往返在 64 已摊薄,稳态瓶颈是 Windows GATT 每包 ~10.6ms resolve 延迟)。
|
||||
// 内存:64×480B ≈ 30KB reorder buffer,分配失败有 fallback 丢弃策略兜底。
|
||||
inline constexpr size_t BLE_OTA_ACK_WINDOW = 64;
|
||||
// Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW)
|
||||
inline constexpr size_t BLE_OTA_REORDER_WINDOW = 64;
|
||||
// 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批
|
||||
inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 16);
|
||||
|
||||
// ============================================================
|
||||
// 安全常量(需与 GD32 固件团队确认)
|
||||
@@ -102,17 +163,9 @@ inline constexpr uint8_t MASTER_KEY[] = {
|
||||
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10
|
||||
};
|
||||
|
||||
inline constexpr uint8_t SCRAMBLE_CONST[] = {
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
|
||||
};
|
||||
// SCRAMBLE_CONST 已移至 Protocol 类,运行时由硬件 RNG 随机生成
|
||||
inline constexpr size_t SCRAMBLE_CONST_LEN = 16;
|
||||
|
||||
// GD32_CHIP_UID 仅保留为默认占位,运行时以握手包解出的芯片地址 UID 为准。
|
||||
inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U;
|
||||
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU;
|
||||
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U;
|
||||
|
||||
inline constexpr uint8_t GD32_CHIP_UID[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
|
||||
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
|
||||
};
|
||||
inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U; ///< GD32 设备序列号低字 Flash 地址
|
||||
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU; ///< GD32 设备序列号中字 Flash 地址
|
||||
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U; ///< GD32 设备序列号高字 Flash 地址
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* build_info.h - 真实编译时间(构建系统每次自动生成,替代 version.h 手工 BUILD_TIME)
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** 返回本固件的真实编译时间字符串 "YYYY-MM-DD HH:MM:SS"(每次构建自动刷新)。 */
|
||||
const char *fwBuildTime(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* version.h - 固件版本信息(手动维护)
|
||||
*
|
||||
* 注意:本文件不再由构建脚本自动生成。如需修改版本号或 Git Hash,
|
||||
* 请直接编辑本文件。
|
||||
* - FW_VERSION_MAJOR / MINOR / PATCH:发版时手动递增
|
||||
* - GIT_HASH:手动填写对应提交的 git short hash
|
||||
* - 编译时间:已改为构建系统自动生成(build_info.h 的 fwBuildTime(),
|
||||
* 每次构建刷新),手写 BUILD_TIME 已删——旧值停在 2026-07-26 20:25
|
||||
* 曾被误判为 OTA 回退。
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 5
|
||||
#define FW_VERSION_PATCH 2
|
||||
#define FW_VERSION_STRING "0.5.2" // 0.5.2 CAN 通道收发器 Silent(只听不发):新增 SPI 0xC8 桥接 canFdSetTxSilent + POST /can/silent,0xC1 bus_state 增 bit5=tx_silent;静默通道发送回 0xE6。0.5.1 SPI 调试日志编译期门:app_config.h 新增 CONFIG_SPI_DEBUG_LOG_LEVEL=2,spi_master.cpp 的 84 条 DEBUG_*("SPI") 中 DBG/INFO 被裁掉(心跳/HELLO/INIT_STATUS 明细静默),ERR/WARN 保留 // 内存:s_ota_upload_recv 从常驻静态 4KB 改为 OTA 会话内 RAII malloc,稳态省 4KB 内部 DRAM(httpd 单线程+协调器互斥,结束/失败自动 free)。0.4.9 tx_total/tx_retry/tx_fail/tx_fail_last_type 计数进 /system/status:透明重试(kTxRetryMax=3)掩盖真实失败率,DBG 日志被 CONFIG_LOG_MAXIMUM_LEVEL=3 编译期裁掉→"默默失败无打印"。0.4.8 ring_steal_diag=[rxLen,rb0,rb1] 撕裂形态签名 // 0.4.5 CS 事务间最小间隔 1ms(transferClockedFlags 单点):GD32 CS EXTI re-arm 被 ECAT/CAN 中断延迟时,ESP32 背靠背 CS 撞进 re-arm 窗口使 RX DMA mid-pulse 重装→查询帧撕裂→0x82 0xE8→迟到偷读风暴。0.4.4 快速响应类型禁用盲读。0.4.3 0x82 量化归因插桩。0.4.2 serviceRing 偷读迟到 RESPONSE 的 seq 失配修复:poll 超时放弃后按铃读把响应当 EVENT 偷走且不推进 m_txSeq→下一条命令复用旧序号被 GD32 判重吞掉回放缓存旧 0x82(命令不再执行根因)。0.3.85 INIT_STATUS fault_codes hex;0.3.84 模块级故障码;0.3.83 GD32 APP 初始化状态可视;0.3.82 UART0 RX 泵→/logs;0.3.81 GD32 OTA 包级防呆
|
||||
|
||||
// Git 信息(手动填写)
|
||||
#define GIT_HASH "770ae14"
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* can_feedback_task.cpp - GD32 周期查询 + 事件消费任务
|
||||
*
|
||||
* 架构(2026-08 改造后):
|
||||
* 0xC1 CAN 反馈:请求驱动(HTTP 侧 canFdQueryFeedback),本任务不处理。
|
||||
* 0xC4 ADC / 0xC5 电机状态:由 GD32 主动按铃推送改为本任务周期查询——
|
||||
* 0xC5 每 250ms 查 MOTOR_TOTAL_PAGES 页,0xC4 每 1s 查 1 帧,查询响应
|
||||
* payload 格式与推送一致,复用 onMotorStateEvent()/onGd32AdcFeedbackEvent()
|
||||
* 解析入库。消除 OTA 期间 GD32 悬空按铃(ESP32 抑制 ring 不读)导致
|
||||
* “按铃超时取消”刷屏。
|
||||
* 0xC6 电机版本 / 0xD2 电机 OTA 进度:仍由 GD32 事件驱动推送,本任务 takeEvent 消费。
|
||||
*
|
||||
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务(不查询不消费);
|
||||
* 电机 OTA 期间(motorOtaIsActive())也暂停,避免与 0xD1 喂数抢占 SPI。
|
||||
*/
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
#include "app_config.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "monitor/can_feedback_task.h"
|
||||
#include "monitor/gd32_adc_feedback.h"
|
||||
#include "monitor/motor_state.h"
|
||||
#include "monitor/motor_version.h"
|
||||
#include "monitor/motor_ota_relay.h"
|
||||
#include "esp_system.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kQueryMotorStateMs = 250U; ///< 0xC5 电机状态查询周期
|
||||
constexpr uint32_t kQueryAdcMs = 1000U; ///< 0xC4 ADC 查询周期
|
||||
constexpr uint32_t kEventWaitMs = 20U; ///< 单次等 0xC6/0xD2 事件超时
|
||||
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
|
||||
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
|
||||
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态查询
|
||||
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
|
||||
constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送
|
||||
constexpr size_t kPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
|
||||
constexpr const char *kTag = "CAN_FB";
|
||||
|
||||
static volatile bool s_paused = false;
|
||||
static TaskHandle_t s_taskHandle = nullptr;
|
||||
|
||||
/* 任务独占静态响应缓冲(消除每次在任务栈上分配 256B 大数组,防栈溢出) */
|
||||
static uint8_t s_motor_resp_buf[kPayloadMax] = {};
|
||||
static uint8_t s_adc_resp_buf[kPayloadMax] = {};
|
||||
static uint8_t s_push_ring_buf[kPayloadMax] = {};
|
||||
|
||||
/**
|
||||
* @brief 按页查询 GD32 电机状态(0xC5 请求驱动,替代 GD32 250ms 推送)。
|
||||
* 任一页失败立即中断本轮:GD32 未就绪/瞬时错误时不空耗 6×200ms,下轮再试;
|
||||
* page0 会刷新全局字段(active_count/boot_active),部分页成功即部分更新。
|
||||
*/
|
||||
static bool queryMotorStatePages(void)
|
||||
{
|
||||
uint8_t req[1] = {0U};
|
||||
for (uint8_t page = 0U; page < MOTOR_TOTAL_PAGES; ++page) {
|
||||
req[0] = page;
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
esp_err_t err = SpiMaster::instance().transact(
|
||||
kSpiMotorStateType, req, sizeof(req),
|
||||
&resp_type, s_motor_resp_buf, sizeof(s_motor_resp_buf), &resp_len, 200U);
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK ||
|
||||
resp_len < MOTOR_PAGE_HEADER) {
|
||||
return false;
|
||||
}
|
||||
(void)onMotorStateEvent(s_motor_resp_buf, resp_len);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 查询 GD32 ADC 调试快照(0xC4 请求驱动,替代 GD32 1Hz 推送)。
|
||||
* GD32 0xC4 handler 回自身类型 0xC4(异于 0xC1/0xC5 的 0x80),两者皆接受。
|
||||
*/
|
||||
static bool queryAdcFeedback(void)
|
||||
{
|
||||
uint8_t req[1] = {0U};
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
esp_err_t err = SpiMaster::instance().transact(
|
||||
kSpiAdcFeedbackType, req, sizeof(req),
|
||||
&resp_type, s_adc_resp_buf, sizeof(s_adc_resp_buf), &resp_len, 200U);
|
||||
if (err != ESP_OK || resp_len < 1U) {
|
||||
return false;
|
||||
}
|
||||
if (resp_type != kSpiAdcFeedbackType && resp_type != SPI_OTA_RSP_ACK) {
|
||||
return false;
|
||||
}
|
||||
(void)onGd32AdcFeedbackEvent(s_adc_resp_buf, resp_len);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 消费剩余事件驱动推送(0xC6 电机版本 / 0xD2 电机 OTA 进度)。
|
||||
* 0xC4/0xC5 已改请求驱动,不再有对应推送;未知类型静默跳过。
|
||||
*/
|
||||
static void drainPushEvents(uint32_t wait_ms)
|
||||
{
|
||||
uint8_t ring_type = 0U;
|
||||
size_t ring_len = 0U;
|
||||
if (!SpiMaster::instance().takeEvent(&ring_type, s_push_ring_buf,
|
||||
sizeof(s_push_ring_buf), &ring_len,
|
||||
wait_ms)) {
|
||||
return;
|
||||
}
|
||||
if (ring_type == kSpiMotorVersionType) {
|
||||
(void)onMotorVersionEvent(s_push_ring_buf, ring_len);
|
||||
} else if (ring_type == kSpiMotorOtaStatusType) {
|
||||
onMotorOtaStatusPush(s_push_ring_buf, ring_len);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief can_fb 任务主循环:周期查询 0xC5/0xC4 + 消费 0xC6/0xD2 推送。
|
||||
* OTA / 电机 OTA 期间暂停,SPI 交由 OTA 独占。
|
||||
*/
|
||||
static void taskLoop(void *)
|
||||
{
|
||||
DEBUG_INFO(kTag, "请求驱动查询任务启动 (0xC5 250ms / 0xC4 1s / 0xC6,0xD2 推送)");
|
||||
|
||||
/* 冷启动延后开始查询,错开上电初始化窗口 */
|
||||
{
|
||||
esp_reset_reason_t rr = esp_reset_reason();
|
||||
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
|
||||
constexpr uint32_t kCanFbDeferMs = 1500U;
|
||||
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始查询",
|
||||
(unsigned long)kCanFbDeferMs);
|
||||
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
|
||||
}
|
||||
}
|
||||
|
||||
TickType_t last_motor_q = xTaskGetTickCount();
|
||||
TickType_t last_adc_q = xTaskGetTickCount();
|
||||
|
||||
for (;;) {
|
||||
if (s_paused || motorOtaIsActive()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(kPauseDelayMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 链路在线自适应退避:若 SPI 处于离线状态,进入休眠退避,避免死循环重试和日志风暴 */
|
||||
if (!SpiMaster::instance().isLinkOnline()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
/* 尝试轻量查询一次作为探活 */
|
||||
(void)queryMotorStatePages();
|
||||
last_motor_q = xTaskGetTickCount();
|
||||
last_adc_q = xTaskGetTickCount();
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 周期查询:到期才执行(时间差对比,暂停恢复后立即补一次) */
|
||||
const TickType_t now = xTaskGetTickCount();
|
||||
if ((now - last_motor_q) >= pdMS_TO_TICKS(kQueryMotorStateMs)) {
|
||||
queryMotorStatePages();
|
||||
last_motor_q = xTaskGetTickCount();
|
||||
}
|
||||
if ((now - last_adc_q) >= pdMS_TO_TICKS(kQueryAdcMs)) {
|
||||
queryAdcFeedback();
|
||||
last_adc_q = xTaskGetTickCount();
|
||||
}
|
||||
|
||||
/* 消费 0xC6/0xD2:短阻塞等 worker 入队(事件驱动唤醒) */
|
||||
drainPushEvents(kEventWaitMs);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TaskHandle_t getCanFeedbackTaskHandle()
|
||||
{
|
||||
return s_taskHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 启动事件监听任务(幂等)。
|
||||
* @return true 表示任务已存在或创建成功。
|
||||
*/
|
||||
bool startCanFeedbackTask()
|
||||
{
|
||||
static bool s_started = false;
|
||||
if (s_started) return true;
|
||||
|
||||
BaseType_t xRet = xTaskCreatePinnedToCore(
|
||||
taskLoop,
|
||||
"can_fb",
|
||||
TASK_STACK_CAN_FB,
|
||||
nullptr,
|
||||
4,
|
||||
&s_taskHandle,
|
||||
0);
|
||||
if (xRet != pdPASS) {
|
||||
DEBUG_ERR(kTag, "任务创建失败");
|
||||
return false;
|
||||
}
|
||||
s_started = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 暂停/恢复周期查询与事件消费。
|
||||
* @param pause true=OTA 期间暂停;false=恢复
|
||||
*/
|
||||
void pauseCanFeedbackTask(bool pause)
|
||||
{
|
||||
if (s_paused == pause) return; // 幂等:状态未变时不重复日志
|
||||
s_paused = pause;
|
||||
if (pause) {
|
||||
DEBUG_DBG(kTag, "周期查询已暂停(OTA 进行中)");
|
||||
} else {
|
||||
DEBUG_DBG(kTag, "周期查询已恢复");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 查询是否处于暂停(OTA / send_batch 期间为 true)。
|
||||
* 暂停期间 HTTP handler 应跳过 0xC1 查询,避免与 OTA 抢占 SPI。
|
||||
*/
|
||||
bool canFeedbackPaused()
|
||||
{
|
||||
return s_paused;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* can_feedback_task.h - GD32 周期查询 + 事件消费任务
|
||||
*
|
||||
* 2026-08 架构改造:0xC1 CAN 反馈请求驱动(canFdQueryFeedback);
|
||||
* 0xC4 ADC / 0xC5 电机状态改为本任务周期查询(250ms/1s);
|
||||
* 0xC6 电机版本 / 0xD2 电机 OTA 进度保留事件推送,由本任务 takeEvent 消费。
|
||||
*/
|
||||
#ifndef CAN_FEEDBACK_TASK_H
|
||||
#define CAN_FEEDBACK_TASK_H
|
||||
|
||||
/** @brief 暂停/恢复周期查询与事件消费(OTA 期间调用 pause=true)。 */
|
||||
void pauseCanFeedbackTask(bool pause);
|
||||
|
||||
/** @brief 查询是否处于暂停(OTA / send_batch / 电机 OTA 期间为 true)。
|
||||
* 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */
|
||||
bool canFeedbackPaused();
|
||||
|
||||
/** @brief 获取 can_fb 任务句柄(用于栈水位监控)。 */
|
||||
TaskHandle_t getCanFeedbackTaskHandle();
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief 启动事件监听任务(幂等)。
|
||||
* @return true 表示任务已存在或创建成功。
|
||||
*/
|
||||
bool startCanFeedbackTask();
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // CAN_FEEDBACK_TASK_H
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* cpu_stats.cpp - 1Hz FreeRTOS CPU 占用采样任务
|
||||
*/
|
||||
#include "cpu_stats.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <inttypes.h>
|
||||
|
||||
#include "app_config.h"
|
||||
#include "esp_timer.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
/** 截断/回绕 WARN 最小间隔(30s) */
|
||||
static TickType_t cpuStatsWarnIntervalTicks()
|
||||
{
|
||||
return pdMS_TO_TICKS(30000);
|
||||
}
|
||||
|
||||
CpuStats& CpuStats::instance()
|
||||
{
|
||||
static CpuStats inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
void CpuStats::start()
|
||||
{
|
||||
if (m_started) {
|
||||
return;
|
||||
}
|
||||
m_started = true;
|
||||
|
||||
#if !defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
|
||||
portENTER_CRITICAL(&m_mux);
|
||||
m_published = {};
|
||||
m_published.available = false;
|
||||
portEXIT_CRITICAL(&m_mux);
|
||||
DEBUG_WARN("CPU", "未启用 CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS,跳过采样任务");
|
||||
return;
|
||||
#else
|
||||
portENTER_CRITICAL(&m_mux);
|
||||
m_published = {};
|
||||
m_published.available = true;
|
||||
m_published.valid = false;
|
||||
portEXIT_CRITICAL(&m_mux);
|
||||
|
||||
BaseType_t ok = xTaskCreatePinnedToCore(
|
||||
&CpuStats::taskEntry,
|
||||
"cpu_stats",
|
||||
TASK_STACK_CPU_STATS,
|
||||
this,
|
||||
tskIDLE_PRIORITY + 1, /* 低优先级,少搅动占用统计 */
|
||||
&m_taskHandle,
|
||||
0);
|
||||
if (ok != pdPASS) {
|
||||
m_taskHandle = nullptr;
|
||||
m_started = false;
|
||||
DEBUG_ERR("CPU", "创建 cpu_stats 任务失败");
|
||||
return;
|
||||
}
|
||||
DEBUG_INFO("CPU", "cpu_stats 任务已启动,采样周期 %" PRIu32 " ms", CPU_STATS_SAMPLE_MS);
|
||||
#endif
|
||||
}
|
||||
|
||||
void CpuStats::getSnapshot(CpuUsageSnapshot& out) const
|
||||
{
|
||||
portENTER_CRITICAL(&m_mux);
|
||||
out = m_published;
|
||||
portEXIT_CRITICAL(&m_mux);
|
||||
}
|
||||
|
||||
#if defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
|
||||
|
||||
static uint64_t runtimeDelta(configRUN_TIME_COUNTER_TYPE cur, configRUN_TIME_COUNTER_TYPE prev)
|
||||
{
|
||||
return cur >= prev ? (uint64_t)(cur - prev) : 0U;
|
||||
}
|
||||
|
||||
static bool taskNameIsIdle(const char* name)
|
||||
{
|
||||
return name && strncmp(name, "IDLE", 4) == 0;
|
||||
}
|
||||
|
||||
void CpuStats::sampleOnce()
|
||||
{
|
||||
const UBaseType_t live = uxTaskGetNumberOfTasks();
|
||||
if (live == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* P1:任务数超出快照上限时节流告警(否则静默丢任务) */
|
||||
if (live > (UBaseType_t)CPU_TASK_SNAPSHOT_MAX) {
|
||||
const TickType_t now_tick = xTaskGetTickCount();
|
||||
if (m_lastTruncWarnTick == 0 ||
|
||||
(now_tick - m_lastTruncWarnTick) >= cpuStatsWarnIntervalTicks()) {
|
||||
m_lastTruncWarnTick = now_tick;
|
||||
DEBUG_WARN("CPU",
|
||||
"任务数%lu>上限%u,只计前%u",
|
||||
(unsigned long)live,
|
||||
(unsigned)CPU_TASK_SNAPSHOT_MAX,
|
||||
(unsigned)CPU_TASK_SNAPSHOT_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/* P1:固定成员缓冲,免每拍 heap */
|
||||
const UBaseType_t buf_slots =
|
||||
std::min<UBaseType_t>(live + 2U, (UBaseType_t)CPU_TASK_SNAPSHOT_MAX);
|
||||
|
||||
configRUN_TIME_COUNTER_TYPE total_runtime = 0;
|
||||
const UBaseType_t captured =
|
||||
uxTaskGetSystemState(m_taskStatusBuf, buf_slots, &total_runtime);
|
||||
const int64_t now_us = esp_timer_get_time();
|
||||
|
||||
if (captured == 0 || captured > buf_slots) {
|
||||
DEBUG_WARN("CPU", "uxTaskGetSystemState 异常: captured=%lu buf=%lu",
|
||||
(unsigned long)captured, (unsigned long)buf_slots);
|
||||
return;
|
||||
}
|
||||
|
||||
CpuUsageSnapshot snap = {};
|
||||
snap.available = true;
|
||||
|
||||
/*
|
||||
* P2:先用 m_lastSamples 算本窗口,再写回基线(无需栈上 previous 拷贝)。
|
||||
* total 回绕(total_runtime < m_lastTotal)时丢弃本窗,只重建基线。
|
||||
*/
|
||||
const bool had_previous = m_hasLast;
|
||||
const bool wrapped = had_previous && total_runtime < m_lastTotal;
|
||||
if (wrapped) {
|
||||
const TickType_t now_tick = xTaskGetTickCount();
|
||||
if (m_lastWrapWarnTick == 0 ||
|
||||
(now_tick - m_lastWrapWarnTick) >= cpuStatsWarnIntervalTicks()) {
|
||||
m_lastWrapWarnTick = now_tick;
|
||||
DEBUG_WARN("CPU",
|
||||
"run-time 计数回绕,丢弃本采样窗 total=%" PRIu64 " prev=%" PRIu64,
|
||||
(uint64_t)total_runtime,
|
||||
(uint64_t)m_lastTotal);
|
||||
}
|
||||
}
|
||||
|
||||
const bool can_compute =
|
||||
had_previous && !wrapped && now_us > m_lastTimeUs;
|
||||
const uint64_t total_delta =
|
||||
can_compute ? runtimeDelta(total_runtime, m_lastTotal) : 0U;
|
||||
uint64_t idle_delta = 0U;
|
||||
|
||||
if (can_compute && total_delta > 0U) {
|
||||
for (UBaseType_t i = 0; i < captured; ++i) {
|
||||
configRUN_TIME_COUNTER_TYPE last_runtime = 0;
|
||||
bool found = false;
|
||||
for (size_t j = 0; j < m_lastCount; ++j) {
|
||||
if (m_lastSamples[j].task_number == m_taskStatusBuf[i].xTaskNumber) {
|
||||
last_runtime = m_lastSamples[j].runtime;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
continue; /* 新任务:本窗无 delta */
|
||||
}
|
||||
/* 单任务计数回绕时 runtimeDelta→0,本任务本窗记 0 */
|
||||
uint64_t delta = runtimeDelta(m_taskStatusBuf[i].ulRunTimeCounter, last_runtime);
|
||||
double pct = (double)delta * 100.0 / (double)total_delta;
|
||||
if (taskNameIsIdle(m_taskStatusBuf[i].pcTaskName)) {
|
||||
idle_delta += delta;
|
||||
}
|
||||
if (snap.task_count < CPU_TASK_SNAPSHOT_MAX) {
|
||||
CpuTaskMetric& m = snap.tasks[snap.task_count++];
|
||||
const char* n = m_taskStatusBuf[i].pcTaskName ? m_taskStatusBuf[i].pcTaskName : "";
|
||||
strncpy(m.name, n, sizeof(m.name) - 1);
|
||||
m.name[sizeof(m.name) - 1] = '\0';
|
||||
m.task_number = m_taskStatusBuf[i].xTaskNumber;
|
||||
m.runtime_delta = delta;
|
||||
m.usage_pct = pct;
|
||||
}
|
||||
}
|
||||
snap.valid = true;
|
||||
snap.sample_ms = (uint32_t)((now_us - m_lastTimeUs) / 1000);
|
||||
snap.idle_pct = std::min(100.0, (double)idle_delta * 100.0 / (double)total_delta);
|
||||
snap.usage_pct = std::max(0.0, 100.0 - snap.idle_pct);
|
||||
}
|
||||
|
||||
/* 更新基线(首拍 / 正常窗 / 回绕丢窗后均重建) */
|
||||
const size_t copy_count = std::min<size_t>((size_t)captured, CPU_TASK_SNAPSHOT_MAX);
|
||||
for (size_t i = 0; i < copy_count; ++i) {
|
||||
m_lastSamples[i].task_number = m_taskStatusBuf[i].xTaskNumber;
|
||||
m_lastSamples[i].runtime = m_taskStatusBuf[i].ulRunTimeCounter;
|
||||
}
|
||||
m_lastCount = copy_count;
|
||||
m_lastTotal = total_runtime;
|
||||
m_lastTimeUs = now_us;
|
||||
m_hasLast = true;
|
||||
|
||||
portENTER_CRITICAL(&m_mux);
|
||||
m_published = snap;
|
||||
portEXIT_CRITICAL(&m_mux);
|
||||
}
|
||||
|
||||
void CpuStats::taskEntry(void* arg)
|
||||
{
|
||||
auto* self = static_cast<CpuStats*>(arg);
|
||||
DEBUG_INFO("CPU", "=== cpu_stats 采样任务启动 ===");
|
||||
|
||||
TickType_t last_wake = xTaskGetTickCount();
|
||||
const TickType_t period = pdMS_TO_TICKS(CPU_STATS_SAMPLE_MS);
|
||||
|
||||
/* 第一拍只建基线(valid=false);此后每拍窗口 ≈ 1000ms */
|
||||
for (;;) {
|
||||
self->sampleOnce();
|
||||
vTaskDelayUntil(&last_wake, period);
|
||||
}
|
||||
}
|
||||
|
||||
#else /* !CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS */
|
||||
|
||||
void CpuStats::sampleOnce() {}
|
||||
void CpuStats::taskEntry(void*) { vTaskDelete(nullptr); }
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* cpu_stats.h - FreeRTOS 运行时 CPU 占用采样
|
||||
*
|
||||
* 由独立低优先级任务按固定 1s 周期采样;/system/status 只读最新快照,
|
||||
* 不再在 HTTP 线程里调用 uxTaskGetSystemState(避免窗口绑浏览器轮询、减 httpd 栈压)。
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
/** 单次快照最多跟踪的任务数。
|
||||
* 系统常驻任务 ~14(app)+ ~5(IDF: tiT/wifi/nimble/hci/ble)= ~19,
|
||||
* 按需短暂任务(ble_finish/restart/cancel/gd32_wait/ota_feed)不常驻。
|
||||
* 20 覆盖常驻集 + 余量 1;偶发按需任务多出时 uxTaskGetSystemState 截断,
|
||||
* 仅影响该拍 CPU 统计显示,不影响功能。原 32 浪费 ~1KB BSS。 */
|
||||
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 20;
|
||||
|
||||
/** 采样周期(ms)— 窗口 sample_ms 应对齐此值 */
|
||||
static constexpr uint32_t CPU_STATS_SAMPLE_MS = 1000;
|
||||
|
||||
struct CpuTaskMetric {
|
||||
char name[configMAX_TASK_NAME_LEN];
|
||||
UBaseType_t task_number;
|
||||
uint64_t runtime_delta;
|
||||
double usage_pct;
|
||||
};
|
||||
|
||||
struct CpuUsageSnapshot {
|
||||
bool available; ///< 固件是否启用 RUN_TIME_STATS
|
||||
bool valid; ///< 是否已有完整窗口(至少两拍)
|
||||
uint32_t sample_ms; ///< 本窗口墙钟长度
|
||||
double usage_pct; ///< 100 - idle
|
||||
double idle_pct;
|
||||
CpuTaskMetric tasks[CPU_TASK_SNAPSHOT_MAX];
|
||||
size_t task_count;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief CPU 运行时统计(单例 + 1Hz 采样任务)
|
||||
*/
|
||||
class CpuStats {
|
||||
public:
|
||||
static CpuStats& instance();
|
||||
|
||||
/** 创建采样任务(幂等;失败仅打日志,不阻塞启动) */
|
||||
void start();
|
||||
|
||||
/** 线程安全拷贝最新快照(HTTP 用) */
|
||||
void getSnapshot(CpuUsageSnapshot& out) const;
|
||||
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
private:
|
||||
CpuStats() = default;
|
||||
|
||||
static void taskEntry(void* arg);
|
||||
void sampleOnce();
|
||||
|
||||
mutable portMUX_TYPE m_mux = portMUX_INITIALIZER_UNLOCKED;
|
||||
CpuUsageSnapshot m_published = {};
|
||||
TaskHandle_t m_taskHandle = nullptr;
|
||||
bool m_started = false;
|
||||
|
||||
/* 基线(仅采样任务写) */
|
||||
struct TaskSample {
|
||||
UBaseType_t task_number;
|
||||
configRUN_TIME_COUNTER_TYPE runtime;
|
||||
};
|
||||
TaskSample m_lastSamples[CPU_TASK_SNAPSHOT_MAX] = {};
|
||||
size_t m_lastCount = 0;
|
||||
configRUN_TIME_COUNTER_TYPE m_lastTotal = 0;
|
||||
int64_t m_lastTimeUs = 0;
|
||||
bool m_hasLast = false;
|
||||
|
||||
/** uxTaskGetSystemState 目标缓冲(固定、免每拍 calloc) */
|
||||
TaskStatus_t m_taskStatusBuf[CPU_TASK_SNAPSHOT_MAX] = {};
|
||||
|
||||
/** 任务数截断 WARN 节流(tick) */
|
||||
TickType_t m_lastTruncWarnTick = 0;
|
||||
/** 运行时计数回绕 WARN 节流(tick) */
|
||||
TickType_t m_lastWrapWarnTick = 0;
|
||||
};
|
||||
+712
-258
File diff suppressed because it is too large
Load Diff
+133
-52
@@ -8,8 +8,11 @@
|
||||
*
|
||||
* 注意:当前 tools/WebUI 日志面板走的是 HTTP /logs,
|
||||
* 不是直接监听 UDP 6666。
|
||||
*
|
||||
* UART0: TX=GPIO21, RX=GPIO20, 115200 波特率
|
||||
*
|
||||
* UART0: TX=GPIO21, RX=GPIO20, 3000000 波特率
|
||||
*
|
||||
* 线程模型:DEBUG_* / log() / send() 仅允许在任务上下文调用
|
||||
* (内部使用 portMAX_DELAY 互斥锁,禁止 ISR)。
|
||||
*/
|
||||
#ifndef DEBUG_LOG_H
|
||||
#define DEBUG_LOG_H
|
||||
@@ -17,76 +20,154 @@
|
||||
#include <stdint.h>
|
||||
#include <stdarg.h>
|
||||
#include "esp_err.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "app_config.h"
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
// 调试日志配置(DEBUG_LOG_IP/PORT/ENABLED 已在 app_config.h 中定义)
|
||||
// 调试日志配置(DEBUG_LOG_IP/PORT 在 app_config.h)
|
||||
|
||||
// UART0 调试配置
|
||||
#define UART_DEBUG_TX_GPIO 21
|
||||
#define UART_DEBUG_RX_GPIO 20
|
||||
#define UART_DEBUG_BAUD_RATE 115200
|
||||
#define UART_DEBUG_TX_GPIO 21 ///< UART0 TX GPIO 引脚号
|
||||
#define UART_DEBUG_RX_GPIO 20 ///< UART0 RX GPIO 引脚号
|
||||
#define UART_DEBUG_BAUD_RATE 3000000 ///< UART0 波特率
|
||||
|
||||
// 日志级别
|
||||
typedef enum {
|
||||
DEBUG_LOG_NONE = 0,
|
||||
DEBUG_LOG_ERR = 1, // 错误
|
||||
DEBUG_LOG_WARN = 2, // 警告
|
||||
DEBUG_LOG_INFO = 3, // 信息
|
||||
DEBUG_LOG_DBG = 4, // 调试
|
||||
DEBUG_LOG_ALL = 5 // 全部
|
||||
DEBUG_LOG_DBG = 4 // 调试
|
||||
} debug_log_level_t;
|
||||
|
||||
// 当前日志级别(运行时可改)
|
||||
extern debug_log_level_t g_debug_log_level;
|
||||
/**
|
||||
* @brief /logs 流式发送 sink:每个 chunk 回调一次;返回 false 表示发送失败需中止。
|
||||
* @param ctx 透传上下文(如 httpd_req_t*)。
|
||||
*/
|
||||
typedef bool (*LogJsonSink)(void *ctx, const char *data, int len);
|
||||
|
||||
/**
|
||||
* @brief 初始化日志系统,包括 UART 输出、UDP 目标和内部缓冲区。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
* @brief 调试日志类(单例)
|
||||
*
|
||||
* 封装 UART0、UDP 和环形缓冲区三路日志输出通道,
|
||||
* 所有内部状态(socket、缓冲区、互斥锁等)均为私有成员。
|
||||
*/
|
||||
esp_err_t debug_log_init(void);
|
||||
class DebugLog {
|
||||
public:
|
||||
static DebugLog& instance();
|
||||
|
||||
/**
|
||||
* @brief 初始化 UART0 调试串口。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t uart_debug_init(void);
|
||||
/** @brief 初始化日志系统(UART + 开机大环;UDP socket 延迟创建)。 */
|
||||
esp_err_t init();
|
||||
|
||||
/**
|
||||
* @brief 通过 UART0 直接发送一段原始字节数据。
|
||||
* @param data 待发送的数据指针。
|
||||
* @param len 数据长度。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t uart_debug_send(const uint8_t *data, size_t len);
|
||||
/**
|
||||
* @brief 启动 burst 结束后把环从开机容量缩到稳态小环,释放大块堆。
|
||||
* 幂等;失败则保留大环。宜在 WiFi/HTTP 就绪后、日志变少时调用。
|
||||
*/
|
||||
void shrinkRingAfterBoot();
|
||||
|
||||
/**
|
||||
* @brief 发送原始日志数据到底层输出通道。
|
||||
* @param data 日志数据指针。
|
||||
* @param len 数据长度。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t debug_log_send(const uint8_t *data, size_t len);
|
||||
/** @brief 初始化 UART0 调试串口。 */
|
||||
esp_err_t uartInit();
|
||||
|
||||
/**
|
||||
* @brief 按日志级别与标签格式化输出一条日志。
|
||||
* @param level 日志级别。
|
||||
* @param tag 日志标签。
|
||||
* @param fmt 格式化字符串。
|
||||
*/
|
||||
void debug_log(debug_log_level_t level, const char *tag, const char *fmt, ...);
|
||||
/** @brief 通过 UART0 直接发送一段原始字节数据。 */
|
||||
esp_err_t uartSend(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 获取当前环形日志缓冲区中的日志条数。
|
||||
* @return 当前日志条数。
|
||||
*/
|
||||
int debug_log_ring_count(void);
|
||||
/** @brief 发送原始日志数据到底层输出通道(任务上下文)。 */
|
||||
esp_err_t send(const uint8_t *data, size_t len);
|
||||
|
||||
// 读取并清空环形日志缓冲区,返回 JSON 数组字符串,需由调用者 free。
|
||||
char *debug_log_ring_drain_json(int *out_len); // 调用者 free
|
||||
/** @brief 按日志级别与标签格式化输出一条日志(任务上下文)。 */
|
||||
void log(debug_log_level_t level, const char *tag, const char *fmt, ...);
|
||||
|
||||
#define DEBUG_ERR(tag, fmt, ...) debug_log(DEBUG_LOG_ERR, tag, fmt, ##__VA_ARGS__)
|
||||
#define DEBUG_WARN(tag, fmt, ...) debug_log(DEBUG_LOG_WARN, tag, fmt, ##__VA_ARGS__)
|
||||
#define DEBUG_INFO(tag, fmt, ...) debug_log(DEBUG_LOG_INFO, tag, fmt, ##__VA_ARGS__)
|
||||
#define DEBUG_DBG(tag, fmt, ...) debug_log(DEBUG_LOG_DBG, tag, fmt, ##__VA_ARGS__)
|
||||
/** @brief 获取当前环形日志缓冲区中的日志条数。 */
|
||||
int ringCount();
|
||||
|
||||
#endif // DEBUG_LOG_H
|
||||
/** @brief 弹出一条最旧日志并从环形缓冲区移除。 */
|
||||
bool ringPopOldest(char *out, size_t out_size, int *out_len);
|
||||
|
||||
/**
|
||||
* @brief 将环形日志以 chunked JSON 数组流式发送,消除一次性大块 malloc。
|
||||
* 按条 pop(短持锁),512B 栈缓冲攒满即经 sink 发一个 chunk;
|
||||
* 峰值从原 ~4KB malloc 降为栈缓冲 + 小 chunk。
|
||||
* @param sink 每个 chunk 的发送回调;返回 false 表示发送失败。
|
||||
* @param ctx 透传给 sink 的上下文。
|
||||
* @return 1=已流式发送完整数组(调用方需补发终止 chunk);
|
||||
* 0=无日志/堆门控(调用方一次性发 "[]");-1=中途发送失败。
|
||||
*/
|
||||
int ringDrainJsonStream(LogJsonSink sink, void *ctx);
|
||||
|
||||
void setLogLevel(debug_log_level_t level) { m_logLevel = level; }
|
||||
debug_log_level_t getLogLevel() const { return m_logLevel; }
|
||||
|
||||
private:
|
||||
DebugLog() = default;
|
||||
~DebugLog() = default;
|
||||
DebugLog(const DebugLog&) = delete;
|
||||
DebugLog& operator=(const DebugLog&) = delete;
|
||||
|
||||
int m_udpSock = -1;
|
||||
struct sockaddr_in m_debugAddr = {};
|
||||
bool m_initialized = false;
|
||||
bool m_uartInitialized = false;
|
||||
/** uartInit 成功后才为 true;send() 据此跳过 uart_write_bytes */
|
||||
bool m_uartAvailable = false;
|
||||
debug_log_level_t m_logLevel = DEBUG_LOG_INFO;
|
||||
|
||||
/* 环形日志(堆上分配,非 .data):
|
||||
* 开机 LOG_RING_SLOTS_BOOT×128 容纳启动刷屏;
|
||||
* shrinkRingAfterBoot() 后改为 LOG_RING_SLOTS_STEADY×128,释放大环。
|
||||
* 业务 DEBUG_* 目标:整行([L][TAG]+正文+\\n)≤128B;槽 128 含 \\0 → 有效≤127。 */
|
||||
static constexpr int LOG_RING_SLOTS_BOOT = 64; ///< 开机 burst:~8.4KB 堆
|
||||
static constexpr int LOG_RING_SLOTS_STEADY = 16; ///< 稳态:~2.1KB 堆(对齐 drain)
|
||||
static constexpr int LOG_RING_SIZE = 128; ///< 单条槽(含 \\0);有效≤127(含换行)
|
||||
static constexpr int LOG_DRAIN_MAX = 16; ///< 单次 /logs 最多条数
|
||||
char *m_logRing = nullptr; ///< [slots * LOG_RING_SIZE] 扁平行
|
||||
int *m_logRingLens = nullptr; ///< [slots]
|
||||
int m_logRingSlots = 0; ///< 当前容量
|
||||
int m_logRingHead = 0;
|
||||
int m_logRingCount = 0;
|
||||
bool m_logRingShrunk = false;
|
||||
SemaphoreHandle_t m_logRingMutex = nullptr;
|
||||
|
||||
char *ringSlot(int idx) { return m_logRing + (size_t)idx * (size_t)LOG_RING_SIZE; }
|
||||
const char *ringSlot(int idx) const {
|
||||
return m_logRing + (size_t)idx * (size_t)LOG_RING_SIZE;
|
||||
}
|
||||
bool allocRing(int slots);
|
||||
|
||||
/* 堆门控(防 OTA 时 UDP/格式化抢 LWIP 堆):
|
||||
* ERR/WARN 永不因堆门控丢弃
|
||||
* free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO
|
||||
* free < kDropWarnFree → 全局丢 DBG
|
||||
* free < kSkipUdpFree → 不 sendto(仍 UART+ring)
|
||||
* free < kDrainMinFree → /logs 返 "[]"(低堆时连流式小 chunk 的 pbuf 也省)
|
||||
* keep-alive tag:OTA 前缀、SPI、WIFI */
|
||||
static constexpr uint32_t kDropWarnFree = 6U * 1024U;
|
||||
static constexpr uint32_t kDropInfoFree = 10U * 1024U;
|
||||
static constexpr uint32_t kSkipUdpFree = 6U * 1024U;
|
||||
static constexpr uint32_t kDrainMinFree = 4U * 1024U;
|
||||
|
||||
/** UDP 跳过/失败告警节流与 socket 重建 */
|
||||
TickType_t m_lastUdpSkipWarnTick = 0;
|
||||
TickType_t m_lastUdpFailWarnTick = 0;
|
||||
uint8_t m_udpFailStreak = 0;
|
||||
portMUX_TYPE m_udpMux = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
esp_err_t ensureUdpSocket();
|
||||
void closeUdpSocket();
|
||||
bool udpNetifHasIp() const;
|
||||
void ringPush(const char *data, int len);
|
||||
void ringPushLocked(const char *data, int len);
|
||||
bool ringPopOldestLocked(char *out, size_t out_size, int *out_len);
|
||||
int ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const;
|
||||
|
||||
static bool isKeepAliveTag(const char *tag);
|
||||
static int jsonEscapeAppend(char *buf, int pos, int capacity, const char *src, int src_len);
|
||||
};
|
||||
|
||||
// 便捷宏 — 直接调用单例方法(禁止在 ISR 中使用)
|
||||
#define DEBUG_ERR(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_ERR, tag, fmt, ##__VA_ARGS__)
|
||||
#define DEBUG_WARN(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_WARN, tag, fmt, ##__VA_ARGS__)
|
||||
#define DEBUG_INFO(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_INFO, tag, fmt, ##__VA_ARGS__)
|
||||
#define DEBUG_DBG(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_DBG, tag, fmt, ##__VA_ARGS__)
|
||||
|
||||
#endif // DEBUG_LOG_H
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* gd32_adc_feedback.cpp - GD32 ADC 调试反馈解析与缓存
|
||||
*/
|
||||
#include "monitor/gd32_adc_feedback.h"
|
||||
#include "monitor/spi_ring_cache.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint8_t kPayloadVer = 1U;
|
||||
constexpr size_t kPayloadLen = 20U;
|
||||
|
||||
static SemaphoreHandle_t s_mux = nullptr;
|
||||
static gd32_adc_feedback_t s_latest = {};
|
||||
static bool s_have = false;
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief 处理一帧 0xC4 ADC 调试反馈并更新最新快照。
|
||||
* @param payload 固定 20B 载荷
|
||||
* @param len 实际长度
|
||||
* @return true 解析并写入成功
|
||||
*/
|
||||
bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len)
|
||||
{
|
||||
if (payload == nullptr || len < kPayloadLen) {
|
||||
return false;
|
||||
}
|
||||
if (payload[0] != kPayloadVer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t flags = payload[1];
|
||||
const int16_t temp_x100 = static_cast<int16_t>(rdU16Le(&payload[2]));
|
||||
const uint16_t vdda_mV = rdU16Le(&payload[4]);
|
||||
const uint16_t vin_mV = rdU16Le(&payload[6]);
|
||||
const uint16_t temp_raw = rdU16Le(&payload[8]);
|
||||
const uint16_t vref_raw = rdU16Le(&payload[10]);
|
||||
const uint32_t seq = rdU32Le(&payload[12]);
|
||||
const uint32_t uptime = rdU32Le(&payload[16]);
|
||||
|
||||
gd32_adc_feedback_t fb = {};
|
||||
fb.valid = (flags & 0x01U) != 0U;
|
||||
fb.temperature_c = static_cast<float>(temp_x100) / 100.0f;
|
||||
fb.vdda_v = static_cast<float>(vdda_mV) / 1000.0f;
|
||||
fb.vin_adc_v = static_cast<float>(vin_mV) / 1000.0f;
|
||||
/* 原理图:VCC — R18 47k — VIN_ADC — R13 4.7k — GND
|
||||
* VCC = V_adc * (R18+R13)/R13 = V_adc * 11 */
|
||||
fb.vcc_v = fb.vin_adc_v * ((47.0f + 4.7f) / 4.7f);
|
||||
fb.temp_raw = temp_raw;
|
||||
fb.vref_raw = vref_raw;
|
||||
fb.seq = seq;
|
||||
fb.uptime_sec = uptime;
|
||||
fb.recv_ms = esp_timer_get_time() / 1000;
|
||||
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) {
|
||||
return false;
|
||||
}
|
||||
s_latest = fb;
|
||||
s_have = true;
|
||||
xSemaphoreGive(s_mux);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 线程安全拷贝最近一次 ADC 反馈。
|
||||
* @param[out] out 输出缓冲
|
||||
* @return true 已有至少一帧有效数据
|
||||
*/
|
||||
bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
|
||||
{
|
||||
if (out == nullptr) {
|
||||
return false;
|
||||
}
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) {
|
||||
return false;
|
||||
}
|
||||
bool ok = false;
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
|
||||
if (s_have) {
|
||||
*out = s_latest;
|
||||
ok = true;
|
||||
}
|
||||
xSemaphoreGive(s_mux);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 距上次收到 0xC4 的毫秒数。
|
||||
* @return 年龄 ms;尚无数据返回 UINT32_MAX
|
||||
*/
|
||||
uint32_t gd32AdcFeedbackAgeMs(void)
|
||||
{
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) {
|
||||
return UINT32_MAX;
|
||||
}
|
||||
int64_t recv = 0;
|
||||
bool have = false;
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
|
||||
have = s_have;
|
||||
recv = s_latest.recv_ms;
|
||||
xSemaphoreGive(s_mux);
|
||||
}
|
||||
return ringCacheAgeMs(have, recv);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* gd32_adc_feedback.h - 被动接收 GD32 ADC 调试反馈(SPI TYPE 0xC4)
|
||||
*
|
||||
* 与 CAN 0xC1 同模型:GD32 1Hz 主动按铃推送,ESP32 takeEvent 侧解析入库。
|
||||
* 是否推送由 GD32 CONFIG_SPI_ADC_FEEDBACK_ENABLE 决定,经 HELLO/PING 身份块
|
||||
* (V2_FEAT_ADC_FEEDBACK)上报;ESP32 无本地开关,有帧即解析。
|
||||
*/
|
||||
#ifndef GD32_ADC_FEEDBACK_H
|
||||
#define GD32_ADC_FEEDBACK_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** @brief 最近一次 GD32 ADC 调试快照(工程量已换算)。 */
|
||||
typedef struct {
|
||||
bool valid; ///< GD32 sample.valid
|
||||
float temperature_c; ///< ℃
|
||||
float vdda_v; ///< V(MCU 模拟供电,约 3.3)
|
||||
float vin_adc_v; ///< V(分压后 ADC 引脚 PA6)
|
||||
float vcc_v; ///< V(输入母线 VCC,由 vin 分压反推)
|
||||
uint16_t temp_raw;
|
||||
uint16_t vref_raw;
|
||||
uint32_t seq;
|
||||
uint32_t uptime_sec; ///< GD32 侧 uptime
|
||||
int64_t recv_ms; ///< ESP32 收到时 esp_timer ms
|
||||
} gd32_adc_feedback_t;
|
||||
|
||||
/**
|
||||
* @brief 处理一帧 0xC4 EVENT payload(由 can_fb 任务 takeEvent 分发)。
|
||||
* @return true 表示已接受并更新快照。
|
||||
*/
|
||||
bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 拷贝最新快照。
|
||||
* @param[out] out 输出缓冲
|
||||
* @return true 表示至少收到过一帧
|
||||
*/
|
||||
bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out);
|
||||
|
||||
/** @brief 距离上次收到的毫秒数;无数据返回 UINT32_MAX。 */
|
||||
uint32_t gd32AdcFeedbackAgeMs(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* GD32_ADC_FEEDBACK_H */
|
||||
+728
-147
File diff suppressed because it is too large
Load Diff
+172
-61
@@ -1,10 +1,18 @@
|
||||
/*
|
||||
/*
|
||||
* health_monitor.h - 系统健康监控头文件
|
||||
*
|
||||
* HealthMonitor 类封装健康状态管理、温度监控和 WiFi RSSI 监控。
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "esp_timer.h"
|
||||
#include "app_config.h"
|
||||
#include "task_stack.h" // TaskStackMetric(maybeRecordHeapLowWater 可选参数)
|
||||
|
||||
// T9.3: 健康状态等级
|
||||
typedef enum {
|
||||
@@ -14,76 +22,179 @@ typedef enum {
|
||||
SYS_SAFE_SHUTDOWN = 3 // 安全关机
|
||||
} health_state_t;
|
||||
|
||||
/** @brief 健康监控模块内部状态码,用于函数返回值。 */
|
||||
typedef enum {
|
||||
HEALTH_MON_OK = 0,
|
||||
HEALTH_MON_WARNING = 1,
|
||||
HEALTH_MON_CRITICAL = 2,
|
||||
HEALTH_MON_SHUTDOWN = 3
|
||||
HEALTH_MON_OK = 0, ///< 健康检查通过
|
||||
HEALTH_MON_WARNING = 1, ///< 健康检查警告(可继续)
|
||||
HEALTH_MON_CRITICAL = 2, ///< 健康检查危险(建议中止)
|
||||
HEALTH_MON_SHUTDOWN = 3 ///< 需要安全关机
|
||||
} health_status_t;
|
||||
|
||||
// 外部共享状态(互斥锁保护)
|
||||
extern health_state_t g_health_state;
|
||||
|
||||
// WiFi RSSI 阈值(来自 app_config.h)
|
||||
#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING
|
||||
#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL
|
||||
#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING ///< WiFi 信号正常阈值(别名)
|
||||
|
||||
// ============================================================
|
||||
// 堆低水位快照("最低空闲"创新低时记录上下文,供 /system/heap_low_log 查询)
|
||||
// ============================================================
|
||||
/** @brief 单条任务栈水位(用于低水位快照内嵌的任务栈表)。 */
|
||||
struct HeapLowTaskStack {
|
||||
char name[16]; ///< 任务名(截断)
|
||||
uint32_t remaining; ///< 剩余栈字节(uxTaskGetStackHighWaterMark × sizeof(StackType_t))
|
||||
};
|
||||
|
||||
/** @brief 单条"堆低水位"快照记录。仅当 internal_min_free 出现新低时写入。 */
|
||||
struct HeapLowWaterRecord {
|
||||
uint64_t ts_ms; ///< 启动后毫秒(esp_timer_get_time / 1000)
|
||||
uint32_t free_heap; ///< 当时的总空闲堆(esp_get_free_heap_size)
|
||||
uint32_t min_free_heap; ///< 当时记录到的内部堆最低水位
|
||||
uint32_t largest_free_block; ///< 最大连续空闲块
|
||||
uint8_t sys_state; ///< OTA 系统状态码(ota_system_state_t)
|
||||
uint8_t ota_channel; ///< OTA 通道(0=WIFI, 1=BLE)
|
||||
int8_t rssi; ///< 最近 WiFi RSSI(dBm,-127=未知)
|
||||
float temperature; ///< 最近芯片温度(°C)
|
||||
/* 多任务栈水位表:按"剩余升序"取前 8 个,定位"是谁的栈突然涨了"。
|
||||
* task_stack_count=0 表示未采集到任务信息。 */
|
||||
HeapLowTaskStack task_stacks[8];
|
||||
uint8_t task_stack_count;
|
||||
/* 触发低水位的"当前任务":取当前 TaskHandle 的名字(taskENTER_CRITICAL 后取),
|
||||
* 用于推断是谁在分配堆。空串表示未知。 */
|
||||
char current_task[16];
|
||||
/* 连接/OTA 进度上下文:区分"无连接 vs 中继期 vs 收尾期" */
|
||||
uint8_t ble_connected; ///< 0/1
|
||||
uint8_t wifi_connected; ///< 0/1
|
||||
uint32_t ota_stored_size; ///< OTA 已存储字节
|
||||
uint32_t ota_total_size; ///< OTA 总字节
|
||||
/* 嫌疑资源水位:定位"OTA/IDLE 期低谷由哪个子系统吃堆"。
|
||||
* 非 OTA 期这些应为 0;非 0 时即对应子系统持有堆缓冲。 */
|
||||
uint16_t ble_data_queue_depth; ///< BLE OTA 数据队列当前条数
|
||||
uint16_t ble_reorder_cached; ///< BLE reorder 已缓存乱序包数
|
||||
uint16_t spi_pending_jobs; ///< SPI master 待处理 job 数
|
||||
uint16_t reserved; ///< 对齐保留
|
||||
/* 内存全景:区分"内部碎片"vs"系统级紧"。
|
||||
* default_free 含 PSRAM;internal_free = free_heap(含 SPIRAM 时与 default 区分)。
|
||||
* alloc_count = 当前活跃 alloc 数,看"是不是小块大量未释放"。 */
|
||||
uint32_t default_free_heap; ///< MALLOC_CAP_DEFAULT free(含 PSRAM)
|
||||
uint32_t alloc_count; ///< 内部堆当前 allocated_blocks(heap_caps_get_info)
|
||||
/* heap hook:「破新低时的最后一击 alloc」。
|
||||
* 不记任务名(WiFi IRAM 禁止;且发送路径末包常把任务刷成 wifi,误导主因)。
|
||||
* hook_task 现为类别串:net? / bulk / tiny(非 FreeRTOS 任务名)。
|
||||
* hook_caller_addr 用 addr2line 解析。 */
|
||||
char hook_task[16]; ///< 分配类别 net?/bulk/tiny,不是任务名
|
||||
uint32_t hook_alloc_size; ///< 最后一击 alloc 字节数
|
||||
uint32_t hook_caller_addr; ///< 调用者返回地址(addr2line)
|
||||
uint32_t hook_free_after; ///< 该次后的近似 free(**estimated 低水位近似值**,非精确实测;JSON 输出为 hook_est_free_after + hook_confidence,前端应标注 estimated)
|
||||
uint32_t hook_fired; ///< 0=本窗口 hook 未命中
|
||||
/* DHCP/IP 事件计数:每次 STA GOT_IP(含续约)+1。
|
||||
* 对比两次低谷记录的计数差,确认低谷是不是 DHCP 续约引起。 */
|
||||
uint32_t ip_event_count; ///< 累计 IP 获得/续约次数
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 初始化健康监控模块,创建内部互斥锁与基础状态。
|
||||
*/
|
||||
void health_monitor_init(void);
|
||||
|
||||
/**
|
||||
* @brief 健康监控任务入口,负责启动温度定时检测并周期性巡检系统状态。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void health_monitor_task(void *pvParameters);
|
||||
|
||||
/**
|
||||
* @brief 在 OTA 传输开始前执行健康检查,返回当前是否允许传输。
|
||||
* @return 健康检查结果,决定是否允许 OTA。
|
||||
*/
|
||||
health_status_t health_check_before_ota(void);
|
||||
|
||||
/**
|
||||
* @brief 在 OTA 中继转发阶段执行健康检查,跳过 WiFi 相关检查。
|
||||
* @brief 系统健康监控类(单例)
|
||||
*
|
||||
* SPI 中继阶段固件已存储在 ESP32 Flash 中,不依赖 WiFi 连接,
|
||||
* 因此跳过 WiFi 连接状态和 RSSI 检查,只检查温度等本地状态。
|
||||
* @return 健康检查结果,决定是否允许 OTA。
|
||||
* 封装健康状态管理、温度定时监控和 WiFi RSSI 监控。
|
||||
*/
|
||||
health_status_t health_check_before_ota_relay(void);
|
||||
class HealthMonitor {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 HealthMonitor 单例实例。
|
||||
* @return HealthMonitor 引用。
|
||||
*/
|
||||
static HealthMonitor& instance();
|
||||
|
||||
/**
|
||||
* @brief 记录一条健康相关事件日志,便于故障追踪与状态回溯。
|
||||
* @param event 事件名称字符串。
|
||||
* @param value 事件关联的数值。
|
||||
*/
|
||||
void health_log_event(const char *event, int value);
|
||||
/** @brief 初始化健康监控模块,创建内部互斥锁与基础状态。 */
|
||||
void init();
|
||||
|
||||
/**
|
||||
* @brief 获取当前系统健康状态。
|
||||
* @return 当前健康状态枚举值。
|
||||
*/
|
||||
health_state_t health_get_current(void);
|
||||
/** @brief 健康监控 FreeRTOS 任务入口。 */
|
||||
static void taskEntry(void *pvParameters);
|
||||
|
||||
/**
|
||||
* @brief 根据最新 WiFi RSSI 更新健康状态,并返回对应健康检查结果。
|
||||
* @param rssi 当前 WiFi 信号强度(dBm)。
|
||||
* @return 健康检查结果。
|
||||
*/
|
||||
health_status_t health_update_from_wifi_rssi(int8_t rssi);
|
||||
/** @brief OTA 传输前执行健康检查。 */
|
||||
health_status_t checkBeforeOta();
|
||||
|
||||
/**
|
||||
* @brief 获取最近一次采样到的芯片温度(摄氏度)。
|
||||
* @return 最近一次温度采样值(摄氏度)。
|
||||
*/
|
||||
float health_get_last_temperature(void);
|
||||
/** @brief OTA 中继转发阶段执行健康检查(跳过 WiFi 检查)。 */
|
||||
health_status_t checkBeforeOtaRelay();
|
||||
|
||||
/**
|
||||
* @brief 获取最近一次健康监控采样得到的 WiFi RSSI。
|
||||
* @param out_rssi 输出 RSSI。
|
||||
* @param out_age_ms 输出样本距今时长(毫秒)。
|
||||
* @return true 有有效样本,false 当前无有效样本。
|
||||
*/
|
||||
bool health_get_last_wifi_rssi(int8_t *out_rssi, uint32_t *out_age_ms);
|
||||
|
||||
/** @brief 获取当前系统健康状态(线程安全)。 */
|
||||
health_state_t getCurrent();
|
||||
|
||||
/** @brief 根据 WiFi RSSI 更新健康状态。 */
|
||||
health_status_t updateFromWifiRssi(int8_t rssi);
|
||||
|
||||
/** @brief 获取最近一次芯片温度。 */
|
||||
float getLastTemperature();
|
||||
|
||||
/** @brief 获取最近一次 WiFi RSSI 及其年龄。 */
|
||||
bool getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms);
|
||||
|
||||
/** @brief 获取健康监控任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
// ── 堆低水位快照 ──
|
||||
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
|
||||
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
|
||||
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。 */
|
||||
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
|
||||
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
|
||||
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。
|
||||
* @param pre_metrics 巡检循环已采集的任务栈指标(避免二次遍历);null 时内部自采 */
|
||||
void maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics = nullptr, size_t pre_count = 0);
|
||||
|
||||
/** @brief 将唯一快照序列化为 JSON:{"count":0|1,"records":[...]}。线程安全。 */
|
||||
int dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const;
|
||||
|
||||
/**
|
||||
* @brief 由 WiFi 事件回调推送连接状态(避免 HealthMonitor → WifiOta 依赖)。
|
||||
* WiFi 连接/断开时由 wifi_ota 事件处理器主动调用,
|
||||
* HealthMonitor 无需主动查询 WifiOta::isConnected()。
|
||||
* @param connected true=已连接,false=断开。
|
||||
*/
|
||||
void setWifiConnected(bool connected);
|
||||
|
||||
private:
|
||||
/** @brief 加锁读取健康状态。 */
|
||||
health_state_t getState();
|
||||
|
||||
/** @brief 加锁设置健康状态。 */
|
||||
void setState(health_state_t newState);
|
||||
|
||||
HealthMonitor() = default;
|
||||
~HealthMonitor() = default;
|
||||
HealthMonitor(const HealthMonitor&) = delete;
|
||||
HealthMonitor& operator=(const HealthMonitor&) = delete;
|
||||
|
||||
SemaphoreHandle_t m_mutex = NULL;
|
||||
temperature_sensor_handle_t m_tempHandle = NULL;
|
||||
// 温度监控 esp_timer 句柄存为成员,避免任务被删除后定时器仍在运行、
|
||||
// 回调继续访问 instance() 成员。
|
||||
esp_timer_handle_t m_tempTimer = nullptr;
|
||||
health_state_t m_healthState = SYS_NORMAL;
|
||||
float m_lastTemperature = 0.0f;
|
||||
int8_t m_lastWifiRssi = -127;
|
||||
int64_t m_lastWifiRssiMs = 0;
|
||||
TaskHandle_t m_taskHandle = NULL;
|
||||
// m_wifiConnected 由 m_mutex 保护(setWifiConnected 写 / checkBeforeOta 读)。
|
||||
bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送)
|
||||
|
||||
// 堆低水位:设备仅 1 槽(覆盖写)。m_mutex 保护。
|
||||
HeapLowWaterRecord m_heapLowWater = {};
|
||||
bool m_heapLowWaterValid = false; ///< 是否有一条有效快照
|
||||
uint32_t m_heapLowWaterLastMin = 0xFFFFFFFFu; ///< 上次记下的最低值
|
||||
|
||||
static void tempMonitorCallback(void *arg);
|
||||
|
||||
// RAII 锁守卫(与 SpiMaster::LockGuard 风格一致)
|
||||
class LockGuard {
|
||||
public:
|
||||
explicit LockGuard(SemaphoreHandle_t mutex) : m_lock(mutex) {
|
||||
if (m_lock) { xSemaphoreTake(m_lock, portMAX_DELAY); }
|
||||
}
|
||||
~LockGuard() {
|
||||
if (m_lock) { xSemaphoreGive(m_lock); }
|
||||
}
|
||||
LockGuard(const LockGuard&) = delete;
|
||||
LockGuard& operator=(const LockGuard&) = delete;
|
||||
private:
|
||||
SemaphoreHandle_t m_lock;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
/*
|
||||
* motor_ota_relay.cpp - 电机 OTA relay ESP32 侧实现
|
||||
*/
|
||||
#include "monitor/motor_ota_relay.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "MOTOR_OTA";
|
||||
|
||||
/* SPI 类型常量(与 GD32 app_spi_command_defs.h 对齐) */
|
||||
static constexpr uint8_t kSpiMotorOtaCmd = 0xD0U;
|
||||
static constexpr uint8_t kSpiMotorOtaData = 0xD1U;
|
||||
static constexpr uint8_t kSpiMotorOtaStatus = 0xD2U;
|
||||
static constexpr uint8_t kRspAck = 0x80U;
|
||||
static constexpr uint8_t kRspWait = 0x81U;
|
||||
|
||||
/* 内部状态 */
|
||||
static SemaphoreHandle_t s_mutex = nullptr;
|
||||
static motor_ota_status_t s_status = {};
|
||||
/* 固件不再整体缓冲在堆里:HTTP handler 流式写入 gd32_ota flash 分区,
|
||||
* feedTask 经 flashReadChunk 分块读取(见 feedTask)。 */
|
||||
static uint32_t s_fw_size = 0;
|
||||
static uint32_t s_fw_offset = 0; /* 已喂给 GD32 的偏移 */
|
||||
static TaskHandle_t s_feed_task = nullptr;
|
||||
static volatile bool s_feed_running = false;
|
||||
|
||||
static int64_t nowMs() {
|
||||
return esp_timer_get_time() / 1000;
|
||||
}
|
||||
|
||||
/* ── 共享辅助:终态判断 + QUERY SPI 事务 ── */
|
||||
|
||||
/** @brief GD32 侧已进终态(IDLE/FINISHED/ERROR),喂数据应停止。 */
|
||||
static inline bool isTerminalState(motor_ota_state_t s)
|
||||
{
|
||||
return s == MOTOR_OTA_IDLE || s == MOTOR_OTA_FINISHED || s == MOTOR_OTA_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 发 SPI 0xD0 QUERY 查询 GD32 真实状态。
|
||||
* @param out_state 非 null 时写入 GD32 返回的 state
|
||||
* @param out_err 非 null 时写入 error_code
|
||||
* @param update_status true 时把结果同步写回 s_status(持锁)
|
||||
* @return true=QUERY 成功且 ACK 有有效数据;false=SPI 失败或无 ACK
|
||||
*
|
||||
* 消除 feedTask 中 4 处重复的 qcmd[1]={2} + transact + parse qp[0..5] 块。
|
||||
*/
|
||||
static bool queryMotorOtaStatus(motor_ota_state_t *out_state = nullptr,
|
||||
uint8_t *out_err = nullptr,
|
||||
bool update_status = false)
|
||||
{
|
||||
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
|
||||
uint8_t qt = 0, qp[16] = {0};
|
||||
size_t ql = 0;
|
||||
if (SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) != ESP_OK
|
||||
|| qt != kRspAck || ql < 1U) {
|
||||
return false;
|
||||
}
|
||||
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
|
||||
if (out_state) *out_state = qst;
|
||||
if (out_err) *out_err = (ql >= 2U) ? qp[1] : 0U;
|
||||
if (update_status && s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
|
||||
s_status.state = qst;
|
||||
s_status.error_code = (ql >= 2U) ? qp[1] : 0U;
|
||||
if (ql >= 6U) {
|
||||
s_status.packets_acked = static_cast<uint16_t>(qp[2])
|
||||
| (static_cast<uint16_t>(qp[3]) << 8);
|
||||
s_status.total_packets = static_cast<uint16_t>(qp[4])
|
||||
| (static_cast<uint16_t>(qp[5]) << 8);
|
||||
}
|
||||
if (ql >= 10U) {
|
||||
s_status.fw_size = static_cast<uint32_t>(qp[6])
|
||||
| (static_cast<uint32_t>(qp[7]) << 8)
|
||||
| (static_cast<uint32_t>(qp[8]) << 16)
|
||||
| (static_cast<uint32_t>(qp[9]) << 24);
|
||||
}
|
||||
s_status.last_update_ms = nowMs();
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ═══════════════════ 喂数据 task ═══════════════════ */
|
||||
|
||||
static void feedTask(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
uint8_t resp_payload[16];
|
||||
uint8_t resp_type = 0;
|
||||
size_t resp_len = 0;
|
||||
/* 栈上 chunk:[offset:u32 LE][data…],避免整固件堆缓冲 */
|
||||
uint8_t chunk_buf[MOTOR_OTA_CHUNK_SIZE];
|
||||
|
||||
/* 整体超时保护:feedTask 最长存活 120s,超时自动退出。
|
||||
* 防止 GD32 侧不回 ACK 或持续回 WAIT 时,task 永久卡在循环里,
|
||||
* 导致 motorOtaIsActive() 一直为 true、阻塞其他 OTA。 */
|
||||
const int64_t deadline_ms = nowMs() + 120000LL;
|
||||
|
||||
/* 启动宽限期:只需覆盖“suppress 生效前在途的陈旧 0xD2 push”这一极小窗口——
|
||||
* s_status 刚被 motorOtaStart 置为 HANDSHAKE_REQ,下方 suppress 后 ring 不再读,
|
||||
* 残留 ERROR 只可能在这几十 ms 内覆写。原 1500ms 基于 kBootSettleMs=800ms 时代,
|
||||
* GD32 v1.5.1 已移除残帧窗口(收 0x103 即进握手,握手后不再发 ERROR push),
|
||||
* 100ms 足够(用户指定)。 */
|
||||
const int64_t grace_end_ms = nowMs() + 100LL;
|
||||
|
||||
/* 重新启用 suppressRingService:ring service 抢占 SPI 带宽导致吐量从 ~5 chunks/s 降到 ~1.5 chunks/s。
|
||||
* 进度显示由 feedTask 自己基于 s_fw_offset 更新(近似值),不依赖 GD32 push。 */
|
||||
SpiMaster::instance().setSuppressRingService(true);
|
||||
|
||||
/* suppress 期间 s_status 不被 0xD2 push 更新(worker 不读 ring),
|
||||
* feedTask 无法靠 motorOtaGetStatus() 检测 GD32 是否已进 ERROR/FINISHED 终态。
|
||||
* 连续 SPI 失败计数:超过阈值时主动 QUERY GD32 真实状态,避免盲重试空转到 120s。 */
|
||||
uint32_t consec_fail = 0U;
|
||||
constexpr uint32_t kQueryAfterFails = 8U; /* 连续 8 次失败(~2s)后 QUERY 一次 */
|
||||
uint32_t consec_wait = 0U;
|
||||
constexpr uint32_t kQueryAfterWaits = 150U; /* 连续 150 次 WAIT(~300ms)后 QUERY:
|
||||
* 正常 buffer-full WAIT <100 次即恢复;
|
||||
* 超限说明 GD32 可能因 auto-retry 重置了
|
||||
* s_fw_fed=0,当前 offset 被 gap 拒收。 */
|
||||
uint32_t ack_n = 0U; /* 成功 ACK 计数,用于降频写 s_status */
|
||||
bool handshake_confirmed = false; /* GD32 已进 kTransfer/kErase,可标 TRANSFER */
|
||||
|
||||
while (s_feed_running && s_fw_offset < s_fw_size) {
|
||||
if (nowMs() >= deadline_ms) {
|
||||
ESP_LOGE(TAG, "feedTask 整体超时 (120s),强制退出 offset=%u/%u",
|
||||
(unsigned)s_fw_offset, (unsigned)s_fw_size);
|
||||
break;
|
||||
}
|
||||
|
||||
/* GD32 已进终态:停止喂,避免盲重试空转到 120s 超时。
|
||||
* 启动宽限期(100ms)内跳过终态检查:避免上一次 OTA 残留的 ERROR 状态推送
|
||||
* (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 */
|
||||
const motor_ota_status_t st = motorOtaGetStatus();
|
||||
if (nowMs() >= grace_end_ms) {
|
||||
if (isTerminalState(st.state)) {
|
||||
ESP_LOGW(TAG, "GD32 终态 0x%02X,停止喂数据 offset=%u/%u",
|
||||
(int)st.state, (unsigned)s_fw_offset, (unsigned)s_fw_size);
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* GD32 auto-retry 检测(v1.5.5+):GD32 SPI 断流/电机超时后自动重新握手,
|
||||
* 重置 s_fw_fed=0。ESP32 侧通过连续 WAIT→QUERY 检测到 state!=kTransfer 后
|
||||
* 重置 s_fw_offset=0 从头喂(见下方 kRspWait 分支)。
|
||||
* 注:v1.4.38~v1.5.4 曾移除此逻辑(当时无 auto-retry,握手失败即中止)。 */
|
||||
|
||||
const uint32_t remaining = s_fw_size - s_fw_offset;
|
||||
const uint32_t data_len = (remaining < MOTOR_OTA_CHUNK_DATA)
|
||||
? remaining : MOTOR_OTA_CHUNK_DATA;
|
||||
const uint32_t frame_len = MOTOR_OTA_CHUNK_HDR + data_len;
|
||||
|
||||
/* [0..3]=绝对 offset LE,[4..]=固件数据。重传同 offset 时 GD32 幂等。 */
|
||||
chunk_buf[0] = static_cast<uint8_t>(s_fw_offset & 0xFFU);
|
||||
chunk_buf[1] = static_cast<uint8_t>((s_fw_offset >> 8) & 0xFFU);
|
||||
chunk_buf[2] = static_cast<uint8_t>((s_fw_offset >> 16) & 0xFFU);
|
||||
chunk_buf[3] = static_cast<uint8_t>((s_fw_offset >> 24) & 0xFFU);
|
||||
esp_err_t rd = Gd32FlashStore::instance().flashReadChunk(
|
||||
s_fw_offset, chunk_buf + MOTOR_OTA_CHUNK_HDR, data_len);
|
||||
if (rd != ESP_OK) {
|
||||
ESP_LOGE(TAG, "flash read fail @%u: %s", (unsigned)s_fw_offset, esp_err_to_name(rd));
|
||||
break;
|
||||
}
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().transact(
|
||||
kSpiMotorOtaData,
|
||||
chunk_buf, frame_len,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
200U); /* 200ms: v1.4.59成功时的确切值。poll有~95ms 做多次盲读 */
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "SPI data fail: %s offset=%u", esp_err_to_name(ret), (unsigned)s_fw_offset);
|
||||
vTaskDelay(pdMS_TO_TICKS(10)); /* 快速重试(原 100ms 太慢导致吐量低) */
|
||||
/* suppress 期间 s_status 不更新,连续失败时主动 QUERY GD32 真实状态,
|
||||
* 避免 GD32 已进 ERROR 终态但 feedTask 不知道、盲重试空转到 120s 超时。 */
|
||||
if (++consec_fail >= kQueryAfterFails) {
|
||||
consec_fail = 0U;
|
||||
motor_ota_state_t qst;
|
||||
uint8_t qerr;
|
||||
if (queryMotorOtaStatus(&qst, &qerr, true) && isTerminalState(qst)) {
|
||||
ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u",
|
||||
(int)qst, (unsigned)qerr,
|
||||
(unsigned)s_fw_offset, (unsigned)s_fw_size);
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue; /* 不推进 offset,同 offset 重发 */
|
||||
}
|
||||
consec_fail = 0U;
|
||||
consec_wait = 0U;
|
||||
|
||||
if (resp_type == kRspAck) {
|
||||
s_fw_offset += data_len;
|
||||
/* 进度:每 8 chunk 写 s_status。
|
||||
* ★ 不再喂数据一成功就强标 TRANSFER:GD32 feedData() 在握手阶段
|
||||
* (kPreCancel/kHandshakeReq 等) 也接收预填充数据,若此刻强标
|
||||
* TRANSFER,无电机的空通道会让网页误显示"数据传输 N/912"而非
|
||||
* "握手中…→超时"。改为:只在 GD32 真正进 kErase/kTransfer 后
|
||||
* 才标 TRANSFER;握手未完成时保持握手态显示。
|
||||
* 握手未确认时每 8 个 ACK 重新 QUERY 一次(GD32 握手+擦除
|
||||
* 通常 1~3s,8 个 ACK ~16 个 chunk ≈ 3700B <1s,需多轮确认)。 */
|
||||
++ack_n;
|
||||
const bool touch_status = ((ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size);
|
||||
|
||||
/* 握手未确认时定期 QUERY:确认 GD32 已过握手阶段才标 TRANSFER */
|
||||
if (!handshake_confirmed && (ack_n & 7U) == 0U) {
|
||||
motor_ota_state_t qst;
|
||||
if (queryMotorOtaStatus(&qst, nullptr, false)) {
|
||||
if (qst == MOTOR_OTA_ERASE || qst == MOTOR_OTA_TRANSFER ||
|
||||
qst == MOTOR_OTA_FINISH_OTA || qst == MOTOR_OTA_WAIT_APP_RUN) {
|
||||
handshake_confirmed = true;
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
|
||||
s_status.state = MOTOR_OTA_TRANSFER;
|
||||
s_status.last_update_ms = nowMs();
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
}
|
||||
/* 终态(ERROR/IDLE/FINISHED)交给下方终态检测处理 */
|
||||
}
|
||||
}
|
||||
if (touch_status) {
|
||||
const uint16_t fed_pkts = static_cast<uint16_t>((s_fw_offset + 47U) / 48U);
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
|
||||
/* 握手未确认时不标 TRANSFER、不更新 packets_acked:
|
||||
* 此刻 fed_pkts 是喂给 GD32 环缓冲的字节数(预填充),
|
||||
* 不是电机真实 ACK,握手未成时显示会让用户误以为在传输。 */
|
||||
if (handshake_confirmed) {
|
||||
s_status.state = MOTOR_OTA_TRANSFER;
|
||||
s_status.packets_acked = fed_pkts;
|
||||
}
|
||||
s_status.last_update_ms = nowMs();
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
}
|
||||
} else if (resp_type == kRspWait) {
|
||||
/* 缓冲满 / gap 拒收:不推进 offset,稍候重试。
|
||||
* WAIT 路径 SPI 层已推进 seq,避免同 seq 重发被 GD32 当重复请求死锁。 */
|
||||
vTaskDelay(pdMS_TO_TICKS(2));
|
||||
/* 连续 WAIT 超限:可能 GD32 auto-retry 重置了 s_fw_fed=0,
|
||||
* 当前 offset 被 gap 拒收(永远不会变 ACK)。QUERY 确认真实状态。 */
|
||||
if (++consec_wait >= kQueryAfterWaits) {
|
||||
consec_wait = 0U;
|
||||
motor_ota_state_t qst;
|
||||
uint8_t qerr;
|
||||
if (queryMotorOtaStatus(&qst, &qerr, false)) {
|
||||
if (qst != MOTOR_OTA_TRANSFER) {
|
||||
/* GD32 不在传输态(auto-retry 回了 kPreCancel/握手):
|
||||
* 重置 offset 从头喂,配合 GD32 重新擦除+握手。 */
|
||||
ESP_LOGW(TAG, "GD32 retry detected: state=%u err=0x%02X, reset offset %u->0",
|
||||
(unsigned)qst, (unsigned)qerr, (unsigned)s_fw_offset);
|
||||
s_fw_offset = 0U;
|
||||
ack_n = 0U;
|
||||
handshake_confirmed = false; /* 重新握手,需重新确认 */
|
||||
} else {
|
||||
handshake_confirmed = true; /* GD32 在 transfer,确认握手完成 */
|
||||
}
|
||||
/* 终态仍 break */
|
||||
if (isTerminalState(qst)) {
|
||||
ESP_LOGW(TAG, "QUERY(WAIT) 终态 0x%02X err=0x%02X,停止",
|
||||
(unsigned)qst, (unsigned)qerr);
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
|
||||
s_status.state = qst;
|
||||
s_status.error_code = qerr;
|
||||
s_status.last_update_ms = nowMs();
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* 非 ACK/WAIT(含 ERROR/陈旧 type):不 break 整次 OTA。
|
||||
* 曾因 poll 读到 EVENT/错 type 直接 break → fed 卡首 chunk。 */
|
||||
ESP_LOGW(TAG, "SPI data unexpected type=0x%02X offset=%u,重试",
|
||||
resp_type, (unsigned)s_fw_offset);
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
}
|
||||
|
||||
/* 恢复 ring service,让后续 GD32 push(FINISHED/ERROR) 正常到达 */
|
||||
SpiMaster::instance().setSuppressRingService(false);
|
||||
|
||||
/* feedTask 字节喂完 ≠ GD32/电机已完成。suppress 期间 0xD2 被丢;
|
||||
* 末包 CAN ACK / 跳转 APP 还要数百 ms。只 QUERY 一次会把前端卡在 kTransfer。
|
||||
* 退出后轮询 QUERY,直到 GD32 进 FINISHED/ERROR/IDLE,或最多 ~8s。
|
||||
* 用户取消(s_feed_running 已被 motorOtaCancel 置 false)时跳过:
|
||||
* 设备端已进 IDLE、GD32 已 resetToIdle,无终态可等——立即收尾退出,
|
||||
* 把 cancel task 的等待窗口压到最短(也消除强删窗口)。 */
|
||||
{
|
||||
constexpr uint32_t kExitPollMs = 8000U;
|
||||
constexpr uint32_t kExitGapMs = 150U;
|
||||
const uint32_t exit_deadline = nowMs() + kExitPollMs;
|
||||
bool got_terminal = false;
|
||||
while (s_feed_running && nowMs() < exit_deadline) {
|
||||
/* 优先吃 ring 推送(已恢复 suppress) */
|
||||
{
|
||||
const motor_ota_status_t st = motorOtaGetStatus();
|
||||
if (isTerminalState(st.state)) {
|
||||
ESP_LOGI(TAG, "feedTask 退出,ring 终态: state=%u err=%u pkt=%u/%u",
|
||||
(unsigned)st.state, (unsigned)st.error_code,
|
||||
(unsigned)st.packets_acked, (unsigned)st.total_packets);
|
||||
got_terminal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
motor_ota_state_t qst;
|
||||
uint8_t qerr;
|
||||
if (queryMotorOtaStatus(&qst, &qerr, true)) {
|
||||
if (isTerminalState(qst)) {
|
||||
const motor_ota_status_t snap = motorOtaGetStatus();
|
||||
ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u",
|
||||
(unsigned)qst, (unsigned)qerr,
|
||||
(unsigned)snap.packets_acked, (unsigned)snap.total_packets);
|
||||
got_terminal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(kExitGapMs));
|
||||
}
|
||||
if (!got_terminal) {
|
||||
ESP_LOGW(TAG, "feedTask 退出轮询超时,前端可能仍显示传输中 offset=%u/%u",
|
||||
(unsigned)s_fw_offset, (unsigned)s_fw_size);
|
||||
}
|
||||
}
|
||||
|
||||
s_feed_running = false;
|
||||
s_feed_task = nullptr;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ═══════════════════ 公开接口 ═══════════════════ */
|
||||
|
||||
void motorOtaRelayInit(void)
|
||||
{
|
||||
if (!s_mutex) {
|
||||
s_mutex = xSemaphoreCreateMutex();
|
||||
}
|
||||
}
|
||||
|
||||
void onMotorOtaStatusPush(const uint8_t *payload, size_t len)
|
||||
{
|
||||
if (len < 12U || !s_mutex) return;
|
||||
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) != pdTRUE) return;
|
||||
|
||||
s_status.state = static_cast<motor_ota_state_t>(payload[0]);
|
||||
s_status.error_code = payload[1];
|
||||
s_status.packets_acked = static_cast<uint16_t>(payload[2]) | (static_cast<uint16_t>(payload[3]) << 8);
|
||||
s_status.total_packets = static_cast<uint16_t>(payload[4]) | (static_cast<uint16_t>(payload[5]) << 8);
|
||||
s_status.fw_size = static_cast<uint32_t>(payload[6])
|
||||
| (static_cast<uint32_t>(payload[7]) << 8)
|
||||
| (static_cast<uint32_t>(payload[8]) << 16)
|
||||
| (static_cast<uint32_t>(payload[9]) << 24);
|
||||
s_status.buf_used = static_cast<uint16_t>(payload[10]) | (static_cast<uint16_t>(payload[11]) << 8);
|
||||
s_status.last_update_ms = nowMs();
|
||||
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
int motorOtaStart(uint32_t fw_size, uint8_t channel)
|
||||
{
|
||||
if (fw_size == 0 || fw_size > MOTOR_OTA_MAX_FW_SIZE || channel > 2) {
|
||||
return -1;
|
||||
}
|
||||
if (s_feed_running) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* 固件已由 HTTP handler 流式写入 gd32_ota 分区;此处不再堆缓冲 */
|
||||
s_fw_size = fw_size;
|
||||
s_fw_offset = 0;
|
||||
|
||||
/* 发 SPI start 命令(best-effort,不阻塞等 ACK)。
|
||||
* SPI "延迟一拍"协议下,START 的 ACK 偶发被 send 阶段当作 stale 吃掉、
|
||||
* poll 反读空 → transact 报 TIMEOUT,但 GD32 实际已收到 START 进握手
|
||||
* (GD32 日志 "SPI 0xD0 START recv" + "START v150" 每次稳定出现)。
|
||||
* 重发 START 会被 GD32 拒(已在进行中→0xE5),QUERY 的 ACK 同样受
|
||||
* delayed-by-one 影响不可靠。改为 fire-and-forget:命令发出即继续,
|
||||
* 由 feedTask 靠 GD32 push 的状态 + 超时保护自决去留。 */
|
||||
uint8_t cmd[6];
|
||||
cmd[0] = 0U; /* cmd=START */
|
||||
cmd[1] = channel;
|
||||
cmd[2] = static_cast<uint8_t>(fw_size & 0xFF);
|
||||
cmd[3] = static_cast<uint8_t>((fw_size >> 8) & 0xFF);
|
||||
cmd[4] = static_cast<uint8_t>((fw_size >> 16) & 0xFF);
|
||||
cmd[5] = static_cast<uint8_t>((fw_size >> 24) & 0xFF);
|
||||
|
||||
uint8_t resp_type = 0;
|
||||
uint8_t resp_payload[16];
|
||||
size_t resp_len = 0;
|
||||
esp_err_t ret = SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, cmd, 6,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
300U); /* 300ms: fire-and-forget,让 worker 有足够时间完成 START。
|
||||
* 原 500ms 偏保守;GD32 收到 START 立即进 kPreCancel,<50ms 响应。 */
|
||||
if (ret == ESP_OK && resp_type != kRspAck && resp_len >= 1U &&
|
||||
resp_payload[0] == 0xE5U) {
|
||||
/* 0xE5 = GD32 端 relay 会话残留(上次 CANCEL 丢失/未达):不清会直接
|
||||
* 让本次 OTA 空转失败。补发 CANCEL(GD32 resetToIdle 归 kIdle)后
|
||||
* 重试一次 START,一次自愈即恢复,无需用户重试或刷新。 */
|
||||
DEBUG_WARN(TAG, "START 0xE5(GD32 残留会话)→ 补发 CANCEL 后重试 START");
|
||||
uint8_t ccmd[1] = {1U};
|
||||
uint8_t crt = 0;
|
||||
uint8_t crp[4] = {0};
|
||||
size_t crl = 0;
|
||||
SpiMaster::instance().resetCredit(); /* 解除背压,保证 CANCEL 发出 */
|
||||
(void)SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, ccmd, 1, &crt, crp, sizeof(crp), &crl, 500U);
|
||||
SpiMaster::instance().resetCredit(); /* GD32 已 resetToIdle,窗口对齐满 */
|
||||
ret = SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, cmd, 6,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
300U);
|
||||
}
|
||||
if (ret != ESP_OK || resp_type != kRspAck) {
|
||||
DEBUG_WARN(TAG, "START SPI 未确认 ACK (ret=%s resp=0x%02X)——fire-and-forget 继续",
|
||||
esp_err_to_name(ret), resp_type);
|
||||
}
|
||||
|
||||
/* 初始化状态 */
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
memset(&s_status, 0, sizeof(s_status));
|
||||
s_status.state = MOTOR_OTA_HANDSHAKE_REQ;
|
||||
s_status.fw_size = fw_size;
|
||||
s_status.total_packets = static_cast<uint16_t>((fw_size + 47U) / 48U);
|
||||
s_status.last_update_ms = nowMs();
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
/* 启动喂数据 task。prio=7 > spi_worker(6) > httpd(5):
|
||||
* 传输期优先抢 SPI 队列;实际总线访问仍经 worker 串行。 */
|
||||
s_feed_running = true;
|
||||
BaseType_t ok = xTaskCreate(feedTask, "ota_feed", 4096, nullptr, 7, &s_feed_task);
|
||||
if (ok != pdPASS) {
|
||||
s_feed_running = false;
|
||||
return -3;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Motor OTA started: %lu B, ch=%u", (unsigned long)fw_size, channel);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void motorOtaCancel(void)
|
||||
{
|
||||
DEBUG_WARN(TAG, "cancel: feed_running=%d task=%p offset=%u/%u",
|
||||
(int)s_feed_running, s_feed_task,
|
||||
(unsigned)s_fw_offset, (unsigned)s_fw_size);
|
||||
|
||||
/* 立即标记 false,让 feedTask 在下次循环检查时退出。
|
||||
* motorOtaIsActive() 依赖此标志,设 false 后后续 OTA 不再被阻塞。 */
|
||||
s_feed_running = false;
|
||||
|
||||
/* 标记状态为 IDLE,让前端 /motor/ota/status 立即看到空闲 */
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
s_status.state = MOTOR_OTA_IDLE;
|
||||
s_status.last_update_ms = nowMs();
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
/* 通知 GD32 取消 relay 状态机 + 等 feedTask 退出是耗时操作
|
||||
*(SPI transact 最多 1s + feedTask 退出最长 ~8.2s),不能在 httpd 线程
|
||||
* 同步阻塞,否则 cancel HTTP 请求本身会超时("Failed to fetch"),
|
||||
* 且 httpd 单线程会饿死其他端点。
|
||||
* 用独立 task 异步执行,HTTP handler 立即返回。 */
|
||||
static TaskHandle_t cancel_task = nullptr;
|
||||
if (cancel_task != nullptr) {
|
||||
/* 上一次 cancel 还在跑,不重复创建 */
|
||||
DEBUG_WARN(TAG, "cancel: previous cancel task still running, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
auto do_cancel = [](void *) {
|
||||
/* ★ 先复位 credit 再发 CANCEL:取消的典型时机恰是传输卡住(GD32 缓冲满,
|
||||
* 最后一帧 RESPONSE credit=0 已锁存进 m_remoteCredit)——不先解除背压,
|
||||
* CANCEL 自己会被 CREDIT 背压 refuse,GD32 端 relay 会话收不到取消而残留,
|
||||
* 下次 START 被 GD32 0xE5 拒 → "取消后重新开始没法开始"。
|
||||
* GD32 收到 CANCEL 后 resetToIdle 恢复满窗口,与复位值对齐。 */
|
||||
SpiMaster::instance().resetCredit();
|
||||
uint8_t cmd[1] = {1U}; /* cmd=CANCEL */
|
||||
uint8_t resp_type = 0;
|
||||
uint8_t resp_payload[4];
|
||||
size_t resp_len = 0;
|
||||
esp_err_t ret = SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, cmd, 1,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
1000U);
|
||||
if (ret != ESP_OK) {
|
||||
/* 重试一次:再复位 credit(防首次期间又被失配帧锁 0)后重发 */
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
SpiMaster::instance().resetCredit();
|
||||
ret = SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, cmd, 1,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
1000U);
|
||||
}
|
||||
DEBUG_WARN(TAG, "cancel: GD32 resp ret=%s type=0x%02X", esp_err_to_name(ret), resp_type);
|
||||
|
||||
/* 等待 feedTask 真正退出。feedTask 最坏存活:主循环收尾 ≤0.2s +
|
||||
* 退出轮询 8s ≈ 8.2s(120s 整体超时路径同样有界),等待上限取 10s
|
||||
* 覆盖之——正常路径 feedTask 因 s_status 已被置 IDLE 在 ~0.3s 内退出,
|
||||
* 这里只是兜底;尽量不强删(强删会泄漏 job 槽 + worker 悬垂栈写)。 */
|
||||
TaskHandle_t t = s_feed_task;
|
||||
if (t) {
|
||||
for (int i = 0; i < 1000 && s_feed_task != nullptr; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
if (s_feed_task != nullptr) {
|
||||
DEBUG_ERR(TAG, "cancel: feedTask 未在 10s 内退出,强制删除");
|
||||
vTaskDelete(s_feed_task);
|
||||
s_feed_task = nullptr;
|
||||
/* 强删绕过了 feedTask 收尾,suppress 必须在此恢复,
|
||||
* 否则 ring service 永久关闭 → GD32 推送全部收不到。 */
|
||||
SpiMaster::instance().setSuppressRingService(false);
|
||||
}
|
||||
}
|
||||
s_feed_running = false; /* 双保险 */
|
||||
/* credit 复位:取消的典型时机是传输卡住(GD32 缓冲满)——最后一帧
|
||||
* RESPONSE 的 credit=0(session 合法)被锁存进 m_remoteCredit。
|
||||
* GD32 resetToIdle 后转空闲不再发帧,relay 又跑在 APP(不重启、
|
||||
* 无 session=0 帧)→ 没有任何 resync 入口能把 credit 送回来,
|
||||
* 后续所有 transact 被 CREDIT 背压拒绝 → 网页所有消息收不到。
|
||||
* GD32 端 clearOtaSessionQueues 已把接收窗口恢复满,此处对齐复位。 */
|
||||
SpiMaster::instance().resetCredit();
|
||||
cancel_task = nullptr;
|
||||
vTaskDelete(NULL);
|
||||
};
|
||||
|
||||
BaseType_t ok = xTaskCreate(do_cancel, "ota_cancel", 3072, nullptr, 4, &cancel_task);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "cancel: failed to create cancel task, doing sync cancel");
|
||||
cancel_task = nullptr;
|
||||
/* 降级:同步执行(极端 OOM 情况) */
|
||||
SpiMaster::instance().resetCredit(); /* 先解除背压,保证 CANCEL 能发出 */
|
||||
uint8_t cmd[1] = {1U};
|
||||
uint8_t resp_type = 0;
|
||||
uint8_t resp_payload[4];
|
||||
size_t resp_len = 0;
|
||||
(void)SpiMaster::instance().transact(
|
||||
kSpiMotorOtaCmd, cmd, 1,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
1000U);
|
||||
TaskHandle_t t = s_feed_task;
|
||||
if (t) {
|
||||
for (int i = 0; i < 200 && s_feed_task != nullptr; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
if (s_feed_task != nullptr) {
|
||||
vTaskDelete(s_feed_task);
|
||||
s_feed_task = nullptr;
|
||||
SpiMaster::instance().setSuppressRingService(false);
|
||||
}
|
||||
}
|
||||
s_feed_running = false;
|
||||
SpiMaster::instance().resetCredit(); /* 同 do_cancel:防 credit=0 锁存死锁 */
|
||||
}
|
||||
DEBUG_WARN(TAG, "cancel: dispatched feed_running=%d", (int)s_feed_running);
|
||||
}
|
||||
|
||||
bool motorOtaIsActive(void)
|
||||
{
|
||||
return s_feed_running;
|
||||
}
|
||||
|
||||
motor_ota_status_t motorOtaGetStatus(void)
|
||||
{
|
||||
motor_ota_status_t out = {};
|
||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
out = s_status;
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* motor_ota_relay.h - 电机 OTA relay ESP32 侧(固件缓冲 + SPI 喂数据 + 进度缓存)
|
||||
*
|
||||
* 两段式流程(与 BLE 刷 GD32 对齐,避免收固件期间与 GD32 按铃争抢单缓冲 MISO):
|
||||
* 1. 前端 POST /motor/ota 上传固件 + channel → 流式写 gd32_ota flash 分区,写 meta
|
||||
* 2. 前端 POST /motor/ota/relay?ch=X → motorOtaStart 发 SPI 0xD0(start) + 启动 feedTask
|
||||
* 3. feedTask 从 flash 分块读,经 SPI 0xD1(data) 喂 GD32
|
||||
* 4. GD32 通过 0xD2 ring push 报进度,ESP32 缓存
|
||||
* 5. 前端 GET /motor/ota/status 轮询进度
|
||||
*/
|
||||
#ifndef MOTOR_OTA_RELAY_H
|
||||
#define MOTOR_OTA_RELAY_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MOTOR_OTA_MAX_FW_SIZE 0xC000U /* 48 KB */
|
||||
/* SPI payload 上限 236 = 256 - V2_OVERHEAD(20)。前 4B 为绝对 offset LE,数据最多 232B。
|
||||
* 无 offset 时 SPI 超时重传会被 GD32 重复追加(BLF:前 236B 循环 → 刷坏)。 */
|
||||
#define MOTOR_OTA_CHUNK_HDR 4U
|
||||
#define MOTOR_OTA_CHUNK_DATA 232U
|
||||
#define MOTOR_OTA_CHUNK_SIZE (MOTOR_OTA_CHUNK_HDR + MOTOR_OTA_CHUNK_DATA)
|
||||
|
||||
typedef enum {
|
||||
MOTOR_OTA_IDLE = 0,
|
||||
MOTOR_OTA_HANDSHAKE_REQ,
|
||||
MOTOR_OTA_HANDSHAKE_NONCE,
|
||||
MOTOR_OTA_HANDSHAKE_CONFIRM,
|
||||
MOTOR_OTA_ERASE,
|
||||
MOTOR_OTA_TRANSFER,
|
||||
MOTOR_OTA_FINISH_OTA, /* 收到 0xF0F0F0F0,已发 FINISH_OTA,等跳转 ACK */
|
||||
MOTOR_OTA_FINISHED,
|
||||
MOTOR_OTA_ERROR,
|
||||
MOTOR_OTA_PRE_CANCEL = 9, /* 握手前预取消:发 CANCEL 清理电机残留会话(GD32 侧过渡态) */
|
||||
MOTOR_OTA_WAIT_APP_RUN = 10 /* 尾包 ACK 后等新 APP 自主发 [0A AC] 运行确认,收到才 FINISHED */
|
||||
} motor_ota_state_t;
|
||||
|
||||
typedef struct {
|
||||
motor_ota_state_t state;
|
||||
uint8_t error_code;
|
||||
uint16_t packets_acked;
|
||||
uint16_t total_packets;
|
||||
uint32_t fw_size;
|
||||
uint16_t buf_used; /* GD32 侧缓冲占用 */
|
||||
int64_t last_update_ms; /* ESP32 收到状态的时间 */
|
||||
} motor_ota_status_t;
|
||||
|
||||
/**
|
||||
* @brief 处理 GD32 0xD2 ring push(由 can_feedback_task 分发)。
|
||||
*/
|
||||
void onMotorOtaStatusPush(const uint8_t *payload, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 启动电机 OTA:发 SPI start + 启动喂数据 task。
|
||||
*
|
||||
* 固件须已由调用方(HTTP handler)流式写入 gd32_ota flash 分区
|
||||
* (Gd32FlashStore::flashWriteChunk,offset 0 起)。喂数据 task 从
|
||||
* flash 分块读取并经 SPI 0xD1 喂给 GD32,不在堆上缓冲整个固件
|
||||
* (48KB 整块 malloc 会超出 ESP32-C3 的 largest_free_block,导致 OOM)。
|
||||
*
|
||||
* @param fw_size 固件大小 (≤48KB),须与已写入 flash 的大小一致
|
||||
* @param channel CAN 通道 (0-2)
|
||||
* @return 0 成功, -1 参数错误, -2 已在进行中, -3 SPI 失败
|
||||
*/
|
||||
int motorOtaStart(uint32_t fw_size, uint8_t channel);
|
||||
|
||||
/**
|
||||
* @brief 电机 OTA 是否正在进行(喂数据 task 存活)。
|
||||
* 供 OtaCoordinator 在 beginGd32Storage/beginEsp32Ota 中检查:
|
||||
* 电机喂数期间禁止新的 GD32/ESP32 OTA,避免 gd32_ota 分区被并发覆写。
|
||||
*/
|
||||
bool motorOtaIsActive(void);
|
||||
|
||||
/**
|
||||
* @brief 取消电机 OTA。
|
||||
*/
|
||||
void motorOtaCancel(void);
|
||||
|
||||
/**
|
||||
* @brief 获取最新状态(线程安全)。
|
||||
*/
|
||||
motor_ota_status_t motorOtaGetStatus(void);
|
||||
|
||||
/**
|
||||
* @brief 模块初始化(创建 mutex 等)。
|
||||
*/
|
||||
void motorOtaRelayInit(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* MOTOR_OTA_RELAY_H */
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* motor_state.cpp - GD32 多电机状态推送解析与缓存 (SPI TYPE 0xC5)
|
||||
*/
|
||||
#include "monitor/motor_state.h"
|
||||
#include "monitor/spi_ring_cache.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
static SemaphoreHandle_t s_mux = nullptr;
|
||||
static motor_state_table_t s_table = {};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool onMotorStateEvent(const uint8_t *payload, size_t len)
|
||||
{
|
||||
if (payload == nullptr || len < MOTOR_PAGE_HEADER) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t page = payload[0];
|
||||
const uint8_t total_pages = payload[1];
|
||||
const uint8_t entry_count = payload[2];
|
||||
const uint8_t active_cnt = payload[3];
|
||||
|
||||
if (page >= MOTOR_TOTAL_PAGES || total_pages == 0 || total_pages > MOTOR_TOTAL_PAGES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* 验证长度足够容纳声明的 entry 数 */
|
||||
const size_t expected = MOTOR_PAGE_HEADER + static_cast<size_t>(entry_count) * MOTOR_ENTRY_SIZE;
|
||||
if (len < expected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) return false;
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
|
||||
|
||||
/* page 0 时刷新全局字段。
|
||||
* active 字节 bit7 = boot_active(总线上有电机在 BOOT,0x103 心跳),
|
||||
* 低 7 位才是真实在线电机数。 */
|
||||
if (page == 0) {
|
||||
s_table.active_count = static_cast<uint8_t>(active_cnt & 0x7FU);
|
||||
s_table.boot_active = (active_cnt & 0x80U) ? 1U : 0U;
|
||||
}
|
||||
|
||||
/* 逐条解析 entry:node×channel 双键槽位,同节点号多通道互不覆盖 */
|
||||
size_t offset = MOTOR_PAGE_HEADER;
|
||||
for (uint8_t i = 0; i < entry_count; ++i) {
|
||||
const uint8_t *e = &payload[offset];
|
||||
const uint8_t node = e[0];
|
||||
const uint8_t ch = e[1];
|
||||
if (node < 1 || node > MOTOR_MAX_NODES || ch >= MOTOR_CHANNELS) {
|
||||
offset += MOTOR_ENTRY_SIZE;
|
||||
continue;
|
||||
}
|
||||
|
||||
motor_slot_t &slot = s_table.slots[(node - 1) * MOTOR_CHANNELS + ch];
|
||||
slot.node_id = node;
|
||||
slot.channel = ch;
|
||||
slot.online = e[2];
|
||||
slot.dlc = e[3];
|
||||
slot.rx_count = rdU32Le(&e[4]);
|
||||
slot.seq = rdU32Le(&e[8]);
|
||||
memcpy(slot.data, &e[12], MOTOR_STATUS_DLC);
|
||||
/* v0.74: STATUS DLC 48,CONFIG 字段整体后移 16B */
|
||||
slot.config_rx_count = rdU32Le(&e[60]);
|
||||
slot.config_seq = rdU32Le(&e[64]);
|
||||
memcpy(slot.config_data, &e[68], MOTOR_CONFIG_DLC);
|
||||
|
||||
offset += MOTOR_ENTRY_SIZE;
|
||||
}
|
||||
|
||||
/* 统计有效槽位 */
|
||||
uint8_t valid = 0;
|
||||
for (uint8_t i = 0; i < MOTOR_MAX_SLOTS; ++i) {
|
||||
if (s_table.slots[i].node_id != 0) valid++;
|
||||
}
|
||||
s_table.valid_slots = valid;
|
||||
s_table.recv_ms = esp_timer_get_time() / 1000;
|
||||
s_table.have_data = true;
|
||||
|
||||
xSemaphoreGive(s_mux);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool getLatestMotorState(motor_state_table_t *out)
|
||||
{
|
||||
if (out == nullptr) return false;
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) return false;
|
||||
|
||||
bool ok = false;
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
|
||||
if (s_table.have_data) {
|
||||
*out = s_table;
|
||||
ok = true;
|
||||
}
|
||||
xSemaphoreGive(s_mux);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
uint32_t motorStateAgeMs(void)
|
||||
{
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) return UINT32_MAX;
|
||||
|
||||
int64_t recv = 0;
|
||||
bool have = false;
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
|
||||
have = s_table.have_data;
|
||||
recv = s_table.recv_ms;
|
||||
xSemaphoreGive(s_mux);
|
||||
}
|
||||
return ringCacheAgeMs(have, recv);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* motor_state.h - 被动接收 GD32 多电机状态推送(SPI TYPE 0xC5)
|
||||
*
|
||||
* 与 CAN 0xC1 / ADC 0xC4 同模型:GD32 250ms 主动按铃推送(独立任务位),ESP32 takeEvent 侧解析入库。
|
||||
* 是否推送由 GD32 CONFIG_MOTOR_STATE_ENABLE 决定;ESP32 无本地开关,有帧即解析。
|
||||
*
|
||||
* payload 格式(每页):
|
||||
* [0] page, [1] total_pages, [2] entry_count, [3] active_count
|
||||
* [4..] 每个 entry 92B (v0.74: STATUS DLC 48):
|
||||
* [0] node_id, [1] channel, [2] online, [3] dlc,
|
||||
* [4..7] rx_count (LE), [8..11] seq (LE),
|
||||
* [12..59] data[48] (原始 STATUS payload, 字节 32~35 = InitStatus)
|
||||
* [60..63] config_rx_count (LE), [64..67] config_seq (LE),
|
||||
* [68..91] config_data[24] (原始 CONFIG payload, 目标值回报)
|
||||
*
|
||||
* 注意:STATUS DLC 48 与页大小/页数变更(76→92、9→14 页)需 DCU GD32 与
|
||||
* ESP32 固件成对升级,旧解析方会因长度校验失败拒收(不误解析)。
|
||||
*
|
||||
* 同一节点号可能同时挂在多个 CAN 通道(两台电机同号异线),
|
||||
* 缓存表与 GD32 端一致按 node×channel 双键存储(27 槽)。
|
||||
*/
|
||||
#ifndef MOTOR_STATE_H
|
||||
#define MOTOR_STATE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MOTOR_MAX_NODES 9
|
||||
#define MOTOR_CHANNELS 3
|
||||
#define MOTOR_MAX_SLOTS (MOTOR_MAX_NODES * MOTOR_CHANNELS)
|
||||
#define MOTOR_STATUS_DLC 48
|
||||
#define MOTOR_CONFIG_DLC 24
|
||||
#define MOTOR_ENTRY_SIZE 92
|
||||
#define MOTOR_PAGE_HEADER 4
|
||||
#define MOTOR_TOTAL_PAGES 14
|
||||
|
||||
/** @brief 单电机状态快照。 */
|
||||
typedef struct {
|
||||
uint8_t node_id; ///< 1-9, 0=空槽
|
||||
uint8_t channel; ///< CAN 通道 (0/1/2)
|
||||
uint8_t online; ///< 1=在线, 0=离线
|
||||
uint8_t dlc; ///< STATUS 帧 DLC (正常=48)
|
||||
uint32_t rx_count; ///< 累计收到帧数
|
||||
uint32_t seq; ///< GD32 侧更新序号
|
||||
uint8_t data[MOTOR_STATUS_DLC]; ///< 原始 STATUS payload
|
||||
uint32_t config_rx_count; ///< 累计收到 CONFIG 帧数
|
||||
uint32_t config_seq; ///< CONFIG 更新序号
|
||||
uint8_t config_data[MOTOR_CONFIG_DLC]; ///< 原始 CONFIG payload (目标值回报)
|
||||
} motor_slot_t;
|
||||
|
||||
/** @brief 全局电机状态表。 */
|
||||
typedef struct {
|
||||
motor_slot_t slots[MOTOR_MAX_SLOTS]; ///< 按 (node_id-1)*MOTOR_CHANNELS+channel 索引
|
||||
uint8_t active_count; ///< 在线电机数
|
||||
uint8_t boot_active; ///< 1=总线上有电机在 BOOT(0x103 心跳,来自 0xC5 头 bit7)
|
||||
uint8_t valid_slots; ///< 有效槽位数(node_id!=0)
|
||||
int64_t recv_ms; ///< ESP32 最近收到时 esp_timer ms
|
||||
bool have_data; ///< 至少收到过一帧
|
||||
} motor_state_table_t;
|
||||
|
||||
/**
|
||||
* @brief 处理一帧 0xC5 EVENT payload(由 can_fb 任务 takeEvent 分发)。
|
||||
* @return true 表示已接受并更新。
|
||||
*/
|
||||
bool onMotorStateEvent(const uint8_t *payload, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 拷贝最新电机状态表(线程安全)。
|
||||
* @param[out] out 输出缓冲
|
||||
* @return true 表示至少收到过一帧
|
||||
*/
|
||||
bool getLatestMotorState(motor_state_table_t *out);
|
||||
|
||||
/** @brief 距离上次收到的毫秒数;无数据返回 UINT32_MAX。 */
|
||||
uint32_t motorStateAgeMs(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* MOTOR_STATE_H */
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* motor_version.cpp - GD32 多电机版本推送解析与缓存 (SPI TYPE 0xC6)
|
||||
*
|
||||
* 镜像 motor_state.cpp 的互斥保护缓存模型。
|
||||
*/
|
||||
#include "monitor/motor_version.h"
|
||||
#include "monitor/spi_ring_cache.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
static SemaphoreHandle_t s_mux = nullptr;
|
||||
static motor_version_table_t s_table = {};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool onMotorVersionEvent(const uint8_t *payload, size_t len)
|
||||
{
|
||||
if (payload == nullptr || len < MOTORVER_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t count = payload[0];
|
||||
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) return false;
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
|
||||
|
||||
size_t offset = MOTORVER_HEADER_LEN;
|
||||
for (uint8_t i = 0; i < count; ++i) {
|
||||
if (offset + MOTORVER_ENTRY_SIZE > len) {
|
||||
break;
|
||||
}
|
||||
const uint8_t node = payload[offset];
|
||||
if (node >= 1 && node <= MOTORVER_MAX_NODES) {
|
||||
motor_version_slot_t &slot = s_table.slots[node - 1];
|
||||
slot.node_id = node;
|
||||
slot.seq = rdU32Le(&payload[offset + 1]);
|
||||
memcpy(slot.version, &payload[offset + 5], MOTORVER_DLC);
|
||||
slot.recv_ms = esp_timer_get_time() / 1000;
|
||||
slot.valid = true;
|
||||
s_table.have_data = true;
|
||||
}
|
||||
offset += MOTORVER_ENTRY_SIZE;
|
||||
}
|
||||
|
||||
xSemaphoreGive(s_mux);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool getLatestMotorVersion(motor_version_table_t *out)
|
||||
{
|
||||
if (out == nullptr) return false;
|
||||
ringCacheEnsureMux(&s_mux);
|
||||
if (s_mux == nullptr) return false;
|
||||
|
||||
bool ok = false;
|
||||
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
|
||||
if (s_table.have_data) {
|
||||
*out = s_table;
|
||||
ok = true;
|
||||
}
|
||||
xSemaphoreGive(s_mux);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* motor_version.h - 被动接收 GD32 多电机版本推送(SPI TYPE 0xC6)
|
||||
*
|
||||
* 与 0xC5 电机状态同模型:GD32 收到电机版本应答(0x00FF)后事件驱动按铃推送,
|
||||
* ESP32 takeEvent 侧解析入库。版本为按需查询的低频数据,独立于 0xC5 周期推送。
|
||||
* 是否推送由 GD32 CONFIG_MOTOR_VERSION_ENABLE 决定;ESP32 无本地开关,有帧即解析。
|
||||
*
|
||||
* payload 格式(单帧,Header 2B + 每 entry 29B):
|
||||
* [0] count 本帧节点数
|
||||
* [1] rsvd 保留
|
||||
* [2..] 每个 entry 29B:
|
||||
* [0] node_id, [1..4] seq (LE), [5..28] version[24] (原始版本 payload)
|
||||
*/
|
||||
#ifndef MOTOR_VERSION_H
|
||||
#define MOTOR_VERSION_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MOTORVER_MAX_NODES 9
|
||||
#define MOTORVER_DLC 24
|
||||
#define MOTORVER_ENTRY_SIZE 29 /* node(1) + seq(4) + version(24) */
|
||||
#define MOTORVER_HEADER_LEN 2
|
||||
|
||||
/** @brief 单电机版本快照。 */
|
||||
typedef struct {
|
||||
uint8_t node_id; ///< 1-9, 0=空槽
|
||||
uint32_t seq; ///< GD32 侧更新序号(判新鲜用)
|
||||
uint8_t version[MOTORVER_DLC]; ///< 原始版本 payload
|
||||
int64_t recv_ms; ///< ESP32 收到时 esp_timer ms
|
||||
bool valid; ///< 该槽已收到过版本
|
||||
} motor_version_slot_t;
|
||||
|
||||
/** @brief 全局电机版本表。 */
|
||||
typedef struct {
|
||||
motor_version_slot_t slots[MOTORVER_MAX_NODES]; ///< 按 node_id-1 索引
|
||||
bool have_data; ///< 至少收到过一帧
|
||||
} motor_version_table_t;
|
||||
|
||||
/**
|
||||
* @brief 处理一帧 0xC6 EVENT payload(由 can_fb 任务 takeEvent 分发)。
|
||||
* @return true 表示已接受并更新。
|
||||
*/
|
||||
bool onMotorVersionEvent(const uint8_t *payload, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 拷贝最新电机版本表(线程安全)。
|
||||
* @param[out] out 输出缓冲
|
||||
* @return true 表示至少收到过一帧
|
||||
*/
|
||||
bool getLatestMotorVersion(motor_version_table_t *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* MOTOR_VERSION_H */
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* spi_ring_cache.h - SPI ring push 缓存共享辅助
|
||||
*
|
||||
* gd32_adc_feedback / motor_state / motor_version 三个模块共用相同的
|
||||
* 互斥懒创建 + 小端读 + ageMs 计算样板,提取到此处消除三份拷贝。
|
||||
*/
|
||||
#ifndef SPI_RING_CACHE_H
|
||||
#define SPI_RING_CACHE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── 小端读 ── */
|
||||
|
||||
static inline uint16_t rdU16Le(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
|
||||
}
|
||||
|
||||
static inline uint32_t rdU32Le(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0]
|
||||
| ((uint32_t)p[1] << 8)
|
||||
| ((uint32_t)p[2] << 16)
|
||||
| ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
/* ── 互斥懒创建 ── */
|
||||
|
||||
static inline void ringCacheEnsureMux(SemaphoreHandle_t *mux)
|
||||
{
|
||||
if (*mux == nullptr) {
|
||||
*mux = xSemaphoreCreateMutex();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 通用 ageMs ──
|
||||
* 传入缓存的 have 标志 + recv_ms 时间戳,返回距 now 的毫秒数。
|
||||
* 无数据或 recv_ms<=0 返回 UINT32_MAX。
|
||||
*/
|
||||
static inline uint32_t ringCacheAgeMs(bool have, int64_t recv_ms)
|
||||
{
|
||||
if (!have || recv_ms <= 0) return UINT32_MAX;
|
||||
const int64_t now = esp_timer_get_time() / 1000;
|
||||
if (now < recv_ms) return 0U;
|
||||
const int64_t age = now - recv_ms;
|
||||
return (age > (int64_t)UINT32_MAX) ? UINT32_MAX : (uint32_t)age;
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // SPI_RING_CACHE_H
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @file spi_speed_test.cpp
|
||||
* @brief 【临时功能 SPI-SPEEDTEST】SPI 链路测速端点 GET /spi/speedtest
|
||||
*
|
||||
* 用 0xA1 APP PING(无副作用、GD32 在 onCsRelease ISR 内 µs 级应答)作探针,
|
||||
* 经 SpiMaster::transact() 走与业务完全相同的 worker 串行路径,
|
||||
* 测量事件驱动握手(ready 下降沿唤醒)下的真实 RTT 与等效吞吐。
|
||||
*
|
||||
* 用法:
|
||||
* GET /spi/speedtest # 默认 200 帧 × 236B payload
|
||||
* GET /spi/speedtest?frames=500&payload=0 # 500 帧空载(纯 RTT)
|
||||
* 返回中文纯文本: 帧数/成功/失败、总耗时、往返延迟 平均/最快/最慢、等效吞吐
|
||||
*
|
||||
* ── 删除本临时功能的步骤(共 3 处挂接点,全部以 [SPI-SPEEDTEST] 标记)──
|
||||
* 1. 删除本文件与 monitor/spi_speed_test.h
|
||||
* 2. main/CMakeLists.txt 删除 "monitor/spi_speed_test.cpp" 一行
|
||||
* 3. server/wifi_ota.cpp 删除 include 与 kHttpRoutes 中各一行
|
||||
* (spi_master.cpp 中 0xA1 零盲等白名单为正式优化,保留不删)
|
||||
*/
|
||||
#include "spi_speed_test.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "esp_timer.h"
|
||||
#include "spi_master.h"
|
||||
#include "http_handlers.h" /* http_set_cors_headers */
|
||||
#include "server/http_util.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint8_t kProbeType = 0xA1U; /* APP PING:无副作用探针 */
|
||||
constexpr uint32_t kFramesDefault = 200U;
|
||||
constexpr uint32_t kFramesMax = 2000U; /* ~3s 上限,避免占死 HTTP/worker */
|
||||
constexpr size_t kPayloadDefault = 236U; /* 满帧:等效 0xD1 电机 OTA 数据节奏 */
|
||||
constexpr size_t kPayloadMax = 236U; /* APP kTxBufSize=256 - V2_OVERHEAD */
|
||||
constexpr uint32_t kFrameTimeoutMs = 500U;
|
||||
|
||||
} // namespace
|
||||
|
||||
esp_err_t spi_speedtest_handler(httpd_req_t *req)
|
||||
{
|
||||
uint32_t frames = http_query_u32(req, "frames", kFramesDefault);
|
||||
if (frames < 1U) { frames = 1U; }
|
||||
if (frames > kFramesMax) { frames = kFramesMax; }
|
||||
size_t payload_len = (size_t)http_query_u32(req, "payload", (uint32_t)kPayloadDefault);
|
||||
if (payload_len > kPayloadMax) { payload_len = kPayloadMax; }
|
||||
|
||||
/* 探针 payload:递增填充(CRC32 覆盖,顺带校验数据路径完整性) */
|
||||
uint8_t probe[kPayloadMax];
|
||||
for (size_t i = 0; i < payload_len; ++i) {
|
||||
probe[i] = (uint8_t)i;
|
||||
}
|
||||
|
||||
uint32_t n_ok = 0U;
|
||||
uint32_t n_fail = 0U;
|
||||
uint64_t rtt_sum_us = 0U;
|
||||
uint64_t rtt_min_us = UINT64_MAX;
|
||||
uint64_t rtt_max_us = 0U;
|
||||
/* RTT 分布直方图(µs 计时,<2ms 区间细化为 0.5ms 粒度):
|
||||
* <0.5 / 0.5-1 / 1-1.5 / 1.5-2 / 2-3 / 3-5 / >=5 ms,
|
||||
* 用于判断抖动形态(<2ms 内的尖峰/双峰 vs 离散 +1ms 碰撞) */
|
||||
uint32_t histo[7] = {0U, 0U, 0U, 0U, 0U, 0U, 0U};
|
||||
/* 事务耗时(worker 内执行段,SpiMaster::lastWorkerExecUs)与
|
||||
* 调度/唤醒开销(RTT − 事务)拆解:事务慢=GD32 应答/SPI 时序;
|
||||
* 调度慢=ESP32 任务抢占(WiFi/esp_timer 等)。 */
|
||||
uint64_t w_sum_us = 0U, w_min_us = UINT64_MAX, w_max_us = 0U;
|
||||
uint64_t s_sum_us = 0U, s_min_us = UINT64_MAX, s_max_us = 0U;
|
||||
/* 5 段细分(SpiMaster::SpiProfile):提交/调度、发送帧、等应答、读响应、唤醒 */
|
||||
uint64_t seg_sum[5] = {0U, 0U, 0U, 0U, 0U};
|
||||
uint64_t seg_max[5] = {0U, 0U, 0U, 0U, 0U};
|
||||
|
||||
DEBUG_INFO("SPI", "[SPEEDTEST] start frames=%u payload=%u",
|
||||
(unsigned)frames, (unsigned)payload_len);
|
||||
const int64_t t_start = esp_timer_get_time();
|
||||
|
||||
for (uint32_t i = 0U; i < frames; ++i) {
|
||||
uint8_t resp_type = 0U;
|
||||
uint8_t resp_payload[64];
|
||||
size_t resp_len = 0U;
|
||||
|
||||
const int64_t t0 = esp_timer_get_time();
|
||||
const esp_err_t ret = SpiMaster::instance().transact(
|
||||
kProbeType, probe, payload_len,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
kFrameTimeoutMs);
|
||||
const int64_t t1 = esp_timer_get_time();
|
||||
|
||||
if (ret == ESP_OK && resp_type == 0x80U) {
|
||||
const uint64_t rtt = (uint64_t)(t1 - t0);
|
||||
++n_ok;
|
||||
rtt_sum_us += rtt;
|
||||
if (rtt < rtt_min_us) { rtt_min_us = rtt; }
|
||||
if (rtt > rtt_max_us) { rtt_max_us = rtt; }
|
||||
const uint64_t wus = SpiMaster::instance().lastWorkerExecUs();
|
||||
const uint64_t sus = (rtt > wus) ? (rtt - wus) : 0U;
|
||||
w_sum_us += wus;
|
||||
if (wus < w_min_us) { w_min_us = wus; }
|
||||
if (wus > w_max_us) { w_max_us = wus; }
|
||||
s_sum_us += sus;
|
||||
if (sus < s_min_us) { s_min_us = sus; }
|
||||
if (sus > s_max_us) { s_max_us = sus; }
|
||||
/* 5 段细分(防回绕钳 0) */
|
||||
const SpiMaster::SpiProfile &pf = SpiMaster::instance().lastProfile();
|
||||
const uint64_t seg[5] = {
|
||||
(pf.t_w0 > pf.t_submit0) ? (pf.t_w0 - pf.t_submit0) : 0U,
|
||||
(pf.t_send1 > pf.t_send0) ? (pf.t_send1 - pf.t_send0) : 0U,
|
||||
(pf.t_wait1 > pf.t_wait0) ? (pf.t_wait1 - pf.t_wait0) : 0U,
|
||||
(pf.t_read1 > pf.t_wait1) ? (pf.t_read1 - pf.t_wait1) : 0U,
|
||||
(pf.t_submit1 > pf.t_w1) ? (pf.t_submit1 - pf.t_w1) : 0U,
|
||||
};
|
||||
for (int k = 0; k < 5; ++k) {
|
||||
seg_sum[k] += seg[k];
|
||||
if (seg[k] > seg_max[k]) { seg_max[k] = seg[k]; }
|
||||
}
|
||||
if (rtt < 500U) { ++histo[0]; }
|
||||
else if (rtt < 1000U) { ++histo[1]; }
|
||||
else if (rtt < 1500U) { ++histo[2]; }
|
||||
else if (rtt < 2000U) { ++histo[3]; }
|
||||
else if (rtt < 3000U) { ++histo[4]; }
|
||||
else if (rtt < 5000U) { ++histo[5]; }
|
||||
else { ++histo[6]; }
|
||||
} else {
|
||||
++n_fail;
|
||||
}
|
||||
}
|
||||
|
||||
const int64_t elapsed_us = esp_timer_get_time() - t_start;
|
||||
const uint32_t elapsed_ms = (uint32_t)(elapsed_us / 1000);
|
||||
const uint64_t rtt_avg_us = (n_ok > 0U) ? (rtt_sum_us / n_ok) : 0U;
|
||||
/* 吞吐语义:payload 字节 / 完整"发送+应答"周期,等效 0xD1 单缓冲喂速上限 */
|
||||
double tput_kbps = 0.0;
|
||||
if (elapsed_us > 0 && n_ok > 0U) {
|
||||
tput_kbps = ((double)n_ok * (double)payload_len) / ((double)elapsed_us / 1000000.0) / 1024.0;
|
||||
}
|
||||
if (rtt_min_us == UINT64_MAX) { rtt_min_us = 0U; }
|
||||
if (w_min_us == UINT64_MAX) { w_min_us = 0U; }
|
||||
if (s_min_us == UINT64_MAX) { s_min_us = 0U; }
|
||||
|
||||
DEBUG_INFO("SPI", "[SPEEDTEST] done ok=%u fail=%u avg=%uus tput=%.1fKB/s",
|
||||
(unsigned)n_ok, (unsigned)n_fail, (unsigned)rtt_avg_us, tput_kbps);
|
||||
|
||||
char text[640];
|
||||
const int len = snprintf(text, sizeof(text),
|
||||
"SPI 测速结果\n"
|
||||
"帧数: %u 成功: %u 失败: %u%s\n"
|
||||
"载荷: %u B/帧 总耗时: %u ms\n"
|
||||
"往返延迟: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
|
||||
"SPI 事务: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
|
||||
"调度开销: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
|
||||
"单帧分段 平均: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
|
||||
"单帧分段 最慢: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
|
||||
"延迟分布: <0.5ms:%u 0.5-1ms:%u 1-1.5ms:%u 1.5-2ms:%u 2-3ms:%u 3-5ms:%u >=5ms:%u\n"
|
||||
"等效吞吐: %.1f KB/s\n",
|
||||
(unsigned)frames, (unsigned)n_ok, (unsigned)n_fail,
|
||||
(n_fail > 0U) ? " <-- 存在失败帧, 链路异常!" : "",
|
||||
(unsigned)payload_len, (unsigned)elapsed_ms,
|
||||
(unsigned long long)rtt_avg_us, (unsigned long long)rtt_min_us,
|
||||
(unsigned long long)rtt_max_us,
|
||||
(unsigned long long)(w_sum_us / (n_ok > 0U ? n_ok : 1U)),
|
||||
(unsigned long long)w_min_us, (unsigned long long)w_max_us,
|
||||
(unsigned long long)(s_sum_us / (n_ok > 0U ? n_ok : 1U)),
|
||||
(unsigned long long)s_min_us, (unsigned long long)s_max_us,
|
||||
(unsigned long long)(seg_sum[0] / (n_ok > 0U ? n_ok : 1U)),
|
||||
(unsigned long long)(seg_sum[1] / (n_ok > 0U ? n_ok : 1U)),
|
||||
(unsigned long long)(seg_sum[2] / (n_ok > 0U ? n_ok : 1U)),
|
||||
(unsigned long long)(seg_sum[3] / (n_ok > 0U ? n_ok : 1U)),
|
||||
(unsigned long long)(seg_sum[4] / (n_ok > 0U ? n_ok : 1U)),
|
||||
(unsigned long long)seg_max[0], (unsigned long long)seg_max[1],
|
||||
(unsigned long long)seg_max[2], (unsigned long long)seg_max[3],
|
||||
(unsigned long long)seg_max[4],
|
||||
(unsigned)histo[0], (unsigned)histo[1], (unsigned)histo[2],
|
||||
(unsigned)histo[3], (unsigned)histo[4],
|
||||
(unsigned)histo[5], (unsigned)histo[6], tput_kbps);
|
||||
|
||||
http_set_cors_headers(req);
|
||||
httpd_resp_set_type(req, "text/plain; charset=utf-8");
|
||||
return httpd_resp_send(req, text, (len > 0) ? len : 0);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @file spi_speed_test.h
|
||||
* @brief 【临时功能 SPI-SPEEDTEST】SPI 链路测速端点声明。
|
||||
* 删除方法见 spi_speed_test.cpp 文件头。
|
||||
*/
|
||||
#ifndef SPI_SPEED_TEST_H
|
||||
#define SPI_SPEED_TEST_H
|
||||
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/** @brief GET /spi/speedtest — 0xA1 探针测 SPI RTT/吞吐(临时功能)。 */
|
||||
esp_err_t spi_speedtest_handler(httpd_req_t *req);
|
||||
|
||||
#endif // SPI_SPEED_TEST_H
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* task_stack.cpp - 任务栈水位收集 + FreeRTOS 栈溢出钩子
|
||||
*
|
||||
* 水位表:health_mon 巡检 + /system/status 共用。
|
||||
* 溢出钩子:CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY 下内核回调;
|
||||
* 被踩栈上下文禁止 mutex/网络/格式化,仅 UART0 硬写 + abort。
|
||||
*/
|
||||
#include "task_stack.h"
|
||||
|
||||
#include <cstring>
|
||||
#include "app_config.h"
|
||||
#include "health_monitor.h"
|
||||
#include "cpu_stats.h"
|
||||
#include "ota_relay.h"
|
||||
#include "wifi_ota.h"
|
||||
#include "ble_ota.h"
|
||||
#include "spi_master.h"
|
||||
#include "can_feedback_task.h"
|
||||
#include "driver/uart.h"
|
||||
#include "esp_system.h"
|
||||
|
||||
// ============================================================
|
||||
// 栈溢出钩子
|
||||
// ============================================================
|
||||
volatile bool g_stackOverflowDetected = false;
|
||||
|
||||
extern "C" void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
|
||||
{
|
||||
(void)xTask;
|
||||
g_stackOverflowDetected = true;
|
||||
|
||||
static const char prefix[] = "\r\n!!! STACK OVERFLOW in task: ";
|
||||
uart_write_bytes(UART_NUM_0, prefix, sizeof(prefix) - 1);
|
||||
uart_write_bytes(UART_NUM_0, pcTaskName, strnlen(pcTaskName, configMAX_TASK_NAME_LEN));
|
||||
static const char suffix[] = " !!!\r\n";
|
||||
uart_write_bytes(UART_NUM_0, suffix, sizeof(suffix) - 1);
|
||||
|
||||
/* OTA 末尾若 httpd 栈溢出却不 abort,前端会一直停在 100%。 */
|
||||
esp_system_abort("stack overflow");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务栈水位表
|
||||
// ============================================================
|
||||
size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max)
|
||||
{
|
||||
if (!out || max == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t n = 0;
|
||||
|
||||
// httpd 无公开句柄;未 httpd_start 时 xTaskGetHandle 为 NULL
|
||||
if (n < max) {
|
||||
out[n++] = { "http_server", xTaskGetHandle("httpd"), HTTP_SERVER_STACK_SIZE };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "health_mon", HealthMonitor::instance().getTaskHandle(), TASK_STACK_HEALTH_MON };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "cpu_stats", CpuStats::instance().getTaskHandle(), TASK_STACK_CPU_STATS };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), TASK_STACK_OTA_RELAY };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "spi_worker", SpiMaster::instance().getWorkerTaskHandle(), TASK_STACK_SPI_WORKER };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), TASK_STACK_WIFI_OTA };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, TASK_STACK_WIFI_RECONNECT };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "ble_ota", BleOta::instance().getTaskHandle(), TASK_STACK_BLE_OTA };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), TASK_STACK_BLE_WORKER };
|
||||
}
|
||||
if (n < max) {
|
||||
out[n++] = { "can_fb", getCanFeedbackTaskHandle(), TASK_STACK_CAN_FB };
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* task_stack.h - 任务栈:水位收集 + 溢出钩子
|
||||
*
|
||||
* 1) collectTaskStackMetrics:供 health_mon 巡检与 /system/status 共用任务表
|
||||
* 2) vApplicationStackOverflowHook / g_stackOverflowDetected:canary 溢出
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** canary 触发时由 vApplicationStackOverflowHook 置位(随后 abort)。 */
|
||||
extern volatile bool g_stackOverflowDetected;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
/** @brief 单个任务的栈指标描述。 */
|
||||
struct TaskStackMetric {
|
||||
const char* name; ///< 任务名(展示用)
|
||||
TaskHandle_t handle; ///< 句柄(可能为 NULL:任务尚未创建)
|
||||
size_t stack_size_bytes; ///< 创建时分配的栈大小(字节)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 收集受监控任务的栈指标。
|
||||
* @return 实际写入条数(<= max)。handle==NULL 的条目由调用方跳过。
|
||||
*/
|
||||
size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max);
|
||||
|
||||
#endif /* __cplusplus */
|
||||
@@ -0,0 +1,468 @@
|
||||
/*
|
||||
* esp32_ota_core.cpp - ESP32 本机 OTA 核心流程(WiFi / BLE 通道共用)
|
||||
*
|
||||
* SHA-256 在写入完成后从 OTA 分区读回计算;finalize 用 m_ended 区分 end 与 abort。
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_partition.h"
|
||||
#include "esp_task_wdt.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
|
||||
#include "mbedtls/private/sha256.h"
|
||||
|
||||
#include "esp32_ota_core.h"
|
||||
#include "app_config.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "spi_master.h" /* setSpiPaused:OTA 期间暂停 SPI,避免 flash 写与 SPI 读并发 */
|
||||
|
||||
#define TAG "OTA_CORE"
|
||||
|
||||
// ============================================================
|
||||
// 分区互斥锁
|
||||
// ============================================================
|
||||
SemaphoreHandle_t Esp32OtaCore::s_mutex = NULL;
|
||||
volatile bool Esp32OtaCore::s_occupied = false;
|
||||
|
||||
void Esp32OtaCore::initMutex()
|
||||
{
|
||||
if (!s_mutex) {
|
||||
s_mutex = xSemaphoreCreateMutex();
|
||||
s_occupied = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Esp32OtaCore::acquireMutex(uint32_t timeout_ms)
|
||||
{
|
||||
if (!s_mutex) return false;
|
||||
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) return false;
|
||||
if (s_occupied) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return false;
|
||||
}
|
||||
s_occupied = true;
|
||||
xSemaphoreGive(s_mutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Esp32OtaCore::releaseMutex()
|
||||
{
|
||||
if (!s_mutex) return;
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
s_occupied = false;
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 获取 Esp32OtaCore 单例实例。
|
||||
* @return Esp32OtaCore 引用。
|
||||
*/
|
||||
Esp32OtaCore& Esp32OtaCore::instance()
|
||||
{
|
||||
static Esp32OtaCore inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 复位所有状态变量。
|
||||
*/
|
||||
void Esp32OtaCore::resetState()
|
||||
{
|
||||
m_active = false;
|
||||
m_handle = 0;
|
||||
m_partition = nullptr;
|
||||
m_received = 0;
|
||||
m_shaComputed = false;
|
||||
memset(m_sha256, 0, sizeof(m_sha256));
|
||||
m_ended = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief begin() 失败后的统一清理。
|
||||
* 仅释放 mutex + 复位状态(不调 esp_ota_abort,
|
||||
* begin 失败时 m_handle 未成功创建,无需也不能 abort)。
|
||||
* @param code 返回给调用方的错误码。
|
||||
* @return 透传 code。
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::beginCleanupAndReturn(esp_err_t code)
|
||||
{
|
||||
releaseMutex();
|
||||
resetState();
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief finalize() 失败后的统一清理。
|
||||
* 若 esp_ota_end 未成功(m_ended=false),调 esp_ota_abort 释放 handle;
|
||||
* 然后复位 m_ended、释放 mutex、复位状态,返回 code。
|
||||
* 与现有 abort() 的 m_ended 守卫语义一致,避免 end 成功后误调 abort。
|
||||
* @param code 返回给调用方的错误码。
|
||||
* @return 透传 code。
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::finalizeCleanupAndReturn(esp_err_t code)
|
||||
{
|
||||
if (!m_ended) {
|
||||
esp_ota_abort(m_handle);
|
||||
}
|
||||
m_ended = false;
|
||||
releaseMutex();
|
||||
SpiMaster::instance().setSpiPaused(false); /* OTA 结束(失败也恢复 SPI) */
|
||||
resetState();
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 开启 OTA 会话。
|
||||
* @param total_size 固件总大小(字节)。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 被占用,ESP_FAIL 其他错误。
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::begin(size_t total_size)
|
||||
{
|
||||
// ★ 全局互斥:必须在清理旧会话之前检查,
|
||||
// 否则 WiFi 的 begin() 会先释放 BLE 持有的锁,再自己获取,导致互斥失效
|
||||
if (!acquireMutex(1000)) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 若本核心已有未完成会话(同一通道的重试),先中止并释放全局互斥
|
||||
if (m_active) {
|
||||
DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA 会话,先中止旧会话");
|
||||
if (!m_ended) {
|
||||
esp_ota_abort(m_handle);
|
||||
}
|
||||
releaseMutex();
|
||||
resetState();
|
||||
// 重新获取全局互斥(刚刚释放了)
|
||||
if (!acquireMutex(1000)) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 重新获取互斥失败");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
|
||||
DEBUG_DBG(TAG, "当前启动分区: %s, size=%u, address=0x%lx",
|
||||
boot_partition->label, boot_partition->size, boot_partition->address);
|
||||
|
||||
// 选择非当前启动的 OTA 分区(OTA_0 → OTA_1 fallback)
|
||||
m_partition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
|
||||
if (m_partition == NULL ||
|
||||
m_partition->address == boot_partition->address) {
|
||||
m_partition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
|
||||
}
|
||||
|
||||
if (m_partition == NULL) {
|
||||
DEBUG_ERR(TAG, "找不到可用的 OTA 分区");
|
||||
return beginCleanupAndReturn(ESP_FAIL);
|
||||
}
|
||||
|
||||
DEBUG_DBG(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx",
|
||||
m_partition->label, m_partition->size, m_partition->address);
|
||||
|
||||
if (total_size > m_partition->size) {
|
||||
DEBUG_ERR(TAG, "固件太大: %u > 分区大小: %u",
|
||||
(unsigned)total_size, (unsigned)m_partition->size);
|
||||
return beginCleanupAndReturn(ESP_FAIL);
|
||||
}
|
||||
|
||||
/* ★ 边写边擦(OTA_WITH_SEQUENTIAL_WRITES),不再整分区预擦:
|
||||
* 旧方案在 HTTP 只收到 JSON 头后同步擦 1.25MB,浏览器 TCP 窗口堵死、进度卡开头。
|
||||
* sequential:begin 立即返回,擦除摊到 esp_ota_write,与收流并行(对 WiFi 更友好)。 */
|
||||
#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) || defined(CONFIG_APP_ROLLBACK_ENABLE)
|
||||
{
|
||||
esp_err_t inv = esp_ota_invalidate_inactive_ota_data_slot();
|
||||
if (inv != ESP_OK && inv != ESP_ERR_NOT_SUPPORTED) {
|
||||
DEBUG_WARN(TAG, "invalidate inactive OTA slot: %s(继续)",
|
||||
esp_err_to_name(inv));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const int64_t t0 = esp_timer_get_time();
|
||||
esp_err_t err = esp_ota_begin(m_partition, OTA_WITH_SEQUENTIAL_WRITES, &m_handle);
|
||||
const int64_t t1 = esp_timer_get_time();
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "ota_begin fail:%s %lldms",
|
||||
esp_err_to_name(err), (long long)((t1 - t0) / 1000));
|
||||
return beginCleanupAndReturn(err);
|
||||
}
|
||||
DEBUG_DBG(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu",
|
||||
(long long)((t1 - t0) / 1000),
|
||||
(unsigned long)esp_get_free_heap_size());
|
||||
|
||||
m_received = 0;
|
||||
m_active = true;
|
||||
m_shaComputed = false;
|
||||
m_ended = false;
|
||||
memset(m_sha256, 0, sizeof(m_sha256));
|
||||
|
||||
/* ESP32 自我 OTA 期间暂停 SPI(GD32 按铃读 + 业务 transact 全部停摆):
|
||||
* esp_ota_write 写 flash 时 cache 反复失效,SPI worker 的 flash 驻留代码
|
||||
* 访问会被拖慢甚至触发 Cache error,且此时 GD32 数据无意义。
|
||||
* finalize()/abort() 恢复。 */
|
||||
SpiMaster::instance().setSpiPaused(true);
|
||||
|
||||
DEBUG_DBG(TAG, "ESP32 OTA begin part=%s size=%u (erase-on-write)",
|
||||
m_partition->label, (unsigned)total_size);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 写入固件数据块。
|
||||
* @param data 数据缓冲区。
|
||||
* @param len 数据长度。
|
||||
* @return ESP_OK 成功,否则错误码。
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (!m_active || m_partition == nullptr) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* 整分区已预擦:此处为纯 write,不再扇区擦除。 */
|
||||
(void)esp_task_wdt_reset();
|
||||
esp_err_t err = esp_ota_write(m_handle, data, len);
|
||||
if (err == ESP_OK) {
|
||||
m_received += len;
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "ota_write %s n=%u off=%u",
|
||||
esp_err_to_name(err), (unsigned)len, (unsigned)m_received);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 从 OTA 分区读回数据并计算 SHA-256。
|
||||
* @return ESP_OK 成功,否则错误码。
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::computeShaFromPartition()
|
||||
{
|
||||
if (m_partition == NULL || m_received == 0) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
DEBUG_DBG(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received);
|
||||
|
||||
/* static context + 小读缓冲:勿占 httpd 栈,也勿再静态吃 4KB BSS */
|
||||
static mbedtls_sha256_context s_sha_ctx;
|
||||
static uint8_t s_sha_read_buf[1024];
|
||||
|
||||
mbedtls_sha256_init(&s_sha_ctx);
|
||||
mbedtls_sha256_starts(&s_sha_ctx, 0);
|
||||
|
||||
size_t offset = 0;
|
||||
uint32_t wdt_chunk = 0;
|
||||
while (offset < m_received) {
|
||||
size_t to_read = m_received - offset;
|
||||
if (to_read > sizeof(s_sha_read_buf)) to_read = sizeof(s_sha_read_buf);
|
||||
esp_err_t rd_err = esp_partition_read(m_partition, offset, s_sha_read_buf, to_read);
|
||||
if (rd_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "SHA读失败 off=%u %s",
|
||||
(unsigned)offset, esp_err_to_name(rd_err));
|
||||
mbedtls_sha256_free(&s_sha_ctx);
|
||||
return rd_err;
|
||||
}
|
||||
mbedtls_sha256_update(&s_sha_ctx, s_sha_read_buf, to_read);
|
||||
offset += to_read;
|
||||
/* 长固件 SHA 可能 > TASK_WDT(5s);在 httpd 上跑时必须喂狗 */
|
||||
if ((++wdt_chunk & 0x0FU) == 0U) {
|
||||
(void)esp_task_wdt_reset();
|
||||
taskYIELD();
|
||||
}
|
||||
}
|
||||
|
||||
mbedtls_sha256_finish(&s_sha_ctx, m_sha256);
|
||||
mbedtls_sha256_free(&s_sha_ctx);
|
||||
m_shaComputed = true;
|
||||
|
||||
char hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02x", m_sha256[i]);
|
||||
}
|
||||
DEBUG_DBG(TAG, "设备端 SHA-256: %s", hex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 计算设备端 SHA-256 并输出 hex 字符串。
|
||||
* @param out_device_sha_hex 输出缓冲区,至少 65 字节。
|
||||
* @return ESP_OK 成功,否则错误码。
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::computeDeviceSha(char *out_device_sha_hex)
|
||||
{
|
||||
if (!m_active || m_partition == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
// 若已计算过,直接复用
|
||||
if (!m_shaComputed) {
|
||||
esp_err_t err = computeShaFromPartition();
|
||||
if (err != ESP_OK) return err;
|
||||
}
|
||||
if (out_device_sha_hex) {
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&out_device_sha_hex[i * 2], 65 - i * 2, "%02x", m_sha256[i]);
|
||||
}
|
||||
out_device_sha_hex[64] = '\0';
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 收尾 OTA 会话:校验 SHA、end、set_boot、可选重启。
|
||||
* @param frontend_sha_hex 前端提供的 64 字符 hex SHA-256。
|
||||
* @param reboot 成功后是否重启。
|
||||
* @param out_device_sha_hex 输出设备端 SHA hex 缓冲区。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_VERSION SHA 不匹配,否则错误码。
|
||||
*/
|
||||
esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
|
||||
char *out_device_sha_hex)
|
||||
{
|
||||
if (!m_active) {
|
||||
DEBUG_ERR(TAG, "finalize: OTA 未激活");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// ── 先读回分区计算 SHA-256,并与前端文件 SHA 比对 ──
|
||||
if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) {
|
||||
DEBUG_DBG(TAG, "ESP32 OTA 校验 SHA-256...");
|
||||
esp_err_t sha_err = computeShaFromPartition();
|
||||
if (sha_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "SHA失败:%s 中止", esp_err_to_name(sha_err));
|
||||
return finalizeCleanupAndReturn(sha_err);
|
||||
}
|
||||
|
||||
char device_hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02x", m_sha256[i]);
|
||||
}
|
||||
|
||||
bool sha_match = true;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (tolower((unsigned char)frontend_sha_hex[i]) !=
|
||||
tolower((unsigned char)device_hex[i])) {
|
||||
sha_match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (out_device_sha_hex) {
|
||||
strncpy(out_device_sha_hex, device_hex, 64);
|
||||
out_device_sha_hex[64] = '\0';
|
||||
}
|
||||
|
||||
if (sha_match) {
|
||||
DEBUG_DBG(TAG, "SHA-256 校验通过");
|
||||
} else {
|
||||
/* 与正常刷写失败一致:先报异常,再分 2 行打两个 SHA-256 */
|
||||
DEBUG_ERR(TAG, "SHA-256 不匹配");
|
||||
DEBUG_ERR(TAG, "frontend=%s", frontend_sha_hex);
|
||||
DEBUG_ERR(TAG, "device=%s", device_hex);
|
||||
return finalizeCleanupAndReturn(ESP_ERR_INVALID_VERSION);
|
||||
}
|
||||
} else if (out_device_sha_hex) {
|
||||
// 未提供前端 SHA,但仍计算设备端 SHA 供输出
|
||||
if (computeShaFromPartition() == ESP_OK) {
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&out_device_sha_hex[i * 2], 65 - i * 2, "%02x", m_sha256[i]);
|
||||
}
|
||||
out_device_sha_hex[64] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// ── esp_ota_end ──
|
||||
esp_err_t err = esp_ota_end(m_handle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "esp_ota_end 失败: %s (0x%x)", esp_err_to_name(err), (unsigned)err);
|
||||
// end 失败时仍可 abort(未真正 end)
|
||||
return finalizeCleanupAndReturn(err);
|
||||
}
|
||||
m_ended = true; // ★ end 成功,后续不得再 abort
|
||||
|
||||
// ── 设置启动分区 ──
|
||||
err = esp_ota_set_boot_partition(m_partition);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
|
||||
releaseMutex();
|
||||
SpiMaster::instance().setSpiPaused(false); /* OTA 失败也恢复 SPI */
|
||||
resetState();
|
||||
return err;
|
||||
}
|
||||
|
||||
releaseMutex();
|
||||
SpiMaster::instance().setSpiPaused(false); /* OTA 收尾完成,恢复 SPI 业务 */
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received);
|
||||
|
||||
// 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理
|
||||
// (此处不 reset,以便 BLE finish task 在重启前可查询状态)
|
||||
|
||||
if (reboot) {
|
||||
DEBUG_INFO(TAG, "500ms 后重启 ESP32");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
esp_restart();
|
||||
// 不会返回
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 中止 OTA 会话并清理资源。
|
||||
*/
|
||||
void Esp32OtaCore::abort()
|
||||
{
|
||||
if (!m_active) {
|
||||
return;
|
||||
}
|
||||
if (!m_ended) {
|
||||
esp_ota_abort(m_handle);
|
||||
}
|
||||
releaseMutex();
|
||||
SpiMaster::instance().setSpiPaused(false); /* OTA 中止,恢复 SPI 业务 */
|
||||
resetState();
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 强制退出 OTA 状态机并释放互斥锁。
|
||||
*
|
||||
* 用于新通道(WiFi/BLE)发现锁被残留占用时,主动走完整的状态机退出流程:
|
||||
* 1. 若会话活跃(m_active)→ 走 abort() 完整清理(esp_ota_abort + 释放锁 + 复位状态)
|
||||
* 2. 若会话不活跃但锁被占(s_occupied)→ 仅释放残留锁
|
||||
* 3. 否则无需操作
|
||||
*
|
||||
* @return true 执行了清理(调用方可重试 begin),false 无需清理。
|
||||
*/
|
||||
bool Esp32OtaCore::forceCleanup()
|
||||
{
|
||||
if (m_active) {
|
||||
// 会话仍活跃(前一个通道未正常结束),走完整 abort 流程
|
||||
DEBUG_WARN(TAG, "强制清理: 中止活跃的 OTA 会话 (received=%u)", (unsigned)m_received);
|
||||
abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 会话不活跃,但锁可能被残留占用
|
||||
if (!s_mutex) return false;
|
||||
|
||||
// 在互斥保护下检查 s_occupied
|
||||
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) != pdTRUE) return false;
|
||||
|
||||
bool was_occupied = s_occupied;
|
||||
if (was_occupied) {
|
||||
s_occupied = false;
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
|
||||
if (was_occupied) {
|
||||
DEBUG_WARN(TAG, "强制清理: 释放残留 OTA 锁(无活跃会话)");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* esp32_ota_core.h - ESP32 本机 OTA 核心流程(WiFi / BLE 通道共用)
|
||||
*
|
||||
* 将 ESP32 自身固件升级的完整生命周期封装为单例:
|
||||
* begin → write*(N) → finalize / abort
|
||||
*
|
||||
* 统一以 WiFi 流程为基准:
|
||||
* - begin:esp_ota_begin(OTA_WITH_SEQUENTIAL_WRITES) 边写边擦(避免 HTTP 头卡预擦)
|
||||
* - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算)
|
||||
* - finalize 顺序: 读回 SHA-256 → 比对前端 SHA → esp_ota_end → esp_ota_set_boot_partition → 可选重启
|
||||
* - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle
|
||||
*
|
||||
* WiFi 与 BLE 仅传输方式不同(HTTP / GATT),OTA 写入与收尾逻辑完全复用本核心。
|
||||
*/
|
||||
#ifndef ESP32_OTA_CORE_H
|
||||
#define ESP32_OTA_CORE_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_partition.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
class Esp32OtaCore {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 Esp32OtaCore 单例实例。
|
||||
* @return Esp32OtaCore 引用。
|
||||
*/
|
||||
static Esp32OtaCore& instance();
|
||||
|
||||
// ── 阶段 1:开启 OTA 会话 ──
|
||||
// total_size: 固件总大小(字节),用于分区大小校验和传给 esp_ota_begin
|
||||
// 返回 ESP_OK / ESP_ERR_INVALID_STATE(被其它通道占用)/ ESP_FAIL
|
||||
/**
|
||||
* @brief 开启 OTA 会话。
|
||||
* @param total_size 固件总大小(字节)。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 被占用,ESP_FAIL 其他错误。
|
||||
*/
|
||||
esp_err_t begin(size_t total_size);
|
||||
|
||||
// ── 阶段 2:写入固件数据(可在 begin 后多次调用)──
|
||||
// 纯 esp_ota_write,不做增量 SHA(SHA 在 finalize 时统一从分区读回计算)
|
||||
/**
|
||||
* @brief 写入固件数据块。
|
||||
* @param data 数据缓冲区。
|
||||
* @param len 数据长度。
|
||||
* @return ESP_OK 成功,否则错误码。
|
||||
*/
|
||||
esp_err_t write(const uint8_t *data, size_t len);
|
||||
|
||||
// ── 阶段 3:收尾(校验 SHA → end → set_boot → 可选重启)──
|
||||
// frontend_sha_hex: 前端提供的 64 字符 hex SHA-256,nullptr 表示跳过校验直接应用
|
||||
// reboot: true 则 set_boot 成功后延时 500ms 调 esp_restart()
|
||||
// out_device_sha_hex: 输出设备端计算出的 SHA hex(缓冲区至少 65 字节),可为 nullptr
|
||||
// 返回:
|
||||
// ESP_OK 成功(若 reboot=true,本调用不会返回)
|
||||
// ESP_ERR_INVALID_VERSION SHA 不匹配(已 abort 并清理)
|
||||
// 其他 end/set_boot 失败(已清理)
|
||||
/**
|
||||
* @brief 收尾 OTA 会话:校验 SHA、end、set_boot、可选重启。
|
||||
* @param frontend_sha_hex 前端提供的 64 字符 hex SHA-256。
|
||||
* @param reboot 成功后是否重启。
|
||||
* @param out_device_sha_hex 输出设备端 SHA hex 缓冲区。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_VERSION SHA 不匹配。
|
||||
*/
|
||||
esp_err_t finalize(const char *frontend_sha_hex, bool reboot,
|
||||
char *out_device_sha_hex);
|
||||
|
||||
// ── 仅计算设备端 SHA(不 end、不 set_boot、不重启)──
|
||||
// 供前端只读校验场景(如 WiFi verify_only 模式)
|
||||
// out_device_sha_hex: 输出缓冲区,至少 65 字节
|
||||
// 返回 ESP_OK / 失败
|
||||
/**
|
||||
* @brief 仅计算设备端 SHA(不 end、不 set_boot、不重启)。
|
||||
* @param out_device_sha_hex 输出缓冲区,至少 65 字节。
|
||||
* @return ESP_OK 成功,否则错误码。
|
||||
*/
|
||||
esp_err_t computeDeviceSha(char *out_device_sha_hex);
|
||||
|
||||
// ── 中止并清理(abort + 释放 mutex + 状态复位)──
|
||||
// 若 esp_ota_end 已成功(m_ended=true),则跳过 esp_ota_abort 仅复位状态
|
||||
/**
|
||||
* @brief 中止并清理 OTA 会话。
|
||||
*/
|
||||
void abort();
|
||||
|
||||
// ── 强制退出状态机并释放互斥锁 ──
|
||||
// 用于新通道发现锁被残留占用时,主动走完整的状态机退出流程:
|
||||
// 活跃会话 → abort();仅锁残留 → 直接释放;均无 → 不操作
|
||||
// 返回 true = 执行了清理(调用方可重试 begin)
|
||||
/**
|
||||
* @brief 强制退出 OTA 状态机并释放互斥锁。
|
||||
* @return true 执行了清理,false 无需清理。
|
||||
*/
|
||||
bool forceCleanup();
|
||||
|
||||
// ── 状态查询 ──
|
||||
/** @brief 查询 OTA 是否激活。 */
|
||||
bool isActive() const { return m_active; }
|
||||
/** @brief 获取已接收的字节数。 */
|
||||
size_t getReceived() const { return m_received; }
|
||||
|
||||
// ── 分区互斥锁管理 ──
|
||||
/**
|
||||
* @brief 初始化 ESP32 OTA 分区互斥锁(在 app_main 中调用)。
|
||||
*/
|
||||
static void initMutex();
|
||||
private:
|
||||
Esp32OtaCore() = default;
|
||||
|
||||
// ── 分区互斥锁 ──
|
||||
static SemaphoreHandle_t s_mutex; ///< ESP32 OTA 分区互斥锁
|
||||
static volatile bool s_occupied; ///< 分区占用标志
|
||||
static bool acquireMutex(uint32_t timeout_ms);
|
||||
static void releaseMutex();
|
||||
|
||||
// 从 OTA 分区读回计算 SHA-256(WiFi 风格),结果写入 m_sha256,置 m_shaComputed=true
|
||||
/**
|
||||
* @brief 从 OTA 分区读回计算 SHA-256。
|
||||
* @return ESP_OK 成功,否则错误码。
|
||||
*/
|
||||
esp_err_t computeShaFromPartition();
|
||||
|
||||
// 状态全复位(不调 esp_ota_abort,由调用方根据 m_ended 决定)
|
||||
/**
|
||||
* @brief 复位所有状态(不调 esp_ota_abort)。
|
||||
*/
|
||||
void resetState();
|
||||
|
||||
// begin() 失败后的统一清理:释放 mutex + resetState() + 返回 code
|
||||
// 不调 esp_ota_abort(begin 失败时 m_handle 未成功创建)
|
||||
/**
|
||||
* @brief begin() 失败后的统一清理。
|
||||
* @param code 返回给调用方的错误码。
|
||||
* @return 透传 code。
|
||||
*/
|
||||
esp_err_t beginCleanupAndReturn(esp_err_t code);
|
||||
|
||||
// finalize() 失败后的统一清理:若 m_ended=false 调 esp_ota_abort 释放 handle;
|
||||
// 再复位 m_ended + 释放 mutex + resetState() + 返回 code
|
||||
/**
|
||||
* @brief finalize() 失败后的统一清理。
|
||||
* @param code 返回给调用方的错误码。
|
||||
* @return 透传 code。
|
||||
*/
|
||||
esp_err_t finalizeCleanupAndReturn(esp_err_t code);
|
||||
|
||||
bool m_active = false;
|
||||
esp_ota_handle_t m_handle = 0;
|
||||
const esp_partition_t *m_partition = nullptr;
|
||||
size_t m_received = 0;
|
||||
uint8_t m_sha256[32] = {0};
|
||||
bool m_shaComputed = false;
|
||||
bool m_ended = false; // ★ esp_ota_end 成功后置 true,防 abort 误用
|
||||
};
|
||||
|
||||
#endif // ESP32_OTA_CORE_H
|
||||
@@ -0,0 +1,839 @@
|
||||
/*
|
||||
* ota_coordinator.cpp - 全局 OTA 协调器实现
|
||||
*
|
||||
* OtaCoordinator 是系统级 OTA 状态的唯一权威来源。
|
||||
* 传输层(WiFi/BLE)通过本协调器发起所有 OTA 操作。
|
||||
*/
|
||||
#include "ota_coordinator.h"
|
||||
#include "monitor/can_feedback_task.h"
|
||||
#include "monitor/motor_ota_relay.h"
|
||||
#include "esp32_ota_core.h"
|
||||
#include "relay/ota_relay.h"
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "server/ble_ota.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include <inttypes.h>
|
||||
|
||||
#define TAG "OTA_COORD"
|
||||
|
||||
/** WiFi OTA 时挂起 BLE;BLE 通道自身 OTA 不挂。调用勿持 m_mutex。 */
|
||||
static void wifiOtaSuspendBleIfNeeded(ota_channel_t channel)
|
||||
{
|
||||
if (channel != OTA_CHANNEL_WIFI) {
|
||||
return;
|
||||
}
|
||||
(void)BleOta::instance().suspendStack();
|
||||
}
|
||||
|
||||
/** 回到可空闲态时恢复 BLE。调用勿持 m_mutex。 */
|
||||
static void wifiOtaResumeBleIfNeeded(void)
|
||||
{
|
||||
if (BleOta::instance().isSuspendedForWifiOta()) {
|
||||
(void)BleOta::instance().resumeStack();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单例
|
||||
// ============================================================
|
||||
OtaCoordinator& OtaCoordinator::instance()
|
||||
{
|
||||
static OtaCoordinator inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 线程安全辅助
|
||||
// ============================================================
|
||||
bool OtaCoordinator::lock() const
|
||||
{
|
||||
/* 返回 take 是否成功;调用方须把该值传给 unlock() */
|
||||
if (!m_mutex) return false;
|
||||
bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE);
|
||||
if (!ok) {
|
||||
DEBUG_ERR(TAG, "lock() 超时 (%u ms),本次状态变更被跳过",
|
||||
(unsigned)pdTICKS_TO_MS(kLockTimeout));
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void OtaCoordinator::unlock(bool handle) const
|
||||
{
|
||||
/* 仅当对应 lock() 成功时 give */
|
||||
if (handle && m_mutex) {
|
||||
xSemaphoreGive(m_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 卡死自愈检查(必须在持锁状态下调用)
|
||||
// ============================================================
|
||||
void OtaCoordinator::watchdogCheckLocked()
|
||||
{
|
||||
// 仅对"忙"状态(可能卡死的)做检查。
|
||||
// REBOOTING:已 set_boot,即将 esp_restart,不入狗,避免 abort 成 IDLE 与重启竞态。
|
||||
switch (m_sysState) {
|
||||
case OTA_SYS_GD32_STORING:
|
||||
case OTA_SYS_GD32_RELAYING:
|
||||
case OTA_SYS_ESP32_RECEIVING:
|
||||
case OTA_SYS_ESP32_FINALIZING:
|
||||
break;
|
||||
default:
|
||||
return; // IDLE/STORED/COMPLETE/ERROR/REBOOTING 无需检查
|
||||
}
|
||||
|
||||
if (m_busySinceTick == 0) {
|
||||
// 未记录起始时刻(理论上不应发生),保守不触发
|
||||
return;
|
||||
}
|
||||
|
||||
TickType_t elapsed = xTaskGetTickCount() - m_busySinceTick;
|
||||
if (elapsed < kBusyWatchdogTicks) {
|
||||
return; // 未超时
|
||||
}
|
||||
|
||||
DEBUG_ERR(TAG, "忙状态 %d 超过 %" PRIu32 " ms,强制中止 → IDLE",
|
||||
(int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks));
|
||||
doAbortLocked();
|
||||
}
|
||||
|
||||
esp_err_t OtaCoordinator::beginLockedWithChecks(const char *tag, bool &out_lock)
|
||||
{
|
||||
out_lock = lock();
|
||||
if (!out_lock) return ESP_ERR_INVALID_STATE;
|
||||
|
||||
// ★ 卡死自愈检查
|
||||
watchdogCheckLocked();
|
||||
|
||||
// ★ 电机 OTA 互斥:电机喂数期间禁止新的 GD32/ESP32 OTA
|
||||
if (motorOtaIsActive()) {
|
||||
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 %s", tag);
|
||||
unlock(true);
|
||||
out_lock = false;
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
void OtaCoordinator::init()
|
||||
{
|
||||
if (!m_mutex) {
|
||||
m_mutex = xSemaphoreCreateMutex();
|
||||
if (m_mutex) {
|
||||
DEBUG_INFO(TAG, "OTA 全局协调器已初始化");
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "协调器 mutex 创建失败");
|
||||
}
|
||||
}
|
||||
m_sysState = OTA_SYS_IDLE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0;
|
||||
|
||||
// 注册协调器为 OtaRelay 的中继事件监听器
|
||||
OtaRelay::setRelayListener(this);
|
||||
|
||||
// ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态,
|
||||
// 否则用户重启后看到的状态是 IDLE 但 NVS 里其实有未中继的固件,前后不一致。
|
||||
ota_meta_t boot_meta;
|
||||
if (Gd32FlashStore::instance().metaLoad(&boot_meta) == ESP_OK &&
|
||||
boot_meta.total_size > 0 &&
|
||||
boot_meta.stored_size >= boot_meta.total_size) {
|
||||
m_sysState = OTA_SYS_GD32_STORED;
|
||||
m_gd32StorageDone = true;
|
||||
DEBUG_INFO(TAG, "启动恢复: NVS 检测到完整 GD32 固件 (%" PRIu32 " 字节), → GD32_STORED",
|
||||
boot_meta.total_size);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GD32 存储阶段
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t channel)
|
||||
{
|
||||
bool h;
|
||||
esp_err_t pre = beginLockedWithChecks("GD32 存储", h);
|
||||
if (pre != ESP_OK) return pre;
|
||||
|
||||
// ★ 同类型重启:若上一次 GD32 存储仍在进行中(STORING)且来自同一通道,
|
||||
// 自动中止旧存储并重新开始。覆盖"上传过程中刷新网页再上传"场景。
|
||||
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
|
||||
// RELAYING 是中继阶段,不可中断,也拒绝。
|
||||
if (m_sysState == OTA_SYS_GD32_STORING) {
|
||||
if (m_otaChannel != channel) {
|
||||
DEBUG_WARN(TAG, "GD32存中 ch=%d 拒跨通道 ch=%d",
|
||||
(int)m_otaChannel, (int)channel);
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
DEBUG_WARN(TAG, "GD32同通道重传:中止旧存储");
|
||||
doAbortLocked(); // 清 meta + IDLE,与 abort 路径一致
|
||||
// epoch 将在下方设置 GD32_STORING 后递增
|
||||
}
|
||||
|
||||
// 系统忙检查:仅拒绝真正"在传输"的活跃状态
|
||||
// STORED / COMPLETE 是"待中继/已中继"的非活跃状态 → 允许新存储覆盖
|
||||
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
|
||||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
|
||||
m_sysState == OTA_SYS_ESP32_REBOOTING ||
|
||||
m_sysState == OTA_SYS_GD32_RELAYING) {
|
||||
DEBUG_WARN(TAG, "系统忙 (state=%d),拒绝 GD32 存储请求", (int)m_sysState);
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 如果之前已有存储但未中继(STORED)或刚中继完(COMPLETE),允许新存储覆盖。
|
||||
if (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE) {
|
||||
DEBUG_INFO(TAG, "覆盖旧 GD32 存储状态,开始新存储 (size=%" PRIu32 ")", total_size);
|
||||
}
|
||||
|
||||
m_otaEpoch++; // ★ 新会话开始,递增 epoch
|
||||
m_otaChannel = channel; // ★ 记录发起通道
|
||||
m_sysState = OTA_SYS_GD32_STORING;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
|
||||
DEBUG_DBG(TAG, "GD32存开始 %" PRIu32 "B →STORING ep=%u ch=%d",
|
||||
total_size, m_otaEpoch, (int)channel);
|
||||
// ★ 预分配 flashWriteChunk 对齐缓冲(OTA 期消除逐块 malloc;非 OTA 期零占用)
|
||||
Gd32FlashStore::acquireAlignBufForOta();
|
||||
unlock(h);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t channel)
|
||||
{
|
||||
// 1. 校验固件大小(统一 BLE/HTTP 两路径的大小检查)
|
||||
esp_err_t err = validateGd32Size(total_size);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN(TAG, "prepareGd32Storage: 固件大小非法 raw=%" PRIu32, total_size);
|
||||
return err;
|
||||
}
|
||||
|
||||
/* WiFi GD32 上传前先挂 BLE,腾堆给 HTTP 收流 */
|
||||
wifiOtaSuspendBleIfNeeded(channel);
|
||||
|
||||
// 2. 进入 GD32_STORING 状态(系统忙则拒绝)
|
||||
err = beginGd32Storage(total_size, channel);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN(TAG, "prepareGd32Storage: 协调器拒绝存储(系统忙)");
|
||||
if (channel == OTA_CHANNEL_WIFI) {
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
// 3. 复位擦除水位;不在 prepare 整区预擦。
|
||||
// 旧方案在 HTTP JSON 头后同步擦 gd32_ota,TCP 堵在开头。
|
||||
// 现由 flashWriteChunk → ensureErasedForWrite 按需擦扇区,与收流交织。
|
||||
Gd32FlashStore::instance().beginWriteSession();
|
||||
if (Gd32FlashStore::instance().getPartition() == nullptr) {
|
||||
DEBUG_ERR(TAG, "prepareGd32Storage: 找不到 gd32_ota 分区");
|
||||
abortCurrent();
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
DEBUG_INFO(TAG, "prepareGd32Storage: 会话就绪 fw=%" PRIu32 "(按需擦除,无整区预擦)",
|
||||
total_size);
|
||||
|
||||
// 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0)
|
||||
clearGd32Meta();
|
||||
ota_meta_t meta = {};
|
||||
meta.total_size = total_size;
|
||||
meta.stored_size = 0;
|
||||
meta.last_seq = 0;
|
||||
err = saveGd32Meta(&meta);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "prepareGd32Storage: 元数据保存失败: %s", esp_err_to_name(err));
|
||||
abortCurrent();
|
||||
return err;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void OtaCoordinator::completeGd32Storage()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h && m_sysState == OTA_SYS_GD32_STORING) {
|
||||
m_sysState = OTA_SYS_GD32_STORED;
|
||||
m_gd32StorageDone = true;
|
||||
m_busySinceTick = 0; // ★ STORED 是非忙状态
|
||||
DEBUG_DBG(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED");
|
||||
/* WiFi 路径不在此处恢复 BLE:
|
||||
* 旧逻辑 HTTP 一收完就 resume,55KB 极快时日志紧贴 raw upload start,
|
||||
* 像「一刷新就共存恢复」。挂起到中继结束/中止(notifyRelayComplete、abort)。 */
|
||||
} else if (h) {
|
||||
DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState);
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
esp_err_t OtaCoordinator::finishGd32StorageAfterReceive()
|
||||
{
|
||||
completeGd32Storage();
|
||||
return finalizeGd32Checksum();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GD32 中继阶段
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::triggerGd32Relay()
|
||||
{
|
||||
bool h = lock();
|
||||
if (!h) {
|
||||
DEBUG_ERR(TAG, "triggerGd32Relay: lock 超时");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
watchdogCheckLocked();
|
||||
|
||||
/* 白名单:仅 STORED / IDLE(NVS 有完整固件)/ COMPLETE(允许再中继)可触发 */
|
||||
if (m_sysState != OTA_SYS_GD32_STORED &&
|
||||
m_sysState != OTA_SYS_IDLE &&
|
||||
m_sysState != OTA_SYS_GD32_COMPLETE) {
|
||||
DEBUG_WARN(TAG, "拒中继 state=%s(%d)",
|
||||
stateToString(m_sysState), (int)m_sysState);
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ota_meta_t meta;
|
||||
if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK ||
|
||||
meta.total_size == 0 ||
|
||||
meta.stored_size < meta.total_size) {
|
||||
DEBUG_WARN(TAG, "GD32 固件未完整存储,拒绝触发中继");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* ★ 包级防呆:SHA 匹配只证明"传对了",不证明"传的是对的包"。
|
||||
* 把 BOOT 包中继进 APP 区会擦掉好固件,触发前必须确认镜像身份为 APP。
|
||||
* 返回 ESP_ERR_INVALID_SIZE 供调用方(WiFi/BLE)映射成具体错误提示。 */
|
||||
const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity();
|
||||
if (identity != Gd32ImageIdentity::kApp) {
|
||||
DEBUG_ERR(TAG, "拒绝中继:GD32 固件身份校验失败 (identity=%d)", (int)identity);
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (OtaRelay::getState() != RELAY_IDLE &&
|
||||
OtaRelay::getState() != RELAY_DONE &&
|
||||
OtaRelay::getState() != RELAY_ERROR) {
|
||||
DEBUG_WARN(TAG, "OtaRelay 正忙 (state=%d),拒绝触发", (int)OtaRelay::getState());
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
QueueHandle_t dq = OtaRelay::getDispatchQueue();
|
||||
if (!dq) {
|
||||
DEBUG_ERR(TAG, "OtaRelay dispatchQueue 未就绪");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* 先入队再标 RELAYING:失败时状态与 CAN 反馈都不变 */
|
||||
pauseCanFeedbackTask(true);
|
||||
uint32_t cmd = 1;
|
||||
BaseType_t ret = xQueueSend(dq, &cmd, pdMS_TO_TICKS(100));
|
||||
if (ret != pdTRUE) {
|
||||
DEBUG_ERR(TAG, "向 OtaRelay 发送启动消息失败");
|
||||
pauseCanFeedbackTask(false);
|
||||
unlock(h);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
m_sysState = OTA_SYS_GD32_RELAYING;
|
||||
m_busySinceTick = xTaskGetTickCount();
|
||||
DEBUG_DBG(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING");
|
||||
unlock(h);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/** @brief 中继 task 已真正开始:若 trigger 已标 RELAYING 则只补 pause;否则兜底设状态。 */
|
||||
void OtaCoordinator::notifyRelayStarted()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
if (m_sysState != OTA_SYS_GD32_RELAYING) {
|
||||
m_sysState = OTA_SYS_GD32_RELAYING;
|
||||
m_busySinceTick = xTaskGetTickCount();
|
||||
DEBUG_DBG(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING");
|
||||
}
|
||||
pauseCanFeedbackTask(true);
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:GD32 中继已完成,根据结果转为 COMPLETE 或 ERROR。 */
|
||||
void OtaCoordinator::notifyRelayComplete(bool success)
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
if (success) {
|
||||
m_sysState = OTA_SYS_GD32_COMPLETE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0; // ★ COMPLETE 非忙
|
||||
pauseCanFeedbackTask(false);
|
||||
DEBUG_INFO(TAG, "GD32 中继完成, 系统状态 → GD32_COMPLETE");
|
||||
} else {
|
||||
m_sysState = OTA_SYS_ERROR;
|
||||
m_busySinceTick = 0; // ★ ERROR 非忙(可被后续请求恢复)
|
||||
pauseCanFeedbackTask(false);
|
||||
DEBUG_ERR(TAG, "GD32 中继失败, 系统状态 → ERROR");
|
||||
}
|
||||
}
|
||||
unlock(h);
|
||||
/* 中继结束:若 BLE 仍因 WiFi OTA 挂起则恢复 */
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IRelayListener 接口实现
|
||||
// ============================================================
|
||||
/** @brief IRelayListener 回调:中继已开始,委托给 notifyRelayStarted()。 */
|
||||
void OtaCoordinator::onRelayStarted()
|
||||
{
|
||||
notifyRelayStarted();
|
||||
}
|
||||
|
||||
/** @brief IRelayListener 回调:中继已完成,委托给 notifyRelayComplete()。 */
|
||||
void OtaCoordinator::onRelayComplete(bool success)
|
||||
{
|
||||
notifyRelayComplete(success);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BLE 射频共存管理
|
||||
// ============================================================
|
||||
void OtaCoordinator::setBleCoexListener(IBleCoexListener *listener)
|
||||
{
|
||||
m_bleCoexListener = listener;
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyBleCoexStart()
|
||||
{
|
||||
if (m_bleCoexListener) {
|
||||
m_bleCoexListener->onBleOtaStart();
|
||||
}
|
||||
}
|
||||
|
||||
void OtaCoordinator::notifyBleCoexEnd()
|
||||
{
|
||||
if (m_bleCoexListener) {
|
||||
m_bleCoexListener->onBleOtaEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ESP32 OTA 阶段
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t channel)
|
||||
{
|
||||
bool h;
|
||||
esp_err_t pre = beginLockedWithChecks("ESP32 OTA", h);
|
||||
if (pre != ESP_OK) return pre;
|
||||
|
||||
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
|
||||
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
|
||||
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
|
||||
if (m_sysState == OTA_SYS_ESP32_RECEIVING) {
|
||||
if (m_otaChannel != channel) {
|
||||
DEBUG_WARN(TAG, "ESP32收中 ch=%d 拒跨通道 ch=%d",
|
||||
(int)m_otaChannel, (int)channel);
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
DEBUG_WARN(TAG, "ESP32同通道重传:中止旧会话");
|
||||
doAbortLocked(); // abort core + IDLE + 恢复 CAN 反馈
|
||||
// epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效
|
||||
}
|
||||
|
||||
// ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器
|
||||
// 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。
|
||||
// core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
|
||||
if (m_sysState == OTA_SYS_ESP32_FINALIZING) {
|
||||
if (!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "FINALIZING残留(core空闲),强制复位");
|
||||
doAbortLocked();
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "系统忙 (ESP32_FINALIZING, core 活跃),拒绝 ESP32 OTA");
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 重启中,均拒绝。
|
||||
if (m_sysState == OTA_SYS_GD32_STORING ||
|
||||
m_sysState == OTA_SYS_GD32_RELAYING ||
|
||||
m_sysState == OTA_SYS_ESP32_REBOOTING) {
|
||||
DEBUG_WARN(TAG, "忙 state=%d(%s) 拒ESP32 OTA",
|
||||
(int)m_sysState, stateToString(m_sysState));
|
||||
unlock(h);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 委托 Esp32OtaCore 处理分区/互斥逻辑
|
||||
esp_err_t err = Esp32OtaCore::instance().begin(total_size);
|
||||
if (err == ESP_OK) {
|
||||
m_otaEpoch++; // ★ 新会话开始,递增 epoch(使旧 handler 的 abortCurrentIfEpoch 失效)
|
||||
m_otaChannel = channel; // ★ 记录发起通道
|
||||
m_sysState = OTA_SYS_ESP32_RECEIVING;
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
|
||||
pauseCanFeedbackTask(true);
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
unlock(h);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channel_t channel)
|
||||
{
|
||||
/* WiFi 路径:在 begin 前挂 BLE,把 BT 堆腾给 OTA 收流(BLE 通道 OTA 不挂)。 */
|
||||
wifiOtaSuspendBleIfNeeded(channel);
|
||||
|
||||
esp_err_t err = beginEsp32Ota(total_size, channel);
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
// ★ 仅当系统确实空闲(无任何活跃 OTA)时才允许 forceCleanup:
|
||||
// 这种情况只可能是"锁残留"——前一次 OTA 异常退出没释放全局互斥,
|
||||
// 而协调器状态已回 IDLE。
|
||||
// 若系统正处于任何活跃 OTA(ESP32/GD32),绝不强制清理——那会
|
||||
// 把另一通道正在进行的 esp_ota_handle 中止、释放互斥,导致
|
||||
// 两通道并发写同一 OTA 分区(之前 BLE 与 WiFi 可同时 OTA 的根因)。
|
||||
// 避免连续 isIdle() + getSystemState() 各加锁一次(共 2 次锁)。
|
||||
ota_system_state_t cur = getSystemState();
|
||||
if (!isIdleState(cur) && cur != OTA_SYS_ERROR) {
|
||||
DEBUG_WARN(TAG, "ESP32拒且非空闲 state=%d,不强制清",
|
||||
(int)cur);
|
||||
if (channel == OTA_CHANNEL_WIFI) {
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
if (cur == OTA_SYS_ERROR) {
|
||||
DEBUG_WARN(TAG, "ESP32拒但ERROR,清残留后重试");
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "ESP32拒但空闲,判锁残留,强制清后重试");
|
||||
}
|
||||
/* ★ 若 motor OTA 的 feedTask 残留活跃(s_feed_running 仍 true,
|
||||
* 常见于 cancel 请求未送达或 feedTask 卡在 SPI transact),
|
||||
* 必须先 motorOtaCancel() 让 feedTask 退出,否则 forceCleanup
|
||||
* 只清 ESP32 锁、motorOtaIsActive() 仍 true,重试时又被拒,
|
||||
* 形成日志里"强制清后重试→电机 OTA 进行中→再拒"的死循环。 */
|
||||
if (motorOtaIsActive()) {
|
||||
DEBUG_WARN(TAG, "强制清前先取消残留的电机 OTA feedTask");
|
||||
motorOtaCancel();
|
||||
}
|
||||
Esp32OtaCore::instance().forceCleanup();
|
||||
err = beginEsp32Ota(total_size, channel);
|
||||
}
|
||||
if (err != ESP_OK && channel == OTA_CHANNEL_WIFI) {
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
|
||||
// 供 notifyEsp32*Complete / notifyEsp32Rebooting 等锁内路径复用,避免重复样板代码。
|
||||
void OtaCoordinator::setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg)
|
||||
{
|
||||
m_sysState = s;
|
||||
/* REBOOTING 不参与 busy watchdog,清零起点避免误中止 */
|
||||
if (s == OTA_SYS_ESP32_REBOOTING) {
|
||||
m_busySinceTick = 0;
|
||||
} else if (s == OTA_SYS_ESP32_FINALIZING && m_busySinceTick == 0) {
|
||||
m_busySinceTick = xTaskGetTickCount();
|
||||
}
|
||||
DEBUG_DBG(TAG, "%s", dbg_msg);
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */
|
||||
void OtaCoordinator::notifyEsp32ReceiveComplete()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
|
||||
setEsp32StateLocked(OTA_SYS_ESP32_FINALIZING,
|
||||
"ESP32 固件接收完毕, 系统状态 → ESP32_FINALIZING");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:ESP32 OTA 收尾完成(SHA验证+标记启动),状态转为 REBOOTING。 */
|
||||
void OtaCoordinator::notifyEsp32FinalizeComplete()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
setEsp32StateLocked(OTA_SYS_ESP32_REBOOTING,
|
||||
"ESP32 OTA 收尾完成, 系统状态 → ESP32_REBOOTING");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
/** @brief 通知协调器:ESP32 即将重启。 */
|
||||
void OtaCoordinator::notifyEsp32Rebooting()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
setEsp32StateLocked(OTA_SYS_ESP32_REBOOTING,
|
||||
"ESP32 即将重启, 系统状态 → ESP32_REBOOTING");
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ESP32 OTA 收尾+重启序列(BLE/WiFi 共用封装)
|
||||
// 关键:finalize() 内部已 end+set_boot+释放锁,reboot=false 返回后
|
||||
// m_active 仍为 true、m_ended 为 true,调用方可在重启前自由查询状态。
|
||||
// ============================================================
|
||||
Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_hex)
|
||||
{
|
||||
Esp32FinalizeResult r = {};
|
||||
notifyEsp32ReceiveComplete();
|
||||
// reboot=false:让 core.finalize 真正返回,由调用方上报 device_sha 后再调 restartEsp32
|
||||
r.err = Esp32OtaCore::instance().finalize(frontend_sha_hex,
|
||||
/*reboot=*/false,
|
||||
r.device_sha_hex);
|
||||
if (r.err == ESP_OK) {
|
||||
notifyEsp32FinalizeComplete();
|
||||
} else {
|
||||
/* finalize 失败:复位协调器到 IDLE */
|
||||
DEBUG_ERR(TAG, "ESP32 finalize 失败: %s,复位协调器 → IDLE",
|
||||
esp_err_to_name(r.err));
|
||||
abortCurrent();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
void OtaCoordinator::restartEsp32()
|
||||
{
|
||||
notifyEsp32Rebooting();
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
esp_restart();
|
||||
// 不会返回
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 中止
|
||||
// ============================================================
|
||||
void OtaCoordinator::doAbortLocked()
|
||||
{
|
||||
DEBUG_INFO(TAG, "中止当前 OTA 操作 (state=%d, epoch=%u)", (int)m_sysState, m_otaEpoch);
|
||||
|
||||
if (m_sysState == OTA_SYS_GD32_STORING) {
|
||||
Gd32FlashStore::instance().metaClear();
|
||||
Gd32FlashStore::releaseAlignBufForOta(); // 释放 OTA 期对齐缓冲
|
||||
} else if (m_sysState == OTA_SYS_GD32_RELAYING) {
|
||||
/* 中继中止必须 CANCEL 远端,否则 GD32 停在 WAITING_* 半会话 */
|
||||
OtaRelay::instance().abortRemoteAndReset(true);
|
||||
Gd32FlashStore::releaseAlignBufForOta(); // 中继阶段也可能写过 flash
|
||||
} else if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
|
||||
m_sysState == OTA_SYS_ESP32_FINALIZING) {
|
||||
Esp32OtaCore::instance().abort();
|
||||
}
|
||||
|
||||
pauseCanFeedbackTask(false);
|
||||
m_sysState = OTA_SYS_IDLE;
|
||||
m_gd32StorageDone = false;
|
||||
m_busySinceTick = 0;
|
||||
DEBUG_INFO(TAG, "已中止, 系统状态 → IDLE");
|
||||
/* 释放协调器锁后由调用方 resume BLE;doAbortLocked 可能持锁,标记后外层 resume */
|
||||
}
|
||||
|
||||
void OtaCoordinator::abortCurrent()
|
||||
{
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
doAbortLocked();
|
||||
}
|
||||
unlock(h);
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
}
|
||||
|
||||
void OtaCoordinator::healOrphanStates()
|
||||
{
|
||||
bool need_resume = false;
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
ota_system_state_t before = m_sysState;
|
||||
watchdogCheckLocked(); // 超时则 doAbortLocked → IDLE
|
||||
// finalize 失败后 core 已空、协调器仍 FINALIZING 的假占用:status 轮询即可自愈
|
||||
if (m_sysState == OTA_SYS_ESP32_FINALIZING &&
|
||||
!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "status 路径发现 ESP32_FINALIZING 残留,自愈 → IDLE");
|
||||
doAbortLocked();
|
||||
}
|
||||
/* ERROR 非忙:status 轮询软清到 IDLE,避免 UI 长期卡 ERROR(新 begin 本也可覆盖) */
|
||||
if (m_sysState == OTA_SYS_ERROR) {
|
||||
DEBUG_INFO(TAG, "status 路径 ERROR → IDLE(软清)");
|
||||
m_sysState = OTA_SYS_IDLE;
|
||||
m_busySinceTick = 0;
|
||||
m_gd32StorageDone = false;
|
||||
pauseCanFeedbackTask(false);
|
||||
}
|
||||
// 任意自愈到 IDLE:恢复 WiFi OTA 期间挂起的 BLE(watchdog 旧路径漏 resume)
|
||||
if (before != OTA_SYS_IDLE && m_sysState == OTA_SYS_IDLE) {
|
||||
need_resume = true;
|
||||
}
|
||||
unlock(h);
|
||||
}
|
||||
if (need_resume) {
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t OtaCoordinator::getOtaEpoch() const
|
||||
{
|
||||
bool h = lock();
|
||||
uint32_t epoch = m_otaEpoch;
|
||||
unlock(h);
|
||||
return epoch;
|
||||
}
|
||||
|
||||
bool OtaCoordinator::abortCurrentIfEpoch(uint32_t epoch)
|
||||
{
|
||||
bool h = lock();
|
||||
if (!h) return false;
|
||||
|
||||
if (m_otaEpoch != epoch) {
|
||||
// ★ epoch 不匹配:新会话已接管,旧 handler 不应中止
|
||||
unlock(h);
|
||||
return false;
|
||||
}
|
||||
|
||||
// epoch 匹配:执行中止(共享 doAbortLocked,已持锁)
|
||||
doAbortLocked();
|
||||
|
||||
unlock(h);
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态查询
|
||||
// ============================================================
|
||||
ota_system_state_t OtaCoordinator::getSystemState() const
|
||||
{
|
||||
// 对于纯读取,短暂加锁保证一致性
|
||||
// m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
|
||||
bool h = lock();
|
||||
ota_system_state_t s = m_sysState;
|
||||
unlock(h);
|
||||
return s;
|
||||
}
|
||||
|
||||
ota_channel_t OtaCoordinator::getOtaChannel() const
|
||||
{
|
||||
bool h = lock();
|
||||
ota_channel_t ch = m_otaChannel;
|
||||
unlock(h);
|
||||
return ch;
|
||||
}
|
||||
|
||||
/** @brief 判断给定状态值是否属于空闲态(不加锁,纯判断)。
|
||||
* 供持锁路径或已取一次状态的调用方复用,避免连续加锁。 */
|
||||
bool OtaCoordinator::isIdleState(ota_system_state_t s) const
|
||||
{
|
||||
return (s == OTA_SYS_IDLE ||
|
||||
s == OTA_SYS_GD32_STORED ||
|
||||
s == OTA_SYS_GD32_COMPLETE);
|
||||
}
|
||||
|
||||
/** @brief 判断系统是否处于空闲状态(IDLE/STORED/COMPLETE)。 */
|
||||
bool OtaCoordinator::isIdle() const
|
||||
{
|
||||
return isIdleState(getSystemState());
|
||||
}
|
||||
|
||||
/** @brief 把状态枚举转成可读字符串(不加锁,纯函数)。
|
||||
* 供持锁代码路径和已取一次状态值的调用方复用,避免二次加锁。 */
|
||||
const char* OtaCoordinator::stateToString(ota_system_state_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case OTA_SYS_IDLE: return "IDLE";
|
||||
case OTA_SYS_GD32_STORING: return "GD32_STORING";
|
||||
case OTA_SYS_GD32_STORED: return "GD32_STORED";
|
||||
case OTA_SYS_GD32_RELAYING: return "GD32_RELAYING";
|
||||
case OTA_SYS_GD32_COMPLETE: return "GD32_COMPLETE";
|
||||
case OTA_SYS_ESP32_RECEIVING: return "ESP32_RECEIVING";
|
||||
case OTA_SYS_ESP32_FINALIZING: return "ESP32_FINALIZING";
|
||||
case OTA_SYS_ESP32_REBOOTING: return "ESP32_REBOOTING";
|
||||
case OTA_SYS_ERROR: return "ERROR";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OTA 状态快照
|
||||
// ============================================================
|
||||
OtaSnapshot OtaCoordinator::getSnapshot() const
|
||||
{
|
||||
OtaSnapshot snap{};
|
||||
|
||||
/* 持锁一次读取状态并生成字符串,保证快照字段一致 */
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
snap.sys_state = m_sysState;
|
||||
snap.channel = m_otaChannel;
|
||||
snap.storage_done = m_gd32StorageDone;
|
||||
snap.sys_state_str = stateToString(snap.sys_state);
|
||||
unlock(h);
|
||||
} else {
|
||||
// 加锁失败时也填一个与默认 sys_state(=IDLE) 一致的字符串
|
||||
snap.sys_state_str = stateToString(snap.sys_state);
|
||||
}
|
||||
|
||||
// ── GD32 NVS 元数据 ──
|
||||
Gd32FlashStore::instance().metaLoad(&snap.meta);
|
||||
|
||||
// ── relay 进度(一次 relay mutex)──
|
||||
snap.relay = OtaRelay::getSnapshot();
|
||||
|
||||
return snap;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GD32 APP 探测
|
||||
// ============================================================
|
||||
esp_err_t OtaCoordinator::probeGd32AppOnce(gd32_version_t *out_version, uint32_t *out_uptime)
|
||||
{
|
||||
/* 统一走 OtaRelay 公开探测,避免与 probeAndCacheAppAlive 双份逻辑 */
|
||||
uint32_t uptime = 0;
|
||||
if (OtaRelay::probeAndCacheAppAlive(500, &uptime)) {
|
||||
if (out_version) {
|
||||
*out_version = OtaRelay::getSnapshot().version;
|
||||
}
|
||||
if (out_uptime) {
|
||||
*out_uptime = uptime;
|
||||
}
|
||||
DEBUG_INFO(TAG, "GD32 APP 探测成功: alive, uptime=%" PRIu32 "s", uptime);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (out_version) {
|
||||
*out_version = gd32_version_t{};
|
||||
}
|
||||
if (out_uptime) {
|
||||
*out_uptime = 0;
|
||||
}
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 完全复位
|
||||
// ============================================================
|
||||
void OtaCoordinator::reset()
|
||||
{
|
||||
// ★ 与 abortCurrent 共用 doAbortLocked:清 Esp32OtaCore / relay / meta,勿只改枚举
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
doAbortLocked();
|
||||
DEBUG_INFO(TAG, "协调器已完全复位(含子系统 abort)");
|
||||
}
|
||||
unlock(h);
|
||||
wifiOtaResumeBleIfNeeded();
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/*
|
||||
* ota_coordinator.h - 全局 OTA 协调器
|
||||
*
|
||||
* OtaCoordinator 是系统级 OTA 状态的唯一权威来源,负责:
|
||||
* 1. 串行化 GD32 中继与 ESP32 OTA 操作(同一时刻只能进行一种)
|
||||
* 2. 为传输层(WiFi/BLE)提供统一的 OTA 操作入口
|
||||
* 3. 通过 FreeRTOS 队列向 OtaRelay task 投递中继命令
|
||||
* 4. 向上层(web UI / status API)暴露系统级状态
|
||||
*
|
||||
* 线程模型:
|
||||
* - 状态读写由内部 mutex 保护
|
||||
* - begin/complete 接口可在任意任务中调用
|
||||
* - triggerGd32Relay() 向 OtaRelay task 的 dispatchQueue 发送消息
|
||||
*/
|
||||
#ifndef OTA_COORDINATOR_H
|
||||
#define OTA_COORDINATOR_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_err.h"
|
||||
#include "app_config.h" // OTA_MAX_FIRMWARE_SIZE / GD32_OTA_PACKET_SIZE(validateGd32Size 用)
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot
|
||||
|
||||
// ============================================================
|
||||
// 系统级 OTA 状态枚举
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
OTA_SYS_IDLE = 0,
|
||||
OTA_SYS_GD32_STORING, // 正在从传输层接收 GD32 固件
|
||||
OTA_SYS_GD32_STORED, // GD32 固件已完整存储,等待中继触发
|
||||
OTA_SYS_GD32_RELAYING, // 正在通过 SPI 向 GD32 中继固件
|
||||
OTA_SYS_GD32_COMPLETE, // GD32 OTA 完成(APP 已存活确认)
|
||||
OTA_SYS_ESP32_RECEIVING, // 正在接收 ESP32 自身固件
|
||||
OTA_SYS_ESP32_FINALIZING, // 正在收尾 ESP32 OTA(SHA校验 + end + set_boot)
|
||||
OTA_SYS_ESP32_REBOOTING, // 即将重启 ESP32
|
||||
OTA_SYS_ERROR // 错误状态
|
||||
} ota_system_state_t;
|
||||
|
||||
// ============================================================
|
||||
// OTA 传输通道标识(用于同类型重启时区分同通道/跨通道)
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
OTA_CHANNEL_WIFI = 0,
|
||||
OTA_CHANNEL_BLE = 1,
|
||||
} ota_channel_t;
|
||||
|
||||
// ============================================================
|
||||
// OTA 系统全量只读快照
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief OTA 系统全量只读快照。
|
||||
* 由 OtaCoordinator::getSnapshot() 一次性填充,
|
||||
* 供 /system/status 等只读接口消费,避免散读各模块状态。
|
||||
*/
|
||||
struct OtaSnapshot {
|
||||
ota_system_state_t sys_state = OTA_SYS_IDLE; ///< 系统级 OTA 状态
|
||||
const char *sys_state_str = "IDLE"; ///< 状态字符串
|
||||
ota_channel_t channel = OTA_CHANNEL_WIFI; ///< 当前/最近一次 OTA 通道
|
||||
bool storage_done = false; ///< GD32 固件存储完成标志
|
||||
ota_meta_t meta = {}; ///< GD32 NVS 元数据
|
||||
RelaySnapshot relay; ///< 中继进度快照
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// IBleCoexListener - BLE 射频共存通知接口
|
||||
// ============================================================
|
||||
// WifiOta 实现此接口并注册到 OtaCoordinator,
|
||||
// BLE OTA 开始/结束时通过协调器通知 WiFi 层,
|
||||
// 避免 ble_ota → wifi_ota 的直接依赖。
|
||||
/**
|
||||
* @brief BLE 射频共存监听器接口。
|
||||
* BLE OTA 开始/结束时,协调器通过此接口通知 WiFi 层暂停/恢复射频活动。
|
||||
*/
|
||||
class IBleCoexListener {
|
||||
public:
|
||||
virtual ~IBleCoexListener() = default;
|
||||
/** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询等射频活动。 */
|
||||
virtual void onBleOtaStart() = 0;
|
||||
/** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */
|
||||
virtual void onBleOtaEnd() = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA 收尾结果(由 finalizeForReboot 返回)。
|
||||
* err 为 ESP_OK 时 device_sha_hex 有效;SHA 不匹配时也填充 device_sha_hex 供前端核对。
|
||||
*/
|
||||
typedef struct {
|
||||
esp_err_t err; ///< ESP_OK / ESP_ERR_INVALID_VERSION(SHA不匹配) / 其他
|
||||
char device_sha_hex[65]; ///< 设备端 SHA hex(成功或 SHA 不匹配时填充)
|
||||
} Esp32FinalizeResult;
|
||||
|
||||
/**
|
||||
* @brief 全局 OTA 协调器(单例)
|
||||
*
|
||||
* 提供系统级 OTA 状态管理与操作串行化。
|
||||
* 传输层(WiFi/BLE)通过本协调器发起所有 OTA 操作,
|
||||
* 不再直接调用 OtaRelay / Esp32OtaCore 的底层接口。
|
||||
*/
|
||||
class OtaCoordinator : public IRelayListener {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 OtaCoordinator 单例实例。
|
||||
* @return OtaCoordinator 单例引用。
|
||||
*/
|
||||
static OtaCoordinator& instance();
|
||||
|
||||
// ── 初始化 ──
|
||||
/**
|
||||
* @brief 初始化协调器(创建 mutex 和队列)。
|
||||
* 在 OtaRelay task 创建前调用。
|
||||
*/
|
||||
void init();
|
||||
|
||||
// ── GD32 存储阶段(传输层 → Flash)──
|
||||
/**
|
||||
* @brief 传输层开始接收 GD32 固件前调用。
|
||||
* 若系统正忙(ESP32 OTA 进行中或 GD32 中继中),返回失败。
|
||||
* @param total_size 固件总字节数。
|
||||
* @param channel 发起通道(WiFi/BLE),用于同类型重启时区分同通道/跨通道。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 系统忙。
|
||||
*/
|
||||
esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel);
|
||||
|
||||
/**
|
||||
* @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位擦除水位 + 清空 meta + 写入初始 meta。
|
||||
* 扇区擦除由 flashWriteChunk/ensureErasedForWrite 按需进行(避免 HTTP 头卡整区预擦)。
|
||||
* 收流时 write 路径只写 Flash(ensureErased 仅作水位兜底)。
|
||||
* WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。
|
||||
* @param total_size 固件总字节数。
|
||||
* @param channel 发起通道(WiFi/BLE)。
|
||||
* @return ESP_OK 成功;ESP_ERR_INVALID_SIZE 大小非法;ESP_ERR_INVALID_STATE 系统忙;其他擦除/写入错误。
|
||||
*/
|
||||
esp_err_t prepareGd32Storage(uint32_t total_size, ota_channel_t channel);
|
||||
|
||||
/**
|
||||
* @brief GD32 固件接收完毕时调用。
|
||||
* 更新系统状态为 OTA_SYS_GD32_STORED。
|
||||
*/
|
||||
void completeGd32Storage();
|
||||
|
||||
/**
|
||||
* @brief 收流完成后的统一收尾:completeGd32Storage + finalizeGd32Checksum。
|
||||
* WiFi/BLE 传输层共用,避免一端只 complete、另一端漏算 SHA 的分叉。
|
||||
* @return finalizeGd32Checksum 的返回值(complete 本身无错误码)。
|
||||
*/
|
||||
esp_err_t finishGd32StorageAfterReceive();
|
||||
|
||||
// ── GD32 存储操作(传输层通过协调器操作 Flash)──
|
||||
/**
|
||||
* @brief 向 GD32 Flash 分区写入固件数据块。
|
||||
* 传输层(WiFi/BLE)通过此接口写入,不再直接调用 Gd32FlashStore。
|
||||
* @param offset 写入偏移。
|
||||
* @param data 数据缓冲区。
|
||||
* @param len 数据长度。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
inline esp_err_t writeGd32Chunk(uint32_t offset, const uint8_t *data, size_t len)
|
||||
{
|
||||
return Gd32FlashStore::instance().flashWriteChunk(offset, data, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 保存 GD32 OTA 元数据。
|
||||
* @param meta 元数据结构体。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
inline esp_err_t saveGd32Meta(const ota_meta_t *meta)
|
||||
{
|
||||
return Gd32FlashStore::instance().metaSave(meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 加载 GD32 OTA 元数据。
|
||||
* @param meta 输出元数据结构体。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
inline esp_err_t loadGd32Meta(ota_meta_t *meta)
|
||||
{
|
||||
return Gd32FlashStore::instance().metaLoad(meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 更新 GD32 OTA 元数据的进度。
|
||||
* @param stored_size 已存储大小。
|
||||
* @param last_seq 最后包序号。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
inline esp_err_t updateGd32Meta(uint32_t stored_size, uint32_t last_seq)
|
||||
{
|
||||
return Gd32FlashStore::instance().metaUpdate(stored_size, last_seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 清除 GD32 OTA 元数据。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
inline esp_err_t clearGd32Meta()
|
||||
{
|
||||
return Gd32FlashStore::instance().metaClear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 校验前端 SHA-256 与设备端固件是否一致。
|
||||
* @param frontend_sha32 前端 32 字节 SHA。
|
||||
* @param out_device_sha 输出设备端 32 字节 SHA(可为 nullptr)。
|
||||
* @return ESP_OK 匹配,ESP_ERR_INVALID_CRC 不匹配。
|
||||
*/
|
||||
inline esp_err_t verifyGd32Firmware(const uint8_t frontend_sha32[32],
|
||||
uint8_t out_device_sha[32])
|
||||
{
|
||||
return Gd32FlashStore::instance().verifyFrontendSha(frontend_sha32, out_device_sha);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 计算 GD32 固件 SHA-256 并写入元数据。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
inline esp_err_t finalizeGd32Checksum()
|
||||
{
|
||||
return Gd32FlashStore::instance().metaFinalizeChecksum();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 擦除 GD32 OTA 分区。
|
||||
* @param size 擦除大小。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
inline esp_err_t eraseGd32Partition(uint32_t size)
|
||||
{
|
||||
return Gd32FlashStore::instance().erasePartition(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 校验 GD32 固件大小是否合法(BLE/HTTP 双通道共用)。
|
||||
*
|
||||
* 统一规则:raw==0 或 raw>MAX 拒绝;按 GD32_OTA_PACKET_SIZE 向上取整后仍>MAX 拒绝。
|
||||
* 调用方各自翻译为通道特定错误码(BLE: BLE_OTA_FINISH_ERR_GD32_SIZE;HTTP: HTTPD_500)。
|
||||
*
|
||||
* @param raw_size 固件原始字节数。
|
||||
* @return ESP_OK 合法;ESP_ERR_INVALID_SIZE 非法。
|
||||
*/
|
||||
inline esp_err_t validateGd32Size(uint32_t raw_size) const
|
||||
{
|
||||
if (raw_size == 0U || raw_size > (uint32_t)OTA_MAX_FIRMWARE_SIZE) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
const uint32_t packet_size = (uint32_t)GD32_OTA_PACKET_SIZE;
|
||||
const uint32_t rounded = ((raw_size + packet_size - 1U) / packet_size) * packet_size;
|
||||
if (rounded > (uint32_t)OTA_MAX_FIRMWARE_SIZE) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ── GD32 中继阶段 ──
|
||||
/**
|
||||
* @brief 触发 GD32 中继转发。
|
||||
* 向 OtaRelay task 的 dispatchQueue 发送启动消息。
|
||||
* @return ESP_OK 消息已投递,ESP_ERR_INVALID_STATE 不允许触发。
|
||||
*/
|
||||
esp_err_t triggerGd32Relay();
|
||||
|
||||
/**
|
||||
* @brief OtaRelay task 回调:中继已开始。
|
||||
* 由 OtaRelay 在开始 forwardFirmware() 时调用。
|
||||
*/
|
||||
void notifyRelayStarted();
|
||||
|
||||
/**
|
||||
* @brief OtaRelay task 回调:中继已完成。
|
||||
* 由 OtaRelay 在 forwardFirmware() 返回后调用。
|
||||
* @param success true 成功,false 失败。
|
||||
*/
|
||||
void notifyRelayComplete(bool success);
|
||||
|
||||
// ── ESP32 OTA 阶段 ──
|
||||
/**
|
||||
* @brief ESP32 OTA 开始接收固件。
|
||||
* 内部委托 Esp32OtaCore::begin()。
|
||||
* @param total_size 固件总字节数。
|
||||
* @param channel 发起通道(WiFi/BLE),用于同类型重启时区分同通道/跨通道。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 系统忙或通道被占用。
|
||||
*/
|
||||
esp_err_t beginEsp32Ota(uint32_t total_size, ota_channel_t channel);
|
||||
|
||||
/**
|
||||
* @brief 发起 ESP32 OTA,若被 ESP_ERR_INVALID_STATE 拒绝则 forceCleanup 后重试一次。
|
||||
* 封装 BLE/WiFi 两端完全相同的重试模式,调用方只需调一次。
|
||||
* @param total_size 固件总字节数。
|
||||
* @param channel 发起通道(WiFi/BLE)。
|
||||
* @return ESP_OK 成功;其他 = 协调器拒绝或 Esp32OtaCore::begin 失败。
|
||||
*/
|
||||
esp_err_t beginEsp32OtaWithRetry(uint32_t total_size, ota_channel_t channel);
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA 固件接收完毕,准备收尾。
|
||||
* 更新系统状态为 OTA_SYS_ESP32_FINALIZING。
|
||||
*/
|
||||
void notifyEsp32ReceiveComplete();
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA 收尾完成(finalize 成功)。
|
||||
*/
|
||||
void notifyEsp32FinalizeComplete();
|
||||
|
||||
/**
|
||||
* @brief ESP32 即将重启。
|
||||
*/
|
||||
void notifyEsp32Rebooting();
|
||||
|
||||
/**
|
||||
* @brief ESP32 OTA 收尾(统一封装 BLE/WiFi 两端重复的收尾序列)。
|
||||
* 步骤:notifyEsp32ReceiveComplete → Esp32OtaCore::finalize(reboot=false)
|
||||
* → 成功则 notifyEsp32FinalizeComplete;
|
||||
* → 失败(SHA 不匹配 / end 失败等)则 abortCurrent() 复位到 IDLE,
|
||||
* 避免协调器卡在 ESP32_FINALIZING 导致后续上传被假拒绝。
|
||||
* 不重启;调用方负责把 device_sha_hex 上报前端后再调 restartEsp32()。
|
||||
* @param frontend_sha_hex 前端 64 字符 hex SHA-256,nullptr 表示跳过比对。
|
||||
* @return Esp32FinalizeResult,err 为 ESP_OK/ESP_ERR_INVALID_VERSION/其他。
|
||||
*/
|
||||
Esp32FinalizeResult finalizeForReboot(const char *frontend_sha_hex);
|
||||
|
||||
/**
|
||||
* @brief 重启 ESP32(统一封装 BLE/WiFi 两端重复的重启序列)。
|
||||
* 步骤:notifyEsp32Rebooting → vTaskDelay(500ms) → esp_restart()。
|
||||
* 应在 finalizeForReboot 成功并完成前端上报后调用。不会返回。
|
||||
*/
|
||||
void restartEsp32();
|
||||
|
||||
// ── 中止 ──
|
||||
/**
|
||||
* @brief 中止当前 OTA 操作(GD32 或 ESP32)。
|
||||
* 内部调用对应子系统的 abort/reset。
|
||||
*/
|
||||
void abortCurrent();
|
||||
|
||||
/**
|
||||
* @brief 自愈孤儿忙态(供 /system/status 等轮询路径调用)。
|
||||
* - 忙状态 watchdog(超时完整 abort → IDLE;不含 REBOOTING)
|
||||
* - ESP32_FINALIZING 但 core 已空闲:残留 → IDLE
|
||||
* - ERROR → IDLE 软清,避免 UI 长期卡错误态
|
||||
*/
|
||||
void healOrphanStates();
|
||||
|
||||
/**
|
||||
* @brief 获取当前 OTA 会话的 epoch(代次)。
|
||||
* 每次 beginEsp32Ota/beginGd32Storage 成功时递增。
|
||||
* 传输层 handler 在 begin 成功后捕获 epoch,
|
||||
* 在失败清理时通过 abortCurrentIfEpoch() 检查是否仍为当前会话,
|
||||
* 避免旧 handler 的清理代码误中止新会话(同类型重启场景)。
|
||||
* @return 当前 epoch 值。
|
||||
*/
|
||||
uint32_t getOtaEpoch() const;
|
||||
|
||||
/**
|
||||
* @brief 仅当 epoch 匹配当前会话时才中止 OTA。
|
||||
* 用于旧 handler 的失败清理路径:若新请求已经接管(epoch 不匹配),
|
||||
* 旧 handler 不应调 abortCurrent,否则会误中止新会话。
|
||||
* @param epoch 调用方在 begin 成功时捕获的 epoch。
|
||||
* @return true 已中止(epoch 匹配),false 未中止(新会话已接管)。
|
||||
*/
|
||||
bool abortCurrentIfEpoch(uint32_t epoch);
|
||||
|
||||
// ── 状态查询 ──
|
||||
/**
|
||||
* @brief 一次性读取系统 OTA 全量状态快照。
|
||||
* coordinator mutex + relay mutex 各取一次,
|
||||
* 供 /system/status 等只读接口消费。
|
||||
* @return 包含 sys_state、meta、relay 进度的完整快照。
|
||||
*/
|
||||
OtaSnapshot getSnapshot() const;
|
||||
|
||||
/**
|
||||
* @brief 单次 SPI 探测 GD32 APP 是否存活。
|
||||
* 成功时更新 OtaRelay 缓存的 alive/version,
|
||||
* 失败时清除 alive 标志。
|
||||
* @param out_version 输出版本信息,可为 nullptr。
|
||||
* @param out_uptime 输出 GD32 运行时间(秒),可为 nullptr。
|
||||
* @return ESP_OK 存活,其他失败。
|
||||
*/
|
||||
esp_err_t probeGd32AppOnce(gd32_version_t *out_version, uint32_t *out_uptime);
|
||||
|
||||
/**
|
||||
* @brief 获取当前系统级 OTA 状态。
|
||||
* @return 当前 ota_system_state_t 状态值。
|
||||
*/
|
||||
ota_system_state_t getSystemState() const;
|
||||
|
||||
/**
|
||||
* @brief 获取当前 OTA 会话的发起通道(WiFi/BLE)。
|
||||
* 仅在活跃状态(ESP32_RECEIVING / GD32_STORING)下有意义;
|
||||
* IDLE 等非活跃状态返回值未定义(默认 OTA_CHANNEL_WIFI)。
|
||||
* @return 当前会话通道枚举值。
|
||||
*/
|
||||
ota_channel_t getOtaChannel() const;
|
||||
|
||||
/**
|
||||
* @brief 把状态枚举转成可读字符串(不加锁,纯函数)。
|
||||
* 供持锁代码路径和已取一次状态值的调用方复用,避免二次加锁。
|
||||
* @param s 状态枚举值。
|
||||
* @return 状态名称字符串(如 "IDLE"、"GD32_STORING" 等)。
|
||||
*/
|
||||
static const char* stateToString(ota_system_state_t s);
|
||||
|
||||
/**
|
||||
* @brief 检查给定状态值是否属于空闲态(不加锁,纯判断)。
|
||||
* 调用方先 getSystemState() 取一次再传入,避免连续加锁。
|
||||
* @param s 状态枚举值。
|
||||
* @return true 表示 IDLE/STORED/COMPLETE。
|
||||
*/
|
||||
bool isIdleState(ota_system_state_t s) const;
|
||||
|
||||
/**
|
||||
* @brief 检查系统是否空闲(可接受新的 OTA 请求)。
|
||||
* 内部仍加锁取一次状态后调 isIdleState()。
|
||||
* @return true 系统空闲(IDLE/STORED/COMPLETE)。
|
||||
*/
|
||||
bool isIdle() const;
|
||||
|
||||
// ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)──
|
||||
// ── 完全复位 ──
|
||||
/**
|
||||
* @brief 完全复位协调器和所有子系统状态。
|
||||
*/
|
||||
void reset();
|
||||
|
||||
// ── IRelayListener 接口实现 ──
|
||||
/**
|
||||
* @brief IRelayListener 回调:中继已开始。
|
||||
* 由 OtaRelay task 在 forwardFirmware() 前通过 s_relayListener 调用。
|
||||
*/
|
||||
void onRelayStarted() override;
|
||||
|
||||
/**
|
||||
* @brief IRelayListener 回调:中继已完成。
|
||||
* 由 OtaRelay task 在 forwardFirmware() 返回后通过 s_relayListener 调用。
|
||||
* @param success true 成功,false 失败。
|
||||
*/
|
||||
void onRelayComplete(bool success) override;
|
||||
|
||||
// ── BLE 射频共存管理 ──
|
||||
/**
|
||||
* @brief 注册 BLE 共存监听器(由 main 在初始化时调用)。
|
||||
* @param listener WifiOta 实现的监听器指针,nullptr 取消注册。
|
||||
*/
|
||||
void setBleCoexListener(IBleCoexListener *listener);
|
||||
|
||||
/**
|
||||
* @brief BLE OTA 开始时调用,通知 WiFi 层暂停 RSSI 轮询。
|
||||
*/
|
||||
void notifyBleCoexStart();
|
||||
|
||||
/**
|
||||
* @brief BLE OTA 结束时调用,通知 WiFi 层恢复射频活动。
|
||||
*/
|
||||
void notifyBleCoexEnd();
|
||||
|
||||
private:
|
||||
OtaCoordinator() = default;
|
||||
~OtaCoordinator() = default;
|
||||
OtaCoordinator(const OtaCoordinator&) = delete;
|
||||
OtaCoordinator& operator=(const OtaCoordinator&) = delete;
|
||||
|
||||
// 线程安全:lock() 返回 take 是否成功;unlock(handle) 仅在成功时 give
|
||||
bool lock() const;
|
||||
void unlock(bool handle) const;
|
||||
|
||||
// 忙状态超时检查(须已持锁);超时则 doAbortLocked → IDLE
|
||||
void watchdogCheckLocked();
|
||||
|
||||
// 已持锁中止:清理子系统并将状态复位为 IDLE
|
||||
void doAbortLocked();
|
||||
|
||||
/** @brief 统一 "lock + watchdog 自愈 + 电机 OTA 互斥拒绝" 前缀检查。
|
||||
* beginGd32Storage / beginEsp32Ota 共用此逻辑。
|
||||
* @param tag 日志标签(如 "GD32 存储" / "ESP32 OTA")
|
||||
* @param out_lock 成功时输出锁句柄(调用方负责 unlock)
|
||||
* @return ESP_OK=前缀通过可继续;ESP_ERR_INVALID_STATE=被拒绝(已 unlock) */
|
||||
esp_err_t beginLockedWithChecks(const char *tag, bool &out_lock);
|
||||
|
||||
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
|
||||
// 供 notifyEsp32*Complete / notifyEsp32Rebooting 等锁内路径复用,避免重复样板代码。
|
||||
void setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg);
|
||||
|
||||
// 系统状态
|
||||
ota_system_state_t m_sysState = OTA_SYS_IDLE;
|
||||
|
||||
// 进入当前忙状态的时刻(tick)。IDLE/STORED/COMPLETE/ERROR 时为 0。
|
||||
TickType_t m_busySinceTick = 0;
|
||||
|
||||
// GD32 存储跟踪
|
||||
bool m_gd32StorageDone = false;
|
||||
|
||||
// OTA 会话代次:每次 beginEsp32Ota/beginGd32Storage 成功时递增。
|
||||
// 传输层 handler 在 begin 后捕获 epoch,失败清理时通过 abortCurrentIfEpoch
|
||||
// 检查 epoch 是否仍匹配,避免旧 handler 误中止同类型重启后的新会话。
|
||||
uint32_t m_otaEpoch = 0;
|
||||
|
||||
// 当前 OTA 会话的发起通道(WiFi/BLE),用于同类型重启时区分同通道/跨通道。
|
||||
// 仅在同类型活跃状态(ESP32_RECEIVING / GD32_STORING)下有意义。
|
||||
ota_channel_t m_otaChannel = OTA_CHANNEL_WIFI;
|
||||
|
||||
// 互斥锁(mutable 允许在 const 方法中加锁,无需 const_cast)
|
||||
mutable SemaphoreHandle_t m_mutex = nullptr;
|
||||
|
||||
// BLE 共存监听器(由 WifiOta 实现并注册)
|
||||
IBleCoexListener *m_bleCoexListener = nullptr;
|
||||
static constexpr TickType_t kLockTimeout =
|
||||
pdMS_TO_TICKS(OTA_COORD_LOCK_TIMEOUT_MS);
|
||||
|
||||
// ★ 忙状态 watchdog:须覆盖最长中继预算;数值见 app_config.h
|
||||
static constexpr TickType_t kBusyWatchdogTicks =
|
||||
pdMS_TO_TICKS(OTA_COORD_BUSY_WATCHDOG_MS);
|
||||
};
|
||||
|
||||
#endif // OTA_COORDINATOR_H
|
||||
+1406
-953
File diff suppressed because it is too large
Load Diff
+379
-125
@@ -1,6 +1,16 @@
|
||||
/*
|
||||
* ota_relay.h - OTA 中继核心状态机
|
||||
* T2.3, T3.1 - T3.7
|
||||
/*
|
||||
* ota_relay.h - OTA 中继事件驱动状态机
|
||||
*
|
||||
* OtaRelay 将 GD32 SPI OTA 协议封装为事件驱动的状态机:
|
||||
* 1. 每个协议阶段对应一个状态(共 6 个阶段状态 + ERROR)
|
||||
* 2. 状态通过 processEvent() 驱动转换,而非顺序赋值
|
||||
* 3. 外部(OtaCoordinator / transport layer)通过 dispatchQueue 触发中继
|
||||
* 4. forwardFirmware() 保留为便捷入口,内部驱动状态机循环
|
||||
*
|
||||
* 状态转换:
|
||||
* IDLE → HANDSHAKING → SIZE_NEG → TRANSFERRING → FINISHING → DONE
|
||||
* 任意状态 ──(error)──→ ERROR
|
||||
* ERROR/DONE ──(reset)──→ IDLE
|
||||
*/
|
||||
#ifndef OTA_RELAY_H
|
||||
#define OTA_RELAY_H
|
||||
@@ -8,149 +18,393 @@
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "transport/protocol.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "esp_partition.h"
|
||||
|
||||
// T4.3: OTA 元数据结构,用于 NVS 存储
|
||||
#define OTA_META_NVS_NAMESPACE "ota_meta"
|
||||
#define OTA_META_NVS_KEY "meta"
|
||||
|
||||
typedef struct {
|
||||
uint32_t total_size; // 固件总大小(字节)
|
||||
uint32_t stored_size; // 当前已存储大小(字节)
|
||||
uint32_t last_seq; // 最后成功存储的序列号
|
||||
uint32_t chunk_count; // 预期总块数
|
||||
uint8_t checksum[32]; // 固件 SHA256 校验(可选)
|
||||
} ota_meta_t;
|
||||
|
||||
// OTA 中继状态(对应 GD32 四阶段协议)
|
||||
// ============================================================
|
||||
// 阶段状态枚举(精简为 7 个状态,原 17 个"微状态"合并为阶段)
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
// 阶段0: 空闲
|
||||
RELAY_IDLE = 0,
|
||||
|
||||
// 阶段1: 安全握手
|
||||
RELAY_HANDSHAKE_IDLE, // 等待 HANDSHAKE_REQ
|
||||
RELAY_HANDSHAKE_SENT, // 已发送 0x00 AC,等待 GD32 响应
|
||||
RELAY_CHALLENGE_SENT, // 已发送 nonce_server (16B),等待响应
|
||||
RELAY_CHALLENGE_RESPOND, // 已收到 GD32 握手数据包
|
||||
RELAY_SESSION_KEY_SENT, // 已发送变换后的随机数,等待 0xBA C0
|
||||
RELAY_AUTH_COMPLETE, // 已收到 0xBA C0,安全通道建立完成
|
||||
|
||||
// 阶段2: 传输准备
|
||||
RELAY_SIZE_SENT, // 已发送固件大小(4B LE)
|
||||
RELAY_SIZE_ACK, // 已收到 0xBA C0 + seq
|
||||
|
||||
// 阶段3: 数据传输
|
||||
RELAY_RECEIVING, // 接收并存储固件到 Flash
|
||||
RELAY_RELAY_ACTIVE, // 存储完成,开始向 GD32 转发
|
||||
RELAY_PACKET_SENT, // 已发送 DATA_PACKET,等待 GD32 ACK
|
||||
RELAY_PACKET_ACK_OK, // ACK 正常,继续下一包
|
||||
RELAY_PACKET_ACK_RETRY, // ACK 要求重发,重新发送当前包
|
||||
RELAY_RECEIVE_DONE, // 已发送 DATA_END,等待 TRANSFER_DONE
|
||||
|
||||
// 阶段4: 结束跳转
|
||||
RELAY_TRANSFER_DONE, // 已收到 0xBA C0 + FF FF FF FF
|
||||
RELAY_FINISH_SENT, // 已发送 0x0A AC,等待 GD32 确认
|
||||
RELAY_DONE, // GD32 回复 0x0A AC,OTA 完成
|
||||
RELAY_APP_VERIFY, // 等待 GD32 APP 启动并回复存活确认
|
||||
|
||||
// 错误状态
|
||||
RELAY_ERROR = 0xFF
|
||||
RELAY_IDLE = 0, // 空闲
|
||||
RELAY_HANDSHAKING, // 阶段1: 握手 + 挑战认证
|
||||
RELAY_SIZE_NEG, // 阶段2: 固件大小协商
|
||||
RELAY_TRANSFERRING, // 阶段3: 数据包传输
|
||||
RELAY_FINISHING, // 阶段4-5: 结束OTA + APP存活验证
|
||||
RELAY_DONE, // 完成
|
||||
RELAY_ERROR = 0xFF // 错误
|
||||
} relay_state_t;
|
||||
|
||||
// OTA 结果码
|
||||
// ============================================================
|
||||
// 事件类型(驱动状态转换)
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
OTA_RESULT_OK = 0,
|
||||
OTA_RESULT_ERR_TIMEOUT = -1, // GD32 超时无响应
|
||||
OTA_RESULT_ERR_CRC = -2, // CRC 校验失败
|
||||
OTA_RESULT_ERR_AUTH = -3, // 认证失败
|
||||
OTA_RESULT_ERR_STORAGE = -4, // Flash 存储错误
|
||||
OTA_RESULT_ERR_GD32 = -5, // GD32 返回错误
|
||||
OTA_RESULT_ERR_HEALTH = -6, // 健康状态异常中止
|
||||
OTA_RESULT_ERR_USER_ABORT = -7 // 用户中止
|
||||
RELAY_EVT_START = 0, // 启动中继流程
|
||||
RELAY_EVT_RESP_OK, // 收到 GD32 有效响应
|
||||
RELAY_EVT_RESP_ERROR, // 收到 GD32 错误响应
|
||||
RELAY_EVT_TIMEOUT, // 超时
|
||||
RELAY_EVT_HEALTH_CRIT, // 健康检查危险
|
||||
RELAY_EVT_ABORT // 外部中止
|
||||
} relay_event_t;
|
||||
|
||||
/** @brief OTA 中继操作结果码枚举。 */
|
||||
typedef enum {
|
||||
OTA_RESULT_OK = 0, ///< 操作成功
|
||||
OTA_RESULT_ERR_TIMEOUT = -1, ///< 等待 GD32 响应超时
|
||||
OTA_RESULT_ERR_CRC = -2, ///< 固件 CRC/SHA-256 校验失败
|
||||
OTA_RESULT_ERR_AUTH = -3, ///< 挑战认证失败
|
||||
OTA_RESULT_ERR_STORAGE = -4, ///< Flash 存储错误
|
||||
OTA_RESULT_ERR_GD32 = -5, ///< GD32 返回错误
|
||||
OTA_RESULT_ERR_HEALTH = -6, ///< 健康检查未通过
|
||||
OTA_RESULT_ERR_USER_ABORT = -7 ///< 用户主动中止
|
||||
} ota_result_t;
|
||||
|
||||
// 外部队列句柄(在 app_main 中创建)
|
||||
extern QueueHandle_t g_ota_dispatch_queue; // 从 WiFi/BLE 到 OTA 中继
|
||||
extern QueueHandle_t g_ota_result_queue; // OTA 结果到 WiFi/BLE
|
||||
|
||||
// ============================================================
|
||||
// IRelayListener - 中继事件监听接口
|
||||
// ============================================================
|
||||
// OtaCoordinator 实现此接口并注册到 OtaRelay,
|
||||
// OtaRelay 通过接口回调通知协调器,不再直接 #include ota_coordinator.h。
|
||||
// 依赖方向为单向:OtaCoordinator → OtaRelay(通过接口)。
|
||||
/**
|
||||
* @brief OTA 中继任务入口,负责执行握手、认证、分包转发和结束流程。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
* @brief 中继事件监听器接口。
|
||||
* OtaCoordinator 实现此接口,OtaRelay 通过回调通知状态变更,
|
||||
* 避免 OtaRelay → OtaCoordinator 的直接依赖。
|
||||
*/
|
||||
void ota_relay_task(void *pvParameters);
|
||||
class IRelayListener {
|
||||
public:
|
||||
virtual ~IRelayListener() = default;
|
||||
/** @brief 中继已开始(forwardFirmware 被调用)。 */
|
||||
virtual void onRelayStarted() = 0;
|
||||
/** @brief 中继已完成。 @param success true=成功,false=失败。 */
|
||||
virtual void onRelayComplete(bool success) = 0;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 中继进度快照(一次锁保护读取,消除逐 getter 不一致)
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 发起与 GD32 的握手流程,建立 OTA 传输前的安全会话。
|
||||
* @return 操作结果码。
|
||||
* @brief GD32 中继进度只读快照。
|
||||
* 由 OtaRelay::getSnapshot() 在单次 StateLockGuard 下填充,
|
||||
* 供 OtaCoordinator::getSnapshot() 组装完整系统快照。
|
||||
*/
|
||||
ota_result_t ota_relay_start_handshake(void);
|
||||
struct RelaySnapshot {
|
||||
relay_state_t state = RELAY_IDLE; ///< 当前中继状态机状态
|
||||
uint32_t seq = 0; ///< 当前中继包序号
|
||||
uint32_t total_packets = 0; ///< 本次中继总包数
|
||||
bool gd32_app_alive = false; ///< GD32 APP 存活标志
|
||||
gd32_version_t version = {}; ///< GD32 固件版本
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 从 Flash 中读取已缓存固件并按协议转发给 GD32。
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
ota_result_t ota_relay_forward_firmware(void);
|
||||
class OtaRelay {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取OtaRelay单例引用.
|
||||
* @return OtaRelay 单例引用。
|
||||
*/
|
||||
static OtaRelay& instance();
|
||||
|
||||
/**
|
||||
* @brief 获取当前 OTA 中继状态机状态。
|
||||
* @return 当前中继状态枚举值。
|
||||
*/
|
||||
relay_state_t ota_relay_get_state(void);
|
||||
/**
|
||||
* @brief OTA中继FreeRTOS任务入口.
|
||||
* @param pvParameters FreeRTOS 任务参数(未使用)。
|
||||
*/
|
||||
static void taskEntry(void *pvParameters);
|
||||
|
||||
/**
|
||||
* @brief 重置 OTA 中继状态机,清理当前状态以便重新开始流程。
|
||||
*/
|
||||
void ota_relay_reset(void);
|
||||
/**
|
||||
* @brief 从Flash向GD32转发固件(便捷入口,内部驱动状态机循环).
|
||||
* @return 操作结果码.
|
||||
*/
|
||||
ota_result_t forwardFirmware();
|
||||
|
||||
/**
|
||||
* @brief 向 OTA 缓存分区写入一段固件数据。
|
||||
* @param offset 分区内写入偏移。
|
||||
* @param data 待写入的数据指针。
|
||||
* @param len 数据长度(字节)。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t flash_write_chunk(uint32_t offset, const uint8_t *data, size_t len);
|
||||
// ── 状态查询(线程安全)──
|
||||
/**
|
||||
* @brief 获取当前中继状态.
|
||||
* @return 当前 relay_state_t 状态值。
|
||||
*/
|
||||
static relay_state_t getState();
|
||||
/**
|
||||
* @brief 获取GD32 APP存活标志位.
|
||||
* @return true 表示 GD32 APP 存活。
|
||||
*/
|
||||
static bool getGd32AppAlive();
|
||||
/**
|
||||
* @brief 一次性读取所有中继状态字段(单次锁保护)。
|
||||
* 消除逐 getter 调用之间 relay task 更新状态导致的不一致。
|
||||
* @return 中继进度快照。
|
||||
*/
|
||||
static RelaySnapshot getSnapshot();
|
||||
/**
|
||||
* @brief 获取 OTA 中继任务句柄。
|
||||
* @return FreeRTOS 任务句柄。
|
||||
*/
|
||||
static TaskHandle_t getTaskHandle();
|
||||
|
||||
/**
|
||||
* @brief 从 OTA 缓存分区读取一段固件数据。
|
||||
* @param offset 分区内读取偏移。
|
||||
* @param data 读取缓冲区指针。
|
||||
* @param len 读取长度(字节)。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t flash_read_chunk(uint32_t offset, uint8_t *data, size_t len);
|
||||
/**
|
||||
* @brief 重置本端中继状态机到 IDLE(不主动通知 GD32)。
|
||||
* 中继失败/中止时请优先用 abortRemoteAndReset()。
|
||||
* @param release_ring_suppress true(默认)解除 SPI ring 抑制引用;
|
||||
* 状态机起点(IDLE→HANDSHAKING)必须传 false——taskEntry 在调度时
|
||||
* 取的抑制引用要覆盖整个中继,否则中继全程 worker 会偷读 OTA ACK/WAIT。
|
||||
*/
|
||||
void reset(bool release_ring_suppress = true);
|
||||
|
||||
/**
|
||||
* @brief 从持久化文件中加载 OTA 元数据。
|
||||
* @param meta 输出参数,加载后的元数据结构体。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_load(ota_meta_t *meta);
|
||||
/**
|
||||
* @brief 通知 GD32 取消 OTA 会话(SPI CANCEL),再复位本端到 IDLE。
|
||||
* 失败路径必须调用,否则 GD32 可能停在 WAITING_RANDOM/CONFIRM 等半会话。
|
||||
* @param notify_remote true 时尽力发送 CANCEL(默认 true)。
|
||||
*/
|
||||
void abortRemoteAndReset(bool notify_remote = true);
|
||||
|
||||
/**
|
||||
* @brief 将 OTA 元数据保存到持久化文件。
|
||||
* @param meta 待保存的元数据结构体。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_save(const ota_meta_t *meta);
|
||||
// ── 中继事件监听器 ──
|
||||
/**
|
||||
* @brief 注册中继事件监听器(由 OtaCoordinator 在 init 时调用)。
|
||||
* @param listener 监听器指针,传 nullptr 取消注册。
|
||||
*/
|
||||
static void setRelayListener(IRelayListener *listener);
|
||||
|
||||
/**
|
||||
* @brief 更新已存储大小与最后分块序号等 OTA 进度信息。
|
||||
* @param stored_size 已存储的固件大小(字节)。
|
||||
* @param last_seq 最后成功的分块序号。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_update(uint32_t stored_size, uint32_t last_seq);
|
||||
// ── 队列访问器 ──
|
||||
/**
|
||||
* @brief 获取事件分发队列句柄。
|
||||
* 外部模块(OtaCoordinator / BLE)通过 getter 访问,不再直接引用静态成员。
|
||||
* @return dispatchQueue 句柄,未初始化时为 NULL。
|
||||
*/
|
||||
static QueueHandle_t getDispatchQueue();
|
||||
|
||||
/**
|
||||
* @brief 清空 OTA 元数据,通常在完成或中止后调用。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_clear(void);
|
||||
/**
|
||||
* @brief 获取结果回传队列句柄。
|
||||
* @return resultQueue 句柄,未初始化时为 NULL。
|
||||
*/
|
||||
static QueueHandle_t getResultQueue();
|
||||
|
||||
// GD32 APP 存活标志(OTA 完成后由 APP PING 确认)
|
||||
extern volatile bool g_gd32_app_alive;
|
||||
/**
|
||||
* @brief 探测 GD32 APP 存活并缓存版本(公开,供 OtaCoordinator / 启动探测共用)。
|
||||
* @param timeout_ms appPing 超时(毫秒)。
|
||||
* @param out_uptime 可选,输出 GD32 uptime(秒)。
|
||||
* @return true 探测成功,false 失败。
|
||||
*/
|
||||
static bool probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime = nullptr);
|
||||
|
||||
// 当前中继状态(供健康监控外部访问)
|
||||
extern relay_state_t g_relay_state;
|
||||
/** @brief 统一 "HELLO(role=APP) → PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。 */
|
||||
static bool helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
|
||||
uint32_t max_attempts, bool allow_hello_shortcut);
|
||||
|
||||
#endif // OTA_RELAY_H
|
||||
// ── 内部状态写入(仅 ota_relay.cpp 使用,无外部调用)──
|
||||
static void setState(relay_state_t s);
|
||||
static void setRelaySeq(uint32_t seq);
|
||||
static void setRelayTotalPackets(uint32_t total);
|
||||
static void setGd32AppAlive(bool alive);
|
||||
static void setGd32Version(const gd32_version_t &ver);
|
||||
static IRelayListener* getRelayListener();
|
||||
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
|
||||
// ── 线程安全锁 ──
|
||||
static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用)
|
||||
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间
|
||||
|
||||
/** @brief RAII 状态锁守护类,自动获取/释放 s_stateMutex。
|
||||
* 作为 public 嵌套类保留,内部代码在持锁块内可直接访问 private 状态成员。 */
|
||||
class StateLockGuard {
|
||||
public:
|
||||
explicit StateLockGuard(TickType_t timeout = kLockTimeout) {
|
||||
m_locked = (s_stateMutex && xSemaphoreTake(s_stateMutex, timeout) == pdTRUE);
|
||||
}
|
||||
~StateLockGuard() {
|
||||
if (m_locked && s_stateMutex) { xSemaphoreGive(s_stateMutex); }
|
||||
}
|
||||
StateLockGuard(const StateLockGuard&) = delete;
|
||||
StateLockGuard& operator=(const StateLockGuard&) = delete;
|
||||
private:
|
||||
bool m_locked = false;
|
||||
};
|
||||
|
||||
// ── 实例状态 ──
|
||||
session_ctx_t m_sessionCtx; ///< AES 会话上下文
|
||||
uint8_t m_nonceServer[16] = {0}; ///< 服务端随机数
|
||||
uint8_t m_kSession[16] = {0}; ///< 会话密钥(挑战认证后生成)
|
||||
uint8_t m_gd32UidRuntime[12] = {0}; ///< GD32 芯片 UID(运行时握手获取)
|
||||
|
||||
static constexpr size_t GD32_UID_LEN = 12;
|
||||
static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36;
|
||||
|
||||
private:
|
||||
// ── 队列(由 getter 访问)──
|
||||
static QueueHandle_t dispatchQueue; ///< 事件分发队列
|
||||
static QueueHandle_t resultQueue; ///< 结果回传队列
|
||||
|
||||
// ── 状态成员(统一通过 public setter/getter 访问)──
|
||||
// 外部代码使用 getState()/getRelaySeq()/... 读取,setState()/setRelaySeq()/... 写入。
|
||||
// 内部代码:若已在 StateLockGuard 块内(reset/getSnapshot/启动探测),可直接访问;
|
||||
// 否则必须使用 setter/getter,禁止裸读写(消除数据竞争)。
|
||||
static relay_state_t relayState; ///< 当前状态机状态
|
||||
static uint32_t relaySeq; ///< 当前中继会话序号
|
||||
static uint32_t relayTotalPackets; ///< 本次中继总包数
|
||||
static volatile bool gd32AppAlive; ///< GD32 APP 存活标志
|
||||
static gd32_version_t gd32Version; ///< GD32 固件版本信息
|
||||
static IRelayListener *s_relayListener; ///< 中继事件监听器
|
||||
static SemaphoreHandle_t s_stateMutex; ///< 状态成员互斥锁(由 StateLockGuard 使用)
|
||||
|
||||
// taskHandle 为 write-once-at-init 字段:仅 taskEntry 启动首行写入一次,
|
||||
// 之后只读。getTaskHandle() 无锁返回,因写入发生在任务创建后、任务循环开始前,
|
||||
// 与任何外部读取者构成 happens-before(任务创建同步),无数据竞争。
|
||||
static TaskHandle_t taskHandle; ///< OTA 中继任务句柄
|
||||
|
||||
OtaRelay() = default;
|
||||
~OtaRelay() = default;
|
||||
OtaRelay(const OtaRelay&) = delete;
|
||||
OtaRelay& operator=(const OtaRelay&) = delete;
|
||||
|
||||
// ── 状态机内部类型 ──
|
||||
/**
|
||||
* @brief 状态处理函数类型:接收事件 + 载荷,返回结果码.
|
||||
* 返回 OTA_RESULT_OK 表示事件已成功处理且状态机可继续;
|
||||
* 返回负值表示错误,状态机转入 RELAY_ERROR.
|
||||
*/
|
||||
typedef ota_result_t (OtaRelay::*StateHandler)(relay_event_t event,
|
||||
const uint8_t *payload, size_t len);
|
||||
|
||||
struct RelayStateDef {
|
||||
const char *name; // 状态名称(日志用)
|
||||
StateHandler handler; // 事件处理器
|
||||
};
|
||||
|
||||
// ── 状态表(const,编译期确定)──
|
||||
static const RelayStateDef s_stateTable[];
|
||||
|
||||
// ── 各状态的事件处理器 ──
|
||||
ota_result_t handleIdle(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleHandshaking(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleSizeNeg(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleTransferring(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleFinishing(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleDone(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
ota_result_t handleError(relay_event_t event, const uint8_t *payload, size_t len);
|
||||
|
||||
// ── 协议子步骤(由状态 handler 内部调用)──
|
||||
/**
|
||||
* @brief 请求GD32 APP进入Bootloader.
|
||||
* @return ESP_OK 成功,SPI 通信失败返回错误码。
|
||||
*/
|
||||
esp_err_t requestGd32AppEnterBootloader();
|
||||
/**
|
||||
* @brief 执行握手请求(发送 HANDSHAKE_REQ).
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_TIMEOUT 超时。
|
||||
*/
|
||||
ota_result_t doHandshakeReq(uint32_t attempts = 20U);
|
||||
/**
|
||||
* @brief 执行挑战认证.
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_AUTH 认证失败。
|
||||
*/
|
||||
ota_result_t doChallengeAndAuth();
|
||||
/**
|
||||
* @brief 向 GD32 发送 OTA CANCEL(0x03),请求远端完整 resetToIdle。
|
||||
* @return ESP_OK 已发出并读到响应(或可忽略的链路错误已记录)。
|
||||
*/
|
||||
esp_err_t sendRemoteCancel();
|
||||
/**
|
||||
* @brief 发送固件总大小.
|
||||
* @param size 固件总大小(字节)。
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_TIMEOUT 超时。
|
||||
*/
|
||||
ota_result_t doSendFirmwareSize(uint32_t size);
|
||||
/**
|
||||
* @brief 转发下一批数据包(由 TRANSFERRING 状态循环调用).
|
||||
* @param deadline_tick 全局超时时刻(0=不限时)。
|
||||
* @return OTA_RESULT_OK 成功,负值表示错误。
|
||||
*/
|
||||
ota_result_t doRelayPackets(TickType_t deadline_tick);
|
||||
/**
|
||||
* @brief 准备单包:读 Flash + 组装 tx_buf + 加密 + CRC(doRelayPackets 子步骤)。
|
||||
* @param seq 当前包序号。
|
||||
* @param flash_offset 本包在 gd32_ota 分区中的起始偏移(不依赖 m_transferFlashOffset,便于预组下一包)。
|
||||
* @param meta 固件元数据(用于计算末包 chunk_size)。
|
||||
* @param tx_buf 输出缓冲区(64 字节)。
|
||||
* @param out_chunk_size 输出本包有效字节数。
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_STORAGE 读 Flash 失败,OTA_RESULT_ERR_AUTH 加密失败。
|
||||
*/
|
||||
ota_result_t prepareRelayPacket(uint32_t seq, uint32_t flash_offset, const ota_meta_t &meta,
|
||||
uint8_t tx_buf[64], size_t *out_chunk_size);
|
||||
|
||||
/** 等 ACK 期间可选预组下一包(与 GD32 处理重叠,隐藏本端 Flash/AES 延迟)。 */
|
||||
struct NextPacketPrep {
|
||||
uint32_t next_seq = 0;
|
||||
uint32_t next_flash_offset = 0;
|
||||
const ota_meta_t *meta = nullptr;
|
||||
uint8_t *buf = nullptr; ///< 64 字节
|
||||
size_t *chunk_size = nullptr;
|
||||
bool *ready = nullptr;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 发送单包并等待 ACK(doRelayPackets 子步骤)。
|
||||
*
|
||||
* 见 WAIT 后只 poll 不重发 DATA。可选 next_prep:在等 ACK 的 poll 间隙预组下一包。
|
||||
*
|
||||
* @param seq 当前包序号。
|
||||
* @param tx_buf 已组装好的 64 字节发送缓冲区。
|
||||
* @param chunk_size 本包有效字节数(成功时用于推进 m_transferFlashOffset)。
|
||||
* @param[out] out_result 失败时写入错误码(OK 时不修改)。
|
||||
* @param next_prep 可选;非空则在 poll 循环内至多预组一次下一包。
|
||||
* @return true 应中止外层循环(result 已设);false 继续下一包。
|
||||
*/
|
||||
bool sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], size_t chunk_size,
|
||||
ota_result_t *out_result, NextPacketPrep *next_prep = nullptr);
|
||||
/**
|
||||
* @brief 发送结束指令并验证 APP 存活(顺序调用 doSendFinishOta + doWaitForAppAlive).
|
||||
* @return OTA_RESULT_OK 成功,负值表示错误。
|
||||
*/
|
||||
ota_result_t doFinishAndVerify();
|
||||
/**
|
||||
* @brief 发送 FINISH_OTA 指令并等待 GD32 ACK(10 次重试).
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_GD32 失败。
|
||||
*/
|
||||
ota_result_t doSendFinishOta();
|
||||
/**
|
||||
* @brief 轮询等待 GD32 APP 存活(10 次重试).
|
||||
* @param timeout_ms 单次 appPing 超时(毫秒)。
|
||||
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_GD32 超时失败。
|
||||
*/
|
||||
ota_result_t doWaitForAppAlive(uint32_t timeout_ms);
|
||||
|
||||
// ── 传输进度跟踪(状态机内部)──
|
||||
uint32_t m_transferTotalPackets = 0;
|
||||
uint32_t m_transferCurrentSeq = 0;
|
||||
uint32_t m_transferFlashOffset = 0;
|
||||
TickType_t m_transferDeadline = 0;
|
||||
|
||||
// ── 加密/工具方法(不变)──
|
||||
static int aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static void logRespPayload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len);
|
||||
esp_err_t waitUntilNotWait(const char *stage, uint8_t *resp_type,
|
||||
uint8_t *resp_payload, size_t resp_max,
|
||||
size_t *resp_len, uint32_t timeout_ms,
|
||||
uint32_t max_wait_rounds);
|
||||
static bool isStructuredAckWithSeq(const uint8_t *payload, size_t len, uint32_t expected_seq);
|
||||
static bool parseStructuredError(const uint8_t *payload, size_t len, uint32_t *seq_out, uint8_t *err_out);
|
||||
static bool isHandshakeEchoAck(const uint8_t *payload, size_t len);
|
||||
static bool isChallengePayloadAck(const uint8_t *payload, size_t len);
|
||||
static bool isConfirmPlainAck(const uint8_t *payload, size_t len);
|
||||
static bool isSizeSeq0Ack(const uint8_t *payload, size_t len);
|
||||
static bool isFinishPlainAck(const uint8_t *payload, size_t len);
|
||||
static esp_err_t pollUntilExpectedAckShape(const char *stage,
|
||||
uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len,
|
||||
uint32_t poll_timeout_ms, uint32_t wait_rounds,
|
||||
uint32_t maxExtraPolls,
|
||||
bool (*matcher)(const uint8_t *, size_t));
|
||||
static bool parseAndVerifyChallengePayload(const uint8_t *resp_payload, size_t resp_len,
|
||||
uint8_t *out_k_session, uint8_t *out_uid);
|
||||
static bool aes128EcbEncrypt48Bytes(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static void calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48, uint8_t *output);
|
||||
/** @brief 清理会话密钥/上下文/nonce/UID 运行时缓存(reset 与 doFinishAndVerify 共用)。 */
|
||||
void clearSessionCryptoState();
|
||||
ota_result_t sendAndWaitAckShape(const char *stage, uint8_t cmd,
|
||||
const uint8_t *payload, size_t payload_len,
|
||||
uint32_t transact_timeout_ms,
|
||||
uint32_t poll_timeout_ms, uint32_t wait_rounds, uint32_t max_extra_polls,
|
||||
uint8_t resp_payload[], size_t resp_max, size_t *resp_len,
|
||||
bool (*matcher)(const uint8_t *, size_t));
|
||||
};
|
||||
|
||||
#endif // OTA_RELAY_H
|
||||
|
||||
+241
-908
File diff suppressed because it is too large
Load Diff
+74
-23
@@ -1,41 +1,92 @@
|
||||
/*
|
||||
/*
|
||||
* ble_ota.h - BLE OTA GATT 服务器
|
||||
* T5.1 - T5.5
|
||||
*
|
||||
* ESP32 本机 OTA 的写入/收尾逻辑已抽离到 Esp32OtaCore(WiFi/BLE 共用)。
|
||||
* 本类仅保留 BLE 传输层相关状态(连接、收尾任务去重、ACK 窗口)。
|
||||
*/
|
||||
#ifndef BLE_OTA_H
|
||||
#define BLE_OTA_H
|
||||
|
||||
#include "ota_relay.h"
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
/**
|
||||
* @brief BLE OTA 任务入口,负责初始化 NimBLE 并运行 GATT OTA 服务。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
class BleOta {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 BleOta 单例实例。
|
||||
* @return BleOta 类引用。
|
||||
*/
|
||||
void ble_ota_task(void *pvParameters);
|
||||
static BleOta& instance();
|
||||
|
||||
/**
|
||||
* @brief 启动 BLE GATT 服务器并注册 OTA 相关服务与特征。
|
||||
/**
|
||||
* @brief BLE OTA FreeRTOS 任务入口。
|
||||
* @param pvParameters 任务参数,未使用。
|
||||
*/
|
||||
static void taskEntry(void *pvParameters);
|
||||
/**
|
||||
* @brief 启动 BLE GATT 服务器并注册 OTA 服务。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t ble_ota_server_start(void);
|
||||
esp_err_t serverStart();
|
||||
|
||||
/**
|
||||
* @brief 通过 BLE Notify 将 GD32 的响应数据发送给上位机 APP。
|
||||
/**
|
||||
* @brief WiFi OTA 期间软挂起 BLE(停广播、断连、拒收写;不 deinit 控制器)。
|
||||
* ESP32-C3 上 hard deinit 会打挂 WiFi 共存,导致上传立刻失联。
|
||||
* @return ESP_OK 已挂起或本就未运行。
|
||||
*/
|
||||
esp_err_t suspendStack();
|
||||
|
||||
/**
|
||||
* @brief WiFi OTA 结束后恢复 BLE 广播(栈仍在则只重新 adv)。
|
||||
* @return ESP_OK 成功或无需恢复。
|
||||
*/
|
||||
esp_err_t resumeStack();
|
||||
|
||||
/** @brief 当前是否因 WiFi OTA 挂起 BLE。 */
|
||||
bool isSuspendedForWifiOta() const { return m_suspendedForWifiOta; }
|
||||
|
||||
/**
|
||||
* @brief 通过 BLE Notify 推送响应数据。
|
||||
* @param data 待推送的数据指针。
|
||||
* @param len 数据长度(字节)。
|
||||
* @param len 数据长度。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t ble_ota_notify_response(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 检查当前是否存在已连接的 BLE 客户端。
|
||||
* @return true 已连接,false 未连接。
|
||||
esp_err_t notifyResponse(const uint8_t *data, size_t len);
|
||||
/**
|
||||
* @brief 检查 BLE 客户端连接状态。
|
||||
* @return 已连接返回 true,未连接返回 false。
|
||||
*/
|
||||
bool ble_ota_is_connected(void);
|
||||
bool isConnected();
|
||||
/** @brief 获取 BLE OTA 守护任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
/**
|
||||
* @brief 在固件存储完成后通知中继模块开始向 GD32 转发。
|
||||
*/
|
||||
void ble_ota_signal_relay(void);
|
||||
/** @brief 获取 BLE OTA 数据工作线程句柄。 */
|
||||
TaskHandle_t getWorkerTaskHandle() const { return m_workerTaskHandle; }
|
||||
|
||||
#endif // BLE_OTA_H
|
||||
// 以下状态为 public,供 C 回调函数访问
|
||||
bool m_bleReady = false; ///< BLE GATT 服务注册完成标志
|
||||
bool m_bleConnected = false; ///< BLE 客户端连接状态
|
||||
uint16_t m_connHandle = 0xFFFF; ///< BLE 连接句柄(0xFFFF=未连接)
|
||||
|
||||
// BLE 传输相关(OTA 写入状态已移至 Esp32OtaCore)
|
||||
bool m_esp32RebootPending = false; ///< 收尾任务去重标志
|
||||
uint8_t m_esp32LastCmd = 0; ///< 最后一次数据包命令(用于补发 ACK)
|
||||
uint16_t m_esp32LastSeq = 0xFFFFU; ///< 最后一次数据包序号(用于补发 ACK)
|
||||
TaskHandle_t m_taskHandle = nullptr; ///< BLE OTA 守护任务句柄
|
||||
TaskHandle_t m_workerTaskHandle = nullptr; ///< BLE OTA 数据工作线程句柄
|
||||
bool m_stackRunning = false; ///< NimBLE 已 init 且 host 在跑
|
||||
bool m_suspendedForWifiOta = false; ///< 因 WiFi OTA 主动挂起
|
||||
};
|
||||
|
||||
// ── 堆低水位快照辅助 getter(不在 BleOta 类内,file-static 资源访问) ──
|
||||
/** @brief BLE OTA 数据队列当前深度(每个 item 持有 malloc 的 data 缓冲)。
|
||||
* 供 /system/heap_low_log 采样。非 OTA 期返回 0。 */
|
||||
uint32_t ble_ota_get_data_queue_depth();
|
||||
/** @brief BLE reorder buffer 已缓存的乱序包数(每个 slot 持有 malloc 的 payload)。
|
||||
* 供 /system/heap_low_log 采样。非 ESP32 OTA 期返回 0。 */
|
||||
uint32_t ble_ota_get_reorder_cached_count();
|
||||
|
||||
#endif // BLE_OTA_H
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
||||
/*
|
||||
* ble_ota_host.cpp - BLE OTA GATT/GAP/广播/NimBLE Host
|
||||
*/
|
||||
#include "ble_ota_priv.h"
|
||||
|
||||
uint16_t s_data_out_val_handle = 0;
|
||||
|
||||
int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg)
|
||||
{
|
||||
(void)conn_handle;
|
||||
(void)attr_handle;
|
||||
(void)arg;
|
||||
|
||||
if (BleOta::instance().isSuspendedForWifiOta()) {
|
||||
return BLE_ATT_ERR_WRITE_NOT_PERMITTED;
|
||||
}
|
||||
|
||||
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
|
||||
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
uint8_t buf[36] = {0};
|
||||
int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
|
||||
if (len < 1) {
|
||||
DEBUG_WARN(TAG, "CTRL 数据为空");
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
DEBUG_DBG(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
|
||||
|
||||
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
|
||||
uint16_t session_id = 0;
|
||||
if (len >= 35) {
|
||||
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
|
||||
} else if (len >= 3) {
|
||||
session_id = (uint16_t)(buf[1] | (buf[2] << 8));
|
||||
}
|
||||
|
||||
if (len < 35) {
|
||||
DEBUG_WARN(TAG, "CTRL 0xF7 len<35 缺SHA,拒GD32中继");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
|
||||
packBleFinishValue(session_id, 1));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继
|
||||
ota_meta_t meta = {};
|
||||
esp_err_t err = OtaCoordinator::instance().loadGd32Meta(&meta);
|
||||
if (err == ESP_OK &&
|
||||
meta.total_size > 0 &&
|
||||
meta.stored_size >= meta.total_size) {
|
||||
const uint8_t *frontend_sha = &buf[1];
|
||||
char frontend_sha_hex[65] = {0};
|
||||
char device_sha_hex[65] = {0};
|
||||
shaBytesToHexUpper(frontend_sha, frontend_sha_hex);
|
||||
|
||||
// ★ 统一走共享 helper:计算设备端 SHA → 比对前端 SHA
|
||||
uint8_t device_sha[32] = {0};
|
||||
err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha);
|
||||
shaBytesToHexUpper(device_sha, device_sha_hex);
|
||||
sendSha256Notify(device_sha, session_id);
|
||||
if (err == ESP_ERR_INVALID_CRC) {
|
||||
DEBUG_ERR(TAG, "GD32 SHA-256 不匹配");
|
||||
DEBUG_ERR(TAG, "frontend=%s", frontend_sha_hex);
|
||||
DEBUG_ERR(TAG, "device=%s", device_sha_hex);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
|
||||
packBleFinishValue(session_id, 0));
|
||||
return 0;
|
||||
} else if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "GD32 SHA 失败: %s", esp_err_to_name(err));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||
packBleFinishValue(session_id, 3));
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "GD32就绪 SHA匹配 %" PRIu32 "/%" PRIu32 ",触发中继",
|
||||
meta.stored_size, meta.total_size);
|
||||
|
||||
/* ★ 包级防呆:SHA 匹配只证明传输无误。BOOT 包/非 APP 包在此拒绝,
|
||||
* 不进入 SPI 中继,GD32 APP 区不会被擦除。 */
|
||||
const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity();
|
||||
if (identity != Gd32ImageIdentity::kApp) {
|
||||
DEBUG_ERR(TAG, "GD32 固件身份校验失败 identity=%d,拒中继", (int)identity);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FW_IDENTITY,
|
||||
packBleFinishValue(session_id,
|
||||
(identity == Gd32ImageIdentity::kBootloader) ? 1 : 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (OtaRelay::getResultQueue()) {
|
||||
uint32_t stale = 0;
|
||||
while (xQueueReceive(OtaRelay::getResultQueue(), &stale, 0) == pdTRUE) {
|
||||
DEBUG_WARN(TAG, "清理旧 GD32 中继结果: %" PRIu32, stale);
|
||||
}
|
||||
}
|
||||
esp_err_t trig_err = OtaCoordinator::instance().triggerGd32Relay();
|
||||
if (trig_err == ESP_ERR_INVALID_SIZE) {
|
||||
/* 兜底:triggerGd32Relay 内部身份校验拒绝(与上方同判据) */
|
||||
DEBUG_ERR(TAG, "GD32 固件身份校验失败(trigger 兜底),拒中继");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FW_IDENTITY,
|
||||
packBleFinishValue(session_id, 2));
|
||||
return 0;
|
||||
}
|
||||
if (trig_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "GD32 中继触发失败: %s", esp_err_to_name(trig_err));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||
packBleFinishValue(session_id, 1));
|
||||
return 0;
|
||||
}
|
||||
|
||||
sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START,
|
||||
packBleFinishValue(session_id, 0));
|
||||
DEBUG_INFO(TAG, "GD32中继开始 stage=0x10 sess=%u", session_id);
|
||||
|
||||
BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx));
|
||||
if (!ctx) {
|
||||
DEBUG_ERR(TAG, "GD32 中继等待任务上下文分配失败");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||
packBleFinishValue(session_id, 2));
|
||||
return 0;
|
||||
}
|
||||
ctx->session_id = session_id;
|
||||
BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task,
|
||||
"ble_gd32_wait",
|
||||
TASK_STACK_BLE_GD32_WAIT,
|
||||
ctx,
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 GD32 中继等待任务失败");
|
||||
free(ctx);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||
packBleFinishValue(session_id, 2));
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
|
||||
meta.stored_size, meta.total_size);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||
packBleFinishValue(session_id, 1));
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
|
||||
uint16_t session_id = 0;
|
||||
if (len >= 35) {
|
||||
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
|
||||
}
|
||||
if (!Esp32OtaCore::instance().isActive()) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||
packBleFinishValue(session_id, 1));
|
||||
return 0;
|
||||
}
|
||||
if (BleOta::instance().m_esp32RebootPending) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||
packBleFinishValue(session_id, 1));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对,
|
||||
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。
|
||||
if (len < 33) {
|
||||
DEBUG_WARN(TAG, "CTRL 0xF8 len<33 缺SHA,拒绝");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG);
|
||||
return 0;
|
||||
}
|
||||
|
||||
BleEsp32FinishCtx *finish_ctx = (BleEsp32FinishCtx *)malloc(sizeof(BleEsp32FinishCtx));
|
||||
if (!finish_ctx) {
|
||||
DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||
packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM));
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&finish_ctx->sha_hex[i * 2], 3, "%02x", buf[1 + i]);
|
||||
}
|
||||
finish_ctx->sha_hex[64] = '\0';
|
||||
finish_ctx->session_id = session_id;
|
||||
DEBUG_DBG(TAG, "feSHA=%s sess=%u", finish_ctx->sha_hex, session_id);
|
||||
|
||||
BleOta::instance().m_esp32RebootPending = true;
|
||||
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
|
||||
"ble_ota_finish",
|
||||
TASK_STACK_BLE_FINISH,
|
||||
finish_ctx, // heap context; task 负责 free
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||
free(finish_ctx);
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||
packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM));
|
||||
} else {
|
||||
DEBUG_DBG(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {
|
||||
// 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host
|
||||
DEBUG_INFO(TAG, "0xFA重启,建restart task(500ms)");
|
||||
if (BleOta::instance().m_esp32RebootPending) {
|
||||
DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL");
|
||||
return 0;
|
||||
}
|
||||
BleOta::instance().m_esp32RebootPending = true;
|
||||
BaseType_t ok = xTaskCreate(ble_esp32_restart_task,
|
||||
"ble_ota_restart",
|
||||
TASK_STACK_BLE_RESTART,
|
||||
NULL,
|
||||
5,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 0xFA 重启任务失败");
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0);
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_ABORT_OTA) {
|
||||
// 0xFB: 中止当前 OTA(保持 BLE 连接)
|
||||
// - 数据接收阶段(GD32_STORING / ESP32_RECEIVING):立即中止 + 清队列 + 回复 ABORTED
|
||||
// - 中继/收尾阶段(GD32_RELAYING / ESP32_FINALIZING):拒绝中止(SPI 转发不可中断)
|
||||
// - IDLE/已完成状态:幂等回复 ABORTED
|
||||
uint16_t session_id = 0;
|
||||
if (len >= 3) {
|
||||
session_id = (uint16_t)(buf[1] | (buf[2] << 8));
|
||||
}
|
||||
|
||||
ota_system_state_t cur_state = OtaCoordinator::instance().getSystemState();
|
||||
DEBUG_WARN(TAG, "0xFB abort state=%s sess=%u",
|
||||
OtaCoordinator::stateToString(cur_state), session_id);
|
||||
|
||||
if (cur_state == OTA_SYS_GD32_RELAYING) {
|
||||
DEBUG_WARN(TAG, "GD32 SPI中继中,无法中止,请等待");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
|
||||
packBleFinishValue(session_id, 2));
|
||||
return 0;
|
||||
}
|
||||
if (cur_state == OTA_SYS_ESP32_FINALIZING) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾进行中,无法中止");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
|
||||
packBleFinishValue(session_id, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── 可中止状态:清队列 + abort core + 复位协调器 ──
|
||||
if (s_otaDataQueue) {
|
||||
BleOtaDataBatch stale;
|
||||
size_t dropped = 0;
|
||||
while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
|
||||
if (stale.pool_idx >= 0) ble_ota_pool_release(stale.pool_idx);
|
||||
else free(stale.data);
|
||||
dropped++;
|
||||
}
|
||||
if (dropped > 0) {
|
||||
DEBUG_INFO(TAG, "中止时清理 OTA 数据队列: %zu 项", dropped);
|
||||
}
|
||||
}
|
||||
|
||||
// abortCurrent 内部按状态分发:GD32_STORING→metaClear, ESP32_RECEIVING→core.abort, IDLE→空操作
|
||||
// ble_ota_session_reset() 统一负责 abortCurrent + core.abort 兜底 + 会话状态复位
|
||||
// (m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending/reorder/s_gd32_ooo_count)
|
||||
ble_ota_session_reset();
|
||||
|
||||
ble_ota_restore_wifi_ps();
|
||||
|
||||
DEBUG_INFO(TAG, "OTA 已中止(BLE 保持连接),通知前端 (stage=0x20)");
|
||||
sendFinishEvent(BLE_OTA_FINISH_ABORTED,
|
||||
packBleFinishValue(session_id, 0));
|
||||
}
|
||||
// 0xF7 = 触发 GD32 中继
|
||||
// 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对,
|
||||
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启)
|
||||
// 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)
|
||||
// 0xFB = 中止当前 OTA(不断开 BLE):仅数据接收阶段有效,中继/收尾阶段拒绝
|
||||
|
||||
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
|
||||
}
|
||||
|
||||
/*
|
||||
* DATA_IN (0x0002) - Write
|
||||
* 数据块格式:
|
||||
* [0] cmd (0x00=数据)
|
||||
* [1-2] seq uint16 LE - 分块序号
|
||||
* [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效)
|
||||
* [7..n] payload 固件数据
|
||||
* 注: 已移除CRC字段(BLE链路层已有CRC校验)
|
||||
*/
|
||||
/**
|
||||
* @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。
|
||||
* @param conn_handle BLE 连接句柄。
|
||||
* @param attr_handle GATT 属性句柄。
|
||||
* @param ctxt GATT 访问上下文。
|
||||
* @param arg 回调用户参数。
|
||||
* @return NimBLE ATT 错误码,0 表示成功。
|
||||
*/
|
||||
|
||||
int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg)
|
||||
{
|
||||
(void)conn_handle;
|
||||
(void)attr_handle;
|
||||
(void)arg;
|
||||
|
||||
if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) {
|
||||
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const char *version = FW_VERSION;
|
||||
// 版本字符串: "vX.Y.Z YYYY-MM-DD HH:MM:SS"
|
||||
char ver_buf[64];
|
||||
int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, fwBuildTime());
|
||||
if (ver_len < 0 || ver_len >= (int)sizeof(ver_buf)) {
|
||||
ver_len = strlen(version);
|
||||
int rc = os_mbuf_append(ctxt->om, version, ver_len);
|
||||
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
int rc = os_mbuf_append(ctxt->om, ver_buf, ver_len);
|
||||
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
/*
|
||||
* DATA_OUT (0x0003) - Notify only
|
||||
* 访问回调不做读写处理,通知由 BleOta::instance().notifyResponse() 发送
|
||||
*/
|
||||
/**
|
||||
* @brief GATT DATA_OUT 特征访问回调(Notify-only,占位实现)。
|
||||
* @param conn_handle BLE 连接句柄。
|
||||
* @param attr_handle GATT 属性句柄。
|
||||
* @param ctxt GATT 访问上下文。
|
||||
* @param arg 回调用户参数。
|
||||
* @return 0。
|
||||
*/
|
||||
int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg)
|
||||
{
|
||||
(void)conn_handle;
|
||||
(void)attr_handle;
|
||||
(void)ctxt;
|
||||
(void)arg;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GATT 服务定义
|
||||
// ============================================================
|
||||
|
||||
// BLE_UUID16_DECLARE 在 C++ 中产生 "taking address of rvalue" 编译错误。
|
||||
// 改用 static ble_uuid16_t 变量,再取 .u 地址。
|
||||
/** @brief OTA GATT 服务 UUID */
|
||||
const ble_uuid16_t GATT_SVC_UUID = BLE_UUID16_INIT(BLE_OTA_SERVICE_UUID);
|
||||
/** @brief CTRL 特征 UUID,写入 0xF7 触发中继 */
|
||||
const ble_uuid16_t GATT_CTRL_UUID = BLE_UUID16_INIT(BLE_OTA_CTRL_UUID);
|
||||
/** @brief DATA_IN 特征 UUID,接收固件数据块 */
|
||||
const ble_uuid16_t GATT_DATA_IN_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_IN_UUID);
|
||||
/** @brief DATA_OUT 特征 UUID,Notify 推送 GD32 响应 */
|
||||
const ble_uuid16_t GATT_DATA_OUT_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_OUT_UUID);
|
||||
/** @brief VERSION 特征 UUID,返回固件版本字符串 */
|
||||
const ble_uuid16_t GATT_VERSION_UUID = BLE_UUID16_INIT(BLE_OTA_VERSION_UUID);
|
||||
|
||||
// NimBLE C 结构体在 C++ designated initializer 不能显式初始化全部字段。
|
||||
// 用 pragma 局部关闭 -Wmissing-field-initializers 警告。
|
||||
#ifdef __cplusplus
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
|
||||
#endif
|
||||
|
||||
const struct ble_gatt_chr_def gatt_ota_chrs[] = {
|
||||
{
|
||||
.uuid = &GATT_CTRL_UUID.u,
|
||||
.access_cb = ble_ota_ctrl_access,
|
||||
.flags = BLE_GATT_CHR_F_WRITE,
|
||||
},
|
||||
{
|
||||
.uuid = &GATT_DATA_IN_UUID.u,
|
||||
.access_cb = ble_ota_data_in_access,
|
||||
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
|
||||
},
|
||||
{
|
||||
.uuid = &GATT_DATA_OUT_UUID.u,
|
||||
.access_cb = ble_ota_data_out_access,
|
||||
.flags = BLE_GATT_CHR_F_NOTIFY,
|
||||
.val_handle = &s_data_out_val_handle,
|
||||
},
|
||||
{
|
||||
.uuid = &GATT_VERSION_UUID.u,
|
||||
.access_cb = ble_ota_version_access,
|
||||
.flags = BLE_GATT_CHR_F_READ,
|
||||
},
|
||||
{} // 终止符
|
||||
};
|
||||
|
||||
const struct ble_gatt_svc_def gatt_ota_svcs[] = {
|
||||
{
|
||||
.type = BLE_GATT_SVC_TYPE_PRIMARY,
|
||||
.uuid = &GATT_SVC_UUID.u,
|
||||
.characteristics = (struct ble_gatt_chr_def *)gatt_ota_chrs,
|
||||
},
|
||||
{} // 终止符
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
|
||||
// ============================================================
|
||||
// GAP 事件回调
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief BLE GAP 事件回调,处理连接和断开事件。
|
||||
* @param event
|
||||
* @param arg
|
||||
* @return 0 成功。
|
||||
*/
|
||||
int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
switch (event->type) {
|
||||
|
||||
case BLE_GAP_EVENT_CONNECT:
|
||||
if (event->connect.status == 0) {
|
||||
if (BleOta::instance().isSuspendedForWifiOta()) {
|
||||
/* WiFi OTA 中拒绝 BLE 连接,立刻踢掉 */
|
||||
DEBUG_WARN(TAG, "WiFi OTA 中,拒绝 BLE 连接 handle=%d",
|
||||
event->connect.conn_handle);
|
||||
(void)ble_gap_terminate(event->connect.conn_handle,
|
||||
BLE_ERR_REM_USER_CONN_TERM);
|
||||
BleOta::instance().m_bleConnected = false;
|
||||
return 0;
|
||||
}
|
||||
// 连接成功
|
||||
BleOta::instance().m_bleConnected = true;
|
||||
BleOta::instance().m_connHandle = event->connect.conn_handle;
|
||||
s_conn_fallback_requested = false;
|
||||
DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", BleOta::instance().m_connHandle);
|
||||
|
||||
ble_ota_log_conn_params(BleOta::instance().m_connHandle, "初始");
|
||||
int rc = ble_ota_request_conn_params(BleOta::instance().m_connHandle, false);
|
||||
if (rc != 0) {
|
||||
s_conn_fallback_requested = true;
|
||||
ble_ota_request_conn_params(BleOta::instance().m_connHandle, true);
|
||||
}
|
||||
/* 优先 LE 2M PHY(空中速率约翻倍);对端不支持则保持 1M,失败仅打日志 */
|
||||
{
|
||||
const uint8_t phy_mask = BLE_GAP_LE_PHY_1M_MASK | BLE_GAP_LE_PHY_2M_MASK;
|
||||
int phy_rc = ble_gap_set_prefered_le_phy(
|
||||
BleOta::instance().m_connHandle, phy_mask, phy_mask,
|
||||
BLE_GAP_LE_PHY_CODED_ANY);
|
||||
DEBUG_DBG(TAG, "请求 LE PHY 1M|2M: rc=%d", phy_rc);
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
|
||||
BleOta::instance().m_bleConnected = false;
|
||||
}
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_DISCONNECT:
|
||||
DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d",
|
||||
event->disconnect.reason);
|
||||
|
||||
// ★ 断开时清空数据队列,丢弃属于旧会话的残留包
|
||||
if (s_otaDataQueue) {
|
||||
drainOtaDataQueue();
|
||||
}
|
||||
|
||||
BleOta::instance().m_bleConnected = false;
|
||||
BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE;
|
||||
s_conn_fallback_requested = false;
|
||||
|
||||
/* WiFi OTA 软挂起期间:主动踢连触发的 disconnect 不得 abort WiFi OTA,也不得再开广播 */
|
||||
if (BleOta::instance().isSuspendedForWifiOta()) {
|
||||
DEBUG_INFO(TAG, "WiFi OTA 挂起中:忽略 BLE 断开对 OTA 状态的影响");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ★ BLE 断开时恢复 WiFi 射频
|
||||
ble_ota_restore_wifi_ps();
|
||||
|
||||
// ★ 断开时清理"BLE 数据接收阶段"残留的 OTA 会话状态,避免下次刷写被
|
||||
// 系统 busy 假拒绝。注意区分可中止 / 不可中止状态:
|
||||
// - GD32_STORING / ESP32_RECEIVING / ESP32_FINALIZING:BLE 是唯一数据源,
|
||||
// 断开即等价于中止,必须 abortCurrent() + 清理底层 core。
|
||||
// - GD32_RELAYING / ESP32_REBOOTING:SPI 中继/重启不依赖 BLE,不能打断。
|
||||
// 这些状态保留,等中继/重启自然结束。
|
||||
// - IDLE / STORED / COMPLETE / ERROR:abortCurrent 幂等安全,直接调即可。
|
||||
{
|
||||
ota_system_state_t cur = OtaCoordinator::instance().getSystemState();
|
||||
if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) {
|
||||
DEBUG_WARN(TAG, "BLE断但%s进行中,保留状态",
|
||||
OtaCoordinator::stateToString(cur));
|
||||
} else if (cur == OTA_SYS_ESP32_RECEIVING ||
|
||||
cur == OTA_SYS_ESP32_FINALIZING ||
|
||||
cur == OTA_SYS_GD32_STORING) {
|
||||
/* 仅当当前 OTA 通道是 BLE 时才因断连中止;WiFi 通道的 RECEIVING 不能被误杀 */
|
||||
ota_channel_t ch = OTA_CHANNEL_WIFI;
|
||||
{
|
||||
/* get channel via snapshot if available */
|
||||
OtaSnapshot snap = OtaCoordinator::instance().getSnapshot();
|
||||
ch = snap.channel;
|
||||
}
|
||||
if (ch == OTA_CHANNEL_BLE) {
|
||||
DEBUG_WARN(TAG, "BLE断,中止OTA state=%s",
|
||||
OtaCoordinator::stateToString(cur));
|
||||
ble_ota_session_reset();
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "BLE 断开但当前为 WiFi OTA,不中止会话");
|
||||
}
|
||||
} else {
|
||||
ble_ota_session_reset();
|
||||
}
|
||||
}
|
||||
// 恢复广播以接受新连接
|
||||
ble_ota_start_advertising();
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_ADV_COMPLETE:
|
||||
if (BleOta::instance().isSuspendedForWifiOta()) {
|
||||
return 0; /* WiFi OTA 中不要自动再开广播 */
|
||||
}
|
||||
DEBUG_INFO(TAG, "广播周期结束,重新开始");
|
||||
ble_ota_start_advertising();
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_SUBSCRIBE:
|
||||
DEBUG_INFO(TAG, "CCC upd conn=%d attr=%d n=%d i=%d",
|
||||
event->subscribe.conn_handle,
|
||||
event->subscribe.attr_handle,
|
||||
event->subscribe.cur_notify,
|
||||
event->subscribe.cur_indicate);
|
||||
if (event->subscribe.cur_notify) {
|
||||
ble_ota_log_conn_params(event->subscribe.conn_handle, "订阅时");
|
||||
}
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_CONN_UPDATE: {
|
||||
DEBUG_DBG(TAG, "BLE 连接参数更新完成: status=%d", event->conn_update.status);
|
||||
uint16_t interval = ble_ota_log_conn_params(event->conn_update.conn_handle, "更新后");
|
||||
/* 回退阈值:高速分支目标是 itvl ≤ 8(≤10ms)。若协商失败或落在 > 8(>10ms),
|
||||
* 说明高速区间被对端拒绝/降级,尝试 Windows 兼容回退路径(10..12.5ms)。
|
||||
* 原阈值 > 16(>20ms)过宽松,收窄后让回退更早触发、避免停在 15ms 这种"慢但不算失败"的区间。 */
|
||||
if (!s_conn_fallback_requested &&
|
||||
(event->conn_update.status != 0 || interval == 0 || interval > 8)) {
|
||||
s_conn_fallback_requested = true;
|
||||
ble_ota_request_conn_params(event->conn_update.conn_handle, true);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case BLE_GAP_EVENT_MTU:
|
||||
DEBUG_INFO(TAG, "MTU 协商: conn=%d mtu=%d",
|
||||
event->mtu.conn_handle, event->mtu.value);
|
||||
return 0;
|
||||
|
||||
#ifdef BLE_GAP_EVENT_PHY_UPDATE_COMPLETE
|
||||
case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: {
|
||||
/* NimBLE: event type 名 *_COMPLETE,union 字段 phy_updated(非 phy_update)。
|
||||
* 2M 生效才能翻倍空中速率;Chrome/Win 常仍停在 1M。 */
|
||||
const uint8_t rx = event->phy_updated.rx_phy;
|
||||
const uint8_t tx = event->phy_updated.tx_phy;
|
||||
const char *rx_phy = (rx == BLE_GAP_LE_PHY_2M) ? "2M" :
|
||||
(rx == BLE_GAP_LE_PHY_1M) ? "1M" :
|
||||
(rx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?";
|
||||
const char *tx_phy = (tx == BLE_GAP_LE_PHY_2M) ? "2M" :
|
||||
(tx == BLE_GAP_LE_PHY_1M) ? "1M" :
|
||||
(tx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?";
|
||||
DEBUG_INFO(TAG, "PHY %d %s/%s",
|
||||
event->phy_updated.status, rx_phy, tx_phy);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BLE 广播
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 启动 BLE 广播,使设备可被发现和连接。
|
||||
*/
|
||||
void ble_ota_start_advertising(void) // kept as free function for BLE callback compatibility
|
||||
{
|
||||
if (BleOta::instance().isSuspendedForWifiOta()) {
|
||||
DEBUG_DBG(TAG, "WiFi OTA 挂起中,跳过广播");
|
||||
return;
|
||||
}
|
||||
|
||||
int rc;
|
||||
|
||||
// 自动推断可用地址类型(优先 Public,不可用则生成 Random)
|
||||
uint8_t own_addr_type;
|
||||
rc = ble_hs_id_infer_auto(0, &own_addr_type);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "推断地址失败 rc=%d,试随机地址", rc);
|
||||
// 生成不可解析随机地址作为 fallback
|
||||
ble_addr_t rnd_addr;
|
||||
rc = ble_hs_id_gen_rnd(0, &rnd_addr);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "随机地址失败 rc=%d", rc);
|
||||
return;
|
||||
}
|
||||
own_addr_type = rnd_addr.type;
|
||||
}
|
||||
|
||||
DEBUG_DBG(TAG, "BLE addr type: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random");
|
||||
|
||||
// --- 构建广播数据 ---
|
||||
uint8_t adv_data[31] = {0};
|
||||
int adv_pos = 0;
|
||||
|
||||
// AD Flags: LE General Discoverable + BR/EDR Not Supported
|
||||
adv_data[adv_pos++] = 0x02; // 长度
|
||||
adv_data[adv_pos++] = 0x01; // 类型 = Flags
|
||||
adv_data[adv_pos++] = 0x06; // 值
|
||||
|
||||
// Complete Local Name
|
||||
const char *name = BLE_DEVICE_NAME;
|
||||
size_t name_len = strlen(name);
|
||||
if (name_len > 29 - adv_pos - 2) {
|
||||
name_len = 29 - adv_pos - 2; // 截断以适配广播包
|
||||
}
|
||||
adv_data[adv_pos++] = (uint8_t)(name_len + 1);
|
||||
adv_data[adv_pos++] = 0x09; // 类型 = Complete Local Name
|
||||
memcpy(&adv_data[adv_pos], name, name_len);
|
||||
adv_pos += name_len;
|
||||
|
||||
rc = ble_gap_adv_set_data(adv_data, adv_pos);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "设置广播数据失败: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
// 扫描响应: 包含 Service UUID 以帮助发现
|
||||
uint8_t scan_rsp[31] = {0};
|
||||
int rsp_pos = 0;
|
||||
scan_rsp[rsp_pos++] = 0x11; // 长度 (2 + 15)
|
||||
scan_rsp[rsp_pos++] = 0x09; // 类型 = Complete Local Name
|
||||
name_len = strlen(name);
|
||||
if (name_len > (size_t)(31 - rsp_pos)) {
|
||||
name_len = 31 - rsp_pos;
|
||||
}
|
||||
memcpy(&scan_rsp[rsp_pos], name, name_len);
|
||||
rsp_pos += name_len;
|
||||
|
||||
rc = ble_gap_adv_rsp_set_data(scan_rsp, rsp_pos);
|
||||
if (rc != 0) {
|
||||
DEBUG_WARN(TAG, "设置扫描响应数据失败: %d", rc);
|
||||
}
|
||||
|
||||
// 开始广播
|
||||
struct ble_gap_adv_params adv_params = {};
|
||||
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||
// own_addr_type 已在上面由 ble_hs_id_infer_auto 推断
|
||||
|
||||
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
|
||||
&adv_params, ble_ota_gap_event, NULL);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "启动广播失败: %d", rc);
|
||||
} else {
|
||||
/* 冷启动与 WiFi 共存恢复都会走这里;常态 INFO 不刷,避免上传日志夹杂 BLE */
|
||||
DEBUG_DBG(TAG, "BLE广播就绪: \"%s\"", name);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NimBLE 同步/重置回调
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief NimBLE 协议栈同步完成回调,初始化广播。
|
||||
*/
|
||||
void ble_ota_on_sync(void)
|
||||
{
|
||||
DEBUG_INFO(TAG, "NimBLE 协议栈同步完成");
|
||||
|
||||
// GATT 服务已在 serverStart() 中注册,不再重复注册
|
||||
// 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY
|
||||
|
||||
DEBUG_INFO(TAG, "DATA_OUT 值句柄: %u", s_data_out_val_handle);
|
||||
if (s_data_out_val_handle == 0) {
|
||||
DEBUG_WARN(TAG, "DATA_OUT 值句柄为 0,通知功能不可用");
|
||||
}
|
||||
|
||||
BleOta::instance().m_bleReady = true;
|
||||
ble_ota_start_advertising();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief NimBLE 协议栈重置回调。
|
||||
* @param reason
|
||||
*/
|
||||
void ble_ota_on_reset(int reason)
|
||||
{
|
||||
DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason);
|
||||
BleOta::instance().m_bleReady = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NimBLE Host 任务(独立 FreeRTOS 任务)
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief NimBLE 主机任务入口,处理协议栈事件循环。
|
||||
* @param param
|
||||
*/
|
||||
void ble_ota_nimble_host_task(void *param)
|
||||
{
|
||||
(void)param;
|
||||
DEBUG_INFO(TAG, "NimBLE Host 任务启动");
|
||||
nimble_port_run();
|
||||
// 正常情况下不会到达这里
|
||||
DEBUG_INFO(TAG, "NimBLE Host 任务退出");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 公开 API
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 启动 BLE GATT 服务器并注册 OTA 服务。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* ble_ota_priv.h - BLE OTA 内部共享声明(仅 server/ble_ota*.cpp 使用)
|
||||
*
|
||||
* 拆分:
|
||||
* ble_ota.cpp — 单例/会话/收尾/公开 API
|
||||
* ble_ota_data.cpp — 乱序缓冲、数据写路径、worker
|
||||
* ble_ota_host.cpp — GATT/GAP/广播/Host
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <string.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/ble_gap.h"
|
||||
#include "nimble/ble.h"
|
||||
#include "services/gap/ble_svc_gap.h"
|
||||
#include "services/gatt/ble_svc_gatt.h"
|
||||
#include "host/ble_att.h"
|
||||
|
||||
#include "app_config.h"
|
||||
#include "version.h"
|
||||
#include "ble_ota.h"
|
||||
#include "esp32_ota_core.h"
|
||||
#include "ota_coordinator.h"
|
||||
#include "ota_relay.h"
|
||||
#include "storage/gd32_flash_store.h"
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
#define TAG "BLE_OTA"
|
||||
#define BLE_OTA_NOTIFY_ACK 0xA1
|
||||
#define BLE_OTA_NOTIFY_FINISH 0xB1
|
||||
#define BLE_OTA_NOTIFY_SHA256 0xC1
|
||||
#define BLE_OTA_FINISH_DONE 0x02
|
||||
#define BLE_OTA_FINISH_REBOOT_NOW 0x07
|
||||
#define BLE_OTA_FINISH_GD32_RELAY_START 0x10
|
||||
#define BLE_OTA_FINISH_GD32_RELAY_DONE 0x11
|
||||
#define BLE_OTA_FINISH_ABORTED 0x20
|
||||
#define BLE_ATT_ERR_OTA_BUSY 0x80
|
||||
#define BLE_OTA_FINISH_ERR_FINALIZE 0x81
|
||||
#define BLE_OTA_FINISH_ERR_MUTEX 0x83
|
||||
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84
|
||||
#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85
|
||||
#define BLE_OTA_FINISH_ERR_GD32_RELAY 0x86
|
||||
#define BLE_OTA_FINISH_ERR_FW_IDENTITY 0x87 ///< 包身份不符(疑似 Bootloader/非 APP 包),拒绝中继
|
||||
|
||||
struct BleOtaDataBatch {
|
||||
uint8_t *data;
|
||||
size_t len;
|
||||
int pool_idx; ///< 静态池槽索引(>=0 为池内,<0 为 heap malloc,需 free)
|
||||
};
|
||||
|
||||
struct BleGd32RelayWaitCtx {
|
||||
uint16_t session_id;
|
||||
};
|
||||
|
||||
struct BleEsp32FinishCtx {
|
||||
char sha_hex[65];
|
||||
uint16_t session_id;
|
||||
};
|
||||
|
||||
struct BleFinishCtxDeleter {
|
||||
BleEsp32FinishCtx *p;
|
||||
explicit BleFinishCtxDeleter(BleEsp32FinishCtx *ctx) : p(ctx) {}
|
||||
~BleFinishCtxDeleter() { if (p) { free(p); p = nullptr; } }
|
||||
BleFinishCtxDeleter(const BleFinishCtxDeleter&) = delete;
|
||||
BleFinishCtxDeleter& operator=(const BleFinishCtxDeleter&) = delete;
|
||||
};
|
||||
|
||||
extern QueueHandle_t s_otaDataQueue;
|
||||
extern uint16_t s_data_out_val_handle;
|
||||
extern bool s_wifi_ps_boosted;
|
||||
/** GAP 连接参数回退请求标志(定义在 ble_ota_data.cpp,host 连接事件读写) */
|
||||
extern bool s_conn_fallback_requested;
|
||||
|
||||
void drainOtaDataQueue();
|
||||
/** @brief 归还 DATA_IN 静态池槽(drainOtaDataQueue / 会话清理跨 TU 调用)。 */
|
||||
void ble_ota_pool_release(int idx);
|
||||
void ble_ota_start_advertising(void);
|
||||
void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
|
||||
void sendFinishEvent(uint8_t stage, uint32_t value);
|
||||
uint32_t packBleFinishValue(uint16_t session_id, uint16_t code);
|
||||
void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]);
|
||||
void sendSha256Notify(const uint8_t sha[32], uint16_t session_id);
|
||||
void esp32_reorder_free();
|
||||
void ble_ota_session_reset(void);
|
||||
void ble_ota_restore_wifi_ps(void);
|
||||
void ble_ota_boost_wifi_ps(void);
|
||||
void ble_ota_data_worker_task(void *pvParameters);
|
||||
void ble_gd32_relay_wait_task(void *pvParameters);
|
||||
int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len);
|
||||
void ble_ota_on_sync(void);
|
||||
void ble_ota_on_reset(int reason);
|
||||
void ble_ota_nimble_host_task(void *param);
|
||||
int ble_ota_gap_event(struct ble_gap_event *event, void *arg);
|
||||
uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase);
|
||||
int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compatible);
|
||||
void ble_esp32_restart_task(void *pvParameters);
|
||||
void ble_esp32_finish_and_reboot_task(void *pvParameters);
|
||||
int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg);
|
||||
int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg);
|
||||
int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg);
|
||||
int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg);
|
||||
|
||||
extern const struct ble_gatt_svc_def gatt_ota_svcs[];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
#ifndef HTTP_HANDLERS_H
|
||||
#define HTTP_HANDLERS_H
|
||||
|
||||
#include "esp_http_server.h"
|
||||
|
||||
// HTTP CORS and options
|
||||
void http_set_cors_headers(httpd_req_t *req);
|
||||
/** @brief 给 GET 轮询接口加 Connection: keep-alive,让浏览器复用 TCP 连接,
|
||||
* 省 4Hz 轮询的握手开销。OTA/relay 等需 close 的 handler 不要调用。 */
|
||||
void http_set_keepalive_hdr(httpd_req_t *req);
|
||||
esp_err_t http_options_handler(httpd_req_t *req);
|
||||
|
||||
// OTA handlers
|
||||
esp_err_t esp32_ota_update_handler(httpd_req_t *req);
|
||||
esp_err_t ota_upload_handler(httpd_req_t *req);
|
||||
esp_err_t ota_relay_trigger_handler(httpd_req_t *req);
|
||||
esp_err_t restart_handler(httpd_req_t *req);
|
||||
|
||||
// Utility handlers
|
||||
esp_err_t logs_handler(httpd_req_t *req);
|
||||
/** @brief CAN 发送(唯一入口):同通道 1~N 帧,单帧即 frames 长度为 1。 */
|
||||
esp_err_t can_send_batch_handler(httpd_req_t *req);
|
||||
/** @brief CAN 跨通道突发发送:多帧经 0xC7 一次/多次 SPI 事务背靠背发出,近同时。 */
|
||||
esp_err_t can_send_burst_handler(httpd_req_t *req);
|
||||
esp_err_t spi_send_handler(httpd_req_t *req);
|
||||
esp_err_t system_status_handler(httpd_req_t *req);
|
||||
esp_err_t system_probe_handler(httpd_req_t *req);
|
||||
|
||||
/** @brief GET /system/heap_low_log — 返回设备侧最新 1 条创新低上下文。 */
|
||||
esp_err_t system_heap_low_log_handler(httpd_req_t *req);
|
||||
|
||||
// CAN 反馈快照(被动 0xC1)与接收模式
|
||||
esp_err_t can_feedback_handler(httpd_req_t *req);
|
||||
esp_err_t can_mode_handler(httpd_req_t *req);
|
||||
// CAN 通道收发器 Silent(0xC8,只听不发)
|
||||
esp_err_t can_silent_handler(httpd_req_t *req);
|
||||
|
||||
// 多电机状态(被动 0xC5)
|
||||
esp_err_t motor_state_handler(httpd_req_t *req);
|
||||
|
||||
// 多电机版本(被动 0xC6,事件驱动)
|
||||
esp_err_t motor_version_handler(httpd_req_t *req);
|
||||
esp_err_t motor_ota_upload_handler(httpd_req_t *req);
|
||||
esp_err_t motor_ota_relay_handler(httpd_req_t *req);
|
||||
esp_err_t motor_ota_status_handler(httpd_req_t *req);
|
||||
esp_err_t motor_ota_cancel_handler(httpd_req_t *req);
|
||||
|
||||
#endif // HTTP_HANDLERS_H
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* http_util.h - HTTP handler 共享工具(JSON 追加、query 解析、hex 编解码)
|
||||
*
|
||||
* 消除 http_handlers.cpp / health_monitor.cpp / spi_speed_test.cpp 中的重复样板。
|
||||
*/
|
||||
#ifndef HTTP_UTIL_H
|
||||
#define HTTP_UTIL_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ── JSON buffer 追加 ──────────────────────────────────────────
|
||||
* 统一替代 feedback_append / append_jsonf / appendJsonfSafe 三份拷贝。
|
||||
* 截断时不越界,返回新 offset;出错返回 -1 或原 offset(视调用方约定)。
|
||||
* 返回 (int)cap-1 表示已截断填满。 */
|
||||
static inline int http_json_append(char *buf, size_t cap, int offset, const char *fmt, ...)
|
||||
{
|
||||
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset;
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
|
||||
va_end(ap);
|
||||
if (n < 0) return offset;
|
||||
if ((size_t)n >= cap - (size_t)offset) return (int)cap - 1; /* 截断 */
|
||||
return offset + n;
|
||||
}
|
||||
|
||||
/* ── Query 参数解析 ────────────────────────────────────────────
|
||||
* 统一替代 6 处内联 ?lite=1 / ?bin=1 / ?ch=N 解析块。 */
|
||||
|
||||
/** 查询布尔型参数:值为 "1"/"y"/"t"/"T" 时返回 true,缺失或其它返回 false。 */
|
||||
static inline bool http_query_bool(httpd_req_t *req, const char *key)
|
||||
{
|
||||
char query[48];
|
||||
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return false;
|
||||
char val[8] = {};
|
||||
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return false;
|
||||
return val[0] == '1' || val[0] == 'y' || val[0] == 'Y' ||
|
||||
val[0] == 't' || val[0] == 'T';
|
||||
}
|
||||
|
||||
/** 查询无符号整型参数:缺失/非法时返回 defval。 */
|
||||
static inline uint32_t http_query_u32(httpd_req_t *req, const char *key, uint32_t defval)
|
||||
{
|
||||
char query[64];
|
||||
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return defval;
|
||||
char val[16] = {};
|
||||
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return defval;
|
||||
return (uint32_t)strtoul(val, NULL, 10);
|
||||
}
|
||||
|
||||
/* ── Hex 编解码 ────────────────────────────────────────────────
|
||||
* 统一替代内联 strtoul 循环和 snprintf("%02X") 循环。 */
|
||||
|
||||
/** hex 字符串 → 字节数组,返回写入字节数。 */
|
||||
static inline size_t http_hex_to_bytes(const char *hex, uint8_t *out, size_t max_out)
|
||||
{
|
||||
size_t hex_len = strlen(hex);
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
|
||||
char byte_str[3] = { hex[i], hex[i + 1], 0 };
|
||||
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** hex 字符串(带长度)→ 字节数组,返回写入字节数。
|
||||
* 供 json_parse.h 的非 NUL 终止字符串值使用。 */
|
||||
static inline size_t http_hex_to_bytes_n(const char *hex, size_t hex_len, uint8_t *out, size_t max_out)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
|
||||
char byte_str[3] = { hex[i], hex[i + 1], 0 };
|
||||
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 字节数组 → 大写 hex 字符串(out 需至少 2*n+1 字节)。 */
|
||||
static inline void http_bytes_to_hex_upper(const uint8_t *in, size_t n, char *out)
|
||||
{
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
snprintf(out + i * 2, 3, "%02X", in[i]);
|
||||
}
|
||||
out[n * 2] = '\0';
|
||||
}
|
||||
|
||||
/** 字节数组 → 小写 hex 字符串(out 需至少 2*n+1 字节)。 */
|
||||
static inline void http_bytes_to_hex_lower(const uint8_t *in, size_t n, char *out)
|
||||
{
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
snprintf(out + i * 2, 3, "%02x", in[i]);
|
||||
}
|
||||
out[n * 2] = '\0';
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // HTTP_UTIL_H
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* json_parse.h - 极简 JSON 扁平对象解析器(cJSON 零堆分配替代)
|
||||
*
|
||||
* 项目中对 JSON 的使用全部是"解析前端发来的扁平 JSON 对象":
|
||||
* { "sha256": "...", "verify_only": true, "total_size": 12345, "type": "esp32" }
|
||||
* 无嵌套对象、无数组(can_send_batch 已自研流式解析)。
|
||||
* 本解析器直接在调用方提供的可写字符串缓冲上做扫描,
|
||||
* 不分配堆内存,字符串值通过就地 NUL 终止返回指针。
|
||||
*
|
||||
* 用法:
|
||||
* JsonView root = jsonParse(buf); // buf 须为可写 char[]
|
||||
* if (!root.valid) return error;
|
||||
* JsonView sha = jsonGet(&root, "sha256");
|
||||
* if (sha.type == JSON_STRING) { use(sha.str); }
|
||||
* JsonView sz = jsonGet(&root, "total_size");
|
||||
* if (sz.type == JSON_NUMBER) { uint32_t n = (uint32_t)sz.num; }
|
||||
* // 无需 free —— 指针指向 buf,buf 由调用方管理
|
||||
*/
|
||||
#ifndef JSON_PARSE_H
|
||||
#define JSON_PARSE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
JSON_INVALID = 0,
|
||||
JSON_NULL,
|
||||
JSON_BOOL,
|
||||
JSON_NUMBER,
|
||||
JSON_STRING,
|
||||
JSON_OBJECT,
|
||||
} JsonType;
|
||||
|
||||
typedef struct {
|
||||
JsonType type;
|
||||
bool valid;
|
||||
char *str; /* JSON_STRING:指向 buf 内字符串(不一定 NUL 终止!) */
|
||||
size_t str_len; /* JSON_STRING:字符串长度(配合 str 使用,避免就地写 NUL) */
|
||||
double num; /* JSON_NUMBER / JSON_BOOL */
|
||||
char *obj_start; /* JSON_OBJECT:'{' 后的内容区 */
|
||||
size_t obj_len;
|
||||
} JsonView;
|
||||
|
||||
/**
|
||||
* @brief 解析 JSON 字符串为根对象视图(就地扫描,可能写入 NUL 终止符)。
|
||||
* @param json 可写 NUL 终止 JSON 字符串(须为顶层 { ... } 扁平对象)
|
||||
* @return type=JSON_OBJECT 且 valid=true,或 valid=false 解析失败
|
||||
*/
|
||||
static inline JsonView jsonParse(char *json)
|
||||
{
|
||||
JsonView v = {};
|
||||
if (!json) return v;
|
||||
|
||||
char *p = json;
|
||||
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
|
||||
if (*p != '{') return v;
|
||||
|
||||
p++;
|
||||
char *content = p;
|
||||
int depth = 1;
|
||||
while (*p) {
|
||||
if (*p == '"') {
|
||||
p++;
|
||||
while (*p && *p != '"') {
|
||||
if (*p == '\\' && p[1]) p++;
|
||||
p++;
|
||||
}
|
||||
if (*p) p++;
|
||||
} else if (*p == '{') {
|
||||
depth++; p++;
|
||||
} else if (*p == '}') {
|
||||
depth--;
|
||||
if (depth == 0) break;
|
||||
p++;
|
||||
} else {
|
||||
p++;
|
||||
}
|
||||
}
|
||||
if (depth != 0) return v;
|
||||
|
||||
v.type = JSON_OBJECT;
|
||||
v.valid = true;
|
||||
v.obj_start = content;
|
||||
v.obj_len = (size_t)(p - content);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 在扁平 JSON 对象中查找指定 key 的值。
|
||||
* @return valid=true 表示找到;字符串值经就地 NUL 终止后通过 .str 返回
|
||||
*/
|
||||
static inline JsonView jsonGet(const JsonView *obj, const char *key)
|
||||
{
|
||||
JsonView result = {};
|
||||
if (!obj || !obj->valid || obj->type != JSON_OBJECT || !key) return result;
|
||||
|
||||
char *p = obj->obj_start;
|
||||
char *end = obj->obj_start + obj->obj_len;
|
||||
|
||||
while (p < end) {
|
||||
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')) p++;
|
||||
if (p >= end) break;
|
||||
|
||||
/* "key" */
|
||||
if (*p != '"') break;
|
||||
p++;
|
||||
char *key_start = p;
|
||||
while (p < end && *p != '"') p++;
|
||||
if (p >= end) break;
|
||||
size_t klen = (size_t)(p - key_start);
|
||||
p++;
|
||||
|
||||
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
|
||||
if (p >= end || *p != ':') break;
|
||||
p++;
|
||||
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
|
||||
if (p >= end) break;
|
||||
|
||||
bool match = (strlen(key) == klen && strncmp(key_start, key, klen) == 0);
|
||||
|
||||
if (*p == '"') {
|
||||
p++;
|
||||
char *val_start = p;
|
||||
while (p < end && *p != '"') {
|
||||
if (*p == '\\' && p + 1 < end) p++;
|
||||
p++;
|
||||
}
|
||||
if (p >= end) break;
|
||||
if (match) {
|
||||
/* 不就地写 NUL(会破坏后续 jsonGet 扫描);用 str_len 返回长度 */
|
||||
result.type = JSON_STRING;
|
||||
result.valid = true;
|
||||
result.str = val_start;
|
||||
result.str_len = (size_t)(p - val_start);
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
} else if (*p == 't' || *p == 'f') {
|
||||
if (match) {
|
||||
result.type = JSON_BOOL;
|
||||
result.valid = true;
|
||||
result.num = (*p == 't') ? 1.0 : 0.0;
|
||||
return result;
|
||||
}
|
||||
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
|
||||
} else if (*p == 'n') {
|
||||
if (match) {
|
||||
result.type = JSON_NULL;
|
||||
result.valid = true;
|
||||
return result;
|
||||
}
|
||||
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
|
||||
} else {
|
||||
char *val_start = p;
|
||||
if (*p == '-') p++;
|
||||
while (p < end && ((*p >= '0' && *p <= '9') || *p == '.' || *p == 'e' || *p == 'E' || *p == '+' || *p == '-')) p++;
|
||||
if (match) {
|
||||
/* 不就地写 NUL;strtod 可在非 NUL 终止串上工作(指定长度)。
|
||||
* 但 strtod 需要终止符——用临时方式:记录起止,用 sscanf 或手动解析。
|
||||
* 更简方案:buf 本身是 NUL 终止的(jsonParse 要求 NUL 终止输入),
|
||||
* 而 *p 此处是数字后的分隔符(',' 或 '}' 或空白),不是 NUL。
|
||||
* 我们临时保存 *p、写 NUL、解析、恢复 *p。 */
|
||||
char saved = *p;
|
||||
*p = '\0';
|
||||
result.type = JSON_NUMBER;
|
||||
result.valid = true;
|
||||
result.num = strtod(val_start, nullptr);
|
||||
*p = saved; /* 恢复,不影响后续 jsonGet 扫描 */
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── 便捷谓词(兼容 cJSON 调用风格)── */
|
||||
|
||||
static inline bool jsonIsString(const JsonView *item) {
|
||||
return item && item->valid && item->type == JSON_STRING;
|
||||
}
|
||||
static inline bool jsonIsNumber(const JsonView *item) {
|
||||
return item && item->valid && item->type == JSON_NUMBER;
|
||||
}
|
||||
static inline bool jsonIsBool(const JsonView *item) {
|
||||
return item && item->valid && item->type == JSON_BOOL;
|
||||
}
|
||||
static inline bool jsonIsTrue(const JsonView *item) {
|
||||
return item && item->valid && item->type == JSON_BOOL && item->num != 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 获取字符串值指针和长度(字符串不一定 NUL 终止,须配合 str_len 使用)。
|
||||
* @param item JSON 值视图
|
||||
* @param out_len 可选,输出字符串长度
|
||||
* @return 字符串指针(指向 buf 内部),或 nullptr
|
||||
*/
|
||||
static inline const char *jsonGetStringValue(const JsonView *item, size_t *out_len = nullptr) {
|
||||
if (item && item->valid && item->type == JSON_STRING) {
|
||||
if (out_len) *out_len = item->str_len;
|
||||
return item->str;
|
||||
}
|
||||
if (out_len) *out_len = 0;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 比较字符串值是否等于指定 C 字符串。
|
||||
* 因 jsonGet 不写 NUL 终止符,不能用 strcmp,须用此函数。
|
||||
*/
|
||||
static inline bool jsonStringEquals(const JsonView *item, const char *cstr) {
|
||||
if (!jsonIsString(item) || !cstr) return false;
|
||||
size_t clen = strlen(cstr);
|
||||
return clen == item->str_len && strncmp(item->str, cstr, clen) == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将字符串值拷贝到 NUL 终止的输出缓冲(安全截断)。
|
||||
* @return 实际拷贝长度(不含 NUL)
|
||||
*/
|
||||
static inline size_t jsonGetStringCopy(const JsonView *item, char *out, size_t out_max) {
|
||||
if (!jsonIsString(item) || !out || out_max == 0) return 0;
|
||||
size_t n = (item->str_len < out_max - 1) ? item->str_len : out_max - 1;
|
||||
memcpy(out, item->str, n);
|
||||
out[n] = '\0';
|
||||
return n;
|
||||
}
|
||||
|
||||
static inline double jsonGetNumberValue(const JsonView *item) {
|
||||
return (item && item->valid && item->type == JSON_NUMBER) ? item->num : 0.0;
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // JSON_PARSE_H
|
||||
+413
-1311
File diff suppressed because it is too large
Load Diff
+129
-34
@@ -1,52 +1,147 @@
|
||||
/*
|
||||
* wifi_ota.h - WiFi OTA 服务器
|
||||
* T4.1 - T4.5
|
||||
*
|
||||
* WifiOta 类封装 WiFi 初始化、HTTP OTA 服务器和网络状态查询。
|
||||
* ESP32 本机 OTA 的写入/收尾逻辑已抽离到 Esp32OtaCore(WiFi/BLE 共用)。
|
||||
*/
|
||||
#ifndef WIFI_OTA_H
|
||||
#define WIFI_OTA_H
|
||||
|
||||
#include "ota_relay.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_event.h"
|
||||
#include "freertos/task.h"
|
||||
#include <atomic>
|
||||
|
||||
// WiFi 模式: AP 或 STA
|
||||
#include "ota/ota_coordinator.h" // IBleCoexListener 接口定义
|
||||
|
||||
/** @brief WiFi 工作模式枚举,区分 AP(设备热点)和 STA(连接路由器)模式。 */
|
||||
typedef enum {
|
||||
OTA_WIFI_MODE_AP, // AP 模式(ESP32 作为热点)
|
||||
OTA_WIFI_MODE_STA // STA 模式(连接路由器)
|
||||
OTA_WIFI_MODE_AP, ///< AP 模式:设备作为热点供局域网设备连接
|
||||
OTA_WIFI_MODE_STA ///< STA 模式:设备连接上级路由器
|
||||
} ota_wifi_mode_t;
|
||||
|
||||
/**
|
||||
* @brief WiFi OTA 任务入口,负责 HTTP 服务、上传处理和 OTA 触发流程。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void wifi_ota_task(void *pvParameters);
|
||||
class WifiOta : public IBleCoexListener {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 WifiOta 单例实例。
|
||||
* @return WifiOta 单例引用。
|
||||
*/
|
||||
static WifiOta& instance();
|
||||
|
||||
// 在 app_main 早期执行 WiFi 初始化并尝试以 STA 模式联网。
|
||||
/**
|
||||
* @brief 返回 true 表示 STA 初始化已开始,false 表示后续应回退到 AP 模式。
|
||||
* @return true STA 初始化中,false 需回退 AP。
|
||||
*/
|
||||
bool wifi_early_init(void);
|
||||
/**
|
||||
* @brief 在调度器启动前初始化 WiFi 并尝试 STA 连接。
|
||||
* @return true 表示 STA 初始化中,false 需回退至 AP 模式。
|
||||
*/
|
||||
bool earlyInit();
|
||||
/**
|
||||
* @brief WiFi OTA FreeRTOS 任务入口,提供 HTTP 上传和中继触发。
|
||||
* @param pvParameters FreeRTOS 任务参数。
|
||||
*/
|
||||
static void taskEntry(void *pvParameters);
|
||||
/**
|
||||
* @brief 获取当前 WiFi 工作模式。
|
||||
* @return 当前 WiFi 模式(AP 或 STA)。
|
||||
*/
|
||||
static ota_wifi_mode_t getMode();
|
||||
/**
|
||||
* @brief 获取当前 WiFi 信号强度。
|
||||
* @return 信号强度值(dBm)。
|
||||
*/
|
||||
static int8_t getRssi();
|
||||
/**
|
||||
* @brief 判断 STA 是否已连接到上级 WiFi。
|
||||
* @return true 已连接,false 未连接。
|
||||
*/
|
||||
static bool isConnected();
|
||||
|
||||
/**
|
||||
* @brief 在固件存储完成后通知 OTA 中继开始向 GD32 转发。
|
||||
*/
|
||||
void wifi_ota_signal_relay(void);
|
||||
// ── WiFi 连接状态(WiFi 事件回调 / HTTP / reconnect task 共享,需 mutex 保护)──
|
||||
/** @brief 获取 STA 连接状态。 */
|
||||
bool getStaConnected() const { return m_staConnected.load(); }
|
||||
/** @brief 设置 STA 连接状态。 */
|
||||
void setStaConnected(bool v) { m_staConnected.store(v); }
|
||||
/** @brief 获取 STA 重试次数。 */
|
||||
int getStaRetryCount() const { return m_staRetryCount.load(); }
|
||||
/** @brief 设置 STA 重试次数。 */
|
||||
void setStaRetryCount(int v) { m_staRetryCount.store(v); }
|
||||
/** @brief 获取 STA 重试是否已耗尽。 */
|
||||
bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); }
|
||||
/** @brief 设置 STA 重试耗尽标志。 */
|
||||
void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); }
|
||||
// ── 状态互斥锁(保护复合读-改-写操作)──
|
||||
/** @brief 获取状态互斥锁句柄。 */
|
||||
SemaphoreHandle_t getStateMutex() const { return m_stateMutex; }
|
||||
/** @brief 锁定状态互斥锁。 */
|
||||
void stateLock() { if (m_stateMutex) xSemaphoreTake(m_stateMutex, portMAX_DELAY); }
|
||||
/** @brief 解锁状态互斥锁。 */
|
||||
void stateUnlock() { if (m_stateMutex) xSemaphoreGive(m_stateMutex); }
|
||||
|
||||
/**
|
||||
* @brief 获取当前 WiFi 工作模式(AP 或 STA)。
|
||||
* @return 当前 WiFi 模式枚举值。
|
||||
*/
|
||||
ota_wifi_mode_t wifi_get_mode(void);
|
||||
// ── 初始化/运行时状态(仅 OTA 任务访问,无需额外锁)──
|
||||
ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP; ///< 当前 WiFi 工作模式
|
||||
bool m_coldBoot = false; ///< 是否为冷启动(首次上电)
|
||||
SemaphoreHandle_t m_staGotIpSem = NULL; ///< STA 获取 IP 信号量
|
||||
TaskHandle_t m_reconnectTask = nullptr; ///< STA 后台重连任务句柄
|
||||
|
||||
/**
|
||||
* @brief 获取当前 WiFi RSSI,未连接时通常返回无效/默认值。
|
||||
* @return 信号强度(dBm),未连接时返回无效值。
|
||||
*/
|
||||
int8_t wifi_get_rssi(void);
|
||||
httpd_handle_t m_httpServer = NULL; ///< HTTP OTA 服务器句柄
|
||||
|
||||
/**
|
||||
* @brief 判断当前 STA 模式下是否已经连接到上级 WiFi。
|
||||
* @return true 已连接,false 未连接。
|
||||
*/
|
||||
bool wifi_is_connected(void);
|
||||
// ★ BLE OTA 传输期间暂停 WiFi 射频活动(减少共存干扰)
|
||||
std::atomic<bool> m_bleOtaActive{false};
|
||||
/** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */
|
||||
void setBleOtaActive(bool v) { m_bleOtaActive.store(v); }
|
||||
|
||||
// ── IBleCoexListener 接口实现 ──
|
||||
/** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询。 */
|
||||
void onBleOtaStart() override;
|
||||
/** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */
|
||||
void onBleOtaEnd() override;
|
||||
|
||||
/** @brief 获取 WiFi OTA 任务句柄。 */
|
||||
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
|
||||
|
||||
private:
|
||||
// ── 跨任务共享状态(atomic + m_stateMutex 保护)──
|
||||
std::atomic<bool> m_staConnected{false};
|
||||
std::atomic<int> m_staRetryCount{0};
|
||||
std::atomic<bool> m_staRetryExhausted{false};
|
||||
SemaphoreHandle_t m_stateMutex = NULL;
|
||||
TaskHandle_t m_taskHandle = nullptr;
|
||||
|
||||
/**
|
||||
* @brief 初始化 SoftAP 模式,供局域网 OTA 使用。
|
||||
*/
|
||||
void initAp();
|
||||
/**
|
||||
* @brief 初始化 STA 模式并连接预配置路由器。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t initSta();
|
||||
/**
|
||||
* @brief WiFi/IP 事件回调,处理连接、断开和 IP 获取。
|
||||
* @param arg 回调用户参数。
|
||||
* @param event_base 事件基类。
|
||||
* @param event_id 具体事件 ID。
|
||||
* @param event_data 事件附带数据。
|
||||
*/
|
||||
static void wifiEventHandler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data);
|
||||
/**
|
||||
* @brief STA 后台重连任务,处理退避延迟和重连。
|
||||
* @param pvParameters FreeRTOS 任务参数。
|
||||
*/
|
||||
static void reconnectTask(void *pvParameters);
|
||||
/**
|
||||
* @brief 启动 HTTP OTA 服务器,注册各接口路由。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t httpServerStart();
|
||||
/**
|
||||
* @brief 等待 STA 连接成功并获取 IP 地址。
|
||||
* @param ip_str 输出缓冲区,存放 IP 字符串。
|
||||
* @param ip_len 缓冲区长度。
|
||||
* @return ESP_OK 连接成功,ESP_ERR_TIMEOUT 超时。
|
||||
*/
|
||||
esp_err_t waitStaConnected(char *ip_str, size_t ip_len);
|
||||
};
|
||||
|
||||
#endif // WIFI_OTA_H
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
/*
|
||||
* gd32_flash_store.cpp - GD32 OTA 固件 Flash 存储抽象层实现
|
||||
*
|
||||
* 从 OtaRelay 剥离的 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验。
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <inttypes.h>
|
||||
#include "esp_partition.h"
|
||||
#include "esp_task_wdt.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
|
||||
#include "mbedtls/private/sha256.h"
|
||||
|
||||
#include "gd32_flash_store.h"
|
||||
#include "app_config.h" // OTA_BLOCK_SIZE(flashWriteChunk 静态对齐缓冲容量)
|
||||
#include "monitor/debug_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
// ============================================================
|
||||
// 单例
|
||||
// ============================================================
|
||||
Gd32FlashStore& Gd32FlashStore::instance()
|
||||
{
|
||||
static Gd32FlashStore inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// 构造时创建 NVS 互斥锁。
|
||||
// 单例静态局部变量在首次 instance() 调用时构造,此时 FreeRTOS 已就绪。
|
||||
Gd32FlashStore::Gd32FlashStore()
|
||||
{
|
||||
m_nvsMutex = xSemaphoreCreateMutex();
|
||||
if (!m_nvsMutex) {
|
||||
DEBUG_ERR("OTA", "Gd32FlashStore NVS mutex 创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NVS 互斥锁辅助
|
||||
// ============================================================
|
||||
bool Gd32FlashStore::lockNvs(uint32_t timeout_ms)
|
||||
{
|
||||
// 参考 OtaCoordinator::lock:返回 take 结果,由调用方传回 unlockNvs 决定是否 give。
|
||||
if (!m_nvsMutex) return false;
|
||||
return xSemaphoreTake(m_nvsMutex, pdMS_TO_TICKS(timeout_ms)) == pdTRUE;
|
||||
}
|
||||
|
||||
void Gd32FlashStore::unlockNvs(bool handle)
|
||||
{
|
||||
// 仅在对应 lockNvs 成功 take 时才 give,避免误释放。
|
||||
if (handle && m_nvsMutex) {
|
||||
xSemaphoreGive(m_nvsMutex);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 分区懒初始化
|
||||
// ============================================================
|
||||
const esp_partition_t* Gd32FlashStore::getPartition()
|
||||
{
|
||||
if (!m_partition) {
|
||||
m_partition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota");
|
||||
if (!m_partition) {
|
||||
DEBUG_ERR("OTA", "找不到 gd32_ota 分区");
|
||||
}
|
||||
}
|
||||
return m_partition;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Flash 分区读写
|
||||
// ============================================================
|
||||
|
||||
void Gd32FlashStore::beginWriteSession(bool full_erase)
|
||||
{
|
||||
m_erasedUpTo = 0;
|
||||
if (!full_erase) {
|
||||
return; // GD32 OTA:保持原优化,由 flashWriteChunk 按需擦除与收流交织
|
||||
}
|
||||
/* ★ motor OTA:强制擦除整个 gd32_ota 分区,而不是只重置水位。
|
||||
* 之前只重置 m_erasedUpTo=0,依赖 flashWriteChunk 按需擦除覆盖到的扇区,
|
||||
* 导致两类隐患:
|
||||
* 1. 上次刷的更大固件在高位扇区残留,本次若擦除位错误会与残留 AND 产生位翻转
|
||||
* (正是 0xAC20 处 0x19→0x18 单 bit 翻转的根因)。
|
||||
* 2. ensureErasedForWrite 按水位推进,若某次擦除返回 OK 但物理失败,
|
||||
* 旧数据残留 + AND 语义写入 = 静默位错误。
|
||||
* 强制全擦(252KB ~200ms)确保每次上传从全 0xFF 干净状态开始,
|
||||
* 即使新固件只覆盖前 44KB,剩余高位扇区也回到擦除态,杜绝残留干扰。
|
||||
* 擦除失败立刻报错,不让坏状态继续。 */
|
||||
const esp_partition_t *part = getPartition();
|
||||
if (!part) {
|
||||
DEBUG_ERR("OTA", "beginWriteSession: 分区未找到,无法预擦除");
|
||||
return;
|
||||
}
|
||||
esp_err_t err = erasePartition(part->size);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "beginWriteSession: 预擦除整个分区失败: %s", esp_err_to_name(err));
|
||||
/* 不 return(void 函数);后续 flashWriteChunk 的 ensureErasedForWrite
|
||||
* 仍会尝试擦除目标扇区,给数据写入多一次机会。 */
|
||||
} else {
|
||||
m_erasedUpTo = part->size; /* 全擦完成,后续 ensureErasedForWrite 全部跳过 */
|
||||
DEBUG_INFO("OTA", "beginWriteSession: 已预擦除整个 gd32_ota 分区 (%lu B)",
|
||||
(unsigned long)part->size);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 确保 [offset, offset+len) 覆盖的扇区已擦除(从 m_erasedUpTo 顺序推进)。 */
|
||||
esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len)
|
||||
{
|
||||
if (len == 0) return ESP_OK;
|
||||
const esp_partition_t *part = getPartition();
|
||||
if (!part) return ESP_ERR_NOT_FOUND;
|
||||
|
||||
const uint32_t end = offset + (uint32_t)len;
|
||||
// 目标区间已在水位内:无需再擦(顺序 OTA 不回写旧扇区)
|
||||
if (end <= m_erasedUpTo) return ESP_OK;
|
||||
|
||||
// 水位始终扇区对齐;只从 m_erasedUpTo 向高地址推进,禁止重擦已写扇区。
|
||||
// 若写入跳空,一并擦掉空隙,保持 [0, m_erasedUpTo) 连续已擦。
|
||||
uint32_t erase_to = (end + (kFlashSectorSize - 1U)) & ~(kFlashSectorSize - 1U);
|
||||
if (erase_to > part->size) {
|
||||
erase_to = part->size;
|
||||
}
|
||||
uint32_t erase_from = m_erasedUpTo;
|
||||
if (erase_from >= erase_to) return ESP_OK;
|
||||
|
||||
for (uint32_t addr = erase_from; addr < erase_to; ) {
|
||||
uint32_t chunk = kFlashSectorSize;
|
||||
if (addr + chunk > erase_to) {
|
||||
chunk = erase_to - addr;
|
||||
}
|
||||
esp_err_t err = esp_partition_erase_range(part, addr, chunk);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "ensureErased: erase @%" PRIu32 " len=%" PRIu32 " failed: %s",
|
||||
addr, chunk, esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
addr += chunk;
|
||||
m_erasedUpTo = addr;
|
||||
(void)esp_task_wdt_reset();
|
||||
taskYIELD(); /* 长时间兜底擦时让 WiFi/LWIP 有机会跑 */
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OTA 期对齐缓冲池(2026-08 内存优化)
|
||||
//
|
||||
// flashWriteChunk 在非 4 字节对齐写入时需要临时缓冲读回 padding。
|
||||
// 原实现每次 malloc/free,OTA 收流时与非对齐块叠加产生堆抖动。
|
||||
// 现改为 OTA begin 时一次性 calloc,end/abort 时 free,消除抖动
|
||||
// 又不增加常驻 BSS(非 OTA 期零占用)。
|
||||
// ============================================================
|
||||
static uint8_t *s_align_buf = nullptr;
|
||||
static constexpr size_t kAlignBufCap = OTA_BLOCK_SIZE + 8;
|
||||
|
||||
void Gd32FlashStore::acquireAlignBufForOta()
|
||||
{
|
||||
if (!s_align_buf) {
|
||||
s_align_buf = (uint8_t *)calloc(1, kAlignBufCap);
|
||||
if (!s_align_buf) {
|
||||
DEBUG_WARN("OTA", "对齐缓冲分配失败,回退 malloc free=%u",
|
||||
(unsigned)esp_get_free_heap_size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Gd32FlashStore::releaseAlignBufForOta()
|
||||
{
|
||||
if (s_align_buf) {
|
||||
free(s_align_buf);
|
||||
s_align_buf = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 取对齐缓冲。优先用 OTA 期预分配;未分配或太小则临时 malloc。 */
|
||||
static uint8_t *acquireAlignBuf(size_t needed)
|
||||
{
|
||||
if (s_align_buf && needed <= kAlignBufCap) {
|
||||
return s_align_buf;
|
||||
}
|
||||
// OTA 未预分配(begin 失败)或包过大:回退临时 malloc
|
||||
return (uint8_t *)malloc(needed);
|
||||
}
|
||||
|
||||
/** @brief 归还对齐缓冲。OTA 池的不释放;临时 malloc 的 free。 */
|
||||
static void releaseAlignBuf(uint8_t *buf)
|
||||
{
|
||||
if (buf && buf != s_align_buf) {
|
||||
free(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 向 gd32_ota 分区写入数据块,自动处理非 4 字节对齐的读写。 */
|
||||
esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len)
|
||||
{
|
||||
if (len == 0U) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (data == nullptr) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
const esp_partition_t *part = getPartition();
|
||||
if (!part) return ESP_ERR_NOT_FOUND;
|
||||
|
||||
// 计算 head/tail padding,使写入地址和长度都对齐到 4 字节
|
||||
size_t head_pad = offset % 4;
|
||||
uint32_t aligned_offset = offset - head_pad;
|
||||
size_t total = head_pad + len;
|
||||
size_t aligned_len = (total + 3) & ~(size_t)3;
|
||||
|
||||
if (aligned_offset + aligned_len > part->size) {
|
||||
DEBUG_ERR("OTA", "flashWrite越界 off=%" PRIu32 " n=%zu part=%" PRIu32,
|
||||
offset, len, part->size);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
// 按需擦除目标扇区(与收流交织;prepare 不再整区预擦)
|
||||
esp_err_t erase_err = ensureErasedForWrite(aligned_offset, aligned_len);
|
||||
if (erase_err != ESP_OK) {
|
||||
return erase_err;
|
||||
}
|
||||
|
||||
// 快速路径:offset 和 len 都对齐
|
||||
if (head_pad == 0 && len % 4 == 0) {
|
||||
return esp_partition_write(part, offset, data, len);
|
||||
}
|
||||
|
||||
if (head_pad > 0 || len % 4 != 0) {
|
||||
DEBUG_WARN("OTA", "flashWrite非对齐 off=%" PRIu32 " n=%zu head=%zu",
|
||||
offset, len, head_pad);
|
||||
}
|
||||
|
||||
/* OTA 期按需对齐缓冲(OTA begin 时 calloc,end/abort 时 free):
|
||||
* 消除每非对齐块的 malloc/free 抖动,又不增加常驻 BSS。
|
||||
* 容量 = OTA_BLOCK_SIZE(4KB) + 最多 3 字节 tail pad。 */
|
||||
uint8_t *aligned_buf = acquireAlignBuf(aligned_len);
|
||||
if (!aligned_buf) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk 对齐缓冲不足/未分配: %zu", aligned_len);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
// 读回 head padding
|
||||
if (head_pad > 0) {
|
||||
esp_err_t err = esp_partition_read(part, aligned_offset, aligned_buf, head_pad);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s",
|
||||
aligned_offset, head_pad, esp_err_to_name(err));
|
||||
releaseAlignBuf(aligned_buf);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
// 拷贝实际数据
|
||||
memcpy(aligned_buf + head_pad, data, len);
|
||||
|
||||
// 读回 tail padding
|
||||
size_t tail_pad = aligned_len - total;
|
||||
if (tail_pad > 0) {
|
||||
esp_err_t err = esp_partition_read(part, aligned_offset + total, aligned_buf + total, tail_pad);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s",
|
||||
aligned_offset + total, tail_pad, esp_err_to_name(err));
|
||||
releaseAlignBuf(aligned_buf);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len);
|
||||
releaseAlignBuf(aligned_buf);
|
||||
return err;
|
||||
}
|
||||
|
||||
/** @brief 从 gd32_ota 分区读取数据块。 */
|
||||
esp_err_t Gd32FlashStore::flashReadChunk(uint32_t offset, uint8_t *data, size_t len)
|
||||
{
|
||||
if (len == 0U) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (data == nullptr) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
const esp_partition_t *part = getPartition();
|
||||
if (!part) return ESP_ERR_NOT_FOUND;
|
||||
|
||||
/* 防坏 meta / 错误 offset 读越界 */
|
||||
if (offset >= part->size || len > (size_t)(part->size - offset)) {
|
||||
DEBUG_ERR("OTA", "flashReadChunk越界: offset=%" PRIu32 " len=%zu part=%" PRIu32,
|
||||
offset, len, part->size);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
return esp_partition_read(part, offset, data, len);
|
||||
}
|
||||
|
||||
/** @brief 擦除 gd32_ota 分区指定范围。 */
|
||||
esp_err_t Gd32FlashStore::erasePartition(uint32_t size)
|
||||
{
|
||||
const esp_partition_t *part = getPartition();
|
||||
if (!part) return ESP_ERR_NOT_FOUND;
|
||||
if (size > part->size) {
|
||||
DEBUG_ERR("OTA", "erasePartition: size=%" PRIu32 " > partition=%" PRIu32, size, part->size);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
// 按扇区擦除并喂狗/yield,避免大块 erase 长时间占住调用任务饿死 WiFi
|
||||
uint32_t done = 0;
|
||||
while (done < size) {
|
||||
uint32_t chunk = kFlashSectorSize;
|
||||
if (done + chunk > size) {
|
||||
chunk = size - done;
|
||||
}
|
||||
esp_err_t err = esp_partition_erase_range(part, done, chunk);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "erasePartition: erase @%" PRIu32 " len=%" PRIu32 " failed: %s",
|
||||
done, chunk, esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
done += chunk;
|
||||
if (done > m_erasedUpTo) {
|
||||
m_erasedUpTo = done;
|
||||
}
|
||||
(void)esp_task_wdt_reset();
|
||||
taskYIELD();
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NVS 元数据持久化
|
||||
// ============================================================
|
||||
|
||||
/** @brief 已持锁版本:从 NVS 加载 OTA 元数据,命名空间不存在时返回全零结构。 */
|
||||
esp_err_t Gd32FlashStore::metaLoadLocked(ota_meta_t *meta)
|
||||
{
|
||||
if (!meta) return ESP_ERR_INVALID_ARG;
|
||||
memset(meta, 0, sizeof(ota_meta_t));
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
// 区分 nvs_open 失败原因,不再一律当 OK。
|
||||
// ESP_ERR_NVS_NOT_FOUND = 命名空间不存在(首次使用/被擦除),视为空元数据,保持兼容。
|
||||
// 其他错误(NVS 未初始化/分区丢失/损坏等)返回真实错误码,
|
||||
// 避免掩盖底层故障导致调用方误判为"空元数据"继续执行。
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
DEBUG_WARN("OTA", "metaLoad nvs_open 失败(非 NOT_FOUND): %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
uint32_t val = 0;
|
||||
if (nvs_get_u32(handle, "total_size", &val) == ESP_OK) meta->total_size = val;
|
||||
if (nvs_get_u32(handle, "stored_size", &val) == ESP_OK) meta->stored_size = val;
|
||||
if (nvs_get_u32(handle, "last_seq", &val) == ESP_OK) meta->last_seq = val;
|
||||
|
||||
size_t sha_len = sizeof(meta->checksum);
|
||||
if (nvs_get_blob(handle, "checksum", meta->checksum, &sha_len) == ESP_OK && sha_len == sizeof(meta->checksum)) {
|
||||
} else {
|
||||
memset(meta->checksum, 0, sizeof(meta->checksum));
|
||||
}
|
||||
|
||||
nvs_close(handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/** @brief 从 NVS 加载 OTA 元数据(加锁包装)。 */
|
||||
esp_err_t Gd32FlashStore::metaLoad(ota_meta_t *meta)
|
||||
{
|
||||
if (!meta) return ESP_ERR_INVALID_ARG;
|
||||
bool locked = lockNvs(kNvsLockTimeoutMs);
|
||||
if (!locked) {
|
||||
// 加锁失败不能跳过锁直接访问 NVS,清零输出并返回超时
|
||||
memset(meta, 0, sizeof(ota_meta_t));
|
||||
DEBUG_WARN("OTA", "metaLoad 加锁超时");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
esp_err_t err = metaLoadLocked(meta);
|
||||
unlockNvs(true);
|
||||
return err;
|
||||
}
|
||||
|
||||
/** @brief 已持锁版本:将 OTA 元数据写入 NVS。 */
|
||||
esp_err_t Gd32FlashStore::metaSaveLocked(const ota_meta_t *meta)
|
||||
{
|
||||
if (!meta) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "无法打开 NVS 写入: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
nvs_set_u32(handle, "total_size", meta->total_size);
|
||||
nvs_set_u32(handle, "stored_size", meta->stored_size);
|
||||
nvs_set_u32(handle, "last_seq", meta->last_seq);
|
||||
nvs_set_blob(handle, "checksum", meta->checksum, sizeof(meta->checksum));
|
||||
err = nvs_commit(handle);
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "NVS commit 失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/** @brief 将 OTA 元数据写入 NVS(加锁包装)。 */
|
||||
esp_err_t Gd32FlashStore::metaSave(const ota_meta_t *meta)
|
||||
{
|
||||
if (!meta) return ESP_ERR_INVALID_ARG;
|
||||
bool locked = lockNvs(kNvsLockTimeoutMs);
|
||||
if (!locked) {
|
||||
DEBUG_WARN("OTA", "metaSave 加锁超时");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
esp_err_t err = metaSaveLocked(meta);
|
||||
unlockNvs(true);
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 传输过程中更新元数据的 stored_size 和 last_seq 字段。
|
||||
* 整个读-改-写过程持锁,避免并发 metaUpdate 丢失更新。
|
||||
* 内部调用已持锁的 metaLoadLocked/metaSaveLocked,不再经公开方法重复加锁。
|
||||
*/
|
||||
esp_err_t Gd32FlashStore::metaUpdate(uint32_t stored_size, uint32_t last_seq)
|
||||
{
|
||||
bool locked = lockNvs(kNvsLockTimeoutMs);
|
||||
if (!locked) {
|
||||
DEBUG_WARN("OTA", "metaUpdate 加锁超时");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoadLocked(&meta);
|
||||
if (err == ESP_OK) {
|
||||
meta.stored_size = stored_size;
|
||||
meta.last_seq = last_seq;
|
||||
err = metaSaveLocked(&meta);
|
||||
}
|
||||
unlockNvs(true);
|
||||
return err;
|
||||
}
|
||||
|
||||
/** @brief OTA 完成或中止时清除元数据(全零写入)。 */
|
||||
esp_err_t Gd32FlashStore::metaClear()
|
||||
{
|
||||
DEBUG_DBG("OTA", "清除 OTA 元数据");
|
||||
bool locked = lockNvs(kNvsLockTimeoutMs);
|
||||
if (!locked) {
|
||||
DEBUG_WARN("OTA", "metaClear 加锁超时");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
ota_meta_t meta = {};
|
||||
esp_err_t err = metaSaveLocked(&meta);
|
||||
unlockNvs(true);
|
||||
return err;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SHA-256 校验
|
||||
// ============================================================
|
||||
|
||||
/** @brief 从 gd32_ota 分区读取固件并计算 SHA-256,输出 32 字节哈希。 */
|
||||
esp_err_t Gd32FlashStore::computeFlashSha256(uint8_t out_sha256[32])
|
||||
{
|
||||
if (!out_sha256) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoad(&meta);
|
||||
if (err != ESP_OK || meta.total_size == 0) {
|
||||
DEBUG_ERR("OTA", "computeSha256: 元数据无效");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* static context:OTA 串行;勿放 httpd 栈(上传 100% 后 SHA 易栈溢出) */
|
||||
static mbedtls_sha256_context s_sha_ctx;
|
||||
mbedtls_sha256_init(&s_sha_ctx);
|
||||
if (mbedtls_sha256_starts(&s_sha_ctx, 0) != 0) {
|
||||
mbedtls_sha256_free(&s_sha_ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
static constexpr size_t kChunkSize = 1024;
|
||||
uint8_t *buf = (uint8_t *)malloc(kChunkSize);
|
||||
if (!buf) {
|
||||
mbedtls_sha256_free(&s_sha_ctx);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
uint32_t remaining = meta.total_size;
|
||||
uint32_t offset = 0;
|
||||
uint32_t wdt_chunk = 0;
|
||||
while (remaining > 0) {
|
||||
size_t to_read = (remaining > kChunkSize) ? kChunkSize : remaining;
|
||||
err = flashReadChunk(offset, buf, to_read);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "computeSha256: Flash 读取失败 offset=%" PRIu32, offset);
|
||||
free(buf);
|
||||
mbedtls_sha256_free(&s_sha_ctx);
|
||||
return err;
|
||||
}
|
||||
if (mbedtls_sha256_update(&s_sha_ctx, buf, to_read) != 0) {
|
||||
free(buf);
|
||||
mbedtls_sha256_free(&s_sha_ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
offset += to_read;
|
||||
remaining -= to_read;
|
||||
if ((++wdt_chunk & 0x0FU) == 0U) {
|
||||
(void)esp_task_wdt_reset();
|
||||
taskYIELD();
|
||||
}
|
||||
}
|
||||
|
||||
free(buf);
|
||||
int ret = mbedtls_sha256_finish(&s_sha_ctx, out_sha256);
|
||||
mbedtls_sha256_free(&s_sha_ctx);
|
||||
return (ret == 0) ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
/** @brief 固件接收完毕后计算 SHA-256 并写入 meta.checksum。 */
|
||||
esp_err_t Gd32FlashStore::metaFinalizeChecksum()
|
||||
{
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoad(&meta);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
if (meta.total_size == 0 || meta.stored_size < meta.total_size) {
|
||||
DEBUG_ERR("OTA", "metaFinalize: 固件不完整 stored=%" PRIu32 " total=%" PRIu32,
|
||||
meta.stored_size, meta.total_size);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
err = computeFlashSha256(meta.checksum);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "metaFinalize: SHA-256 计算失败");
|
||||
return err;
|
||||
}
|
||||
|
||||
char hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i)
|
||||
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", meta.checksum[i]);
|
||||
DEBUG_INFO("OTA", "固件 SHA-256: %s", hex);
|
||||
|
||||
return metaSave(&meta);
|
||||
}
|
||||
|
||||
/** @brief 校验固件完整性:重新计算 Flash SHA-256 并与 meta.checksum 比对。 */
|
||||
esp_err_t Gd32FlashStore::metaVerifyChecksum()
|
||||
{
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoad(&meta);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
// 检查 checksum 是否全零(未计算过)
|
||||
// 注意:返回 INVALID_STATE 而非 ESP_OK,由调用方决定是否容忍。
|
||||
// 若直接返回 OK 跳过校验,meta 被意外清零时会掩盖固件损坏。
|
||||
static const uint8_t kZeroSha[32] = {0};
|
||||
if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) {
|
||||
DEBUG_WARN("OTA", "metaVerify: checksum未算→INVALID_STATE");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint8_t computed[32] = {0};
|
||||
err = computeFlashSha256(computed);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "metaVerify: SHA-256 计算失败");
|
||||
return err;
|
||||
}
|
||||
|
||||
if (memcmp(computed, meta.checksum, 32) != 0) {
|
||||
char hex_stored[65] = {0}, hex_computed[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&hex_stored[i * 2], sizeof(hex_stored) - i * 2, "%02X", meta.checksum[i]);
|
||||
snprintf(&hex_computed[i * 2], sizeof(hex_computed) - i * 2, "%02X", computed[i]);
|
||||
}
|
||||
DEBUG_ERR("OTA", "固件完整性校验失败!");
|
||||
DEBUG_ERR("OTA", " 存储 SHA-256: %s", hex_stored);
|
||||
DEBUG_ERR("OTA", " 计算 SHA-256: %s", hex_computed);
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "固件完整性校验通过 (SHA-256 匹配)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t Gd32FlashStore::verifyFrontendSha(const uint8_t frontend_sha32[32],
|
||||
uint8_t out_device_sha[32])
|
||||
{
|
||||
if (!frontend_sha32) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
/* 上传收尾 finishGd32StorageAfterReceive 通常已 metaFinalizeChecksum(并打过 SHA 日志)。
|
||||
* relay 触发时若 checksum 已有,直接比对,避免整盘重算 + 再打一遍「固件 SHA-256」。 */
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoad(&meta);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
static const uint8_t kZeroSha[32] = {0};
|
||||
if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) {
|
||||
err = metaFinalizeChecksum();
|
||||
if (err != ESP_OK) return err;
|
||||
err = metaLoad(&meta);
|
||||
if (err != ESP_OK) return err;
|
||||
}
|
||||
|
||||
if (out_device_sha) memcpy(out_device_sha, meta.checksum, 32);
|
||||
|
||||
// 3) 比对前端 SHA 与设备端 SHA
|
||||
if (memcmp(meta.checksum, frontend_sha32, 32) != 0) {
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 包级身份校验(向量表内容判定)
|
||||
// ============================================================
|
||||
|
||||
/* GD32E508 Flash 布局常量,与 GD32E50X_BOOT sys/config.h 对齐:
|
||||
* BOOT 0x08000000 ~ 0x08011FFF(72KB)
|
||||
* APP 0x08048000 ~ 0x0807DFFB(APPLATEST_START + APP_MAX_SIZE,216KB-4)
|
||||
* SRAM 0x20000000 ~ 0x2001FFFF(128KB)
|
||||
* APP 固件链接基址 0x08048000、BOOT 链接基址 0x08000000,
|
||||
* 两者向量表第二字(Reset_Handler)必然落在各自区域内,据此区分包类型。
|
||||
* 注意:BOOT 侧 validateApplication() 使用同一判据,修改需两边同步。 */
|
||||
static constexpr uint32_t kGd32BootRegionStart = 0x08000000U;
|
||||
static constexpr uint32_t kGd32BootRegionEnd = 0x08012000U;
|
||||
static constexpr uint32_t kGd32AppRegionStart = 0x08048000U;
|
||||
static constexpr uint32_t kGd32AppRegionEnd = 0x08048000U + 0x00035FFCU;
|
||||
static constexpr uint32_t kGd32SramStart = 0x20000000U;
|
||||
static constexpr uint32_t kGd32SramEnd = 0x20020000U;
|
||||
|
||||
static inline uint32_t rd_le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
Gd32ImageIdentity Gd32FlashStore::validateStoredImageIdentity()
|
||||
{
|
||||
uint8_t vec[8] = {0};
|
||||
if (flashReadChunk(0, vec, sizeof(vec)) != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "身份校验:读取向量表失败");
|
||||
return Gd32ImageIdentity::kReadFailed;
|
||||
}
|
||||
|
||||
const uint32_t msp = rd_le32(&vec[0]);
|
||||
const uint32_t reset = rd_le32(&vec[4]) & ~1U; // 清除 Thumb 位
|
||||
|
||||
const bool msp_valid = (msp >= kGd32SramStart && msp < kGd32SramEnd);
|
||||
const bool reset_is_app = (reset >= kGd32AppRegionStart && reset < kGd32AppRegionEnd);
|
||||
const bool reset_is_boot = (reset >= kGd32BootRegionStart && reset < kGd32BootRegionEnd);
|
||||
|
||||
if (msp_valid && reset_is_app) {
|
||||
return Gd32ImageIdentity::kApp;
|
||||
}
|
||||
|
||||
if (reset_is_boot) {
|
||||
DEBUG_ERR("OTA", "身份校验:检测到 Bootloader 包 (MSP=0x%08lX RH=0x%08lX),禁止刷入 APP 区",
|
||||
(unsigned long)msp, (unsigned long)reset);
|
||||
return Gd32ImageIdentity::kBootloader;
|
||||
}
|
||||
|
||||
DEBUG_ERR("OTA", "身份校验:向量表非法 (MSP=0x%08lX RH=0x%08lX),非 GD32 APP 固件",
|
||||
(unsigned long)msp, (unsigned long)reset);
|
||||
return Gd32ImageIdentity::kInvalid;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* gd32_flash_store.h - GD32 OTA 固件 Flash 存储抽象层
|
||||
*
|
||||
* 封装 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验。
|
||||
* 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储。
|
||||
*
|
||||
* 线程模型:
|
||||
* - 通过 instance() 单例访问,NVS 元数据读写由内部 mutex 保护
|
||||
* - metaLoad/metaSave/metaUpdate/metaClear 串行化,读-改-写原子
|
||||
* - 分区指针懒初始化,首次使用时查找并缓存
|
||||
*/
|
||||
#ifndef GD32_FLASH_STORE_H
|
||||
#define GD32_FLASH_STORE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_partition.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
#define OTA_META_NVS_NAMESPACE "ota_meta" ///< OTA 元数据 NVS 命名空间
|
||||
|
||||
/** @brief OTA 传输元数据结构,持久化到 NVS 用于断点续传和完整性校验。 */
|
||||
typedef struct {
|
||||
uint32_t total_size; ///< 固件总大小(字节)
|
||||
uint32_t stored_size; ///< 已存储到 Flash 的大小(字节)
|
||||
uint32_t last_seq; ///< 最后一次成功写入的包序号
|
||||
uint8_t checksum[32]; ///< 固件 SHA-256 校验值
|
||||
} ota_meta_t;
|
||||
|
||||
/**
|
||||
* @brief gd32_ota 分区中存储镜像的身份分类(包级防呆)。
|
||||
*
|
||||
* 判定依据是镜像自身内容(Cortex-M 向量表前两个字),不依赖文件名:
|
||||
* SHA-256 只能证明"传对了",不能证明"传的是对的包"。
|
||||
* 把 BOOT 包(或任意非 APP 包)中继进 GD32 APP 区会擦掉好固件,
|
||||
* 因此触发中继前必须确认镜像身份为 kApp。
|
||||
*/
|
||||
enum class Gd32ImageIdentity : uint8_t {
|
||||
kApp = 0, ///< 复位向量落在 APP 区 → 合法 GD32 APP 固件
|
||||
kBootloader, ///< 复位向量落在 BOOT 区(0x08000000~0x08011FFF)→ Bootloader 包,禁止刷入 APP
|
||||
kInvalid, ///< 向量表非法 → 非 GD32 APP 固件(ESP32 镜像 / 文本 / 损坏数据)
|
||||
kReadFailed, ///< 分区读取失败,无法判定
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief GD32 OTA 固件 Flash 存储抽象层(纯静态工具类)。
|
||||
*
|
||||
* 封装 gd32_ota 分区的读写、NVS 元数据持久化、SHA-256 校验。
|
||||
* 传输层通过 OtaCoordinator 或直接调用本类完成存储操作,
|
||||
* OtaRelay 仅负责 SPI 中继状态机,不再承担存储职责。
|
||||
*/
|
||||
class Gd32FlashStore {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取单例实例。
|
||||
* @return Gd32FlashStore 单例引用。
|
||||
*/
|
||||
static Gd32FlashStore& instance();
|
||||
|
||||
// ── Flash 分区读写 ──
|
||||
|
||||
/**
|
||||
* @brief 向 gd32_ota 分区写入数据块(支持非 4 字节对齐写入)。
|
||||
* @param offset 分区内写入偏移(字节)。
|
||||
* @param data 待写入数据缓冲区。
|
||||
* @param len 数据长度(字节)。
|
||||
* @return ESP_OK 成功,其他错误码表示失败。
|
||||
*/
|
||||
esp_err_t flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 从 gd32_ota 分区读取数据块。
|
||||
* @param offset 分区内读取偏移(字节)。
|
||||
* @param data 输出数据缓冲区。
|
||||
* @param len 读取长度(字节)。
|
||||
* @return ESP_OK 成功,其他错误码表示失败。
|
||||
*/
|
||||
esp_err_t flashReadChunk(uint32_t offset, uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* @brief 擦除 gd32_ota 分区指定范围。
|
||||
* @param size 擦除大小(字节),从偏移 0 开始。
|
||||
* @return ESP_OK 成功,其他错误码表示失败。
|
||||
*/
|
||||
esp_err_t erasePartition(uint32_t size);
|
||||
|
||||
/**
|
||||
* @brief 新一轮 GD32 固件写入会话开始时调用:复位擦除水位。
|
||||
* @param full_erase true=强制擦除整个分区(motor OTA 用,杜绝旧固件残留导致的位错误);
|
||||
* false=只重置水位,由 flashWriteChunk 按需擦扇区(GD32 OTA 用,与收流交织)。
|
||||
*/
|
||||
void beginWriteSession(bool full_erase = false);
|
||||
|
||||
// ── NVS 元数据持久化 ──
|
||||
|
||||
/**
|
||||
* @brief 从 NVS 加载 OTA 元数据。
|
||||
* @param meta 输出元数据结构体指针(任何错误路径下都会被清零)。
|
||||
* @return ESP_OK 成功(含命名空间不存在的兼容路径,返回全零结构);
|
||||
* ESP_ERR_TIMEOUT 加锁超时;
|
||||
* 其他错误码 = nvs_open 真实失败(如 NVS 未初始化/损坏),不再一律当 OK。
|
||||
*/
|
||||
esp_err_t metaLoad(ota_meta_t *meta);
|
||||
|
||||
/**
|
||||
* @brief 将 OTA 元数据写入 NVS。
|
||||
* @param meta 待保存的元数据结构体指针。
|
||||
* @return ESP_OK 成功,NVS 写入失败时返回错误码。
|
||||
*/
|
||||
esp_err_t metaSave(const ota_meta_t *meta);
|
||||
|
||||
/**
|
||||
* @brief 传输过程中更新元数据的 stored_size 和 last_seq。
|
||||
* @param stored_size 已存储大小(字节)。
|
||||
* @param last_seq 最后成功写入的包序号。
|
||||
* @return ESP_OK 成功,其他错误码表示失败。
|
||||
*/
|
||||
esp_err_t metaUpdate(uint32_t stored_size, uint32_t last_seq);
|
||||
|
||||
/**
|
||||
* @brief OTA 完成或中止时清除元数据(全零写入)。
|
||||
* @return ESP_OK 成功。
|
||||
*/
|
||||
esp_err_t metaClear();
|
||||
|
||||
// ── SHA-256 校验 ──
|
||||
|
||||
/**
|
||||
* @brief 计算 gd32_ota 分区中已存储固件的 SHA-256。
|
||||
* @param out_sha256 输出 32 字节 SHA-256 哈希。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 元数据无效。
|
||||
*/
|
||||
esp_err_t computeFlashSha256(uint8_t out_sha256[32]);
|
||||
|
||||
/**
|
||||
* @brief 固件接收完毕后计算 SHA-256 并写入元数据。
|
||||
* @return ESP_OK 成功,ESP_ERR_INVALID_STATE 固件不完整。
|
||||
*/
|
||||
esp_err_t metaFinalizeChecksum();
|
||||
|
||||
/**
|
||||
* @brief 校验固件完整性(重新计算 SHA-256 并与 meta.checksum 比对)。
|
||||
* @return ESP_OK 校验通过,ESP_ERR_INVALID_CRC 校验失败。
|
||||
*/
|
||||
esp_err_t metaVerifyChecksum();
|
||||
|
||||
/**
|
||||
* @brief 校验前端提供的 SHA-256 是否与设备端计算的一致。
|
||||
* @param frontend_sha32 前端 32 字节原始 SHA(非 hex)。
|
||||
* @param out_device_sha 可为 nullptr;非空时输出设备端 32 字节 SHA。
|
||||
* @return ESP_OK 匹配;ESP_ERR_INVALID_CRC 不匹配;其他 = 元数据/计算失败。
|
||||
*/
|
||||
esp_err_t verifyFrontendSha(const uint8_t frontend_sha32[32],
|
||||
uint8_t out_device_sha[32]);
|
||||
|
||||
/**
|
||||
* @brief 读取 gd32_ota 分区镜像开头的 Cortex-M 向量表并判定包身份。
|
||||
*
|
||||
* 检查初始 MSP 是否落在 GD32E508 SRAM、复位向量是否落在 APP 区
|
||||
* (0x08048000~0x0807DFFB,与 BOOT 侧 validateApplication() 同一判据)。
|
||||
* 复位向量落在 BOOT 区(0x08000000~0x08011FFF)则判为 Bootloader 包。
|
||||
* 供中继触发前的包级防呆使用(WiFi /ota/relay 与 BLE 0xF7 共用)。
|
||||
* @return 镜像身份分类;调用方仅应在 kApp 时允许中继。
|
||||
*/
|
||||
Gd32ImageIdentity validateStoredImageIdentity();
|
||||
|
||||
/**
|
||||
* @brief 获取分区指针(懒初始化)。
|
||||
* @return 分区指针,找不到时返回 nullptr。
|
||||
*/
|
||||
const esp_partition_t* getPartition();
|
||||
|
||||
// ── OTA 期对齐缓冲生命周期 ──
|
||||
/** @brief GD32 OTA begin 时调用:预分配对齐缓冲(消除 flashWriteChunk 逐块 malloc)。 */
|
||||
static void acquireAlignBufForOta();
|
||||
/** @brief GD32 OTA end/abort 时调用:释放对齐缓冲(非 OTA 期零占用)。 */
|
||||
static void releaseAlignBufForOta();
|
||||
|
||||
private:
|
||||
Gd32FlashStore();
|
||||
~Gd32FlashStore() = default;
|
||||
Gd32FlashStore(const Gd32FlashStore&) = delete;
|
||||
Gd32FlashStore& operator=(const Gd32FlashStore&) = delete;
|
||||
|
||||
const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化)
|
||||
|
||||
// ── 擦除水位:beginWriteSession 后从 0 起;
|
||||
// flashWriteChunk → ensureErasedForWrite 按需推进(不再依赖 prepare 整区预擦)。
|
||||
static constexpr uint32_t kFlashSectorSize = 4096U;
|
||||
uint32_t m_erasedUpTo = 0;
|
||||
esp_err_t ensureErasedForWrite(uint32_t offset, size_t len);
|
||||
|
||||
// NVS 互斥:lockNvs 返回 take 是否成功;unlockNvs(handle) 仅成功时 give
|
||||
SemaphoreHandle_t m_nvsMutex = nullptr;
|
||||
bool lockNvs(uint32_t timeout_ms);
|
||||
void unlockNvs(bool handle);
|
||||
static constexpr uint32_t kNvsLockTimeoutMs = 100; ///< NVS 加锁超时(ms)
|
||||
|
||||
// 已持锁版本(调用方已持 m_nvsMutex)
|
||||
esp_err_t metaLoadLocked(ota_meta_t *meta);
|
||||
esp_err_t metaSaveLocked(const ota_meta_t *meta);
|
||||
};
|
||||
|
||||
#endif // GD32_FLASH_STORE_H
|
||||
@@ -0,0 +1,547 @@
|
||||
/*
|
||||
* canfd_bridge.cpp - CANFD 3通道通信桥接实现
|
||||
*
|
||||
* SPI 业务类型:
|
||||
* 0xC0 发 CAN 帧(canFdSend)
|
||||
* 0xC1 反馈页解析(canFdParseFeedbackPage;由 GD32 主动推送,本文件不轮询)
|
||||
* 0xC2 设 RX 模式(canFdSetRxMode)
|
||||
* 0xC8 设通道收发器 Silent(canFdSetTxSilent,只听不发)
|
||||
*
|
||||
* 0xC1 线格式与 GD32 app_spi_cmd_can_feedback.cpp 对齐:
|
||||
* 固定头 28B + 条目 id(4LE)+dlc(1)+flags(1)+rsvd(2)+data(dlc)
|
||||
* 业务 payload 上限 256 - V2_OVERHEAD(20) = 236
|
||||
*/
|
||||
#include <string.h>
|
||||
#include "canfd_bridge.h"
|
||||
#include "spi_master.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint8_t kSpiCanFrameType = 0xC0U;
|
||||
constexpr uint8_t kSpiCanBurstType = 0xC7U;
|
||||
constexpr uint8_t kSpiCanSetModeType = 0xC2U;
|
||||
constexpr uint8_t kSpiCanSetSilentType = 0xC8U;
|
||||
constexpr uint8_t kFeedbackFixedHeader = 28U;
|
||||
constexpr uint8_t kFeedbackMaxPage = 1U; /* GD32 仅 page 0/1 */
|
||||
|
||||
/* 0xC1 收到链路层 LINK_ERROR(0x7F) 的限流标记:见 canFdQueryFeedback。
|
||||
* 一轮"拒绝风暴"(同一成因:GD32 刚复位、序号/session 对不上)只报第一条 WARN,
|
||||
* 其后降级 DBG;任一次查询成功即复位,下一轮风暴仍能报出来。 */
|
||||
static bool s_linkErrLogged = false;
|
||||
|
||||
/* 突发 payload 上限 = V2_MAX_PAYLOAD(236);头 4B = count+dlc+suffix(2)。 */
|
||||
constexpr uint8_t kBurstHeaderLen = 4U;
|
||||
constexpr uint16_t kBurstPayloadCap = 236U;
|
||||
|
||||
/**
|
||||
* @brief 经 SPI 发送一帧 CAN,并解析 ACK/ERROR。
|
||||
*
|
||||
* 两步法:transact 发命令(丢弃延迟一拍陈旧响应)+ poll 真实响应。
|
||||
* CAN 帧非幂等,命令只发一次,不重发。
|
||||
*
|
||||
* @return >=0 ACK 时响应负载长度;-2 SPI 失败;-3 GD32 ERROR。
|
||||
*/
|
||||
static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms,
|
||||
uint8_t *gd32_err_out)
|
||||
{
|
||||
uint8_t tx_buf[72];
|
||||
size_t tx_len = 0;
|
||||
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>(channel & CAN_CHANNEL_MASK);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 24) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 16) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 8) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>(ext_id & 0xFFU);
|
||||
if (dlc > 0U && data != nullptr) {
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
}
|
||||
|
||||
/* 独立缓冲收响应:调用方 recv_buf 为空时也能拿到 ERROR 细分码。 */
|
||||
uint8_t local_resp[72];
|
||||
uint8_t *spi_rx = (recv_buf != nullptr && recv_max > 0U) ? recv_buf : local_resp;
|
||||
const size_t spi_rx_max = (recv_buf != nullptr && recv_max > 0U)
|
||||
? static_cast<size_t>(recv_max)
|
||||
: sizeof(local_resp);
|
||||
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
esp_err_t ret = ESP_OK;
|
||||
/* 0xC0 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
|
||||
* 喂数(0xD1→WAIT)残留在 SPI 响应管道里的陈旧帧(0xC0 命令本身永不产生 WAIT)。
|
||||
* 陈旧 WAIT 会被下方误判为 ERROR → resp_len=0 → gd32_err=0xFF → 无声拒绝发送(
|
||||
* 表现为“失败一次就再也发不出去”)。此处最多重试 4 次排空陈旧帧,
|
||||
* 直到读到本命令真正的 ACK/ERROR。
|
||||
* SPI 传输失败/无响应的重发已由 SpiMaster::otaSendAndPoll 统一处理
|
||||
* (同 TX_SEQ,最多 3 次尝试),此处不再各自重试;GD32 拒绝(ERROR)同样不重试。 */
|
||||
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
|
||||
resp_type = 0U;
|
||||
resp_len = 0U;
|
||||
ret = SpiMaster::instance().transact(
|
||||
kSpiCanFrameType,
|
||||
tx_buf, tx_len,
|
||||
&resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms);
|
||||
if (resp_type != SPI_OTA_RSP_WAIT) {
|
||||
break; /* 已拿到真正响应(ACK/ERROR);SPI 失败(ret!=OK)也退出交上层处理 */
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(3)); /* 让 GD32 更新响应,再读一次排空陈旧 WAIT */
|
||||
}
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
/* 同类错误节流日志,降低高频失败时的日志开销 */
|
||||
static uint32_t s_spi_fail_n = 0U;
|
||||
static int s_last_spi_err = 0;
|
||||
const int err_i = static_cast<int>(ret);
|
||||
if (s_spi_fail_n == 0U || err_i != s_last_spi_err || (s_spi_fail_n % 32U) == 0U) {
|
||||
DEBUG_WARN("CAN", "SEND SPI fail ch=%u id=%08lX %s x%lu",
|
||||
(unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret),
|
||||
(unsigned long)(s_spi_fail_n + 1U));
|
||||
}
|
||||
s_last_spi_err = err_i;
|
||||
++s_spi_fail_n;
|
||||
if (gd32_err_out) {
|
||||
*gd32_err_out = 0U;
|
||||
}
|
||||
return -2;
|
||||
}
|
||||
if (resp_type == SPI_OTA_RSP_ACK) {
|
||||
if (gd32_err_out) {
|
||||
*gd32_err_out = 0U;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/* ERROR:细分码在 payload[0];不依赖调用方是否提供 recv_buf */
|
||||
const uint8_t gd32_err = (resp_len > 0U) ? spi_rx[0] : 0xFFU;
|
||||
if (gd32_err_out) {
|
||||
*gd32_err_out = gd32_err;
|
||||
}
|
||||
{
|
||||
static uint32_t s_rej_n = 0U;
|
||||
static uint8_t s_last_rej = 0xFFU;
|
||||
if (s_rej_n == 0U || gd32_err != s_last_rej || (s_rej_n % 32U) == 0U) {
|
||||
DEBUG_WARN("CAN", "SEND拒 ch=%u id=%08lX r=%02X e=%02X x%lu",
|
||||
(unsigned)channel, (unsigned long)ext_id, resp_type, gd32_err,
|
||||
(unsigned long)(s_rej_n + 1U));
|
||||
}
|
||||
s_last_rej = gd32_err;
|
||||
++s_rej_n;
|
||||
}
|
||||
return -3;
|
||||
}
|
||||
|
||||
/** @brief 小端读 uint32(0xC1 固定头/条目 id)。 */
|
||||
static inline uint32_t readU32Le(const uint8_t *p)
|
||||
{
|
||||
return static_cast<uint32_t>(p[0])
|
||||
| (static_cast<uint32_t>(p[1]) << 8)
|
||||
| (static_cast<uint32_t>(p[2]) << 16)
|
||||
| (static_cast<uint32_t>(p[3]) << 24);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应。
|
||||
*
|
||||
* 返回值: >=0 成功(接收数据长度), -1 参数错误, -2 SPI通信失败, -3 GD32拒绝。
|
||||
* gd32_err_out 在返回 -3 时输出 GD32 细分错误码,其它情况清零。
|
||||
* dlc==0 时允许 data 为 nullptr(仅发 ID 的远程帧/空数据帧)。
|
||||
*/
|
||||
int canFdSend(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms,
|
||||
uint8_t *gd32_err_out)
|
||||
{
|
||||
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC) {
|
||||
if (gd32_err_out) {
|
||||
*gd32_err_out = 0U;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
/* dlc>0 时 data 必填;dlc==0 允许空指针 */
|
||||
if (dlc > 0U && data == nullptr) {
|
||||
if (gd32_err_out) {
|
||||
*gd32_err_out = 0U;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return sendCanFrameOverSpi(channel, ext_id, data, dlc,
|
||||
recv_buf, recv_max, timeout_ms, gd32_err_out);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。
|
||||
* payload = 4B 头 + count*(1+dlc),须 ≤ 236B;并受 GD32 硬上限约束。
|
||||
*/
|
||||
uint8_t canBurstCapacity(uint8_t dlc)
|
||||
{
|
||||
if (dlc > CAN_MAX_DLC) {
|
||||
return 0U;
|
||||
}
|
||||
const uint16_t entry_len = static_cast<uint16_t>(1U + dlc);
|
||||
const uint16_t avail = (kBurstPayloadCap > kBurstHeaderLen)
|
||||
? static_cast<uint16_t>(kBurstPayloadCap - kBurstHeaderLen)
|
||||
: 0U;
|
||||
const uint16_t cap = static_cast<uint16_t>(avail / entry_len);
|
||||
return static_cast<uint8_t>((cap < CAN_BURST_HARD_MAX) ? cap : CAN_BURST_HARD_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 发送一次 CAN 突发(SPI 0xC7)。
|
||||
*
|
||||
* GD32 在 CS 释放 ISR 内对每帧调用非阻塞 transmitFrame(仅装载邮箱),
|
||||
* 全部装载完成后才回 ACK,因此各通道帧近同时上线;ESP32 这里等待的只是
|
||||
* 这一个 ACK,并非逐帧等待,不会破坏组内同时性。
|
||||
*
|
||||
* 返回值: count 成功, -1 参数错误, -2 SPI 通信失败, -3 GD32 拒绝。
|
||||
*/
|
||||
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
|
||||
const can_burst_entry_t *entries, uint8_t count,
|
||||
uint8_t *status_out, uint8_t status_max,
|
||||
uint32_t timeout_ms, uint8_t *gd32_err_out)
|
||||
{
|
||||
if (gd32_err_out) {
|
||||
*gd32_err_out = 0U;
|
||||
}
|
||||
if (dlc > CAN_MAX_DLC || entries == nullptr || count == 0U) {
|
||||
return -1;
|
||||
}
|
||||
if (count > canBurstCapacity(dlc)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t tx_buf[240];
|
||||
size_t tx_len = 0U;
|
||||
tx_buf[tx_len++] = count;
|
||||
tx_buf[tx_len++] = dlc;
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((id_suffix >> 8) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>(id_suffix & 0xFFU);
|
||||
for (uint8_t i = 0U; i < count; ++i) {
|
||||
tx_buf[tx_len++] = entries[i].chnode;
|
||||
if (dlc > 0U) {
|
||||
if (entries[i].data == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(&tx_buf[tx_len], entries[i].data, dlc);
|
||||
tx_len += dlc;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t resp[32];
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
esp_err_t ret = ESP_OK;
|
||||
/* 与 0xC0 相同:排空可能残留的陈旧 WAIT,直到读到本次真正的 ACK/ERROR */
|
||||
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
|
||||
resp_type = 0U;
|
||||
resp_len = 0U;
|
||||
ret = SpiMaster::instance().transact(
|
||||
kSpiCanBurstType, tx_buf, tx_len,
|
||||
&resp_type, resp, sizeof(resp), &resp_len, timeout_ms);
|
||||
if (resp_type != SPI_OTA_RSP_WAIT) {
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(3));
|
||||
}
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_WARN("CAN", "BURST SPI fail n=%u %s",
|
||||
(unsigned)count, esp_err_to_name(ret));
|
||||
return -2;
|
||||
}
|
||||
if (resp_type == SPI_OTA_RSP_ACK) {
|
||||
/* resp[0]=count, resp[1..count]=每帧状态(CanTxResult 码) */
|
||||
const uint8_t n = (resp_len > 0U) ? resp[0] : 0U;
|
||||
if (status_out != nullptr && status_max > 0U) {
|
||||
const uint8_t copy_n = (n < status_max) ? n : status_max;
|
||||
for (uint8_t i = 0U; i < copy_n; ++i) {
|
||||
status_out[i] = (static_cast<size_t>(1U + i) < resp_len)
|
||||
? resp[1U + i] : 0xFFU;
|
||||
}
|
||||
}
|
||||
return static_cast<int>(n);
|
||||
}
|
||||
|
||||
const uint8_t gd32_err = (resp_len > 0U) ? resp[0] : 0xFFU;
|
||||
if (gd32_err_out) {
|
||||
*gd32_err_out = gd32_err;
|
||||
}
|
||||
DEBUG_WARN("CAN", "BURST拒 n=%u r=%02X e=%02X",
|
||||
(unsigned)count, resp_type, gd32_err);
|
||||
return -3;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。
|
||||
*
|
||||
* page=0 → 清空 entries、重置 entryIdx,填充 28 字节固定头 + 首批条目;
|
||||
* page=1 → 校验 channel 一致后在已有条目后追加。
|
||||
* 调用方按通道维护 entryIdx。
|
||||
*
|
||||
* @return 0 成功;-1 参数/页号/通道/截断导致无法解析。
|
||||
*/
|
||||
int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
|
||||
can_feedback_result_t *out, uint8_t *entryIdx)
|
||||
{
|
||||
if (payload == nullptr || out == nullptr || entryIdx == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (len < kFeedbackFixedHeader) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const uint8_t channel = payload[0];
|
||||
const uint8_t page = payload[1];
|
||||
if (channel >= CAN_CHANNEL_MAX) {
|
||||
return -1;
|
||||
}
|
||||
/* GD32 仅 page 0/1;非法页拒绝,避免当追加污染缓冲 */
|
||||
if (page > kFeedbackMaxPage) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (page == 0U) {
|
||||
/* 防 total 变少时 HTTP 读到上一轮残留 ID */
|
||||
*entryIdx = 0U;
|
||||
memset(out->entries, 0, sizeof(out->entries));
|
||||
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];
|
||||
} else {
|
||||
/* page>=1:必须与 page0 已建立的通道一致 */
|
||||
if (out->stats.channel != channel) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
const uint8_t entryCount = payload[3];
|
||||
const uint8_t idx_before = *entryIdx;
|
||||
uint16_t offset = kFeedbackFixedHeader;
|
||||
|
||||
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;
|
||||
/* 线协议不携带 per-ID seq/rx_count,显式置 0 避免脏读 */
|
||||
e.seq = 0U;
|
||||
e.rx_count = 0U;
|
||||
|
||||
const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX)
|
||||
? e.dlc : static_cast<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
|
||||
// 线协议条目实际大小按原始 dlc 计算(CANFD 帧可能 64B),offset 必须按
|
||||
// 实际条目推进,否则后续条目错位。data 拷贝按 dataLen 截断。
|
||||
const uint16_t wireEntrySize = static_cast<uint16_t>(8U + e.dlc);
|
||||
if (offset + wireEntrySize > 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 += wireEntrySize;
|
||||
(*entryIdx)++;
|
||||
}
|
||||
|
||||
/* 声明了条目但一个都没完整解析 → 截断/坏帧 */
|
||||
if (entryCount > 0U && *entryIdx == idx_before) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out->valid = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── 0xC1 请求/响应查询(2026-08 架构改造:替代被动推送缓存)── */
|
||||
|
||||
int canFdQueryFeedback(uint8_t channel, uint8_t page,
|
||||
can_feedback_result_t *out, uint8_t *entryIdx)
|
||||
{
|
||||
if (out == nullptr || entryIdx == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
if (channel >= CAN_CHANNEL_MAX || page > 1U) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t req[2] = { channel, page };
|
||||
uint8_t resp_type = 0;
|
||||
/* SPI v2 payload 上限 236B(256 帧 - 20 开销),与 GD32 kFeedbackPayloadLen 一致 */
|
||||
uint8_t resp_buf[SPI_OTA_FRAME_MAX];
|
||||
size_t resp_len = 0;
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().transact(
|
||||
0xC1, req, sizeof(req),
|
||||
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
static TickType_t s_lastFailWarnTick = 0;
|
||||
const TickType_t now_tick = xTaskGetTickCount();
|
||||
if (s_lastFailWarnTick == 0 || (now_tick - s_lastFailWarnTick) >= pdMS_TO_TICKS(5000)) {
|
||||
s_lastFailWarnTick = now_tick;
|
||||
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u SPI失败: %s (限频5s)",
|
||||
(unsigned)channel, (unsigned)page, esp_err_to_name(ret));
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (resp_type == SPI_OTA_RSP_ERROR) {
|
||||
uint8_t gd32_err = (resp_len > 0) ? resp_buf[0] : 0;
|
||||
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u GD32拒绝 err=0x%02X",
|
||||
(unsigned)channel, (unsigned)page, (unsigned)gd32_err);
|
||||
return -2;
|
||||
}
|
||||
if (resp_type != SPI_OTA_RSP_ACK || resp_len < 1U) {
|
||||
/* 0x7F = 链路层 LINK_ERROR(payload = [错误码, 期望序号])。成因几乎只有一种:
|
||||
* GD32 刚被复位过(APP→BOOT 交接、BOOT→新 APP 跳转、上电),它的接收序号/session
|
||||
* 从 0 重来,而 ESP32 仍按旧序号发 —— 于是一帧一条。此刻对面根本没有 CAN 桥
|
||||
* (BOOT 不提供 0xC1),这些查询注定无果,属纯噪音。
|
||||
* 一轮风暴只报第一条 WARN(含错误码与期望序号,够定位),其余降级 DBG;
|
||||
* 成功一次即复位标记,下一轮风暴照样能报。避免 1Hz × 3 通道刷屏。 */
|
||||
if (resp_type == V2_TYPE_LINK_ERROR) {
|
||||
const uint8_t errc = (resp_len > 0U) ? resp_buf[0] : 0xFFU;
|
||||
const uint8_t exp = (resp_len > 1U) ? resp_buf[1] : 0xFFU;
|
||||
if (!s_linkErrLogged) {
|
||||
s_linkErrLogged = true;
|
||||
DEBUG_WARN("CAN", "queryFeedback 链路层拒绝(0x7F code=0x%02X exp=%u):"
|
||||
"GD32 正在复位/重同步(刷写交接或 BOOT↔APP),同类后续静默",
|
||||
errc, exp);
|
||||
} else {
|
||||
DEBUG_DBG("CAN", "queryFeedback 链路层拒绝(0x7F code=0x%02X exp=%u) 已静默",
|
||||
errc, exp);
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 非ACK type=0x%02X len=%zu",
|
||||
(unsigned)channel, (unsigned)page, (unsigned)resp_type, resp_len);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
s_linkErrLogged = false; /* 通了一次:链路已重同步,标记复位 */
|
||||
|
||||
/* 解析复用现有 canFdParseFeedbackPage(payload 格式与推送一致) */
|
||||
int parse_rc = canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx);
|
||||
if (parse_rc != 0) {
|
||||
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 解析失败 rc=%d len=%zu",
|
||||
(unsigned)channel, (unsigned)page, parse_rc, resp_len);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
|
||||
* @return 0 成功;-1 参数非法或 SPI/应答失败。
|
||||
*/
|
||||
int canFdSetRxMode(can_rx_mode_t mode,
|
||||
can_rx_mode_t *actual,
|
||||
uint32_t timeout_ms)
|
||||
{
|
||||
if (mode != CAN_RX_MODE_ALL_PASS && mode != CAN_RX_MODE_FILTER_ID
|
||||
&& mode != CAN_RX_MODE_BRIDGE_0_TO_12) {
|
||||
DEBUG_WARN("CAN", "SET_MODE 非法 mode=%u", (unsigned)mode);
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t req = static_cast<uint8_t>(mode);
|
||||
uint8_t resp_buf[1] = {0};
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
|
||||
/* 两步法:发命令一次 + poll 真实响应。0xC2 幂等,但避免无效重试。 */
|
||||
esp_err_t ret = SpiMaster::instance().transact(
|
||||
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) {
|
||||
if (actual != nullptr) {
|
||||
*actual = static_cast<can_rx_mode_t>(resp_buf[0]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG_WARN("CAN", "SET_MODE fail want=%u %s r=0x%02X n=%u",
|
||||
(unsigned)mode,
|
||||
esp_err_to_name(ret),
|
||||
(unsigned)resp_type,
|
||||
(unsigned)resp_len);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 切换指定 CAN 通道收发器 Silent(SPI 0xC8):只听不发。
|
||||
* @return 0 成功;-1 参数非法或 SPI/应答失败;-2 GD32 返回 ERROR。
|
||||
* @note 与 canFdSetRxMode 同款单步事务:GD32 在 CS EXTI ISR 内直接回 ACK/ERROR,
|
||||
* 不会回 WAIT(区别于 0xC0 那条要排空 OTA 残留 WAIT 的路径)。
|
||||
* ACK payload = [0]channel [1]实际 silent [2]通道使能位。
|
||||
*/
|
||||
int canFdSetTxSilent(uint8_t channel,
|
||||
bool silent,
|
||||
uint8_t *actual,
|
||||
uint32_t timeout_ms)
|
||||
{
|
||||
if (channel > 2U) {
|
||||
DEBUG_WARN("CAN", "SET_SILENT 非法 ch=%u", (unsigned)channel);
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t req[2] = { channel, static_cast<uint8_t>(silent ? 1U : 0U) };
|
||||
uint8_t resp_buf[3] = {0};
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
|
||||
esp_err_t ret = SpiMaster::instance().transact(
|
||||
kSpiCanSetSilentType,
|
||||
req, sizeof(req),
|
||||
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms);
|
||||
|
||||
if (ret == ESP_OK && resp_type == SPI_OTA_RSP_ACK && resp_len >= 2U) {
|
||||
if (actual != nullptr) {
|
||||
*actual = resp_buf[1];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (ret == ESP_OK && resp_type == SPI_OTA_RSP_ERROR) {
|
||||
DEBUG_WARN("CAN", "SET_SILENT ch=%u want=%u GD32 err=0x%02X",
|
||||
(unsigned)channel, (unsigned)(silent ? 1U : 0U),
|
||||
(resp_len >= 1U) ? (unsigned)resp_buf[0] : 0U);
|
||||
return -2;
|
||||
}
|
||||
|
||||
DEBUG_WARN("CAN", "SET_SILENT ch=%u want=%u %s r=0x%02X n=%u",
|
||||
(unsigned)channel, (unsigned)(silent ? 1U : 0U),
|
||||
esp_err_to_name(ret),
|
||||
(unsigned)resp_type,
|
||||
(unsigned)resp_len);
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* canfd_bridge.h - CANFD 3通道通信桥接
|
||||
*
|
||||
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责分离。
|
||||
* 通过 SpiMaster 的公共 SPI 接口完成底层传输。
|
||||
*/
|
||||
#ifndef CANFD_BRIDGE_H
|
||||
#define CANFD_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// ============================================================
|
||||
// CANFD 帧格式(3通道)
|
||||
// [通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]
|
||||
// ============================================================
|
||||
#define CAN_EXTENDED_ID_BITS (29) ///< CAN 扩展帧 ID 位数
|
||||
#define CAN_MAX_DLC (64) ///< 最大数据长度
|
||||
#define CAN_CHANNEL_MASK (0x03) ///< 通道号掩码 (0~2)
|
||||
|
||||
// 通道定义
|
||||
typedef enum {
|
||||
CAN_CHANNEL_0 = 0,
|
||||
CAN_CHANNEL_1 = 1,
|
||||
CAN_CHANNEL_2 = 2,
|
||||
CAN_CHANNEL_MAX = 3,
|
||||
} can_channel_t;
|
||||
|
||||
// CANFD 帧结构
|
||||
typedef struct {
|
||||
can_channel_t channel; ///< CAN 通道 (0~2)
|
||||
uint32_t ext_id; ///< 扩展帧 ID (29bit, Big-Endian 存储)
|
||||
uint8_t data[CAN_MAX_DLC]; ///< CAN 数据负载 (最大 64 字节)
|
||||
uint8_t dlc; ///< 数据长度 (0~64)
|
||||
} can_frame_t;
|
||||
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应。
|
||||
* 内部通过 SpiMaster 的公共 SPI 接口完成传输。
|
||||
* @param channel CAN 通道编号(0~2)。
|
||||
* @param ext_id 扩展帧 ID(29 位)。
|
||||
* @param data CAN 数据负载;dlc==0 时可为 nullptr。
|
||||
* @param dlc 数据长度(0~64);0 表示空数据帧,仍发送 ID。
|
||||
* @param recv_buf 接收数据缓冲区(可为 nullptr)。
|
||||
* @param recv_max 接收缓冲区最大长度。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @param gd32_err_out 可选输出,GD32 返回的细分错误码(0xE0/0xE1/0xE2/0xE3/0xE4/0xE5/0xE6=通道静默拒发/0xE7=该通道被电机 OTA 独占);
|
||||
* 仅在返回 -3 时有效,其它情况清零。可为 nullptr。
|
||||
* @return 成功时返回接收数据长度,失败返回 -1/-2/-3。
|
||||
*/
|
||||
int canFdSend(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms,
|
||||
uint8_t *gd32_err_out = nullptr);
|
||||
|
||||
// ============================================================
|
||||
// CAN 跨通道突发发送(SPI 0xC7)
|
||||
// 一次 SPI 事务携带多帧,GD32 背靠背装载各通道 CAN 邮箱,
|
||||
// 组内近同时上线(偏差 <1ms),用于多电机同步驱动。
|
||||
// ============================================================
|
||||
#define CAN_BURST_HARD_MAX (16) ///< GD32 单次突发硬上限(帧数)
|
||||
|
||||
/** @brief 突发条目:chnode=(ch<<4)|node,data 指向 dlc 字节数据。 */
|
||||
typedef struct {
|
||||
uint8_t chnode; ///< (channel<<4) | node
|
||||
const uint8_t *data; ///< dlc 字节数据
|
||||
} can_burst_entry_t;
|
||||
|
||||
/**
|
||||
* @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。
|
||||
* 受 236B SPI payload(4B 头 + count*(1+dlc))与 GD32 硬上限约束。
|
||||
*/
|
||||
uint8_t canBurstCapacity(uint8_t dlc);
|
||||
|
||||
/**
|
||||
* @brief 发送一次 CAN 突发(SPI 0xC7)。count 必须 <= canBurstCapacity(dlc)。
|
||||
* GD32 在 ISR 内对每帧调用非阻塞 transmitFrame,各通道邮箱背靠背装载。
|
||||
* @param id_suffix CAN ID 低 16 位(CMD=0x0200);id=(node<<16)|suffix。
|
||||
* @param dlc 所有条目统一数据长度(0~64)。
|
||||
* @param entries count 个条目(chnode + data)。
|
||||
* @param count 条目数(1~canBurstCapacity(dlc))。
|
||||
* @param status_out 输出每帧状态(GD32 CanTxResult 码,0x00=成功),可为 nullptr。
|
||||
* @param status_max status_out 容量。
|
||||
* @param timeout_ms SPI 事务超时。
|
||||
* @param gd32_err_out 返回 -3 时的 GD32 细分错误码;可为 nullptr。
|
||||
* @return 成功返回 count;-1 参数错误;-2 SPI 通信失败;-3 GD32 ERROR。
|
||||
*/
|
||||
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
|
||||
const can_burst_entry_t *entries, uint8_t count,
|
||||
uint8_t *status_out, uint8_t status_max,
|
||||
uint32_t timeout_ms,
|
||||
uint8_t *gd32_err_out = nullptr);
|
||||
|
||||
// ============================================================
|
||||
// 不重复 ID 快照表(SPI 0xC3 协议配套)
|
||||
// ============================================================
|
||||
#define CANFD_ID_SNAPSHOT_MAX 16 ///< 每通道最大不重复 ID 条目数
|
||||
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数(CANFD 最大 64B,保持完整不截断)
|
||||
|
||||
/**
|
||||
* @brief 单个不重复 ID 的最新帧快照条目。
|
||||
*
|
||||
* 0xC1 线协议条目:id(4LE)+dlc(1)+flags(1)+rsvd(2)+data(dlc)。
|
||||
* seq / rx_count 未上线,解析后恒为 0(保留字段便于日后扩协议)。
|
||||
*/
|
||||
typedef struct {
|
||||
uint32_t id; ///< CAN ID
|
||||
uint32_t seq; ///< 线协议未携带,解析后恒 0
|
||||
uint32_t rx_count; ///< 线协议未携带,解析后恒 0
|
||||
uint8_t dlc;
|
||||
uint8_t fd_flag;
|
||||
uint8_t brs;
|
||||
uint8_t esi;
|
||||
uint8_t data[CANFD_ID_SNAPSHOT_DATA_MAX];
|
||||
} id_snapshot_entry_t;
|
||||
|
||||
// ============================================================
|
||||
// CAN 接收过滤模式 & 反馈快照(GD32 约 1Hz 刷新;ESP32 被动收 0xC1)
|
||||
// 与 GD32 DCU_APP SPI 协议帧 0xC1/0xC2 配套
|
||||
// ============================================================
|
||||
typedef enum {
|
||||
CAN_RX_MODE_ALL_PASS = 0, ///< 默认:禁用 ID 过滤,三路全部接收
|
||||
CAN_RX_MODE_FILTER_ID = 1, ///< 按 CONFIG_CAN_RX_FILTER_ID/MASK 做标准帧过滤
|
||||
CAN_RX_MODE_BRIDGE_0_TO_12 = 2, ///< CAN0 桥接:CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2
|
||||
} can_rx_mode_t;
|
||||
|
||||
/**
|
||||
* @brief 单通道 CAN 反馈统计(快照字段;非 ESP32 侧 1Hz 轮询)。
|
||||
*/
|
||||
typedef struct {
|
||||
uint8_t channel;
|
||||
uint8_t mode;
|
||||
uint32_t snapshot_seq;
|
||||
uint32_t rx_count;
|
||||
uint32_t tx_count;
|
||||
uint32_t rx_drop_count;
|
||||
uint32_t error_count;
|
||||
uint8_t tec; /**< 硬件发送错误计数器 (0~255) */
|
||||
uint8_t rec; /**< 硬件接收错误计数器 (0~255) */
|
||||
uint8_t bus_state; /**< 总线状态位图:bit0=init,bit1=控制器sleep,bit2=error_passive,bit3=bus_off,bit4=error_warning,bit5=tx_silent(收发器Silent,只听不发) */
|
||||
} can_channel_feedback_t;
|
||||
|
||||
/**
|
||||
* @brief 单通道 0xC1 完整响应:统计 + ID 快照表。
|
||||
*/
|
||||
typedef struct {
|
||||
can_channel_feedback_t stats;
|
||||
id_snapshot_entry_t entries[CANFD_ID_SNAPSHOT_MAX];
|
||||
uint8_t total_count; /**< 该通道实际 ID 条目总数 */
|
||||
uint8_t valid; /**< 数据是否有效 */
|
||||
} can_feedback_result_t;
|
||||
|
||||
/**
|
||||
* @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。
|
||||
*
|
||||
* GD32 APP 通过 requestEsp32Read(0xC1) 主动推送每页反馈,ESP32 收到后
|
||||
* 调用本函数解析:
|
||||
* - page=0:清空 entries、重置 entryIdx,填固定头 + 首批条目;
|
||||
* - page=1:校验 channel 与 page0 一致后追加;
|
||||
* - 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 canFdParseFeedbackPage(const uint8_t *payload, size_t len,
|
||||
can_feedback_result_t *out, uint8_t *entryIdx);
|
||||
|
||||
/**
|
||||
* @brief 主动向 GD32 查询一页 CAN 反馈(SPI 0xC1 请求/响应)。
|
||||
*
|
||||
* 2026-08 架构改造:ESP32 不再被动缓存 GD32 推送,改为前端请求时按需查询。
|
||||
* GD32 handleFeedback 接收 [channel, page] 返回 ACK + 页数据(与推送 payload 格式一致)。
|
||||
* 查询成功后调 canFdParseFeedbackPage 解析,复用现有累积逻辑。
|
||||
*
|
||||
* @param channel 目标通道 0/1/2。
|
||||
* @param page 页号 0 或 1(page 0 重置累积 + 填统计,page 1 追加 entries)。
|
||||
* @param out 累积结果(跨 page 0/1 累积,调用方按通道维护)。
|
||||
* @param entryIdx 当前已填充条目数(page 0 时被重置为 0)。
|
||||
* @return 0 成功;-1 参数/SPI 失败;-2 GD32 返回 ERROR。
|
||||
*/
|
||||
int canFdQueryFeedback(uint8_t channel, uint8_t page,
|
||||
can_feedback_result_t *out, uint8_t *entryIdx);
|
||||
|
||||
/**
|
||||
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
|
||||
* @param mode 目标模式(0=全收,1=按 ID 过滤,2=CAN0 桥接到 1/2)。
|
||||
* @param actual 可选输出,GD32 实际生效模式。
|
||||
* @param timeout_ms SPI 事务超时。
|
||||
* @return 0 成功,<0 失败(失败时打 WARN 日志)。
|
||||
*/
|
||||
int canFdSetRxMode(can_rx_mode_t mode,
|
||||
can_rx_mode_t *actual,
|
||||
uint32_t timeout_ms);
|
||||
|
||||
/**
|
||||
* @brief 切换指定 CAN 通道收发器的 Silent 模式(SPI 0xC8):只听不发。
|
||||
* GD32 侧把该通道 TCAN1057AV 的 S 脚置高 → 驱动器关断、**接收机照常工作**
|
||||
* (手册 Table 8-4:RXD 仍镜像总线),所以静默期间接收与 0xC1 上报不受影响。
|
||||
* 静默期间该通道所有发送(0xC0/0xC7/桥接/relay)回 0xE6=kErrSilent,不计错误。
|
||||
* @param channel 目标通道 0/1/2。
|
||||
* @param silent true=进 Silent,false=回 Normal。
|
||||
* @param actual 可选输出,GD32 实际生效状态(1=Silent,与其发送门控同一个值)。
|
||||
* @param timeout_ms SPI 事务超时。
|
||||
* @return 0 成功,<0 失败(-1 参数/SPI 失败,-2 GD32 返回 ERROR)。
|
||||
*/
|
||||
int canFdSetTxSilent(uint8_t channel,
|
||||
bool silent,
|
||||
uint8_t *actual,
|
||||
uint32_t timeout_ms);
|
||||
|
||||
#endif // CANFD_BRIDGE_H
|
||||
+97
-323
@@ -1,299 +1,129 @@
|
||||
/*
|
||||
* protocol.c - 协议层实现
|
||||
* T2.1, T2.2, T1.3
|
||||
* ESP-IDF 6.0 适配版本 - 使用硬件 AES-128-CBC + 硬件 RNG
|
||||
* protocol.cpp - OTA 握手辅助实现
|
||||
*
|
||||
* 加密实现使用 ESP32-C3 硬件 AES 加速器(通过 mbedtls port)
|
||||
* 当前保留的接口:
|
||||
* - init :硬件 RNG 填充扰码常数
|
||||
* - scrambleGenerate :server nonce 生成
|
||||
* - sessionKeyInit :会话密钥写入
|
||||
* - sessionKeyClear :会话密钥安全擦除
|
||||
*
|
||||
* 帧构建、AES-ECB/CBC 加解密、CRC16 等中间件代码已迁出(见 ota_relay / spi_link_v2)。
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_random.h"
|
||||
#include "aes/esp_aes.h"
|
||||
#include "app_config.h"
|
||||
#include "protocol.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
/** @brief 协议层初始化标志。 */
|
||||
static bool s_protocol_initialized = false;
|
||||
// ============================================================
|
||||
// Protocol 单例
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* @brief 初始化协议层,准备 CRC 和 AES 模块。
|
||||
* @brief 获取 Protocol 单例实例(Meyers 单例)。
|
||||
* @return Protocol 单例引用。
|
||||
*/
|
||||
void protocol_init(void)
|
||||
Protocol& Protocol::instance()
|
||||
{
|
||||
if (s_protocol_initialized) return;
|
||||
|
||||
DEBUG_INFO("PROT", "=== 初始化协议层 ===");
|
||||
DEBUG_INFO("PROT", "协议层功能: CRC16 Modbus + 硬件 AES-128-CBC");
|
||||
|
||||
/** @brief 协议层初始化标志。 */
|
||||
s_protocol_initialized = true;
|
||||
DEBUG_INFO("PROT", "协议层就绪");
|
||||
static Protocol inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief CRC16 Modbus(LE,低字节在前)
|
||||
* @param data 待校验的数据指针。
|
||||
* @param len 数据长度(字节)。
|
||||
* @return CRC16 校验值。
|
||||
* @brief 抗优化密钥/敏感缓冲擦除。
|
||||
*
|
||||
* 普通 memset 可能被编译器当作「无后续读取」而消除;
|
||||
* 通过 volatile 逐字节写 0,保证运行时真正清零。
|
||||
*
|
||||
* @param p 起始地址;nullptr 忽略。
|
||||
* @param n 字节数。
|
||||
*/
|
||||
uint16_t crc16_modbus(const uint8_t *data, size_t len)
|
||||
void Protocol::secureWipe(void *p, size_t n)
|
||||
{
|
||||
uint16_t crc = 0xFFFF;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if (crc & 0x0001) {
|
||||
crc = (crc >> 1) ^ 0xA001;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
if (p == nullptr || n == 0U) {
|
||||
return;
|
||||
}
|
||||
return (crc >> 8) | (crc << 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 构建 SPI 帧: SOF + CMD + LEN(2LE) + PAYLOAD + CRC16(2LE)
|
||||
* @param cmd 命令码。
|
||||
* @param payload 负载数据指针,可为 NULL。
|
||||
* @param len 负载长度(字节)。
|
||||
* @param out_frame 输出帧缓冲区。
|
||||
* @return 帧总长度,失败返回 -1。
|
||||
*/
|
||||
int frame_build(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_frame)
|
||||
{
|
||||
if (len > FRAME_MAX_PAYLOAD) {
|
||||
DEBUG_ERR("PROT", "Payload 太大: %zu > %d", len, FRAME_MAX_PAYLOAD);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t idx = 0;
|
||||
out_frame[idx++] = FRAME_SOF;
|
||||
out_frame[idx++] = cmd;
|
||||
out_frame[idx++] = (uint8_t)(len & 0xFF);
|
||||
out_frame[idx++] = (uint8_t)((len >> 8) & 0xFF);
|
||||
|
||||
if (payload && len > 0) {
|
||||
memcpy(&out_frame[idx], payload, len);
|
||||
idx += len;
|
||||
}
|
||||
|
||||
uint16_t crc = crc16_modbus(out_frame, idx);
|
||||
out_frame[idx++] = (uint8_t)(crc & 0xFF);
|
||||
out_frame[idx++] = (uint8_t)((crc >> 8) & 0xFF);
|
||||
|
||||
DEBUG_DBG("PROT", "帧构建: cmd=0x%02X, len=%zu, crc=0x%04X", cmd, len, crc);
|
||||
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 解析并校验一帧 SPI 协议数据。
|
||||
* @param frame 输入帧缓冲区。
|
||||
* @param frame_len 输入帧长度。
|
||||
* @param out_cmd 输出命令字节,可为 NULL。
|
||||
* @param out_payload 输出负载缓冲区,可为 NULL。
|
||||
* @param out_len 输出负载长度指针,可为 NULL。
|
||||
* @return 0 成功,-1 失败。
|
||||
*/
|
||||
int frame_parse(const uint8_t *frame, size_t frame_len,
|
||||
uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len)
|
||||
{
|
||||
if (frame_len < 6) {
|
||||
DEBUG_WARN("PROT", "帧太短: %zu < 6", frame_len);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t idx = 0;
|
||||
|
||||
if (frame[idx++] != FRAME_SOF) {
|
||||
DEBUG_WARN("PROT", "无效 SOF: 0x%02X (期望 0x%02X)", frame[0], FRAME_SOF);
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t cmd = frame[idx++];
|
||||
|
||||
size_t payload_len = frame[idx] | (frame[idx + 1] << 8);
|
||||
idx += 2;
|
||||
|
||||
if (payload_len > FRAME_MAX_PAYLOAD) {
|
||||
DEBUG_ERR("PROT", "Payload 长度太大: %zu > %d", payload_len, FRAME_MAX_PAYLOAD);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t expected_len = 1 + 1 + 2 + payload_len + 2;
|
||||
if (frame_len < expected_len) {
|
||||
DEBUG_WARN("PROT", "帧太短: %zu < %zu (期望)", frame_len, expected_len);
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint16_t received_crc = frame[idx + payload_len] | (frame[idx + payload_len + 1] << 8);
|
||||
uint16_t calc_crc = crc16_modbus(frame, idx + payload_len);
|
||||
|
||||
if (received_crc != calc_crc) {
|
||||
DEBUG_WARN("PROT", "CRC 不匹配: 收到=0x%04X, 计算=0x%04X", received_crc, calc_crc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_DBG("PROT", "帧解析: cmd=0x%02X, len=%zu, crc=0x%04X OK", cmd, payload_len, calc_crc);
|
||||
|
||||
if (out_payload && out_len) {
|
||||
memcpy(out_payload, &frame[idx], payload_len);
|
||||
*out_len = payload_len;
|
||||
}
|
||||
if (out_cmd) {
|
||||
*out_cmd = cmd;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 使用 AES-128 CBC 对明文进行加密(PKCS#7 填充)。
|
||||
* @param plaintext 明文数据指针。
|
||||
* @param len 明文长度。
|
||||
* @param key 16 字节密钥。
|
||||
* @param iv 16 字节初始向量。
|
||||
* @param ciphertext_out 输出密文缓冲区。
|
||||
* @return >=0 为密文长度,-1 表示失败。
|
||||
*/
|
||||
int protocol_aes_encrypt(const uint8_t *plaintext, size_t len,
|
||||
const uint8_t *key, const uint8_t *iv,
|
||||
uint8_t *ciphertext_out)
|
||||
{
|
||||
if (!plaintext || !key || !iv || !ciphertext_out) {
|
||||
DEBUG_ERR("PROT", "AES 加密参数无效");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// PKCS#7 填充
|
||||
size_t padded_len = ((len + 15) / 16) * 16;
|
||||
uint8_t padded[256];
|
||||
memset(padded, 0, sizeof(padded));
|
||||
memcpy(padded, plaintext, len);
|
||||
uint8_t pad = (uint8_t)(16 - (len % 16));
|
||||
if (pad == 0) pad = 16;
|
||||
memset(&padded[len], pad, pad);
|
||||
|
||||
DEBUG_DBG("PROT", "AES 加密: 明文=%zu 字节, 填充后=%zu 字节, 填充值=0x%02X", len, padded_len, pad);
|
||||
|
||||
// 硬件 AES-CBC 加密(使用 ESP32-C3 硬件 AES 加速器)
|
||||
esp_aes_context aes_ctx;
|
||||
esp_aes_init(&aes_ctx);
|
||||
|
||||
int ret = esp_aes_setkey(&aes_ctx, key, 128);
|
||||
if (ret != 0) {
|
||||
DEBUG_ERR("PROT", "esp_aes_setkey 失败: %d", ret);
|
||||
esp_aes_free(&aes_ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t iv_copy[16];
|
||||
memcpy(iv_copy, iv, 16);
|
||||
|
||||
ret = esp_aes_crypt_cbc(&aes_ctx, ESP_AES_ENCRYPT, padded_len, iv_copy, padded, ciphertext_out);
|
||||
|
||||
esp_aes_free(&aes_ctx);
|
||||
|
||||
if (ret == 0) {
|
||||
DEBUG_DBG("PROT", "AES 加密成功: %zu -> %zu 字节", len, padded_len);
|
||||
return padded_len;
|
||||
} else {
|
||||
DEBUG_ERR("PROT", "esp_aes_crypt_cbc 加密失败: %d", ret);
|
||||
return -1;
|
||||
volatile uint8_t *v = static_cast<volatile uint8_t *>(p);
|
||||
while (n > 0U) {
|
||||
*v++ = 0;
|
||||
--n;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 使用 AES-128 CBC 解密密文并移除 PKCS#7 填充。
|
||||
* @param ciphertext 密文数据指针。
|
||||
* @param len 密文长度(必须是 16 的倍数)。
|
||||
* @param key 16 字节密钥。
|
||||
* @param iv 16 字节初始向量。
|
||||
* @param plaintext_out 输出明文缓冲区。
|
||||
* @return >=0 为明文长度,-1 表示失败。
|
||||
* @brief 初始化协议层,打印初始化信息并设置初始化标志。
|
||||
*
|
||||
* 使用硬件 RNG 填充 m_scrambleConst。完整常数不再以 INFO 打 hex,
|
||||
* 避免 UDP/串口日志侧信道;需要核对时开 DEBUG_DBG。
|
||||
*/
|
||||
int protocol_aes_decrypt(const uint8_t *ciphertext, size_t len,
|
||||
const uint8_t *key, const uint8_t *iv,
|
||||
uint8_t *plaintext_out)
|
||||
void Protocol::init()
|
||||
{
|
||||
if (!ciphertext || !key || !iv || !plaintext_out) {
|
||||
DEBUG_ERR("PROT", "AES 解密参数无效");
|
||||
return -1;
|
||||
if (m_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (len % 16 != 0 || len == 0) {
|
||||
DEBUG_ERR("PROT", "Ciphertext 长度无效: %zu (必须为 16 的倍数)", len);
|
||||
return -1;
|
||||
}
|
||||
DEBUG_INFO("PROT", "=== 初始化协议层(nonce 扰码 + 会话密钥管理)===");
|
||||
|
||||
DEBUG_DBG("PROT", "AES 解密: 密文=%zu 字节", len);
|
||||
esp_fill_random(m_scrambleConst, SCRAMBLE_CONST_LEN);
|
||||
|
||||
// 硬件 AES-CBC 解密(使用 ESP32-C3 硬件 AES 加速器)
|
||||
esp_aes_context aes_ctx;
|
||||
esp_aes_init(&aes_ctx);
|
||||
/* 不把完整扰码常数打到 INFO/UDP:减小日志侧信道;需要调试时开 DBG。 */
|
||||
DEBUG_DBG("PROT", "SCRAMBLE_CONST ready (%u B from HW RNG)",
|
||||
(unsigned)SCRAMBLE_CONST_LEN);
|
||||
|
||||
int ret = esp_aes_setkey(&aes_ctx, key, 128);
|
||||
if (ret != 0) {
|
||||
DEBUG_ERR("PROT", "esp_aes_setkey 失败: %d", ret);
|
||||
esp_aes_free(&aes_ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint8_t iv_copy[16];
|
||||
memcpy(iv_copy, iv, 16);
|
||||
|
||||
ret = esp_aes_crypt_cbc(&aes_ctx, ESP_AES_DECRYPT, len, iv_copy, ciphertext, plaintext_out);
|
||||
|
||||
esp_aes_free(&aes_ctx);
|
||||
|
||||
if (ret != 0) {
|
||||
DEBUG_ERR("PROT", "esp_aes_crypt_cbc 解密失败: %d", ret);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 移除 PKCS#7 填充
|
||||
uint8_t pad = plaintext_out[len - 1];
|
||||
if (pad > 16) {
|
||||
DEBUG_ERR("PROT", "无效填充: %d", pad);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t orig_len = len - pad;
|
||||
DEBUG_DBG("PROT", "AES 解密成功: %zu -> %zu 字节 (填充=%d)", len, orig_len, pad);
|
||||
|
||||
return orig_len;
|
||||
m_initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 根据时间戳、地址和随机种子生成 16 字节 nonce。
|
||||
* @param timestamp 时间戳输入。
|
||||
* @param mem_addr 地址输入。
|
||||
* @param rand_seed 随机种子输入。
|
||||
* @param nonce_out 输出 nonce 缓冲区(16 字节)。
|
||||
* @param timestamp 当前时间戳(tick)。
|
||||
* @param mem_addr 内存地址(增加随机性)。
|
||||
* @param rand_seed 随机种子。
|
||||
* @param nonce_out 输出 16 字节 nonce 缓冲区;为 nullptr 时直接返回。
|
||||
*
|
||||
* 混合步骤概要:
|
||||
* 1) state = timestamp|mem_addr|rand_seed|scrambleConst[0..3](小端)
|
||||
* 2) 3 轮:逐字节 XOR 全表 scrambleConst + 循环左移 1
|
||||
* 3) 2 轮:按 32-bit 字 XOR 完整 4B scrambleConst(memcpy 避免未对齐访问)
|
||||
*
|
||||
* @note 非标准 KDF;仅 OTA 握手用。未 init 时自动 init()。
|
||||
*/
|
||||
void scramble_generate(uint32_t timestamp, uint32_t mem_addr,
|
||||
uint32_t rand_seed, uint8_t *nonce_out)
|
||||
void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
|
||||
uint32_t rand_seed, uint8_t *nonce_out)
|
||||
{
|
||||
if (nonce_out == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (!m_initialized) {
|
||||
init();
|
||||
}
|
||||
|
||||
uint8_t state[16];
|
||||
|
||||
/* 宿主小端布局;ESP32 / GD32 均为 LE,与对端一致。 */
|
||||
memcpy(&state[0], ×tamp, 4);
|
||||
memcpy(&state[4], &mem_addr, 4);
|
||||
memcpy(&state[8], &rand_seed, 4);
|
||||
memcpy(&state[12], SCRAMBLE_CONST, 4);
|
||||
memcpy(&state[12], m_scrambleConst, 4);
|
||||
|
||||
for (int round = 0; round < 3; round++) {
|
||||
for (int i = 0; i < 16; i++) {
|
||||
state[i] ^= SCRAMBLE_CONST[i];
|
||||
state[i] = (state[i] << 1) | (state[i] >> 7);
|
||||
state[i] ^= m_scrambleConst[i];
|
||||
state[i] = (uint8_t)((state[i] << 1) | (state[i] >> 7));
|
||||
}
|
||||
}
|
||||
|
||||
/* 按 32-bit 字再混一轮完整 4B 扰码(memcpy 避免未对齐 uint32_t*)。 */
|
||||
for (int round = 0; round < 2; round++) {
|
||||
uint32_t *words = (uint32_t *)state;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
words[i] ^= SCRAMBLE_CONST[i * 4 % 16] | ((uint32_t)SCRAMBLE_CONST[i * 4 + 1] << 8);
|
||||
uint32_t w = 0;
|
||||
memcpy(&w, &state[i * 4], 4);
|
||||
const uint32_t mix =
|
||||
(uint32_t)m_scrambleConst[i * 4]
|
||||
| ((uint32_t)m_scrambleConst[i * 4 + 1] << 8)
|
||||
| ((uint32_t)m_scrambleConst[i * 4 + 2] << 16)
|
||||
| ((uint32_t)m_scrambleConst[i * 4 + 3] << 24);
|
||||
w ^= mix;
|
||||
memcpy(&state[i * 4], &w, 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,90 +131,34 @@ void scramble_generate(uint32_t timestamp, uint32_t mem_addr,
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 会话密钥初始化
|
||||
* @param sess 待初始化的会话上下文。
|
||||
* @param k_session 128 位会话密钥。
|
||||
* @return 0 成功,-1 失败。
|
||||
* @brief 会话密钥初始化。
|
||||
* @param sess 会话上下文指针。
|
||||
* @param k_session 16 字节会话密钥。
|
||||
* @return 0 成功,-1 参数无效。
|
||||
*
|
||||
* 先 secureWipe 再拷贝,避免旧 key 残留。
|
||||
*/
|
||||
int session_key_init(session_ctx_t *sess, const uint8_t *k_session)
|
||||
int Protocol::sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session)
|
||||
{
|
||||
if (!sess || !k_session) return -1;
|
||||
|
||||
memset(sess, 0, sizeof(session_ctx_t));
|
||||
memcpy(sess->key, k_session, 16);
|
||||
memset(sess->iv, 0, 16);
|
||||
sess->used = 0;
|
||||
if (sess == nullptr || k_session == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
secureWipe(sess, sizeof(*sess));
|
||||
memcpy(sess->key, k_session, SESSION_KEY_LEN);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 清除会话上下文中的密钥和状态。
|
||||
* @param sess 待清除的会话上下文。
|
||||
* @brief 清除会话上下文中的密钥数据。
|
||||
* @param sess 会话上下文指针;为 nullptr 时忽略。
|
||||
*
|
||||
* 使用 secureWipe,避免编译器优化掉「无读回的」密钥清零。
|
||||
*/
|
||||
void session_key_clear(session_ctx_t *sess)
|
||||
void Protocol::sessionKeyClear(session_ctx_t *sess)
|
||||
{
|
||||
if (sess) {
|
||||
memset(sess->key, 0, 16);
|
||||
memset(sess->iv, 0, 16);
|
||||
sess->used = 0;
|
||||
if (sess == nullptr) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 使用会话上下文加密数据。
|
||||
* @param sess 会话上下文。
|
||||
* @param in 输入明文。
|
||||
* @param len 数据长度。
|
||||
* @param out 输出密文缓冲区。
|
||||
* @return 密文长度,失败返回 -1。
|
||||
*/
|
||||
int session_encrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out)
|
||||
{
|
||||
if (!sess || !in || !out) return -1;
|
||||
|
||||
uint8_t iv_copy[16];
|
||||
memcpy(iv_copy, sess->iv, 16);
|
||||
|
||||
int ret = protocol_aes_encrypt(in, len, sess->key, iv_copy, out);
|
||||
|
||||
if (ret > 0) {
|
||||
memcpy(sess->iv, &out[ret - 16], 16);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 使用会话上下文解密数据。
|
||||
* @param sess 会话上下文。
|
||||
* @param in 输入密文。
|
||||
* @param len 数据长度。
|
||||
* @param out 输出明文缓冲区。
|
||||
* @return 明文长度,失败返回 -1。
|
||||
*/
|
||||
int session_decrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out)
|
||||
{
|
||||
if (!sess || !in || !out) return -1;
|
||||
|
||||
uint8_t iv_copy[16];
|
||||
memcpy(iv_copy, sess->iv, 16);
|
||||
|
||||
int ret = protocol_aes_decrypt(in, len, sess->key, iv_copy, out);
|
||||
|
||||
if (ret > 0) {
|
||||
memcpy(sess->iv, &in[len - 16], 16);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 使用 ESP-IDF 硬件 RNG 生成随机数
|
||||
* @param nonce 输出缓冲区。
|
||||
* @param len 随机数字节数。
|
||||
*/
|
||||
void generate_random_nonce(uint8_t *nonce, size_t len)
|
||||
{
|
||||
esp_fill_random(nonce, len);
|
||||
secureWipe(sess->key, SESSION_KEY_LEN);
|
||||
}
|
||||
|
||||
+86
-142
@@ -1,164 +1,108 @@
|
||||
/*
|
||||
* protocol.h - 帧格式、CRC16、AES 加密解密
|
||||
* T2.2, T1.3
|
||||
* ESP-IDF 6.0 适配版本 - 使用硬件 AES-128-CBC + 硬件 RNG
|
||||
* protocol.h - OTA 握手辅助(nonce 扰码 + 会话密钥生命周期)
|
||||
*
|
||||
* 本模块刻意保持轻量(原「帧格式 / CRC16 / AES-CBC」中间件已迁出):
|
||||
* - 不负责 SPI Link v2 帧 / CRC32
|
||||
* - 不负责 AES 加解密(OTA 数据包 AES-ECB 在 ota_relay 内)
|
||||
* - 仅提供:RNG 扰码常数、server nonce 生成、session key 存清
|
||||
*
|
||||
* AES 主密钥 MASTER_KEY[] 见 app_config.h。
|
||||
*/
|
||||
#ifndef PROTOCOL_H
|
||||
#define PROTOCOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "app_config.h"
|
||||
|
||||
// 帧格式: SOF(1B) + CMD(1B) + LEN(2B LE) + PAYLOAD(LEN) + CRC16(2B LE)
|
||||
#define FRAME_SOF (0xEB) // 帧起始标记
|
||||
#define FRAME_MAX_PAYLOAD (64) // 安全最大 payload
|
||||
#define FRAME_OVERHEAD (6) // SOF + CMD + LEN(2) + CRC16(2)
|
||||
/** @brief AES-128 / 会话密钥长度(字节)。 */
|
||||
inline constexpr size_t SESSION_KEY_LEN = 16;
|
||||
|
||||
// 命令码(对应 GD32 OTA 协议)
|
||||
#define CMD_HANDSHAKE_REQ (0xF0) // 握手请求
|
||||
#define CMD_CHALLENGE (0xF1) // 挑战/随机数
|
||||
#define CMD_SESSION_KEY (0xF2) // 会话密钥
|
||||
#define CMD_SEND_SIZE (0xF3) // 发送固件大小
|
||||
#define CMD_DATA_PACKET (0xF4) // 数据包
|
||||
#define CMD_DATA_END (0xF5) // 数据结束
|
||||
#define CMD_TRANSFER_DONE (0xF6) // 传输完成
|
||||
#define CMD_FINISH_OTA (0xF7) // 结束 OTA
|
||||
#define CMD_ACK (0x80) // 通用 ACK
|
||||
#define CMD_ERROR (0x81) // 通用错误
|
||||
|
||||
// ACK 状态码
|
||||
#define ACK_OK (0x00) // 成功
|
||||
#define ACK_UNSUPPORTED (0x01) // 命令不支持
|
||||
#define ACK_AUTH_FAILED (0x02) // 认证失败
|
||||
#define ACK_SIZE_OOR (0x03) // 大小超出范围
|
||||
#define ACK_CRC_FAILED (0x04) // CRC 校验失败
|
||||
#define ACK_RETRY_EXCEED (0x05) // 重试次数超限
|
||||
#define ACK_SESSION_INVALID (0x06) // 会话密钥无效
|
||||
#define ACK_FLASH_FAILED (0x07) // Flash 写入失败
|
||||
|
||||
// 安全常量(定义在 app_config.h 中)
|
||||
inline constexpr size_t MASTER_KEY_HERE_LEN = 16;
|
||||
inline constexpr size_t SCRAMBLE_CONST_HERE_LEN = 16;
|
||||
inline constexpr size_t CHIP_UID_LEN = 12;
|
||||
|
||||
// AES 会话上下文
|
||||
/**
|
||||
* @brief OTA 会话密钥上下文。
|
||||
*
|
||||
* 仅缓存协商得到的 K_session;真正加解密在 ota_relay 中用该 key 做 AES-ECB。
|
||||
*/
|
||||
typedef struct {
|
||||
uint8_t key[16]; // 会话密钥 K_session
|
||||
uint8_t iv[16]; // CBC 模式 IV
|
||||
int used; // 会话是否已初始化标志
|
||||
uint8_t key[SESSION_KEY_LEN]; ///< 会话密钥 K_session(16 字节)
|
||||
} session_ctx_t;
|
||||
|
||||
/**
|
||||
* @brief 初始化协议层资源,建立 CRC/AES 等基础能力的可用状态。
|
||||
* @brief OTA 握手辅助类(单例)
|
||||
*
|
||||
* 持有 per-boot 扰码常数 m_scrambleConst;
|
||||
* sessionKeyInit / sessionKeyClear 为无状态静态工具,不依赖单例成员。
|
||||
*/
|
||||
void protocol_init(void);
|
||||
class Protocol {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 Protocol 单例实例。
|
||||
* @return Protocol 单例引用。
|
||||
*/
|
||||
static Protocol& instance();
|
||||
|
||||
/**
|
||||
* @brief 计算一段数据的 CRC16-Modbus 校验值。
|
||||
* @param data 待校验的数据指针。
|
||||
* @param len 数据长度(字节)。
|
||||
* @return CRC16 校验值。
|
||||
*/
|
||||
uint16_t crc16_modbus(const uint8_t *data, size_t len);
|
||||
/**
|
||||
* @brief 初始化协议层(幂等)。
|
||||
*
|
||||
* 使用硬件 RNG 填充 m_scrambleConst,供 scrambleGenerate 混合使用。
|
||||
* 可重复调用;已初始化则直接返回。
|
||||
*/
|
||||
void init();
|
||||
|
||||
/**
|
||||
* @brief 按协议格式构建一帧数据,返回生成后的总帧长度。
|
||||
* @param cmd 命令码。
|
||||
* @param payload 负载数据指针,可为 NULL。
|
||||
* @param len 负载长度(字节)。
|
||||
* @param out_frame 输出帧缓冲区。
|
||||
* @return 帧总长度,失败返回 -1。
|
||||
*/
|
||||
int frame_build(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_frame);
|
||||
/**
|
||||
* @brief 生成基于时间戳、地址和随机种子的 16 字节 server nonce。
|
||||
* @param timestamp 时间戳(如 FreeRTOS tick)。
|
||||
* @param mem_addr 内存地址(增加熵,通常取栈/缓冲区地址)。
|
||||
* @param rand_seed 随机种子。
|
||||
* @param nonce_out 输出缓冲区,至少 16 字节;为 nullptr 时直接返回。
|
||||
*
|
||||
* @note 非标准 KDF,仅作 OTA 握手 nonce,勿当通用密钥派生。
|
||||
* @note 若尚未 init(),内部会自动调用 init()。
|
||||
*/
|
||||
void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
|
||||
uint32_t rand_seed, uint8_t *nonce_out);
|
||||
|
||||
/**
|
||||
* @brief 解析一帧协议数据,提取命令字、负载和长度,失败返回 -1。
|
||||
* @param frame 待解析的帧数据。
|
||||
* @param frame_len 帧数据总长度。
|
||||
* @param out_cmd 输出参数,解析出的命令码。
|
||||
* @param out_payload 输出缓冲区,存放解析出的负载。
|
||||
* @param out_len 输出参数,负载实际长度。
|
||||
* @return 0 成功,-1 失败。
|
||||
*/
|
||||
int frame_parse(const uint8_t *frame, size_t frame_len,
|
||||
uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len);
|
||||
/**
|
||||
* @brief 会话密钥初始化,将密钥拷贝到会话上下文。
|
||||
* @param sess 会话上下文指针。
|
||||
* @param k_session 会话密钥数据(至少 SESSION_KEY_LEN 字节)。
|
||||
* @return 成功返回 0,参数无效返回 -1。
|
||||
*/
|
||||
static int sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session);
|
||||
|
||||
/**
|
||||
* @brief 使用 AES-128-CBC 对明文进行加密,返回密文长度,失败返回负值。
|
||||
* @param plaintext 明文数据。
|
||||
* @param len 明文长度(字节)。
|
||||
* @param key 128 位 AES 密钥。
|
||||
* @param iv 128 位 CBC 初始向量。
|
||||
* @param ciphertext_out 输出缓冲区,存放密文。
|
||||
* @return 密文长度,失败返回 -1。
|
||||
*/
|
||||
int protocol_aes_encrypt(const uint8_t *plaintext, size_t len,
|
||||
const uint8_t *key, const uint8_t *iv,
|
||||
uint8_t *ciphertext_out);
|
||||
/**
|
||||
* @brief 清除会话上下文中的密钥数据(抗优化擦除)。
|
||||
* @param sess 会话上下文指针;为 nullptr 时忽略。
|
||||
*/
|
||||
static void sessionKeyClear(session_ctx_t *sess);
|
||||
|
||||
/**
|
||||
* @brief 使用 AES-128-CBC 对密文进行解密,返回去除填充后的明文长度。
|
||||
* @param ciphertext 密文数据。
|
||||
* @param len 密文长度(字节,须为 16 的倍数)。
|
||||
* @param key 128 位 AES 密钥。
|
||||
* @param iv 128 位 CBC 初始向量。
|
||||
* @param plaintext_out 输出缓冲区,存放解密后的明文。
|
||||
* @return 明文长度,失败返回 -1。
|
||||
*/
|
||||
int protocol_aes_decrypt(const uint8_t *ciphertext, size_t len,
|
||||
const uint8_t *key, const uint8_t *iv,
|
||||
uint8_t *plaintext_out);
|
||||
private:
|
||||
/**
|
||||
* @brief 私有默认构造函数。
|
||||
*/
|
||||
Protocol() = default;
|
||||
/**
|
||||
* @brief 私有析构函数。
|
||||
*/
|
||||
~Protocol() = default;
|
||||
/**
|
||||
* @brief 禁止复制构造。
|
||||
*/
|
||||
Protocol(const Protocol&) = delete;
|
||||
/**
|
||||
* @brief 禁止复制赋值。
|
||||
*/
|
||||
Protocol& operator=(const Protocol&) = delete;
|
||||
|
||||
/**
|
||||
* @brief 根据时间戳、内存地址和随机种子生成 16 字节随机数。
|
||||
* @param timestamp 时间戳种子。
|
||||
* @param mem_addr 内存地址种子。
|
||||
* @param rand_seed 随机数种子。
|
||||
* @param nonce_out 输出缓冲区,存放生成的 16 字节随机数。
|
||||
*/
|
||||
void scramble_generate(uint32_t timestamp, uint32_t mem_addr,
|
||||
uint32_t rand_seed, uint8_t *nonce_out);
|
||||
/**
|
||||
* @brief 抗编译器优化的内存擦除(volatile 逐字节写 0)。
|
||||
* @param p 起始地址;nullptr 忽略。
|
||||
* @param n 字节数。
|
||||
*/
|
||||
static void secureWipe(void *p, size_t n);
|
||||
|
||||
/**
|
||||
* @brief 用给定会话密钥初始化会话上下文。
|
||||
* @param sess 待初始化的会话上下文。
|
||||
* @param k_session 128 位会话密钥。
|
||||
* @return 0 成功,-1 失败。
|
||||
*/
|
||||
int session_key_init(session_ctx_t *sess, const uint8_t *k_session);
|
||||
uint8_t m_scrambleConst[SCRAMBLE_CONST_LEN] = {0}; ///< per-boot 扰码常数(init 时 RNG 填充)
|
||||
bool m_initialized = false; ///< 是否已 init
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 清除会话上下文中的密钥与状态信息。
|
||||
* @param sess 待清除的会话上下文。
|
||||
*/
|
||||
void session_key_clear(session_ctx_t *sess);
|
||||
|
||||
/**
|
||||
* @brief 使用当前会话上下文对输入数据执行加密。
|
||||
* @param sess 会话上下文。
|
||||
* @param in 输入明文。
|
||||
* @param len 数据长度。
|
||||
* @param out 输出密文缓冲区。
|
||||
* @return 密文长度,失败返回 -1。
|
||||
*/
|
||||
int session_encrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out);
|
||||
|
||||
/**
|
||||
* @brief 使用当前会话上下文对输入数据执行解密。
|
||||
* @param sess 会话上下文。
|
||||
* @param in 输入密文。
|
||||
* @param len 数据长度。
|
||||
* @param out 输出明文缓冲区。
|
||||
* @return 明文长度,失败返回 -1。
|
||||
*/
|
||||
int session_decrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out);
|
||||
|
||||
/**
|
||||
* @brief 使用 ESP-IDF 硬件 RNG 生成指定长度的随机数。
|
||||
* @param nonce 输出缓冲区。
|
||||
* @param len 随机数字节数。
|
||||
*/
|
||||
void generate_random_nonce(uint8_t *nonce, size_t len);
|
||||
|
||||
#endif // PROTOCOL_H
|
||||
#endif // PROTOCOL_H
|
||||
|
||||
+2477
-592
File diff suppressed because it is too large
Load Diff
+586
-125
@@ -1,158 +1,619 @@
|
||||
/*
|
||||
* spi_master.h - SPI Master 驱动头文件
|
||||
* CANFD 3通道发送接口 + 底层 SPI 总线
|
||||
*
|
||||
* SpiMaster 类封装 SPI 总线初始化、CANFD 通信和 SPI OTA 协议。
|
||||
*
|
||||
* 握手架构(v2 DATA_READY,GPIO10 ← GD32 PB6 开漏):
|
||||
* - PB6 仅由 GD32 驱动:低 = 有待读数据,高 = 队列空。ESP32 只输入、不拉线。
|
||||
* - 帧同步/许可在帧内 SEQ/ACK_SEQ/CREDIT;CS 本身即互斥,TX 前不再等 HIGH。
|
||||
* - GD32 有数据:拉低 DATA_READY → ESP32 下降沿/轮询 → CS 读响应。
|
||||
*/
|
||||
#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"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
#include "spi_link_v2.h"
|
||||
#include "app_config.h" /* SPI_MASTER_CS_GPIO / SPI_READY_GPIO / SPI_OTA_FRAME_MAX 等 */
|
||||
|
||||
// SPI 配置常量
|
||||
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
|
||||
#define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
|
||||
#define SPI_MODE (0) // CPOL=0, CPHA=0
|
||||
#define SPI_QUEUE_SIZE (1)
|
||||
#define SPI_OTA_FRAME_MAX (96)
|
||||
// SPI 时钟来自 menuconfig;其余可调项在 app_config.h
|
||||
#define SPI_CLOCK_HZ CONFIG_DCU_SPI_CLOCK_HZ
|
||||
|
||||
// GD32 SPI OTA 帧类型
|
||||
#define SPI_OTA_SOF (0xA5)
|
||||
// GD32 SPI 业务 TYPE(与 SpiDefs / v2 TYPE 字段一致)
|
||||
#define SPI_OTA_CMD_REQ (0x01)
|
||||
#define SPI_OTA_CMD_DATA (0x02)
|
||||
#define SPI_OTA_CMD_CANCEL (0x03)
|
||||
#define SPI_OTA_CMD_CANCEL (0x03) /* 中止 OTA 会话 → GD32 resetToIdle */
|
||||
#define SPI_APP_ENTER_BOOT_CMD (0xB0)
|
||||
#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5)
|
||||
#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5)
|
||||
#define SPI_OTA_RSP_ACK (0x80)
|
||||
#define SPI_OTA_RSP_WAIT (0x81)
|
||||
#define SPI_OTA_RSP_ERROR (0x82)
|
||||
|
||||
// SPI 设备句柄(外部引用)
|
||||
extern spi_device_handle_t g_spi_device;
|
||||
|
||||
// ============================================================
|
||||
// CANFD 帧格式(3通道)
|
||||
// [通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]
|
||||
// 长度自动识别,无需发送 DLC
|
||||
// ============================================================
|
||||
#define CAN_EXTENDED_ID_BITS (29) // CAN 扩展帧 ID 位数
|
||||
#define CAN_MAX_DLC (64) // 最大数据长度
|
||||
#define CAN_CHANNEL_MASK (0x03) // 通道号掩码 (0~2)
|
||||
|
||||
// 通道定义
|
||||
typedef enum {
|
||||
CAN_CHANNEL_0 = 0,
|
||||
CAN_CHANNEL_1 = 1,
|
||||
CAN_CHANNEL_2 = 2,
|
||||
CAN_CHANNEL_MAX = 3,
|
||||
} can_channel_t;
|
||||
|
||||
// CANFD 帧结构
|
||||
// GD32 版本信息(从 APP PING 响应解析)
|
||||
typedef struct {
|
||||
can_channel_t channel; // CAN 通道 (0~2)
|
||||
uint32_t ext_id; // 扩展帧 ID (29bit, Big-Endian 存储)
|
||||
uint8_t data[CAN_MAX_DLC]; // CAN 数据负载 (最大 64 字节)
|
||||
uint8_t dlc; // 数据长度 (1~64)
|
||||
} can_frame_t;
|
||||
uint16_t app_major;
|
||||
uint16_t app_minor;
|
||||
uint16_t app_patch;
|
||||
uint16_t app_build;
|
||||
uint16_t boot_major;
|
||||
uint16_t boot_minor;
|
||||
uint16_t boot_patch;
|
||||
uint16_t boot_build;
|
||||
} gd32_version_t;
|
||||
|
||||
/* GD32 APP init_state 编码(身份块 [15] 与 0xA3 payload[9],见 spi_link_v2.h) */
|
||||
#define GD32_INIT_STATE_OK (0x00) /* 全部模块初始化成功 */
|
||||
#define GD32_INIT_STATE_DEGRADED (0x01) /* critical 全成,非 critical 有失败/跳过 */
|
||||
#define GD32_INIT_STATE_CRITICAL_FAIL (0x02) /* 有 critical 模块失败 */
|
||||
#define GD32_INIT_STATE_UNKNOWN (0xFF) /* 本端尚未收到身份块 */
|
||||
|
||||
// GD32 APP 初始化状态详情(SPI 0xA3 INIT_STATUS 响应解析结果)
|
||||
#define GD32_INIT_MODULE_COUNT 13 /* 模块数(fault_codes 长度,与 DCU_APP kModuleCount 一致) */
|
||||
typedef struct {
|
||||
uint32_t module_mask; /* 成功初始化的模块位图(DCU_APP AppInitializer::ModuleBit) */
|
||||
uint32_t critical_fail_mask; /* 失败的 critical 模块位图(0=无 critical 失败) */
|
||||
uint8_t reset_cause; /* 复位源位图(bit0 POR/bit1 EPR/bit2 FWDGT/bit3 WWDGT/bit4 SW/bit5 LP) */
|
||||
uint8_t init_state; /* GD32_INIT_STATE_* */
|
||||
/* 每模块 1B 故障码(GD32 1.5.36+,响应 25B 时填充;0=成功。
|
||||
* 编码见 DCU_APP sys/initial.h;canfd = 原因码|(首个失败通道<<4)) */
|
||||
uint8_t fault_codes[GD32_INIT_MODULE_COUNT];
|
||||
bool has_fault_codes; /* fault_codes 是否有效(响应 ≥25B) */
|
||||
bool valid; /* 本结构是否已成功查询填充 */
|
||||
} gd32_init_status_t;
|
||||
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应内容。
|
||||
* @param channel CAN 通道(0~2)。
|
||||
* @param ext_id 29 位扩展帧 ID。
|
||||
* @param data 数据负载指针。
|
||||
* @param dlc 数据长度,范围 1~64。
|
||||
* @param recv_buf 接收缓冲区,可为 NULL。
|
||||
* @param recv_max 期望最大接收字节数。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @return >= 0 接收字节数,< 0 失败。
|
||||
* @brief SPI Master 驱动类(单例)
|
||||
*
|
||||
* 封装 SPI 总线初始化、同步传输、CANFD 通信和 SPI OTA 协议。
|
||||
*/
|
||||
int gd32_can_send(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms);
|
||||
class SpiMaster {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取 SpiMaster 单例实例。
|
||||
* @return SpiMaster 单例引用。
|
||||
*/
|
||||
static SpiMaster& instance();
|
||||
|
||||
/**
|
||||
* @brief 发送一帧无需响应的 CANFD 数据,适合广播或通知场景。
|
||||
* @param channel CAN 通道(0~2)。
|
||||
* @param ext_id 29 位扩展帧 ID。
|
||||
* @param data 数据负载指针。
|
||||
* @param dlc 数据长度,范围 1~64。
|
||||
* @return >= 0 成功,< 0 失败。
|
||||
*/
|
||||
int gd32_can_send_no_reply(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc);
|
||||
/** @brief 初始化 SPI2 总线与目标从设备。 */
|
||||
esp_err_t init();
|
||||
|
||||
/**
|
||||
* @brief 初始化 SPI2 总线与目标从设备。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t spi_master_init(void);
|
||||
// ============================================================
|
||||
// SPI OTA 协议
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* @brief 执行一次同步 SPI 事务,先发送请求,再读取请求之后的响应字节。
|
||||
* @param cmd SPI 命令码;为 0 时不额外插入命令字节。
|
||||
* @param payload 发送数据指针。
|
||||
* @param payload_len 发送数据长度。
|
||||
* @param recv_buf 接收缓冲区,可为 NULL。
|
||||
* @param recv_len 期望接收的响应长度。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t spi_sync_transfer(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 OTA 响应读取事务,不重复发送请求帧。 */
|
||||
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 仅发送 SPI 请求数据,不关心返回内容。
|
||||
* @param cmd SPI 命令码;为 0 时仅发送 payload。
|
||||
* @param payload 发送数据指针。
|
||||
* @param len 发送数据长度。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t spi_send(uint8_t cmd, const uint8_t *payload, size_t len);
|
||||
/** @brief ESP32→GD32 发送命令 + 轮询读取真实响应(两步法,原子持锁)。
|
||||
*
|
||||
* 解决 GD32 "延迟一拍"协议:transact 首笔 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 仅接收 SPI 数据,必要时先发送命令字节,再发送哑数据产生时钟。
|
||||
* @param cmd SPI 命令码;为 0 时只发送哑数据。
|
||||
* @param recv_buf 接收缓冲区。
|
||||
* @param len 接收长度。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t spi_recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms);
|
||||
/** @brief 读取对端最新上报的 CREDIT(0 表示对端忙,应 POLL 等待而非重发)。 */
|
||||
uint8_t remoteCredit() const { return m_remoteCredit; }
|
||||
|
||||
/**
|
||||
* @brief 按 GD32 SPI OTA 最小协议执行一次请求事务 + 一次响应读取事务。
|
||||
* @param type 请求帧类型(REQ/DATA/CANCEL)。
|
||||
* @param payload 请求负载。
|
||||
* @param payload_len 负载长度。
|
||||
* @param resp_type 输出响应类型(ACK/WAIT/ERROR)。
|
||||
* @param resp_payload 输出响应负载缓冲区。
|
||||
* @param resp_max 输出响应负载最大长度。
|
||||
* @param resp_len 输出实际响应负载长度。
|
||||
* @param timeout_ms 超时。
|
||||
*/
|
||||
esp_err_t spi_ota_transfer(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(SPEC §10.1,/system/status 展示用)。 */
|
||||
uint8_t localCredit() const { return m_localCredit; }
|
||||
|
||||
/**
|
||||
* @brief 仅执行 SPI OTA 响应读取事务,不重复发送请求帧。
|
||||
*/
|
||||
esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
|
||||
/** @brief credit 窗口复位到初始值(电机 OTA 取消等半会话终止后调用)。
|
||||
* credit 只随帧头传递:对端转空闲后不再发帧,传输期锁存的 credit=0
|
||||
* 无法自愈 → 所有 transact 被 CREDIT 背压拒绝(死锁)。
|
||||
* GD32 端 resetToIdle 已恢复接收窗口(clearOtaSessionQueues 置满),
|
||||
* 本侧同步复位与之对齐;此后每帧帧头 credit 会覆盖为真实值。 */
|
||||
void resetCredit() { m_localCredit = 8; m_remoteCredit = 8; }
|
||||
|
||||
/**
|
||||
* @brief 通知正在运行的 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。
|
||||
* @return ESP_OK 表示 APP 已确认;ESP_ERR_TIMEOUT 常表示 GD32 已在 Bootloader 或通信未就绪。
|
||||
*/
|
||||
esp_err_t gd32_app_request_bootloader(uint32_t timeout_ms);
|
||||
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
|
||||
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
|
||||
|
||||
/**
|
||||
* @brief 向 GD32 APP 发送存活探测命令 (TYPE=0xA1),等待 APP 回复 ALIVE ACK。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @param uptime_sec 输出 APP 运行秒数,可为 NULL。
|
||||
* @return ESP_OK APP 存活并回复;ESP_ERR_TIMEOUT 超时无响应。
|
||||
*/
|
||||
esp_err_t gd32_app_ping(uint32_t timeout_ms, uint32_t *uptime_sec);
|
||||
/** @brief 最近一次 worker job 实际执行耗时(µs,worker 内计时)。
|
||||
* 测速页用 RTT − 本值拆出"提交/调度/唤醒"开销,定位抖动归属。 */
|
||||
uint32_t lastWorkerExecUs() const { return m_lastWorkerExecUs; }
|
||||
|
||||
/**
|
||||
* @brief 单次 transact 的分段耗时剖析(测速页诊断用)。
|
||||
* 5 段:提交/调度(t_submit0→t_w0)、发送帧(t_send0→t_send1)、
|
||||
* 等应答(t_wait0→t_wait1)、读响应(t_wait1→t_read1)、唤醒(t_w1→t_submit1)。
|
||||
* 写入顺序由 done 信号量保证 happens-before(worker 先写全再 give,调用方 take 后读)。
|
||||
*/
|
||||
struct SpiProfile {
|
||||
uint64_t t_submit0 = 0; ///< 调用方 transact 入口
|
||||
uint64_t t_w0 = 0; ///< worker executeJob 开始
|
||||
uint64_t t_send0 = 0; ///< 发送帧开始
|
||||
uint64_t t_send1 = 0; ///< 发送帧结束
|
||||
uint64_t t_wait0 = 0; ///< 进入 poll(等 READY)
|
||||
uint64_t t_wait1 = 0; ///< READY 就绪(wait 段结束)
|
||||
uint64_t t_read1 = 0; ///< 响应帧读取完成
|
||||
uint64_t t_w1 = 0; ///< worker executeJob 结束
|
||||
uint64_t t_submit1 = 0; ///< 调用方 submitJob 返回
|
||||
};
|
||||
|
||||
/** @brief 最近一次 transact 的剖析快照(仅测速/诊断用)。 */
|
||||
SpiProfile lastProfile() const { return m_lastProfile; }
|
||||
|
||||
/** @brief SPI 任务队列当前待处理 job 数(固定 Job pool 槽,无堆分配)。
|
||||
* 供 /system/heap_low_log 堆低水位快照采样,定位"低谷是不是 SPI 队列堆积引起"。 */
|
||||
UBaseType_t getPendingJobCount() const { return m_jobQueue ? uxQueueMessagesWaiting(m_jobQueue) : 0; }
|
||||
|
||||
/**
|
||||
* @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。
|
||||
* 中继开始设 true,结束/复位设 false。
|
||||
* 实现为引用计数:GD32 中继与电机 OTA feed 可并发持有,任一持有者
|
||||
* 退出都不会提前解除抑制(避免电机 OTA 收尾把 GD32 中继的抑制误清)。
|
||||
*/
|
||||
void setSuppressRingService(bool suppress);
|
||||
|
||||
/**
|
||||
* @brief ESP32 自我 OTA 期间暂停全部 SPI 活动(GD32 按铃读 + 业务 transact)。
|
||||
* 暂停后 worker 空转不读 SPI,submitJob 快速返回 ESP_ERR_INVALID_STATE
|
||||
* (业务立即失败,不阻塞排队);恢复时唤醒 worker 恢复正常。幂等。
|
||||
*/
|
||||
void setSpiPaused(bool paused);
|
||||
|
||||
/**
|
||||
* @brief 业务层确认当前请求已成功时推进/对齐 m_txSeq。
|
||||
*/
|
||||
void confirmTxSeqAccepted();
|
||||
|
||||
/**
|
||||
* @brief 统一请求入口:经 SPI worker 串行执行(send+poll)。
|
||||
*/
|
||||
esp_err_t transact(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 取出 GD32 EVENT 帧(worker 已读入事件队列)。
|
||||
* @param timeout_ms 等待超时(ms)。0 = 非阻塞(原语义)。
|
||||
* @return true 取到事件;false 队列空且超时未到达。
|
||||
*/
|
||||
bool takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len,
|
||||
uint32_t timeout_ms = 0U);
|
||||
|
||||
// ============================================================
|
||||
// SPI Link v2 链路层(阶段1:帧格式 + 状态成员就位)
|
||||
// ============================================================
|
||||
|
||||
/** @brief v2 链路状态统计(SPEC/spi_link_v2.md §12)。 */
|
||||
struct SpiLinkStats {
|
||||
uint32_t crc_errors; ///< CRC 校验失败计数
|
||||
uint32_t duplicate_requests; ///< 重复请求计数(序号回退)
|
||||
uint32_t seq_errors; ///< 序号跳变计数
|
||||
uint32_t retries; ///< 重试总数
|
||||
uint32_t ready_timeouts; ///< DATA_READY 超时计数
|
||||
uint32_t rx_overruns; ///< RX overrun 计数
|
||||
uint32_t resyncs; ///< HELLO 重同步次数
|
||||
uint32_t max_resp_latency_us; ///< 最大响应延迟(微秒)
|
||||
/* 0.4.3 诊断:GD32 回 0x82 的量化归因(/system/status 直读,不依赖 /logs) */
|
||||
uint32_t rsp_err_0x82; ///< 收到 GD32 0x82 ERROR 响应总数
|
||||
uint8_t rsp_err_last_req; ///< 最近一笔 0x82 对应的主动请求 TYPE
|
||||
uint8_t rsp_err_last_p0; ///< 最近一笔 0x82 的 payload[0](GD32 错误码)
|
||||
uint8_t rsp_err_last_p1; ///< 0xE8 时=GD32 收到的 rxLen
|
||||
uint8_t rsp_err_last_p2; ///< 0xE8 时=GD32 rxBuf[0](期望 SOF0=0xA5)
|
||||
uint8_t rsp_err_last_p3; ///< 0xE8 时=GD32 rxBuf[1](期望 SOF1=0x5A)
|
||||
uint8_t rsp_err_last_p4; ///< 0xE8 时=GD32 SpiParseResult 解析失败原因枚举
|
||||
uint8_t rsp_err_last_p5; ///< 0xE8 时=GD32 SOF offset (0=正常, >0=漂移, 255=未找到)
|
||||
uint32_t ring_steals; ///< serviceRing 偷读迟到 RESPONSE 总数
|
||||
uint8_t ring_steal_last_type; ///< 最近一笔偷读帧的 TYPE(0.4.5 归因)
|
||||
uint8_t ring_steal_last_ack; ///< 最近一笔偷读帧 ack_seq 是否匹配 lastSent
|
||||
uint8_t ring_steal_last_p0; ///< 最近一笔偷读帧 payload[0](0.4.6)
|
||||
uint8_t ring_steal_last_req; ///< 偷读时刻的 m_lastReqType(0.4.6)
|
||||
uint8_t ring_steal_last_p1; ///< 0xE8 时=GD32 诊断 rxLen(0.4.8)
|
||||
uint8_t ring_steal_last_p2; ///< 0xE8 时=GD32 诊断 rxBuf[0](0.4.8)
|
||||
uint8_t ring_steal_last_p3; ///< 0xE8 时=GD32 诊断 rxBuf[1](0.4.8)
|
||||
uint8_t ring_steal_last_p4; ///< 0xE8 时=GD32 诊断 parse_res
|
||||
uint8_t ring_steal_last_p5; ///< 0xE8 时=GD32 诊断 sof_offset
|
||||
/* 0.4.9 诊断:透明重试会掩盖真实失败率(DBG 被编译裁掉、无计数)。
|
||||
* tx_total 进入事务;tx_retry 走到重发;tx_fail 耗尽重试仍失败返回。 */
|
||||
uint32_t tx_total; ///< otaSendAndPoll 事务总数
|
||||
uint32_t tx_retry; ///< 因 send/poll 超时触发的重发总次数
|
||||
uint32_t tx_fail; ///< 重试后仍失败(返回非 OK)的事务数
|
||||
uint8_t tx_fail_last_type; ///< 最近一笔最终失败的命令 TYPE
|
||||
};
|
||||
|
||||
/** @brief v2 完整链路状态(SPEC §10.1,会话/credit + 统计)。 */
|
||||
struct SpiLinkStatus {
|
||||
uint8_t version; ///< 协议版本(固定 2)
|
||||
uint8_t gd32_role; ///< 0=unknown, 1=app, 2=boot
|
||||
uint32_t session_id; ///< 当前会话 ID
|
||||
uint8_t local_credit; ///< 本端剩余 credit
|
||||
uint8_t remote_credit; ///< 对端剩余 credit
|
||||
SpiLinkStats stats; ///< 8 个统计计数器
|
||||
};
|
||||
|
||||
/** @brief 当前会话 ID(0 = 未建立 session)。 */
|
||||
uint32_t sessionId() const { return m_sessionId; }
|
||||
|
||||
/** @brief v2 session 是否已激活。 */
|
||||
bool v2Active() const { return m_v2Active; }
|
||||
|
||||
/** @brief SPI 链路是否判定在线(连续失败未触发熔断,防 SPI 不通时死循环阻塞与堆栈耗尽)。 */
|
||||
bool isLinkOnline() const { return m_linkOnline; }
|
||||
|
||||
/** @brief 当前连续失败次数。 */
|
||||
uint32_t consecutiveFailures() const { return m_consecutiveFailures; }
|
||||
|
||||
/** @brief 检查当前是否允许发起常规业务 SPI 事务(离线熔断冷却期内快速失败)。 */
|
||||
bool allowRegularTransact(uint8_t type) const;
|
||||
|
||||
/** @brief 最近一次 HELLO 得到的 GD32 角色(0=unknown, 1=app, 2=boot)。 */
|
||||
uint8_t gd32Role() const { return m_gd32Role; }
|
||||
|
||||
/**
|
||||
* @brief 是否已从 HELLO_ACK/PING 身份块拿到功能位图。
|
||||
* @note false=尚未建连身份或旧固件。
|
||||
*/
|
||||
bool gd32FeatKnown() const { return m_gd32FeatKnown; }
|
||||
|
||||
/** @brief GD32 功能位图(V2_FEAT_*);未知时为 0。 */
|
||||
uint32_t gd32FeatFlags() const { return m_gd32FeatFlags; }
|
||||
|
||||
/** @brief 便捷:GD32 是否开启 ADC 0xC4(需 gd32FeatKnown)。 */
|
||||
bool gd32FeatAdcFeedback() const {
|
||||
return m_gd32FeatKnown && (m_gd32FeatFlags & V2_FEAT_ADC_FEEDBACK) != 0U;
|
||||
}
|
||||
|
||||
/** @brief 是否已从 HELLO_ACK/PING 拿到 APP/BOOT 版本。 */
|
||||
bool gd32IdKnown() const { return m_gd32IdKnown; }
|
||||
|
||||
/** @brief 最近身份块中的版本(HELLO 或 PING)。 */
|
||||
gd32_version_t gd32VersionCached() const { return m_gd32Version; }
|
||||
|
||||
/** @brief 获取链路统计快照(线程安全,持锁读取)。 */
|
||||
SpiLinkStats linkStats();
|
||||
|
||||
/** @brief 获取完整链路状态(SPEC §10.1,会话/credit + 统计)。 */
|
||||
SpiLinkStatus linkStatus();
|
||||
|
||||
/** @brief 发起 HELLO 握手建立 v2 session(SPEC §5.1)。
|
||||
* 生成随机非零 SESSION_ID,发送 LINK_HELLO,等待 HELLO_ACK。
|
||||
* 成功后 m_v2Active=true,序号归零。
|
||||
* @param timeout_ms 总超时(毫秒),默认 3000。
|
||||
* @param role_hint 期望的对端角色(0=any, 1=app, 2=boot)。
|
||||
* @return ESP_OK 成功;ESP_ERR_TIMEOUT 超时;ESP_ERR_INVALID_RESPONSE 响应异常。 */
|
||||
esp_err_t hello(uint32_t timeout_ms = 3000U, uint8_t role_hint = 0U);
|
||||
|
||||
/** @brief 通知 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。 */
|
||||
esp_err_t appRequestBootloader();
|
||||
|
||||
/** @brief 通过 HELLO 轮询等待 Bootloader 就绪(v2 方式,不再依赖 PB6 序列)。
|
||||
* App→Boot 切换后,ESP32 每 20ms 发送 HELLO(role_hint=boot),
|
||||
* 等待 HELLO_ACK 且 role=boot,最长等待 timeout_ms。
|
||||
* 超时返回 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 查询 GD32 APP 初始化状态(SPI 0xA3,12B:模块位图/失败位图/复位源/init_state)。
|
||||
* 仅 APP 角色支持(BOOT 无此命令);成功时填充 out 并置 valid=true,同时更新本端缓存。 */
|
||||
esp_err_t appInitStatus(uint32_t timeout_ms, gd32_init_status_t *out);
|
||||
|
||||
/** @brief 身份块 [15] 带来的 init_state(HELLO/PING 自动刷新;0xFF=未知)。 */
|
||||
uint8_t gd32InitState() const { return m_gd32InitState; }
|
||||
|
||||
/** @brief 读取缓存的 0xA3 查询结果;valid=false 表示尚未成功查询过。 */
|
||||
bool gd32InitStatusCached(gd32_init_status_t *out) const;
|
||||
|
||||
/**
|
||||
* @brief L0 本机自检(不依赖 GD32):SPI 设备/互斥/worker/队列已就绪。
|
||||
*/
|
||||
bool selfTestL0() const;
|
||||
|
||||
/**
|
||||
* @brief L0 对端轻检:短超时 HELLO(role=any),确认 SPI 链路可达 GD32。
|
||||
* @note GD32 仍在启动延时中时可能失败,属非关键项。
|
||||
*/
|
||||
bool selfTestPeerL0();
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief 私有默认构造函数。
|
||||
*/
|
||||
SpiMaster() = default;
|
||||
/**
|
||||
* @brief 私有析构函数。
|
||||
*/
|
||||
~SpiMaster() = default;
|
||||
/**
|
||||
* @brief 禁止复制构造。
|
||||
*/
|
||||
SpiMaster(const SpiMaster&) = delete;
|
||||
/**
|
||||
* @brief 禁止复制赋值。
|
||||
*/
|
||||
SpiMaster& operator=(const SpiMaster&) = delete;
|
||||
|
||||
spi_device_handle_t m_spiDevice = NULL;
|
||||
SemaphoreHandle_t m_mutex = NULL;
|
||||
|
||||
// ── 本端事务态(非 PB6 电平门控;PB6 仅 DATA_READY 通知)──
|
||||
/** 本端 SPI 事务状态:仅用于 acceptRing(忙时不接按铃)。 */
|
||||
enum class HandshakeState : uint8_t {
|
||||
IDLE = 0, ///< 空闲,可接受 GD32 按铃
|
||||
MASTER_SENDING, ///< 主机正在 CS 发送
|
||||
WAITING_RESPONSE, ///< 主机正在 poll/读响应
|
||||
SERVING_GD32_RING, ///< 正在读 GD32 EVENT
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 本端事务占用标记(v2)。
|
||||
*
|
||||
* DATA_READY(PB6)由 GD32 独占:低=有待读,高=队列空。
|
||||
* 本状态机只表示 ESP32 是否正占用 SPI 总线,避免与按铃读并发。
|
||||
*/
|
||||
class SpiLinkState {
|
||||
public:
|
||||
/** 进入忙碌态。 */
|
||||
void enterBusy(HandshakeState hs) { m_hsState = hs; }
|
||||
|
||||
/** 退出忙碌态,回到 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 worker 消费。 */
|
||||
volatile bool m_gd32RequestPending = false;
|
||||
/** ring service 抑制引用计数(setSuppressRingService 内部使用,临界区保护)。 */
|
||||
uint32_t m_ringSuppressRefs = 0;
|
||||
/** OTA 中继时为 true:worker 不 serviceRing,避免偷读 OTA ACK/WAIT。 */
|
||||
volatile bool m_suppressRingService = false;
|
||||
/** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */
|
||||
volatile bool m_spiPaused = false;
|
||||
/** GD32 重启检测(响应帧 session=0)置位:worker 空闲时 HELLO 重同步。 */
|
||||
volatile bool m_resyncPending = false;
|
||||
/** 上次 HELLO 重同步时刻(冷却 5s,防 GD32 不响应时反复 HELLO)。 */
|
||||
volatile uint32_t m_lastResyncTick = 0;
|
||||
/** 链路在线状态及熔断状态:防 SPI 不通时死循环阻塞与堆栈耗尽 */
|
||||
volatile bool m_linkOnline = true;
|
||||
volatile uint32_t m_consecutiveFailures = 0;
|
||||
volatile uint32_t m_lastFailureTick = 0;
|
||||
static constexpr uint32_t kCircuitBreakerFailThreshold = 3U; ///< 连续 3 次失败触发熔断
|
||||
static constexpr uint32_t kCircuitBreakerCooloffMs = 2000U; ///< 熔断后冷却 2 秒
|
||||
|
||||
void recordSpiSuccess();
|
||||
void recordSpiFailure();
|
||||
|
||||
/** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */
|
||||
volatile uint8_t m_lastReqType = 0xFFU;
|
||||
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */
|
||||
TaskHandle_t m_spiTaskHandle = NULL;
|
||||
TaskHandle_t m_workerTask = NULL;
|
||||
QueueHandle_t m_jobQueue = NULL;
|
||||
/** 最近一次 job 执行耗时(µs,worker 写入、业务只读;单核 32 位读原子)。 */
|
||||
uint32_t m_lastWorkerExecUs = 0;
|
||||
/** 最近一次 transact 分段剖析(worker 与调用方交叉写入,done 信号量同步)。 */
|
||||
SpiProfile m_lastProfile;
|
||||
/** 事件计数信号量(worker push / takeEvent pop)。 */
|
||||
SemaphoreHandle_t m_eventSem = NULL;
|
||||
|
||||
// ── 多槽事件队列(worker 写入,业务 takeEvent 读出)──
|
||||
static constexpr uint8_t kEventSlots = 4U;
|
||||
struct EventSlot {
|
||||
uint8_t type;
|
||||
uint16_t len;
|
||||
uint8_t data[SPI_OTA_FRAME_MAX];
|
||||
};
|
||||
EventSlot m_eventQ[kEventSlots] = {};
|
||||
uint8_t m_eventHead = 0;
|
||||
uint8_t m_eventTail = 0;
|
||||
uint8_t m_eventCount = 0;
|
||||
|
||||
// ── 作业提交(跨任务:固定 Job pool + 静态 binary done;worker 串行执行)──
|
||||
enum class JobKind : uint8_t {
|
||||
Transact = 0,
|
||||
Poll,
|
||||
Hello,
|
||||
EnterBoot,
|
||||
Ping,
|
||||
WaitBoot,
|
||||
InitStatus,
|
||||
};
|
||||
struct Job {
|
||||
JobKind kind = JobKind::Transact;
|
||||
uint32_t timeout_ms = 0;
|
||||
uint8_t type = 0;
|
||||
uint8_t expected_type = 0xFFU;
|
||||
uint8_t role_hint = 0;
|
||||
size_t payload_len = 0;
|
||||
uint8_t payload[SPI_OTA_FRAME_MAX] = {};
|
||||
uint8_t *out_type = nullptr;
|
||||
uint8_t *out_payload = nullptr;
|
||||
size_t out_max = 0;
|
||||
size_t *out_len = nullptr;
|
||||
uint32_t *out_uptime = nullptr;
|
||||
gd32_version_t *out_version = nullptr;
|
||||
gd32_init_status_t *out_init_status = nullptr;
|
||||
esp_err_t result = ESP_FAIL;
|
||||
SemaphoreHandle_t done = nullptr; ///< 池槽静态 binary,init 创建,永不 delete
|
||||
volatile bool finished = false; ///< worker 已 execute + give
|
||||
volatile bool abandoned = false; ///< 提交方已超时返回,worker 负责 release 槽
|
||||
volatile bool executing = false; ///< worker 已 dequeue(即将/正在写 out_*;超时放弃分型用)
|
||||
bool in_use = false; ///< 池槽占用(critical 保护)
|
||||
};
|
||||
/** 队列深度;池 = 队列 + 1(在途执行槽),覆盖 queued + executing。 */
|
||||
static constexpr UBaseType_t kJobQueueLen = 6;
|
||||
static constexpr UBaseType_t kJobPoolSize = kJobQueueLen + 1U;
|
||||
|
||||
Job m_jobPool[kJobPoolSize] = {};
|
||||
bool m_jobPoolReady = false;
|
||||
|
||||
bool onWorker() const;
|
||||
/** @brief 从固定池取空闲 Job;池耗尽返回 nullptr(ESP_ERR_NO_MEM)。 */
|
||||
Job *acquireJobSlot();
|
||||
/** @brief 归还池槽(清空业务字段/标志,drain done 信号量)。 */
|
||||
void releaseJobSlot(Job *job);
|
||||
esp_err_t submitJob(Job &job);
|
||||
void executeJob(Job &job);
|
||||
/** @brief worker 完成池 Job 后的收尾(give done;abandoned 时 release 槽)。 */
|
||||
void finishPoolJob(Job *job);
|
||||
/** @brief FreeRTOS 任务入口,转到 workerLoop。 */
|
||||
static void workerEntry(void *arg);
|
||||
/** @brief SPI worker 主循环:EVENT 按铃 + Job 队列。 */
|
||||
void workerLoop();
|
||||
/** @brief 单次服务 GD32 按铃读帧并入 EVENT 队列。 */
|
||||
esp_err_t serviceRingOnce();
|
||||
/** @brief EVENT 入队(队列满丢最旧)。 */
|
||||
bool pushEvent(uint8_t type, const uint8_t *payload, size_t len);
|
||||
/** @brief EVENT 出队(非阻塞)。 */
|
||||
bool popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len);
|
||||
/**
|
||||
* @brief 解析并缓存 APP 身份 16B(HELLO_ACK 附加块 / PING body 同布局)。
|
||||
* @param id 身份块
|
||||
* @param out_version 可选输出版本
|
||||
* @param out_uptime_sec 可选输出 uptime 秒
|
||||
*/
|
||||
void applyAppIdentity16(const uint8_t *id, gd32_version_t *out_version,
|
||||
uint32_t *out_uptime_sec);
|
||||
|
||||
// ── 帧内同步状态机 ──
|
||||
volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口)
|
||||
volatile uint8_t m_remoteCredit = 8; ///< 对端上报的剩余信用
|
||||
|
||||
// ── SPI Link v2 会话/序号状态(阶段1 就位,阶段2 启用校验)──
|
||||
volatile uint32_t m_sessionId = 0; ///< 会话 ID(0 = 未建立,HELLO 时由 ESP32 生成)
|
||||
volatile uint8_t m_txSeq = 0; ///< 本端发送序号(每发一帧递增,重发不变)
|
||||
volatile uint8_t m_lastSentSeq = 0; ///< 上次发送的序号(用于响应归属匹配)
|
||||
volatile uint8_t m_rxAckedSeq = 0; ///< 已确认的对端最新序号
|
||||
volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号
|
||||
volatile uint8_t m_gd32Role = 0; ///< GD32 角色(0=unknown, 1=app, 2=boot)
|
||||
volatile bool m_gd32FeatKnown = false; ///< HELLO/PING 身份是否带 feat_map
|
||||
volatile uint32_t m_gd32FeatFlags = 0; ///< V2_FEAT_*
|
||||
volatile bool m_gd32IdKnown = false; ///< 是否已缓存版本号
|
||||
gd32_version_t m_gd32Version = {}; ///< HELLO/PING 缓存的版本
|
||||
volatile uint8_t m_gd32InitState = GD32_INIT_STATE_UNKNOWN; ///< 身份块 [15] init_state
|
||||
gd32_init_status_t m_gd32InitStatus = {}; ///< 0xA3 查询缓存(valid 标志内置)
|
||||
volatile bool m_v2Active = false; ///< v2 session 是否激活
|
||||
/** 最近一次 otaFindFrame 解析到的 RESPONSE 是否与 m_lastSentSeq 归属匹配 */
|
||||
volatile bool m_lastRxAckMatched = false;
|
||||
SpiLinkStats m_stats = {}; ///< 链路统计
|
||||
|
||||
// RAII 锁守卫
|
||||
class LockGuard {
|
||||
public:
|
||||
explicit LockGuard(SemaphoreHandle_t lock) : m_lock(lock) {
|
||||
if (m_lock) { xSemaphoreTake(m_lock, portMAX_DELAY); }
|
||||
}
|
||||
~LockGuard() {
|
||||
if (m_lock) { xSemaphoreGive(m_lock); }
|
||||
}
|
||||
LockGuard(const LockGuard&) = delete;
|
||||
LockGuard& operator=(const LockGuard&) = delete;
|
||||
private:
|
||||
SemaphoreHandle_t m_lock;
|
||||
};
|
||||
|
||||
/** @brief GPIO 边沿中断回调(注册到 ESP-IDF GPIO ISR 服务)。 */
|
||||
static void IRAM_ATTR onReadyEdgeISR(void *arg);
|
||||
|
||||
/** @brief APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
|
||||
static bool isAppOnlySpiType(uint8_t type);
|
||||
|
||||
// ── 去重辅助 ──
|
||||
/** @brief 把 otaFindFrame 找到的帧拷贝到调用方输出缓冲(安全截断)。 */
|
||||
static void copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
|
||||
uint8_t *resp_payload, size_t resp_max,
|
||||
const uint8_t *found_payload, size_t found_len,
|
||||
size_t *resp_len);
|
||||
|
||||
/** @brief 见到 APP 独有 TYPE 时纠正粘滞的 boot role(3 处调用合并)。 */
|
||||
void correctRoleFromType(uint8_t found_type, const char *src);
|
||||
|
||||
// 内部 SPI 传输
|
||||
/**
|
||||
* @brief 执行底层 SPI 时钟同步传输。
|
||||
* @param tx_buf 发送数据缓冲区。
|
||||
* @param tx_len 发送数据长度。
|
||||
* @param rx_buf 接收数据缓冲区。
|
||||
* @param rx_len 接收数据长度。
|
||||
* @param timeout_ms 超时时间(毫秒),0 表示无限等待。
|
||||
* @return ESP_OK 表示成功,否则返回错误码。
|
||||
*/
|
||||
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 事务 flags(如 SPI_TRANS_CS_KEEP_ACTIVE)。
|
||||
* 多段同 CS 变长读时:先 spi_device_acquire_bus,段间 KEEP_ACTIVE,末段清 flag 后 release_bus。
|
||||
*/
|
||||
esp_err_t transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
|
||||
uint8_t *rx_buf, size_t rx_len,
|
||||
uint32_t timeout_ms, uint32_t trans_flags);
|
||||
|
||||
/**
|
||||
* @brief 读一帧响应:单 CS 固定时钟预算(BOOT=128 / APP=256),再 v2_parse 截真实长度。
|
||||
* 调用者须已持锁。不用两段 KEEP_ACTIVE(Boot 中途断 CS 会清 pending → 全 0xFF)。
|
||||
* @param out_frame 完整帧输出(含头+payload+CRC)
|
||||
* @param out_max 输出缓冲容量
|
||||
* @param out_len 实际帧长
|
||||
* @param timeout_ms 单次 SPI 超时
|
||||
* @param budget_hint 读预算提示(0=按角色默认;>0 时取 min(hint, 角色预算),
|
||||
* 主动 poll 按最近请求类型收紧,省 0xFF 填充时钟)
|
||||
*/
|
||||
esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
|
||||
uint32_t timeout_ms, size_t budget_hint = 0U);
|
||||
|
||||
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
|
||||
/**
|
||||
* @brief 无锁版本的 OTA 响应轮询(调用者须已持有锁)。
|
||||
* @param resp_type 输出响应类型。
|
||||
* @param resp_payload 输出响应负载缓冲区。
|
||||
* @param resp_max 响应负载缓冲区最大长度。
|
||||
* @param resp_len 输出实际响应长度。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @return ESP_OK 表示成功,否则返回错误码。
|
||||
*/
|
||||
esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len, uint32_t timeout_ms,
|
||||
uint8_t expected_type = 0xFFU);
|
||||
/** @brief 无锁版本的 otaTransfer(调用者须已持有锁)。
|
||||
* @param expect_stale true 表示调用方预期读到过期/空响应(如 otaSendAndPoll 步骤1),
|
||||
* 找不到帧时降级为 DBG 日志,不打 WARN。 */
|
||||
esp_err_t otaTransferUnlocked(uint8_t type, const uint8_t *payload, size_t payload_len,
|
||||
uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len, uint32_t timeout_ms,
|
||||
bool expect_stale = false);
|
||||
|
||||
// OTA 帧工具(静态,无状态)
|
||||
static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len,
|
||||
uint8_t *out, size_t *out_len);
|
||||
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, uint8_t *out_flags = nullptr);
|
||||
};
|
||||
|
||||
#endif // SPI_MASTER_H
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ota_upload_test.py - ESP32 OTA 上传集成测试脚本
|
||||
|
||||
用法: python ota_upload_test.py
|
||||
前提: 已编译 build/ESP32_COMM.bin,ESP32 已连接网络。
|
||||
步骤: 上传固件 → 触发更新 → 等待重启并验证版本。
|
||||
"""
|
||||
import urllib.request, json, time, sys
|
||||
|
||||
ESP_IP = "192.168.31.127" # 目标 ESP32 IP 地址(根据实际网络修改)
|
||||
|
||||
# Step 1: Upload ESP32 firmware
|
||||
bin_path = r"G:\Robot_v2\DCU\ESP32_COMM\build\ESP32_COMM.bin"
|
||||
with open(bin_path, "rb") as f:
|
||||
fw_data = f.read()
|
||||
|
||||
header = json.dumps({"type": "esp32", "total_size": len(fw_data)}).encode("utf-8")
|
||||
body = header + fw_data
|
||||
print(f"[1/3] Uploading {len(fw_data)} bytes...")
|
||||
|
||||
req = urllib.request.Request(f"http://{ESP_IP}/ota/upload", data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
result = resp.read().decode()
|
||||
print(f" Upload OK: {result}")
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode()
|
||||
print(f" Upload FAILED: HTTP {e.code} - {body_text}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Trigger update
|
||||
print("[2/3] Triggering OTA update...")
|
||||
time.sleep(1)
|
||||
req2 = urllib.request.Request(f"http://{ESP_IP}/ota/update", data=b"", method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req2, timeout=10) as resp:
|
||||
result2 = resp.read().decode()
|
||||
print(f" Update OK: {result2}")
|
||||
except (ConnectionResetError, urllib.error.URLError, OSError) as e:
|
||||
print(f" Update triggered (connection reset expected): {type(e).__name__}")
|
||||
|
||||
# Step 3: Wait for ESP32 to come back
|
||||
print("[3/3] Waiting for ESP32 to reboot with new firmware...")
|
||||
for attempt in range(1, 31):
|
||||
time.sleep(5)
|
||||
try:
|
||||
req3 = urllib.request.Request(f"http://{ESP_IP}/system/status")
|
||||
with urllib.request.urlopen(req3, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
ver = data.get("version", "?")
|
||||
bld = data.get("build_time", "?")
|
||||
upt = data.get("uptime_sec", 0)
|
||||
print(f" Online! version={ver} build={bld} uptime={upt}s")
|
||||
break
|
||||
except Exception:
|
||||
if attempt % 5 == 0:
|
||||
print(f" Still waiting... ({attempt}/30)")
|
||||
else:
|
||||
print(" ESP32 did not come back within 150 seconds")
|
||||
@@ -1,12 +0,0 @@
|
||||
*** Begin Patch
|
||||
*** Update File: G:\Robot_v2\DCU\ESP32_COMM\main\transport\spi_master.h
|
||||
@@ #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
|
||||
- #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
|
||||
+ #define SPI_CLOCK_HZ (10 * 1000 * 1000) // 10MHz
|
||||
@@ #define SPI_QUEUE_SIZE (1)
|
||||
- #define SPI_QUEUE_SIZE (1)
|
||||
+ #define SPI_QUEUE_SIZE (3)
|
||||
@@ #define SPI_OTA_FRAME_MAX (96)
|
||||
- #define SPI_OTA_FRAME_MAX (96)
|
||||
+ #define SPI_OTA_FRAME_MAX (128)
|
||||
*** End Patch
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
CONFIG_IDF_TARGET="esp32c3"
|
||||
CONFIG_ESP32C3_REV_MIN_101=y
|
||||
CONFIG_ESP_WIFI_SSID="ssid_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
CONFIG_ESP_WIFI_PASSWORD="password_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
@@ -1,4 +0,0 @@
|
||||
CONFIG_IDF_TARGET="esp32c2"
|
||||
CONFIG_XTAL_FREQ_26=y
|
||||
CONFIG_ESP_WIFI_SSID="ssid_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
CONFIG_ESP_WIFI_PASSWORD="password_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
@@ -1,5 +0,0 @@
|
||||
CONFIG_IDF_TARGET="esp32c2"
|
||||
CONFIG_XTAL_FREQ_26=y
|
||||
CONFIG_ESP32C2_REV_MIN_200=y
|
||||
CONFIG_ESP_WIFI_SSID="ssid_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
CONFIG_ESP_WIFI_PASSWORD="password_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
@@ -1,3 +0,0 @@
|
||||
CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT=n
|
||||
CONFIG_ESP_WIFI_SSID="ssid_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
CONFIG_ESP_WIFI_PASSWORD="password_${IDF_TARGET}_${CI_PIPELINE_ID}"
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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
|
||||
|
||||
# Flash mode DIO(锁定):本板 PCB 实测 QIO 不可用。
|
||||
# 2026-07 尝试过 CONFIG_ESPTOOLPY_FLASHMODE_QIO=y 加速 OTA,结果:
|
||||
# 开机 boot 日志 SPI Mode : QIO 看似正常,但 WiFi OTA 写 flash 后
|
||||
# 从分区读回计算 SHA-256 就与上传前 SHA 不一致(每次都不过),
|
||||
# 属于“能启动但数据不可靠”。推断:当前 flash 芯片/PCB 走线/WP+HOLD 不支持 QIO。
|
||||
# 不要再开 QIO,除非确认 flash 型号与硬件连接均兼容。
|
||||
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
|
||||
|
||||
# DCU SPI clock -- 13.3 MHz(2026-08-02:80MHz/6 整除,比 12M 再省 ~8%;
|
||||
# 半周期 37.5ns,MISO 输出延迟 + GPIO 矩阵延迟 ~45ns 预算已近上限,需实测;
|
||||
# ≥16MHz 全双工不可行。写 13333333:若写 13000000 驱动取整 80/7=11.4M 反而更慢。
|
||||
# 2026-08-09 注:曾怀疑 13.33M 是 relay 重发率高(~4.4%)的根因,改 12M 实测
|
||||
# 重发率几乎不变 → 时钟不是主因,回到 13.33M,继续找逻辑层瓶颈。)
|
||||
CONFIG_DCU_SPI_CLOCK_HZ=13333333
|
||||
|
||||
# GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压
|
||||
# (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发)
|
||||
|
||||
# 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
|
||||
# C3 堆紧:窗口过大 OTA 峰值易顶;8KB≈5*MSS,在 4KB OTA 块下吞吐更好,仍比 12KB+ 保守。
|
||||
# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。
|
||||
# WND 保持 8192:OTA 上传走接收窗口,不动以保上传吞吐。
|
||||
CONFIG_LWIP_TCP_WND_DEFAULT=8192
|
||||
# SND_BUF 8192→5760(=4*MSS):发送侧(/logs、status、feedback 响应)每 socket 的
|
||||
# 瞬时 TX pbuf 上限从 ~8KB 降到 ~5.76KB;max_open_sockets=6 时聚合发送峰值明显下降。
|
||||
# 我方响应体均 ≤5KB,4*MSS 不损局域网 TX 吞吐;OTA 上传是 RX 侧,不受影响。
|
||||
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=5760
|
||||
# 乱序队列 pbuf:4→2,少占堆;HTTP 顺序上传基本不需要大 OOSEQ
|
||||
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=2
|
||||
|
||||
# WiFi 动态缓冲(C3 + NimBLE 常驻):32 过大,OTA 峰值与 TCP 窗口叠加易堆碎。
|
||||
# 16/16/6 对单连接 OTA 足够,省出 ~10–20KB 峰值给 Flash 写路径。
|
||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6
|
||||
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16
|
||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16
|
||||
|
||||
# ── 固件体积裁剪(P0,2026-07):仅三项,WPA2-PSK / 本地 HTTP / IPv4 OTA 不受影响 ──
|
||||
# 关闭企业 WiFi(802.1X),家用 WPA2-PSK 不受影响
|
||||
CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT=n
|
||||
# 无 HTTPS 出站客户端,本地 HTTP OTA 不需要 WiFi 内嵌 mbedTLS TLS client
|
||||
CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT=n
|
||||
# 仅 IPv4;关 IPv6 可去掉 nd6 等路径
|
||||
CONFIG_LWIP_IPV6=n
|
||||
|
||||
# FreeRTOS runtime stats -- /system/status CPU usage
|
||||
CONFIG_FREERTOS_USE_TRACE_FACILITY=y
|
||||
|
||||
CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y
|
||||
CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
|
||||
CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y
|
||||
CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y
|
||||
|
||||
# ── BLE / NimBLE 主机栈(set-target 会重生 sdkconfig,必须写在 defaults 里)──
|
||||
CONFIG_BT_ENABLED=y
|
||||
CONFIG_BT_NIMBLE_ENABLED=y
|
||||
CONFIG_BT_CONTROLLER_ENABLED=y
|
||||
# CONFIG_BT_BLUEDROID_ENABLED is not set
|
||||
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
|
||||
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
|
||||
|
||||
# ── NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service)──
|
||||
# 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB
|
||||
CONFIG_BT_NIMBLE_PROX_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_ANS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_CTS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HTP_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IPSS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_TPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_IAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_LLS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_SPS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_HR_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_BAS_SERVICE=n
|
||||
CONFIG_BT_NIMBLE_DIS_SERVICE=n
|
||||
|
||||
# ── NimBLE: 缓冲略收(WiFi OTA 优先;BLE OTA 单连接仍够用)──
|
||||
# 原 24/24 为 BLE 突发优化,但与 WiFi OTA 抢堆导致“卡半 + free~5KB”。
|
||||
# 12/16:BLE 吞吐略降,WiFi 峰值堆更稳。
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=16
|
||||
# Event buffers: 30→16 (省 ~1 KB)
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
|
||||
# Prepared write entries: 64→16 (省 ~1 KB)
|
||||
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
|
||||
# GATT procedures: 4→2 (单连接够用)
|
||||
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
|
||||
CONFIG_BT_CTRL_CE_LENGTH_TYPE_ORIG=y
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MIN_CONN_INTERVAL=12
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=16
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200
|
||||
|
||||
# Brownout detector threshold (LVL7, ~2.8V)
|
||||
# 长时间断电后 CHIP_EN RC 充电慢,LVL5 可能停在半复位状态;提高到 LVL7 确保电源稳定后才启动。
|
||||
CONFIG_ESP_BROWNOUT_DET=y
|
||||
# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5 is not set
|
||||
CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7=y
|
||||
# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_5 is not set
|
||||
CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_7=y
|
||||
|
||||
# ── 控制台/调试 UART 波特率 3M(debug_log.h UART_DEBUG_BAUD_RATE 与此对齐)──
|
||||
# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig 或删 sdkconfig 重生。
|
||||
CONFIG_ESP_CONSOLE_UART_BAUDRATE=3000000
|
||||
CONFIG_CONSOLE_UART_BAUDRATE=3000000
|
||||
@@ -0,0 +1,22 @@
|
||||
import serial, time, sys
|
||||
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user