diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..906f61f --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev-server", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "8080"], + "port": 8080, + "autoPort": true + }, + { + "name": "tools-server", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "8080", "--directory", "tools"], + "port": 8080, + "autoPort": true + } + ] +} diff --git a/README.md b/README.md index 2e5d4d0..c32733b 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ ESP32_COMM/ |------|------|------| | 1 | `begin(total_size)` | 选择非当前 OTA 分区,获取全局互斥,`esp_ota_begin` | | 2 | `write(data, len)` | 循环调用 `esp_ota_write`(WiFi 通过 HTTP 流,BLE 通过 GATT 块) | -| 3 | `finalize(sha_hex, reboot)` | `esp_ota_end` → 从分区读回计算 SHA-256 → 比对前端 SHA → `esp_ota_set_boot_partition` → 可选重启 | +| 3 | `finalize(sha_hex, reboot)` | 从分区读回计算 SHA-256 → 比对前端 SHA → `esp_ota_end` → `esp_ota_set_boot_partition` → 可选重启 | | - | `abort()` | 中止并清理(释放 mutex + 复位状态) | **SHA-256 校验**: 固件写入完成后从 OTA 分区读回计算,不做增量 SHA。前端与设备端比对一致才 set_boot,否则返回 `ESP_ERR_INVALID_VERSION`。 @@ -272,7 +272,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器) - **ACK 窗口**: 每 32 包等待一次 DATA_OUT ACK(带累计字节数验证) - **窗口重传**: ACK 超时或字节数不足时重发整个窗口(最多 3 次) -- **ESP32 收尾**: BLE 传输完成后由前端发 `0xF8 + 32B 文件 SHA-256`,设备端创建异步任务执行 finalize(含 SHA 比对),结果通过 FINISH 事件通知前端 +- **ESP32 收尾**: BLE 传输完成后由前端发 `0xF8 + 32B 文件 SHA-256`,设备端读回 OTA 分区做 SHA 比对,通过后完成 `esp_ota_end`、设置启动分区并重启;FINISH 仅上报完成/重启/错误 - **GD32 触发**: 传输完成后自动发 `0xF7` 触发 SPI 中继 - **合并包**: NimBLE 可能将多个 writeWithoutResponse 合并到同一 mbuf,接收端支持一次解析多个数据包 diff --git a/docs/CODE_REVIEW_REPORT_2026-06-13.html b/docs/CODE_REVIEW_REPORT_2026-06-13.html deleted file mode 100644 index f55735d..0000000 --- a/docs/CODE_REVIEW_REPORT_2026-06-13.html +++ /dev/null @@ -1,970 +0,0 @@ - - - - - -ESP32_COMM 高风险代码评审报告 - - - - -
- - -
-

ESP32_COMM 高风险代码评审报告

-
多维度并行审查 + 对抗性验证 · 4维度 × 33项确认发现
-
- 📋 项目: ESP32_COMM (ESP32-C3 OTA Relay) - 📅 日期: 2026-06-13 - 🔌 固件版本: v0.3.12 - 👥 审查Agent: 4 Review + 36 Verify + 1 Synthesize -
-
- - -
-
-
33
-
确认发现
-
-
-
8
-
CRITICAL
-
-
-
18
-
HIGH
-
-
-
4
-
MEDIUM
-
-
-
33/36
-
验证通过率
-
-
-
2
-
已驳回(误报)
-
-
- - -
-
📊 各维度发现分布
-
-
-
8
-
-
-
-
-
-
内存安全
-
-
-
10
-
-
-
-
-
-
并发/RTOS
-
-
-
9
-
-
-
-
-
-
OTA/固件
-
-
-
7
-
-
-
-
-
-
SPI/协议
-
-
-
- - -
-
🚨 TOP 3 最危险 Bug
- - -
-
-
-
Finding #1 — CRITICAL
-
SPI 总线无并发保护 — 多任务竞争导致协议损坏
-
-
- CRITICAL - 并发竞态 -
-
-
-
- 文件: - spi_master.h:73-142, ota_relay.cpp, wifi_ota.cpp - 维度: - 并发/RTOS - 验证: - ✔ HIGH置信度 — 源码确认 -
-

SpiMaster 是单例但没有任何互斥锁。多个 FreeRTOS 任务直接调用 SPI 方法:ota_relay 任务 (优先级 MAX-2)、wifi_ota 任务 (MAX-3)、health_monitor 任务 (MAX-4)。协议操作是多步非原子的(如 otaTransfer = 发送帧 + 轮询响应,CS在两次传输之间释放),高优先级任务可在协议序列中间抢占。

-
// spi_master.h — 无任何mutex成员 -class SpiMaster { -private: - spi_device_handle_t m_spiDevice = NULL; // ← 唯一成员,无锁 -};
-
-
⚠ 影响
- 帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。 -
-
-
🔧 修复建议
- 在 SpiMaster 中添加 SemaphoreHandle_t m_mutex,所有公共方法入口加锁。对于多步协议操作(otaTransfer等),使用统一的锁粒度保护整个请求-响应序列。 -
-
-
- - -
-
-
-
Finding #2 — CRITICAL
-
硬编码占位密钥 — 认证体系形同虚设
-
-
- CRITICAL - 安全/OTA -
-
-
-
- 文件: - app_config.h:107-115 - 维度: - OTA/固件完整性 - 验证: - ✔ HIGH置信度 — 源码确认 -
-

MASTER_KEY 使用 NIST AES 测试向量,SCRAMBLE_CONST 使用简单递增序列,GD32_CHIP_UID 使用 0x00-0x0B。注释标注"需填入实际密钥"但从未替换。

