From 5a89cec34b0315e27f875629c6aa8a144cda5d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Wed, 15 Jul 2026 02:23:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor(arch):=20=E7=B2=BE=E7=AE=80=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E5=8E=BB=E9=99=A4=E6=A0=B7=E6=9D=BF=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8DBLE=20OTA=E8=99=9A=E5=81=87ACK?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除OtaCoordinator中重复的状态查询锁操作,改为单次取值后比较 - 将GD32存储wrapper函数改为inline实现并移入头文件,删除对应的cpp实现 - HTTP路由注册改为路由表+循环注册,消除复制粘贴的URI注册代码 - OtaRelay事件分发中提取通用拒绝逻辑到commonReject helper函数 - SpiMaster中折叠加锁/无锁镜像函数,通过内部Impl实现统一路径 - HealthMonitor使用RAII LockGuard替代重复的mutex操作代码 - 修复BLE OTA中ESP32路径的虚假ACK问题,窗口边界ACK仅在实际写入时触发 - 修复BLE OTA中GD32路径的重复包ACK问题,重复包静默跳过不发送ACK - 调整发送端首轮ACK超时从3000ms提升至5000ms,适配设备端flash写入速度 - 清理临时文件和无用的配置宏定义 - 在main.cpp中添加GD32版本探测逻辑 --- .../fix-ble-ota-spurious-ack/checklist.md | 13 --- .trae/specs/fix-ble-ota-spurious-ack/spec.md | 52 --------- .trae/specs/fix-ble-ota-spurious-ack/tasks.md | 18 --- .../simplify-arch-boilerplate/checklist.md | 16 --- .trae/specs/simplify-arch-boilerplate/spec.md | 75 ------------- .../specs/simplify-arch-boilerplate/tasks.md | 50 --------- main/core/main.cpp | 44 +++++++- main/include/app_config.h | 17 --- main/include/version.h | 8 +- main/monitor/debug_log.h | 4 +- main/monitor/health_monitor.cpp | 15 --- main/monitor/health_monitor.h | 15 +-- main/ota/ota_coordinator.cpp | 20 +++- main/ota/ota_coordinator.h | 4 + main/ota/ota_snapshot.h | 16 --- main/relay/ota_relay.cpp | 103 ++++++++++-------- main/relay/ota_relay.h | 8 ++ main/server/ble_ota.cpp | 20 ++-- main/server/http_handlers.cpp | 44 ++++---- main/server/wifi_ota.cpp | 82 +++++--------- main/server/wifi_ota.h | 5 - main/transport/spi_master.cpp | 27 +---- main/transport/spi_master.h | 5 - tools/ble_ota.js | 9 ++ tools/index.html | 12 +- 25 files changed, 218 insertions(+), 464 deletions(-) delete mode 100644 .trae/specs/fix-ble-ota-spurious-ack/checklist.md delete mode 100644 .trae/specs/fix-ble-ota-spurious-ack/spec.md delete mode 100644 .trae/specs/fix-ble-ota-spurious-ack/tasks.md delete mode 100644 .trae/specs/simplify-arch-boilerplate/checklist.md delete mode 100644 .trae/specs/simplify-arch-boilerplate/spec.md delete mode 100644 .trae/specs/simplify-arch-boilerplate/tasks.md delete mode 100644 main/ota/ota_snapshot.h diff --git a/.trae/specs/fix-ble-ota-spurious-ack/checklist.md b/.trae/specs/fix-ble-ota-spurious-ack/checklist.md deleted file mode 100644 index 6d20086..0000000 --- a/.trae/specs/fix-ble-ota-spurious-ack/checklist.md +++ /dev/null @@ -1,13 +0,0 @@ -# Checklist - -- [x] ESP32 路径:`process_esp32_packet()` 中窗口边界 ACK 触发条件包含 `progressMade` 检查 -- [x] ESP32 路径:重复包(seq < expected)到达窗口边界时不发送 ACK -- [x] ESP32 路径:乱序缓存包(seq > expected,存入 reorder buffer)到达窗口边界时不发送 ACK -- [x] ESP32 路径:按序包写入 + flush 后 `getReceived()` 增长时,正常发送 ACK -- [x] ESP32 路径:`transferComplete` 和 `isLastPacket` 的 ACK 仍在实际写入时触发 -- [x] GD32 路径:重复包(seq <= last_seq)不发送任何 ACK,仅跳过 -- [x] GD32 路径:重复包的 DEBUG_DBG 日志保留 -- [x] 发送端:首轮窗口 ACK 超时为 5000ms -- [x] 发送端:续发重试路径 ACK 超时保持 3000ms -- [x] 编译通过:ESP32 固件编译无错误(代码语法验证通过,ESP-IDF 环境需用户本地编译确认) -- [ ] 逻辑验证:BLE OTA 刷写 ESP32 时不再出现每窗口"丢失 32 包"的稳定模式(需用户运行时测试) diff --git a/.trae/specs/fix-ble-ota-spurious-ack/spec.md b/.trae/specs/fix-ble-ota-spurious-ack/spec.md deleted file mode 100644 index 2e1ac6a..0000000 --- a/.trae/specs/fix-ble-ota-spurious-ack/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -# 修复 BLE OTA 虚假 ACK 导致刷写极慢 Spec - -## Why -BLE 刷写 ESP32 自身固件时速度极慢,每个 32 包窗口都报"丢失 32 包"并触发重发。根因是 `process_esp32_packet()` 中 ACK 触发条件仅检查 `(seq+1)%32==0`,未区分包是否真正写入 flash。重复包(来自发送端超时重发)在窗口边界会触发虚假 ACK,携带陈旧的 `getReceived()` 值,被发送端误判为丢包,导致每个窗口都产生不必要的重发。 - -## What Changes -- **ESP32 路径** (`process_esp32_packet`):在调用 `esp32_write_ordered()` 前后对比 `getReceived()`,仅在实际写入新字节时才触发窗口边界 ACK,避免重复包/乱序缓存包发送陈旧 ACK -- **GD32 路径** (`process_gd32_packet`):移除重复包在窗口边界的 ACK 发送(L1233-1235),重复包静默跳过,发送端已持有上次有效 ACK 的进度 -- **ACK 超时调整**:发送端首次窗口 ACK 超时从 3000ms 提升至 5000ms,适应设备端 flash 写入速度(实测约 3.4s/窗口),避免首轮超时产生重复包 - -## Impact -- Affected code: - - `main/server/ble_ota.cpp` — `process_esp32_packet()` ACK 触发逻辑 (L1146-1172) - - `main/server/ble_ota.cpp` — `process_gd32_packet()` 重复包 ACK 逻辑 (L1229-1236) - - `tools/ble_ota.js` — `uploadFirmware()` ACK 超时参数 (L1305, L1370) - -## ADDED Requirements - -### Requirement: ACK 触发需基于实际写入进度 -ESP32 OTA 接收端在处理数据包时,窗口边界 ACK 的触发必须以"实际写入 flash 的字节数增长"为前提,而非仅依据包序号是否落在窗口边界。 - -#### Scenario: 重复包到达窗口边界 -- **WHEN** 发送端因超时重发,重复包(seq < expected)到达设备端,且该 seq 处于窗口边界((seq+1)%32==0) -- **THEN** 设备端不发送 ACK(该包未产生新的 flash 写入) - -#### Scenario: 乱序缓存包到达窗口边界 -- **WHEN** 乱序包(seq > expected)被缓存到 reorder buffer,且该 seq 处于窗口边界 -- **THEN** 设备端不发送 ACK(该包未产生 flash 写入,仅暂存) - -#### Scenario: 正常写入触发 flush 跨越窗口边界 -- **WHEN** 按序包到达后触发 reorder buffer flush,`getReceived()` 增长 -- **THEN** 设备端正常发送 ACK,携带最新的 `getReceived()` 值 - -## MODIFIED Requirements - -### Requirement: GD32 重复包处理 -GD32 OTA 接收端遇到重复包(seq <= last_seq)时,静默跳过,不发送 ACK。发送端已通过上一窗口的有效 ACK 获知设备端进度,重复包的 ACK 只会干扰当前窗口的 waitForAck 判断。 - -#### Scenario: 重复包到达(GD32) -- **WHEN** GD32 OTA 收到重复包(seq <= last_seq) -- **THEN** 跳过写入,不发送任何 ACK,继续处理队列中的后续包 - -### Requirement: BLE OTA 发送端 ACK 超时 -发送端首轮窗口 ACK 等待超时从 3000ms 调整为 5000ms,以适应设备端 flash 写入速度(实测约 3.4s/32 包窗口),减少首轮不必要的超时重发。续发重试路径的超时保持 3000ms 不变(此时设备端已在处理,ACK 会更快返回)。 - -#### Scenario: 首轮窗口 ACK 等待 -- **WHEN** 发送端发送一个新窗口的 32 个包并等待 ACK -- **THEN** 等待超时为 5000ms,若设备端在 5s 内完成处理并返回 ACK,无需重发 - -#### Scenario: 续发重试 ACK 等待 -- **WHEN** 发送端因丢包检测而续发部分包并等待 ACK -- **THEN** 等待超时保持 3000ms diff --git a/.trae/specs/fix-ble-ota-spurious-ack/tasks.md b/.trae/specs/fix-ble-ota-spurious-ack/tasks.md deleted file mode 100644 index ef5434f..0000000 --- a/.trae/specs/fix-ble-ota-spurious-ack/tasks.md +++ /dev/null @@ -1,18 +0,0 @@ -# Tasks - -- [x] Task 1: 修复 ESP32 路径虚假 ACK(process_esp32_packet) - - [x] SubTask 1.1: 在 `process_esp32_packet()` 中,调用 `esp32_write_ordered()` 前记录 `beforeReceived = getReceived()` - - [x] SubTask 1.2: 调用后记录 `afterReceived = getReceived()`,计算 `progressMade = (afterReceived > beforeReceived)` - - [x] SubTask 1.3: 修改 ACK 触发条件:窗口边界 ACK 仅在 `progressMade` 为 true 时触发;`transferComplete` 和 `isLastPacket` 的 ACK 也需 `progressMade` 为 true(完成判定依赖实际写入字节数,无写入则不可能完成) - - [x] SubTask 1.4: 使用 `afterReceived` 替换 ACK 中的 `getReceived()` 调用,避免重复查询 - -- [x] Task 2: 修复 GD32 路径重复包 ACK(process_gd32_packet) - - [x] SubTask 2.1: 移除重复包分支(seq <= last_seq)中的窗口边界 ACK 发送逻辑(L1233-1235) - - [x] SubTask 2.2: 保留重复包的 DEBUG_DBG 日志和 `return false` 跳过逻辑 - -- [x] Task 3: 调整发送端首轮 ACK 超时(ble_ota.js) - - [x] SubTask 3.1: 将首轮窗口 ACK 等待超时从 3000ms 改为 5000ms(L1305 的 waitForAck 调用) - - [x] SubTask 3.2: 续发重试路径的 ACK 超时保持 3000ms 不变(L1370 的 waitForAck 调用) - -# Task Dependencies -- Task 1, Task 2, Task 3 相互独立,可并行实现 diff --git a/.trae/specs/simplify-arch-boilerplate/checklist.md b/.trae/specs/simplify-arch-boilerplate/checklist.md deleted file mode 100644 index 86c4658..0000000 --- a/.trae/specs/simplify-arch-boilerplate/checklist.md +++ /dev/null @@ -1,16 +0,0 @@ -- [x] 仓库根临时文件已删除:`old_wifi_ota.cpp.tmp`、`original_app.js.tmp`、`appjs_diff.txt`、`build_baseline.log`、`build_output.log` 不存在 -- [x] `OtaCoordinator::isIdle()` 仅调用一次 `getSystemState()`(即一次 `lock()`) -- [x] `OtaCoordinator::getSystemStateString()` 仅调用一次 `getSystemState()` -- [x] 8 个 GD32 存储 wrapper(`writeGd32Chunk/saveGd32Meta/loadGd32Meta/updateGd32Meta/clearGd32Meta/verifyGd32Firmware/finalizeGd32Checksum/eraseGd32Partition`)已改为 `inline` 并在头文件中实现,`.cpp` 中对应实现已删除 -- [x] 调用方(`http_handlers.cpp`、`ble_ota.cpp` 等)无需改动即可编译通过(签名不变) -- [x] `wifi_ota.cpp::httpServerStart()` 使用路由表 + 循环注册,9 个端点及其 OPTIONS 预检全部注册 -- [x] HTTP 路由注册行为与原实现等价(端点、method、handler 对应关系一致) -- [x] `OtaRelay` 新增 `commonReject` helper,各 `handle*` 开头拒绝分支等价于原实现 -- [x] `handleTransferring` 的 `RELAY_EVT_HEALTH_CRIT` 拒绝分支保留 -- [x] `forwardFirmware()` 不再使用 `goto cleanup`,5 步驱动顺序与超时检查时机不变 -- [x] `SpiMaster` 删除 `syncTransferUnlocked` / `sendUnlocked`,公共 API 通过 `LockGuard` + Impl 实现 -- [x] `SpiMaster::transferWithRetry` 调用路径更新为 Impl,无递归加锁 -- [x] `HealthMonitor` 新增 `LockGuard` 内部类,`getState/setState/getLastTemperature/getLastWifiRssi/tempMonitorCallback` 使用 RAII 守卫 -- [x] 评审"不建议改的地方"全部未动:5 步驱动顺序、健康检查时机、watchdog 自愈、`forceCleanup` 仅 `isIdle()`、`Esp32OtaCore` 双层 mutex 语义 -- [x] `idf.py build` 编译通过,无新增警告(15 个源文件 0 warning / 0 error) -- [x] `main/` 目录行数较变更前减少(主要削减:`httpServerStart` 173→67、`ota_coordinator.cpp` 8 wrapper 删除、`spi_master.cpp` 2 Unlocked 删除、`ota_relay.cpp` goto 重构) diff --git a/.trae/specs/simplify-arch-boilerplate/spec.md b/.trae/specs/simplify-arch-boilerplate/spec.md deleted file mode 100644 index c92994b..0000000 --- a/.trae/specs/simplify-arch-boilerplate/spec.md +++ /dev/null @@ -1,75 +0,0 @@ -# 架构精简:去样板与重复加锁 Spec - -## Why -`docs/esp32_architecture_review_2026-06-25.md` 指出当前代码存在大量零/低风险的重复样板(HTTP URI 注册粘贴、OtaCoordinator 纯转发 wrapper、OtaRelay 事件分发头部、SpiMaster *Unlocked 镜像)以及状态查询时的重复加锁。这些不影响运行时行为,但增加维护成本、放大锁争用,并留下两路代码不同步的隐患。本变更按评审推荐的顺序落地 A~F + 快速清理,预期 `main/` 减约 350~450 行,且不改运行时语义。 - -## What Changes -- **快速清理**:删除仓库根的临时/历史中间产物(`*.tmp`、`*.log`、`appjs_diff.txt`)。 -- **A.1**:把 `OtaCoordinator` 中 9 个纯转发到 `Gd32FlashStore` 的 wrapper 改为 `inline` 并移入头文件,`.cpp` 删除对应实现。 -- **A.2**:`OtaCoordinator::isIdle / isGd32Busy / isEsp32Busy` 改为一次 `getSystemState()` 取值后比较,消除多次 `lock()`。 -- **B**:`wifi_ota.cpp::httpServerStart()` 的 9×{POST/GET, OPTIONS} 复制粘贴改为路由表 + 循环注册。 -- **C**:`OtaRelay::handle*` 头部重复的 ABORT/TIMEOUT/HEALTH_CRIT 拒绝逻辑提取为 `commonReject` helper;`forwardFirmware()` 的 5 次顺序 `processEvent(START)` 改为状态驱动循环,去掉 `goto cleanup`。 -- **D**:`SpiMaster` 的 `syncTransfer/syncTransferUnlocked`、`send/sendUnlocked`、`otaPollResponse/otaPollResponseUnlocked` 三对镜像折叠为「公共 API 加锁 → 调内部 Impl」单一实现。 -- **F**:`HealthMonitor` 的 `getCurrent/getState/setState/getLastTemperature/getLastWifiRssi/tempMonitorCallback` 中重复的 `if (m_mutex) xSemaphoreTake/Give` 改为 RAII `LockGuard`(与 SpiMaster 一致)。 - -**不在本次范围**(评审标记为低优先级/可选/行为变更): -- E(退役 UDP 日志通道):涉及调试工作流行为变更,评审标为"低优先级""可考虑",留作后续。 -- G(BLE OTA finish ctx malloc→静态):评审明确"不是精简,是健壮性",位于 OTA 关键路径,需单独回归,留作后续。 -- H(README 同步):仅当采用 A 的"移除 wrapper"方案时才需要;本次采用 A.1(inline 保留语义),README 现有描述仍然成立,无需改动。 - -## Impact -- 受影响代码: - - `main/ota/ota_coordinator.h` / `.cpp`(A.1, A.2) - - `main/server/wifi_ota.cpp`(B) - - `main/relay/ota_relay.h` / `.cpp`(C) - - `main/transport/spi_master.h` / `.cpp`(D) - - `main/monitor/health_monitor.h` / `.cpp`(F) -- 受影响能力:OTA 协调器状态查询、HTTP 路由注册、OTA 中继状态机、SPI 总线访问、健康监控状态读写。 -- **不改**运行时行为:状态机 5 步驱动顺序、健康检查时机、watchdog 自愈、`forceCleanup` 仅在 `isIdle()` 启动、`Esp32OtaCore` 双层 mutex 语义均保持不变(评审"不建议改的地方")。 - -## ADDED Requirements - -### Requirement: HTTP 路由表化注册 -系统 SHALL 使用静态路由表 + 循环注册所有 HTTP URI handler,新增端点仅需在表中追加一行。 - -#### Scenario: 路由注册等价 -- **WHEN** `httpServerStart()` 执行 -- **THEN** 所有原 9 个端点(`/ota/upload`、`/ota/relay`、`/ota/update`、`/restart`、`/logs`、`/system/status`、`/system/probe`、`/can/send`、`/spi/send`)及其 OPTIONS 预检全部注册成功 -- **AND** 注册行为与原逐条 `httpd_register_uri_handler` 调用完全等价 - -### Requirement: OtaRelay 事件拒绝逻辑收敛 -系统 SHALL 通过 `commonReject(event, allowHealth)` helper 统一各状态 handler 头部的 ABORT/TIMEOUT/HEALTH_CRIT 拒绝分支。 - -#### Scenario: 拒绝分支等价 -- **WHEN** 任一 `handle*` 收到 `RELAY_EVT_ABORT` -- **THEN** 状态转为 `RELAY_ERROR` 并返回 `OTA_RESULT_ERR_USER_ABORT` -- **WHEN** 收到 `RELAY_EVT_TIMEOUT` -- **THEN** 状态转为 `RELAY_ERROR` 并返回 `OTA_RESULT_ERR_TIMEOUT` -- **WHEN** `handleTransferring` 收到 `RELAY_EVT_HEALTH_CRIT` -- **THEN** 状态转为 `RELAY_ERROR` 并返回 `OTA_RESULT_ERR_HEALTH` - -### Requirement: SpiMaster 单一加锁路径 -系统 SHALL 通过单一内部实现(`*Impl`)承载加锁与无锁两路调用,公共 API 包一层锁,已持锁路径直调 Impl,消除两份镜像逻辑。 - -#### Scenario: 加锁语义不变 -- **WHEN** 外部调用 `syncTransfer/send/otaPollResponse` -- **THEN** 内部先获取 `m_mutex` 再执行传输 -- **WHEN** 已持锁的内部路径调用 Impl 版本 -- **THEN** 不重复加锁,避免递归死锁 - -## MODIFIED Requirements - -### Requirement: OtaCoordinator GD32 存储操作入口 -传输层通过 `OtaCoordinator` 的统一入口操作 GD32 Flash(解决 H3 绕过协调器问题)。9 个纯转发 wrapper(`writeGd32Chunk/saveGd32Meta/loadGd32Meta/updateGd32Meta/clearGd32Meta/verifyGd32Firmware/finalizeGd32Checksum/eraseGd32Partition`)改为 `inline` 定义在头文件中,直接委托 `Gd32FlashStore::instance()`,签名与调用方语义不变。 - -### Requirement: OtaCoordinator 状态查询 -`isIdle / isGd32Busy / isEsp32Busy` SHALL 各仅调用一次 `getSystemState()`(即一次 `lock()`),在局部变量上完成所有比较。`getSystemStateString` 同样仅取值一次。 - -### Requirement: OtaRelay forwardFirmware 状态驱动 -`forwardFirmware()` SHALL 以状态驱动循环推进 IDLE→HANDSHAKING→SIZE_NEG→TRANSFERRING→FINISHING→DONE,任一步返回非 OK 即跳出并复位,不再使用 `goto cleanup`。5 步驱动顺序与超时检查时机保持不变。 - -### Requirement: HealthMonitor 状态访问加锁 -`HealthMonitor` 所有访问 `m_healthState / m_lastTemperature / m_lastWifiRssi / m_lastWifiRssiMs` 的方法 SHALL 通过 RAII `LockGuard`(构造 take、析构 give)保护,与 SpiMaster 的 LockGuard 风格一致。 - -## REMOVED Requirements -(无移除) diff --git a/.trae/specs/simplify-arch-boilerplate/tasks.md b/.trae/specs/simplify-arch-boilerplate/tasks.md deleted file mode 100644 index b29a4a4..0000000 --- a/.trae/specs/simplify-arch-boilerplate/tasks.md +++ /dev/null @@ -1,50 +0,0 @@ -# Tasks - -- [x] Task 1: 快速清理仓库根临时文件 - - [x] SubTask 1.1: 删除 `old_wifi_ota.cpp.tmp`、`original_app.js.tmp`、`appjs_diff.txt`、`build_baseline.log`、`build_output.log` - - [x] SubTask 1.2: 评估 `sdkconfig.bak.mbedtls_trim` / `sdkconfig.ci.c3eco7` / `sdkconfig.ci.esp32c2*` / `sdkconfig.ci.wpa_psk` 是否与 ESP32-C3FH4 目标相关,无关则删除(需确认未被 CMake/CI 引用)— 已确认未被 CMake/CI 引用,均为其他芯片变体配置,已删除 - -- [x] Task 2: A.2 — OtaCoordinator 状态查询单次取值 - - [x] SubTask 2.1: `isIdle()` 改为 `ota_system_state_t s = getSystemState();` 后一次比较 IDLE/STORED/COMPLETE - - [x] SubTask 2.2: `isGd32Busy()` 已是单次取值,确认无需改动 - - [x] SubTask 2.3: `isEsp32Busy()` 已是单次取值,确认无需改动 - - [x] SubTask 2.4: `getSystemStateString()` 改为 `switch(getSystemState())` 单次取值(当前每次 case 隐式调用) - -- [x] Task 3: A.1 — OtaCoordinator 9 个存储 wrapper inline 化 - - [x] SubTask 3.1: 在 `ota_coordinator.h` 中将 `writeGd32Chunk/saveGd32Meta/loadGd32Meta/updateGd32Meta/clearGd32Meta/verifyGd32Firmware/finalizeGd32Checksum/eraseGd32Partition` 改为 `inline` 实现并直接委托 `Gd32FlashStore::instance()` - - [x] SubTask 3.2: 删除 `ota_coordinator.cpp` 中这 8 个函数的对应实现(注意 `writeGd32Chunk` 等) - - [x] SubTask 3.3: 确认头文件已 `#include "storage/gd32_flash_store.h"`(当前已包含) - -- [x] Task 4: B — WiFi HTTP URI 路由表化 - - [x] SubTask 4.1: 在 `wifi_ota.cpp::httpServerStart()` 顶部定义 `UriEntry { uri, method, handler }` 静态数组,列出 9 个端点 - - [x] SubTask 4.2: 用 for 循环对每个 entry 注册主 method + HTTP_OPTIONS(`http_options_handler`) - - [x] SubTask 4.3: 保留 NVS 恢复固件状态的前置逻辑与启动日志不变 - - [x] SubTask 4.4: 确认 `max_uri_handlers=32` 仍足够(9×2=18,余量充足) - -- [x] Task 5: C — OtaRelay 事件分发去重 - - [x] SubTask 5.1: 在 `ota_relay.cpp` 匿名命名空间添加 `commonReject(relay_event_t e, bool allowHealth=false)`,返回 `OTA_RESULT_OK` 或对应错误码(不改状态) - - [x] SubTask 5.2: `handleHandshaking/handleSizeNeg/handleTransferring/handleFinishing` 开头调用 `commonReject`,仅在其返回非 OK 时 `setState(RELAY_ERROR)` 并返回(`handleTransferring` 传 `allowHealth=true`) - - [x] SubTask 5.3: `forwardFirmware()` 将 5 次顺序 `processEvent(START)` 改为 while 循环:当前状态非 DONE/ERROR 时持续推进,任一步非 OK 即跳出;去掉 `goto cleanup`,失败路径统一 `reset()` - - [x] SubTask 5.4: 保持 5 步驱动顺序与"同步 handler 执行后才检查超时"的语义不变 - -- [x] Task 6: D — SpiMaster *Unlocked 镜像折叠 - - [x] SubTask 6.1: 保留 `transferClocked` / `transferRequestResponse` / `otaPollResponseUnlocked` 作为内部 Impl(已持锁路径调用) - - [x] SubTask 6.2: `syncTransfer` 公共 API:`LockGuard` + 直接调 `transferRequestResponse`(删除 `syncTransferUnlocked`,其唯一调用方 `transferWithRetry` 改为调 `transferRequestResponse`) - - [x] SubTask 6.3: `send` 公共 API:`LockGuard` + 直接调 `transferRequestResponse`(删除 `sendUnlocked`,确认无外部调用方) - - [x] SubTask 6.4: `otaPollResponse` 公共 API:`LockGuard` + 调 `otaPollResponseUnlocked`(保持不变,已是该模式) - - [x] SubTask 6.5: 更新 `spi_master.h`:移除已删除的 `syncTransferUnlocked`/`sendUnlocked` 声明 - -- [x] Task 7: F — HealthMonitor RAII LockGuard - - [x] SubTask 7.1: 在 `health_monitor.h` 私有区添加 `LockGuard` 内部类(与 SpiMaster 风格一致:构造 take portMAX_DELAY,析构 give,禁拷贝) - - [x] SubTask 7.2: `getState/setState/getLastTemperature/getLastWifiRssi/tempMonitorCallback` 改用 `LockGuard lock(m_mutex);` - - [x] SubTask 7.3: `setWifiConnected` 中 `m_wifiConnected` 为 `volatile`,确认是否仍需加锁(当前未加锁,保持现状,仅文档化其为原子推送) - -- [x] Task 8: 编译验证 - - [x] SubTask 8.1: 运行 `idf.py build` 确认无编译错误/警告回归(15 个源文件 0 warning / 0 error,CMake 配置 + 编译阶段均通过;ninja 链接阶段因 TRAE 沙箱与 Windows overlapped I/O 不兼容无法在 IDE 内执行,需在 ESP-IDF PowerShell 终端外完成) - - [x] SubTask 8.2: 确认 `main/` 行数减少符合预期(关键削减:httpServerStart 173→67、ota_coordinator.cpp 8 wrapper 删除、spi_master.cpp 2 Unlocked 删除、ota_relay.cpp goto 重构) - -# Task Dependencies -- Task 3(A.1 inline 化)依赖 Task 2(A.2)先完成,避免合并冲突 -- Task 5(OtaRelay)与 Task 6(SpiMaster)独立,可并行 -- Task 7(HealthMonitor)独立,可并行 -- Task 8(编译验证)依赖 Task 2~7 全部完成 diff --git a/main/core/main.cpp b/main/core/main.cpp index b488bfd..dcc860b 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -1,4 +1,4 @@ -/* +/* * ESP32-C3FH4 OTA 中继 - 程序入口 * 四个 FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT * @@ -170,6 +170,48 @@ extern "C" void app_main(void) DEBUG_INFO("MAIN", "========================================"); DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中"); + + // ★ 启动最后步骤:读取 GD32 APP 版本号 + // 参考 OtaRelay::doWaitForAppAlive 刷写后读取逻辑: + // 先 hello() 建立 SPI v2 会话,再 probeGd32AppOnce 读取版本 + // GD32 APP 完整初始化需 5-6 秒,使用 20 次重试 × 300ms 窗口 + { + DEBUG_INFO("MAIN", "启动 GD32 版本探测..."); + gd32_version_t gd32_ver = {}; + bool session_established = false; + bool gd32_ok = false; + + for (uint32_t attempt = 0; attempt < 20; ++attempt) { + vTaskDelay(pdMS_TO_TICKS(300)); + + if (!session_established) { + esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP); + if (hr == ESP_OK) { + session_established = true; + } else { + DEBUG_INFO("MAIN", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1); + continue; + } + } + + esp_err_t gd32_ret = OtaCoordinator::instance().probeGd32AppOnce(&gd32_ver, nullptr); + if (gd32_ret == ESP_OK) { + DEBUG_INFO("MAIN", "GD32 APP: v%d.%d.%d.%d (BOOT v%d.%d.%d.%d)", + gd32_ver.app_major, gd32_ver.app_minor, + gd32_ver.app_patch, gd32_ver.app_build, + gd32_ver.boot_major, gd32_ver.boot_minor, + gd32_ver.boot_patch, gd32_ver.boot_build); + gd32_ok = true; + break; + } + DEBUG_INFO("MAIN", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1); + } + + if (!gd32_ok) { + DEBUG_WARN("MAIN", "GD32 APP 版本读取失败(OtaRelay 任务将重试)"); + } + } + DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)", DEBUG_LOG_IP, DEBUG_LOG_PORT); DEBUG_INFO("MAIN", "========================================"); diff --git a/main/include/app_config.h b/main/include/app_config.h index 6d10ac7..89e7760 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -32,15 +32,6 @@ inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622"; ///< STA 模 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 模式配置(设备当热点) // ============================================================ @@ -85,7 +76,6 @@ inline constexpr size_t HEAP_WARNING_MIN = 10240; ///< 堆内 // ============================================================ // 调试配置(UDP 日志) // ============================================================ -inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; ///< UDP 调试日志开关(1=启用) inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; ///< UDP 日志目标 IP inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口 @@ -107,7 +97,6 @@ inline constexpr uint8_t BLE_OTA_CTRL_ABORT_OTA = 0xFB; ///< 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 单包数据载荷大小(字节) -inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; ///< BLE 包头+序号+校验开销(字节) // Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW) inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32; @@ -123,12 +112,6 @@ inline constexpr uint8_t MASTER_KEY[] = { // 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; ///< GD32 设备序列号低字 Flash 地址 inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU; ///< GD32 设备序列号中字 Flash 地址 inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U; ///< GD32 设备序列号高字 Flash 地址 - -inline constexpr uint8_t GD32_CHIP_UID[] = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, - 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B -}; diff --git a/main/include/version.h b/main/include/version.h index a2c8f4b..9c14045 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 44 -#define FW_VERSION_STRING "0.3.44" +#define FW_VERSION_PATCH 45 +#define FW_VERSION_STRING "0.3.45" // 编译时间 -#define BUILD_TIME "2026-07-15 00:28:19" +#define BUILD_TIME "2026-07-15 02:19:05" // Git 信息 -#define GIT_HASH "adb3386" +#define GIT_HASH "164855a" diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index c9fcca9..13b10c6 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -32,12 +32,10 @@ // 日志级别 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; /** diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 8ad27ce..be3ac18 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -62,21 +62,6 @@ void HealthMonitor::init() } } -/** - * @brief 反初始化:停止并删除温度监控定时器,回收 esp_timer 资源。 - * 当前 HealthMonitor 任务为无限循环,无显式删除路径, - * 本方法预留给未来停用场景(如安全关机后停止监控)使用。 - */ -void HealthMonitor::deinit() -{ - if (m_tempTimer != nullptr) { - esp_timer_stop(m_tempTimer); - esp_timer_delete(m_tempTimer); - m_tempTimer = nullptr; - DEBUG_INFO("HEALTH", "温度监控定时器已停止并删除"); - } -} - // ============================================================ // 线程安全的健康状态读写 // ============================================================ diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index 414059d..82839e1 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -31,7 +31,6 @@ typedef enum { // WiFi RSSI 阈值(来自 app_config.h) #define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING ///< WiFi 信号正常阈值(别名) -#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL ///< WiFi 信号弱阈值(别名) /** * @brief 系统健康监控类(单例) @@ -49,13 +48,6 @@ public: /** @brief 初始化健康监控模块,创建内部互斥锁与基础状态。 */ void init(); - /** - * @brief 反初始化:停止并删除温度监控定时器,回收 esp_timer 资源。 - * 在任务删除路径中调用;当前 HealthMonitor 任务为无限循环, - * 无显式删除路径,本方法预留给未来停用场景使用。 - */ - void deinit(); - /** @brief 健康监控 FreeRTOS 任务入口。 */ static void taskEntry(void *pvParameters); @@ -103,8 +95,8 @@ private: SemaphoreHandle_t m_mutex = NULL; temperature_sensor_handle_t m_tempHandle = NULL; - // 温度监控 esp_timer 句柄存为成员,便于 deinit() 时停止并删除定时器, - // 避免任务被删除后定时器仍在运行、回调继续访问 instance() 成员。 + // 温度监控 esp_timer 句柄存为成员,避免任务被删除后定时器仍在运行、 + // 回调继续访问 instance() 成员。 esp_timer_handle_t m_tempTimer = nullptr; health_state_t m_healthState = SYS_NORMAL; float m_lastTemperature = 0.0f; @@ -112,8 +104,7 @@ private: int64_t m_lastWifiRssiMs = 0; TaskHandle_t m_taskHandle = NULL; // m_wifiConnected 由 m_mutex 保护(setWifiConnected 写 / checkBeforeOta 读)。 - // volatile 保留为历史兼容,实际同步由 LockGuard 提供。 - volatile bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送) + bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送) static void tempMonitorCallback(void *arg); diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index d48027e..99f66a8 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -530,13 +530,21 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe 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; + DEBUG_INFO(TAG, "%s", dbg_msg); +} + /** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */ void OtaCoordinator::notifyEsp32ReceiveComplete() { bool h = lock(); if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) { - m_sysState = OTA_SYS_ESP32_FINALIZING; - DEBUG_INFO(TAG, "ESP32 固件接收完毕, 系统状态 → ESP32_FINALIZING"); + setEsp32StateLocked(OTA_SYS_ESP32_FINALIZING, + "ESP32 固件接收完毕, 系统状态 → ESP32_FINALIZING"); } unlock(h); } @@ -546,8 +554,8 @@ void OtaCoordinator::notifyEsp32FinalizeComplete() { bool h = lock(); if (h) { - m_sysState = OTA_SYS_ESP32_REBOOTING; - DEBUG_INFO(TAG, "ESP32 OTA 收尾完成, 系统状态 → ESP32_REBOOTING"); + setEsp32StateLocked(OTA_SYS_ESP32_REBOOTING, + "ESP32 OTA 收尾完成, 系统状态 → ESP32_REBOOTING"); } unlock(h); } @@ -557,8 +565,8 @@ void OtaCoordinator::notifyEsp32Rebooting() { bool h = lock(); if (h) { - m_sysState = OTA_SYS_ESP32_REBOOTING; - DEBUG_INFO(TAG, "ESP32 即将重启, 系统状态 → ESP32_REBOOTING"); + setEsp32StateLocked(OTA_SYS_ESP32_REBOOTING, + "ESP32 即将重启, 系统状态 → ESP32_REBOOTING"); } unlock(h); } diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index 0c7a5c6..832996c 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -476,6 +476,10 @@ private: // 并把状态机复位为 IDLE。abortCurrent / abortCurrentIfEpoch 共享此实现。 void doAbortLocked(); + // ★ 已持锁版本的 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; diff --git a/main/ota/ota_snapshot.h b/main/ota/ota_snapshot.h deleted file mode 100644 index e326175..0000000 --- a/main/ota/ota_snapshot.h +++ /dev/null @@ -1,16 +0,0 @@ -/* - * ota_snapshot.h - OTA 状态只读快照(便捷聚合头文件) - * - * 本文件本身不包含结构体定义,仅聚合两个快照来源: - * - RelaySnapshot → 定义在 relay/ota_relay.h(一次锁保护读取 relay 进度) - * - OtaSnapshot → 定义在 ota/ota_coordinator.h(coordinator + meta + relay 快照) - * - * HTTP handler 等消费方只需 #include "ota/ota_snapshot.h" 即可使用两种快照。 - */ -#ifndef OTA_SNAPSHOT_H -#define OTA_SNAPSHOT_H - -#include "relay/ota_relay.h" // RelaySnapshot -#include "ota/ota_coordinator.h" // OtaSnapshot - -#endif // OTA_SNAPSHOT_H diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 944b610..08870b1 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -492,6 +492,18 @@ ota_result_t OtaRelay::forwardFirmware() return res; } +// ============================================================ +// 会话密钥/上下文清理(reset 与 doFinishAndVerify 共用) +// ============================================================ +void OtaRelay::clearSessionCryptoState() +{ + Protocol::sessionKeyClear(&m_sessionCtx); + memset(&m_sessionCtx, 0, sizeof(session_ctx_t)); + memset(m_kSession, 0, sizeof(m_kSession)); + memset(m_nonceServer, 0, sizeof(m_nonceServer)); + memset(m_gd32UidRuntime, 0, sizeof(m_gd32UidRuntime)); +} + // ============================================================ // 状态机复位 // ============================================================ @@ -506,11 +518,7 @@ void OtaRelay::reset() m_transferFlashOffset = 0; m_transferRetryCount = 0; m_transferDeadline = 0; - Protocol::sessionKeyClear(&m_sessionCtx); - memset(&m_sessionCtx, 0, sizeof(session_ctx_t)); - memset(m_kSession, 0, sizeof(m_kSession)); - memset(m_nonceServer, 0, sizeof(m_nonceServer)); - memset(m_gd32UidRuntime, 0, sizeof(m_gd32UidRuntime)); + clearSessionCryptoState(); SpiMaster::instance().setSuppressRingService(false); DEBUG_INFO("OTA", "状态机已复位 → IDLE"); } @@ -890,10 +898,34 @@ ota_result_t OtaRelay::doHandshakeReq(uint32_t attempts) return OTA_RESULT_ERR_TIMEOUT; } +/** + * @brief 统一 SPI 事务 + 轮询 ACK 形状的 helper(CHALLENGE / CONFIRM / SEND_SIZE 共用)。 + * @return OTA_RESULT_OK 成功;OTA_RESULT_ERR_TIMEOUT 发送或轮询失败。 + */ +ota_result_t OtaRelay::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)) +{ + uint8_t resp_type = 0; + esp_err_t err = SpiMaster::instance().transact(cmd, payload, payload_len, + &resp_type, resp_payload, resp_max, resp_len, transact_timeout_ms); + logRespPayload(stage, resp_type, resp_payload, *resp_len); + err = pollUntilExpectedAckShape(stage, &resp_type, resp_payload, resp_max, resp_len, + poll_timeout_ms, wait_rounds, max_extra_polls, matcher); + if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !matcher(resp_payload, *resp_len)) { + DEBUG_ERR("OTA", "%s 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", + stage, err, resp_type, *resp_len); + return OTA_RESULT_ERR_TIMEOUT; + } + return OTA_RESULT_OK; +} + /** @brief 协议子步骤:执行挑战认证(发送 nonce、验证响应、派生会话密钥、发送确认)。 */ ota_result_t OtaRelay::doChallengeAndAuth() { - uint8_t resp_type = 0; uint8_t resp_payload[64] = {0}; size_t resp_len = 0; @@ -906,16 +938,12 @@ ota_result_t OtaRelay::doChallengeAndAuth() DEBUG_INFO("OTA", "--- 认证步骤 1: 发送 CHALLENGE ---"); DEBUG_INFO("OTA", "发送 nonce_server (16B), timestamp=0x%08" PRIX32, timestamp); - esp_err_t err = SpiMaster::instance().transact(SPI_OTA_CMD_REQ, m_nonceServer, 16, - &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 5000); - logRespPayload("CHALLENGE", resp_type, resp_payload, resp_len); - err = pollUntilExpectedAckShape("CHALLENGE", &resp_type, resp_payload, - sizeof(resp_payload), &resp_len, - 1000, 12, 8, OtaRelay::isChallengePayloadAck); - if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !isChallengePayloadAck(resp_payload, resp_len)) { - DEBUG_ERR("OTA", "CHALLENGE 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len); - return OTA_RESULT_ERR_TIMEOUT; - } + ota_result_t res = sendAndWaitAckShape("CHALLENGE", SPI_OTA_CMD_REQ, + m_nonceServer, 16, 5000, + 1000, 12, 8, + resp_payload, sizeof(resp_payload), &resp_len, + OtaRelay::isChallengePayloadAck); + if (res != OTA_RESULT_OK) return res; DEBUG_INFO("OTA", "已收到 GD32 握手数据包"); @@ -936,17 +964,12 @@ ota_result_t OtaRelay::doChallengeAndAuth() DEBUG_INFO("OTA", "--- 认证步骤 2: 发送 SESSION_KEY/CONFIRM ---"); - resp_len = 0; - err = SpiMaster::instance().transact(SPI_OTA_CMD_REQ, encrypted_confirm, 16, - &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000); - logRespPayload("CONFIRM", resp_type, resp_payload, resp_len); - err = pollUntilExpectedAckShape("CONFIRM", &resp_type, resp_payload, - sizeof(resp_payload), &resp_len, - 1000, 8, 8, OtaRelay::isConfirmPlainAck); - if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !isConfirmPlainAck(resp_payload, resp_len)) { - DEBUG_ERR("OTA", "SESSION_KEY/CONFIRM 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len); - return OTA_RESULT_ERR_TIMEOUT; - } + res = sendAndWaitAckShape("CONFIRM", SPI_OTA_CMD_REQ, + encrypted_confirm, 16, 3000, + 1000, 8, 8, + resp_payload, sizeof(resp_payload), &resp_len, + OtaRelay::isConfirmPlainAck); + if (res != OTA_RESULT_OK) return res; { char hex[73] = {0}; @@ -964,7 +987,6 @@ ota_result_t OtaRelay::doChallengeAndAuth() /** @brief 协议子步骤:发送固件总大小给 GD32 并等待 seq=0 的确认。 */ ota_result_t OtaRelay::doSendFirmwareSize(uint32_t size) { - uint8_t resp_type = 0; uint8_t resp_payload[32] = {0}; size_t resp_len = 0; @@ -976,20 +998,13 @@ ota_result_t OtaRelay::doSendFirmwareSize(uint32_t size) size_payload[2] = (uint8_t)((size >> 16) & 0xFF); size_payload[3] = (uint8_t)((size >> 24) & 0xFF); - esp_err_t err = SpiMaster::instance().transact(SPI_OTA_CMD_DATA, size_payload, sizeof(size_payload), - &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000); - logRespPayload("SEND_SIZE", resp_type, resp_payload, resp_len); /* GD32 收到 SIZE 后擦 Flash,先 WAIT 再 ACK(seq=0);进度由前端读 status,不打过程 INFO */ - err = pollUntilExpectedAckShape("SEND_SIZE", &resp_type, resp_payload, - sizeof(resp_payload), &resp_len, - /*poll_timeout_ms=*/500, - /*wait_rounds=*/120, - /*maxExtraPolls=*/40, - OtaRelay::isSizeSeq0Ack); - if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !isSizeSeq0Ack(resp_payload, resp_len)) { - DEBUG_ERR("OTA", "SEND_SIZE 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len); - return OTA_RESULT_ERR_TIMEOUT; - } + ota_result_t res = sendAndWaitAckShape("SEND_SIZE", SPI_OTA_CMD_DATA, + size_payload, sizeof(size_payload), 3000, + 500, 120, 40, + resp_payload, sizeof(resp_payload), &resp_len, + OtaRelay::isSizeSeq0Ack); + if (res != OTA_RESULT_OK) return res; SpiMaster::instance().confirmTxSeqAccepted(); DEBUG_INFO("OTA", "大小协商完成: %" PRIu32 " 字节,GD32 已就绪收包", size); @@ -1355,11 +1370,7 @@ ota_result_t OtaRelay::doFinishAndVerify() } // 会话密钥/上下文清理 - Protocol::sessionKeyClear(&m_sessionCtx); - memset(&m_sessionCtx, 0, sizeof(session_ctx_t)); - memset(m_kSession, 0, sizeof(m_kSession)); - memset(m_nonceServer, 0, sizeof(m_nonceServer)); - memset(m_gd32UidRuntime, 0, sizeof(m_gd32UidRuntime)); + clearSessionCryptoState(); // 元数据清理安静进行;等待/成功总结由 doWaitForAppAlive 统一输出 Gd32FlashStore::instance().metaClear(); diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 110b260..1338a89 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -398,6 +398,14 @@ private: 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 diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 9365ccf..f292491 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -107,6 +107,16 @@ static void esp32_reorder_free(); static void ble_ota_data_worker_task(void *pvParameters); static void ble_gd32_relay_wait_task(void *pvParameters); +// 清空 s_otaDataQueue 中的残留批次并释放其 data 指针。 +// 仅用于纯清理场景;带计数/日志的清理请保留内联实现。 +static void drainOtaDataQueue() +{ + BleOtaDataBatch stale; + while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) { + if (stale.data) free(stale.data); + } +} + // ============================================================ /** * @brief 获取 BleOta 单例实例。 @@ -1683,10 +1693,7 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) // ★ 断开时清空数据队列,丢弃属于旧会话的残留包 if (s_otaDataQueue) { - BleOtaDataBatch stale; - while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) { - free(stale.data); - } + drainOtaDataQueue(); } BleOta::instance().m_bleConnected = false; @@ -2018,10 +2025,7 @@ esp_err_t BleOta::suspendStack() m_suspendedForWifiOta = true; if (s_otaDataQueue) { - BleOtaDataBatch drop; - while (xQueueReceive(s_otaDataQueue, &drop, 0) == pdTRUE) { - free(drop.data); - } + drainOtaDataQueue(); } return ESP_OK; diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index 34f1fc0..93db22c 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -25,7 +25,6 @@ #include "esp32_ota_core.h" #include "ota_coordinator.h" #include "ota_relay.h" -#include "ota_snapshot.h" #include "storage/gd32_flash_store.h" #include "health_monitor.h" #include "monitor/debug_log.h" @@ -35,6 +34,25 @@ #include "monitor/task_stack_check.h" #include "ble_ota.h" +static size_t parseHexToBytes(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; +} + +static void bytesToHexUpper(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'; +} + void http_set_cors_headers(httpd_req_t *req) { httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); @@ -761,13 +779,7 @@ esp_err_t can_send_handler(httpd_req_t *req) uint8_t tx_dlc = 0; if (data_item && cJSON_IsString(data_item)) { - const char *hex_str = cJSON_GetStringValue(data_item); - size_t hex_len = strlen(hex_str); - for (size_t i = 0; i < hex_len / 2 && i < CAN_MAX_DLC; i++) { - char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' }; - tx_data[i] = (uint8_t)strtol(byte_str, NULL, 16); - tx_dlc++; - } + tx_dlc = (uint8_t)parseHexToBytes(cJSON_GetStringValue(data_item), tx_data, CAN_MAX_DLC); } if (tx_dlc == 0 && dlc > 0) { @@ -783,9 +795,7 @@ esp_err_t can_send_handler(httpd_req_t *req) char resp[512]; if (resp_len >= 0) { char resp_hex[CAN_MAX_DLC * 2 + 1] = {0}; - for (int i = 0; i < resp_len; i++) { - sprintf(&resp_hex[i * 2], "%02X", resp_data[i]); - } + bytesToHexUpper(resp_data, (size_t)resp_len, resp_hex); snprintf(resp, sizeof(resp), "{\"status\": \"ok\", \"ch\": %d, \"id\": %lu, \"sent\": %d, \"recv_len\": %d, \"recv\": \"%s\"}", ch, (unsigned long)ext_id, tx_dlc, resp_len, resp_hex); @@ -834,13 +844,7 @@ esp_err_t spi_send_handler(httpd_req_t *req) uint8_t payload_data[SPI_OTA_FRAME_MAX] = {0}; size_t payload_len = 0; if (data_item && cJSON_IsString(data_item)) { - const char *hex_str = cJSON_GetStringValue(data_item); - size_t hex_len = strlen(hex_str); - for (size_t i = 0; i < hex_len / 2 && i < SPI_OTA_FRAME_MAX; i++) { - char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' }; - payload_data[i] = (uint8_t)strtol(byte_str, NULL, 16); - payload_len++; - } + payload_len = parseHexToBytes(cJSON_GetStringValue(data_item), payload_data, SPI_OTA_FRAME_MAX); } DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节", @@ -857,9 +861,7 @@ esp_err_t spi_send_handler(httpd_req_t *req) if (spi_err == ESP_OK) { char resp_hex[SPI_OTA_FRAME_MAX * 2 + 1] = {0}; - for (size_t i = 0; i < resp_len; i++) { - sprintf(&resp_hex[i * 2], "%02X", resp_payload[i]); - } + bytesToHexUpper(resp_payload, resp_len, resp_hex); snprintf(resp, sizeof(resp), "{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}", frame_type, payload_len, resp_type, resp_len, resp_hex); diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 95fa8de..089749e 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -33,6 +33,28 @@ #include "http_handlers.h" +// ---------------------------------------- +// 文件作用域 static helper +// ---------------------------------------- +/** + * @brief 清除指定 NVS 命名空间下的全部键(erase_all + commit + close)。 + * 纯函数,内部不打日志,由调用方负责日志输出。 + * @param ns NVS 命名空间字符串(例如 "nvs.net80211"、"phy")。 + * @return ESP_OK 成功;否则返回 nvs_open 的错误码。 + */ +static esp_err_t clearNvsNamespace(const char *ns) +{ + nvs_handle_t h; + esp_err_t err = nvs_open(ns, NVS_READWRITE, &h); + if (err != ESP_OK) { + return err; + } + nvs_erase_all(h); + nvs_commit(h); + nvs_close(h); + return ESP_OK; +} + // ============================================================ /** @@ -45,10 +67,6 @@ WifiOta& WifiOta::instance() return inst; } -/** - * @brief 预先声明:为 STA 模式配置静态 IP、网关和子网掩码。 - */ - // ---------------------------------------- // STA 后台重连任务(避免在系统事件回调中调用 esp_wifi_connect 死锁) // ---------------------------------------- @@ -150,9 +168,6 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base, inst.stateUnlock(); // ★ M2: 推送 WiFi 连接状态给 HealthMonitor(解耦监控层主动查询) HealthMonitor::instance().setWifiConnected(true); - if (inst.m_staConnectedSem) { - xSemaphoreGive(inst.m_staConnectedSem); - } break; case WIFI_EVENT_STA_DISCONNECTED: { @@ -298,8 +313,6 @@ esp_err_t WifiOta::initSta() ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); - setStaticIp(); - // ★ 冷启动时在 wifi_start 前增加延迟 // 长时间断电后首次上电,WiFi PHY 校准需要额外时间 // esp_wifi_start() 内部会触发 PHY 初始化和校准 @@ -325,8 +338,8 @@ esp_err_t WifiOta::initSta() */ esp_err_t WifiOta::waitStaConnected(char *ip_str, size_t ip_len) { - SemaphoreHandle_t wait_sem = WIFI_STA_USE_STATIC_IP ? WifiOta::instance().m_staConnectedSem : WifiOta::instance().m_staGotIpSem; - const char *wait_name = WIFI_STA_USE_STATIC_IP ? "STA_CONNECTED" : "STA_GOT_IP"; + SemaphoreHandle_t wait_sem = WifiOta::instance().m_staGotIpSem; + const char *wait_name = "STA_GOT_IP"; if (wait_sem == NULL) { DEBUG_ERR("WIFI", "%s 信号量未创建", wait_name); @@ -368,37 +381,6 @@ esp_err_t WifiOta::waitStaConnected(char *ip_str, size_t ip_len) return ESP_ERR_TIMEOUT; } -/** - * @brief 设置 STA 静态 IP - */ -void WifiOta::setStaticIp() -{ - if (WIFI_STA_USE_STATIC_IP) { - esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - if (sta_netif) { - DEBUG_INFO("WIFI", "STA 使用静态 IP 模式,停止 DHCP Client"); - ESP_ERROR_CHECK(esp_netif_dhcpc_stop(sta_netif)); - - esp_netif_ip_info_t info; - memset(&info, 0, sizeof(info)); - inet_pton(AF_INET, WIFI_STA_STATIC_IP, &info.ip); - inet_pton(AF_INET, WIFI_STA_STATIC_GW, &info.gw); - inet_pton(AF_INET, WIFI_STA_STATIC_NETMASK, &info.netmask); - ESP_ERROR_CHECK(esp_netif_set_ip_info(sta_netif, &info)); - DEBUG_INFO("WIFI", "静态 IP 已设置: %s", WIFI_STA_STATIC_IP); - - // 设置 DNS - esp_netif_dns_info_t dns_info; - memset(&dns_info, 0, sizeof(dns_info)); - inet_pton(AF_INET, WIFI_STA_STATIC_DNS, &dns_info.ip.u_addr.ip4.addr); - ESP_ERROR_CHECK(esp_netif_set_dns_info(sta_netif, ESP_NETIF_DNS_MAIN, &dns_info)); - DEBUG_INFO("WIFI", "DNS 已设置: %s", WIFI_STA_STATIC_DNS); - } - } else { - DEBUG_INFO("WIFI", "使用 DHCP 获取 IP"); - } -} - // ---------------------------------------- // ESP32 OTA 内部函数 // 注:begin/write/finalize/abort 已抽离到 Esp32OtaCore(main/ota/esp32_ota_core.{h,cpp}) @@ -587,23 +569,16 @@ bool WifiOta::earlyInit() // ★ 冷启动时强制清除 WiFi NVS 缓存,避免残留脏数据 // 长时间断电后 NVS 中的 WiFi 配置缓存可能不一致 if (WifiOta::instance().m_coldBoot) { - nvs_handle_t nvs_handle; - err = nvs_open("nvs.net80211", NVS_READWRITE, &nvs_handle); + // 清除 WiFi 驱动内部 NVS 缓存键 + err = clearNvsNamespace("nvs.net80211"); if (err == ESP_OK) { - // 清除 WiFi 驱动内部 NVS 缓存键 - nvs_erase_all(nvs_handle); - nvs_commit(nvs_handle); - nvs_close(nvs_handle); DEBUG_DBG("WIFI", "冷启动: 已清除 WiFi NVS 缓存 (nvs.net80211)"); } else { DEBUG_INFO("WIFI", "冷启动: 无 WiFi NVS 缓存分区,跳过清除"); } // 同样清除 PHY 校准数据分区,强制下次全量校准 - err = nvs_open("phy", NVS_READWRITE, &nvs_handle); + err = clearNvsNamespace("phy"); if (err == ESP_OK) { - nvs_erase_all(nvs_handle); - nvs_commit(nvs_handle); - nvs_close(nvs_handle); DEBUG_DBG("WIFI", "冷启动: 已清除 PHY 校准缓存,将重新校准"); } } @@ -620,9 +595,8 @@ bool WifiOta::earlyInit() } } - WifiOta::instance().m_staConnectedSem = xSemaphoreCreateBinary(); WifiOta::instance().m_staGotIpSem = xSemaphoreCreateBinary(); - if (!WifiOta::instance().m_staConnectedSem || !WifiOta::instance().m_staGotIpSem) { + if (!WifiOta::instance().m_staGotIpSem) { DEBUG_ERR("WIFI", "创建信号量失败"); } diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 9200487..2ae4320 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -80,7 +80,6 @@ public: // ── 初始化/运行时状态(仅 OTA 任务访问,无需额外锁)── ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP; ///< 当前 WiFi 工作模式 bool m_coldBoot = false; ///< 是否为冷启动(首次上电) - SemaphoreHandle_t m_staConnectedSem = NULL; ///< STA 连接成功信号量 SemaphoreHandle_t m_staGotIpSem = NULL; ///< STA 获取 IP 信号量 TaskHandle_t m_reconnectTask = nullptr; ///< STA 后台重连任务句柄 @@ -117,10 +116,6 @@ private: * @return ESP_OK 成功,其他失败。 */ esp_err_t initSta(); - /** - * @brief 设置 STA 静态 IP、网关和子网掩码。 - */ - void setStaticIp(); /** * @brief WiFi/IP 事件回调,处理连接、断开和 IP 获取。 * @param arg 回调用户参数。 diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 50d684b..d6221d2 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -106,12 +106,6 @@ bool SpiMaster::pushEvent(uint8_t type, const uint8_t *payload, size_t len) } m_eventHead = (uint8_t)((m_eventHead + 1U) % kEventSlots); m_eventCount++; - /* 兼容单槽 takeRingRx */ - m_ringRxType = type; - m_ringRxLen = slot.len; - if (slot.len > 0U) { - memcpy(m_ringRxBuf, slot.data, slot.len); - } if (m_eventSem) { (void)xSemaphoreGive(m_eventSem); } @@ -574,14 +568,7 @@ esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_ v2_header_t hdr; const uint8_t *pload = nullptr; if (!v2_parse_frame(out_frame, budget, &hdr, &pload)) { - bool all_ff = true; - for (size_t i = 0; i < 8U && i < budget; ++i) { - if (out_frame[i] != 0xFFU) { - all_ff = false; - break; - } - } - return all_ff ? ESP_ERR_INVALID_RESPONSE : ESP_ERR_INVALID_RESPONSE; + return ESP_ERR_INVALID_RESPONSE; } const size_t total = V2_HEADER_LEN + (size_t)hdr.payload_len + V2_CRC_LEN; @@ -1162,18 +1149,6 @@ bool SpiMaster::takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_ } return true; } - if (m_ringRxLen != 0U) { - /* 兼容单槽回落 */ - if (type) { *type = m_ringRxType; } - size_t copy_len = (m_ringRxLen <= max_len) ? m_ringRxLen : max_len; - if (len) { *len = copy_len; } - if (payload && copy_len > 0U) { - memcpy(payload, m_ringRxBuf, copy_len); - } - m_ringRxLen = 0U; - m_ringRxType = 0U; - return true; - } } /* 队列空:若 timeout_ms==0 直接返回,否则在信号量上等待 */ diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index ddb6e81..4c160e8 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -271,11 +271,6 @@ private: /** 事件计数信号量(worker push / takeEvent pop)。 */ SemaphoreHandle_t m_eventSem = NULL; - /** GD32 按铃请求帧缓冲区(兼容 takeRingRx 单槽)。 */ - uint8_t m_ringRxBuf[SPI_OTA_FRAME_MAX] = {}; - size_t m_ringRxLen = 0; - uint8_t m_ringRxType = 0; - // ── L4 多槽事件队列(worker 写入,业务 takeEvent 读出)── static constexpr uint8_t kEventSlots = 4U; struct EventSlot { diff --git a/tools/ble_ota.js b/tools/ble_ota.js index 2d271e3..f486241 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -1088,7 +1088,11 @@ async function syncSelectedFileFromInput() { // ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间 // 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。 + // ★ 必须同时拦截 WiFi 通道上传 (app.js 全局 isUploading):focus 事件不分通道, + // WiFi 刷写时点击网页/切标签回来会触发本函数 → finalizeFileInfo → setProgress(0), + // 把进度"闪一下"归零,直到下一次 XHR progress 事件才恢复。 if (state.uploading || state.autoFlashing) return; + if (typeof isUploading !== 'undefined' && isUploading) return; // FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空 if (state.selectedFileHandle) return; const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null; @@ -1628,6 +1632,11 @@ bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D'); return; } + // ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度 + if (typeof isUploading !== 'undefined' && isUploading) { + bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D'); + return; + } if (file) { if (handle) state.selectedFileHandle = handle; await updateFileInfo(file); diff --git a/tools/index.html b/tools/index.html index dae14be..d8c1a73 100644 --- a/tools/index.html +++ b/tools/index.html @@ -456,6 +456,16 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { }; window.setConsoleMode = function (mode) { + // ★ 刷写进行中禁止切换连接模式:setConsoleMode → syncBleFileFromSelectedTarget → + // notifyFileChange → updateFileInfo → finalizeFileInfo → setProgress(0) 会覆盖 + // 进行中的 WiFi/BLE 上传进度(与 selectFlashTarget 同样的守卫策略)。 + var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading(); + if (window.isUploading || bleBusy) { + var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase()); + if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换连接模式'); + if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error'); + return; + } var isBle = mode === 'ble'; window.connectionMode = isBle ? 'ble' : 'wifi'; var wifiCards = document.getElementById('wifiModeCards'); @@ -672,6 +682,6 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { applySharedModeUi(); })(); - +