-
// ⚠️ 请填入实际密钥 ← 注释说"需填入"但从未填入 -inline constexpr uint8_t MASTER_KEY[] = { - 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, // ← NIST AES测试向量! - 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 -}; -inline constexpr uint8_t GD32_CHIP_UID[] = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, // ← 0x00-0x0B - 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B -};
-
-
⚠ 影响
- 攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。 -
-
-
🔧 修复建议
- 从安全存储(NVS/efuse)读取密钥;移除所有硬编码占位值;添加编译期断言确保非测试密钥;MASTER_KEY和SCRAMBLE_CONST应在首次设备配准时由安全随机数生成。 -
-
-
- - -
-
-
-
Finding #3 — CRITICAL
-
Flash 写入无对齐检查 — 可能导致固件损坏
-
-
- CRITICAL - 内存安全/OTA -
-
-
-
- 文件: - ota_relay.cpp:308-320, wifi_ota.cpp:711/740, ble_ota.cpp:575 - 维度: - 内存安全 + OTA完整性 - 验证: - ✔ HIGH置信度 — 源码确认 -
-
esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len) -{ - // ... 无对齐检查 ... - return esp_partition_write(instance().m_gd32Partition, offset, data, len); // ← offset/len可能非4字节对齐 -}
-

两个调用路径产生非对齐值:(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字节对齐。

-
-
⚠ 影响
- 非对齐写入导致 ESP_ERR_INVALID_ARG 或触发 read-modify-write 异常,GD32固件最后一个chunk损坏或丢失。 -
-
-
🔧 修复建议
- flashWriteChunk 内部做4字节对齐对齐(不足部分 read-modify-write),或在上层确保只传对齐参数。 -
-
-
-
- - -
-
🔴 其他 CRITICAL 级发现
- -
-
-
-
Finding #4 — CRITICAL
-
BLE/WiFi 双通道 OTA 同时触发 — ESP32 OTA状态冲突
-
-
CRITICAL
-
-
-
- 文件:wifi_ota.h:42-46, ble_ota.h - 验证:✔ HIGH置信度 -
-

BleOta 和 WifiOta 各自维护独立的 m_esp32OtaActive、m_esp32OtaHandle、m_esp32UpdatePartition,但底层 esp_ota_begin/write/end 操作的是同一个OTA分区。两个通道同时触发时:第二个 esp_ota_begin 会失败或覆盖句柄;esp_ota_write 可能交错写入同一分区;导致固件损坏+变砖。

-
🔧 修复建议
添加全局 OTA 互斥标志,同一时刻只允许一个OTA源(BLE或WiFi)。
-
-
- -
-
-
-
Finding #5 — CRITICAL
-
SPI 协议帧仅用 XOR 伪校验 — 数据损坏不可检测
-
-
CRITICAL
-
-
-
- 文件:spi_master.cpp:134-141 - 验证:✔ HIGH置信度 -
-
uint8_t SpiMaster::otaCrc8(const uint8_t *data, size_t len) { - uint8_t crc = 0; - for (size_t i = 0; i < len; ++i) - crc ^= data[i]; // ← 简单XOR,非CRC8 - return crc; -}
-

XOR校验无法检测:偶数个相同位置的位翻转、字节交换、全零段丢失。固件数据的静默损坏几乎不可检测。

-
🔧 修复建议
替换为 CRC-8/MAXIM 或 CRC-16/CCITT,与GD32团队协商。
-
-
- -
-
-
-
Finding #6 — CRITICAL
-
SPI_OTA_SOF 与 SPI_APP_ENTER_BOOT_ACK_OK 值碰撞 (0xA5)
-
-
CRITICAL
-
-
-
- 文件:spi_master.h:21,26 - 验证:✔ HIGH置信度 -
-
#define SPI_OTA_SOF (0xA5) // OTA帧起始标记 -#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5) // APP确认进入Bootloader
-

同一值 0xA5 表示两种完全不同的语义。GD32在OTA窗口回复ACK时,ESP32可能将ACK字节误解析为OTA帧起始,导致协议状态机混乱。

-
🔧 修复建议
修改其中一个常量值,消除歧义。例如将 SPI_APP_ENTER_BOOT_ACK_OK 改为 0xB5。
-
-
- -
-
-
-
Finding #7 — CRITICAL
-
OTA 会话无全局超时 — 可无限挂起
-
-
CRITICAL
-
-
-
- 文件:ota_relay.cpp:76-91,562; ota_relay.h:59 - 验证:✔ HIGH置信度 -
-

waitUntilNotWait() 每次调用有 max_wait_rounds 上限,但整个 forwardFirmware() 流程没有全局超时。GD32 持续返回 WAIT 时,单个 SEND_SIZE阶段可挂起 ~78秒,完整流程可挂起数分钟。OTA_RESULT_ERR_USER_ABORT 已定义但从未使用。

-
🔧 修复建议
在 forwardFirmware() 入口记录开始时间,每个阶段检查全局超时(如5分钟),超时则中止并清理。
-
-
- -
-
-
-
Finding #8 — CRITICAL
-
HTTP CORS 允许所有来源 — 任意网页可触发 OTA
-
-
CRITICAL
-
-
-
- 文件:wifi_ota.cpp:58 - 验证:✔ HIGH置信度 -
-
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
-

任何网页都可以向 ESP32 发起 OTA 请求。结合占位密钥问题,攻击链完整:恶意网页 → 上传伪造固件 → 通过占位密钥认证 → 烧录恶意GD32固件。

-
🔧 修复建议
限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。
-
-
-
- - -
-
🟠 HIGH 级关键发现
-
- 共 18 项 HIGH 级发现,点击展开详情 - -
- - - - - - - - - - - - - - - - - - - - -
#严重度文件问题影响
9HIGHwifi_ota.cpp:121WiFi事件回调中调用 esp_wifi_connect() 重连在系统事件任务中调用可能死锁
10HIGHhealth_monitor.cpphealthState 静态变量无锁跨任务读写竞态导致健康判断错误
11HIGHota_relay.h:80-845个静态变量(relayState等)无锁跨任务访问协议状态不一致
12HIGHwifi_ota.h:33-47WifiOta 所有成员 public 且无锁HTTP回调与WiFi事件回调竞争
13HIGHble_ota.hBleOta 成员无锁,BLE GATT回调与主任务竞争BLE OTA 数据损坏
14HIGHdebug_log.cpp:296log() 栈分配 msg[512] + log_buf[600] = 1112字节低栈任务中栈溢出
15HIGHwifi_ota.cpp:1008spi_send_handler 栈分配 1728+ 字节HTTP server栈仅8KB,接近溢出
16HIGHota_relay.cpp:456sendNonceAndAuthenticate() nonce 仅用 esp_random() 一次认证会话密钥可预测
17HIGHota_relay.h:25ota_meta_t.checksum[32] 从未填充/验证固件完整性无保障
18HIGHwifi_ota.cpp:429-444ESP32 OTA 完成→重启,无回滚验证新固件若无法启动将变砖
19HIGHspi_master.cpp:258syncTransfer/recv 忽略 timeout_ms 参数SPI事务可能无限等待
20HIGHwifi_ota.cpp:599-629esp_ota_write 失败后 break,未调用 esp_ota_abortOTA分区处于不一致状态
21HIGHble_ota.cpp:439-444BLE OTA 帧预留4字节CRC但减去计算不匹配最后几个字节被误截断
22HIGHspi_master.hSPI仅4线,无硬件流控/就绪信号高速传输时GD32来不及处理
-
- - -
-
🏗 架构级隐患
- -
-

① 全局状态泛滥,无封装无同步

-

项目中有 5个静态全局变量 (relayState, relaySeq, relayTotalPackets, gd32AppAlive, gd32Version) 和 大量public成员 (WifiOta/BleOta所有状态),跨越3+个FreeRTOS任务和多个ISR/callback上下文访问,没有任何一个被mutex/atomic/critical section保护。这是系统性问题,不是个别bug。

-
- -
-

② 单通道SPI + 多任务无仲裁

-

SpiMaster 单例是唯一的SPI通信通道,但没有任何仲裁机制。WiFi/BLE/健康监控/OTA中继四个功能模块各自直接调用,协议级操作(请求-响应对)不是原子的。需要从架构层面引入SPI总线仲裁器。

-
- -
-

③ 安全架构不完整

-

认证握手存在但密钥硬编码→形同虚设;固件校验字段存在但未填充→形同虚设;CORS开放→任何人可触发OTA。安全功能"看起来有"但实际上不提供保护,这种"安全假象"比没有安全功能更危险,因为它给了虚假的信心。

-
-
- - -
-
🛠 优先修复顺序
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
优先级修复项工作量紧急度进度
P0添加 SPI 总线互斥锁小极高 — 生产环境必崩
P0替换硬编码密钥为安全存储中极高 — 安全漏洞
P0Flash 写入对齐检查小极高 — 数据损坏
P1添加全局 OTA 超时+中止机制中高 — 设备挂死
P1ESP32 OTA 添加回滚验证中高 — 变砖风险
P1替换 XOR 为 CRC-8/16 校验小高 — 固件损坏
P1消除 0xA5 值碰撞极小高 — 协议混乱
P2添加 OTA 单源互斥 (BLE/WiFi)中中高
P2为所有跨任务状态添加锁/atomic大中高
P2修复 WiFi 事件回调中的重连调用小中
P3修复大栈分配问题小中
P3限制 CORS 来源小中
P3实现 ota_meta_t 校验填充与验证中中
-
- - -
-
✅ 已驳回发现(误报)
- - - - - - - - - - - - - - - - -
原始严重度问题描述驳回原因
CRITICALSPI DMA缓冲区栈分配导致数据损坏/硬FaultESP-IDF 6.0 的 spi_master 驱动内置 setup_dma_priv_buffer() 函数自动检测并对齐DMA缓冲区,栈分配仅导致性能损失,不引发数据损坏
HIGHTOCTOU: relayState 检查后 reset() 无锁虽然模式存在,但当前代码流程中检查和重置在同一线程同步执行,实际竞态窗口极小,风险被高估
-
- - - - -
- - - - - diff --git a/docs/COMMUNICATION_AUDIT_2026-05-05.md b/docs/COMMUNICATION_AUDIT_2026-05-05.md deleted file mode 100644 index dcca2df..0000000 --- a/docs/COMMUNICATION_AUDIT_2026-05-05.md +++ /dev/null @@ -1,74 +0,0 @@ -# ESP32_COMM 通信梳理(2026-05-05) - -## 结论 - -当前“ESP32 ↔ 上位机(tools WebUI)”的主通信方式是 **HTTP**,不是 UDP。 - -### WebUI 实际在用的接口 - -- `GET /system/status`:每 1 秒轮询一次,用于在线状态、WiFi、heap、温度、OTA 状态展示 -- `GET /logs`:每 1 秒轮询一次,用于拉取并清空日志环形缓冲区 -- `POST /can/send`:发送 CAN 请求到 GD32 -- `POST /ota/upload` / `POST /ota/relay` / `POST /ota/update`:OTA 相关 - -## 日志链路 - -所有 `DEBUG_*` 日志目前会并行写入三处: - -1. **UART0** - - 本地串口调试 -2. **UDP 单播** → `DEBUG_LOG_IP:DEBUG_LOG_PORT` - - 可供独立 UDP 调试接收端使用 -3. **内部环形缓冲区** - - 被 `GET /logs` 读取 - - 这是当前 WebUI 日志面板真正使用的通道 - -## 为什么 `{"t":"hb"}` 是多余的 - -之前在 `wifi_ota_task()` 里,每 3 秒会发送一次: - -```json -{"t":"hb"} -``` - -它是通过 `debug_log_send()` 发出的,所以会同时进入: -- UART -- UDP -- `/logs` 对应的环形缓冲区 - -这会导致 WebUI 把它显示成一条普通日志: - -- level: `I` -- tag: `ESP32` -- message: `{"t":"hb"}` - -但 WebUI 本来已经每秒轮询 `/system/status`,完全可以判断设备在线,不需要再靠这种“伪日志心跳”。 - -## 已处理 - -已删除 `wifi_ota_task()` 中每 3 秒发送一次的 `{"t":"hb"}`。 - -## 现在是否还有重复/不必要通信 - -### 对 WebUI 来说 - -有两条并行链路,但用途不同,不算重复: - -- `/system/status`:结构化状态 -- `/logs`:文本日志 - -这两条都保留是合理的。 - -### 对底层日志输出来说 - -日志同时发 UART + UDP + ring buffer,属于“一份日志三路镜像”。 - -这对调试友好,但从“最简设计”角度看,**UDP 在当前 WebUI 场景里不是必需的**。 - -也就是说: - -- **必须保留:** ring buffer + `/logs` -- **建议保留:** UART0 -- **可选保留:** UDP 6666 - -如果后续你想继续精简,可以做“方案2”:把 UDP 也做成可关闭,甚至默认关闭。 diff --git a/docs/DEVELOPMENT_GUIDE.md b/docs/DEVELOPMENT_GUIDE.md deleted file mode 100644 index bac07d1..0000000 --- a/docs/DEVELOPMENT_GUIDE.md +++ /dev/null @@ -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` 标注,不得自行假定。 - -**开发过程中遇到任何问题,请继续尝试解决,不要停止。** diff --git a/docs/test_ble_ota_fix.md b/docs/test_ble_ota_fix.md deleted file mode 100644 index 79bcbda..0000000 --- a/docs/test_ble_ota_fix.md +++ /dev/null @@ -1,316 +0,0 @@ -# BLE OTA 刷写修复测试计划 - -> 对应修复:问题1(偶发丢包检测错误)+ 问题2(通道占用延迟) -> 涉及文件:`ble_ota.h` / `ble_ota.cpp` / `ble_ota.html` - ---- - -## 一、正常流程测试 - -### T-01 标准 ESP32 OTA 全流程(基线回归) - -| 项目 | 说明 | -|------|------| -| 固件大小 | 使用常规固件(~200KB–1MB) | -| 操作步骤 | BLE 连接 → 选择文件 → 一键刷写 | -| 预期结果 | 刷写完成,设备重启,日志无报错 | - -**验证要点:** -- 每个窗口 ACK 字节数与期望完全匹配 -- 日志中出现 `已写 X/2035 包` 进度连续递增 -- 日志出现 `[FINISH] ESP32 OTA 刷写完成,等待重启` + toast 成功提示 -- 设备重启后 `readVersion` 返回新版本号 - -### T-02 标准 GD32 OTA 中继全流程(回归验证) - -| 项目 | 说明 | -|------|------| -| 目标 | GD32(经 ESP32 中继 SPI 转发) | -| 操作步骤 | BLE 连接 → 选择 GD32 固件 → 一键刷写 | -| 预期结果 | ESP32 中继正常,GD32 收到完整固件 | - -**验证要点:** -- ESP32 端去重逻辑仅作用于 `BLE_OTA_CMD_ESP32_DATA`(0x01),不影响 GD32 通道(0x02) -- GD32 窗口 ACK 正常返回 -- 刷写完成后 CTRL 命令触发成功 - -### T-03 尾包非整窗口场景 - -| 项目 | 说明 | -|------|------| -| 条件 | `packetTotal % BLE_OTA_ACK_WINDOW != 0`,如固件 976752 字节 / 480 = 2035 包,最后一个窗口仅 19 包(seq 2016–2034) | -| 预期 | 最后一个窗口的 `endSeq = 2034`,ACK 正常返回 | - -**验证要点:** -- `expectedBytes = Math.min((endSeq+1)*480, totalSize)` 正确截断 -- 尾包 `data_len < BLE_OTA_CHUNK_PAYLOAD` 触发 `isLastPacket` -- 设备创建收尾任务,发送 `[FINISH] 即将执行 esp_ota_end()` - -### T-04 多窗口连续刷写稳定性 - -| 项目 | 说明 | -|------|------| -| 条件 | 固件 > 500KB(至少 16 个窗口) | -| 操作 | 连续执行 3 次完整刷写 | -| 预期 | 每次均成功,无丢包报错 | - -**验证要点:** -- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中正确重置为 0 -- 旧 OTA 会话被先中止(日志出现 `检测到未完成的 ESP32 OTA,会先中止旧会话`) -- 每次刷写的 `m_esp32Received` 从 0 开始累加 - ---- - -## 二、边界条件测试 - -### T-05 固件大小恰好为窗口整数倍 - -| 项目 | 说明 | -|------|------| -| 条件 | 构造固件大小 = N × 32 × 480,如 15360 字节(1 窗口)或 491520 字节(32 窗口) | -| 预期 | 无尾包,最后一个窗口 `endSeq = packetTotal - 1`,`expectedBytes = totalSize` | - -**验证要点:** -- 最后一个包 `data_len == BLE_OTA_CHUNK_PAYLOAD`,不触发 `isLastPacket` -- 完成条件走 `m_esp32Received >= total_size` 分支 - -### T-06 固件极小(单包/小于窗口) - -| 项目 | 说明 | -|------|------| -| 条件 | 固件 < 480 字节(仅 1 包)或 480–15360 字节(1 窗口内) | -| 预期 | 单包即触发尾包检测或窗口 ACK | - -**验证要点:** -- 单包场景:`seq=0` 同时满足 `(seq+1)%32==0` 否、但 `isLastPacket` 是 -- 设备发送 ACK,`m_esp32Received` 等于实际固件大小 -- `expectedBytes` 正确等于 `totalSize` - -### T-07 固件极大(接近分区上限) - -| 项目 | 说明 | -|------|------| -| 条件 | 固件大小接近 OTA 分区 size(如 ~1.5MB) | -| 预期 | 完整刷写成功,约 50+ 个窗口全部通过 | - -**验证要点:** -- `m_esp32ExpectedSeq` 跨窗口持续递增,不会溢出 `uint16_t`(最大值 65535) -- `ble_esp32_ota_begin` 中 `total_size > check_partition->size` 检查仍正确拒绝超大固件 - -### T-08 payloadSize 非默认值 - -| 项目 | 说明 | -|------|------| -| 条件 | 在页面修改 payload 大小为 256 / 400 / 498 | -| 预期 | 包总数自动调整,每窗口 ACK 字节校验正确 | - -**验证要点:** -- 设备端 `BLE_OTA_CHUNK_PAYLOAD = 480` 是固定值 -- 若 Web 端 payload < 480,设备端 `remaining_after_header < BLE_OTA_CHUNK_PAYLOAD + 4` 会走 `data_len = remaining - 4` 分支 -- 若 Web 端 payload > 480,设备端会按 480 解析,剩余数据被当作下一包处理(可能出错) -- **注意:** payload 大小必须与设备端 `BLE_OTA_CHUNK_PAYLOAD` 一致,此为已知约束 - -### T-09 BLE 断开重连后重新刷写 - -| 项目 | 说明 | -|------|------| -| 操作 | 刷写到一半 → 手动断开 BLE → 重新连接 → 再次刷写 | -| 预期 | 新刷写从 `ble_esp32_ota_begin` 重新开始 | - -**验证要点:** -- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中重置为 0 -- 旧 OTA 会话被中止,`m_esp32OtaActive` 被清除 -- 全局互斥 `esp32_ota_mutex_release` 被正确调用 - ---- - -## 三、故障注入测试 - -### T-10 BLE 链路层偶发丢包自动恢复(核心修复验证) - -| 项目 | 说明 | -|------|------| -| 注入方式 | 在 BLE 信号较弱的环境中刷写(距离远/有干扰),或临时将包间延迟移除后快速发送 | -| 预期 | 检测到丢包 → 自动重发窗口 → 恢复成功 | - -**验证要点(日志检查):** -- 出现 `丢包检测: seq=X 丢失Y字节(Z包),第1次重发整个窗口` 警告日志 -- 重发后 ACK 字节数匹配,继续下一窗口 -- 设备端日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y` -- 最终刷写成功,无数据损坏 - -### T-11 单窗口连续 2 次丢包(重试用尽) - -| 项目 | 说明 | -|------|------| -| 注入方式 | 极端信号劣化,使一个窗口连续 3 次发送都有丢包 | -| 预期 | 2 次重试用尽后抛出错误 | - -**验证要点:** -- 日志出现第 1 次、第 2 次重发记录 -- 最终报错:`BLE 丢包检测: seq=X 期望Y字节 实际Z字节 丢失W字节(V包) 已重试2次` -- `uploadFirmware` 返回 `false`,toast 显示失败 -- `state.lastUploadStatus = 'failed'` -- 用户手动重新刷写可成功 - -### T-12 通道占用 → 立即失败(核心修复验证) - -| 项目 | 说明 | -|------|------| -| 注入方式 | 先通过 WiFi 通道启动 ESP32 OTA(占用互斥锁),然后通过 BLE 发起刷写 | -| 预期 | BLE 刷写在 < 1 秒内失败,而非等待 10 秒超时 | - -**验证要点(日志检查):** -- 设备端日志:`ESP32 OTA 被其他通道占用,拒绝 BLE OTA begin` -- Web 端日志按顺序出现: - 1. `[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝` - 2. Toast 弹出 `ESP32 OTA 被其他通道占用,请稍后重试` - 3. `写入失败: 设备端 FINISH 错误: stage=0x83 value=0` - 4. `[AUTO] 一键刷写中止` -- **关键时序**:从 FINISH 通知到写入失败之间 < 500ms(此前需要 10 秒) -- 不应出现 `等待 ACK 超时` 日志 - -### T-13 通道占用后重试成功 - -| 项目 | 说明 | -|------|------| -| 操作 | T-12 失败后 → 等待 WiFi OTA 完成 → 重新 BLE 刷写 | -| 预期 | 互斥锁已释放,BLE 刷写成功 | - -**验证要点:** -- `esp32_ota_mutex_release` 在 WiFi OTA 完成后被调用 -- `g_esp32OtaOccupied` 被清除为 `false` -- BLE 刷写正常走完全流程 - -### T-14 esp_ota_end / set_boot_partition 失败快速中断 - -| 项目 | 说明 | -|------|------| -| 注入方式 | 使用损坏的固件文件(无有效 ESP32 镜像头),使 `esp_ota_end` 返回错误 | -| 预期 | 设备发送 `FINISH stage=0x81`,Web 端立即报错 | - -**验证要点:** -- Web 端收到 FINISH 0x81,toast `ESP32 OTA 刷写失败` -- `ackWaiter` 被 reject(如果此时有等待中的 ACK) -- `uploadFirmware` 快速返回 `false` -- 设备端 `esp_ota_abort` 被调用,互斥锁释放 - -### T-15 刷写过程中用户中止 - -| 项目 | 说明 | -|------|------| -| 操作 | 刷写进行中点击「中止」按钮 | -| 预期 | 当前窗口完成后抛出 `用户中止` 错误 | - -**验证要点:** -- `state.abortRequested = true` 在窗口循环开头被检测 -- `throw new Error('用户中止')` 被触发 -- `finally` 块清理 `ackWaiter`、设置 `state.uploading = false` -- 设备端 OTA 会话在下次刷写时被 `ble_esp32_ota_begin` 清理 - -### T-16 窗口末尾丢包恢复(最常见场景) - -| 项目 | 说明 | -|------|------| -| 场景 | 窗口 0(seq 0–31),其中 seq=31 在首次发送时丢失 | -| 预期 | 重发整个窗口后,seq=0–30 被跳过(重复包),seq=31 写入成功 | - -**验证要点:** -- 首次发送:seq=0–30 写入成功,`m_esp32ExpectedSeq = 31` -- seq=31 丢失,设备未收到,不发送 ACK(因为 ACK 触发条件在 seq=31) -- 主机 waitForAck 超时 → 触发窗口重发 -- 重发时 seq=0 到 seq=30:`seq < m_esp32ExpectedSeq(31)` → 重复包跳过 -- 重发时 seq=31:`seq == expectedSeq(31)` → 写入成功,`expectedSeq` 变为 32 -- 触发 ACK,`m_esp32Received = 32 × 480 = 15360`,匹配期望值 -- 设备日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y` - -### T-17 窗口中间丢包恢复(严格顺序检测验证) - -| 项目 | 说明 | -|------|------| -| 场景 | 窗口 0(seq 0–31),其中 seq=15 在首次发送时丢失,seq=16–31 正常到达 | -| 预期 | 乱序包被拒绝,重发后缺失包被填补 | - -**验证要点:** -- 首次发送:seq=0–14 写入成功,`m_esp32ExpectedSeq = 15` -- seq=15 丢失 -- seq=16 到达:`seq(16) > expectedSeq(15)` → **乱序包跳过**,设备日志出现 `ESP32 OTA 乱序包,跳过: seq=16 expected=15 (前序包丢失)` -- seq=17–31 同样被跳过(乱序) -- seq=31 被跳过但仍触发 ACK(因 `(seq+1)%32==0`),ACK 回报 `m_esp32Received = 15 × 480 = 7200` -- 主机检测到 7200 < 15360,触发重发 -- 重发:seq=0–14 跳过(重复),seq=15 写入(`expectedSeq` 变为 16),seq=16–31 写入 -- 最终 `m_esp32Received = 15360`,匹配期望值 -- 最终刷写成功,固件数据完整 - -### T-18 窗口内多包丢失跨轮次恢复 - -| 项目 | 说明 | -|------|------| -| 场景 | 窗口内 seq=5 和 seq=20 同时丢失 | -| 预期 | 第 1 次重发填补 seq=5,第 2 次重发填补 seq=20 | - -**验证要点:** -- 首次:seq=0–4 写入,seq=5 丢失,seq=6–31 被拒绝(乱序),ACK 报 2400 字节 -- 第 1 次重发:seq=0–4 跳过,seq=5 写入,seq=6–19 写入,seq=20 丢失,seq=21–31 拒绝,ACK 报 9600 字节 -- 第 2 次重发:seq=0–19 跳过,seq=20 写入,seq=21–31 写入,ACK 报 15360 字节 -- 2 次重试内完成(`MAX_WINDOW_RETRIES = 2`),刷写成功 - ---- - -## 四、性能与回归测试 - -### T-19 包间延迟对总刷写时间的影响 - -| 项目 | 说明 | -|------|------| -| 测量 | 记录完整刷写耗时(修改前 vs 修改后) | -| 预期 | 每窗口增加 ~62ms(31 × 2ms),64 窗口约增加 ~4 秒,可接受 | - -**参考计算:** -- 2035 包 ÷ 32 包/窗口 ≈ 64 窗口 -- 每窗口 31 个间隔 × 2ms = 62ms -- 总额外延迟 ≈ 64 × 62ms ≈ 3.97 秒 -- 总刷写时间预计从 ~30 秒增加到 ~34 秒 - -### T-20 连续多次刷写无内存泄漏 - -| 项目 | 说明 | -|------|------| -| 操作 | 连续刷写 5 次(每次完整流程) | -| 预期 | 设备 free heap 无明显下降 | - -**验证要点:** -- `ble_esp32_ota_begin` 中旧会话清理完整(abort + mutex release + 状态重置) -- `m_esp32ExpectedSeq` 每次重置,不会累积 -- 收尾任务(`ble_esp32_finish_and_reboot_task`)正常删除 - -### T-21 刷写完成后版本验证 - -| 项目 | 说明 | -|------|------| -| 操作 | 刷写完成 → 设备重启 → 重新 BLE 连接 → 读取版本号 | -| 预期 | 版本号与新固件匹配 | - -**验证要点:** -- `esp_ota_set_boot_partition` 指向新分区 -- 设备从新分区启动 -- `readVersion` 返回 `version.h` 中定义的版本号 - ---- - -## 五、修改文件清单 - -| 文件 | 修改内容 | -|------|----------| -| `main/server/ble_ota.h` | 新增 `m_esp32ExpectedSeq` 字段,用于跟踪下一个期望的包序号 | -| `main/server/ble_ota.cpp` | `ble_esp32_ota_begin` 重置期望序号;DATA_IN 回调添加严格顺序检测(拒绝重复包 + 拒绝乱序包) | -| `tools/ble_ota.html` | 包间 2ms 延迟;整窗口重传重试(最多 2 次,50ms 重发前等待);FINISH 错误事件立即 reject ackWaiter | - -## 六、测试优先级 - -| 优先级 | 用例 | 说明 | -|--------|------|------| -| P0 | T-01, T-10, T-12, T-16, T-17 | 核心功能回归 + 两个修复的直接验证 + 丢包恢复场景 | -| P1 | T-03, T-09, T-13, T-15, T-18 | 边界场景 + 恢复路径 + 多包丢失 | -| P2 | T-05, T-06, T-07, T-11, T-14 | 极端边界 + 重试耗尽 | -| P3 | T-02, T-04, T-08, T-19, T-20, T-21 | 回归 + 性能 + 长期稳定性 | diff --git a/docs/wifi_cold_boot_fix_plan.html b/docs/wifi_cold_boot_fix_plan.html new file mode 100644 index 0000000..990f006 --- /dev/null +++ b/docs/wifi_cold_boot_fix_plan.html @@ -0,0 +1,1149 @@ + + + + + + ESP32 WiFi 冷启动连接失败排查与修复计划 + + + +
+
+
+
+
ESP32-C3 OTA Relay / WiFi Incident Plan
+

WiFi 冷启动连接失败排查与系统化修复计划

+

+ 面向长时间断电后无法连接 STA 的问题,按“复现证据 → 最小诊断 → P0 修复 → 运行期恢复 → 验证矩阵”的顺序推进,避免一次性大改掩盖真实根因。 +

+
+ 目标:冷启动 STA 稳定联网 + 范围:WiFi / NVS / PHY / Watchdog / SPI mutex + 项目:G:\Robot_v2\DCU\ESP32_COMM +
+
+
+ + +
+ + + +
+
+

根因优先级

+

先处理能直接解释“长时间断电后首连失败”和“失败后不恢复”的路径。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
优先级根因位置影响
P0冷启动擦 PHY 校准 + 仅 500ms 延迟wifi_ota.cpp L1524-L1531, L340-L342全量 PHY 校准可能需要更长时间,反复擦除会让首连更不稳定。
P0重试耗尽标志运行中不恢复wifi_ota.cpp L196-L208m_staRetryExhausted=true 后可能长期阻止后续重连。
P1listen_interval=3 偏短wifi_ota.cpp L324长时间空闲或 AP 策略严格时,STA 更容易被驱逐。
P1无 IP_EVENT_STA_LOST_IP 处理wifi_ota.cpp L228-L242设备可能已经无 IP,但内部仍认为已连接。
P1主循环无 WiFi 断线看门狗wifi_ota.cpp L1679-L1707运行期断线后缺少强制恢复策略。
P2断线原因码未解码wifi_ota.cpp L201日志只有数字,难以区分认证、AP 不存在、握手超时、AP 驱逐。
P2SpiMaster mutex 空指针风险spi_master.cpp L288-L290先 lock 空 mutex 再创建 mutex,属于明确初始化顺序问题。
P2NVS 初始化错误处理不完整wifi_ota.cpp L1492-L1508只处理两个错误码,其他 NVS 异常缺少统一恢复路径。
+
+ +
+
+

复现证据

+

本阶段只补充诊断能力和采集日志,不改变核心连接策略。

+
+
+
+

串口日志必抓字段

+
    +
  • 复位原因: POWER_ON / BROWNOUT / SOFTWARE / 其他
  • +
  • 冷启动 是否成立,是否擦除 WiFi NVS 或 PHY
  • +
  • STA 已启动、STA 已关联到 AP、STA 获取 IP
  • +
  • STA 断开 的 reason code 与可读原因
  • +
  • STA 重试已耗尽 和是否切换 AP
  • +
+
+
+

路由器侧同步检查

+
    +
  • ESP32 MAC 是否出现在关联列表
  • +
  • 是否产生 DHCP 租约,租约 IP 是否变化
  • +
  • AP 日志中是否有 deauth / disassoc / handshake timeout
  • +
  • 路由器冷启动时 WiFi 和 DHCP 服务恢复耗时
  • +
+
+
+
+ +
+
+

实施阶段

+

从可观测性开始,再进入 P0/P1 修复,最后做覆盖测试。

+
+
+
+
01Baseline
+
+

复现与日志基线

+

冷启动抓 5 分钟串口日志,同时记录路由器关联和 DHCP 日志,确认失败发生在关联前、关联后取 IP、还是运行中断线。

+
+ serial_monitor.py + reset reason + router log +
+
+
+
+
02Observe
+
+

增加最小诊断能力

+

解码断线 reason,补充 retry、exhausted、heap、uptime 和 WiFi API 返回值日志。

+
+ wifiReasonToStr + heap + uptime +
+
+
+
+
03P0 Fix
+
+

冷启动与重试耗尽修复

+

冷启动 PHY 延迟提升到 1500ms;移除无条件 PHY 擦除;重试耗尽增加 30s 冷却恢复机制。

+
+ PHY delay + conditional erase + retry cooldown +
+
+
+
+
04P1 Fix
+
+

运行期断线恢复

+

处理 LOST_IP,增大 listen_interval,配置 inactive time,并加入断线 60s 后的 WiFi 看门狗恢复。

+
+ LOST_IP + listen_interval=10 + watchdog +
+
+
+
+
05Verify
+
+

构建与实机验证

+

ESP-IDF v6.0 全量构建,执行冷启动、路由器晚启动、DHCP 变化、路由器重启、弱信号与 BLE OTA 后恢复测试。

+
+ idf.py build + cold boot x5 + router restart x3 +
+
+
+
+
+ +
+
+

任务清单

+

勾选状态保存在当前浏览器 localStorage,便于多次打开继续跟踪。

+
+
+ + +
+
+ + + + + + + + +
+
+ +
+
+

验证矩阵

+

每个场景至少保留串口日志;涉及路由器的场景同步保留 AP/DHCP 日志。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
场景操作通过标准
长时间断电后冷启动断电足够久后上电,抓 5 分钟日志,重复 5 次。STA 能连接并获取 IP,不依赖手动重启。
路由器晚启动 60-120s先启动 ESP32,延迟启动路由器或 WiFi 服务。首次失败后仍能自动恢复 STA。
DHCP 服务慢或租约变化缩短 DHCP 租约或重启 DHCP 服务。LOST_IP 后标记断线并重新获取 IP。
路由器重启ESP32 在线时重启路由器,重复 3 次。断线后自动恢复,HTTP 服务重新可访问。
WiFi 密码错误临时配置错误密码或错误 SSID。reason 可读,不高频无限重试。
弱信号环境拉远距离或降低 AP 发射功率。RSSI、断线原因、恢复动作均可观察。
BLE OTA 后 WiFi执行 BLE OTA 流程后访问 WiFi HTTP API。WiFi 省电模式恢复为 WIFI_PS_NONE。
SPI 初始化启动后执行 SPI/CAN 相关 API 或中继流程。无 mutex NULL 风险,无初始化异常。
+
+ +
+
+

风险与缓解

+

修复目标是提高恢复能力,同时避免掩盖配置错误或产生高频重试。

+
+
+
+

启动速度变慢

+

PHY 延迟仅冷启动生效,热启动不受影响。验证时记录冷启动总耗时。

+
+
+

无限重连循环

+

用 30s 冷却期和看门狗分级恢复控制频率,并保留错误 reason。

+
+
+

HTTP 连接中断

+

stop/start 仅在断线 60s+ 且多次强制重连失败后执行,此时 HTTP 已不可用。

+
+
+

功耗略增

+

设备为有线供电,listen_interval=10 与 inactive time 的稳定性收益优先。

+
+
+
+ +
+
+

执行命令

+

根据本机 ESP-IDF 环境调整串口号和 Python/IDF 命令入口。

+
+
+
# 冷启动串口日志采集
+python serial_monitor.py
+
+# 预期输出文件
+G:\Robot_v2\DCU\ESP32_COMM\serial_log.txt
+
# ESP-IDF v6.0 构建
+idf.py build
+
+# 可选:清理后构建
+idf.py fullclean
+idf.py build
+
+
+ +
+
+

决策记录

+

以下备选方案暂不采用,除非后续日志推翻当前判断。

+
+
+
+
×
+
+ 保留无条件 PHY 擦除 + PHY 校准数据是环境相关积累数据,保留通常比每次冷启动擦除更可靠。 +
+
+
+
×
+
+ 只增加 WIFI_STA_MAX_RETRY + 单纯增加次数不能解决耗尽后恢复、运行期断线、LOST_IP 等状态闭环问题。 +
+
+
+
×
+
+ 优先优化 DHCP 超时 + 当前总等待 30s 已覆盖多数 DHCP 慢场景,需先用日志确认是否真的卡在 DHCP。 +
+
+
+
✓
+
+ 先增强诊断再修复 + reason 解码和状态日志能避免把认证、信道、供电、DHCP 问题混在一起处理。 +
+
+
+
+ + +
+ + + + diff --git a/main/include/app_config.h b/main/include/app_config.h index 4a072e2..09de0bf 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -1,4 +1,4 @@ -/* +/* * app_config.h - 统一配置文件(C++ 版本) * ESP32-C3 OTA 中继项目 * @@ -102,6 +102,8 @@ 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; inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; +// Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW) +inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32; // ============================================================ // 安全常量(需与 GD32 固件团队确认) @@ -112,10 +114,8 @@ 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; diff --git a/main/include/version.h b/main/include/version.h index ccf027f..8195509 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -8,7 +8,7 @@ #define FW_VERSION_STRING "0.3.12" // 编译时间 -#define BUILD_TIME "2026-06-14 23:55:14" +#define BUILD_TIME "2026-06-16 23:01:18" // Git 信息 -#define GIT_HASH "unknown" +#define GIT_HASH "2875ef2" diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index 6ba112d..02d6ff7 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -3,7 +3,7 @@ * * 设计依据 WiFi 原流程(wifi_ota.cpp 旧实现),修复已知 bug: * - 旧 wifi_ota.cpp:625 在 esp_ota_end 成功后又调 esp_ota_abort → 用 m_ended 标志避免 - * - SHA 统一从分区读回计算,与 BLE 增量计算方式统一为本风格 + * - SHA 统一从分区读回计算,不在传输过程中做增量 SHA */ #include #include @@ -54,7 +54,14 @@ void Esp32OtaCore::resetState() */ esp_err_t Esp32OtaCore::begin(size_t total_size) { - // 若本核心已有未完成会话,先中止并释放全局互斥 + // ★ 全局互斥:必须在清理旧会话之前检查, + // 否则 WiFi 的 begin() 会先释放 BLE 持有的锁,再自己获取,导致互斥失效 + if (!esp32_ota_mutex_acquire(1000)) { + DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin"); + return ESP_ERR_INVALID_STATE; + } + + // 若本核心已有未完成会话(同一通道的重试),先中止并释放全局互斥 if (m_active) { DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA 会话,先中止旧会话"); if (!m_ended) { @@ -62,12 +69,11 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) } esp32_ota_mutex_release(); resetState(); - } - - // ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用 - if (!esp32_ota_mutex_acquire(1000)) { - DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 begin"); - return ESP_ERR_INVALID_STATE; + // 重新获取全局互斥(刚刚释放了) + if (!esp32_ota_mutex_acquire(1000)) { + DEBUG_ERR(TAG, "ESP32 OTA 重新获取互斥失败"); + return ESP_ERR_INVALID_STATE; + } } const esp_partition_t *boot_partition = esp_ota_get_running_partition(); @@ -100,8 +106,10 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) return ESP_FAIL; } - // 统一传 total_size 给 esp_ota_begin(WiFi 风格) - esp_err_t err = esp_ota_begin(m_partition, total_size, &m_handle); + // ★ 必须传分区大小,而非固件实际大小 + // esp_ota_begin 用 size 参数决定擦除多少 flash 扇区 + // 传固件大小只擦部分扇区,未擦除区域可能残留脏数据,导致 esp_ota_end 校验失败 + esp_err_t err = esp_ota_begin(m_partition, m_partition->size, &m_handle); if (err != ESP_OK) { DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err)); m_partition = nullptr; @@ -219,7 +227,7 @@ esp_err_t Esp32OtaCore::computeDeviceSha(char *out_device_sha_hex) } /** - * @brief 收尾 OTA 会话:end、校验 SHA、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 缓冲区。 @@ -233,26 +241,14 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, return ESP_ERR_INVALID_STATE; } - // ── esp_ota_end ── - DEBUG_INFO(TAG, "[FINALIZE] esp_ota_end..."); - esp_err_t err = esp_ota_end(m_handle); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "esp_ota_end 失败: %s", esp_err_to_name(err)); - // end 失败时仍可 abort(未真正 end) - esp_ota_abort(m_handle); - m_ended = false; - esp32_ota_mutex_release(); - resetState(); - return err; - } - m_ended = true; // ★ end 成功,后续不得再 abort - DEBUG_INFO(TAG, "[FINALIZE] esp_ota_end 完成"); - - // ── SHA-256 校验(若前端提供)── + // ── 先读回分区计算 SHA-256,并与前端文件 SHA 比对 ── if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) { + DEBUG_INFO(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)); + esp_ota_abort(m_handle); + m_ended = false; esp32_ota_mutex_release(); resetState(); return sha_err; @@ -263,11 +259,11 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02x", m_sha256[i]); } - bool match = true; + 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])) { - match = false; + sha_match = false; break; } } @@ -277,16 +273,19 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, out_device_sha_hex[64] = '\0'; } - if (!match) { - DEBUG_ERR(TAG, "SHA-256 不匹配! frontend=%s device=%s,中止", + if (sha_match) { + DEBUG_INFO(TAG, "SHA-256 校验通过"); + } else { + DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s", frontend_sha_hex, device_hex); + esp_ota_abort(m_handle); + m_ended = false; esp32_ota_mutex_release(); resetState(); - return ESP_ERR_INVALID_VERSION; // 调用方据此返回错误给前端 + return ESP_ERR_INVALID_VERSION; } - DEBUG_INFO(TAG, "SHA-256 校验通过"); } else if (out_device_sha_hex) { - // 未提供前端 SHA,但调用方仍想拿到设备端 SHA + // 未提供前端 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]); @@ -295,8 +294,20 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, } } + // ── 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) + esp_ota_abort(m_handle); + m_ended = false; + esp32_ota_mutex_release(); + resetState(); + return err; + } + m_ended = true; // ★ end 成功,后续不得再 abort + // ── 设置启动分区 ── - DEBUG_INFO(TAG, "[FINALIZE] esp_ota_set_boot_partition..."); 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)); @@ -304,7 +315,6 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, resetState(); return err; } - DEBUG_INFO(TAG, "[FINALIZE] esp_ota_set_boot_partition 完成"); esp32_ota_mutex_release(); DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received); @@ -313,7 +323,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, // (此处不 reset,以便 BLE finish task 在重启前可查询状态) if (reboot) { - DEBUG_INFO(TAG, "[FINALIZE] 500ms 后重启..."); + DEBUG_INFO(TAG, "500ms 后重启 ESP32"); vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); // 不会返回 @@ -336,3 +346,42 @@ void Esp32OtaCore::abort() resetState(); DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理"); } + +/** + * @brief 强制退出 OTA 状态机并释放互斥锁。 + * + * 用于新通道(WiFi/BLE)发现锁被残留占用时,主动走完整的状态机退出流程: + * 1. 若会话活跃(m_active)→ 走 abort() 完整清理(esp_ota_abort + 释放锁 + 复位状态) + * 2. 若会话不活跃但锁被占(g_esp32OtaOccupied)→ 仅释放残留锁 + * 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 (!g_esp32OtaMutex) return false; + + // 在互斥保护下检查 g_esp32OtaOccupied + if (xSemaphoreTake(g_esp32OtaMutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false; + + bool was_occupied = g_esp32OtaOccupied; + if (was_occupied) { + g_esp32OtaOccupied = false; + } + xSemaphoreGive(g_esp32OtaMutex); + + if (was_occupied) { + DEBUG_WARN(TAG, "强制清理: 释放残留 OTA 锁(无活跃会话)"); + return true; + } + + return false; +} diff --git a/main/ota/esp32_ota_core.h b/main/ota/esp32_ota_core.h index 2439ebd..705c453 100644 --- a/main/ota/esp32_ota_core.h +++ b/main/ota/esp32_ota_core.h @@ -7,7 +7,7 @@ * 统一以 WiFi 流程为基准: * - esp_ota_begin 第二参数传 total_size * - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算) - * - finalize 顺序: esp_ota_end → 比对前端 SHA → esp_ota_set_boot_partition → 可选重启 + * - 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 写入与收尾逻辑完全复用本核心。 @@ -48,7 +48,7 @@ public: */ esp_err_t write(const uint8_t *data, size_t len); - // ── 阶段 3:收尾(end → 校验 SHA → set_boot → 可选重启)── + // ── 阶段 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 @@ -57,7 +57,7 @@ public: // ESP_ERR_INVALID_VERSION SHA 不匹配(已 abort 并清理) // 其他 end/set_boot 失败(已清理) /** - * @brief 收尾 OTA 会话:end、校验 SHA、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 缓冲区。 @@ -84,16 +84,21 @@ public: */ 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 查询是否等待重启。 */ - bool isRebootPending() const { return m_rebootPending; } - /** @brief 设置重启待处理标志。 */ - void setRebootPending(bool v) { m_rebootPending = v; } - private: Esp32OtaCore() = default; @@ -117,7 +122,6 @@ private: uint8_t m_sha256[32] = {0}; bool m_shaComputed = false; bool m_ended = false; // ★ esp_ota_end 成功后置 true,防 abort 误用 - bool m_rebootPending = false; // BLE 收尾任务去重用 }; #endif // ESP32_OTA_CORE_H diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 500a0c1..02dda3c 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -1,4 +1,4 @@ -/* +/* * ota_relay.cpp - OTA 中继核心状态机实现 * T2.3, T2.4, T3.1 - T3.7 * @@ -849,7 +849,7 @@ ota_result_t OtaRelay::sendNonceAndAuthenticate() uint32_t mem_addr = (uint32_t)(uintptr_t)m_nonceServer; uint32_t rand_seed = 0x12345678; - Protocol::scrambleGenerate(timestamp, mem_addr, rand_seed, m_nonceServer); + Protocol::instance().scrambleGenerate(timestamp, mem_addr, rand_seed, m_nonceServer); DEBUG_INFO("OTA", "--- 认证步骤 1: 发送 CHALLENGE ---"); DEBUG_INFO("OTA", "发送 nonce_server (16B), timestamp=0x%08X", timestamp); diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 8d6f885..aa93ae8 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -24,6 +24,7 @@ #include "esp_ota_ops.h" #include "esp_system.h" #include "esp_timer.h" +#include "esp_wifi.h" // NimBLE 协议栈 #include "nimble/nimble_port.h" @@ -39,6 +40,7 @@ #include "app_config.h" #include "ota_mutex.h" #include "ble_ota.h" +#include "wifi_ota.h" #include "esp32_ota_core.h" #include "ota_relay.h" #include "health_monitor.h" @@ -48,15 +50,9 @@ #define BLE_OTA_NOTIFY_ACK 0xA1 #define BLE_OTA_NOTIFY_FINISH 0xB1 #define BLE_OTA_NOTIFY_SHA256 0xC1 // 设备端 SHA-256 上报([0xC1] + [32字节]) -#define BLE_OTA_FINISH_BEGIN_OTA_END 0x01 -#define BLE_OTA_FINISH_DONE_OTA_END 0x02 -#define BLE_OTA_FINISH_BEGIN_SET_BOOT 0x03 -#define BLE_OTA_FINISH_DONE_SET_BOOT 0x04 -#define BLE_OTA_FINISH_BEGIN_DELAY 0x05 -#define BLE_OTA_FINISH_DONE_DELAY 0x06 +#define BLE_OTA_FINISH_DONE 0x02 #define BLE_OTA_FINISH_REBOOT_NOW 0x07 -#define BLE_OTA_FINISH_ERR_OTA_END 0x81 -#define BLE_OTA_FINISH_ERR_SET_BOOT 0x82 +#define BLE_OTA_FINISH_ERR_FINALIZE 0x81 #define BLE_OTA_ACK_WINDOW 32 #define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用 #define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用 @@ -69,6 +65,7 @@ static void ble_ota_start_advertising(void); static void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes); static void sendFinishEvent(uint8_t stage, uint32_t value); +static void esp32_reorder_free(); // ============================================================ /** @@ -89,8 +86,8 @@ static uint16_t s_data_out_val_handle = 0; /** * @brief 异步完成 ESP32 OTA 收尾并触发重启。 * - * 实际的 esp_ota_end / SHA 校验 / esp_ota_set_boot_partition / esp_restart 全部 - * 由 Esp32OtaCore::finalize 完成。本任务仅负责:补发 ACK → 调 finalize → 上报 SHA → 通知前端。 + * 实际的 SHA 校验 / esp_ota_end / esp_ota_set_boot_partition 全部由 + * Esp32OtaCore::finalize 完成。本任务仅负责:补发 ACK → 调 finalize → 上报 SHA → 通知前端。 * @param pvParameters 任务参数,当前未使用。 */ /** @@ -132,17 +129,15 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启"); if (frontend_sha_hex) free(frontend_sha_hex); BleOta::instance().m_esp32RebootPending = false; + esp32_reorder_free(); vTaskDelete(NULL); return; } DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); - // 通知前端:开始 finalize - sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); - // 关键:reboot=false,让 finalize 真正返回、拿到 device_sha_hex, - // 再依次推送 SHA 通知 + FINISH_DONE_OTA_END + FINISH_REBOOT_NOW, + // 再依次推送 SHA 通知 + FINISH_DONE + FINISH_REBOOT_NOW, // 最后延迟重启。若 reboot=true,core 内部直接 esp_restart(), // 下面所有 notifyResponse / sendFinishEvent 都不会执行, // 前端也就拿不到设备端 SHA 与"收尾完成/即将重启"事件。 @@ -167,7 +162,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) vTaskDelay(pdMS_TO_TICKS(150)); // 3) 推送"OTA 收尾完成"与"即将重启"事件 - sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0); + sendFinishEvent(BLE_OTA_FINISH_DONE, 0); sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); // 4) 通知全部发出后延迟 500ms 重启 @@ -179,16 +174,58 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0); BleOta::instance().m_esp32RebootPending = false; + // ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer + esp32_reorder_free(); } else { DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err); BleOta::instance().m_esp32RebootPending = false; + // ★ finalize 内部已 abort 并释放 mutex,这里释放 reorder buffer + esp32_reorder_free(); } if (frontend_sha_hex) free(frontend_sha_hex); vTaskDelete(NULL); } +// ============================================================ +// WiFi 共存优化:BLE OTA 传输期间降低 WiFi 射频占用 +// ============================================================ +static bool s_wifi_ps_boosted = false; + +/** + * @brief BLE OTA 开始时,将 WiFi 切换到最大省电模式,减少射频抢占。 + * WiFi STA 仍保持连接,但信标监听间隔大幅拉长,BLE 获得更多空中时间。 + */ +static void ble_ota_boost_wifi_ps() +{ + if (s_wifi_ps_boosted) return; + esp_err_t err = esp_wifi_set_ps(WIFI_PS_MAX_MODEM); + if (err == ESP_OK) { + s_wifi_ps_boosted = true; + WifiOta::instance().setBleOtaActive(true); // ★ 暂停 WiFi RSSI 轮询 + DEBUG_INFO(TAG, "WiFi 已切换到 MAX_MODEM 省电模式(BLE OTA 传输优化)"); + } else { + DEBUG_WARN(TAG, "WiFi 省电模式切换失败: %s", esp_err_to_name(err)); + } +} + +/** + * @brief BLE OTA 结束/中止时,恢复 WiFi 省电策略为 WIFI_PS_NONE。 + */ +static void ble_ota_restore_wifi_ps() +{ + if (!s_wifi_ps_boosted) return; + esp_err_t err = esp_wifi_set_ps(WIFI_PS_NONE); + if (err == ESP_OK) { + s_wifi_ps_boosted = false; + WifiOta::instance().setBleOtaActive(false); // ★ 恢复 WiFi RSSI 轮询 + DEBUG_INFO(TAG, "WiFi 已恢复 WIFI_PS_NONE 模式"); + } else { + DEBUG_WARN(TAG, "WiFi 省电模式恢复失败: %s", esp_err_to_name(err)); + } +} + // ============================================================ // NimBLE 工具: 从 mbuf 读取原始数据 /** @@ -207,6 +244,273 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len) return (int)copy_len; } +// ============================================================ +// ESP32 OTA 包序号顺序保护:Reorder Buffer(TCP 接收窗口模式) +// +// 背景: +// esp_ota_write 是顺序追加写入,不支持 seek。 +// 客户端窗口内使用 writeValueWithoutResponse(fire-and-forget), +// BLE 空口会导致同一窗口的包乱序到达设备端。 +// +// 策略(窗口级 reorder buffer): +// - seq < expected → 重复包,丢弃 +// - seq == expected → 写入 flash,expected++, +// 并 flush 连续紧邻的缓存包 +// - seq > expected 且在窗口范围内 → 暂存到 slot,不写 flash +// - seq 超出窗口范围 → 防御性丢弃 + ACK 报告缺口 +// +// 与旧"丢弃策略"的区别:乱序包不再丢弃,而是暂存到 buffer, +// 当缺口(expected)的包到达时按序 flush,从而吸收窗口内乱序, +// 彻底消除"重传包与原发包再次乱序"导致的重传风暴。 +// +// 内存:slots 指针/长度数组在 OTA begin 时分配(常驻 ~384B), +// 每个 slot 的 payload 缓冲区按需 lazy malloc(最多 480B), +// flush 后立即 free。OTA 结束/abort 时统一释放。 +// 若 begin 时分配失败 → active=false,自动回退到丢弃策略。 +// +// ACK 协议不变:getReceived() 仅在真正按序写入 flash 时增长, +// 客户端 resumeSeq = ceil(bytes/payloadSize) 仍正确。 +// ============================================================ + +/** + * @brief ESP32 OTA reorder buffer 状态。 + * 窗口级缓存,吸收 BLE 乱序到达的包。 + */ +struct Esp32OtaReorderBuf { + bool active = false; ///< 是否已启用(分配成功) + bool fallback = false; ///< 是否回退到丢弃策略(buffer 分配失败时) + uint32_t expected = 0; ///< 下一个期望按序写入的 seq + uint32_t windowBase = 0; ///< 当前窗口起始 seq(= 上次 flush 后的 expected) + uint32_t windowSize = 0; ///< 窗口大小(= BLE_OTA_REORDER_WINDOW) + uint8_t **slots = nullptr; ///< slots[i]:窗口内第 i 个包的 payload 拷贝(nullptr=未占) + size_t *lens = nullptr; ///< lens[i]:slots[i] 的 payload 长度 + size_t payloadCap = 0; ///< 每个 slot 的容量(= BLE_OTA_CHUNK_PAYLOAD) +}; + +static Esp32OtaReorderBuf s_reorder; +static uint32_t s_fallback_ooo_count = 0; // 回退模式下自上次 ACK 以来的乱序包计数 + +// 前向声明(alloc/free 互相引用) +static void esp32_reorder_free(); + +/** + * @brief 分配 reorder buffer(slots 指针/长度数组)。 + * 在 ble_esp32_ota_begin() 中调用。分配失败则置 fallback=true, + * 后续自动回退到丢弃策略。 + */ +static void esp32_reorder_alloc() +{ + // 已分配则先释放(防御性) + if (s_reorder.slots || s_reorder.lens) { + esp32_reorder_free(); + } + + s_reorder.expected = 0; + s_reorder.windowBase = 0; + s_reorder.windowSize = (uint32_t)BLE_OTA_REORDER_WINDOW; + s_reorder.payloadCap = BLE_OTA_CHUNK_PAYLOAD; + s_reorder.slots = (uint8_t **)calloc(s_reorder.windowSize, sizeof(uint8_t *)); + s_reorder.lens = (size_t *)calloc(s_reorder.windowSize, sizeof(size_t)); + s_fallback_ooo_count = 0; + + if (!s_reorder.slots || !s_reorder.lens) { + DEBUG_WARN(TAG, "Reorder buffer 分配失败 (slots=%p lens=%p),回退到丢弃策略。" + "heap: free=%u min=%u", + s_reorder.slots, s_reorder.lens, + (unsigned)esp_get_free_heap_size(), + (unsigned)esp_get_minimum_free_heap_size()); + // 释放可能部分成功的分配 + free(s_reorder.slots); s_reorder.slots = nullptr; + free(s_reorder.lens); s_reorder.lens = nullptr; + s_reorder.active = false; + s_reorder.fallback = true; + return; + } + s_reorder.active = true; + s_reorder.fallback = false; + DEBUG_INFO(TAG, "Reorder buffer 已启用: window=%u payloadCap=%u heap=%u", + (unsigned)s_reorder.windowSize, (unsigned)s_reorder.payloadCap, + (unsigned)esp_get_free_heap_size()); +} + +/** + * @brief 释放 reorder buffer 全部内存(含已占用的 slot)。 + * 同时复位 expected/windowBase 等状态,供下次 OTA begin 重新分配。 + */ +static void esp32_reorder_free() +{ + if (s_reorder.slots) { + for (uint32_t i = 0; i < s_reorder.windowSize; ++i) { + if (s_reorder.slots[i]) { + free(s_reorder.slots[i]); + s_reorder.slots[i] = nullptr; + } + } + free(s_reorder.slots); + s_reorder.slots = nullptr; + } + if (s_reorder.lens) { + free(s_reorder.lens); + s_reorder.lens = nullptr; + } + s_reorder.expected = 0; + s_reorder.windowBase = 0; + s_reorder.active = false; + s_reorder.fallback = false; + s_reorder.windowSize = 0; + s_reorder.payloadCap = 0; + s_fallback_ooo_count = 0; +} + +/** + * @brief flush expected 对应的连续缓存包到 flash,并释放对应 slot。 + * 由 esp32_write_ordered 在每次 expected 推进后调用。 + * @return flush 的包数(不含当前已写入的包)。 + */ +static uint32_t esp32_reorder_flush() +{ + uint32_t flushed = 0; + while (s_reorder.active) { + uint32_t idx = s_reorder.expected - s_reorder.windowBase; + // idx 应始终 < windowSize(窗口未越界) + if (idx >= s_reorder.windowSize) break; + if (!s_reorder.slots[idx]) break; // 缺口:后续虽有缓存但不连续 + + uint8_t *data = s_reorder.slots[idx]; + size_t len = s_reorder.lens[idx]; + esp_err_t err = Esp32OtaCore::instance().write(data, len); + // 释放 slot + free(s_reorder.slots[idx]); + s_reorder.slots[idx] = nullptr; + s_reorder.lens[idx] = 0; + + if (err != ESP_OK) { + DEBUG_ERR(TAG, "Reorder flush 写入失败: seq=%u err=%s", + (unsigned)s_reorder.expected, esp_err_to_name(err)); + // 写入失败不推进 expected,交由上层 abort + return flushed; + } + s_reorder.expected++; + flushed++; + } + return flushed; +} + +/** + * @brief 按序号顺序写入 ESP32 OTA 数据。 + * + * 启用 reorder buffer 时:吸收窗口内乱序,缺口填满后按序 flush。 + * 回退模式(buffer 分配失败):执行旧丢弃策略(乱序包丢弃 + ACK 报告缺口)。 + * + * @param seq 包序号。 + * @param cmd 数据命令字节(用于 ACK)。 + * @param data 负载数据指针。 + * @param data_len 负载数据长度。 + * @return ESP_OK 写入成功或被安全跳过,其他为失败。 + */ +static esp_err_t esp32_write_ordered(uint16_t seq, uint8_t cmd, + const uint8_t *data, size_t data_len) +{ + // ── 回退模式:旧丢弃策略 ── + if (s_reorder.fallback) { + // 重复包 + if (seq < s_reorder.expected) { + DEBUG_DBG(TAG, "ESP32 重复包: seq=%u < expected=%u,跳过", + seq, (unsigned)s_reorder.expected); + return ESP_OK; + } + // 乱序包:丢弃 + 节流 ACK 报告缺口 + // ★ 节流策略:每 BLE_OTA_ACK_WINDOW 个乱序包才发一次 ACK, + // 或在窗口边界处发 ACK。避免大量乱序包涌入时触发 ACK 风暴, + // 耗尽 NimBLE mbuf 池导致 ESP_ERR_NO_MEM。 + if (seq > s_reorder.expected) { + s_fallback_ooo_count++; + bool shouldAck = + (s_fallback_ooo_count >= BLE_OTA_ACK_WINDOW) || + ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U); + if (shouldAck) { + uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); + DEBUG_WARN(TAG, "ESP32 乱序包跳过(回退): seq=%u (expected=%u),节流 ACK bytes=%u (ooo=%u)", + seq, (unsigned)s_reorder.expected, (unsigned)curReceived, + (unsigned)s_fallback_ooo_count); + sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, + curReceived); + s_fallback_ooo_count = 0; + } else { + DEBUG_DBG(TAG, "ESP32 乱序包跳过(回退): seq=%u (expected=%u),抑制 ACK (ooo=%u)", + seq, (unsigned)s_reorder.expected, + (unsigned)s_fallback_ooo_count); + } + return ESP_OK; + } + // seq == expected:正常写入 + esp_err_t err = Esp32OtaCore::instance().write(data, data_len); + if (err != ESP_OK) return err; + s_reorder.expected++; + return ESP_OK; + } + + // ── Reorder buffer 模式 ── + // 窗口推进检测:若 expected 已超出当前窗口基址,滑动窗口 + if (s_reorder.expected >= s_reorder.windowBase + s_reorder.windowSize) { + s_reorder.windowBase = s_reorder.expected; + } + + // 重复包:seq < expected → 已写入,跳过 + if (seq < s_reorder.expected) { + DEBUG_DBG(TAG, "ESP32 重复包: seq=%u < expected=%u,跳过", + seq, (unsigned)s_reorder.expected); + return ESP_OK; + } + + // seq == expected:正常写入 + flush 连续缓存包 + if (seq == s_reorder.expected) { + esp_err_t err = Esp32OtaCore::instance().write(data, data_len); + if (err != ESP_OK) return err; + s_reorder.expected++; + uint32_t flushed = esp32_reorder_flush(); + if (flushed > 0) { + DEBUG_DBG(TAG, "Reorder flush: seq=%u 后连续写入 %u 包 (expected→%u)", + seq, (unsigned)flushed, (unsigned)s_reorder.expected); + } + return ESP_OK; + } + + // seq > expected:暂存到 slot(若在窗口范围内) + uint32_t idx = seq - s_reorder.windowBase; + if (idx < s_reorder.windowSize) { + if (s_reorder.slots[idx]) { + // 该 slot 已被占用(重复的乱序包),跳过 + DEBUG_DBG(TAG, "Reorder slot 已占: seq=%u idx=%u,跳过重复包", + seq, (unsigned)idx); + return ESP_OK; + } + // data 可能来自静态 buf(ble_ota_data_in_access 的合并缓冲区), + // 后续包处理会覆盖它,必须拷贝一份 + uint8_t *copy = (uint8_t *)malloc(data_len); + if (!copy) { + DEBUG_WARN(TAG, "Reorder slot malloc 失败: seq=%u,丢弃该包(靠客户端重传)", + seq); + return ESP_OK; // 不算失败,靠客户端重传补 + } + memcpy(copy, data, data_len); + s_reorder.slots[idx] = copy; + s_reorder.lens[idx] = data_len; + DEBUG_DBG(TAG, "Reorder 缓存乱序包: seq=%u idx=%u (expected=%u)", + seq, (unsigned)idx, (unsigned)s_reorder.expected); + return ESP_OK; + } + + // seq 超出窗口范围(防御性,理论不会发生) + uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); + DEBUG_WARN(TAG, "ESP32 包超出 reorder 窗口: seq=%u (expected=%u base=%u win=%u),ACK bytes=%u", + seq, (unsigned)s_reorder.expected, (unsigned)s_reorder.windowBase, + (unsigned)s_reorder.windowSize, (unsigned)curReceived); + sendWindowAck(cmd, s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, + curReceived); + return ESP_OK; +} + // ───────────────────────────────────────────────────────────── // ESP32 OTA 薄包装:实际 begin/write/finalize 已抽离到 Esp32OtaCore // 此处仅保留 BLE 侧的状态复位与日志,供 data_in_access 调用。 @@ -221,6 +525,12 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len) static esp_err_t ble_esp32_ota_begin(size_t total_size) { esp_err_t err = Esp32OtaCore::instance().begin(total_size); + if (err == ESP_ERR_INVALID_STATE) { + // ★ OTA 锁被占用,走完整的状态机退出 + 清理锁,然后重试一次 + DEBUG_WARN(TAG, "ESP32 OTA 锁被占用,强制清理状态机后重试"); + Esp32OtaCore::instance().forceCleanup(); + err = Esp32OtaCore::instance().begin(total_size); + } if (err != ESP_OK) { return err; } @@ -228,6 +538,10 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size) BleOta::instance().m_esp32RebootPending = false; BleOta::instance().m_esp32LastCmd = 0; BleOta::instance().m_esp32LastSeq = 0xFFFFU; + // ★ 分配 reorder buffer(失败则自动回退丢弃策略) + esp32_reorder_alloc(); + // ★ BLE OTA 开始:降低 WiFi 射频占用 + ble_ota_boost_wifi_ps(); return ESP_OK; } @@ -238,6 +552,10 @@ static void ble_esp32_ota_abort() { Esp32OtaCore::instance().abort(); BleOta::instance().m_esp32RebootPending = false; + // ★ 释放 reorder buffer + esp32_reorder_free(); + // ★ BLE OTA 中止:恢复 WiFi 射频 + ble_ota_restore_wifi_ps(); } /** @@ -354,14 +672,14 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 if (len < 33) { DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发"); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_ARG); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG); return 0; } char *frontend_sha_hex = (char *)malloc(65); if (!frontend_sha_hex) { DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败"); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_NO_MEM); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_NO_MEM); return 0; } for (int i = 0; i < 32; ++i) { @@ -508,7 +826,27 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, } } - esp_err_t err = Esp32OtaCore::instance().write(&buf[data_offset], data_len); + // ★ 会话有效性检查:如果 begin() 失败(如互斥拒绝),后续包也应丢弃 + if (!Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "ESP32 OTA 会话无效,丢弃 seq=%u", seq); + offset += packet_len; + if (offset + 7 > (size_t)recv_len) break; + continue; + } + + // ★ 防重复写入保护:如果已接收字节 >= 声明大小,跳过(重试引起的重复包) + uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); + if (total_size > 0 && curReceived >= total_size) { + DEBUG_WARN(TAG, "ESP32 OTA 已收满 (%u >= %" PRIu32 "),跳过重复 seq=%u", + (unsigned)curReceived, total_size, seq); + sendWindowAck(cmd, seq, curReceived); + offset += packet_len; + if (offset + 7 > (size_t)recv_len) break; + continue; + } + + // ★ 使用带序号顺序保护的写入:拒绝乱序包并立即 ACK 通知发送端 + esp_err_t err = esp32_write_ordered(seq, cmd, &buf[data_offset], data_len); if (err != ESP_OK) { DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s", seq, esp_err_to_name(err)); @@ -519,26 +857,37 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u", seq, (unsigned int)Esp32OtaCore::instance().getReceived()); - // 尾包检测: 期望 payload < 标准负载说明是真正的最后一包 - // 使用 expected_payload 而非 data_len,避免缓冲区截断导致误触发重启 + // 完成判定:以实际按序写入字节数为准。 + // reorder buffer 模式下,尾包可能是乱序到达被缓存的包, + // 此刻 getReceived() 尚未达到 total_size,不能据此判定完成。 + // isLastPacket 仅记录"这是最后一包的形态",不单独作为完成条件。 bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD); + bool transferComplete = (total_size > 0 && + Esp32OtaCore::instance().getReceived() >= total_size); + // ACK 触发条件: + // - 窗口末尾包 (seq+1 是窗口整数倍) + // - 数据全部按序写完(含 reorder flush 后达到 total_size) + // - 尾包形态(兜底:即使 getReceived 不足也回一个 ACK,让客户端续传) if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || - (total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || + transferComplete || isLastPacket) { - if (isLastPacket) { - DEBUG_INFO(TAG, "ESP32 尾包检测: seq=%u data_len=%zu received=%u total=%" PRIu32, + if (isLastPacket || transferComplete) { + DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32, seq, data_len, (unsigned int)Esp32OtaCore::instance().getReceived(), total_size); } sendWindowAck(cmd, seq, (uint32_t)Esp32OtaCore::instance().getReceived()); } - if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) { - DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", - (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1, - isLastPacket ? " [尾包]" : ""); + // ★ 仅当数据真正全部按序写完时才标记传输完成。 + // 尾包乱序缓存但缺口未填满时,不算完成(等待客户端续传缺口包)。 + if (transferComplete) { + DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)", + (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1); BleOta::instance().m_esp32LastCmd = cmd; BleOta::instance().m_esp32LastSeq = seq; + // ★ ESP32 OTA 传输完成:恢复 WiFi 射频 + ble_ota_restore_wifi_ps(); // 注:与 WiFi 流程对齐,传输完成后由前端发送 0xF8 + SHA-256 触发 finalize, // 设备端不再自动创建收尾任务。 } @@ -567,6 +916,8 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu", meta.total_size, data_len); + // ★ GD32 BLE OTA 开始:降低 WiFi 射频占用 + ble_ota_boost_wifi_ps(); } ota_meta_t meta = {}; @@ -583,6 +934,15 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, // --- GD32 写入 Flash --- uint32_t flash_offset = meta.stored_size; + // ★ 防重复写入保护:如果已存储字节 >= 声明大小,跳过重试引起的重复包 + if (flash_offset >= meta.total_size) { + DEBUG_WARN(TAG, "GD32 OTA 已收满 (%" PRIu32 " >= %" PRIu32 "),跳过重复 seq=%u", + flash_offset, meta.total_size, seq); + sendWindowAck(cmd, seq, flash_offset); + offset += packet_len; + if (offset + 7 > (size_t)recv_len) break; + continue; + } if (flash_offset + (uint32_t)data_len > meta.total_size) { DEBUG_ERR(TAG, "GD32 写入越界: offset=%" PRIu32 " len=%zu total=%" PRIu32, flash_offset, data_len, meta.total_size); @@ -617,6 +977,8 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, meta.stored_size >= meta.total_size) { DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)", meta.stored_size, meta.total_size, seq + 1); + // ★ GD32 BLE OTA 传输完成:恢复 WiFi 射频 + ble_ota_restore_wifi_ps(); // 计算并存储 SHA-256 校验和 esp_err_t sha_err = OtaRelay::metaFinalizeChecksum(); if (sha_err != ESP_OK) { @@ -661,7 +1023,15 @@ static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle, } const char *version = FW_VERSION; - int rc = os_mbuf_append(ctxt->om, version, strlen(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, FW_BUILD_TIME); + 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; } @@ -796,9 +1166,14 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) case BLE_GAP_EVENT_DISCONNECT: DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d", event->disconnect.reason); - /** @brief BLE GATT 客户端连接状态。 */ BleOta::instance().m_bleConnected = false; - BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE; + BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE; + // ★ BLE 断开时恢复 WiFi 射频,并中止未完成的 ESP32 OTA + ble_ota_restore_wifi_ps(); + if (Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "BLE 断开,中止未完成的 ESP32 OTA"); + ble_esp32_ota_abort(); + } // 恢复广播以接受新连接 ble_ota_start_advertising(); return 0; @@ -1125,4 +1500,4 @@ void BleOta::taskEntry(void *pvParameters) vTaskDelay(pdMS_TO_TICKS(10000)); } -} \ No newline at end of file +} diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 025d4d2..a4006da 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -610,6 +610,10 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) DEBUG_INFO("WIFI", "=== HTTP: POST /ota/upload ==="); DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len); + // ★ WiFi OTA 入口:强制恢复 WIFI_PS_NONE,防止 BLE OTA 残留省电模式导致断连 + esp_wifi_set_ps(WIFI_PS_NONE); + WifiOta::instance().setBleOtaActive(false); + // 预读 1 字节判断是 JSON 还是 binary char peek; int peek_ret = httpd_req_recv(req, &peek, 1); @@ -663,6 +667,12 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) Esp32OtaCore &core = Esp32OtaCore::instance(); esp_err_t err = core.begin(total_size); + if (err == ESP_ERR_INVALID_STATE) { + // ★ OTA 锁被占用,走完整的状态机退出 + 清理锁,然后重试一次 + DEBUG_WARN("WIFI", "ESP32 OTA 锁被占用,强制清理状态机后重试"); + core.forceCleanup(); + err = core.begin(total_size); + } if (err != ESP_OK) { if (err == ESP_ERR_INVALID_STATE) { httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); @@ -1672,6 +1682,13 @@ void WifiOta::taskEntry(void *pvParameters) while (1) { tick++; + // ★ BLE OTA 传输期间,暂停 WiFi RSSI 轮询,避免 esp_wifi_sta_get_ap_info() + // 触发射频活动干扰 BLE 连接事件 + if (WifiOta::instance().m_bleOtaActive.load()) { + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + // 每 10 秒检查一次 RSSI if (tick % 10 == 0) { int8_t rssi = WifiOta::getRssi(); diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 0b4e5db..5ffe550 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -97,6 +97,11 @@ public: httpd_handle_t m_httpServer = NULL; + // ★ BLE OTA 传输期间暂停 WiFi 射频活动(减少共存干扰) + std::atomic m_bleOtaActive{false}; + /** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */ + void setBleOtaActive(bool v) { m_bleOtaActive.store(v); } + private: // ── 跨任务共享状态(atomic + m_stateMutex 保护)── std::atomic m_staConnected{false}; diff --git a/main/transport/protocol.cpp b/main/transport/protocol.cpp index c5453e8..b075b6d 100644 --- a/main/transport/protocol.cpp +++ b/main/transport/protocol.cpp @@ -40,6 +40,15 @@ void Protocol::init() DEBUG_INFO("PROT", "=== 初始化协议层 ==="); DEBUG_INFO("PROT", "协议层功能: nonce 扰码 + 会话密钥管理"); + esp_fill_random(m_scrambleConst, SCRAMBLE_CONST_LEN); + + { + char hex[SCRAMBLE_CONST_LEN * 2 + 1] = {0}; + for (size_t i = 0; i < SCRAMBLE_CONST_LEN; ++i) + snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", m_scrambleConst[i]); + DEBUG_INFO("PROT", "SCRAMBLE_CONST (RNG): %s", hex); + } + m_initialized = true; DEBUG_INFO("PROT", "协议层就绪"); } @@ -55,11 +64,11 @@ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, 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] ^= m_scrambleConst[i]; state[i] = (state[i] << 1) | (state[i] >> 7); } } @@ -67,7 +76,7 @@ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, 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); + words[i] ^= m_scrambleConst[i * 4 % 16] | ((uint32_t)m_scrambleConst[i * 4 + 1] << 8); } } @@ -99,4 +108,4 @@ void Protocol::sessionKeyClear(session_ctx_t *sess) memset(sess->iv, 0, 16); sess->used = 0; } -} \ No newline at end of file +} diff --git a/main/transport/protocol.h b/main/transport/protocol.h index c1a7c13..a3ca412 100644 --- a/main/transport/protocol.h +++ b/main/transport/protocol.h @@ -19,7 +19,6 @@ // 安全常量(定义在 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 会话上下文 @@ -55,7 +54,7 @@ public: * @param rand_seed 随机种子。 * @param nonce_out 输出的 16 字节 nonce 缓冲区。 */ - static void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, + void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, uint32_t rand_seed, uint8_t *nonce_out); /** * @brief 会话密钥初始化,将密钥拷贝到会话上下文。 @@ -76,6 +75,7 @@ private: Protocol(const Protocol&) = delete; Protocol& operator=(const Protocol&) = delete; + uint8_t m_scrambleConst[SCRAMBLE_CONST_LEN] = {0}; bool m_initialized = false; }; diff --git a/tmp_patch_ble_cpp.py b/tmp_patch_ble_cpp.py deleted file mode 100644 index a8897ba..0000000 --- a/tmp_patch_ble_cpp.py +++ /dev/null @@ -1,304 +0,0 @@ -import sys - -with open('main/server/ble_ota.cpp','r',encoding='utf-8-sig') as f: - data=f.read() - -# 1. 添加 SHA_MISMATCH 错误码宏 -old='#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小' -new='#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小\n#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 // ESP32 OTA SHA-256 不匹配' -assert old in data, 'macro old not found' -data=data.replace(old,new) - -# 2. 修改 ble_esp32_finish_and_reboot_task 接受 frontend SHA 参数 -old_task='''static void ble_esp32_finish_and_reboot_task(void *pvParameters) -{ - (void)pvParameters; - DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动"); - - Esp32OtaCore &core = Esp32OtaCore::instance(); - - // 补发最终 ACK,让前端尽快得知最后一包已收到 - if (BleOta::instance().m_esp32LastSeq != 0xFFFFU) { - sendWindowAck(BleOta::instance().m_esp32LastCmd, - BleOta::instance().m_esp32LastSeq, - (uint32_t)core.getReceived()); - DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq); - } - vTaskDelay(pdMS_TO_TICKS(150)); - - if (!core.isActive()) { - DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启"); - BleOta::instance().m_esp32RebootPending = false; - vTaskDelete(NULL); - return; - } - - DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); - - // 通知前端:开始 finalize - sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); - - // 委托核心完成: esp_ota_end → SHA 计算 → set_boot_partition → esp_restart - // BLE 不传前端 SHA(BLE 协议无 finalize 前校验机制),直接应用;SHA 通过 0xC1 上报供前端比对 - char device_sha_hex[65] = {0}; - esp_err_t err = core.finalize(/*frontend_sha_hex=*/nullptr, - /*reboot=*/true, - device_sha_hex); - // reboot=true 时 finalize 正常不会返回(core 内部 esp_restart) - - if (err == ESP_OK) { - // 极端情况:reboot 未执行(理论上不会发生),仍上报并标记 - if (device_sha_hex[0] != '\\0') { - // 上报设备端 SHA-256 供前端比对显示([0xC1] + [32 字节]) - uint8_t sha_pkt[33] = {0}; - sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; - for (int i = 0; i < 32; ++i) { - unsigned int byte = 0; - sscanf(&device_sha_hex[i * 2], "%02x", &byte); - sha_pkt[1 + i] = (uint8_t)byte; - } - BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); - } - sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0); - sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); - } else { - DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err); - BleOta::instance().m_esp32RebootPending = false; - } - - vTaskDelete(NULL); -}''' - -new_task='''static void ble_esp32_finish_and_reboot_task(void *pvParameters) -{ - // pvParameters: heap-allocated char[65] frontend SHA hex string (or nullptr) - char *frontend_sha_hex = (char *)pvParameters; - DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动"); - - Esp32OtaCore &core = Esp32OtaCore::instance(); - - // 补发最终 ACK,让前端尽快得知最后一包已收到 - if (BleOta::instance().m_esp32LastSeq != 0xFFFFU) { - sendWindowAck(BleOta::instance().m_esp32LastCmd, - BleOta::instance().m_esp32LastSeq, - (uint32_t)core.getReceived()); - DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq); - } - vTaskDelay(pdMS_TO_TICKS(150)); - - if (!core.isActive()) { - DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启"); - if (frontend_sha_hex) free(frontend_sha_hex); - BleOta::instance().m_esp32RebootPending = false; - vTaskDelete(NULL); - return; - } - - DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); - - // 通知前端:开始 finalize - sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); - - char device_sha_hex[65] = {0}; - esp_err_t err = core.finalize(frontend_sha_hex, - /*reboot=*/true, - device_sha_hex); - // reboot=true 时 finalize 正常不会返回(core 内部 esp_restart) - - if (err == ESP_OK) { - // 极端情况:reboot 未执行(理论上不会发生),仍上报并标记 - if (device_sha_hex[0] != '\\0') { - uint8_t sha_pkt[33] = {0}; - sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; - for (int i = 0; i < 32; ++i) { - unsigned int byte = 0; - sscanf(&device_sha_hex[i * 2], "%02x", &byte); - sha_pkt[1 + i] = (uint8_t)byte; - } - BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); - } - sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0); - sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); - } else if (err == ESP_ERR_INVALID_VERSION) { - DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); - sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0); - BleOta::instance().m_esp32RebootPending = false; - } else { - DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err); - BleOta::instance().m_esp32RebootPending = false; - } - - if (frontend_sha_hex) free(frontend_sha_hex); - vTaskDelete(NULL); -}''' -assert old_task in data, 'old_task not found' -data=data.replace(old_task,new_task) - -# 3. 修改 ble_ota_ctrl_access: 扩展 buf 到 36,解析前端 SHA -old_ctrl=''' uint8_t buf[4] = {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_INFO(TAG, "CTRL 写入: cmd=0x%02X", buf[0]); - - if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) { - // 检查固件是否存储完毕 → 触发 GD32 OTA 中继 - ota_meta_t meta; - esp_err_t err = OtaRelay::metaLoad(&meta); - if (err == ESP_OK && - meta.total_size > 0 && - meta.stored_size >= meta.total_size) { - DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继", - meta.stored_size, meta.total_size); - BleOta::instance().signalRelay(); - } else { - DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")", - meta.stored_size, meta.total_size); - } - } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { - if (!Esp32OtaCore::instance().isActive()) { - DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); - return 0; - } - if (BleOta::instance().m_esp32RebootPending) { - DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); - return 0; - } - - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - 4096, - NULL, - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - BleOta::instance().m_esp32RebootPending = false; - } else { - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(由设备端异步完成)"); - } - } - // 注: BLE_OTA_CTRL_VERIFY_SHA256 (0xF9) 路径已移除。 - // SHA-256 现由 Esp32OtaCore 在 finalize 时统一从分区读回计算, - // 并通过 BLE_OTA_NOTIFY_SHA256 (0xC1) 通知前端比对,校验逻辑与 WiFi 完全一致。''' - -new_ctrl=''' 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_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len); - - if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) { - // 检查固件是否存储完毕 → 触发 GD32 OTA 中继 - ota_meta_t meta; - esp_err_t err = OtaRelay::metaLoad(&meta); - if (err == ESP_OK && - meta.total_size > 0 && - meta.stored_size >= meta.total_size) { - DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继", - meta.stored_size, meta.total_size); - BleOta::instance().signalRelay(); - } else { - DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")", - meta.stored_size, meta.total_size); - } - } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { - if (!Esp32OtaCore::instance().isActive()) { - DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); - return 0; - } - if (BleOta::instance().m_esp32RebootPending) { - DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); - return 0; - } - - // 解析前端 SHA-256: [0xF8] + [32 bytes binary SHA] - char *frontend_sha_hex = nullptr; - if (len >= 33) { - frontend_sha_hex = (char *)malloc(65); - if (frontend_sha_hex) { - for (int i = 0; i < 32; ++i) { - snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]); - } - frontend_sha_hex[64] = '\\0'; - DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); - } - } else { - DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33,无法获取前端 SHA-256"); - } - - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - 4096, - frontend_sha_hex, // heap string; task 负责 free - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - if (frontend_sha_hex) free(frontend_sha_hex); - BleOta::instance().m_esp32RebootPending = false; - } else { - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); - } - } - // 注: BLE_OTA_CTRL_VERIFY_SHA256 (0xF9) 路径已移除。 - // SHA-256 现由 Esp32OtaCore 在 finalize 时统一从分区读回计算, - // 并通过 BLE_OTA_NOTIFY_SHA256 (0xC1) 通知前端比对,校验逻辑与 WiFi 完全一致。''' -assert old_ctrl in data, 'old_ctrl not found' -data=data.replace(old_ctrl,new_ctrl) - -# 4. 移除 DATA_IN 中的自动收尾代码(ESP32 收到最后一包后自动创建 finish task) -old_auto=''' if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) { - DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", - (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1, - isLastPacket ? " [尾包]" : ""); - BleOta::instance().m_esp32LastCmd = cmd; - BleOta::instance().m_esp32LastSeq = seq; - - if (!BleOta::instance().m_esp32RebootPending) { - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - 4096, - NULL, - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - ble_esp32_ota_abort(); // core 内部完成 abort + mutex release + 状态复位 - BleOta::instance().m_esp32RebootPending = false; - return BLE_ATT_ERR_UNLIKELY; - } - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启"); - } else { - DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发"); - } - } - }''' - -new_auto=''' if ((total_size > 0 && Esp32OtaCore::instance().getReceived() >= total_size) || isLastPacket) { - DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", - (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1, - isLastPacket ? " [尾包]" : ""); - BleOta::instance().m_esp32LastCmd = cmd; - BleOta::instance().m_esp32LastSeq = seq; - // 注:与 WiFi 流程对齐,传输完成后由前端发送 0xF8 + SHA-256 触发 finalize, - // 设备端不再自动创建收尾任务。 - } - }''' -assert old_auto in data, 'old_auto not found' -data=data.replace(old_auto,new_auto) - -with open('main/server/ble_ota.cpp','w',encoding='utf-8-sig') as f: - f.write(data) -print('ble_ota.cpp updated') diff --git a/tmp_patch_ble_cpp2.py b/tmp_patch_ble_cpp2.py deleted file mode 100644 index c6a59e2..0000000 --- a/tmp_patch_ble_cpp2.py +++ /dev/null @@ -1,147 +0,0 @@ -import sys - -path = r'main\server\ble_ota.cpp' -with open(path, 'r', encoding='utf-8-sig') as f: - data = f.read() - -# 1. 文件头注释:补充 0xFA 说明 -old = ' * - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级\n' -new = ' * - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级(含 32B SHA-256 校验并 reboot),0xFA=重启ESP32\n' -assert old in data, 'header old not found' -data = data.replace(old, new, 1) - -# 2. 0xF8 分支:移除 "无 SHA 跳过比对" 路径,强制要求 32B SHA -old_f8 = ''' } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { - if (!Esp32OtaCore::instance().isActive()) { - DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); - return 0; - } - if (BleOta::instance().m_esp32RebootPending) { - DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); - return 0; - } - - // 解析前端 SHA-256: [0xF8] + [32 bytes binary SHA] - char *frontend_sha_hex = nullptr; - if (len >= 33) { - frontend_sha_hex = (char *)malloc(65); - if (frontend_sha_hex) { - for (int i = 0; i < 32; ++i) { - snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]); - } - frontend_sha_hex[64] = '\\0'; - DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); - } - } else { - // 前端未传 SHA,说明已经走过 0xF9 先校验、收尾不再重复比对 - DEBUG_INFO(TAG, "CTRL 0xF8 未传入 SHA-256,finalize 将跳过 SHA 比对与重启前控制流"); - } - - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - 4096, - frontend_sha_hex, // heap string; task 负责 free - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - if (frontend_sha_hex) free(frontend_sha_hex); - BleOta::instance().m_esp32RebootPending = false; - } else { - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); - } - } else if (buf[0] == BLE_OTA_CTRL_VERIFY_SHA256) { - // 仅校验:从 OTA 分区读回计算 SHA-256,通过 0xC1 上报。不 end / 不 set_boot / 不重启。 - Esp32OtaCore &core = Esp32OtaCore::instance(); - if (!core.isActive()) { - DEBUG_WARN(TAG, "0xF9 请求被拒绝:ESP32 OTA 会话未激活"); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_STATE); - return 0; - } - char device_sha_hex[65] = {0}; - esp_err_t sha_err = core.computeDeviceSha(device_sha_hex); - if (sha_err != ESP_OK) { - DEBUG_ERR(TAG, "0xF9 请求:从 OTA 分区计算 SHA-256 失败: %s", esp_err_to_name(sha_err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)sha_err); - return 0; - } - uint8_t sha_pkt[33] = {0}; - sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; - for (int i = 0; i < 32; ++i) { - unsigned int byte = 0; - sscanf(&device_sha_hex[i * 2], "%02x", &byte); - sha_pkt[1 + i] = (uint8_t)byte; - } - esp_err_t notify_err = BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); - if (notify_err != ESP_OK) { - DEBUG_WARN(TAG, "0xF9 请求:上报 SHA-256 notify 失败: %s", esp_err_to_name(notify_err)); - } else { - DEBUG_INFO(TAG, "0xF9 请求:已上报设备端 SHA-256,前端可继续比对,%s%s", - device_sha_hex, BleOta::instance().m_esp32RebootPending ? "" : ""); - } - } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {''' - -new_f8 = ''' } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { - if (!Esp32OtaCore::instance().isActive()) { - DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); - return 0; - } - if (BleOta::instance().m_esp32RebootPending) { - DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); - return 0; - } - - // 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对, - // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 - if (len < 33) { - DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发"); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_INVALID_ARG); - return 0; - } - - char *frontend_sha_hex = (char *)malloc(65); - if (!frontend_sha_hex) { - DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败"); - sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)ESP_ERR_NO_MEM); - return 0; - } - for (int i = 0; i < 32; ++i) { - snprintf(&frontend_sha_hex[i * 2], 3, "%02x", buf[1 + i]); - } - frontend_sha_hex[64] = '\\0'; - DEBUG_INFO(TAG, "前端 SHA-256: %s", frontend_sha_hex); - - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - 4096, - frontend_sha_hex, // heap string; task 负责 free - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - free(frontend_sha_hex); - BleOta::instance().m_esp32RebootPending = false; - } else { - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); - } - } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {''' -assert old_f8 in data, '0xF8 block old not found' -data = data.replace(old_f8, new_f8, 1) - -# 3. 结尾协议说明注释:删除 0xF9 行 -old_tail = ''' // 0xF7 = 触发 GD32 中继 - // 0xF8 = 触发 ESP32 本机 OTA 收尾(end + set_boot + reboot,默认不重复校验 SHA,前端需先走 0xF9 确认) - // 0xF9 = 仅从 OTA 分区读回计算 SHA-256,通过 0xC1 notify 上报前端(不 end / 不 reboot) - // 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)''' -new_tail = ''' // 0xF7 = 触发 GD32 中继 - // 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对, - // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启) - // 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)''' -assert old_tail in data, 'tail old not found' -data = data.replace(old_tail, new_tail, 1) - -with open(path, 'w', encoding='utf-8-sig') as f: - f.write(data) -print('ble_ota.cpp updated') diff --git a/tmp_patch_css.py b/tmp_patch_css.py deleted file mode 100644 index e815444..0000000 --- a/tmp_patch_css.py +++ /dev/null @@ -1,37 +0,0 @@ -path = r'tools\style.css' -with open(path, 'r', encoding='utf-8-sig') as f: - css = f.read() - -# 在 .svi-val.err 之后追加 stage-result 样式 -old = """.svi-val.err { color: var(--red); font-weight: 600; }""" -new = """.svi-val.err { color: var(--red); font-weight: 600; } - -/* Stage result panel - 上位机确认 0x07/0x85 收尾 */ -.stage-result { - margin-top: 12px; padding: 14px 16px; - border-radius: var(--radius-sm); - background: var(--bg-elevated); border: 1px solid var(--border); - display: flex; flex-direction: column; gap: 10px; -} -.stage-result.ok { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); } -.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); } -.stage-result .sr-head { display: flex; align-items: center; gap: 10px; } -.stage-result .sr-ico { - width: 32px; height: 32px; border-radius: 50%; - display: inline-flex; align-items: center; justify-content: center; - font-size: 18px; font-weight: 700; color: #07121c; -} -.stage-result.ok .sr-ico { background: var(--green); } -.stage-result.err .sr-ico { background: var(--red); } -.stage-result .sr-title { font-size: 16px; font-weight: 700; } -.stage-result.ok .sr-title { color: var(--green); } -.stage-result.err .sr-title { color: var(--red); } -.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.5; } -.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; }""" - -assert old in css, 'svi-val.err CSS not found' -css = css.replace(old, new, 1) - -with open(path, 'w', encoding='utf-8-sig') as f: - f.write(css) -print('style.css updated') diff --git a/tmp_patch_html.py b/tmp_patch_html.py deleted file mode 100644 index c6c6e5b..0000000 --- a/tmp_patch_html.py +++ /dev/null @@ -1,20 +0,0 @@ -path = r'tools\ble_ota.html' -with open(path, 'r', encoding='utf-8-sig') as f: - html = f.read() - -old_hint = " hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后会先发 0xF9 让设备端读回算 SHA-256,前端比对一致后才发 0xF8 触发收尾(end + set_boot + reboot)。'" -new_hint = " hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后发 0xF8 + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。'" -print('old in html:', old_hint in html) -html2 = html.replace(old_hint, new_hint, 1) -print('changed:', html2 != html) -print('new still present:', new_hint in html2) -with open(path, 'w', encoding='utf-8-sig') as f: - f.write(html2) -print('written, file len:', len(html2)) - -# verify -with open(path, 'r', encoding='utf-8-sig') as f: - reread = f.read() -print('re-read len:', len(reread)) -print('new in reread:', new_hint in reread) -print('old in reread:', old_hint in reread) diff --git a/tmp_test.py b/tmp_test.py deleted file mode 100644 index a83f1c1..0000000 --- a/tmp_test.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -os.chdir(r'G:\Robot_v2\DCU\ESP32_COMM') -path = 'tools/ble_ota.html' - -with open(path, 'r', encoding='utf-8-sig') as f: - html = f.read() - -# 计数 -print('0xF9 BEFORE:', html.count('0xF9')) -print('shaWaiter BEFORE:', html.count('shaWaiter')) - -# 1) TARGETS esp32 hint -old_hint = " hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后会先发 0xF9 让设备端读回算 SHA-256,前端比对一致后才发 0xF8 触发收尾(end + set_boot + reboot)。'" -new_hint = " hint: 'TEST NEW'" -print('hint in html:', old_hint in html) -html = html.replace(old_hint, new_hint, 1) - -with open(path, 'w', encoding='utf-8-sig') as f: - f.write(html) - -# 验证 -with open(path, 'r', encoding='utf-8-sig') as f: - reread = f.read() -print('TEST NEW present:', 'TEST NEW' in reread) -print('old hint present:', old_hint in reread) diff --git a/tmp_test2.py b/tmp_test2.py deleted file mode 100644 index 87b16e7..0000000 --- a/tmp_test2.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -os.chdir(r'G:\Robot_v2\DCU\ESP32_COMM') -with open('tools/ble_ota.html', 'r', encoding='utf-8-sig') as f: - text = f.read() - -# Try a small, single line replace -old = " fileSha256: null," -print('old line found:', text.count(old)) - -# Now do a multi-line block test -old_block = """ fileSha256: null, - deviceSha256: null, - expectedReboot: false, // 发送 0xFA 后置 true,重连后清 - reconnecting: false, // 自动重连中 - reconnectAttempts: 0, // 重连尝试次数 - shaWaiter: null, // {resolve, reject, timer} 0xC1 等待者 - };""" - -print('block found:', old_block in text) -print('block repr (last 200):', repr(old_block[-200:])) diff --git a/tools/ble_ota.html b/tools/ble_ota.html deleted file mode 100644 index 5afa9ae..0000000 --- a/tools/ble_ota.html +++ /dev/null @@ -1,1407 +0,0 @@ - - - - - -ESP32 BLE OTA 控制台 - - - -
-
-
- - ESP32 BLE OTA 控制台 -
-
- 未连接 - - - - - 未读取 -
-
- -
-
-
-
🔗 蓝牙连接
-
-
- 本页走 BLE GATT,不依赖 WiFi。适合现场、移动端或无法直接访问设备 IP 的场景。支持 GD32 中继 与 ESP32 自升级 两种目标。 -
-
- - -
-
- - - 回 WiFi 页面 ↗ -
-
-
- -
-
🎯 升级目标
-
-
- - -
-
-
- - -
-
- - -
-
-
- 当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。协议格式:cmd(1) + seq(2) + total_size(4) + payload。 -
-
-
- -
-
📁 固件文件
-
-
- -
-
📤
-

点击或拖拽文件到此处

-

支持 .bin 和 .hex 格式

-
- - -
-
- 📄 - - -
-
-
类型-
-
大小0 B
-
总包数0
-
已写包0
-
-
- SHA-256 - - -
-
-
- -
-
- -
-
⚡ 执行操作
-
-
当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。
-
- - - - -
-
-
-
- -
-
-
📡 设备状态
-
-
-
连接状态
offline
-
GATT / MTU
未就绪
-
版本
未读取
-
OTA 状态
待机
-
-
-
- -
-
📊 OTA 进度
-
-
-
-
- 等待选择文件 - 0% -
-
-
-
- -
-
🔀 OTA 流程阶段
-
-
-
1
BLE 连接
-
2
GATT 准备
-
3
文件准备
-
4
数据写入
-
5
校验比对
-
6
完成触发
-
-
- - 待机 -
- -
-
- -
-
🖥️ 运行日志
-
-
-
-
-
-
-
- - - - -
- ✓ - 操作成功 -
- - diff --git a/tools/serve.bat b/tools/serve.bat index 7447dba..2d6266b 100644 --- a/tools/serve.bat +++ b/tools/serve.bat @@ -9,8 +9,9 @@ echo ESP32 COMM Host Server echo PORT: %PORT% echo ======================================== echo. -echo WiFi OTA: http://localhost:%PORT%/index.html -echo BLE OTA: http://localhost:%PORT%/ble_ota.html +echo OTA Console: http://localhost:%PORT%/index.html +echo. +echo Switch WiFi / BLE mode in the "Device Connect" card. echo. echo Press Ctrl+C to stop echo ========================================