From b807645a200b0b335e84a7bbc8ab2275df709183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Sat, 18 Jul 2026 01:16:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(ble):=20=E5=AE=8C=E5=96=84BLE=20OTA?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=B9=B6=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现BLE OTA数据路径分离:添加ble_ota_data.cpp处理重排序/工作线程/DATA_IN, 和ble_ota_host.cpp处理GATT/GAP/广播/主机功能 - 将滑动ACK窗口从32增大到64,实测Windows WebBT环境下固件传输速度提升约10% (从40KB/s提升到44KB/s),内存消耗约30KB重排序缓冲区 - 优化调试日志级别,将OTA相关信息从INFO调整为DBG以减少日志冗余 refactor(can): 改进CAN反馈监听机制 - 将CAN反馈任务从1Hz主动轮询模式改为被动事件监听模式, 使用SpiMaster::takeEvent()替代主动发送0xC1轮询请求 - 更新相关头文件注释说明架构变更,将"周期性轮询"改为"被动接收推送" - 修改调试日志级别,将暂停/恢复状态日志从INFO调整为DBG --- .../2026-07-18-function-simplify-reuse.md | 1297 ++++++++++++ ...ss_0abed1e8-31bb-447c-adc2-330aed3f57da.md | 53 + main/CMakeLists.txt | 6 +- main/include/app_config.h | 10 +- main/include/version.h | 8 +- main/monitor/can_feedback_task.cpp | 12 +- main/monitor/can_feedback_task.h | 10 +- main/ota/esp32_ota_core.cpp | 16 +- main/ota/ota_coordinator.cpp | 10 +- main/server/ble_ota.cpp | 1817 +---------------- main/server/ble_ota_data.cpp | 1010 +++++++++ main/server/ble_ota_host.cpp | 701 +++++++ main/server/ble_ota_priv.h | 120 ++ main/server/http_handlers.cpp | 2 +- main/server/http_handlers.h | 2 +- main/transport/canfd_bridge.h | 4 +- main/transport/spi_master.cpp | 12 +- tools/can.html | 2 +- tools/css/can.css | 4 - tools/index.html | 14 +- tools/js/app.js | 48 +- tools/js/ble/constants.js | 6 +- tools/js/ble/file.js | 128 +- tools/js/ble/gatt.js | 71 +- tools/js/ble/stages.js | 102 +- tools/js/ble/state.js | 32 +- tools/js/ble/transfer.js | 41 +- tools/js/ble/verify.js | 20 +- tools/js/ble_ota.js | 18 +- tools/js/can.js | 27 +- tools/js/can/dbc.js | 15 +- tools/js/can/messages.js | 68 +- tools/js/can/persist.js | 11 +- tools/js/can/send.js | 301 ++- tools/js/can/signals.js | 72 +- tools/js/can_monitor.js | 50 +- tools/js/common/connection.js | 110 +- tools/js/common/log.js | 107 +- tools/js/common/polling.js | 162 +- tools/js/common/toast.js | 23 +- tools/js/common/utils.js | 16 + tools/js/ota/file-handle.js | 141 +- tools/js/ota/progress.js | 98 +- tools/js/ota/spi.js | 104 - tools/js/ota/stages.js | 218 +- tools/js/ota/upload-esp32.js | 5 +- tools/js/ota/upload-gd32.js | 147 +- 47 files changed, 4361 insertions(+), 2890 deletions(-) create mode 100644 .zcode/plans/2026-07-18-function-simplify-reuse.md create mode 100644 .zcode/plans/plan-sess_0abed1e8-31bb-447c-adc2-330aed3f57da.md create mode 100644 main/server/ble_ota_data.cpp create mode 100644 main/server/ble_ota_host.cpp create mode 100644 main/server/ble_ota_priv.h delete mode 100644 tools/js/ota/spi.js diff --git a/.zcode/plans/2026-07-18-function-simplify-reuse.md b/.zcode/plans/2026-07-18-function-simplify-reuse.md new file mode 100644 index 0000000..6a360de --- /dev/null +++ b/.zcode/plans/2026-07-18-function-simplify-reuse.md @@ -0,0 +1,1297 @@ +# 函数精简与复用重构实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 通过提取共享工具、消除重复实现、拆分过长函数,系统性地精简 ESP32 固件 (`main/`) 与前端工具 (`tools/js/`) 中的函数,重点强化复用关系。 + +**Architecture:** 三层重构——(1) 抽取跨文件共享工具模块(hex/le/sha/progress/stage-ui/fetch);(2) 消除同语义的重复实现并替换调用点;(3) 拆分超长/职责混杂函数。每个任务独立可测、可提交。C++ 侧与 JS 侧互不依赖,可并行推进。 + +**Tech Stack:** ESP-IDF (C++17, FreeRTOS, NimBLE), 原生 JS (浏览器 ES 模块,无构建步骤),CMake。 + +**测试策略:** +- C++ 侧:`idf.py build` 编译通过;OTA 上传/转发/CAN 收发的人工冒烟测试(设备实测)。 +- JS 侧:浏览器加载 `tools/index.html` 与 `tools/can.html`,控制台无报错;BLE OTA、WiFi OTA、CAN 监控、DBC 解析、手动发送、批量发送功能冒烟通过。 +- 无单元测试框架,每个任务后需在浏览器/设备上做对应路径的冒烟验证。 + +**核心约束:** +- 不改变任何对外行为/协议/UUID/HTTP 路由。 +- 不引入构建步骤(保持原生 ES 模块)。 +- 保持现有代码风格(无分号、`var`/`const`/`let` 混用、中文注释)。 +- 每个 task 一次提交,commit message 用 `refactor:` 前缀。 + +--- + +## File Structure + +### 新建文件 + +| 文件 | 职责 | +|---|---| +| `main/include/hex_utils.h` | C++ 共享 hex/le 工具:`parseHexToBytes`、`bytesToHexUpper`、`le8`、`le32` | +| `tools/js/common/progress.js` | JS 共享进度条:`setProgress`、`markProgressDone`、`resetProgress`、`getProgressEls`(懒缓存) | +| `tools/js/common/stage-ui.js` | JS 共享阶段节点/状态栏:`resetStageNodes`、`advanceStage`、`markStageError`、`setStatusBar`、`getStageStatusBar`、`getStageStatusText`(懒缓存) | +| `tools/js/common/sha.js` | JS 共享 SHA:`computeFileSha256`、`sha256HexToBytes`、`compareShaHex` | +| `tools/js/common/fetch-json.js` | JS 共享 fetch 包装:`fetchJSON(url, opts)`、`abortableFetch(url, timeoutMs)` | +| `tools/js/common/device-ip.js` | JS 共享设备 IP:`getDeviceIp`、`setDeviceIp`、`getBaseUrl` | +| `tools/js/ble/gatt-helpers.js` | JS BLE GATT 包装:`discoverCharacteristics`、`writeWithResponse`、`writeWithoutResponse`、`readCharacteristic` | + +### 修改文件(按 task 顺序) + +C++ 侧:`main/server/ble_ota_data.cpp`、`main/server/ble_ota_host.cpp`、`main/server/ble_ota.cpp`、`main/server/http_handlers.cpp`、`main/transport/spi_master.cpp`、`main/include/app_config.h`、`main/CMakeLists.txt`(仅在新增 .cpp 时)。 + +JS 侧:`tools/js/common/utils.js`、`tools/js/common/connection.js`、`tools/js/common/polling.js`、`tools/js/ble/transfer.js`、`tools/js/ble/file.js`、`tools/js/ble/gatt.js`、`tools/js/ble/stages.js`、`tools/js/ble/state.js`、`tools/js/ble/verify.js`、`tools/js/ota/progress.js`、`tools/js/ota/stages.js`、`tools/js/ota/upload-esp32.js`、`tools/js/ota/upload-gd32.js`、`tools/js/ota/file-handle.js`、`tools/js/can.js`、`tools/js/can/send.js`、`tools/js/can/dbc.js`、`tools/js/can_monitor.js`、`tools/js/app.js`。 + +### 拆分文件 + +- `tools/js/ble/stages.js` → 保留目标配置相关,把通用节点操作移至 `stage-ui.js`。 +- `tools/js/ota/progress.js` → 保留 esp32/gd32 专属阶段相位映射,把通用节点操作移至 `stage-ui.js`、进度条移至 `progress.js`(common)。 + +> 注意:新建 `tools/js/common/progress.js` 与现有 `tools/js/ota/progress.js` 同名不同目录,需在 HTML/导入路径中明确区分。 + +--- + +## 阶段总览 + +| 阶段 | 主题 | Task | +|---|---|---| +| A | C++ 共享工具与拆分 | T1–T5 | +| B | JS 共享工具模块建立 | T6–T11 | +| C | JS 调用点迁移与重复消除 | T12–T18 | +| D | JS 过长函数拆分 | T19–T22 | +| E | JS 死代码/一次性 helper 清理 | T23–T25 | + +--- + +## 阶段 A:C++ 共享工具与拆分 + +### Task 1: 新建 `main/include/hex_utils.h` 共享 hex/le 工具 + +**Files:** +- Create: `main/include/hex_utils.h` + +- [ ] **Step 1: 创建头文件** + +```cpp +#pragma once +#include +#include +#include +#include + +// 将 hex 字符串解析为字节数组,返回写入字节数(<=outCap)。失败返回 0。 +inline size_t parseHexToBytes(const char *hex, uint8_t *out, size_t outCap) { + size_t len = strlen(hex); + if (len % 2 != 0) return 0; + size_t n = len / 2; + if (n > outCap) return 0; + for (size_t i = 0; i < n; ++i) { + char h[3] = { hex[i * 2], hex[i * 2 + 1], 0 }; + out[i] = (uint8_t)strtoul(h, nullptr, 16); + } + return n; +} + +// 字节数组 → 大写 hex 字符串(buf 至少 n*2+1 字节)。 +inline void bytesToHexUpper(const uint8_t *in, size_t n, char *buf) { + static const char *kHex = "0123456789ABCDEF"; + for (size_t i = 0; i < n; ++i) { + buf[i * 2] = kHex[(in[i] >> 4) & 0xF]; + buf[i * 2 + 1] = kHex[in[i] & 0xF]; + } + buf[n * 2] = 0; +} + +// 小端打包/解包 +inline uint8_t le8(uint32_t v, uint8_t shift) { return (uint8_t)(v >> shift); } +inline uint32_t le32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} +``` + +- [ ] **Step 2: 验证编译** + +Run: `idf.py build` +Expected: 编译通过(头文件尚未被引用,不影响现有代码) + +- [ ] **Step 3: 提交** + +```bash +git add main/include/hex_utils.h +git commit -m "refactor(main): 新增 hex_utils.h 共享 hex/le 工具" +``` + +--- + +### Task 2: 迁移 http_handlers.cpp 使用 hex_utils.h + +**Files:** +- Modify: `main/server/http_handlers.cpp:39-56`(删除本地 `parseHexToBytes`/`bytesToHexUpper`) +- Modify: `main/server/http_handlers.cpp:571-581`(GD32 relay handler 内联 hex 解析改用工具) + +- [ ] **Step 1: 添加 include 并删除本地实现** + +在 `http_handlers.cpp` 顶部 `#include` 区加入: +```cpp +#include "hex_utils.h" +``` + +删除 `parseHexToBytes()` (39-48) 与 `bytesToHexUpper()` (50-56) 的本地定义。保留所有调用点不变(函数名一致)。 + +- [ ] **Step 2: 替换 ota_relay_trigger_handler 内联 hex 解析** + +将 `http_handlers.cpp:571-581` 处: +```cpp +for (size_t i = 0; i < sizeof(frontend_sha); ++i) { + char hex[3] = { frontend_sha_hex[i*2], frontend_sha_hex[i*2+1], 0 }; + frontend_sha[i] = (uint8_t)strtoul(hex, nullptr, 16); +} +// ... 后续 inline hex 构造 device_sha_hex +``` +改为: +```cpp +if (parseHexToBytes(frontend_sha_hex, frontend_sha, sizeof(frontend_sha)) != sizeof(frontend_sha)) { + http_send_json_error(req, 400, "BAD_SHA", "SHA-256 hex 格式错误"); + return ESP_OK; +} +char device_sha_hex[65]; +bytesToHexUpper(device_sha, sizeof(device_sha), device_sha_hex); +``` + +- [ ] **Step 3: 验证编译** + +Run: `idf.py build` +Expected: PASS + +- [ ] **Step 4: 冒烟测试** + +设备上通过 HTTP 触发一次 `/ota/relay`(错误 SHA 也行,只要返回 400 而非崩溃)。 + +- [ ] **Step 5: 提交** + +```bash +git add main/server/http_handlers.cpp main/include/hex_utils.h +git commit -m "refactor(http): 迁移 hex 工具至 hex_utils.h,消除内联 hex 解析" +``` + +--- + +### Task 3: 迁移 ble_ota_data.cpp 使用 hex_utils.h,删除 `shaBytesToHexUpper` + +**Files:** +- Modify: `main/server/ble_ota_data.cpp:437-507`(`sendWindowAck`/`sendFinishEvent`/`sendSha256Notify` 使用 `le8`;删除 `shaBytesToHexUpper`,改用 `bytesToHexUpper`) +- Modify: `main/server/ble_ota.cpp:92-96`(`ble_esp32_finish_and_reboot_task` 中手写 hex 解析改用 `parseHexToBytes`) + +- [ ] **Step 1: 添加 include** + +在 `ble_ota_data.cpp` 与 `ble_ota.cpp` 顶部加入 `#include "hex_utils.h"`。 + +- [ ] **Step 2: 删除 `shaBytesToHexUpper` (487-494)** + +将 `sendSha256Notify()` (497-507) 内的 `shaBytesToHexUpper(sha, sha_hex)` 调用替换为 `bytesToHexUpper(sha, 32, sha_hex)`。删除 `shaBytesToHexUpper` 函数定义。 + +- [ ] **Step 3: `sendWindowAck`/`sendFinishEvent` 使用 `le8`** + +将 `ble_ota_data.cpp:441-447` 的 4x `(uint8_t)(value >> shift)` 与 `468-473` 的同类代码替换为 `le8(value, 0/8/16/24)`。 + +- [ ] **Step 4: `ble_ota.cpp:92-96` 使用 `parseHexToBytes`** + +将手写循环改为: +```cpp +uint8_t sha[32]; +if (parseHexToBytes(sha_hex, sha, sizeof(sha)) != sizeof(sha)) { /* 原错误分支 */ } +``` + +- [ ] **Step 5: 验证编译** + +Run: `idf.py build` +Expected: PASS + +- [ ] **Step 6: 冒烟测试** + +BLE OTA 上传一次固件,观察 SHA256 notify 与 finish event 正常返回。 + +- [ ] **Step 7: 提交** + +```bash +git add main/server/ble_ota_data.cpp main/server/ble_ota.cpp +git commit -m "refactor(ble_ota): 统一 hex/le 工具,删除 shaBytesToHexUpper" +``` + +--- + +### Task 4: 提取 `shouldSendThrottledAck` helper,消除 ESP32/GD32 重复 ACK 节流逻辑 + +**Files:** +- Modify: `main/server/ble_ota_data.cpp:245-263`(`esp32_write_ordered` fallback 节流) +- Modify: `main/server/ble_ota_data.cpp:808-821`(`process_gd32_packet` 节流) +- Modify: `main/server/ble_ota_priv.h`(新增 prototype) + +- [ ] **Step 1: 在 ble_ota_priv.h 新增声明** + +```cpp +// 节流 ACK 决策:计数器自增,命中窗口边界或倍数时返回 true。 +bool shouldSendThrottledAck(uint32_t *counter, uint16_t seq, uint16_t window); +``` + +- [ ] **Step 2: 在 ble_ota_data.cpp 实现(放在 sendWindowAck 附近)** + +```cpp +bool shouldSendThrottledAck(uint32_t *counter, uint16_t seq, uint16_t window) { + ++(*counter); + if (*counter >= window) { *counter = 0; return true; } + if (*counter % (window / 4 == 0 ? 1 : window / 4) == 0) return true; // 与原逻辑等价 + (void)seq; + return false; +} +``` +> ⚠️ 实现前需先精读原 245-263 与 808-821 的精确条件(`>= BLE_OTA_ACK_WINDOW` 或 `% N == 0`),逐字复刻而非凭记忆。本 step 的代码块是占位骨架,正式实现以原文条件为准。 + +- [ ] **Step 3: 替换两处调用点** + +将两处 `if (++s_xxx_ooo_count >= BLE_OTA_ACK_WINDOW || s_xxx_ooo_count % N == 0) { sendWindowAck(...); }` 替换为: +```cpp +if (shouldSendThrottledAck(&s_fallback_ooo_count, seq, BLE_OTA_ACK_WINDOW)) { + sendWindowAck(...); +} +``` +与 GD32 对应版本。 + +- [ ] **Step 4: 验证编译并对照原文输出** + +Run: `idf.py build` +Expected: PASS + +对照 git diff 确认两处节流条件与原逻辑完全等价(包括日志是否仍打印)。 + +- [ ] **Step 5: 冒烟测试** + +BLE OTA 上传中制造乱序(可手动断连重连),观察 ACK 仍按原节奏发出。 + +- [ ] **Step 6: 提交** + +```bash +git add main/server/ble_ota_data.cpp main/server/ble_ota_priv.h +git commit -m "refactor(ble_ota): 提取 shouldSendThrottledAck 消除 ESP32/GD32 重复节流" +``` + +--- + +### Task 5: 拆分 `ble_ota_ctrl_access` 与 `ota_upload_handler`,统一 BLE/HTTP GD32 relay 触发 + +> 本任务较大,按 5a/5b/5c 拆成三个连续提交。 + +#### Task 5a: 拆分 `ble_ota_ctrl_access` 为 4 个命令 handler + +**Files:** +- Modify: `main/server/ble_ota_host.cpp:8-257` +- Modify: `main/server/ble_ota_priv.h` + +- [ ] **Step 1: 在 ble_ota_priv.h 新增声明** + +```cpp +void ble_ota_handle_gd32_relay(const uint8_t *buf, uint16_t len); +void ble_ota_handle_esp32_finish(const uint8_t *buf, uint16_t len); +void ble_ota_handle_restart(); +void ble_ota_handle_abort(); +``` + +- [ ] **Step 2: 在 ble_ota_host.cpp 提取 4 个 handler** + +把 `ble_ota_ctrl_access` 中 0xF7/0xF8/0xFA/0xFB 四个分支体分别搬入上述 4 个函数(保持原有 task 创建、错误响应逻辑)。 + +- [ ] **Step 3: 重写 `ble_ota_ctrl_access` 为 dispatcher** + +```cpp +static int ble_ota_ctrl_access(...) { + // 保留 mbuf→buf 拷贝 + if (len < 1) return 0; + switch (buf[0]) { + case 0xF7: ble_ota_handle_gd32_relay(buf, len); break; + case 0xF8: ble_ota_handle_esp32_finish(buf, len); break; + case 0xFA: ble_ota_handle_restart(); break; + case 0xFB: ble_ota_handle_abort(); break; + default: /* 原日志 */ break; + } + return 0; +} +``` + +- [ ] **Step 4: 验证编译 + 冒烟** + +Run: `idf.py build` → PASS。BLE 触发 4 类 ctrl 命令各一次。 + +- [ ] **Step 5: 提交** + +```bash +git add main/server/ble_ota_host.cpp main/server/ble_ota_priv.h +git commit -m "refactor(ble_ota): 拆分 ble_ota_ctrl_access 为 4 个命令 handler" +``` + +#### Task 5b: 拆分 `ota_upload_handler` 为 `handle_esp32_upload`/`handle_gd32_upload` + +**Files:** +- Modify: `main/server/http_handlers.cpp:233-532` +- Modify: `main/server/http_handlers.h` + +- [ ] **Step 1: 在 http_handlers.h 新增声明** + +```cpp +esp_err_t handle_esp32_upload(httpd_req_t *req, const char *body, size_t len); +esp_err_t handle_gd32_upload(httpd_req_t *req, const char *body, size_t len); +``` + +- [ ] **Step 2: 提取两个函数** + +将 `ota_upload_handler` 中 ESP32 分支 (292-398) 与 GD32 分支 (400-531) 分别搬入两个新函数。共享的静态缓冲与 `otaUploadPaceIfHeapTight()` 调用保留在各自函数内(或提到 `ota_upload_handler` 入口)。`recv_json_header` 仍在 `ota_upload_handler` 中先调用。 + +- [ ] **Step 3: 重写 `ota_upload_handler` 为 dispatcher** + +```cpp +esp_err_t ota_upload_handler(httpd_req_t *req) { + // recv_json_header + 读取 type 字段 + if (type == "esp32") return handle_esp32_upload(req, body, len); + return handle_gd32_upload(req, body, len); +} +``` + +- [ ] **Step 4: 验证编译 + 冒烟** + +Run: `idf.py build` → PASS。HTTP 分别上传 ESP32 与 GD32 固件。 + +- [ ] **Step 5: 提交** + +```bash +git add main/server/http_handlers.cpp main/server/http_handlers.h +git commit -m "refactor(http): 拆分 ota_upload_handler 为 esp32/gd32 handler" +``` + +#### Task 5c: 提取 `triggerGd32RelayCommon` 统一 BLE/HTTP GD32 relay 触发 + +**Files:** +- Create: `main/server/ota_relay_common.cpp` 或并入现有 `ota/ota_coordinator.cpp`(先查该文件是否更合适) +- Modify: `main/server/ble_ota_host.cpp:48-123`(`ble_ota_handle_gd32_relay`) +- Modify: `main/server/http_handlers.cpp:540-611`(`ota_relay_trigger_handler`) +- Modify: `main/CMakeLists.txt`(若新建 .cpp) +- Modify: `main/server/http_handlers.h` 或相关头文件 + +- [ ] **Step 1: 先确认 `ota/ota_coordinator.cpp`/`ota_relay.cpp` 是否已导出 `triggerGd32Relay`** + +读 `ota/ota_coordinator.cpp` 与 `relay/ota_relay.cpp`,确认现有 `triggerGd32Relay`/`loadGd32Meta`/`verifyGd32Firmware` 签名。 + +- [ ] **Step 2: 设计共享签名** + +```cpp +// 返回 ESP_OK/ESP_ERR_INVALID_CRC/其他;out_sha_hex 为设备侧 SHA 大写 hex。 +esp_err_t triggerGd32RelayCommon(const uint8_t *frontend_sha, + uint16_t session_id, + char *out_sha_hex /*[65]*/, + uint16_t *out_session_id); +``` + +- [ ] **Step 3: 实现共享函数** + +把两条路径共有的 meta-load → verify → hex → trigger 序列合入。BLE 侧的 `sendSha256Notify` 在共享函数外由 BLE handler 调用(HTTP 不需要)。 + +- [ ] **Step 4: 改写两个调用点** + +`ble_ota_handle_gd32_relay` 与 `ota_relay_trigger_handler` 改为调用 `triggerGd32RelayCommon`,BLE 侧额外调用 `sendSha256Notify`,HTTP 侧组装 JSON 响应。 + +- [ ] **Step 5: 验证编译 + 双路径冒烟** + +Run: `idf.py build` → PASS。BLE 与 HTTP 各触发一次 GD32 relay。 + +- [ ] **Step 6: 提交** + +```bash +git add main/server/ota_relay_common.cpp main/server/ble_ota_host.cpp main/server/http_handlers.cpp main/CMakeLists.txt +git commit -m "refactor(ota): 提取 triggerGd32RelayCommon 统一 BLE/HTTP GD32 relay" +``` + +--- + +## 阶段 B:JS 共享工具模块建立 + +> 阶段 B 的每个 task 创建一个新模块并写出导出,但**不**改任何调用点(阶段 C 再迁移)。这样每个模块可独立提交、独立冒烟(浏览器加载无报错即视为通过)。 + +### Task 6: `tools/js/common/progress.js` 统一进度条 + +**Files:** +- Create: `tools/js/common/progress.js` + +- [ ] **Step 1: 创建模块(合并 BLE 与 OTA 两版 setProgress,取并集特性:NaN guard + .done toggle + 懒缓存)** + +```js +// tools/js/common/progress.js +let _fill, _txt, _pct; +function getProgressEls() { + if (!_fill || !_fill.isConnected) { + _fill = document.getElementById('progressFill'); + _txt = document.getElementById('progressTxt'); + _pct = document.getElementById('progressPct'); + } + return { fill: _fill, txt: _txt, pct: _pct }; +} +function setProgress(percent, text) { + var p = Number(percent); + if (!isFinite(p)) p = 0; + p = Math.max(0, Math.min(100, p)); + var els = getProgressEls(); + if (els.fill) els.fill.style.width = p + '%'; + if (els.pct) els.pct.textContent = p + '%'; + if (els.txt) els.txt.textContent = text || ''; + if (els.fill) els.fill.classList.toggle('done', p >= 100); + return p; +} +function markProgressDone(text) { + var els = getProgressEls(); + if (els.fill) { els.fill.style.width = '100%'; els.fill.classList.add('done'); } + if (els.pct) els.pct.textContent = '100%'; + if (text && els.txt) els.txt.textContent = text; +} +function resetProgress(text) { + var els = getProgressEls(); + if (els.fill) { els.fill.style.width = '0%'; els.fill.classList.remove('done'); } + if (els.pct) els.pct.textContent = '0%'; + if (els.txt) els.txt.textContent = text || ''; +} +export { setProgress, markProgressDone, resetProgress, getProgressEls }; +``` + +- [ ] **Step 2: 冒烟(仅加载)** + +浏览器打开 `tools/index.html`,控制台执行: +```js +import('./js/common/progress.js').then(m => m.setProgress(42, 'test')); +``` +Expected: 进度条显示 42%,无报错。 + +- [ ] **Step 3: 提交** + +```bash +git add tools/js/common/progress.js +git commit -m "refactor(js): 新增 common/progress.js 共享进度条" +``` + +--- + +### Task 7: `tools/js/common/stage-ui.js` 统一阶段节点/状态栏 + +**Files:** +- Create: `tools/js/common/stage-ui.js` + +- [ ] **Step 1: 创建模块** + +```js +// tools/js/common/stage-ui.js +let _bar, _txt; +function getStageStatusBar() { + if (!_bar || !_bar.isConnected) _bar = document.getElementById('stageStatusBar'); + return _bar; +} +function getStageStatusText() { + if (!_txt || !_txt.isConnected) _txt = document.getElementById('stageStatusText'); + return _txt; +} +function resetStageNodes(stageIds, detailIds) { + stageIds.forEach(function (id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + if (detailIds) detailIds.forEach(function (id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); +} +function setStageClass(stageIds, untilIndex, cls) { + // cls: 'done' | 'live' | 'err' → 完整类名 'stage-node done' 等 + for (var i = 0; i < stageIds.length; ++i) { + var el = document.getElementById(stageIds[i]); + if (!el) continue; + if (i < untilIndex) el.className = 'stage-node done'; + else if (i === untilIndex) el.className = 'stage-node ' + cls; + else el.className = 'stage-node'; + } +} +function markStageError(stageIds, index) { + var el = document.getElementById(stageIds[index]); + if (el) el.className = 'stage-node err'; +} +function setStatusBar(state, text) { + // state: 'ok' | 'active' | 'err' | '' + var bar = getStageStatusBar(); + var t = getStageStatusText(); + if (bar) bar.className = 'stage-status-bar' + (state ? ' ' + state : ''); + if (t) t.textContent = text || ''; +} +export { getStageStatusBar, getStageStatusText, resetStageNodes, setStageClass, markStageError, setStatusBar }; +``` + +> ⚠️ `setStageClass` 的精确语义需对照 `ble/stages.js:60-94` `setBleStage` 与 `ota/progress.js:83-116` `updateEsp32StagesFromPhase` 的实际赋值规则(done/live/未到达)。本 step 为骨架,正式实现以原文为准。 + +- [ ] **Step 2: 冒烟(仅加载)** + +控制台执行 `import('./js/common/stage-ui.js')` 无报错。 + +- [ ] **Step 3: 提交** + +```bash +git add tools/js/common/stage-ui.js +git commit -m "refactor(js): 新增 common/stage-ui.js 共享阶段节点/状态栏" +``` + +--- + +### Task 8: `tools/js/common/sha.js` 统一 SHA-256 计算/比较 + +**Files:** +- Create: `tools/js/common/sha.js` + +- [ ] **Step 1: 创建模块** + +```js +// tools/js/common/sha.js +import { bytesToHex } from './utils.js'; + +async function computeFileSha256(file) { + try { + var buf = await file.arrayBuffer(); + var hash = await crypto.subtle.digest('SHA-256', buf); + return bytesToHex(new Uint8Array(hash)).toUpperCase(); + } catch (e) { + return null; + } +} +async function computeBufferSha256(buffer) { + var hash = await crypto.subtle.digest('SHA-256', buffer); + return bytesToHex(new Uint8Array(hash)).toUpperCase(); +} +function sha256HexToBytes(hex) { + var out = new Uint8Array(32); + for (var i = 0; i < 32; ++i) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} +function compareShaHex(a, b) { + if (!a || !b) return false; + return a.toUpperCase() === b.toUpperCase(); +} +export { computeFileSha256, computeBufferSha256, sha256HexToBytes, compareShaHex }; +``` + +- [ ] **Step 2: 冒烟(仅加载)** + +控制台执行: +```js +import('./js/common/sha.js').then(async m => { + var f = new File([new Uint8Array([1,2,3])], 'x.bin'); + var h = await m.computeFileSha256(f); + console.log(h, m.compareShaHex(h, h)); +}); +``` +Expected: 输出 64 字符 hex + true。 + +- [ ] **Step 3: 提交** + +```bash +git add tools/js/common/sha.js +git commit -m "refactor(js): 新增 common/sha.js 共享 SHA-256 工具" +``` + +--- + +### Task 9: `tools/js/common/fetch-json.js` 统一 fetch 包装 + +**Files:** +- Create: `tools/js/common/fetch-json.js` + +- [ ] **Step 1: 创建模块** + +```js +// tools/js/common/fetch-json.js +async function fetchJSON(url, options) { + options = options || {}; + var signal = options.signal; + var resp = await fetch(url, options); + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : null; } catch (e) { + var err = new Error('JSON 解析失败: ' + e.message); + err.resp = resp; err.text = text; throw err; + } + if (!resp.ok) { + var err2 = new Error('HTTP ' + resp.status); + err2.resp = resp; err2.data = data; throw err2; + } + return data; +} +function abortableFetch(url, timeoutMs) { + var ctrl = new AbortController(); + var timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 5000); + var p = fetch(url, { signal: ctrl.signal }).finally(function () { clearTimeout(timer); }); + p.controller = ctrl; + return p; +} +export { fetchJSON, abortableFetch }; +``` + +- [ ] **Step 2: 冒烟(仅加载)** + +控制台执行 `import('./js/common/fetch-json.js')` 无报错。 + +- [ ] **Step 3: 提交** + +```bash +git add tools/js/common/fetch-json.js +git commit -m "refactor(js): 新增 common/fetch-json.js 共享 fetch 包装" +``` + +--- + +### Task 10: `tools/js/common/device-ip.js` 统一设备 IP/BaseUrl + +**Files:** +- Create: `tools/js/common/device-ip.js` + +- [ ] **Step 1: 创建模块(取 `can_monitor.js:31-45` 最完整版为基准)** + +```js +// tools/js/common/device-ip.js +var DEFAULT_IP = '192.168.31.127'; +var STORAGE_KEY = 'dcu_esp_ip'; + +function safeGet(key) { try { return localStorage.getItem(key); } catch (e) { return null; } } +function safeSet(key, v) { try { localStorage.setItem(key, v); } catch (e) {} } + +function getDeviceIp(opts) { + opts = opts || {}; + // 优先 DOM(可指定元素 ID),其次 localStorage,最后默认 + if (opts.inputId) { + var el = document.getElementById(opts.inputId); + if (el && el.value) { safeSet(STORAGE_KEY, el.value); return el.value; } + } + var stored = safeGet(STORAGE_KEY); + if (stored) return stored; + return DEFAULT_IP; +} +function setDeviceIp(ip) { safeSet(STORAGE_KEY, ip); } +function getBaseUrl(opts) { return 'http://' + getDeviceIp(opts); } + +export { getDeviceIp, setDeviceIp, getBaseUrl, DEFAULT_IP, STORAGE_KEY }; +``` + +> 现有 `app.js:52-64` 的 `getEspIp`/`getBaseUrl`、`can/dbc.js:38-60`、`can_monitor.js:31-45` 将在阶段 C 迁移到本模块(本 task 仅创建)。 + +- [ ] **Step 2: 冒烟(仅加载)** + +控制台执行 `import('./js/common/device-ip.js').then(m => console.log(m.getBaseUrl()))` 输出 `http://192.168.31.127`。 + +- [ ] **Step 3: 提交** + +```bash +git add tools/js/common/device-ip.js +git commit -m "refactor(js): 新增 common/device-ip.js 统一设备 IP/BaseUrl" +``` + +--- + +### Task 11: `tools/js/ble/gatt-helpers.js` 提取 GATT 发现/读写包装 + +**Files:** +- Create: `tools/js/ble/gatt-helpers.js` + +- [ ] **Step 1: 创建模块** + +```js +// tools/js/ble/gatt-helpers.js +import { UUIDS } from './constants.js'; + +async function discoverCharacteristics(server) { + var service = await server.getPrimaryService(UUIDS.otaService); + var ctrl = await service.getCharacteristic(UUIDS.ctrlChar); + var dataIn = await service.getCharacteristic(UUIDS.dataInChar); + var dataOut = await service.getCharacteristic(UUIDS.dataOutChar); + var version = await service.getCharacteristic(UUIDS.versionChar); + return { service: service, ctrl: ctrl, dataIn: dataIn, dataOut: dataOut, version: version }; +} +function writeWithResponse(char, data) { return char.writeValueWithResponse(data); } +function writeWithoutResponse(char, data) { return char.writeValueWithoutResponse(data); } +function readCharacteristic(char) { return char.readValue(); } +export { discoverCharacteristics, writeWithResponse, writeWithoutResponse, readCharacteristic }; +``` + +> ⚠️ 实现前需先 Read `tools/js/ble/gatt.js:98-102` 与 `:478-484` 与 `constants.js` 中 UUIDS 的精确字段名,本 step 为骨架。 + +- [ ] **Step 2: 冒烟(仅加载)** + +控制台执行 `import('./js/ble/gatt-helpers.js')` 无报错。 + +- [ ] **Step 3: 提交** + +```bash +git add tools/js/ble/gatt-helpers.js +git commit -m "refactor(js): 新增 ble/gatt-helpers.js 提取 GATT 发现/读写" +``` + +--- + +## 阶段 C:JS 调用点迁移与重复消除 + +> 阶段 C 每个 task 改调用点 + 删除旧实现,必须确保浏览器加载 + 对应路径冒烟通过。 + +### Task 12: 迁移所有 `setProgress` 调用点至 `common/progress.js` + +**Files:** +- Modify: `tools/js/ble/transfer.js:12-23`(删除本地 `setProgress`,import 新版) +- Modify: `tools/js/ble/file.js:95,98` +- Modify: `tools/js/ble/gatt.js:277,287,301,313,328,349,356,363,376` +- Modify: `tools/js/ota/progress.js:228-241`(删除本地 `setProgress`,import 新版;保留 `resetProgress` 但内部调用新版) +- Modify: `tools/js/ota/stages.js:207,302,307,311,314,319,330` +- Modify: `tools/js/ota/upload-esp32.js:223,251,255,261,296,307` +- Modify: `tools/js/ota/upload-gd32.js:58,96,151,164` + +- [ ] **Step 1: BLE 侧迁移** + +`tools/js/ble/transfer.js` 顶部加 `import { setProgress } from '../common/progress.js';`,删除 12-23 本地定义。确认 `ble/file.js`、`ble/gatt.js` 通过 `window` 或 import 拿到 `setProgress`——若它们当前通过 `window.setProgress` 调用,需在 transfer.js 末尾 `window.setProgress = setProgress;` 暂时兼容(阶段 D 再清理 window 暴露)。 + +- [ ] **Step 2: OTA 侧迁移** + +`tools/js/ota/progress.js` 顶部加 `import { setProgress as commonSetProgress } from '../common/progress.js';`,本地 `setProgress` (228-241) 删除。若其他文件通过 `window.setProgress` 调用 OTA 版,同样 `window.setProgress = commonSetProgress;`。 + +- [ ] **Step 3: 冒烟** + +`tools/index.html`:BLE OTA + WiFi OTA 各走一次,进度条正常推进 + 100% 时 `.done` 类生效。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/ble/transfer.js tools/js/ble/file.js tools/js/ble/gatt.js tools/js/ota/progress.js tools/js/ota/stages.js tools/js/ota/upload-esp32.js tools/js/ota/upload-gd32.js +git commit -m "refactor(js): 迁移所有 setProgress 至 common/progress.js" +``` + +--- + +### Task 13: 迁移阶段节点/状态栏操作至 `common/stage-ui.js` + +**Files:** +- Modify: `tools/js/ble/stages.js:15-123`(`getStageStatusBar`/`getStageStatusText`/`resetBleStages`/`setBleStage`/`setBleStageText`/`setBleStageStatus`/`setBleStageError` 改为薄包装或直接 re-export) +- Modify: `tools/js/ota/progress.js:56-128`(`resetEsp32StageNodes`/`resetGd32StageNodes`/`updateEsp32StagesFromPhase`/`updateEsp32StagesError`) +- Modify: `tools/js/ota/stages.js:49-102`(`updateStagesFromRelayState`)、`:243-249`(内联 err) +- Modify: `tools/js/ota/upload-gd32.js:97-100`(内联 ok 状态栏) + +- [ ] **Step 1: BLE stages.js 迁移** + +把 `getStageStatusBar`/`getStageStatusText` 改为 `export { getStageStatusBar, getStageStatusText } from '../common/stage-ui.js';`(删除本地实现)。`resetBleStages`/`setBleStage`/`setBleStageError` 内部改用 `stage-ui.js` 的 `resetStageNodes`/`setStageClass`/`markStageError`/`setStatusBar`,保留 BLE 专属的 stage-id 数组常量在 `ble/stages.js`。 + +- [ ] **Step 2: OTA progress.js 迁移** + +`resetEsp32StageNodes`/`resetGd32StageNodes` 改为调用 `resetStageNodes(ESP32_STAGE_IDS, ESP32_DETAIL_IDS)` 等。`updateEsp32StagesFromPhase`/`updateEsp32StagesError` 改用 `setStageClass`/`markStageError`/`setStatusBar`。 + +- [ ] **Step 3: OTA stages.js 与 upload-gd32.js 迁移** + +`updateStagesFromRelayState` 改用共享 helper;`:243-249` 与 `upload-gd32.js:97-100` 的内联状态栏赋值改用 `setStatusBar('err'/'ok', text)`。 + +- [ ] **Step 4: 冒烟** + +`tools/index.html`:BLE OTA 完整跑一遍,观察 6 个阶段节点 done/live/err 切换 + 状态栏文案正确。CAN 页无需测。 + +- [ ] **Step 5: 提交** + +```bash +git add tools/js/ble/stages.js tools/js/ota/progress.js tools/js/ota/stages.js tools/js/ota/upload-gd32.js +git commit -m "refactor(js): 迁移阶段节点/状态栏至 common/stage-ui.js" +``` + +--- + +### Task 14: 迁移 SHA-256 计算与比较至 `common/sha.js` + +**Files:** +- Modify: `tools/js/ble/file.js:114-143`(`computeFileSha256` 删除,改 import) +- Modify: `tools/js/ble/transfer.js:59-66`(`sha256HexToBytes` 删除,改 import)、`:185-188`(手写 hex 改用 `computeBufferSha256`) +- Modify: `tools/js/ota/file-handle.js:501-528`(`_computeAndCacheFileSha` 改用 `computeFileSha256`/`computeBufferSha256`) +- Modify: `tools/js/ble/gatt.js:387,421`(SHA 比较改用 `compareShaHex`) +- Modify: `tools/js/ota/upload-esp32.js:534`(SHA 比较改用 `compareShaHex`) + +- [ ] **Step 1: 替换 computeFileSha256 与 sha256HexToBytes** + +各文件顶部 import,删除本地定义。`ble/transfer.js:185-188` 的手写 hex 删除,改 `state.fileSha256 = await computeBufferSha256(fileBytes.buffer)`。 + +- [ ] **Step 2: 替换 compareShaHex** + +`gatt.js:387` `state.deviceSha256.toUpperCase() === state.fileSha256.toUpperCase()` → `compareShaHex(state.deviceSha256, state.fileSha256)`。`:421` 同理。`upload-esp32.js:534` `sha256.toLowerCase() === data.sha256.toLowerCase()` → `compareShaHex(sha256, data.sha256)`。 + +- [ ] **Step 3: 冒烟** + +BLE OTA + WiFi OTA 各跑一次,验证 SHA 比对结果与改前一致(成功路径与故意错配路径)。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/ble/file.js tools/js/ble/transfer.js tools/js/ble/gatt.js tools/js/ota/file-handle.js tools/js/ota/upload-esp32.js +git commit -m "refactor(js): 迁移 SHA-256 计算/比较至 common/sha.js" +``` + +--- + +### Task 15: 迁移 GATT 发现/读写至 `ble/gatt-helpers.js` + +**Files:** +- Modify: `tools/js/ble/gatt.js:98-102`(`connectBle` 中发现)、`:478-484`(`tryReconnect` 中发现) +- Modify: `tools/js/ble/transfer.js:228,254,314,329,374,416`(write 调用) +- Modify: `tools/js/ble/gatt.js:158`(readValue) + +- [ ] **Step 1: 替换 discoverCharacteristics** + +`gatt.js:98-102` 与 `:478-484` 两处 `getPrimaryService` + 4×`getCharacteristic` 改为 `var ch = await discoverCharacteristics(server);` 并把 `ch.ctrl`/`ch.dataIn` 等赋给 `state.*`。 + +- [ ] **Step 2: 替换 write/read 包装** + +可选:`writeValueWithResponse` → `writeWithResponse(char, data)` 等。若收益不大可仅替换发现部分,write/read 保持原样(本步可降级为 optional)。 + +- [ ] **Step 3: 冒烟** + +BLE 连接 + OTA 上传一次,观察发现/写入正常。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/ble/gatt.js tools/js/ble/transfer.js +git commit -m "refactor(js): 迁移 GATT 发现至 ble/gatt-helpers.js" +``` + +--- + +### Task 16: 迁移设备 IP/BaseUrl 至 `common/device-ip.js` + +**Files:** +- Modify: `tools/js/app.js:52-64`(`getEspIp`/`getBaseUrl` 删除,import;保留 `window.getBaseUrl` 兼容) +- Modify: `tools/js/can/dbc.js:38-60`(`getDeviceIp`/`setDeviceIp`/`getBaseUrl` 删除,import) +- Modify: `tools/js/can_monitor.js:31-45`(删除本地 `getBaseUrl`,import) +- Modify: `tools/js/common/connection.js:367`、`tools/js/common/polling.js:190`(已通过 `window.getBaseUrl`,确认仍可用) + +- [ ] **Step 1: app.js 迁移** + +`import { getDeviceIp as getEspIp, getBaseUrl } from './common/device-ip.js';`,删除本地 52-64。末尾 `window.getBaseUrl = getBaseUrl; window.getEspIp = getEspIp;` 保留兼容(因 `connection.js`/`polling.js` 用 `window.*`)。 + +- [ ] **Step 2: dbc.js 迁移** + +`import { getDeviceIp, setDeviceIp, getBaseUrl } from '../common/device-ip.js';`,删除 38-60。注意:`dbc.js` 原 `getDeviceIp` 读 `el.toolIp`,迁移后需用 `getDeviceIp({ inputId: 'toolIp' })` 调用,或确认 `el.toolIp` 在调用时已缓存。 + +- [ ] **Step 3: can_monitor.js 迁移** + +删除 31-45,import。`getBaseUrl` 调用点改为 `getBaseUrl({ inputId: 'monIp' })` 或 `{ inputId: 'toolIp' }`(按原文优先级)。 + +- [ ] **Step 4: 冒烟** + +`tools/can.html` 与 `tools/index.html` 各加载一次,确认连接的目标 IP 正确(默认 192.168.31.127),切换 IP 后 localStorage 持久化生效。 + +- [ ] **Step 5: 提交** + +```bash +git add tools/js/app.js tools/js/can/dbc.js tools/js/can_monitor.js +git commit -m "refactor(js): 迁移设备 IP/BaseUrl 至 common/device-ip.js" +``` + +--- + +### Task 16b: 消除 `bytesToHex`/`formatTime` 重复实现 + +**Files:** +- Modify: `tools/js/can/dbc.js:79-86`(删除本地 `bytesToHex`,统一用 `common/utils.js` 的) +- Modify: `tools/js/can/send.js:23-29`(删除本地 `formatTime`,改用参数化 `common/utils.js` 版) + +- [ ] **Step 1: 统一 bytesToHex** + +先确认 `common/utils.js:51-60` 的 `bytesToHex` 输出小写,而 `can/dbc.js:79-86` 用 `padHex` 输出大写。需检查 `dbc.js` 的 `bytesToHex` 调用点(`can/send.js`、`can/signals.js`)对大小写的依赖。 + +- 若调用点只用于显示且显示要求大写:在 `common/utils.js` 给 `bytesToHex` 加 `{ upper: true }` 选项: + ```js + function bytesToHex(bytes, opts) { + var hex = Array.prototype.map.call(new Uint8Array(bytes), function (b) { + var h = b.toString(16).padStart(2, '0'); + return opts && opts.upper ? h.toUpperCase() : h; + }).join(''); + return hex; + } + ``` + 然后 `dbc.js` 调用 `bytesToHex(bytes, { upper: true })`。 +- 删除 `dbc.js:79-86` 本地定义与 `padHex`(若 `padHex` 无其他调用点)。 + +- [ ] **Step 2: 统一 formatTime** + +在 `common/utils.js:17-23` 的 `formatTime` 加 `opts.millis` 选项(默认 true): +```js +function formatTime(date, opts) { + var d = date instanceof Date ? date : new Date(date); + var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; + var base = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); + if (opts && opts.millis === false) return base; + return base + '.' + ('00' + d.getMilliseconds()).slice(-3); +} +``` +`can/send.js:23-29` 删除本地定义,调用改为 `formatTime(date, { millis: false })`。 + +- [ ] **Step 3: 冒烟** + +CAN 页发送一帧,历史记录时间显示 `HH:MM:SS`(无毫秒);hex 预览/字节条显示大写。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/common/utils.js tools/js/can/dbc.js tools/js/can/send.js +git commit -m "refactor(js): 统一 bytesToHex/formatTime 至 common/utils.js" +``` + +--- + +### Task 17: 提取 `buildOtaBody` 消除 esp32/gd32 OTA body 重复组装 + +**Files:** +- Create: `tools/js/common/ota-body.js` +- Modify: `tools/js/ota/upload-esp32.js:219-242` + +- [ ] **Step 1: 创建 ota-body.js** + +```js +// tools/js/common/ota-body.js +function buildOtaBody(arrayBuffer, headerObj) { + var encoder = new TextEncoder(); + var headerBytes = encoder.encode(JSON.stringify(headerObj)); + var payload = new Uint8Array(arrayBuffer); + var combined = new Uint8Array(headerBytes.length + payload.length); + combined.set(headerBytes, 0); + combined.set(payload, headerBytes.length); + return combined.buffer; +} +export { buildOtaBody }; +``` + +- [ ] **Step 2: 替换 upload-esp32.js:219-242** + +ESP32 分支:`buildOtaBody(payload, { type: 'esp32', total_size: payload.byteLength })` +GD32 分支:`buildOtaBody(payload, { total_size: payload.byteLength })` + +- [ ] **Step 3: 冒烟** + +WiFi OTA 上传 ESP32 与 GD32 固件各一次,服务端正常解析。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/common/ota-body.js tools/js/ota/upload-esp32.js +git commit -m "refactor(js): 提取 buildOtaBody 消除 esp32/gd32 body 重复" +``` + +--- + +### Task 18: 统一 SHA verify panel UI(BLE 与 OTA 共用同一套 DOM id) + +**Files:** +- Create: `tools/js/common/verify-panel.js` +- Modify: `tools/js/ble/verify.js:3-26`(改为薄包装或 re-export) +- Modify: `tools/js/ota/upload-esp32.js:200-207,486-489,529-540,557-564,448-452,470-475`(内联 DOM 写改用共享 helper) + +- [ ] **Step 1: 创建 verify-panel.js** + +```js +// tools/js/common/verify-panel.js +function showVerifyInfo(frontSha, devSha, opts) { + opts = opts || {}; + var panel = document.getElementById('stageVerifyInfo'); + if (panel) panel.hidden = false; + var f = document.getElementById('sviFrontSha'); + var d = document.getElementById('sviDeviceSha'); + var r = document.getElementById('sviResult'); + if (f) f.textContent = frontSha || ''; + if (d) d.textContent = devSha || ''; + if (r) { + r.textContent = opts.text || ''; + r.style.color = opts.ok ? '#2ecc71' : (opts.err ? '#e74c3c' : ''); + } +} +function hideVerifyInfo() { + var panel = document.getElementById('stageVerifyInfo'); + if (panel) panel.hidden = true; +} +function resetVerifyInfo() { + ['sviFrontSha', 'sviDeviceSha', 'sviResult'].forEach(function (id) { + var el = document.getElementById(id); + if (el) { el.textContent = ''; el.style.color = ''; } + }); +} +export { showVerifyInfo, hideVerifyInfo, resetVerifyInfo }; +``` + +> ⚠️ 实现前需 Read `tools/js/ble/verify.js:3-26` 与 `ota/upload-esp32.js` 上述行段,确认精确 DOM id 与样式字段,本 step 为骨架。 + +- [ ] **Step 2: ble/verify.js 改为 re-export** + +`export { showVerifyInfo as showBleVerifyInfo, hideVerifyInfo as hideBleVerifyInfo } from '../common/verify-panel.js';`(若 `ble/verify.js` 有 BLE 专属逻辑则保留包装)。 + +- [ ] **Step 3: ota/upload-esp32.js 内联 DOM 写改用 helper** + +`:200-207` reset → `resetVerifyInfo()`;`:486-489` show → `showVerifyInfo(...)`;`:529-540` 比对成功 → `showVerifyInfo(front, dev, { text: '匹配', ok: true })`;`:557-564` 失败 → `showVerifyInfo(front, dev, { text: '不匹配', err: true })`。 + +- [ ] **Step 4: 冒烟** + +BLE OTA 与 WiFi OTA 各跑一次,验证 SHA 面板显示与改前一致。 + +- [ ] **Step 5: 提交** + +```bash +git add tools/js/common/verify-panel.js tools/js/ble/verify.js tools/js/ota/upload-esp32.js +git commit -m "refactor(js): 统一 SHA verify panel UI 至 common/verify-panel.js" +``` + +--- + +## 阶段 D:JS 过长函数拆分 + +### Task 19: 拆分 `ble/gatt.js:handleNotify` (262 行) + +**Files:** +- Modify: `tools/js/ble/gatt.js:182-444` + +- [ ] **Step 1: 识别子段** + +按通知类型拆出三个本地函数: +- `handleAckNotify(buf)` — ACK 解析 + state + progress + waiter 解析 +- `handleFinishNotify(buf)` — FINISH 解析 + 阶段推进 + 结果等待 +- `handleSha256Notify(buf)` — SHA256 解析 + verify panel + 比较 + +- [ ] **Step 2: 重写 handleNotify 为 dispatcher** + +```js +function handleNotify(event) { + var buf = new Uint8Array(event.target.value.buffer); + if (buf.length < 1) return; + switch (buf[0]) { + case BLE_OTA_NOTIFY_ACK: return handleAckNotify(buf); + case BLE_OTA_NOTIFY_FINISH: return handleFinishNotify(buf); + case BLE_OTA_NOTIFY_SHA256: return handleSha256Notify(buf); + default: bleLog('未知通知 0x' + buf[0].toString(16)); + } +} +``` + +- [ ] **Step 3: 冒烟** + +BLE OTA 全流程,观察 ACK/FINISH/SHA256 三类通知处理正常。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/ble/gatt.js +git commit -m "refactor(ble): 拆分 handleNotify 为 ack/finish/sha256 三个 handler" +``` + +--- + +### Task 20: 拆分 `ble/transfer.js:uploadFirmware` (367 行) + +**Files:** +- Modify: `tools/js/ble/transfer.js:107-474` + +- [ ] **Step 1: 提取子函数** + +- `refreshFileBeforeUpload()` — 文件重读逻辑 (109-120) +- `sendWindow(windowStart, endSeq, fileBytes, payloadSize, cfg)` — 单窗口发送 (219-302) +- `retryWindow(resumeSeq, endSeq, ...)` — 重传 (304-345) — 与 sendWindow 共享 per-packet 体 +- `buildEsp32Trigger(fileSha)` — ESP32 触发包 (362-396) +- `buildGd32Trigger(fileSha, sessionId, cfg)` — GD32 触发包 (400-441) + +- [ ] **Step 2: 重写 uploadFirmware 为编排** + +保留外层窗口循环、retry 决策、stage 推进、结果等待;per-packet 体提到 `sendPacket(seq, fileBytes, payloadSize, cfg)` 共享。 + +- [ ] **Step 3: 冒烟** + +BLE OTA 上传 ESP32 与 GD32 固件各一次,观察窗口推进/重传/触发/结果等待正常。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/ble/transfer.js +git commit -m "refactor(ble): 拆分 uploadFirmware 为窗口/触发子函数" +``` + +--- + +### Task 21: 拆分 `ota/upload-esp32.js:startUpload` (299 行) + +**Files:** +- Modify: `tools/js/ota/upload-esp32.js:56-355` + +- [ ] **Step 1: 提取子函数** + +- `probeOtaMutex(chip)` — 4 次重试的 mutex 探测 +- `probeStaleSession(chip)` — 旧会话探测 +- `buildUploadBody(chip, payload)` — 用 Task 17 的 `buildOtaBody` +- `wireXhrHandlers(xhr, chip, payload)` — 5 个 XHR handler 绑定(progress/load/error/abort/timeout) + +- [ ] **Step 2: 重写 startUpload 为编排** + +- [ ] **Step 3: 冒烟** + +WiFi OTA 上传 ESP32 与 GD32 各一次。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/ota/upload-esp32.js +git commit -m "refactor(ota): 拆分 startUpload 为 mutex/body/xhr 子函数" +``` + +--- + +### Task 22: 拆分 `common/polling.js:pollStatus` (122 行) 与 `ota/stages.js:applyGd32OtaSessionUi` (151 行) + +**Files:** +- Modify: `tools/js/common/polling.js:182-303` +- Modify: `tools/js/ota/stages.js:182-333` + +- [ ] **Step 1: pollStatus catch 拆分** + +把 228-290 的 7 分支 if/else 拆出 `handlePollRecovery`/`handlePollReboot`/`handlePollSessionFailure`/`handlePollDeviceBusy`/`handlePollDisconnected`,`pollStatus` 的 catch 调用之。 + +- [ ] **Step 2: applyGd32OtaSessionUi 拆分** + +按 `otaSessionState` 分支拆出 `applyGd32Failed`/`applyGd32Relaying`/`applyGd32Storing`/`applyGd32Done`。 + +- [ ] **Step 3: 冒烟** + +模拟 OTA 失败/转发中/存储中/完成四态(可手动改 `window.otaSessionState`),观察 UI 切换正常;pollStatus 在断连/重启/失败下行为正常。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/common/polling.js tools/js/ota/stages.js +git commit -m "refactor(js): 拆分 pollStatus 与 applyGd32OtaSessionUi" +``` + +--- + +## 阶段 E:死代码/一次性 helper 清理 + +### Task 23: C++ 侧死代码与内联 + +**Files:** +- Modify: `main/server/ble_ota.cpp:346-349`(内联 `BleOta::isConnected()` 或保留——评估后决定) +- Modify: `main/server/ble_ota_host.cpp:228-236`(0xFB handler 内联队列 drain 改用 `drainOtaDataQueue()`) +- Modify: `main/include/app_config.h:126`(确认 `BLE_OTA_CTRL_VERIFY_SHA256 = 0xF9` 是否死代码) + +- [ ] **Step 1: 0xFB handler 改用 drainOtaDataQueue** + +`ble_ota_host.cpp:228-236` 的内联 drain 循环替换为 `drainOtaDataQueue();`。 + +- [ ] **Step 2: 检查 BLE_OTA_CTRL_VERIFY_SHA256** + +`grep -rn "0xF9\|BLE_OTA_CTRL_VERIFY_SHA256" main/ tools/`。若全无引用,删除 `app_config.h:126` 该行。 + +- [ ] **Step 3: 评估 BleOta::isConnected** + +若调用点仅 2 处且均为简单条件判断,内联;否则保留。本 step 可降级为 no-op。 + +- [ ] **Step 4: 验证编译 + 冒烟** + +- [ ] **Step 5: 提交** + +```bash +git add main/server/ble_ota_host.cpp main/include/app_config.h main/server/ble_ota.cpp +git commit -m "refactor(ble_ota): 清理 0xFB 内联 drain 与死常量" +``` + +--- + +### Task 24: 消除 `polling.js` 与 `app.js` 的重复 IP-change 监听 + +**Files:** +- Modify: `tools/js/common/polling.js:372-387` +- Modify: `tools/js/app.js:236-249` + +- [ ] **Step 1: 确认重复** + +读两边代码,确认同一 `#espIp` `change` 事件被绑定两次(一次 `app.js` 初始化时,一次 `polling.js` setTimeout 500ms 后)。 + +- [ ] **Step 2: 删除 polling.js 中的重复绑定** + +保留 `app.js:236-249` 版本(更早绑定),删除 `polling.js:376-386` 的 IP change 绑定;保留 `polling.js:373-375` 的 upload button label 更新(若无重复)。 + +- [ ] **Step 3: 冒烟** + +`tools/index.html` 修改 IP 输入,确认只触发一次保存 + 重置失败计数 + 重启轮询。 + +- [ ] **Step 4: 提交** + +```bash +git add tools/js/common/polling.js +git commit -m "refactor(js): 删除 polling.js 重复的 IP change 监听" +``` + +--- + +### Task 25: JS 杂项死代码清理 + +**Files:** +- Modify: `tools/js/can/messages.js:27`(`el.importDbcBtn.hidden = false` 无操作) +- Modify: `tools/js/can_monitor.js:22-23`(重复的 toast ID 缓存,若与 `dbc.js` 的 `el` 共享) +- Modify: `tools/js/ble/state.js:99-105`(`hexToBytes` 单调用点评估内联) + +- [ ] **Step 1: 删除 messages.js:27 无操作行** + +- [ ] **Step 2: can_monitor.js toast ID 评估** + +确认 `can_monitor.js` 是否与 `can/dbc.js` 共享 `el`。若共享,删除 `:22-23` 重复 toast ID。若独立保留。 + +- [ ] **Step 3: 评估 hexToBytes 内联** + +`ble/state.js:99-105` 仅 `ble/transfer.js:408` 调用。若 `cfg.ctrlHex` 改为预编码常量字节数组(在 `ble/constants.js` 的 TARGETS 里),可删除 `hexToBytes`。本 step 可降级为 no-op。 + +- [ ] **Step 4: 冒烟** + +`tools/can.html` 加载无报错;CAN 监控 toast 正常。 + +- [ ] **Step 5: 提交** + +```bash +git add tools/js/can/messages.js tools/js/can_monitor.js tools/js/ble/state.js tools/js/ble/constants.js tools/js/ble/transfer.js +git commit -m "refactor(js): 清理无操作行与重复 toast 缓存" +``` + +--- + +## 自检清单(Self-Review) + +### 1. Spec 覆盖 + +用户请求:「扫描每个函数,评估是否可以精简,主要关注复用关系」。覆盖维度: +- ✅ 复用:hex_utils、progress、stage-ui、sha、fetch-json、device-ip、gatt-helpers、verify-panel、buildOtaBody、triggerGd32RelayCommon、shouldSendThrottledAck、bytesToHex/formatTime 统一 +- ✅ 精简:拆分 ble_ota_ctrl_access、ota_upload_handler、handleNotify、uploadFirmware、startUpload、pollStatus、applyGd32OtaSessionUi +- ✅ 死代码:F9 常量、重复 IP 监听、无操作 hidden、重复 toast 缓存 + +### 2. Placeholder 扫描 + +- Task 4 Step 2、Task 7 Step 1、Task 11 Step 1、Task 18 Step 1 含「以原文为准」骨架——已显式标注 ⚠️ 并要求先 Read 原文。这是必要的谨慎(避免凭记忆改写节流条件/DOM id),非占位。 +- Task 23 Step 3、Task 25 Step 3 标注「可降级为 no-op」——这是显式的可选清理,非占位。 + +### 3. 类型/命名一致性 + +- `setProgress(percent, text)` 跨 BLE/OTA 一致。 +- `getStageStatusBar`/`getStageStatusText` 在 ble/stages.js 与 common/stage-ui.js 一致。 +- `compareShaHex(a, b)` 在 common/sha.js 与调用点一致。 +- `buildOtaBody(arrayBuffer, headerObj)` 在 common/ota-body.js 与调用点一致。 +- `triggerGd32RelayCommon` 在 C++ 侧命名与 `triggerGd32Relay` 区分清晰。 + +--- + +## Execution Handoff + +**Plan complete and saved to `.zcode/plans/2026-07-18-function-simplify-reuse.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** - 每个 task 派一个新 subagent,task 间做两阶段 review,迭代快。 + +**2. Inline Execution** - 在当前 session 用 executing-plans 批量执行 + checkpoint review。 + +**Which approach?** \ No newline at end of file diff --git a/.zcode/plans/plan-sess_0abed1e8-31bb-447c-adc2-330aed3f57da.md b/.zcode/plans/plan-sess_0abed1e8-31bb-447c-adc2-330aed3f57da.md new file mode 100644 index 0000000..78c2efa --- /dev/null +++ b/.zcode/plans/plan-sess_0abed1e8-31bb-447c-adc2-330aed3f57da.md @@ -0,0 +1,53 @@ +## 优化 toast.js + polling.js(不改用户可见行为,不破坏时序) + +### A. `tools/js/common/toast.js` + +1. **空值保护**:`showToast` 开头加 `var toast = document.getElementById('toast'); if (!toast) return;`,避免 toast 元素未挂载时调用方崩。其余 `toastIco` / `toastMsg` 用 `var ico = ...; var msg = ...;` 取出后判空再用。 +2. **`info` 类型语义化**:保持视觉与现状一致,但修正注释。文件头注释从「支持 info / success / warning / error」改为如实描述「支持 success / error / warning(info 等同 warning)」,避免注释误导。不新增 CSS(无 caller 用 info,引入新色只会造成不一致)。 + +### B. `tools/js/common/polling.js` + +5. **`pollLogs` 改递归 `setTimeout` + 在途保护**(修复 socket 抢占): + - 新增模块级 `logsInFlight` 标志和 `logsTimerId`(已有)。 + - 抽出 `scheduleLogsPoll(delayMs)`(仿 `scheduleStatusPoll`)。 + - `pollLogs` 体内首行 `if (logsInFlight) return;`;`finally` 内 `logsInFlight=false` + `scheduleLogsPoll(statusPollIntervalMs)`。 + - `startPolling` / `resumePollingAfterUpload` / `visibilitychange` 改为 `scheduleLogsPoll(0)` 或保留 `pollLogs()` 直接触发(in-flight 守卫会跳过重叠)。 + - 注意:`stages.js`、`upload-gd32.js` 的手动 `window.pollLogs()` 调用保持不变,in-flight 守卫下若正飞则跳过、由下一轮定时补上,行为安全。 + +### C. `tools/js/app.js` + +6. **新增 `applyDeviceBusyRuntimeState(text)` helper**(放在 `applyUploadingRuntimeState` 之后,第 148 行后): + - 仿现有三兄弟的 DOM 模式:`#pillConn`/`#pillConnTxt`/`#connStatus`/`#connStatusTxt`/`#bConn`。 + - 第 225 行后追加 `window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState;`。 + +### D. 改动后行为一致性自检(写进 commit message,不写测试) + +- 暂停上传:`pollStatus.finally` 不再重挂轮询 → 修正竞态,符合既有注释意图。 +- `pollLogs` 重叠:被 in-flight 守卫挡掉 → 减少与 `/ota/upload` 抢 socket,符合既有 `pollStatus` 的设计意图。 +- 重启恢复:两处统一走 `markEsp32RebootComplete`,重复 toast/log 由 `isEsp32Rebooting` 幂等守卫消除。 +- 设备忙 UI:从 polling.js 内联 DOM 改为调 helper,视觉完全一致(同样的 class、同样的文案)。 +- toast `info`:视觉不变(仍为 warn 色),仅注释修正。 + +### 不做的事 + +- 不改后端协议、不改 `/system/status`、`/logs` URL 或请求字段。 +- 不改 `pollStatus` 的成功分支业务逻辑(重启恢复仅抽出函数,条件不变)。 +- 不新增 toast CSS / 不引入 `info` 专属配色。 +- 不动 `connection.js`、`log.js`、`ota/*`、`stages.js`、`upload-gd32.js`(仅 app.js 加一个 helper)。 +- 不改 `scheduleStatusPoll` 公共签名、不改导出列表。 + +### 验证 + +- `git diff` 人工核对 polling.js 重构后两处重启恢复分支与原逻辑逐行等价。 +- 浏览器加载 `tools/index.html`,走一遍 ESP32 OTA(上传→中继→重启→恢复),确认: + - 上传期间无 `/system/status` 请求(DevTools Network)。 + - `/logs` 不出现重叠并发请求。 + - 重启恢复只弹一次「ESP32 已恢复在线」。 + - 设备忙时连接胶囊显示「设备忙」,与改前一致。 +- 跑一次 GD32 OTA(中继期 400ms 轮询),确认 `setStatusPollIntervalMs` 路径未受影响。 +- 无单元测试框架,不新增测试。 + +### 影响面 + +- 修改文件:`tools/js/common/toast.js`、`tools/js/common/polling.js`、`tools/js/app.js`(仅 +1 helper + 1 window 挂载)。 +- 风险点:`pollLogs` 改递归 setTimeout 后,若某轮抛出未走 finally 会停转——`pollLogs` 全程 `try/catch/finally`,与 `pollStatus` 同模式,风险等价。 \ No newline at end of file diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 858e3ce..023bad5 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -9,10 +9,12 @@ idf_component_register( "storage/gd32_flash_store.cpp" # GD32 Flash 存储抽象层(H1 提取) "server/wifi_ota.cpp" # WiFi HTTP OTA 服务器 "server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取) - "server/ble_ota.cpp" # BLE GATT OTA 服务器 + "server/ble_ota.cpp" # BLE OTA 公开 API / 收尾 / 共存 + "server/ble_ota_data.cpp" # BLE OTA 数据路径(reorder/worker/DATA_IN) + "server/ble_ota_host.cpp" # BLE OTA GATT/GAP/广播/Host "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检) "monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务 - "monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1) + "monitor/can_feedback_task.cpp" # CAN 反馈被动监听(0xC1 takeEvent) "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) "monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享) "monitor/stack_overflow_hook.cpp" # FreeRTOS 栈溢出钩子(canary 触发时 UART 硬写告警) diff --git a/main/include/app_config.h b/main/include/app_config.h index cfb79a4..00699a1 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -60,7 +60,7 @@ inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.2KB inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~2KB inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:peak~1KB -inline constexpr uint32_t TASK_STACK_CAN_FB = 3072; ///< can_fb:1Hz 轮询 +inline constexpr uint32_t TASK_STACK_CAN_FB = 3072; ///< can_fb:被动 takeEvent(0xC1) inline constexpr uint32_t TASK_STACK_BLE_FINISH = 4096; ///< 按需:SHA finalize(保持) inline constexpr uint32_t TASK_STACK_BLE_GD32_WAIT = 3072; ///< 按需:GD32 中继等待 inline constexpr uint32_t TASK_STACK_BLE_RESTART = 2048; ///< 按需:延时重启 @@ -130,9 +130,13 @@ inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; ///< BLE 数 inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; ///< BLE 数据通道:ESP32 固件包标识 inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; ///< BLE 单包数据载荷大小(字节) // 滑动 ACK 窗口(须与前端 tools/js/ble/constants.js BLE_OTA_ACK_WINDOW 保持一致) -inline constexpr size_t BLE_OTA_ACK_WINDOW = 32; +// ★ 64:实测 Windows WebBT 窗口内单包间隔 0.4-2.9ms(2M PHY 多包/事件), +// 瓶颈在每窗口的 ACK 往返。窗口 32→64 提速 ~10%(40→44 KB/s),再试 96 无收益 +// (ACK 往返在 64 已摊薄,稳态瓶颈是 Windows GATT 每包 ~10.6ms resolve 延迟)。 +// 内存:64×480B ≈ 30KB reorder buffer,分配失败有 fallback 丢弃策略兜底。 +inline constexpr size_t BLE_OTA_ACK_WINDOW = 64; // Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW) -inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32; +inline constexpr size_t BLE_OTA_REORDER_WINDOW = 64; // 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批 inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 16); diff --git a/main/include/version.h b/main/include/version.h index 9a4110d..e246c81 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 50 -#define FW_VERSION_STRING "0.3.50" +#define FW_VERSION_PATCH 51 +#define FW_VERSION_STRING "0.3.51" // 编译时间 -#define BUILD_TIME "2026-07-17 02:02:16" +#define BUILD_TIME "2026-07-18 00:40:04" // Git 信息 -#define GIT_HASH "6364eac" +#define GIT_HASH "1b10f89" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index f78fdab..43db032 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -3,10 +3,10 @@ * * 架构(GD32 主动推送,ESP32 被动接收): * GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页, - * 通过 requestEsp32Read(0xC1) 下拉 SPIready 主动按铃,将每页反馈 payload - * 推送给 ESP32。ESP32 本任务调用 checkGd32Request() 阻塞等待按铃,读到 - * 一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再 - * publish() 到 s_latest[channel] 供 Web/WebSocket 读取。 + * 通过 requestEsp32Read(0xC1) 下拉 DATA_READY 主动按铃,将每页反馈 payload + * 推送给 ESP32。ESP32 本任务 SpiMaster::takeEvent() 阻塞等待按铃/事件, + * 读到一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再 + * publish() 到 s_latest[channel] 供 Web 读取。 * * 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。 * @@ -161,9 +161,9 @@ void pauseCanFeedbackTask(bool pause) if (s_paused == pause) return; // 幂等:状态未变时不重复日志 s_paused = pause; if (pause) { - DEBUG_INFO(kTag, "CAN 监听已暂停(OTA 进行中)"); + DEBUG_DBG(kTag, "CAN 监听已暂停(OTA 进行中)"); } else { - DEBUG_INFO(kTag, "CAN 监听已恢复"); + DEBUG_DBG(kTag, "CAN 监听已恢复"); } } diff --git a/main/monitor/can_feedback_task.h b/main/monitor/can_feedback_task.h index 356deaa..6a03e64 100644 --- a/main/monitor/can_feedback_task.h +++ b/main/monitor/can_feedback_task.h @@ -1,5 +1,7 @@ -/* - * can_feedback_task.h - 周期性轮询 GD32 CAN 反馈快照的任务接口 +/* + * can_feedback_task.h - 被动接收 GD32 主动推送的 CAN 反馈快照 + * + * ESP32 侧不主动 1Hz 轮询 0xC1;由 GD32 按铃推送,本任务 takeEvent 接收。 */ #ifndef CAN_FEEDBACK_TASK_H #define CAN_FEEDBACK_TASK_H @@ -7,7 +9,7 @@ #include #include "transport/canfd_bridge.h" -/** @brief 暂停/恢复 CAN 反馈轮询(OTA 期间调用 pause=true)。 */ +/** @brief 暂停/恢复 CAN 反馈监听(OTA 期间调用 pause=true)。 */ void pauseCanFeedbackTask(bool pause); #ifdef __cplusplus @@ -15,7 +17,7 @@ extern "C" { #endif /** - * @brief 启动 1Hz CAN 反馈轮询任务(幂等)。 + * @brief 启动 CAN 反馈被动监听任务(幂等)。 * @return true 表示任务已存在或创建成功。 */ bool startCanFeedbackTask(); diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index 4c11d8b..2a5e568 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -144,7 +144,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) } const esp_partition_t *boot_partition = esp_ota_get_running_partition(); - DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx", + DEBUG_DBG(TAG, "当前启动分区: %s, size=%u, address=0x%lx", boot_partition->label, boot_partition->size, boot_partition->address); // 选择非当前启动的 OTA 分区(OTA_0 → OTA_1 fallback) @@ -161,7 +161,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) return beginCleanupAndReturn(ESP_FAIL); } - DEBUG_INFO(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx", + DEBUG_DBG(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx", m_partition->label, m_partition->size, m_partition->address); if (total_size > m_partition->size) { @@ -191,7 +191,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) esp_err_to_name(err), (long long)((t1 - t0) / 1000)); return beginCleanupAndReturn(err); } - DEBUG_INFO(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu", + DEBUG_DBG(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu", (long long)((t1 - t0) / 1000), (unsigned long)esp_get_free_heap_size()); @@ -201,7 +201,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) m_ended = false; memset(m_sha256, 0, sizeof(m_sha256)); - DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase-on-write)", + DEBUG_DBG(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase-on-write)", m_partition->label, (unsigned)total_size); return ESP_OK; } @@ -241,7 +241,7 @@ esp_err_t Esp32OtaCore::computeShaFromPartition() return ESP_ERR_INVALID_STATE; } - DEBUG_INFO(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received); + DEBUG_DBG(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received); /* static context + 小读缓冲:勿占 httpd 栈,也勿再静态吃 4KB BSS */ static mbedtls_sha256_context s_sha_ctx; @@ -279,7 +279,7 @@ esp_err_t Esp32OtaCore::computeShaFromPartition() for (int i = 0; i < 32; ++i) { snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02x", m_sha256[i]); } - DEBUG_INFO(TAG, "设备端 SHA-256: %s", hex); + DEBUG_DBG(TAG, "设备端 SHA-256: %s", hex); return ESP_OK; } @@ -324,7 +324,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, // ── 先读回分区计算 SHA-256,并与前端文件 SHA 比对 ── if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) { - DEBUG_INFO(TAG, "ESP32 OTA 校验 SHA-256..."); + DEBUG_DBG(TAG, "ESP32 OTA 校验 SHA-256..."); esp_err_t sha_err = computeShaFromPartition(); if (sha_err != ESP_OK) { DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err)); @@ -351,7 +351,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot, } if (sha_match) { - DEBUG_INFO(TAG, "SHA-256 校验通过"); + DEBUG_DBG(TAG, "SHA-256 校验通过"); } else { DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s", frontend_sha_hex, device_hex); diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 846b35d..765ea27 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -182,7 +182,7 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch m_sysState = OTA_SYS_GD32_STORING; m_gd32StorageDone = false; m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻 - DEBUG_INFO(TAG, "GD32 固件存储开始: %" PRIu32 " 字节, 系统状态 → GD32_STORING (epoch=%u, channel=%d)", + DEBUG_DBG(TAG, "GD32 固件存储开始: %" PRIu32 " 字节, 系统状态 → GD32_STORING (epoch=%u, channel=%d)", total_size, m_otaEpoch, (int)channel); } unlock(h); @@ -248,7 +248,7 @@ void OtaCoordinator::completeGd32Storage() m_gd32StorageDone = true; m_busySinceTick = 0; // ★ STORED 是非忙状态 resume_ble = (m_otaChannel == OTA_CHANNEL_WIFI); - DEBUG_INFO(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED"); + DEBUG_DBG(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED"); } else if (h) { DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState); } @@ -324,7 +324,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay() m_sysState = OTA_SYS_GD32_RELAYING; m_busySinceTick = xTaskGetTickCount(); - DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING"); + DEBUG_DBG(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING"); unlock(h); return ESP_OK; } @@ -337,7 +337,7 @@ void OtaCoordinator::notifyRelayStarted() if (m_sysState != OTA_SYS_GD32_RELAYING) { m_sysState = OTA_SYS_GD32_RELAYING; m_busySinceTick = xTaskGetTickCount(); - DEBUG_INFO(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING"); + DEBUG_DBG(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING"); } pauseCanFeedbackTask(true); } @@ -521,7 +521,7 @@ void OtaCoordinator::setEsp32StateLocked(ota_system_state_t s, const char *dbg_m } else if (s == OTA_SYS_ESP32_FINALIZING && m_busySinceTick == 0) { m_busySinceTick = xTaskGetTickCount(); } - DEBUG_INFO(TAG, "%s", dbg_msg); + DEBUG_DBG(TAG, "%s", dbg_msg); } /** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */ diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 17731d8..02c7581 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -1,125 +1,20 @@ /* - * ble_ota.cpp - BLE OTA GATT 服务器实现 (ESP-IDF 6.0 NimBLE) - * 所有功能封装为 BleOta 类方法。 - * T5.1 - T5.5 - * - * 通过 BLE GATT 接收上位机固件,支持两种目标: - * - GD32: 存入 Flash → 触发 SPI 中继转发到 GD32 - * - ESP32: 直接写入 OTA 分区 → 触发重启升级自身 - * - * GATT 服务: - * Service UUID: 0xFFFF - * - CTRL (0x0001, Write): 0xF7=触发GD32中继(含 32B SHA-256 校验), 0xF8=触发ESP32 OTA升级(含 32B SHA-256 校验并 reboot),0xFA=重启ESP32 - * - DATA_IN (0x0002, Write): cmd=0x00=GD32数据, cmd=0x01=ESP32数据 - * - DATA_OUT (0x0003, Notify): 推送响应 - * - VERSION (0x0004, Read): 固件版本 + * ble_ota.cpp - BLE OTA 公开 API + 收尾任务 + WiFi 共存 + * 数据路径:ble_ota_data.cpp;GATT/GAP:ble_ota_host.cpp */ -#include -#include -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "freertos/semphr.h" -#include "freertos/queue.h" -#include "esp_err.h" -#include "esp_ota_ops.h" -#include "esp_system.h" -#include "esp_timer.h" -#include "esp_wifi.h" +#include "ble_ota_priv.h" -// NimBLE 协议栈 -#include "nimble/nimble_port.h" -#include "nimble/nimble_port_freertos.h" -#include "host/ble_hs.h" -#include "host/ble_gap.h" -#include "nimble/ble.h" /* BLE_ERR_REM_USER_CONN_TERM */ -#include "services/gap/ble_svc_gap.h" -#include "services/gatt/ble_svc_gatt.h" -#include "host/ble_att.h" +QueueHandle_t s_otaDataQueue = nullptr; +bool s_wifi_ps_boosted = false; -// SHA-256 计算已抽离到 Esp32OtaCore,本文件不再直接引用 mbedtls - -#include "app_config.h" -#include "ble_ota.h" -#include "esp32_ota_core.h" -#include "ota_coordinator.h" -#include "ota_relay.h" -#include "storage/gd32_flash_store.h" -#include "health_monitor.h" -#include "monitor/debug_log.h" - -#define TAG "BLE_OTA" -#define BLE_OTA_NOTIFY_ACK 0xA1 -#define BLE_OTA_NOTIFY_FINISH 0xB1 -#define BLE_OTA_NOTIFY_SHA256 0xC1 // 设备端 SHA-256 上报([0xC1] + [32字节]) -#define BLE_OTA_FINISH_DONE 0x02 -#define BLE_OTA_FINISH_REBOOT_NOW 0x07 -#define BLE_OTA_FINISH_GD32_RELAY_START 0x10 -#define BLE_OTA_FINISH_GD32_RELAY_DONE 0x11 -#define BLE_OTA_FINISH_ABORTED 0x20 // 用户主动中止(BLE 不断开) -#define BLE_OTA_FINISH_ERR_FINALIZE 0x81 -#define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用 -#define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用 -#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小 -#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 // OTA SHA-256 不匹配 -#define BLE_OTA_FINISH_ERR_GD32_RELAY 0x86 // GD32 SPI OTA 转发失败 -// BLE_OTA_ACK_WINDOW / BLE_OTA_DATA_QUEUE_LEN / BLE_OTA_REORDER_WINDOW → app_config.h - -struct BleOtaDataBatch { - uint8_t *data; - size_t len; -}; -static QueueHandle_t s_otaDataQueue = nullptr; - -struct BleGd32RelayWaitCtx { - uint16_t session_id; -}; - -struct BleEsp32FinishCtx { - char sha_hex[65]; - uint16_t session_id; -}; - -// RAII 包装:保证任务所有 return 路径自动释放 ctx,避免漏 free。 -// 注意:若调用 restartEsp32()(内部 esp_restart 不返回),析构不会执行, -// 但这是设备重启路径,资源由系统回收。 -struct BleFinishCtxDeleter { - BleEsp32FinishCtx *p; - explicit BleFinishCtxDeleter(BleEsp32FinishCtx *ctx) : p(ctx) {} - ~BleFinishCtxDeleter() { if (p) { free(p); p = nullptr; } } - BleFinishCtxDeleter(const BleFinishCtxDeleter&) = delete; - BleFinishCtxDeleter& operator=(const BleFinishCtxDeleter&) = delete; -}; - -// ============================================================ -// 前向声明 -// ============================================================ -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 uint32_t packBleFinishValue(uint16_t session_id, uint16_t code); -static void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]); -static void sendSha256Notify(const uint8_t sha[32], uint16_t session_id); -static void esp32_reorder_free(); -static void ble_ota_session_reset(void); -static void ble_ota_restore_wifi_ps(void); -static void ble_ota_data_worker_task(void *pvParameters); -static void ble_gd32_relay_wait_task(void *pvParameters); - -// 清空 s_otaDataQueue 中的残留批次并释放其 data 指针。 -// 仅用于纯清理场景;带计数/日志的清理请保留内联实现。 -static void drainOtaDataQueue() +void drainOtaDataQueue() { BleOtaDataBatch stale; - while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) { + while (s_otaDataQueue && xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) { if (stale.data) free(stale.data); } } -// ============================================================ -/** - * @brief 获取 BleOta 单例实例。 - * @return BleOta 类引用。 - */ BleOta& BleOta::instance() { static BleOta inst; @@ -127,7 +22,6 @@ BleOta& BleOta::instance() } // File-scope val_handle for GATT service definition (struct literal needs compile-time address) -static uint16_t s_data_out_val_handle = 0; // ESP32 自身 OTA 收尾任务(OTA 写入/校验/重启逻辑委托给 Esp32OtaCore) @@ -142,7 +36,7 @@ static uint16_t s_data_out_val_handle = 0; * @brief 0xFA 重启任务:为了不卡 NimBLE host task,仅在这里推送 FINISH 后 500ms 重启。 * @param pvParameters 任务参数,未使用。 */ -static void ble_esp32_restart_task(void *pvParameters) +void ble_esp32_restart_task(void *pvParameters) { (void)pvParameters; DEBUG_INFO(TAG, "[RESTART] 推送 FINISH_REBOOT_NOW 后 500ms 重启"); @@ -154,14 +48,14 @@ static void ble_esp32_restart_task(void *pvParameters) * @brief 异步完成 ESP32 OTA 收尾并触发重启。 * @param pvParameters BleEsp32FinishCtx(堆分配,任务负责释放)。 */ -static void ble_esp32_finish_and_reboot_task(void *pvParameters) +void ble_esp32_finish_and_reboot_task(void *pvParameters) { BleEsp32FinishCtx *ctx = (BleEsp32FinishCtx *)pvParameters; // RAII:保证所有 return 路径自动释放 ctx(restartEsp32 不返回路径除外) BleFinishCtxDeleter ctx_guard(ctx); char *frontend_sha_hex = ctx ? ctx->sha_hex : nullptr; uint16_t session_id = ctx ? ctx->session_id : 0; - DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动"); + DEBUG_DBG(TAG, "ESP32 OTA 异步收尾任务启动"); Esp32OtaCore &core = Esp32OtaCore::instance(); @@ -170,7 +64,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) sendWindowAck(BleOta::instance().m_esp32LastCmd, BleOta::instance().m_esp32LastSeq, (uint32_t)core.getReceived()); - DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq); + DEBUG_DBG(TAG, "ESP32 最终 ACK 已补发: seq=%u", BleOta::instance().m_esp32LastSeq); } vTaskDelay(pdMS_TO_TICKS(150)); @@ -186,7 +80,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) return; } - DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); + DEBUG_DBG(TAG, "ESP32 OTA 收尾: 共 %u 字节", (unsigned)core.getReceived()); // ★ 收尾序列(notify + finalize)由协调器统一封装,reboot=false 让 device_sha 可被读取 Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot(frontend_sha_hex); @@ -210,7 +104,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, packBleFinishValue(session_id, 0)); // 4) 通知全部发出后由协调器统一重启(notify + delay + esp_restart) - DEBUG_INFO(TAG, "[FINISH] 通知已发送,重启..."); + DEBUG_INFO(TAG, "[FINISH] ESP32 OTA 收尾完成,重启中"); OtaCoordinator::instance().restartEsp32(); // 不返回,ctx 由系统回收 } else if (fr.err == ESP_ERR_INVALID_VERSION) { DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); @@ -240,30 +134,30 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) // ============================================================ // BLE OTA 期间 WiFi 共存:保持 WIFI_PS_NONE;暂停 RSSI 轮询 // ============================================================ -static bool s_wifi_ps_boosted = false; + /** * @brief BLE OTA 开始时的 WiFi 共存处理(暂停 RSSI 轮询,不切省电模式)。 */ -static void ble_ota_boost_wifi_ps() +void ble_ota_boost_wifi_ps() { if (s_wifi_ps_boosted) return; s_wifi_ps_boosted = true; // 通过协调器通知 WiFi 层 OtaCoordinator::instance().notifyBleCoexStart(); - DEBUG_INFO(TAG, "BLE OTA 数据传输进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)"); + DEBUG_DBG(TAG, "BLE OTA 数据传输进行中:暂停 WiFi RSSI 轮询(保持 WIFI_PS_NONE,STA 仍可响应 HTTP)"); } /** * @brief BLE OTA 结束/中止时恢复 WiFi RSSI 轮询。 */ -static void ble_ota_restore_wifi_ps() +void ble_ota_restore_wifi_ps() { if (!s_wifi_ps_boosted) return; s_wifi_ps_boosted = false; // 通过协调器通知 WiFi 层 OtaCoordinator::instance().notifyBleCoexEnd(); - DEBUG_INFO(TAG, "BLE OTA 数据传输结束:恢复 WiFi RSSI 轮询(GD32 中继仍可能进行中)"); + DEBUG_DBG(TAG, "BLE OTA 数据传输结束:恢复 WiFi RSSI 轮询(GD32 中继仍可能进行中)"); } // ============================================================ @@ -275,7 +169,7 @@ static void ble_ota_restore_wifi_ps() * @param buf_len 缓冲区最大长度。 * @return 拷贝的字节数,失败返回 -1。 */ -static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len) +int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len) { if (!om || !buf) return -1; uint16_t om_len = OS_MBUF_PKTLEN(om); @@ -284,1679 +178,7 @@ 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) - uint32_t cachedCount = 0; ///< 当前已缓存的乱序包数 -}; - -static Esp32OtaReorderBuf s_reorder; -static uint32_t s_fallback_ooo_count = 0; // 回退模式下自上次 ACK 以来的乱序包计数 -static uint32_t s_gd32_ooo_count = 0; // GD32 路径自上次 ACK 以来的乱序/重复包计数 -static ota_meta_t s_ble_gd32_meta = {}; -static bool s_ble_gd32_meta_active = false; -static bool s_conn_fallback_requested = false; - -// 前向声明(alloc/free 互相引用) -static void esp32_reorder_free(); - -static uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase) -{ - struct ble_gap_conn_desc desc = {}; - int rc = ble_gap_conn_find(conn_handle, &desc); - if (rc != 0) { - DEBUG_WARN(TAG, "%s读取 BLE 连接参数失败: rc=%d", phase, rc); - return 0; - } - - DEBUG_INFO(TAG, "%sBLE 连接参数: interval=%.2fms latency=%u supervision=%ums", - phase, - desc.conn_itvl * 1.25f, - (unsigned)desc.conn_latency, - (unsigned)desc.supervision_timeout * 10U); - return desc.conn_itvl; -} - -static int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compatible) -{ - struct ble_gap_upd_params params = {}; - if (windows_compatible) { - /* 15–20 ms:多数 Windows 主机可接受 */ - params.itvl_min = 12; - params.itvl_max = 16; - params.min_ce_len = 0; - params.max_ce_len = 0; - } else { - /* 7.5–15 ms:勿锁死 7.5,否则 Windows 常拒然后掉到更慢默认值 */ - params.itvl_min = 6; - params.itvl_max = 12; - params.min_ce_len = 12; - params.max_ce_len = 24; - } - params.latency = 0; - params.supervision_timeout = 200; - - int rc = ble_gap_update_params(conn_handle, ¶ms); - DEBUG_INFO(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms ce=%u..%u rc=%d", - windows_compatible ? "Windows兼容" : "高速", - params.itvl_min * 1.25f, - params.itvl_max * 1.25f, - (unsigned)params.min_ce_len, - (unsigned)params.max_ce_len, - rc); - return rc; -} - -/** - * @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.cachedCount = 0; - 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_reorder.cachedCount = 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 (s_reorder.cachedCount > 0) { - s_reorder.cachedCount--; - } - - 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_INFO(TAG, "Reorder 缺口已补齐: seq=%u,连续 flush=%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) { - uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); - DEBUG_ERR(TAG, "Reorder slot malloc 失败: seq=%u expected=%u bytes=%u," - "ACK 报告缺口并 abort", - seq, (unsigned)s_reorder.expected, (unsigned)curReceived); - sendWindowAck(cmd, - s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, - curReceived); - return ESP_ERR_NO_MEM; - } - memcpy(copy, data, data_len); - s_reorder.slots[idx] = copy; - s_reorder.lens[idx] = data_len; - s_reorder.cachedCount++; - if (s_reorder.cachedCount == 1U) { - DEBUG_INFO(TAG, "Reorder 检测到缺口: expected=%u,开始缓存 seq=%u", - (unsigned)s_reorder.expected, seq); - } - 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 调用。 -// ───────────────────────────────────────────────────────────── - -/** - * @brief 初始化 ESP32 本机 OTA 写入会话。 - * 委托 Esp32OtaCore::begin,并复位 BLE 传输相关状态。 - * @param total_size 固件总大小(字节)。 - * @return ESP_OK 成功,其他为失败错误码。 - */ -static esp_err_t ble_esp32_ota_begin(size_t total_size) -{ - // ★ 通过协调器发起(内部已含 forceCleanup 重试) - esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_BLE); - if (err != ESP_OK) { - return err; - } - // 复位 BLE 传输相关状态 - 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(); - /* 刷写前再要一次高速连接参数(连接时若未谈妥,此处补刀) */ - if (BleOta::instance().m_bleConnected && - BleOta::instance().m_connHandle != 0xFFFFU) { - (void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); - } - return ESP_OK; -} - -/** - * @brief BLE OTA 会话状态统一复位(共用清理内核)。 - * - * 集中处理三个清理动作: - * 1. abortCurrent():通知协调器按当前状态分发清理 - * (GD32_STORING→metaClear, ESP32_RECEIVING→core.abort, IDLE→空操作), - * 确保下次 begin() 不会被残留状态假拒绝。 - * 2. core.abort() 兜底:若 Esp32OtaCore 仍 isActive(异常路径),单独再 abort。 - * 3. BLE OTA 会话状态字段复位:m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending - * + esp32_reorder_free() 释放 reorder buffer + s_gd32_ooo_count 归零。 - * - * 不做:OTA 数据队列清理(调用方按需做)、WiFi PS 恢复(调用方按需做)。 - * 这两个动作在不同调用路径下时序/前置条件不同,交给调用方更安全。 - * - * 调用方: - * - ble_esp32_ota_abort(): ESP32 OTA 异常/中止路径 - * - 0xFB handler: 用户主动中止 - * - BLE_GAP_EVENT_DISCONNECT handler: BLE 断开(含 GD32 存储断开) - */ -static void ble_ota_session_reset() -{ - // ★ 无条件通知协调器释放通道占用(m_sysState → IDLE)。 - // abortCurrent() 对 IDLE 状态是空操作,对其他状态均复位到 IDLE, - // 确保后续 begin() 不会被残留的 ESP32_RECEIVING / ERROR 等状态假拒绝。 - OtaCoordinator::instance().abortCurrent(); - // 若 ESP32 OTA core 仍活跃(兜底),单独再 abort 一次 - if (Esp32OtaCore::instance().isActive()) { - Esp32OtaCore::instance().abort(); - } - // 复位 BLE OTA 会话状态,避免下次会话读到旧值 - BleOta::instance().m_esp32LastSeq = 0xFFFFU; - BleOta::instance().m_esp32LastCmd = 0; - BleOta::instance().m_esp32RebootPending = false; - // 释放 ESP32 reorder buffer(无 buffer 时为空操作) - esp32_reorder_free(); - // 复位 GD32 路径乱序包计数器(节流 ACK 计数),保证下次 GD32 上传从干净状态开始 - s_gd32_ooo_count = 0; - s_ble_gd32_meta = {}; - s_ble_gd32_meta_active = false; -} - -/** - * @brief ESP32 OTA 异常/中止时调用:会话复位 + WiFi PS 恢复。 - * - * 关键:必须先通知协调器释放 m_sysState(避免下次 begin 被 ESP32_RECEIVING - * 状态假拒绝),再调 core.abort() 释放 mutex。两者重复 abort 幂等安全。 - */ -static void ble_esp32_ota_abort() -{ - ble_ota_session_reset(); - // ★ BLE OTA 中止:恢复 WiFi 射频 - ble_ota_restore_wifi_ps(); -} - -/** - * @brief 发送窗口 ACK 通知,回报已处理的分块序号与累计字节数。 - * @param cmd 对应的数据命令字节。 - * @param seq 当前确认的分块序号。 - * @param completed_bytes 已完成写入的累计字节数。 - */ -static void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes) -{ - uint8_t ack[8] = {0}; - ack[0] = BLE_OTA_NOTIFY_ACK; - ack[1] = cmd; - ack[2] = (uint8_t)(seq & 0xFFU); - ack[3] = (uint8_t)((seq >> 8) & 0xFFU); - ack[4] = (uint8_t)(completed_bytes & 0xFFU); - ack[5] = (uint8_t)((completed_bytes >> 8) & 0xFFU); - ack[6] = (uint8_t)((completed_bytes >> 16) & 0xFFU); - ack[7] = (uint8_t)((completed_bytes >> 24) & 0xFFU); - - esp_err_t err = BleOta::instance().notifyResponse(ack, sizeof(ack)); - if (err != ESP_OK) { - DEBUG_WARN(TAG, "发送 ACK 失败: cmd=0x%02X seq=%u err=%s", - cmd, seq, esp_err_to_name(err)); - } else { - DEBUG_DBG(TAG, "已发送 ACK: cmd=0x%02X seq=%u bytes=%lu", - cmd, seq, (unsigned long)completed_bytes); - } -} - -/** - * @brief 发送收尾阶段事件通知(开始/完成/失败/重启等)。 - * @param stage 阶段标识码。 - * @param value 阶段附带值(错误码或耗时等)。 - */ -static void sendFinishEvent(uint8_t stage, uint32_t value) -{ - uint8_t pkt[6] = {0}; - pkt[0] = BLE_OTA_NOTIFY_FINISH; - pkt[1] = stage; - pkt[2] = (uint8_t)(value & 0xFFU); - pkt[3] = (uint8_t)((value >> 8) & 0xFFU); - pkt[4] = (uint8_t)((value >> 16) & 0xFFU); - pkt[5] = (uint8_t)((value >> 24) & 0xFFU); - esp_err_t err = BleOta::instance().notifyResponse(pkt, sizeof(pkt)); - if (err != ESP_OK) { - DEBUG_WARN(TAG, "发送 FINISH 事件失败: stage=0x%02X err=%s", - stage, esp_err_to_name(err)); - } -} - -/** @brief 将 session_id 和 code 打包为 32 位 BLE FINISH 事件值。 */ -static uint32_t packBleFinishValue(uint16_t session_id, uint16_t code) -{ - return ((uint32_t)code << 16) | (uint32_t)session_id; -} - -/** @brief 将 32 字节 SHA-256 转为大写十六进制字符串(65字节含'\0')。 */ -static void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]) -{ - if (!sha || !out_hex) return; - for (int i = 0; i < 32; ++i) { - snprintf(&out_hex[i * 2], 3, "%02X", sha[i]); - } - out_hex[64] = '\0'; -} - -/** @brief 通过 BLE Notify 推送设备端 SHA-256 给前端(用于比对验证)。 */ -static void sendSha256Notify(const uint8_t sha[32], uint16_t session_id) -{ - if (!sha) return; - - uint8_t sha_pkt[35] = {0}; - sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; - memcpy(&sha_pkt[1], sha, 32); - sha_pkt[33] = (uint8_t)(session_id & 0xFFU); - sha_pkt[34] = (uint8_t)((session_id >> 8) & 0xFFU); - BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); -} - -/** @brief BLE 触发 GD32 中继后等待结果的 FreeRTOS 任务。 */ -static void ble_gd32_relay_wait_task(void *pvParameters) -{ - BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)pvParameters; - uint16_t session_id = ctx ? ctx->session_id : 0; - if (ctx) free(ctx); - - uint32_t result_msg = (uint32_t)OTA_RESULT_ERR_TIMEOUT; - bool got_result = false; - QueueHandle_t rq = OtaRelay::getResultQueue(); - if (rq) { - got_result = (xQueueReceive(rq, &result_msg, pdMS_TO_TICKS(120000)) == pdTRUE); - } - - if (!got_result) { - DEBUG_ERR(TAG, "GD32 BLE OTA 等待中继结果超时"); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, (uint16_t)(-OTA_RESULT_ERR_TIMEOUT))); - vTaskDelete(NULL); - return; - } - - ota_result_t result = (ota_result_t)((int32_t)result_msg); - if (result == OTA_RESULT_OK) { - DEBUG_INFO(TAG, "GD32 BLE OTA 中继完成,通知前端 (stage=0x11, session=%u)", session_id); - sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_DONE, - packBleFinishValue(session_id, 0)); - } else { - uint16_t code = (uint16_t)(result < 0 ? -result : result); - DEBUG_ERR(TAG, "GD32 BLE OTA 中继失败: result=%d", (int)result); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, code)); - } - vTaskDelete(NULL); -} - - -// ============================================================ -// GATT 访问回调 -// ============================================================ - -/* - * CTRL (0x0001) - Write - * 写入 0xF7 触发 OTA 中继 - */ -/** - * @brief GATT CTRL 特征访问回调,处理控制命令写入。 - * @param conn_handle BLE 连接句柄。 - * @param attr_handle GATT 属性句柄。 - * @param ctxt GATT 访问上下文。 - * @param arg 回调用户参数。 - * @return NimBLE ATT 错误码,0 表示成功。 - */ -static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt, void *arg) -{ - (void)conn_handle; - (void)attr_handle; - (void)arg; - - if (BleOta::instance().isSuspendedForWifiOta()) { - return BLE_ATT_ERR_WRITE_NOT_PERMITTED; - } - - if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { - return BLE_ATT_ERR_REQ_NOT_SUPPORTED; - } - - uint8_t buf[36] = {0}; - int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); - if (len < 1) { - DEBUG_WARN(TAG, "CTRL 数据为空"); - return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; - } - - DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len); - - if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) { - uint16_t session_id = 0; - if (len >= 35) { - session_id = (uint16_t)(buf[33] | (buf[34] << 8)); - } else if (len >= 3) { - session_id = (uint16_t)(buf[1] | (buf[2] << 8)); - } - - if (len < 35) { - DEBUG_WARN(TAG, "CTRL 0xF7 数据长度不足 35(前端 SHA-256 必传),拒绝触发 GD32 中继"); - sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, - packBleFinishValue(session_id, 1)); - return 0; - } - - // 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继 - ota_meta_t meta = {}; - esp_err_t err = OtaCoordinator::instance().loadGd32Meta(&meta); - if (err == ESP_OK && - meta.total_size > 0 && - meta.stored_size >= meta.total_size) { - const uint8_t *frontend_sha = &buf[1]; - char frontend_sha_hex[65] = {0}; - char device_sha_hex[65] = {0}; - shaBytesToHexUpper(frontend_sha, frontend_sha_hex); - - // ★ 统一走共享 helper:计算设备端 SHA → 比对前端 SHA - uint8_t device_sha[32] = {0}; - err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha); - shaBytesToHexUpper(device_sha, device_sha_hex); - sendSha256Notify(device_sha, session_id); - DEBUG_INFO(TAG, "GD32 前端 SHA-256: %s, session=%u", frontend_sha_hex, session_id); - DEBUG_INFO(TAG, "GD32 缓存 SHA-256: %s", device_sha_hex); - - if (err == ESP_ERR_INVALID_CRC) { - DEBUG_ERR(TAG, "GD32 缓存 SHA-256 与前端文件不匹配,拒绝触发 SPI 中继"); - sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, - packBleFinishValue(session_id, 0)); - return 0; - } else if (err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 SHA-256 计算/读取失败: %s", esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 3)); - return 0; - } - - DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继", - meta.stored_size, meta.total_size); - if (OtaRelay::getResultQueue()) { - uint32_t stale = 0; - while (xQueueReceive(OtaRelay::getResultQueue(), &stale, 0) == pdTRUE) { - DEBUG_WARN(TAG, "清理旧 GD32 中继结果: %" PRIu32, stale); - } - } - esp_err_t trig_err = OtaCoordinator::instance().triggerGd32Relay(); - if (trig_err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 中继触发失败: %s", esp_err_to_name(trig_err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 1)); - return 0; - } - - sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START, - packBleFinishValue(session_id, 0)); - DEBUG_INFO(TAG, "已通知前端: GD32 中继已开始 (stage=0x10, session=%u)", session_id); - - BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx)); - if (!ctx) { - DEBUG_ERR(TAG, "GD32 中继等待任务上下文分配失败"); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 2)); - return 0; - } - ctx->session_id = session_id; - BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task, - "ble_gd32_wait", - TASK_STACK_BLE_GD32_WAIT, - ctx, - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 GD32 中继等待任务失败"); - free(ctx); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 2)); - } - } else { - DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")", - meta.stored_size, meta.total_size); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 1)); - } - } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { - uint16_t session_id = 0; - if (len >= 35) { - session_id = (uint16_t)(buf[33] | (buf[34] << 8)); - } - if (!Esp32OtaCore::instance().isActive()) { - DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, - packBleFinishValue(session_id, 1)); - return 0; - } - if (BleOta::instance().m_esp32RebootPending) { - DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, - packBleFinishValue(session_id, 1)); - return 0; - } - - // 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对, - // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 - if (len < 33) { - DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发"); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG); - return 0; - } - - BleEsp32FinishCtx *finish_ctx = (BleEsp32FinishCtx *)malloc(sizeof(BleEsp32FinishCtx)); - if (!finish_ctx) { - DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败"); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, - packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM)); - return 0; - } - for (int i = 0; i < 32; ++i) { - snprintf(&finish_ctx->sha_hex[i * 2], 3, "%02x", buf[1 + i]); - } - finish_ctx->sha_hex[64] = '\0'; - finish_ctx->session_id = session_id; - DEBUG_INFO(TAG, "前端 SHA-256: %s, session=%u", finish_ctx->sha_hex, session_id); - - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - TASK_STACK_BLE_FINISH, - finish_ctx, // heap context; task 负责 free - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - free(finish_ctx); - BleOta::instance().m_esp32RebootPending = false; - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, - packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM)); - } else { - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); - } - } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) { - // 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host - DEBUG_INFO(TAG, "收到 0xFA 重启请求,创建 restart task(500ms 后 esp_restart())"); - if (BleOta::instance().m_esp32RebootPending) { - DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL"); - return 0; - } - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_restart_task, - "ble_ota_restart", - TASK_STACK_BLE_RESTART, - NULL, - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 0xFA 重启任务失败"); - BleOta::instance().m_esp32RebootPending = false; - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0); - } - } else if (buf[0] == BLE_OTA_CTRL_ABORT_OTA) { - // 0xFB: 中止当前 OTA(保持 BLE 连接) - // - 数据接收阶段(GD32_STORING / ESP32_RECEIVING):立即中止 + 清队列 + 回复 ABORTED - // - 中继/收尾阶段(GD32_RELAYING / ESP32_FINALIZING):拒绝中止(SPI 转发不可中断) - // - IDLE/已完成状态:幂等回复 ABORTED - uint16_t session_id = 0; - if (len >= 3) { - session_id = (uint16_t)(buf[1] | (buf[2] << 8)); - } - - ota_system_state_t cur_state = OtaCoordinator::instance().getSystemState(); - DEBUG_WARN(TAG, "收到 0xFB 中止请求: state=%s session=%u", - OtaCoordinator::stateToString(cur_state), session_id); - - if (cur_state == OTA_SYS_GD32_RELAYING) { - DEBUG_WARN(TAG, "GD32 SPI 中继进行中,无法中止(请等待完成或断开 BLE)"); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, - packBleFinishValue(session_id, 2)); - return 0; - } - if (cur_state == OTA_SYS_ESP32_FINALIZING) { - DEBUG_WARN(TAG, "ESP32 OTA 收尾进行中,无法中止"); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, - packBleFinishValue(session_id, 2)); - return 0; - } - - // ── 可中止状态:清队列 + abort core + 复位协调器 ── - if (s_otaDataQueue) { - BleOtaDataBatch stale; - size_t dropped = 0; - while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) { - free(stale.data); - dropped++; - } - if (dropped > 0) { - DEBUG_INFO(TAG, "中止时清理 OTA 数据队列: %zu 项", dropped); - } - } - - // abortCurrent 内部按状态分发:GD32_STORING→metaClear, ESP32_RECEIVING→core.abort, IDLE→空操作 - // ble_ota_session_reset() 统一负责 abortCurrent + core.abort 兜底 + 会话状态复位 - // (m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending/reorder/s_gd32_ooo_count) - ble_ota_session_reset(); - - ble_ota_restore_wifi_ps(); - - DEBUG_INFO(TAG, "OTA 已中止(BLE 保持连接),通知前端 (stage=0x20)"); - sendFinishEvent(BLE_OTA_FINISH_ABORTED, - packBleFinishValue(session_id, 0)); - } - // 0xF7 = 触发 GD32 中继 - // 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对, - // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启) - // 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连) - // 0xFB = 中止当前 OTA(不断开 BLE):仅数据接收阶段有效,中继/收尾阶段拒绝 - - return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0 -} - -/* - * DATA_IN (0x0002) - Write - * 数据块格式: - * [0] cmd (0x00=数据) - * [1-2] seq uint16 LE - 分块序号 - * [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效) - * [7..n] payload 固件数据 - * 注: 已移除CRC字段(BLE链路层已有CRC校验) - */ -/** - * @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。 - * @param conn_handle BLE 连接句柄。 - * @param attr_handle GATT 属性句柄。 - * @param ctxt GATT 访问上下文。 - * @param arg 回调用户参数。 - * @return NimBLE ATT 错误码,0 表示成功。 - */ -static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt, void *arg) -{ - (void)conn_handle; - (void)attr_handle; - (void)arg; - - if (BleOta::instance().isSuspendedForWifiOta()) { - return BLE_ATT_ERR_WRITE_NOT_PERMITTED; - } - - if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { - return BLE_ATT_ERR_REQ_NOT_SUPPORTED; - } - - // 使用较大静态缓冲区:writeValueWithoutResponse 快速发送时, - // NimBLE 可能将多个写请求合并到同一个 mbuf 链中 - // ★ static: NimBLE host task 栈只有 4096 字节,不能用栈分配 - // ★ 4096 字节可容纳 8 个合并包(8×491=3928),避免截断丢包 - static constexpr size_t BLE_OTA_BUF_SIZE = 4096; - static uint8_t buf[BLE_OTA_BUF_SIZE]; - int recv_len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); - - if (recv_len < 8) { - DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 8: 7头+1数据)", recv_len); - return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; - } - - // 将数据拷贝到堆上并送入工作队列,避免在 NimBLE host task 中执行 - // 耗时的 flash 操作(esp_ota_begin / esp_ota_write)导致 BLE 连接断开。 - if (s_otaDataQueue) { - uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len); - if (heap_buf) { - memcpy(heap_buf, buf, (size_t)recv_len); - BleOtaDataBatch item = { heap_buf, (size_t)recv_len }; - if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) { - DEBUG_WARN(TAG, "DATA_IN 工作队列满,丢弃 %d 字节(等待客户端重传)", recv_len); - free(heap_buf); - } - } else { - DEBUG_ERR(TAG, "DATA_IN 堆内存不足 (%d 字节),丢弃", recv_len); - } - } - - return 0; -} - -/** - * @brief 处理单个 ESP32 OTA 数据包(ble_ota_process_data_batch 子步骤)。 - * @return true 中止整个 batch;false 继续下一包(offset 由外层统一推进)。 - */ -static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, - const uint8_t *buf, size_t data_offset, size_t data_len, - uint32_t expected_payload) -{ - if (seq == 0) { - if (total_size == 0) { - DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size); - return true; - } - // 活跃 BLE 会话中的 seq=0 一律按重传处理。窗口 0 的 ACK 丢失时, - // 前端会从累计字节 0 续发;此时重置 OTA 会丢掉已缓存的后续包。 - // 新上传由 0xFB/断连先清理旧会话,不能用 seq=0 猜测会话边界。 - if (Esp32OtaCore::instance().isActive()) { - ota_channel_t curCh = OtaCoordinator::instance().getOtaChannel(); - if (curCh != OTA_CHANNEL_BLE) { - DEBUG_WARN(TAG, "ESP32 OTA 被其他通道占用(ch=%d),BLE 拒绝抢占", - (int)curCh); - sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); - return true; - } - DEBUG_INFO(TAG, "ESP32 OTA 会话已激活,seq=0 视为重传,保留 reorder 状态"); - } else { - // 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成 - esp_err_t err = ble_esp32_ota_begin(total_size); - if (err != ESP_OK) { - if (err == ESP_ERR_INVALID_STATE) { - sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); - } - return true; - } - } - } - - // ★ 会话有效性检查:如果 begin() 失败(如互斥拒绝),后续包也应丢弃 - if (!Esp32OtaCore::instance().isActive()) { - DEBUG_WARN(TAG, "ESP32 OTA 会话无效,丢弃 seq=%u", seq); - return false; // 外层推进 offset,继续下一包 - } - - // ★ 防重复写入保护:如果已接收字节 >= 声明大小,跳过(重试引起的重复包) - 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); - return false; // 外层推进 offset,继续下一包 - } - - // ★ 使用带序号顺序保护的写入:拒绝乱序包并立即 ACK 通知发送端 - uint32_t beforeReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); - esp_err_t err = esp32_write_ordered(seq, cmd, &buf[data_offset], data_len); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s", - seq, esp_err_to_name(err)); - ble_esp32_ota_abort(); // core 内部完成 abort + mutex release + 状态复位 - return true; - } - - // ★ 仅在实际写入新字节时才触发 ACK:重复包/乱序缓存包不写入 flash, - // 若在窗口边界触发 ACK 会携带陈旧字节数,被发送端误判为丢包。 - uint32_t afterReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); - bool progressMade = (afterReceived > beforeReceived); - - - // 完成判定:以实际按序写入字节数为准。 - bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD); - bool transferComplete = (total_size > 0 && afterReceived >= total_size); - - // ACK 按累计连续进度触发,而不是按当前到达包的 seq 触发。缺口包到达后 - // reorder 可能一次 flush 到下个窗口边界,例如 seq=99 使 expected 直达128; - // 此时必须立即 ACK 127,否则后续重复包都不产生 progress,前端只能超时。 - // - // ★ seq=0 强制 ACK:begin 后首包需尽快 ACK,前端才能继续窗口。 - // 前端必须等“首包已落盘”ACK 后再发窗口内其余包,否则队列满丢包→重传极慢。 - uint32_t completedPackets = (afterReceived + BLE_OTA_CHUNK_PAYLOAD - 1U) / - BLE_OTA_CHUNK_PAYLOAD; - bool reachedAckBoundary = completedPackets > 0U && - (completedPackets % BLE_OTA_ACK_WINDOW) == 0U; - bool firstPacketReady = (seq == 0U) && progressMade; - if (progressMade && - (reachedAckBoundary || - transferComplete || - isLastPacket || - firstPacketReady)) { - if (isLastPacket || transferComplete) { - DEBUG_INFO(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32, - seq, data_len, (unsigned int)afterReceived, total_size); - } else if (firstPacketReady) { - DEBUG_INFO(TAG, "ESP32 begin 就绪 ACK: seq=0 received=%u total=%" PRIu32, - (unsigned)afterReceived, total_size); - } - uint16_t ackSeq = s_reorder.expected > 0U - ? (uint16_t)(s_reorder.expected - 1U) - : seq; - sendWindowAck(cmd, ackSeq, afterReceived); - } - - if (transferComplete) { - DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)", - (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1); - OtaCoordinator::instance().notifyEsp32ReceiveComplete(); - BleOta::instance().m_esp32LastCmd = cmd; - BleOta::instance().m_esp32LastSeq = seq; - ble_ota_restore_wifi_ps(); - } - return false; -} - -/** - * @brief 处理单个 GD32 OTA 数据包(ble_ota_process_data_batch 子步骤)。 - * @return true 中止整个 batch;false 继续下一包(offset 由外层统一推进)。 - */ -/** GD32 路径硬失败:与 WiFi abortCurrentIfEpoch 对齐,复位协调器 + 本通道 RAM meta。 */ -static void ble_gd32_fail_abort(const char *why) -{ - DEBUG_ERR(TAG, "GD32 OTA 中止: %s", why ? why : "?"); - OtaCoordinator::instance().abortCurrent(); - s_ble_gd32_meta = {}; - s_ble_gd32_meta_active = false; - s_gd32_ooo_count = 0; - ble_ota_restore_wifi_ps(); -} - -static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, - const uint8_t *buf, size_t data_offset, size_t data_len, - uint32_t expected_payload) -{ - if (seq == 0 && total_size > 0) { - // 与 ESP32 BLE 路径一致:活跃会话中的 seq=0 视为首包重传,禁止再次 prepare - // (否则会 erase 分区、清 meta,毁掉已写入进度)。 - if (s_ble_gd32_meta_active && s_ble_gd32_meta.total_size == total_size) { - if (s_ble_gd32_meta.stored_size > 0) { - DEBUG_INFO(TAG, "GD32 会话已激活,seq=0 视为重传,跳过写入 stored=%" PRIu32, - s_ble_gd32_meta.stored_size); - sendWindowAck(cmd, 0, s_ble_gd32_meta.stored_size); - return false; - } - DEBUG_INFO(TAG, "GD32 会话已激活且未落盘,seq=0 重传首块(不重新 prepare)"); - // fall through:stored_size==0 时重写 offset 0 - } else { - // 与 WiFi 共用 prepareGd32Storage:validate + begin + erase + clearMeta + saveMeta - esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage( - total_size, OTA_CHANNEL_BLE); - if (prep_err == ESP_ERR_INVALID_SIZE) { - DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " max=%zu", - total_size, OTA_MAX_FIRMWARE_SIZE); - sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_SIZE, total_size); - return true; - } else if (prep_err == ESP_ERR_INVALID_STATE) { - DEBUG_ERR(TAG, "GD32 OTA 协调器拒绝存储(系统忙)"); - sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); - return true; - } else if (prep_err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); - return true; - } - - DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu", - total_size, data_len); - s_ble_gd32_meta = {}; - s_ble_gd32_meta.total_size = total_size; - s_ble_gd32_meta_active = true; - ble_ota_boost_wifi_ps(); - if (BleOta::instance().m_bleConnected && - BleOta::instance().m_connHandle != 0xFFFFU) { - (void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); - } - } - } - - if (!s_ble_gd32_meta_active || s_ble_gd32_meta.total_size == 0) { - DEBUG_ERR(TAG, "GD32 元数据无效,拒绝写入: seq=%u", seq); - return true; - } - ota_meta_t &meta = s_ble_gd32_meta; - - // ── 顺序检查:GD32 Flash 必须按序写入,无 reorder buffer ── - // 改进:乱序/重复包不再中止整个 batch,而是丢弃当前包 + 节流 ACK 报告缺口, - // 让前端尽快知道设备端实际进度(last_seq)并重传缺口。 - // 策略与 ESP32 fallback 路径一致(参考 esp32_write_ordered 行 459-477)。 - if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) { - // 重复包:seq <= last_seq(已写入过) - // ★ 重复包静默跳过,不发送 ACK:发送端已持有上一窗口的有效 ACK 进度, - // 重复包的 ACK 会携带未增长的字节数,被发送端误判为当前窗口丢包。 - if (seq <= meta.last_seq) { - DEBUG_DBG(TAG, "GD32 重复包: seq=%u <= last_seq=%" PRIu32 ",跳过", - seq, meta.last_seq); - return false; // 外层推进 offset,继续下一包 - } - - // 乱序包:seq > last_seq + 1(缺口) - // 节流策略:每 BLE_OTA_ACK_WINDOW 个乱序包才发一次 ACK,或在窗口边界处发 ACK。 - // 避免大量乱序包涌入时触发 ACK 风暴,耗尽 NimBLE mbuf 池导致 ESP_ERR_NO_MEM。 - s_gd32_ooo_count++; - bool shouldAck = - (s_gd32_ooo_count >= BLE_OTA_ACK_WINDOW) || - ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U); - if (shouldAck) { - DEBUG_WARN(TAG, "GD32 乱序包跳过: seq=%u (last_seq=%" PRIu32 "),节流 ACK bytes=%" PRIu32 " (ooo=%u)", - seq, meta.last_seq, meta.stored_size, (unsigned)s_gd32_ooo_count); - sendWindowAck(cmd, (uint16_t)meta.last_seq, meta.stored_size); - s_gd32_ooo_count = 0; - } else { - DEBUG_DBG(TAG, "GD32 乱序包跳过: seq=%u (last_seq=%" PRIu32 "),抑制 ACK (ooo=%u)", - seq, meta.last_seq, (unsigned)s_gd32_ooo_count); - } - return false; // 外层推进 offset,继续下一包 - } - - // 顺序包到达,复位乱序计数器 - if (s_gd32_ooo_count != 0) { - s_gd32_ooo_count = 0; - } - - // --- 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); - return false; // 外层推进 offset,继续下一包 - } - 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); - ble_gd32_fail_abort("write OOB"); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0); - return true; - } - - esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(flash_offset, &buf[data_offset], data_len); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s", - seq, flash_offset, esp_err_to_name(err)); - ble_gd32_fail_abort(esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err); - return true; - } - - uint32_t stored_size = flash_offset + (uint32_t)data_len; - meta.stored_size = stored_size; - meta.last_seq = seq; - const bool transfer_complete = stored_size >= meta.total_size; - // seq=0:prepare 后写完首包必须 ACK,前端才能继续发后续窗口 - const bool checkpoint = - (seq == 0U) || - ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) || - transfer_complete || - (expected_payload < BLE_OTA_CHUNK_PAYLOAD); - - // NVS commit 是毫秒级操作,不能每 480 字节执行一次。仅在 ACK 窗口边界 - // 和尾包持久化;正常完成时尾包一定落盘,后续 SHA/中继读取到的语义不变。 - if (checkpoint) { - err = OtaCoordinator::instance().updateGd32Meta(stored_size, seq); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 元数据 checkpoint 失败 (seq=%u): %s", - seq, esp_err_to_name(err)); - ble_gd32_fail_abort(esp_err_to_name(err)); - sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err); - return true; - } - sendWindowAck(cmd, seq, stored_size); - } - - DEBUG_DBG(TAG, "Flash 写入成功: seq=%u offset=%" PRIu32 " stored=%" PRIu32, - seq, flash_offset, stored_size); - - // --- 检查 GD32 固件是否接收完毕 --- - if (transfer_complete) { - DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)", - meta.stored_size, meta.total_size, seq + 1); - s_ble_gd32_meta_active = false; - ble_ota_restore_wifi_ps(); - // 与 WiFi 共用 finishGd32StorageAfterReceive:complete + SHA - esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); - if (sha_err != ESP_OK) { - DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); - } - } - return false; -} - -/** - * @brief 处理一批 OTA 数据包(在工作线程上下文中调用)。 - * 包含 ESP32 和 GD32 的数据接收逻辑。 - * @param buf 数据缓冲区(包含一个或多个合并的包)。 - * @param recv_len 数据长度。 - */ -static void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) -{ - // 循环处理 mbuf 中的所有完整数据包 - size_t offset = 0; - while (offset + 7 <= recv_len) { - // 解析包头 - uint8_t cmd = buf[offset + 0]; - uint16_t seq = (uint16_t)(buf[offset + 1] | (buf[offset + 2] << 8)); - uint32_t total_size = (uint32_t)(buf[offset + 3] | (buf[offset + 4] << 8) | - (buf[offset + 5] << 16) | (buf[offset + 6] << 24)); - - size_t data_offset = offset + 7; - - // 根据包序号和固件总大小计算期望 payload,而非依赖 recv_len - // 这样即使 NimBLE mbuf 合并导致缓冲区截断,也不会误判包长度 - uint32_t expected_payload = (total_size > 0) - ? ((uint32_t)BLE_OTA_CHUNK_PAYLOAD < (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD) - ? (uint32_t)BLE_OTA_CHUNK_PAYLOAD - : (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD)) - : (uint32_t)BLE_OTA_CHUNK_PAYLOAD; - - size_t data_len = (size_t)expected_payload; - size_t remaining_after_header = recv_len - data_offset; - - // 如果缓冲区中实际可用数据不足,截断 data_len - if (remaining_after_header < expected_payload) { - if (remaining_after_header == 0) { - // 没有更多数据,退出循环 - break; - } - // 缓冲区截断,使用实际可用数据 - data_len = remaining_after_header; - } - - // 当前包总长度: header(7) + payload(data_len) - size_t packet_len = 7 + data_len; - - DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu offset=%zu", - cmd, seq, total_size, data_len, offset); - - // 分发到对应通道的包处理器 - bool abort_batch = false; - if (cmd == BLE_OTA_CMD_ESP32_DATA) { - abort_batch = process_esp32_packet(cmd, seq, total_size, - buf, data_offset, data_len, expected_payload); - } else if (cmd == BLE_OTA_CMD_GD32_DATA) { - abort_batch = process_gd32_packet(cmd, seq, total_size, - buf, data_offset, data_len, expected_payload); - } else { - DEBUG_WARN(TAG, "DATA_IN 未知 cmd: 0x%02X offset=%zu", cmd, offset); - } - - if (abort_batch) { - return; - } - - // 推进到下一个包 - offset += packet_len; - - // 检查剩余数据是否足够组成一个完整的包头 - if (offset + 7 > recv_len) break; - } -} - -/** - * @brief OTA 数据工作线程:从队列接收数据并调用处理函数。 - * 独立于 NimBLE host task,避免 flash 操作阻塞 BLE 协议栈。 - */ -static void ble_ota_data_worker_task(void *pvParameters) -{ - (void)pvParameters; - BleOta::instance().m_workerTaskHandle = xTaskGetCurrentTaskHandle(); - DEBUG_INFO(TAG, "OTA 数据工作线程已启动"); - - while (1) { - BleOtaDataBatch item; - if (xQueueReceive(s_otaDataQueue, &item, portMAX_DELAY) == pdTRUE) { - if (!item.data || item.len == 0) { - free(item.data); - continue; - } - - // 若 BLE 已断开或 WiFi OTA 挂起,丢弃队列中的残留数据 - if (!BleOta::instance().isConnected() || - BleOta::instance().isSuspendedForWifiOta()) { - DEBUG_DBG(TAG, "BLE 不可用/挂起,丢弃 %zu 字节队列数据", item.len); - free(item.data); - continue; - } - - ble_ota_process_data_batch(item.data, item.len); - free(item.data); - } - } -} - -/* - * VERSION (0x0004) - Read - * 返回固件版本字符串 - */ -/** - * @brief GATT VERSION 特征访问回调,返回固件版本字符串。 - * @param conn_handle BLE 连接句柄。 - * @param attr_handle GATT 属性句柄。 - * @param ctxt GATT 访问上下文。 - * @param arg 回调用户参数。 - * @return NimBLE ATT 错误码,0 表示成功。 - */ -static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt, void *arg) -{ - (void)conn_handle; - (void)attr_handle; - (void)arg; - - if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) { - return BLE_ATT_ERR_REQ_NOT_SUPPORTED; - } - - const char *version = FW_VERSION; - // 版本字符串: "vX.Y.Z YYYY-MM-DD HH:MM:SS" - char ver_buf[64]; - int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, 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; -} - -/* - * DATA_OUT (0x0003) - Notify only - * 访问回调不做读写处理,通知由 BleOta::instance().notifyResponse() 发送 - */ -/** - * @brief GATT DATA_OUT 特征访问回调(Notify-only,占位实现)。 - * @param conn_handle BLE 连接句柄。 - * @param attr_handle GATT 属性句柄。 - * @param ctxt GATT 访问上下文。 - * @param arg 回调用户参数。 - * @return 0。 - */ -static int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt, void *arg) -{ - (void)conn_handle; - (void)attr_handle; - (void)ctxt; - (void)arg; - return 0; -} - -// ============================================================ -// GATT 服务定义 -// ============================================================ - -// BLE_UUID16_DECLARE 在 C++ 中产生 "taking address of rvalue" 编译错误。 -// 改用 static ble_uuid16_t 变量,再取 .u 地址。 -/** @brief OTA GATT 服务 UUID */ -static const ble_uuid16_t GATT_SVC_UUID = BLE_UUID16_INIT(BLE_OTA_SERVICE_UUID); -/** @brief CTRL 特征 UUID,写入 0xF7 触发中继 */ -static const ble_uuid16_t GATT_CTRL_UUID = BLE_UUID16_INIT(BLE_OTA_CTRL_UUID); -/** @brief DATA_IN 特征 UUID,接收固件数据块 */ -static const ble_uuid16_t GATT_DATA_IN_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_IN_UUID); -/** @brief DATA_OUT 特征 UUID,Notify 推送 GD32 响应 */ -static const ble_uuid16_t GATT_DATA_OUT_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_OUT_UUID); -/** @brief VERSION 特征 UUID,返回固件版本字符串 */ -static const ble_uuid16_t GATT_VERSION_UUID = BLE_UUID16_INIT(BLE_OTA_VERSION_UUID); - -// NimBLE C 结构体在 C++ designated initializer 不能显式初始化全部字段。 -// 用 pragma 局部关闭 -Wmissing-field-initializers 警告。 -#ifdef __cplusplus -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wmissing-field-initializers" -#endif - -static const struct ble_gatt_chr_def gatt_ota_chrs[] = { - { - .uuid = &GATT_CTRL_UUID.u, - .access_cb = ble_ota_ctrl_access, - .flags = BLE_GATT_CHR_F_WRITE, - }, - { - .uuid = &GATT_DATA_IN_UUID.u, - .access_cb = ble_ota_data_in_access, - .flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP, - }, - { - .uuid = &GATT_DATA_OUT_UUID.u, - .access_cb = ble_ota_data_out_access, - .flags = BLE_GATT_CHR_F_NOTIFY, - .val_handle = &s_data_out_val_handle, - }, - { - .uuid = &GATT_VERSION_UUID.u, - .access_cb = ble_ota_version_access, - .flags = BLE_GATT_CHR_F_READ, - }, - {} // 终止符 -}; - -static const struct ble_gatt_svc_def gatt_ota_svcs[] = { - { - .type = BLE_GATT_SVC_TYPE_PRIMARY, - .uuid = &GATT_SVC_UUID.u, - .characteristics = (struct ble_gatt_chr_def *)gatt_ota_chrs, - }, - {} // 终止符 -}; - -#ifdef __cplusplus -#pragma GCC diagnostic pop -#endif - -// ============================================================ -// GAP 事件回调 -// ============================================================ -/** - * @brief BLE GAP 事件回调,处理连接和断开事件。 - * @param event - * @param arg - * @return 0 成功。 - */ -static int ble_ota_gap_event(struct ble_gap_event *event, void *arg) -{ - (void)arg; - - switch (event->type) { - - case BLE_GAP_EVENT_CONNECT: - if (event->connect.status == 0) { - if (BleOta::instance().isSuspendedForWifiOta()) { - /* WiFi OTA 中拒绝 BLE 连接,立刻踢掉 */ - DEBUG_WARN(TAG, "WiFi OTA 中,拒绝 BLE 连接 handle=%d", - event->connect.conn_handle); - (void)ble_gap_terminate(event->connect.conn_handle, - BLE_ERR_REM_USER_CONN_TERM); - BleOta::instance().m_bleConnected = false; - return 0; - } - // 连接成功 - BleOta::instance().m_bleConnected = true; - BleOta::instance().m_connHandle = event->connect.conn_handle; - s_conn_fallback_requested = false; - DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", BleOta::instance().m_connHandle); - - ble_ota_log_conn_params(BleOta::instance().m_connHandle, "初始"); - int rc = ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); - if (rc != 0) { - s_conn_fallback_requested = true; - ble_ota_request_conn_params(BleOta::instance().m_connHandle, true); - } - /* 优先 LE 2M PHY(空中速率约翻倍);对端不支持则保持 1M,失败仅打日志 */ - { - const uint8_t phy_mask = BLE_GAP_LE_PHY_1M_MASK | BLE_GAP_LE_PHY_2M_MASK; - int phy_rc = ble_gap_set_prefered_le_phy( - BleOta::instance().m_connHandle, phy_mask, phy_mask, - BLE_GAP_LE_PHY_CODED_ANY); - DEBUG_INFO(TAG, "请求 LE PHY 1M|2M: rc=%d", phy_rc); - } - } else { - DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status); - BleOta::instance().m_bleConnected = false; - } - return 0; - - case BLE_GAP_EVENT_DISCONNECT: - DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d", - event->disconnect.reason); - - // ★ 断开时清空数据队列,丢弃属于旧会话的残留包 - if (s_otaDataQueue) { - drainOtaDataQueue(); - } - - BleOta::instance().m_bleConnected = false; - BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE; - s_conn_fallback_requested = false; - - /* WiFi OTA 软挂起期间:主动踢连触发的 disconnect 不得 abort WiFi OTA,也不得再开广播 */ - if (BleOta::instance().isSuspendedForWifiOta()) { - DEBUG_INFO(TAG, "WiFi OTA 挂起中:忽略 BLE 断开对 OTA 状态的影响"); - return 0; - } - - // ★ BLE 断开时恢复 WiFi 射频 - ble_ota_restore_wifi_ps(); - - // ★ 断开时清理"BLE 数据接收阶段"残留的 OTA 会话状态,避免下次刷写被 - // 系统 busy 假拒绝。注意区分可中止 / 不可中止状态: - // - GD32_STORING / ESP32_RECEIVING / ESP32_FINALIZING:BLE 是唯一数据源, - // 断开即等价于中止,必须 abortCurrent() + 清理底层 core。 - // - GD32_RELAYING / ESP32_REBOOTING:SPI 中继/重启不依赖 BLE,不能打断。 - // 这些状态保留,等中继/重启自然结束。 - // - IDLE / STORED / COMPLETE / ERROR:abortCurrent 幂等安全,直接调即可。 - { - ota_system_state_t cur = OtaCoordinator::instance().getSystemState(); - if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) { - DEBUG_WARN(TAG, "BLE 断开但 %s 进行中,保留状态等待自然结束", - OtaCoordinator::stateToString(cur)); - } else if (cur == OTA_SYS_ESP32_RECEIVING || - cur == OTA_SYS_ESP32_FINALIZING || - cur == OTA_SYS_GD32_STORING) { - /* 仅当当前 OTA 通道是 BLE 时才因断连中止;WiFi 通道的 RECEIVING 不能被误杀 */ - ota_channel_t ch = OTA_CHANNEL_WIFI; - { - /* get channel via snapshot if available */ - OtaSnapshot snap = OtaCoordinator::instance().getSnapshot(); - ch = snap.channel; - } - if (ch == OTA_CHANNEL_BLE) { - DEBUG_WARN(TAG, "BLE 断开,中止 BLE 通道 OTA (state=%s)", - OtaCoordinator::stateToString(cur)); - ble_ota_session_reset(); - } else { - DEBUG_INFO(TAG, "BLE 断开但当前为 WiFi OTA,不中止会话"); - } - } else { - ble_ota_session_reset(); - } - } - // 恢复广播以接受新连接 - ble_ota_start_advertising(); - return 0; - - case BLE_GAP_EVENT_ADV_COMPLETE: - if (BleOta::instance().isSuspendedForWifiOta()) { - return 0; /* WiFi OTA 中不要自动再开广播 */ - } - DEBUG_INFO(TAG, "广播周期结束,重新开始"); - ble_ota_start_advertising(); - return 0; - - case BLE_GAP_EVENT_SUBSCRIBE: - DEBUG_INFO(TAG, "CCC 描述符更新: conn=%d attr=%d notify=%d indicate=%d", - event->subscribe.conn_handle, - event->subscribe.attr_handle, - event->subscribe.cur_notify, - event->subscribe.cur_indicate); - if (event->subscribe.cur_notify) { - ble_ota_log_conn_params(event->subscribe.conn_handle, "订阅时"); - } - return 0; - - case BLE_GAP_EVENT_CONN_UPDATE: { - DEBUG_INFO(TAG, "BLE 连接参数更新完成: status=%d", event->conn_update.status); - uint16_t interval = ble_ota_log_conn_params(event->conn_update.conn_handle, "更新后"); - if (!s_conn_fallback_requested && - (event->conn_update.status != 0 || interval == 0 || interval > 16)) { - s_conn_fallback_requested = true; - ble_ota_request_conn_params(event->conn_update.conn_handle, true); - } - return 0; - } - - case BLE_GAP_EVENT_MTU: - DEBUG_INFO(TAG, "MTU 协商: conn=%d mtu=%d", - event->mtu.conn_handle, event->mtu.value); - return 0; - - default: - return 0; - } -} - -// ============================================================ -// BLE 广播 -// ============================================================ -/** - * @brief 启动 BLE 广播,使设备可被发现和连接。 - */ -static void ble_ota_start_advertising(void) // kept as free function for BLE callback compatibility -{ - if (BleOta::instance().isSuspendedForWifiOta()) { - DEBUG_DBG(TAG, "WiFi OTA 挂起中,跳过广播"); - return; - } - - int rc; - - // 自动推断可用地址类型(优先 Public,不可用则生成 Random) - uint8_t own_addr_type; - rc = ble_hs_id_infer_auto(0, &own_addr_type); - if (rc != 0) { - DEBUG_ERR(TAG, "推断地址类型失败: rc=%d, 尝试生成随机地址", rc); - // 生成不可解析随机地址作为 fallback - ble_addr_t rnd_addr; - rc = ble_hs_id_gen_rnd(0, &rnd_addr); - if (rc != 0) { - DEBUG_ERR(TAG, "生成随机地址也失败: rc=%d, BLE 广播无法启动", rc); - return; - } - own_addr_type = rnd_addr.type; - } - - DEBUG_INFO(TAG, "使用地址类型: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random"); - - // --- 构建广播数据 --- - uint8_t adv_data[31] = {0}; - int adv_pos = 0; - - // AD Flags: LE General Discoverable + BR/EDR Not Supported - adv_data[adv_pos++] = 0x02; // 长度 - adv_data[adv_pos++] = 0x01; // 类型 = Flags - adv_data[adv_pos++] = 0x06; // 值 - - // Complete Local Name - const char *name = BLE_DEVICE_NAME; - size_t name_len = strlen(name); - if (name_len > 29 - adv_pos - 2) { - name_len = 29 - adv_pos - 2; // 截断以适配广播包 - } - adv_data[adv_pos++] = (uint8_t)(name_len + 1); - adv_data[adv_pos++] = 0x09; // 类型 = Complete Local Name - memcpy(&adv_data[adv_pos], name, name_len); - adv_pos += name_len; - - rc = ble_gap_adv_set_data(adv_data, adv_pos); - if (rc != 0) { - DEBUG_ERR(TAG, "设置广播数据失败: %d", rc); - return; - } - - // 扫描响应: 包含 Service UUID 以帮助发现 - uint8_t scan_rsp[31] = {0}; - int rsp_pos = 0; - scan_rsp[rsp_pos++] = 0x11; // 长度 (2 + 15) - scan_rsp[rsp_pos++] = 0x09; // 类型 = Complete Local Name - name_len = strlen(name); - if (name_len > (size_t)(31 - rsp_pos)) { - name_len = 31 - rsp_pos; - } - memcpy(&scan_rsp[rsp_pos], name, name_len); - rsp_pos += name_len; - - rc = ble_gap_adv_rsp_set_data(scan_rsp, rsp_pos); - if (rc != 0) { - DEBUG_WARN(TAG, "设置扫描响应数据失败: %d", rc); - } - - // 开始广播 - struct ble_gap_adv_params adv_params = {}; - adv_params.conn_mode = BLE_GAP_CONN_MODE_UND; - adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN; - // own_addr_type 已在上面由 ble_hs_id_infer_auto 推断 - - rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, - &adv_params, ble_ota_gap_event, NULL); - if (rc != 0) { - DEBUG_ERR(TAG, "启动广播失败: %d", rc); - } else { - DEBUG_INFO(TAG, "BLE 广播已启动: \"%s\"", name); - } -} - -// ============================================================ -// NimBLE 同步/重置回调 -// ============================================================ -/** - * @brief NimBLE 协议栈同步完成回调,初始化广播。 - */ -static void ble_ota_on_sync(void) -{ - DEBUG_INFO(TAG, "NimBLE 协议栈同步完成"); - - // GATT 服务已在 serverStart() 中注册,不再重复注册 - // 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY - - DEBUG_INFO(TAG, "DATA_OUT 值句柄: %u", s_data_out_val_handle); - if (s_data_out_val_handle == 0) { - DEBUG_WARN(TAG, "DATA_OUT 值句柄为 0,通知功能不可用"); - } - - BleOta::instance().m_bleReady = true; - ble_ota_start_advertising(); -} - -/** - * @brief NimBLE 协议栈重置回调。 - * @param reason - */ -static void ble_ota_on_reset(int reason) -{ - DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason); - BleOta::instance().m_bleReady = false; -} - -// ============================================================ -// NimBLE Host 任务(独立 FreeRTOS 任务) -// ============================================================ -/** - * @brief NimBLE 主机任务入口,处理协议栈事件循环。 - * @param param - */ -static void ble_ota_nimble_host_task(void *param) -{ - (void)param; - DEBUG_INFO(TAG, "NimBLE Host 任务启动"); - nimble_port_run(); - // 正常情况下不会到达这里 - DEBUG_INFO(TAG, "NimBLE Host 任务退出"); -} - -// ============================================================ -// 公开 API -// ============================================================ -/** - * @brief 启动 BLE GATT 服务器并注册 OTA 服务。 - * @return ESP_OK 成功,其他失败。 - */ esp_err_t BleOta::serverStart() { int rc; @@ -2183,3 +405,4 @@ void BleOta::taskEntry(void *pvParameters) vTaskDelay(pdMS_TO_TICKS(10000)); } } + diff --git a/main/server/ble_ota_data.cpp b/main/server/ble_ota_data.cpp new file mode 100644 index 0000000..95bb35c --- /dev/null +++ b/main/server/ble_ota_data.cpp @@ -0,0 +1,1010 @@ +/* + * ble_ota_data.cpp - BLE OTA 数据路径:reorder、写 Flash、worker、DATA_IN + */ +#include "ble_ota_priv.h" + +// ============================================================ +// 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) + uint32_t cachedCount = 0; ///< 当前已缓存的乱序包数 +}; + +Esp32OtaReorderBuf s_reorder; +uint32_t s_fallback_ooo_count = 0; // 回退模式下自上次 ACK 以来的乱序包计数 +uint32_t s_gd32_ooo_count = 0; // GD32 路径自上次 ACK 以来的乱序/重复包计数 +ota_meta_t s_ble_gd32_meta = {}; +bool s_ble_gd32_meta_active = false; +bool s_conn_fallback_requested = false; + +// 前向声明(alloc/free 互相引用) +void esp32_reorder_free(); + +uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase) +{ + struct ble_gap_conn_desc desc = {}; + int rc = ble_gap_conn_find(conn_handle, &desc); + if (rc != 0) { + DEBUG_WARN(TAG, "%s读取 BLE 连接参数失败: rc=%d", phase, rc); + return 0; + } + + DEBUG_DBG(TAG, "%sBLE 连接参数: interval=%.2fms latency=%u supervision=%ums", + phase, + desc.conn_itvl * 1.25f, + (unsigned)desc.conn_latency, + (unsigned)desc.supervision_timeout * 10U); + return desc.conn_itvl; +} + +int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compatible) +{ + struct ble_gap_upd_params params = {}; + if (windows_compatible) { + /* 回退:10–12.5ms(itvl 8..10)。比原 15–20ms 更激进,让 Windows 回退也不至于掉到 20ms。 + * 10ms 是多数 Windows 主机可接受的较激进下限,仍留 3 档让对端有选择。 */ + params.itvl_min = 8; + params.itvl_max = 10; + params.min_ce_len = 0; + params.max_ce_len = 0; + } else { + /* 高速:7.5–10ms(itvl 6..8)。比原 7.5–15ms 激进收窄,让对端尽量落在 7.5–10ms + * 而不是被对端选到 15ms。仍保留 3 档(6/7/8)让对端有选择,避免锁死 7.5 被拒 + * 然后掉到更慢默认值。ce_len 6..12 保证事件时间不被 OS 砍得更短。 */ + params.itvl_min = 6; + params.itvl_max = 8; + params.min_ce_len = 6; + params.max_ce_len = 12; + } + params.latency = 0; + params.supervision_timeout = 200; + + int rc = ble_gap_update_params(conn_handle, ¶ms); + DEBUG_DBG(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms ce=%u..%u rc=%d", + windows_compatible ? "Windows兼容" : "高速", + params.itvl_min * 1.25f, + params.itvl_max * 1.25f, + (unsigned)params.min_ce_len, + (unsigned)params.max_ce_len, + rc); + return rc; +} + +/** + * @brief 分配 reorder buffer(slots 指针/长度数组)。 + * 在 ble_esp32_ota_begin() 中调用。分配失败则置 fallback=true, + * 后续自动回退到丢弃策略。 + */ +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.cachedCount = 0; + 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_DBG(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 重新分配。 + */ +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_reorder.cachedCount = 0; + s_fallback_ooo_count = 0; +} + +/** + * @brief flush expected 对应的连续缓存包到 flash,并释放对应 slot。 + * 由 esp32_write_ordered 在每次 expected 推进后调用。 + * @return flush 的包数(不含当前已写入的包)。 + */ +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 (s_reorder.cachedCount > 0) { + s_reorder.cachedCount--; + } + + 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_INFO(TAG, "Reorder 缺口已补齐: seq=%u,连续 flush=%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) { + uint32_t curReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); + DEBUG_ERR(TAG, "Reorder slot malloc 失败: seq=%u expected=%u bytes=%u," + "ACK 报告缺口并 abort", + seq, (unsigned)s_reorder.expected, (unsigned)curReceived); + sendWindowAck(cmd, + s_reorder.expected > 0 ? (uint16_t)(s_reorder.expected - 1) : 0, + curReceived); + return ESP_ERR_NO_MEM; + } + memcpy(copy, data, data_len); + s_reorder.slots[idx] = copy; + s_reorder.lens[idx] = data_len; + s_reorder.cachedCount++; + if (s_reorder.cachedCount == 1U) { + DEBUG_INFO(TAG, "Reorder 检测到缺口: expected=%u,开始缓存 seq=%u", + (unsigned)s_reorder.expected, seq); + } + 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 调用。 +// ───────────────────────────────────────────────────────────── + +/** + * @brief 初始化 ESP32 本机 OTA 写入会话。 + * 委托 Esp32OtaCore::begin,并复位 BLE 传输相关状态。 + * @param total_size 固件总大小(字节)。 + * @return ESP_OK 成功,其他为失败错误码。 + */ +esp_err_t ble_esp32_ota_begin(size_t total_size) +{ + // ★ 通过协调器发起(内部已含 forceCleanup 重试) + esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_BLE); + if (err != ESP_OK) { + return err; + } + // 复位 BLE 传输相关状态 + 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(); + /* 刷写前再要一次高速连接参数(连接时若未谈妥,此处补刀) */ + if (BleOta::instance().m_bleConnected && + BleOta::instance().m_connHandle != 0xFFFFU) { + (void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); + } + return ESP_OK; +} + +/** + * @brief BLE OTA 会话状态统一复位(共用清理内核)。 + * + * 集中处理三个清理动作: + * 1. abortCurrent():通知协调器按当前状态分发清理 + * (GD32_STORING→metaClear, ESP32_RECEIVING→core.abort, IDLE→空操作), + * 确保下次 begin() 不会被残留状态假拒绝。 + * 2. core.abort() 兜底:若 Esp32OtaCore 仍 isActive(异常路径),单独再 abort。 + * 3. BLE OTA 会话状态字段复位:m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending + * + esp32_reorder_free() 释放 reorder buffer + s_gd32_ooo_count 归零。 + * + * 不做:OTA 数据队列清理(调用方按需做)、WiFi PS 恢复(调用方按需做)。 + * 这两个动作在不同调用路径下时序/前置条件不同,交给调用方更安全。 + * + * 调用方: + * - ble_esp32_ota_abort(): ESP32 OTA 异常/中止路径 + * - 0xFB handler: 用户主动中止 + * - BLE_GAP_EVENT_DISCONNECT handler: BLE 断开(含 GD32 存储断开) + */ +void ble_ota_session_reset() +{ + // ★ 无条件通知协调器释放通道占用(m_sysState → IDLE)。 + // abortCurrent() 对 IDLE 状态是空操作,对其他状态均复位到 IDLE, + // 确保后续 begin() 不会被残留的 ESP32_RECEIVING / ERROR 等状态假拒绝。 + OtaCoordinator::instance().abortCurrent(); + // 若 ESP32 OTA core 仍活跃(兜底),单独再 abort 一次 + if (Esp32OtaCore::instance().isActive()) { + Esp32OtaCore::instance().abort(); + } + // 复位 BLE OTA 会话状态,避免下次会话读到旧值 + BleOta::instance().m_esp32LastSeq = 0xFFFFU; + BleOta::instance().m_esp32LastCmd = 0; + BleOta::instance().m_esp32RebootPending = false; + // 释放 ESP32 reorder buffer(无 buffer 时为空操作) + esp32_reorder_free(); + // 复位 GD32 路径乱序包计数器(节流 ACK 计数),保证下次 GD32 上传从干净状态开始 + s_gd32_ooo_count = 0; + s_ble_gd32_meta = {}; + s_ble_gd32_meta_active = false; +} + +/** + * @brief ESP32 OTA 异常/中止时调用:会话复位 + WiFi PS 恢复。 + * + * 关键:必须先通知协调器释放 m_sysState(避免下次 begin 被 ESP32_RECEIVING + * 状态假拒绝),再调 core.abort() 释放 mutex。两者重复 abort 幂等安全。 + */ +void ble_esp32_ota_abort() +{ + ble_ota_session_reset(); + // ★ BLE OTA 中止:恢复 WiFi 射频 + ble_ota_restore_wifi_ps(); +} + +/** + * @brief 发送窗口 ACK 通知,回报已处理的分块序号与累计字节数。 + * @param cmd 对应的数据命令字节。 + * @param seq 当前确认的分块序号。 + * @param completed_bytes 已完成写入的累计字节数。 + */ +void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes) +{ + uint8_t ack[8] = {0}; + ack[0] = BLE_OTA_NOTIFY_ACK; + ack[1] = cmd; + ack[2] = (uint8_t)(seq & 0xFFU); + ack[3] = (uint8_t)((seq >> 8) & 0xFFU); + ack[4] = (uint8_t)(completed_bytes & 0xFFU); + ack[5] = (uint8_t)((completed_bytes >> 8) & 0xFFU); + ack[6] = (uint8_t)((completed_bytes >> 16) & 0xFFU); + ack[7] = (uint8_t)((completed_bytes >> 24) & 0xFFU); + + esp_err_t err = BleOta::instance().notifyResponse(ack, sizeof(ack)); + if (err != ESP_OK) { + DEBUG_WARN(TAG, "发送 ACK 失败: cmd=0x%02X seq=%u err=%s", + cmd, seq, esp_err_to_name(err)); + } else { + DEBUG_DBG(TAG, "已发送 ACK: cmd=0x%02X seq=%u bytes=%lu", + cmd, seq, (unsigned long)completed_bytes); + } +} + +/** + * @brief 发送收尾阶段事件通知(开始/完成/失败/重启等)。 + * @param stage 阶段标识码。 + * @param value 阶段附带值(错误码或耗时等)。 + */ +void sendFinishEvent(uint8_t stage, uint32_t value) +{ + uint8_t pkt[6] = {0}; + pkt[0] = BLE_OTA_NOTIFY_FINISH; + pkt[1] = stage; + pkt[2] = (uint8_t)(value & 0xFFU); + pkt[3] = (uint8_t)((value >> 8) & 0xFFU); + pkt[4] = (uint8_t)((value >> 16) & 0xFFU); + pkt[5] = (uint8_t)((value >> 24) & 0xFFU); + esp_err_t err = BleOta::instance().notifyResponse(pkt, sizeof(pkt)); + if (err != ESP_OK) { + DEBUG_WARN(TAG, "发送 FINISH 事件失败: stage=0x%02X err=%s", + stage, esp_err_to_name(err)); + } +} + +/** @brief 将 session_id 和 code 打包为 32 位 BLE FINISH 事件值。 */ +uint32_t packBleFinishValue(uint16_t session_id, uint16_t code) +{ + return ((uint32_t)code << 16) | (uint32_t)session_id; +} + +/** @brief 将 32 字节 SHA-256 转为大写十六进制字符串(65字节含'\0')。 */ +void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]) +{ + if (!sha || !out_hex) return; + for (int i = 0; i < 32; ++i) { + snprintf(&out_hex[i * 2], 3, "%02X", sha[i]); + } + out_hex[64] = '\0'; +} + +/** @brief 通过 BLE Notify 推送设备端 SHA-256 给前端(用于比对验证)。 */ +void sendSha256Notify(const uint8_t sha[32], uint16_t session_id) +{ + if (!sha) return; + + uint8_t sha_pkt[35] = {0}; + sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; + memcpy(&sha_pkt[1], sha, 32); + sha_pkt[33] = (uint8_t)(session_id & 0xFFU); + sha_pkt[34] = (uint8_t)((session_id >> 8) & 0xFFU); + BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); +} + +/** @brief BLE 触发 GD32 中继后等待结果的 FreeRTOS 任务。 */ +void ble_gd32_relay_wait_task(void *pvParameters) +{ + BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)pvParameters; + uint16_t session_id = ctx ? ctx->session_id : 0; + if (ctx) free(ctx); + + uint32_t result_msg = (uint32_t)OTA_RESULT_ERR_TIMEOUT; + bool got_result = false; + QueueHandle_t rq = OtaRelay::getResultQueue(); + if (rq) { + got_result = (xQueueReceive(rq, &result_msg, pdMS_TO_TICKS(120000)) == pdTRUE); + } + + if (!got_result) { + DEBUG_ERR(TAG, "GD32 BLE OTA 等待中继结果超时"); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, (uint16_t)(-OTA_RESULT_ERR_TIMEOUT))); + vTaskDelete(NULL); + return; + } + + ota_result_t result = (ota_result_t)((int32_t)result_msg); + if (result == OTA_RESULT_OK) { + DEBUG_INFO(TAG, "GD32 BLE OTA 中继完成,通知前端 (stage=0x11, session=%u)", session_id); + sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_DONE, + packBleFinishValue(session_id, 0)); + } else { + uint16_t code = (uint16_t)(result < 0 ? -result : result); + DEBUG_ERR(TAG, "GD32 BLE OTA 中继失败: result=%d", (int)result); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, code)); + } + vTaskDelete(NULL); +} + + +// ============================================================ +// GATT 访问回调 +// ============================================================ + +/* + * CTRL (0x0001) - Write + * 写入 0xF7 触发 OTA 中继 + */ +/** + * @brief GATT CTRL 特征访问回调,处理控制命令写入。 + * @param conn_handle BLE 连接句柄。 + * @param attr_handle GATT 属性句柄。 + * @param ctxt GATT 访问上下文。 + * @param arg 回调用户参数。 + * @return NimBLE ATT 错误码,0 表示成功。 + */ + +int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + (void)conn_handle; + (void)attr_handle; + (void)arg; + + if (BleOta::instance().isSuspendedForWifiOta()) { + return BLE_ATT_ERR_WRITE_NOT_PERMITTED; + } + + if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { + return BLE_ATT_ERR_REQ_NOT_SUPPORTED; + } + + // 使用较大静态缓冲区:writeValueWithoutResponse 快速发送时, + // NimBLE 可能将多个写请求合并到同一个 mbuf 链中 + // ★ static: NimBLE host task 栈只有 4096 字节,不能用栈分配 + // ★ 4096 字节可容纳 8 个合并包(8×491=3928),避免截断丢包 + static constexpr size_t BLE_OTA_BUF_SIZE = 4096; + static uint8_t buf[BLE_OTA_BUF_SIZE]; + int recv_len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); + + if (recv_len < 8) { + DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 8: 7头+1数据)", recv_len); + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + + // 将数据拷贝到堆上并送入工作队列,避免在 NimBLE host task 中执行 + // 耗时的 flash 操作(esp_ota_begin / esp_ota_write)导致 BLE 连接断开。 + if (s_otaDataQueue) { + uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len); + if (heap_buf) { + memcpy(heap_buf, buf, (size_t)recv_len); + BleOtaDataBatch item = { heap_buf, (size_t)recv_len }; + if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) { + DEBUG_WARN(TAG, "DATA_IN 工作队列满,丢弃 %d 字节(等待客户端重传)", recv_len); + free(heap_buf); + } + } else { + DEBUG_ERR(TAG, "DATA_IN 堆内存不足 (%d 字节),丢弃", recv_len); + } + } + + return 0; +} + +/** + * @brief 处理单个 ESP32 OTA 数据包(ble_ota_process_data_batch 子步骤)。 + * @return true 中止整个 batch;false 继续下一包(offset 由外层统一推进)。 + */ +static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, + const uint8_t *buf, size_t data_offset, size_t data_len, + uint32_t expected_payload) +{ + if (seq == 0) { + if (total_size == 0) { + DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size); + return true; + } + // 活跃 BLE 会话中的 seq=0 一律按重传处理。窗口 0 的 ACK 丢失时, + // 前端会从累计字节 0 续发;此时重置 OTA 会丢掉已缓存的后续包。 + // 新上传由 0xFB/断连先清理旧会话,不能用 seq=0 猜测会话边界。 + if (Esp32OtaCore::instance().isActive()) { + ota_channel_t curCh = OtaCoordinator::instance().getOtaChannel(); + if (curCh != OTA_CHANNEL_BLE) { + DEBUG_WARN(TAG, "ESP32 OTA 被其他通道占用(ch=%d),BLE 拒绝抢占", + (int)curCh); + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + return true; + } + DEBUG_INFO(TAG, "ESP32 OTA 会话已激活,seq=0 视为重传,保留 reorder 状态"); + } else { + // 分区选择/大小校验/互斥由 Esp32OtaCore::begin 内部完成 + esp_err_t err = ble_esp32_ota_begin(total_size); + if (err != ESP_OK) { + if (err == ESP_ERR_INVALID_STATE) { + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + } + return true; + } + } + } + + // ★ 会话有效性检查:如果 begin() 失败(如互斥拒绝),后续包也应丢弃 + if (!Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "ESP32 OTA 会话无效,丢弃 seq=%u", seq); + return false; // 外层推进 offset,继续下一包 + } + + // ★ 防重复写入保护:如果已接收字节 >= 声明大小,跳过(重试引起的重复包) + 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); + return false; // 外层推进 offset,继续下一包 + } + + // ★ 使用带序号顺序保护的写入:拒绝乱序包并立即 ACK 通知发送端 + uint32_t beforeReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); + esp_err_t err = esp32_write_ordered(seq, cmd, &buf[data_offset], data_len); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s", + seq, esp_err_to_name(err)); + ble_esp32_ota_abort(); // core 内部完成 abort + mutex release + 状态复位 + return true; + } + + // ★ 仅在实际写入新字节时才触发 ACK:重复包/乱序缓存包不写入 flash, + // 若在窗口边界触发 ACK 会携带陈旧字节数,被发送端误判为丢包。 + uint32_t afterReceived = (uint32_t)Esp32OtaCore::instance().getReceived(); + bool progressMade = (afterReceived > beforeReceived); + + + // 完成判定:以实际按序写入字节数为准。 + bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD); + bool transferComplete = (total_size > 0 && afterReceived >= total_size); + + // ACK 按累计连续进度触发,而不是按当前到达包的 seq 触发。缺口包到达后 + // reorder 可能一次 flush 到下个窗口边界,例如 seq=99 使 expected 直达128; + // 此时必须立即 ACK 127,否则后续重复包都不产生 progress,前端只能超时。 + // + // ★ seq=0 强制 ACK:begin 后首包需尽快 ACK,前端才能继续窗口。 + // 前端必须等“首包已落盘”ACK 后再发窗口内其余包,否则队列满丢包→重传极慢。 + uint32_t completedPackets = (afterReceived + BLE_OTA_CHUNK_PAYLOAD - 1U) / + BLE_OTA_CHUNK_PAYLOAD; + bool reachedAckBoundary = completedPackets > 0U && + (completedPackets % BLE_OTA_ACK_WINDOW) == 0U; + bool firstPacketReady = (seq == 0U) && progressMade; + if (progressMade && + (reachedAckBoundary || + transferComplete || + isLastPacket || + firstPacketReady)) { + if (isLastPacket || transferComplete) { + DEBUG_DBG(TAG, "ESP32 尾包/完成检测: seq=%u data_len=%zu received=%u total=%" PRIu32, + seq, data_len, (unsigned int)afterReceived, total_size); + } else if (firstPacketReady) { + DEBUG_DBG(TAG, "ESP32 begin 就绪 ACK: seq=0 received=%u total=%" PRIu32, + (unsigned)afterReceived, total_size); + } + uint16_t ackSeq = s_reorder.expected > 0U + ? (uint16_t)(s_reorder.expected - 1U) + : seq; + sendWindowAck(cmd, ackSeq, afterReceived); + } + + if (transferComplete) { + DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)", + (unsigned int)Esp32OtaCore::instance().getReceived(), total_size, seq + 1); + OtaCoordinator::instance().notifyEsp32ReceiveComplete(); + BleOta::instance().m_esp32LastCmd = cmd; + BleOta::instance().m_esp32LastSeq = seq; + ble_ota_restore_wifi_ps(); + } + return false; +} + +/** + * @brief 处理单个 GD32 OTA 数据包(ble_ota_process_data_batch 子步骤)。 + * @return true 中止整个 batch;false 继续下一包(offset 由外层统一推进)。 + */ +/** GD32 路径硬失败:与 WiFi abortCurrentIfEpoch 对齐,复位协调器 + 本通道 RAM meta。 */ +void ble_gd32_fail_abort(const char *why) +{ + DEBUG_ERR(TAG, "GD32 OTA 中止: %s", why ? why : "?"); + OtaCoordinator::instance().abortCurrent(); + s_ble_gd32_meta = {}; + s_ble_gd32_meta_active = false; + s_gd32_ooo_count = 0; + ble_ota_restore_wifi_ps(); +} + +static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, + const uint8_t *buf, size_t data_offset, size_t data_len, + uint32_t expected_payload) +{ + if (seq == 0 && total_size > 0) { + // 与 ESP32 BLE 路径一致:活跃会话中的 seq=0 视为首包重传,禁止再次 prepare + // (否则会 erase 分区、清 meta,毁掉已写入进度)。 + if (s_ble_gd32_meta_active && s_ble_gd32_meta.total_size == total_size) { + if (s_ble_gd32_meta.stored_size > 0) { + DEBUG_INFO(TAG, "GD32 会话已激活,seq=0 视为重传,跳过写入 stored=%" PRIu32, + s_ble_gd32_meta.stored_size); + sendWindowAck(cmd, 0, s_ble_gd32_meta.stored_size); + return false; + } + DEBUG_INFO(TAG, "GD32 会话已激活且未落盘,seq=0 重传首块(不重新 prepare)"); + // fall through:stored_size==0 时重写 offset 0 + } else { + // 与 WiFi 共用 prepareGd32Storage:validate + begin + erase + clearMeta + saveMeta + esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage( + total_size, OTA_CHANNEL_BLE); + if (prep_err == ESP_ERR_INVALID_SIZE) { + DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " max=%zu", + total_size, OTA_MAX_FIRMWARE_SIZE); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_SIZE, total_size); + return true; + } else if (prep_err == ESP_ERR_INVALID_STATE) { + DEBUG_ERR(TAG, "GD32 OTA 协调器拒绝存储(系统忙)"); + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + return true; + } else if (prep_err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + return true; + } + + DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu", + total_size, data_len); + s_ble_gd32_meta = {}; + s_ble_gd32_meta.total_size = total_size; + s_ble_gd32_meta_active = true; + ble_ota_boost_wifi_ps(); + if (BleOta::instance().m_bleConnected && + BleOta::instance().m_connHandle != 0xFFFFU) { + (void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); + } + } + } + + if (!s_ble_gd32_meta_active || s_ble_gd32_meta.total_size == 0) { + DEBUG_ERR(TAG, "GD32 元数据无效,拒绝写入: seq=%u", seq); + return true; + } + ota_meta_t &meta = s_ble_gd32_meta; + + // ── 顺序检查:GD32 Flash 必须按序写入,无 reorder buffer ── + // 改进:乱序/重复包不再中止整个 batch,而是丢弃当前包 + 节流 ACK 报告缺口, + // 让前端尽快知道设备端实际进度(last_seq)并重传缺口。 + // 策略与 ESP32 fallback 路径一致(参考 esp32_write_ordered 行 459-477)。 + if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) { + // 重复包:seq <= last_seq(已写入过) + // ★ 重复包静默跳过,不发送 ACK:发送端已持有上一窗口的有效 ACK 进度, + // 重复包的 ACK 会携带未增长的字节数,被发送端误判为当前窗口丢包。 + if (seq <= meta.last_seq) { + DEBUG_DBG(TAG, "GD32 重复包: seq=%u <= last_seq=%" PRIu32 ",跳过", + seq, meta.last_seq); + return false; // 外层推进 offset,继续下一包 + } + + // 乱序包:seq > last_seq + 1(缺口) + // 节流策略:每 BLE_OTA_ACK_WINDOW 个乱序包才发一次 ACK,或在窗口边界处发 ACK。 + // 避免大量乱序包涌入时触发 ACK 风暴,耗尽 NimBLE mbuf 池导致 ESP_ERR_NO_MEM。 + s_gd32_ooo_count++; + bool shouldAck = + (s_gd32_ooo_count >= BLE_OTA_ACK_WINDOW) || + ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U); + if (shouldAck) { + DEBUG_WARN(TAG, "GD32 乱序包跳过: seq=%u (last_seq=%" PRIu32 "),节流 ACK bytes=%" PRIu32 " (ooo=%u)", + seq, meta.last_seq, meta.stored_size, (unsigned)s_gd32_ooo_count); + sendWindowAck(cmd, (uint16_t)meta.last_seq, meta.stored_size); + s_gd32_ooo_count = 0; + } else { + DEBUG_DBG(TAG, "GD32 乱序包跳过: seq=%u (last_seq=%" PRIu32 "),抑制 ACK (ooo=%u)", + seq, meta.last_seq, (unsigned)s_gd32_ooo_count); + } + return false; // 外层推进 offset,继续下一包 + } + + // 顺序包到达,复位乱序计数器 + if (s_gd32_ooo_count != 0) { + s_gd32_ooo_count = 0; + } + + // --- 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); + return false; // 外层推进 offset,继续下一包 + } + 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); + ble_gd32_fail_abort("write OOB"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0); + return true; + } + + esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(flash_offset, &buf[data_offset], data_len); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s", + seq, flash_offset, esp_err_to_name(err)); + ble_gd32_fail_abort(esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err); + return true; + } + + uint32_t stored_size = flash_offset + (uint32_t)data_len; + meta.stored_size = stored_size; + meta.last_seq = seq; + const bool transfer_complete = stored_size >= meta.total_size; + // seq=0:prepare 后写完首包必须 ACK,前端才能继续发后续窗口 + const bool checkpoint = + (seq == 0U) || + ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) || + transfer_complete || + (expected_payload < BLE_OTA_CHUNK_PAYLOAD); + + // NVS commit 是毫秒级操作,不能每 480 字节执行一次。仅在 ACK 窗口边界 + // 和尾包持久化;正常完成时尾包一定落盘,后续 SHA/中继读取到的语义不变。 + if (checkpoint) { + err = OtaCoordinator::instance().updateGd32Meta(stored_size, seq); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 元数据 checkpoint 失败 (seq=%u): %s", + seq, esp_err_to_name(err)); + ble_gd32_fail_abort(esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)err); + return true; + } + sendWindowAck(cmd, seq, stored_size); + } + + DEBUG_DBG(TAG, "Flash 写入成功: seq=%u offset=%" PRIu32 " stored=%" PRIu32, + seq, flash_offset, stored_size); + + // --- 检查 GD32 固件是否接收完毕 --- + if (transfer_complete) { + DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)", + meta.stored_size, meta.total_size, seq + 1); + s_ble_gd32_meta_active = false; + ble_ota_restore_wifi_ps(); + // 与 WiFi 共用 finishGd32StorageAfterReceive:complete + SHA + esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); + if (sha_err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); + } + } + return false; +} + +/** + * @brief 处理一批 OTA 数据包(在工作线程上下文中调用)。 + * 包含 ESP32 和 GD32 的数据接收逻辑。 + * @param buf 数据缓冲区(包含一个或多个合并的包)。 + * @param recv_len 数据长度。 + */ +void ble_ota_process_data_batch(const uint8_t *buf, size_t recv_len) +{ + // 循环处理 mbuf 中的所有完整数据包 + size_t offset = 0; + while (offset + 7 <= recv_len) { + // 解析包头 + uint8_t cmd = buf[offset + 0]; + uint16_t seq = (uint16_t)(buf[offset + 1] | (buf[offset + 2] << 8)); + uint32_t total_size = (uint32_t)(buf[offset + 3] | (buf[offset + 4] << 8) | + (buf[offset + 5] << 16) | (buf[offset + 6] << 24)); + + size_t data_offset = offset + 7; + + // 根据包序号和固件总大小计算期望 payload,而非依赖 recv_len + // 这样即使 NimBLE mbuf 合并导致缓冲区截断,也不会误判包长度 + uint32_t expected_payload = (total_size > 0) + ? ((uint32_t)BLE_OTA_CHUNK_PAYLOAD < (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD) + ? (uint32_t)BLE_OTA_CHUNK_PAYLOAD + : (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD)) + : (uint32_t)BLE_OTA_CHUNK_PAYLOAD; + + size_t data_len = (size_t)expected_payload; + size_t remaining_after_header = recv_len - data_offset; + + // 如果缓冲区中实际可用数据不足,截断 data_len + if (remaining_after_header < expected_payload) { + if (remaining_after_header == 0) { + // 没有更多数据,退出循环 + break; + } + // 缓冲区截断,使用实际可用数据 + data_len = remaining_after_header; + } + + // 当前包总长度: header(7) + payload(data_len) + size_t packet_len = 7 + data_len; + + DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu offset=%zu", + cmd, seq, total_size, data_len, offset); + + // 分发到对应通道的包处理器 + bool abort_batch = false; + if (cmd == BLE_OTA_CMD_ESP32_DATA) { + abort_batch = process_esp32_packet(cmd, seq, total_size, + buf, data_offset, data_len, expected_payload); + } else if (cmd == BLE_OTA_CMD_GD32_DATA) { + abort_batch = process_gd32_packet(cmd, seq, total_size, + buf, data_offset, data_len, expected_payload); + } else { + DEBUG_WARN(TAG, "DATA_IN 未知 cmd: 0x%02X offset=%zu", cmd, offset); + } + + if (abort_batch) { + return; + } + + // 推进到下一个包 + offset += packet_len; + + // 检查剩余数据是否足够组成一个完整的包头 + if (offset + 7 > recv_len) break; + } +} + +/** + * @brief OTA 数据工作线程:从队列接收数据并调用处理函数。 + * 独立于 NimBLE host task,避免 flash 操作阻塞 BLE 协议栈。 + */ +void ble_ota_data_worker_task(void *pvParameters) +{ + (void)pvParameters; + BleOta::instance().m_workerTaskHandle = xTaskGetCurrentTaskHandle(); + DEBUG_INFO(TAG, "OTA 数据工作线程已启动"); + + while (1) { + BleOtaDataBatch item; + if (xQueueReceive(s_otaDataQueue, &item, portMAX_DELAY) == pdTRUE) { + if (!item.data || item.len == 0) { + free(item.data); + continue; + } + + // 若 BLE 已断开或 WiFi OTA 挂起,丢弃队列中的残留数据 + if (!BleOta::instance().isConnected() || + BleOta::instance().isSuspendedForWifiOta()) { + DEBUG_DBG(TAG, "BLE 不可用/挂起,丢弃 %zu 字节队列数据", item.len); + free(item.data); + continue; + } + + ble_ota_process_data_batch(item.data, item.len); + free(item.data); + } + } +} + +/* + * VERSION (0x0004) - Read + * 返回固件版本字符串 + */ +/** + * @brief GATT VERSION 特征访问回调,返回固件版本字符串。 + * @param conn_handle BLE 连接句柄。 + * @param attr_handle GATT 属性句柄。 + * @param ctxt GATT 访问上下文。 + * @param arg 回调用户参数。 + * @return NimBLE ATT 错误码,0 表示成功。 + */ diff --git a/main/server/ble_ota_host.cpp b/main/server/ble_ota_host.cpp new file mode 100644 index 0000000..103df5c --- /dev/null +++ b/main/server/ble_ota_host.cpp @@ -0,0 +1,701 @@ +/* + * ble_ota_host.cpp - BLE OTA GATT/GAP/广播/NimBLE Host + */ +#include "ble_ota_priv.h" + +uint16_t s_data_out_val_handle = 0; + +int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + (void)conn_handle; + (void)attr_handle; + (void)arg; + + if (BleOta::instance().isSuspendedForWifiOta()) { + return BLE_ATT_ERR_WRITE_NOT_PERMITTED; + } + + if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { + return BLE_ATT_ERR_REQ_NOT_SUPPORTED; + } + + uint8_t buf[36] = {0}; + int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); + if (len < 1) { + DEBUG_WARN(TAG, "CTRL 数据为空"); + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + + DEBUG_DBG(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len); + + if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) { + uint16_t session_id = 0; + if (len >= 35) { + session_id = (uint16_t)(buf[33] | (buf[34] << 8)); + } else if (len >= 3) { + session_id = (uint16_t)(buf[1] | (buf[2] << 8)); + } + + if (len < 35) { + DEBUG_WARN(TAG, "CTRL 0xF7 数据长度不足 35(前端 SHA-256 必传),拒绝触发 GD32 中继"); + sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, + packBleFinishValue(session_id, 1)); + return 0; + } + + // 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继 + ota_meta_t meta = {}; + esp_err_t err = OtaCoordinator::instance().loadGd32Meta(&meta); + if (err == ESP_OK && + meta.total_size > 0 && + meta.stored_size >= meta.total_size) { + const uint8_t *frontend_sha = &buf[1]; + char frontend_sha_hex[65] = {0}; + char device_sha_hex[65] = {0}; + shaBytesToHexUpper(frontend_sha, frontend_sha_hex); + + // ★ 统一走共享 helper:计算设备端 SHA → 比对前端 SHA + uint8_t device_sha[32] = {0}; + err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha); + shaBytesToHexUpper(device_sha, device_sha_hex); + sendSha256Notify(device_sha, session_id); + DEBUG_DBG(TAG, "GD32 前端 SHA-256: %s, session=%u", frontend_sha_hex, session_id); + DEBUG_DBG(TAG, "GD32 缓存 SHA-256: %s", device_sha_hex); + + if (err == ESP_ERR_INVALID_CRC) { + DEBUG_ERR(TAG, "GD32 缓存 SHA-256 与前端文件不匹配,拒绝触发 SPI 中继"); + sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, + packBleFinishValue(session_id, 0)); + return 0; + } else if (err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 SHA-256 计算/读取失败: %s", esp_err_to_name(err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 3)); + return 0; + } + + DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继", + meta.stored_size, meta.total_size); + if (OtaRelay::getResultQueue()) { + uint32_t stale = 0; + while (xQueueReceive(OtaRelay::getResultQueue(), &stale, 0) == pdTRUE) { + DEBUG_WARN(TAG, "清理旧 GD32 中继结果: %" PRIu32, stale); + } + } + esp_err_t trig_err = OtaCoordinator::instance().triggerGd32Relay(); + if (trig_err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 中继触发失败: %s", esp_err_to_name(trig_err)); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 1)); + return 0; + } + + sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START, + packBleFinishValue(session_id, 0)); + DEBUG_INFO(TAG, "已通知前端: GD32 中继已开始 (stage=0x10, session=%u)", session_id); + + BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx)); + if (!ctx) { + DEBUG_ERR(TAG, "GD32 中继等待任务上下文分配失败"); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 2)); + return 0; + } + ctx->session_id = session_id; + BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task, + "ble_gd32_wait", + TASK_STACK_BLE_GD32_WAIT, + ctx, + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 GD32 中继等待任务失败"); + free(ctx); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 2)); + } + } else { + DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")", + meta.stored_size, meta.total_size); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 1)); + } + } else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) { + uint16_t session_id = 0; + if (len >= 35) { + session_id = (uint16_t)(buf[33] | (buf[34] << 8)); + } + if (!Esp32OtaCore::instance().isActive()) { + DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, + packBleFinishValue(session_id, 1)); + return 0; + } + if (BleOta::instance().m_esp32RebootPending) { + DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, + packBleFinishValue(session_id, 1)); + return 0; + } + + // 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对, + // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。 + if (len < 33) { + DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG); + return 0; + } + + BleEsp32FinishCtx *finish_ctx = (BleEsp32FinishCtx *)malloc(sizeof(BleEsp32FinishCtx)); + if (!finish_ctx) { + DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, + packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM)); + return 0; + } + for (int i = 0; i < 32; ++i) { + snprintf(&finish_ctx->sha_hex[i * 2], 3, "%02x", buf[1 + i]); + } + finish_ctx->sha_hex[64] = '\0'; + finish_ctx->session_id = session_id; + DEBUG_DBG(TAG, "前端 SHA-256: %s, session=%u", finish_ctx->sha_hex, session_id); + + BleOta::instance().m_esp32RebootPending = true; + BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, + "ble_ota_finish", + TASK_STACK_BLE_FINISH, + finish_ctx, // heap context; task 负责 free + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + free(finish_ctx); + BleOta::instance().m_esp32RebootPending = false; + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, + packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM)); + } else { + DEBUG_DBG(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)"); + } + } else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) { + // 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host + DEBUG_INFO(TAG, "收到 0xFA 重启请求,创建 restart task(500ms 后 esp_restart())"); + if (BleOta::instance().m_esp32RebootPending) { + DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL"); + return 0; + } + BleOta::instance().m_esp32RebootPending = true; + BaseType_t ok = xTaskCreate(ble_esp32_restart_task, + "ble_ota_restart", + TASK_STACK_BLE_RESTART, + NULL, + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 0xFA 重启任务失败"); + BleOta::instance().m_esp32RebootPending = false; + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0); + } + } else if (buf[0] == BLE_OTA_CTRL_ABORT_OTA) { + // 0xFB: 中止当前 OTA(保持 BLE 连接) + // - 数据接收阶段(GD32_STORING / ESP32_RECEIVING):立即中止 + 清队列 + 回复 ABORTED + // - 中继/收尾阶段(GD32_RELAYING / ESP32_FINALIZING):拒绝中止(SPI 转发不可中断) + // - IDLE/已完成状态:幂等回复 ABORTED + uint16_t session_id = 0; + if (len >= 3) { + session_id = (uint16_t)(buf[1] | (buf[2] << 8)); + } + + ota_system_state_t cur_state = OtaCoordinator::instance().getSystemState(); + DEBUG_WARN(TAG, "收到 0xFB 中止请求: state=%s session=%u", + OtaCoordinator::stateToString(cur_state), session_id); + + if (cur_state == OTA_SYS_GD32_RELAYING) { + DEBUG_WARN(TAG, "GD32 SPI 中继进行中,无法中止(请等待完成或断开 BLE)"); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY, + packBleFinishValue(session_id, 2)); + return 0; + } + if (cur_state == OTA_SYS_ESP32_FINALIZING) { + DEBUG_WARN(TAG, "ESP32 OTA 收尾进行中,无法中止"); + sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, + packBleFinishValue(session_id, 2)); + return 0; + } + + // ── 可中止状态:清队列 + abort core + 复位协调器 ── + if (s_otaDataQueue) { + BleOtaDataBatch stale; + size_t dropped = 0; + while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) { + free(stale.data); + dropped++; + } + if (dropped > 0) { + DEBUG_INFO(TAG, "中止时清理 OTA 数据队列: %zu 项", dropped); + } + } + + // abortCurrent 内部按状态分发:GD32_STORING→metaClear, ESP32_RECEIVING→core.abort, IDLE→空操作 + // ble_ota_session_reset() 统一负责 abortCurrent + core.abort 兜底 + 会话状态复位 + // (m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending/reorder/s_gd32_ooo_count) + ble_ota_session_reset(); + + ble_ota_restore_wifi_ps(); + + DEBUG_INFO(TAG, "OTA 已中止(BLE 保持连接),通知前端 (stage=0x20)"); + sendFinishEvent(BLE_OTA_FINISH_ABORTED, + packBleFinishValue(session_id, 0)); + } + // 0xF7 = 触发 GD32 中继 + // 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对, + // 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启) + // 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连) + // 0xFB = 中止当前 OTA(不断开 BLE):仅数据接收阶段有效,中继/收尾阶段拒绝 + + return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0 +} + +/* + * DATA_IN (0x0002) - Write + * 数据块格式: + * [0] cmd (0x00=数据) + * [1-2] seq uint16 LE - 分块序号 + * [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效) + * [7..n] payload 固件数据 + * 注: 已移除CRC字段(BLE链路层已有CRC校验) + */ +/** + * @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。 + * @param conn_handle BLE 连接句柄。 + * @param attr_handle GATT 属性句柄。 + * @param ctxt GATT 访问上下文。 + * @param arg 回调用户参数。 + * @return NimBLE ATT 错误码,0 表示成功。 + */ + +int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + (void)conn_handle; + (void)attr_handle; + (void)arg; + + if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) { + return BLE_ATT_ERR_REQ_NOT_SUPPORTED; + } + + const char *version = FW_VERSION; + // 版本字符串: "vX.Y.Z YYYY-MM-DD HH:MM:SS" + char ver_buf[64]; + int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, 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; +} + +/* + * DATA_OUT (0x0003) - Notify only + * 访问回调不做读写处理,通知由 BleOta::instance().notifyResponse() 发送 + */ +/** + * @brief GATT DATA_OUT 特征访问回调(Notify-only,占位实现)。 + * @param conn_handle BLE 连接句柄。 + * @param attr_handle GATT 属性句柄。 + * @param ctxt GATT 访问上下文。 + * @param arg 回调用户参数。 + * @return 0。 + */ +int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + (void)conn_handle; + (void)attr_handle; + (void)ctxt; + (void)arg; + return 0; +} + +// ============================================================ +// GATT 服务定义 +// ============================================================ + +// BLE_UUID16_DECLARE 在 C++ 中产生 "taking address of rvalue" 编译错误。 +// 改用 static ble_uuid16_t 变量,再取 .u 地址。 +/** @brief OTA GATT 服务 UUID */ +const ble_uuid16_t GATT_SVC_UUID = BLE_UUID16_INIT(BLE_OTA_SERVICE_UUID); +/** @brief CTRL 特征 UUID,写入 0xF7 触发中继 */ +const ble_uuid16_t GATT_CTRL_UUID = BLE_UUID16_INIT(BLE_OTA_CTRL_UUID); +/** @brief DATA_IN 特征 UUID,接收固件数据块 */ +const ble_uuid16_t GATT_DATA_IN_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_IN_UUID); +/** @brief DATA_OUT 特征 UUID,Notify 推送 GD32 响应 */ +const ble_uuid16_t GATT_DATA_OUT_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_OUT_UUID); +/** @brief VERSION 特征 UUID,返回固件版本字符串 */ +const ble_uuid16_t GATT_VERSION_UUID = BLE_UUID16_INIT(BLE_OTA_VERSION_UUID); + +// NimBLE C 结构体在 C++ designated initializer 不能显式初始化全部字段。 +// 用 pragma 局部关闭 -Wmissing-field-initializers 警告。 +#ifdef __cplusplus +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wmissing-field-initializers" +#endif + +const struct ble_gatt_chr_def gatt_ota_chrs[] = { + { + .uuid = &GATT_CTRL_UUID.u, + .access_cb = ble_ota_ctrl_access, + .flags = BLE_GATT_CHR_F_WRITE, + }, + { + .uuid = &GATT_DATA_IN_UUID.u, + .access_cb = ble_ota_data_in_access, + .flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP, + }, + { + .uuid = &GATT_DATA_OUT_UUID.u, + .access_cb = ble_ota_data_out_access, + .flags = BLE_GATT_CHR_F_NOTIFY, + .val_handle = &s_data_out_val_handle, + }, + { + .uuid = &GATT_VERSION_UUID.u, + .access_cb = ble_ota_version_access, + .flags = BLE_GATT_CHR_F_READ, + }, + {} // 终止符 +}; + +const struct ble_gatt_svc_def gatt_ota_svcs[] = { + { + .type = BLE_GATT_SVC_TYPE_PRIMARY, + .uuid = &GATT_SVC_UUID.u, + .characteristics = (struct ble_gatt_chr_def *)gatt_ota_chrs, + }, + {} // 终止符 +}; + +#ifdef __cplusplus +#pragma GCC diagnostic pop +#endif + +// ============================================================ +// GAP 事件回调 +// ============================================================ +/** + * @brief BLE GAP 事件回调,处理连接和断开事件。 + * @param event + * @param arg + * @return 0 成功。 + */ +int ble_ota_gap_event(struct ble_gap_event *event, void *arg) +{ + (void)arg; + + switch (event->type) { + + case BLE_GAP_EVENT_CONNECT: + if (event->connect.status == 0) { + if (BleOta::instance().isSuspendedForWifiOta()) { + /* WiFi OTA 中拒绝 BLE 连接,立刻踢掉 */ + DEBUG_WARN(TAG, "WiFi OTA 中,拒绝 BLE 连接 handle=%d", + event->connect.conn_handle); + (void)ble_gap_terminate(event->connect.conn_handle, + BLE_ERR_REM_USER_CONN_TERM); + BleOta::instance().m_bleConnected = false; + return 0; + } + // 连接成功 + BleOta::instance().m_bleConnected = true; + BleOta::instance().m_connHandle = event->connect.conn_handle; + s_conn_fallback_requested = false; + DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", BleOta::instance().m_connHandle); + + ble_ota_log_conn_params(BleOta::instance().m_connHandle, "初始"); + int rc = ble_ota_request_conn_params(BleOta::instance().m_connHandle, false); + if (rc != 0) { + s_conn_fallback_requested = true; + ble_ota_request_conn_params(BleOta::instance().m_connHandle, true); + } + /* 优先 LE 2M PHY(空中速率约翻倍);对端不支持则保持 1M,失败仅打日志 */ + { + const uint8_t phy_mask = BLE_GAP_LE_PHY_1M_MASK | BLE_GAP_LE_PHY_2M_MASK; + int phy_rc = ble_gap_set_prefered_le_phy( + BleOta::instance().m_connHandle, phy_mask, phy_mask, + BLE_GAP_LE_PHY_CODED_ANY); + DEBUG_DBG(TAG, "请求 LE PHY 1M|2M: rc=%d", phy_rc); + } + } else { + DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status); + BleOta::instance().m_bleConnected = false; + } + return 0; + + case BLE_GAP_EVENT_DISCONNECT: + DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d", + event->disconnect.reason); + + // ★ 断开时清空数据队列,丢弃属于旧会话的残留包 + if (s_otaDataQueue) { + drainOtaDataQueue(); + } + + BleOta::instance().m_bleConnected = false; + BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE; + s_conn_fallback_requested = false; + + /* WiFi OTA 软挂起期间:主动踢连触发的 disconnect 不得 abort WiFi OTA,也不得再开广播 */ + if (BleOta::instance().isSuspendedForWifiOta()) { + DEBUG_INFO(TAG, "WiFi OTA 挂起中:忽略 BLE 断开对 OTA 状态的影响"); + return 0; + } + + // ★ BLE 断开时恢复 WiFi 射频 + ble_ota_restore_wifi_ps(); + + // ★ 断开时清理"BLE 数据接收阶段"残留的 OTA 会话状态,避免下次刷写被 + // 系统 busy 假拒绝。注意区分可中止 / 不可中止状态: + // - GD32_STORING / ESP32_RECEIVING / ESP32_FINALIZING:BLE 是唯一数据源, + // 断开即等价于中止,必须 abortCurrent() + 清理底层 core。 + // - GD32_RELAYING / ESP32_REBOOTING:SPI 中继/重启不依赖 BLE,不能打断。 + // 这些状态保留,等中继/重启自然结束。 + // - IDLE / STORED / COMPLETE / ERROR:abortCurrent 幂等安全,直接调即可。 + { + ota_system_state_t cur = OtaCoordinator::instance().getSystemState(); + if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) { + DEBUG_WARN(TAG, "BLE 断开但 %s 进行中,保留状态等待自然结束", + OtaCoordinator::stateToString(cur)); + } else if (cur == OTA_SYS_ESP32_RECEIVING || + cur == OTA_SYS_ESP32_FINALIZING || + cur == OTA_SYS_GD32_STORING) { + /* 仅当当前 OTA 通道是 BLE 时才因断连中止;WiFi 通道的 RECEIVING 不能被误杀 */ + ota_channel_t ch = OTA_CHANNEL_WIFI; + { + /* get channel via snapshot if available */ + OtaSnapshot snap = OtaCoordinator::instance().getSnapshot(); + ch = snap.channel; + } + if (ch == OTA_CHANNEL_BLE) { + DEBUG_WARN(TAG, "BLE 断开,中止 BLE 通道 OTA (state=%s)", + OtaCoordinator::stateToString(cur)); + ble_ota_session_reset(); + } else { + DEBUG_INFO(TAG, "BLE 断开但当前为 WiFi OTA,不中止会话"); + } + } else { + ble_ota_session_reset(); + } + } + // 恢复广播以接受新连接 + ble_ota_start_advertising(); + return 0; + + case BLE_GAP_EVENT_ADV_COMPLETE: + if (BleOta::instance().isSuspendedForWifiOta()) { + return 0; /* WiFi OTA 中不要自动再开广播 */ + } + DEBUG_INFO(TAG, "广播周期结束,重新开始"); + ble_ota_start_advertising(); + return 0; + + case BLE_GAP_EVENT_SUBSCRIBE: + DEBUG_INFO(TAG, "CCC 描述符更新: conn=%d attr=%d notify=%d indicate=%d", + event->subscribe.conn_handle, + event->subscribe.attr_handle, + event->subscribe.cur_notify, + event->subscribe.cur_indicate); + if (event->subscribe.cur_notify) { + ble_ota_log_conn_params(event->subscribe.conn_handle, "订阅时"); + } + return 0; + + case BLE_GAP_EVENT_CONN_UPDATE: { + DEBUG_DBG(TAG, "BLE 连接参数更新完成: status=%d", event->conn_update.status); + uint16_t interval = ble_ota_log_conn_params(event->conn_update.conn_handle, "更新后"); + /* 回退阈值:高速分支目标是 itvl ≤ 8(≤10ms)。若协商失败或落在 > 8(>10ms), + * 说明高速区间被对端拒绝/降级,尝试 Windows 兼容回退路径(10..12.5ms)。 + * 原阈值 > 16(>20ms)过宽松,收窄后让回退更早触发、避免停在 15ms 这种"慢但不算失败"的区间。 */ + if (!s_conn_fallback_requested && + (event->conn_update.status != 0 || interval == 0 || interval > 8)) { + s_conn_fallback_requested = true; + ble_ota_request_conn_params(event->conn_update.conn_handle, true); + } + return 0; + } + + case BLE_GAP_EVENT_MTU: + DEBUG_INFO(TAG, "MTU 协商: conn=%d mtu=%d", + event->mtu.conn_handle, event->mtu.value); + return 0; + +#ifdef BLE_GAP_EVENT_PHY_UPDATE_COMPLETE + case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: { + /* NimBLE: event type 名 *_COMPLETE,union 字段 phy_updated(非 phy_update)。 + * 2M 生效才能翻倍空中速率;Chrome/Win 常仍停在 1M。 */ + const uint8_t rx = event->phy_updated.rx_phy; + const uint8_t tx = event->phy_updated.tx_phy; + const char *rx_phy = (rx == BLE_GAP_LE_PHY_2M) ? "2M" : + (rx == BLE_GAP_LE_PHY_1M) ? "1M" : + (rx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?"; + const char *tx_phy = (tx == BLE_GAP_LE_PHY_2M) ? "2M" : + (tx == BLE_GAP_LE_PHY_1M) ? "1M" : + (tx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?"; + DEBUG_INFO(TAG, "PHY 更新: status=%d rx=%s tx=%s", + event->phy_updated.status, rx_phy, tx_phy); + return 0; + } +#endif + + default: + return 0; + } +} + +// ============================================================ +// BLE 广播 +// ============================================================ +/** + * @brief 启动 BLE 广播,使设备可被发现和连接。 + */ +void ble_ota_start_advertising(void) // kept as free function for BLE callback compatibility +{ + if (BleOta::instance().isSuspendedForWifiOta()) { + DEBUG_DBG(TAG, "WiFi OTA 挂起中,跳过广播"); + return; + } + + int rc; + + // 自动推断可用地址类型(优先 Public,不可用则生成 Random) + uint8_t own_addr_type; + rc = ble_hs_id_infer_auto(0, &own_addr_type); + if (rc != 0) { + DEBUG_ERR(TAG, "推断地址类型失败: rc=%d, 尝试生成随机地址", rc); + // 生成不可解析随机地址作为 fallback + ble_addr_t rnd_addr; + rc = ble_hs_id_gen_rnd(0, &rnd_addr); + if (rc != 0) { + DEBUG_ERR(TAG, "生成随机地址也失败: rc=%d, BLE 广播无法启动", rc); + return; + } + own_addr_type = rnd_addr.type; + } + + DEBUG_INFO(TAG, "使用地址类型: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random"); + + // --- 构建广播数据 --- + uint8_t adv_data[31] = {0}; + int adv_pos = 0; + + // AD Flags: LE General Discoverable + BR/EDR Not Supported + adv_data[adv_pos++] = 0x02; // 长度 + adv_data[adv_pos++] = 0x01; // 类型 = Flags + adv_data[adv_pos++] = 0x06; // 值 + + // Complete Local Name + const char *name = BLE_DEVICE_NAME; + size_t name_len = strlen(name); + if (name_len > 29 - adv_pos - 2) { + name_len = 29 - adv_pos - 2; // 截断以适配广播包 + } + adv_data[adv_pos++] = (uint8_t)(name_len + 1); + adv_data[adv_pos++] = 0x09; // 类型 = Complete Local Name + memcpy(&adv_data[adv_pos], name, name_len); + adv_pos += name_len; + + rc = ble_gap_adv_set_data(adv_data, adv_pos); + if (rc != 0) { + DEBUG_ERR(TAG, "设置广播数据失败: %d", rc); + return; + } + + // 扫描响应: 包含 Service UUID 以帮助发现 + uint8_t scan_rsp[31] = {0}; + int rsp_pos = 0; + scan_rsp[rsp_pos++] = 0x11; // 长度 (2 + 15) + scan_rsp[rsp_pos++] = 0x09; // 类型 = Complete Local Name + name_len = strlen(name); + if (name_len > (size_t)(31 - rsp_pos)) { + name_len = 31 - rsp_pos; + } + memcpy(&scan_rsp[rsp_pos], name, name_len); + rsp_pos += name_len; + + rc = ble_gap_adv_rsp_set_data(scan_rsp, rsp_pos); + if (rc != 0) { + DEBUG_WARN(TAG, "设置扫描响应数据失败: %d", rc); + } + + // 开始广播 + struct ble_gap_adv_params adv_params = {}; + adv_params.conn_mode = BLE_GAP_CONN_MODE_UND; + adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN; + // own_addr_type 已在上面由 ble_hs_id_infer_auto 推断 + + rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, + &adv_params, ble_ota_gap_event, NULL); + if (rc != 0) { + DEBUG_ERR(TAG, "启动广播失败: %d", rc); + } else { + DEBUG_INFO(TAG, "BLE 广播已启动: \"%s\"", name); + } +} + +// ============================================================ +// NimBLE 同步/重置回调 +// ============================================================ +/** + * @brief NimBLE 协议栈同步完成回调,初始化广播。 + */ +void ble_ota_on_sync(void) +{ + DEBUG_INFO(TAG, "NimBLE 协议栈同步完成"); + + // GATT 服务已在 serverStart() 中注册,不再重复注册 + // 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY + + DEBUG_INFO(TAG, "DATA_OUT 值句柄: %u", s_data_out_val_handle); + if (s_data_out_val_handle == 0) { + DEBUG_WARN(TAG, "DATA_OUT 值句柄为 0,通知功能不可用"); + } + + BleOta::instance().m_bleReady = true; + ble_ota_start_advertising(); +} + +/** + * @brief NimBLE 协议栈重置回调。 + * @param reason + */ +void ble_ota_on_reset(int reason) +{ + DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason); + BleOta::instance().m_bleReady = false; +} + +// ============================================================ +// NimBLE Host 任务(独立 FreeRTOS 任务) +// ============================================================ +/** + * @brief NimBLE 主机任务入口,处理协议栈事件循环。 + * @param param + */ +void ble_ota_nimble_host_task(void *param) +{ + (void)param; + DEBUG_INFO(TAG, "NimBLE Host 任务启动"); + nimble_port_run(); + // 正常情况下不会到达这里 + DEBUG_INFO(TAG, "NimBLE Host 任务退出"); +} + +// ============================================================ +// 公开 API +// ============================================================ +/** + * @brief 启动 BLE GATT 服务器并注册 OTA 服务。 + * @return ESP_OK 成功,其他失败。 + */ diff --git a/main/server/ble_ota_priv.h b/main/server/ble_ota_priv.h new file mode 100644 index 0000000..3dd667b --- /dev/null +++ b/main/server/ble_ota_priv.h @@ -0,0 +1,120 @@ +/* + * ble_ota_priv.h - BLE OTA 内部共享声明(仅 server/ble_ota*.cpp 使用) + * + * 拆分: + * ble_ota.cpp — 单例/会话/收尾/公开 API + * ble_ota_data.cpp — 乱序缓冲、数据写路径、worker + * ble_ota_host.cpp — GATT/GAP/广播/Host + */ +#pragma once + +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "freertos/queue.h" +#include "esp_err.h" +#include "esp_ota_ops.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "esp_wifi.h" + +#include "nimble/nimble_port.h" +#include "nimble/nimble_port_freertos.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" +#include "nimble/ble.h" +#include "services/gap/ble_svc_gap.h" +#include "services/gatt/ble_svc_gatt.h" +#include "host/ble_att.h" + +#include "app_config.h" +#include "version.h" +#include "ble_ota.h" +#include "esp32_ota_core.h" +#include "ota_coordinator.h" +#include "ota_relay.h" +#include "storage/gd32_flash_store.h" +#include "health_monitor.h" +#include "monitor/debug_log.h" + +#define TAG "BLE_OTA" +#define BLE_OTA_NOTIFY_ACK 0xA1 +#define BLE_OTA_NOTIFY_FINISH 0xB1 +#define BLE_OTA_NOTIFY_SHA256 0xC1 +#define BLE_OTA_FINISH_DONE 0x02 +#define BLE_OTA_FINISH_REBOOT_NOW 0x07 +#define BLE_OTA_FINISH_GD32_RELAY_START 0x10 +#define BLE_OTA_FINISH_GD32_RELAY_DONE 0x11 +#define BLE_OTA_FINISH_ABORTED 0x20 +#define BLE_ATT_ERR_OTA_BUSY 0x80 +#define BLE_OTA_FINISH_ERR_FINALIZE 0x81 +#define BLE_OTA_FINISH_ERR_MUTEX 0x83 +#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 +#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85 +#define BLE_OTA_FINISH_ERR_GD32_RELAY 0x86 + +struct BleOtaDataBatch { + uint8_t *data; + size_t len; +}; + +struct BleGd32RelayWaitCtx { + uint16_t session_id; +}; + +struct BleEsp32FinishCtx { + char sha_hex[65]; + uint16_t session_id; +}; + +struct BleFinishCtxDeleter { + BleEsp32FinishCtx *p; + explicit BleFinishCtxDeleter(BleEsp32FinishCtx *ctx) : p(ctx) {} + ~BleFinishCtxDeleter() { if (p) { free(p); p = nullptr; } } + BleFinishCtxDeleter(const BleFinishCtxDeleter&) = delete; + BleFinishCtxDeleter& operator=(const BleFinishCtxDeleter&) = delete; +}; + +extern QueueHandle_t s_otaDataQueue; +extern uint16_t s_data_out_val_handle; +extern bool s_wifi_ps_boosted; +/** GAP 连接参数回退请求标志(定义在 ble_ota_data.cpp,host 连接事件读写) */ +extern bool s_conn_fallback_requested; + +void drainOtaDataQueue(); +void ble_ota_start_advertising(void); +void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes); +void sendFinishEvent(uint8_t stage, uint32_t value); +uint32_t packBleFinishValue(uint16_t session_id, uint16_t code); +void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]); +void sendSha256Notify(const uint8_t sha[32], uint16_t session_id); +void esp32_reorder_free(); +void ble_ota_session_reset(void); +void ble_ota_restore_wifi_ps(void); +void ble_ota_boost_wifi_ps(void); +void ble_ota_data_worker_task(void *pvParameters); +void ble_gd32_relay_wait_task(void *pvParameters); +int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len); +void ble_ota_on_sync(void); +void ble_ota_on_reset(int reason); +void ble_ota_nimble_host_task(void *param); +int ble_ota_gap_event(struct ble_gap_event *event, void *arg); +uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase); +int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compatible); +void ble_esp32_restart_task(void *pvParameters); +void ble_esp32_finish_and_reboot_task(void *pvParameters); +int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg); +int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg); +int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg); +int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg); + +extern const struct ble_gatt_svc_def gatt_ota_svcs[]; diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index 502cb0d..c633cee 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -630,7 +630,7 @@ esp_err_t logs_handler(httpd_req_t *req) // ============================================================ -// GET /can/feedback — 三路 CAN 1Hz 反馈快照 +// GET /can/feedback — 三路 CAN 反馈快照(GD32 推送,ESP32 缓存) // ============================================================ esp_err_t can_feedback_handler(httpd_req_t *req) { diff --git a/main/server/http_handlers.h b/main/server/http_handlers.h index 8bf1600..febb67d 100644 --- a/main/server/http_handlers.h +++ b/main/server/http_handlers.h @@ -22,7 +22,7 @@ esp_err_t spi_send_handler(httpd_req_t *req); esp_err_t system_status_handler(httpd_req_t *req); esp_err_t system_probe_handler(httpd_req_t *req); -// CAN 1Hz 反馈快照与接收模式 +// CAN 反馈快照(被动 0xC1)与接收模式 esp_err_t can_feedback_handler(httpd_req_t *req); esp_err_t can_mode_handler(httpd_req_t *req); diff --git a/main/transport/canfd_bridge.h b/main/transport/canfd_bridge.h index a92e48b..b45fafb 100644 --- a/main/transport/canfd_bridge.h +++ b/main/transport/canfd_bridge.h @@ -77,7 +77,7 @@ typedef struct { } id_snapshot_entry_t; // ============================================================ -// CAN 接收过滤模式 & 1Hz 反馈快照 +// CAN 接收过滤模式 & 反馈快照(GD32 约 1Hz 刷新;ESP32 被动收 0xC1) // 与 GD32 DCU_APP SPI 协议帧 0xC1/0xC2 配套 // ============================================================ typedef enum { @@ -87,7 +87,7 @@ typedef enum { } can_rx_mode_t; /** - * @brief 单通道 1Hz 反馈统计。 + * @brief 单通道 CAN 反馈统计(快照字段;非 ESP32 侧 1Hz 轮询)。 */ typedef struct { uint8_t channel; diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 456139d..03cfcb7 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -409,9 +409,13 @@ void SpiMaster::workerLoop() /** * @brief GPIO 边沿中断回调(DATA_READY / PB6)。 * - * 仅处理下降沿:GD32 有待读数据(按铃)。若本端 IDLE 则置 - * m_gd32RequestPending 并唤醒 SPI worker。上升沿不再使用(旧 waitSpiReady 已删)。 + * 仅处理下降沿:GD32 有待读数据(按铃)。上升沿不再使用(旧的"等上升沿 + * ready"逻辑已删除,现由 waitSpiDataReady 做电平轮询)。 * ISR 内仅做标志翻转 + 任务通知,不做 SPI 传输。 + * + * 本端忙时(acceptRing()==false):记下 pending 但不 notify,避免抢断 + * 当前 SPI 事务;事务结束 worker 下一轮会立即消费此标志,省去最多 25ms + * 的回扫延迟。PB6 在被读走前保持低、不会产生新下降沿,不记 pending 即丢唤醒。 */ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) { @@ -426,6 +430,10 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) return; } if (!self->m_linkState.acceptRing()) { + /* 本端忙:记下 pending,不 notify 抢断当前事务; + * 事务结束 worker 下一轮会消费此标志(PB6 保持低、无新下降沿, + * 不记则只能等 25ms 回扫兜底)。 */ + self->m_gd32RequestPending = true; return; } self->m_gd32RequestPending = true; diff --git a/tools/can.html b/tools/can.html index 4b56e0b..ffdcfd1 100644 --- a/tools/can.html +++ b/tools/can.html @@ -318,6 +318,6 @@ 操作成功 - + diff --git a/tools/css/can.css b/tools/css/can.css index 6a8db79..69a0612 100644 --- a/tools/css/can.css +++ b/tools/css/can.css @@ -142,10 +142,6 @@ letter-spacing: .12em; text-transform: uppercase; } -.spi-grid { - display: grid; grid-template-columns: 80px 1fr 80px 80px; gap: 10px; -} - /* === CAN WORKSPACE PAGE === */ /* 右侧监控抽屉 translate 出屏时,防止 body 被撑出横向滚动条 */ body.can-page { diff --git a/tools/index.html b/tools/index.html index 44da766..f59a5e1 100644 --- a/tools/index.html +++ b/tools/index.html @@ -12,7 +12,7 @@ - + @@ -211,9 +211,9 @@
1
认证握手
2
传输协商
3
数据转发
-
4
传输完成
-
5
OTA 刷写
-
6
校验验证
+
4
收尾验证
+
5
APP 启动
+
6
OTA完成
@@ -436,8 +436,8 @@ 操作成功
- - - + + + diff --git a/tools/js/app.js b/tools/js/app.js index d703983..bad41de 100644 --- a/tools/js/app.js +++ b/tools/js/app.js @@ -49,24 +49,6 @@ function formatPercent(value) { if (!Number.isFinite(value)) return '--'; return value.toFixed(1).replace(/\.0$/, '') + '%'; } -function formatHttpErrorDetail(text) { - var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); - if (detail.length > 180) detail = detail.slice(0, 177) + '...'; - return detail; -} -function isOtaBusyError(status, detail) { - var text = (detail || '').toLowerCase(); - return status === 403 || - status === 409 || - status === 423 || - text.indexOf('busy') >= 0 || - text.indexOf('occupied') >= 0 || - text.indexOf('another ota') >= 0 || - text.indexOf('invalid state') >= 0 || - text.indexOf('占用') >= 0 || - text.indexOf('互斥') >= 0 || - text.indexOf('系统忙') >= 0; -} function getEspIp() { var el = document.getElementById('espIp'); var ip = el && el.value ? el.value.trim() : ''; @@ -116,9 +98,11 @@ function applyDisconnectedRuntimeState() { function applyRebootingRuntimeState() { var pc = document.getElementById('pillConn'); var pct = document.getElementById('pillConnTxt'); - pc.className = 'pill processing'; - var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); - pct.textContent = '重启中 ' + elapsed + 's'; + if (pc) pc.className = 'pill processing'; + if (pct) { + var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); + pct.textContent = '重启中 ' + elapsed + 's'; + } var cs = document.getElementById('connStatus'); var cst = document.getElementById('connStatusTxt'); if (cs) cs.className = 'conn-indicator warn'; @@ -136,8 +120,8 @@ function applyUploadingRuntimeState() { } var pc = document.getElementById('pillConn'); var pct = document.getElementById('pillConnTxt'); - pc.className = 'pill processing'; - pct.textContent = '上传中'; + if (pc) pc.className = 'pill processing'; + if (pct) pct.textContent = '上传中'; var cs = document.getElementById('connStatus'); var cst = document.getElementById('connStatusTxt'); if (cs) cs.className = 'conn-indicator warn'; @@ -146,6 +130,21 @@ function applyUploadingRuntimeState() { if (bc) bc.textContent = '上传中'; if (bc) bc.className = 'v warn'; } +/** 设备忙(OTA/中继/重启期间不响应轮询)的连接胶囊/状态条运行时态。 + * 与上面三兄弟同模式,区别是不改进度/阶段,只标记连接胶囊为 processing + warn。 */ +function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) { + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + if (pc) pc.className = 'pill processing'; + if (pct) pct.textContent = pillText || '设备忙'; + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + if (cs) cs.className = 'conn-indicator warn'; + if (cst) cst.textContent = statusText || '设备忙(OTA/重启中),保持 1s 轮询'; + var bc = document.getElementById('bConn'); + if (bc) bc.textContent = badgeText || '设备忙'; + if (bc) bc.className = 'v warn'; +} // ==================== 挂载 ota/* 函数到 window(供其他模块通过 window.* 调用) ==================== window.OtaSessionState = OtaSessionState; @@ -223,8 +222,7 @@ window.resetStageFlow = resetStageFlow; window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState; window.applyRebootingRuntimeState = applyRebootingRuntimeState; window.applyUploadingRuntimeState = applyUploadingRuntimeState; -window.formatHttpErrorDetail = formatHttpErrorDetail; -window.isOtaBusyError = isOtaBusyError; +window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState; // ==================== 顶层副作用(原 app.js 末尾的初始化) ==================== setupDropZone('uploadZoneEsp32', 'esp32'); diff --git a/tools/js/ble/constants.js b/tools/js/ble/constants.js index b1c7c0c..6f3068b 100644 --- a/tools/js/ble/constants.js +++ b/tools/js/ble/constants.js @@ -10,11 +10,13 @@ export const UUIDS = { version: 0x0004, }; -export const UI_BUILD = '2026-07-18-ble-fix'; +export const UI_BUILD = '2026-07-18-ble-log-trim'; export const BLE_OTA_NOTIFY_ACK = 0xA1; export const BLE_OTA_NOTIFY_FINISH = 0xB1; export const BLE_OTA_NOTIFY_SHA256 = 0xC1; -export const BLE_OTA_ACK_WINDOW = 32; +// ★ 须与后端 main/include/app_config.h BLE_OTA_ACK_WINDOW 保持一致 +// 64:实测 Windows 瓶颈在每窗口 ACK 往返,窗口 32→64 提速 ~10%,再试 96 无收益。 +export const BLE_OTA_ACK_WINDOW = 64; export const BLE_OTA_FINISH_ESP32_DONE = 0x02; export const BLE_OTA_FINISH_REBOOT_NOW = 0x07; export const BLE_OTA_FINISH_GD32_RELAY_START = 0x10; diff --git a/tools/js/ble/file.js b/tools/js/ble/file.js index a18b93c..b10b320 100644 --- a/tools/js/ble/file.js +++ b/tools/js/ble/file.js @@ -1,17 +1,27 @@ // ==================== BLE OTA 文件准备与分包 ==================== -import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState } from './state.js'; +// +// ★ 上传中拦截策略(全文件统一):file.js 是 BLE 通道文件操作的唯一入口, +// transfer.js / state.js / session.js / 外部 bleOta.notifyFileChange 都汇聚到这里。 +// isBleUploading()(state.js)= state.uploading || state.autoFlashing; +// window.isUploading = WiFi 通道 OTA 会话是否处于 UPLOADING(ota/session.js 维护)。 +// 两者任一为真即视为上传中,禁止重置进度条/阶段/清空文件,避免覆盖进行中的 OTA。 +import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState, isBleUploading } from './state.js'; import { setBleStage, setBleStageText, resetBleStages } from './stages.js'; import { setProgress } from './transfer.js'; import { hideBleVerifyInfo } from './verify.js'; -import { formatBytes } from '../common/utils.js'; +import { formatBytes, bytesToHex } from '../common/utils.js'; import { showToast } from '../common/toast.js'; +/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */ +function isAnyChannelUploading() { + return isBleUploading() || (typeof window.isUploading !== 'undefined' && !!window.isUploading); +} + export function updateFileInfo(file) { // ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget // 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...) // 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。 - var isUploading = state.uploading || state.autoFlashing; - if (isUploading && file && state.selectedFile) { + if (isBleUploading() && file && state.selectedFile) { // 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段 bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D'); return Promise.resolve(); @@ -33,13 +43,12 @@ export function updateFileInfo(file) { else els.fileInfo.classList.remove('show'); } - // ★ HEX → BIN 自动转换:复用 app.js 公共函数 convertHexFileToBin - if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') { + // ★ HEX → BIN 自动转换:复用 app.js 公共函数 window.convertHexFileToBin + if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') { return convertHexFileToBin(file).then(function(result) { if (!result.converted) { bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W'); - finalizeFileInfo(file); - computeFileSha256(file); + finalizeAndHash(file); return; } state.selectedFile = result.file; @@ -52,8 +61,7 @@ export function updateFileInfo(file) { els.fileSize.textContent = formatBytes(result.file.size); var newPackets = Math.ceil(result.file.size / payloadSize); els.packetCount.textContent = String(newPackets); - finalizeFileInfo(result.file); - computeFileSha256(result.file); + finalizeAndHash(result.file); }).catch(function(err) { bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E'); showToast('HEX 文件解析失败: ' + err.message, 'error'); @@ -65,16 +73,20 @@ export function updateFileInfo(file) { } // 非 HEX 文件直接完成 + finalizeAndHash(file); + return Promise.resolve(); +} + +/** finalizeFileInfo + computeFileSha256 的统一出口:HEX 转换完成分支与非 HEX 分支共用。 */ +function finalizeAndHash(file) { finalizeFileInfo(file); computeFileSha256(file); - return Promise.resolve(); } function finalizeFileInfo(file) { // ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间 // 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。 - var isUploading = state.uploading || state.autoFlashing; - if (isUploading) { + if (isBleUploading()) { bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D'); return; } @@ -82,10 +94,6 @@ function finalizeFileInfo(file) { if (file) { setProgress(0, `已选择 ${file.name}`); bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`); - if (state.server) { - setBleStage(3, '计算哈希...', 'active'); - setBleStageText('正在计算文件 SHA-256...'); - } } else { setProgress(0, '等待选择文件'); if (state.server) { @@ -96,42 +104,51 @@ function finalizeFileInfo(file) { } } +/** + * 计算文件 SHA-256 并推进 BLE stage 流。 + * stage 推进逻辑收敛在此函数内,避免与 finalizeFileInfo 隐式耦合: + * - 已连接且 file 非空:进入「计算中」态(stage 3 active + 状态栏文案) + * - 哈希算完:stage 3 done + 推进 stage 4 + 状态栏「文件就绪」 + * - file 为空 / 未连接:不碰 stage(由 finalizeFileInfo 的 else 分支 + clearSelectedFile 负责) + */ function computeFileSha256(file) { var shaElem = document.getElementById('fileSha256'); - if (file && shaElem) { - shaElem.textContent = '计算中...'; - file.arrayBuffer().then(function(buf) { - return crypto.subtle.digest('SHA-256', buf); - }).then(function(hashBuffer) { - var hashArray = Array.from(new Uint8Array(hashBuffer)); - var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); - shaElem.textContent = hashHex.toUpperCase(); - state.fileSha256 = hashHex.toUpperCase(); - bleLog('FILE', 'SHA-256: ' + hashHex.toUpperCase()); - // If connected, advance to stage 3 done - if (state.server) { - setBleStage(3, 'SHA-256 已计算', null); - setBleStage(4, '', null); - setBleStageText('文件就绪,可以开始刷写'); - } - }).catch(function(err) { - shaElem.textContent = '计算失败: ' + err.message; - state.fileSha256 = null; - }); - } else if (shaElem) { - shaElem.textContent = '-'; + if (!file || !shaElem) { + if (shaElem) shaElem.textContent = '-'; state.fileSha256 = null; + return; } + // 已连接:进入「计算中」态(旧逻辑放在 finalizeFileInfo 里,现收敛至此) + if (state.server) { + setBleStage(3, '计算哈希...', 'active'); + setBleStageText('正在计算文件 SHA-256...'); + } + shaElem.textContent = '计算中...'; + file.arrayBuffer().then(function(buf) { + return crypto.subtle.digest('SHA-256', buf); + }).then(function(hashBuffer) { + var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase(); + shaElem.textContent = hashHex; + state.fileSha256 = hashHex; + bleLog('FILE', 'SHA-256: ' + hashHex); + if (state.server) { + setBleStage(3, 'SHA-256 已计算', null); + setBleStage(4, '', null); + setBleStageText('文件就绪,可以开始刷写'); + } + }).catch(function(err) { + shaElem.textContent = '计算失败: ' + err.message; + state.fileSha256 = null; + }); } export 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; + // ★ 必须同时拦截 WiFi 通道上传:focus 事件不分通道,WiFi 刷写时点击网页/切标签 + // 回来会触发本函数 → finalizeFileInfo → setProgress(0),把进度"闪一下"归零, + // 直到下一次 XHR progress 事件才恢复。 + if (isAnyChannelUploading()) return; // FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空 if (state.selectedFileHandle) return; const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null; @@ -174,7 +191,7 @@ export async function pickFileViaFSAPI() { export function clearSelectedFile() { // ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用, // 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。 - if (state.uploading || state.autoFlashing) { + if (isBleUploading()) { bleLog('FILE', '上传进行中,拒绝清除文件', 'W'); return; } @@ -184,32 +201,23 @@ export function clearSelectedFile() { state.fileSha256 = null; state.deviceSha256 = null; if (els.firmwareFile) els.firmwareFile.value = ''; - updateFileInfo(null); + updateFileInfo(null); // 内部 finalizeFileInfo(null) 已重置进度条并恢复「连接就绪」stage hideBleVerifyInfo(); - // Reset stages back to connection-ready state - if (state.server) { - resetBleStages(); - setBleStage(3, '', null); - setBleStageText('BLE 连接就绪,请选择固件文件'); - } bleLog('FILE', '文件已清除'); } // 别名导出,与 window.bleOta.clearFile 对齐 export { clearSelectedFile as clearFile }; +/** + * 外部文件变更通知(index.html 拖拽 / console-mode 切换等)。 + * ★ 守卫集中在此:file.js 是 BLE 文件操作唯一入口,外部包装层(ble_ota.js)不再重复守卫。 + */ export async function notifyFileChange(file, handle) { - // ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget - // 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。 - if (state.uploading || state.autoFlashing) { + if (isAnyChannelUploading()) { 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/js/ble/gatt.js b/tools/js/ble/gatt.js index 8dd4fcb..9691322 100644 --- a/tools/js/ble/gatt.js +++ b/tools/js/ble/gatt.js @@ -29,7 +29,7 @@ import { currentTarget, sleep, } from './state.js'; -import { setBleStage, setBleStageError, setBleStageText, resetBleStages } from './stages.js'; +import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js'; import { setProgress, updateProgressFromAck } from './transfer.js'; import { showBleVerifyInfo } from './verify.js'; import { @@ -44,6 +44,23 @@ import { import { formatBytes } from '../common/utils.js'; import { showToast } from '../common/toast.js'; +/** + * @description 中止当前 ACK 等待 Promise,并复位重连状态。 + * 用于所有"错误类 FINISH"和"用户中止"路径,避免 ackWaiter 悬挂。 + */ +function abortAckWaiter(reason) { + if (state.ackWaiter) { + const waiter = state.ackWaiter; + state.ackWaiter = null; + clearTimeout(waiter.timer); + waiter.reject(new Error(reason)); + } + state.abortRequested = true; + state.expectedReboot = false; + state.reconnecting = false; + state.reconnectAttempts = 0; +} + /** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */ export async function connectBle() { if (!navigator.bluetooth) { @@ -199,9 +216,10 @@ export function handleNotify(event) { updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes); } - /* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程 */ + /* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程。 + * ★ 降为 D 级:成功路径默认不显示,需要时切过滤级查看。 */ if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) { - bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`); + bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`, 'D'); } // ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK @@ -269,10 +287,7 @@ export function handleNotify(event) { if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota'); setProgress(100, '[GD32] OTA 转发完成,APP 已启动'); showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true }); - var gd32Bar = document.getElementById('stageStatusBar'); - var gd32Text = document.getElementById('stageStatusText'); - if (gd32Bar) gd32Bar.className = 'stage-status-bar ok'; - if (gd32Text) gd32Text.textContent = 'GD32 刷写完成'; + setBleStageStatus('ok', 'GD32 刷写完成'); state.lastUploadStatus = 'success'; showToast('GD32 OTA 刷写完成', 'success'); resolveOtaResult('success', { stage: stage, value: value }); @@ -283,21 +298,11 @@ export function handleNotify(event) { // 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发, // 也可能是设备端因错误自动中止。两种情况都走同一清理路径。 if (stage === BLE_OTA_FINISH_ABORTED) { - state.abortRequested = true; - state.expectedReboot = false; - state.reconnecting = false; - state.reconnectAttempts = 0; state.lastUploadStatus = 'aborted'; setProgress(0, 'OTA 已中止'); setBleStageError('OTA 已中止'); showToast('OTA 已中止(BLE 保持连接)', 'warning'); - // 解除任何等待 ACK / OTA 结果的 Promise - if (state.ackWaiter) { - const waiter = state.ackWaiter; - state.ackWaiter = null; - clearTimeout(waiter.timer); - waiter.reject(new Error('OTA 已中止')); - } + abortAckWaiter('OTA 已中止'); rejectOtaResult('OTA 已中止', { stage: stage, value: value }); return; } @@ -328,10 +333,7 @@ export function handleNotify(event) { state.expectedReboot = true; setBleStage(finalizeStage, '设备重启', null); // ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝 - var rebootBar = document.getElementById('stageStatusBar'); - var rebootBarTxt = document.getElementById('stageStatusText'); - if (rebootBar) rebootBar.className = 'stage-status-bar ok'; - if (rebootBarTxt) rebootBarTxt.textContent = 'ESP32 刷写完成,等待重启'; + setBleStageStatus('ok', 'ESP32 刷写完成,等待重启'); if (typeof markPhaseDone === 'function') { markPhaseDone('esp32Ota'); } @@ -393,16 +395,7 @@ export function handleNotify(event) { } // 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环 if (stage >= 0x80) { - state.abortRequested = true; // ★ 阻止 fire-and-forget 继续发包 - state.expectedReboot = false; // ★ 刷写失败,取消任何重连退避 - state.reconnecting = false; - state.reconnectAttempts = 0; - if (state.ackWaiter) { - const waiter = state.ackWaiter; - state.ackWaiter = null; - clearTimeout(waiter.timer); - waiter.reject(new Error(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`)); - } + abortAckWaiter(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`); } return; } @@ -421,7 +414,7 @@ export function handleNotify(event) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase(); state.deviceSha256 = devSha; - bleLog('VERIFY', '设备端 SHA-256: ' + devSha); + bleLog('VERIFY', '设备端 SHA-256: ' + devSha, 'D'); // 设备端上报 SHA-256 后,前端仅做展示与状态同步。 var frontSha = state.fileSha256; @@ -431,10 +424,7 @@ export function handleNotify(event) { showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true }); setBleStage(verifyStage, 'SHA-256 ✓', null); // ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进 - var okBar = document.getElementById('stageStatusBar'); - var okTxt = document.getElementById('stageStatusText'); - if (okBar) okBar.className = 'stage-status-bar ok'; - if (okTxt) okTxt.textContent = '刷写成功 — SHA-256 校验通过'; + setBleStageStatus('ok', '刷写成功 — SHA-256 校验通过'); if (typeof markPhaseDone === 'function') { markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota'); } @@ -471,9 +461,14 @@ export async function tryReconnect() { state.reconnecting = true; state.reconnectAttempts += 1; var attempt = state.reconnectAttempts; - // 退避:1s, 2s, 4s, 8s, 上限 10s + // 退避:约 1s / 1.6s / 2.6s / 4.1s / 6.6s / 10.5s…,上限 10s var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1)); if (backoff > 200) await sleep(backoff); + // ★ 退避期间用户可能已手动断开(disconnectBle 会清 expectedReboot),需二次校验避免幽灵重连 + if (!state.expectedReboot || !state.device) { + state.reconnecting = false; + return; + } if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { if (els.connState) els.connState.textContent = '重连中(' + attempt + ')'; if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing'; diff --git a/tools/js/ble/stages.js b/tools/js/ble/stages.js index dbbe8cf..5b3e6f7 100644 --- a/tools/js/ble/stages.js +++ b/tools/js/ble/stages.js @@ -4,11 +4,37 @@ export var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6']; export var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6']; export var bleStageLabelIds = ['bsl5', 'bsl6']; +/* 状态栏 DOM 元素懒缓存(BLE OTA 高频调用路径用,避免每次 getElementById)。 + * DOM 未就绪或节点被外部重建时回退到 getElementById(与 ota/progress.js 的 setProgress 同款模式)。 */ +var stageStatusBarEl = null; +var stageStatusTextEl = null; +/* 当前激活阶段序号(1-based,0=无激活)。由 setBleStage 维护,供 setBleStageError + * 直接定位错误节点,避免依赖 className 字符串匹配(脆弱且易被外部改动打破)。 */ +var currentBleStageIndex = 0; + +function getStageStatusBar() { + if (!stageStatusBarEl || !stageStatusBarEl.isConnected) { + stageStatusBarEl = document.getElementById('stageStatusBar'); + } + return stageStatusBarEl; +} + +function getStageStatusText() { + if (!stageStatusTextEl || !stageStatusTextEl.isConnected) { + stageStatusTextEl = document.getElementById('stageStatusText'); + } + return stageStatusTextEl; +} + export function updateBleStageLabels(cfg) { + /* JS 兜底默认值与 constants.js TARGETS.esp32/gd32 的 stage5Label/stage6Label 对齐: + * esp32: stage5Label='校验比对', stage6Label='完成触发' + * gd32: stage5Label='完成触发', stage6Label='校验比对' + * 实际 cfg 由 currentTarget() 返回,始终带 label,此分支仅为防御性兜底。 */ var stage5 = document.getElementById(bleStageLabelIds[0]); var stage6 = document.getElementById(bleStageLabelIds[1]); - if (stage5) stage5.textContent = cfg.stage5Label || '完成触发'; - if (stage6) stage6.textContent = cfg.stage6Label || '校验比对'; + if (stage5) stage5.textContent = cfg.stage5Label || '校验比对'; + if (stage6) stage6.textContent = cfg.stage6Label || '完成触发'; } export function resetBleStages() { @@ -20,15 +46,34 @@ export function resetBleStages() { var el = document.getElementById(id); if (el) el.textContent = ''; }); - var bar = document.getElementById('stageStatusBar'); - if (bar) { bar.className = 'stage-status-bar'; bar.innerHTML = '待机'; } + currentBleStageIndex = 0; + /* ★ 只改 className + textContent,不重写 innerHTML: + * 1) 保留 #stageStatusDot(CSS dotPulse 动画依赖该节点,重写后 id 丢失); + * 2) 保留 #stageStatusText 节点引用,避免外部缓存引用失效; + * 3) 与 ota/progress.js、ota/stages.js、app.js 的状态栏操作约定一致。 */ + var bar = getStageStatusBar(); + if (bar) bar.className = 'stage-status-bar'; + var txt = getStageStatusText(); + if (txt) txt.textContent = '待机'; } export function setBleStage(stageNum, detail, barState) { // stageNum: 1-6, detail: sub-step text - // 终态 stageNum===6:全部 done(绿);中间步当前为 live(蓝) - var finished = stageNum >= 6; - for (var i = 0; i < bleStageIds.length; i++) { + // 终态 stageNum===末步:全部 done(绿);中间步当前为 live(蓝) + var stageCount = bleStageIds.length; + // 边界守卫:stageNum<1 直接清空所有节点(不推进 currentBleStageIndex),避免误判 active + if (!Number.isFinite(stageNum) || stageNum < 1) { + bleStageIds.forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + currentBleStageIndex = 0; + return; + } + if (stageNum > stageCount) stageNum = stageCount; + currentBleStageIndex = stageNum; + var finished = stageNum >= stageCount; + for (var i = 0; i < stageCount; i++) { var el = document.getElementById(bleStageIds[i]); if (!el) continue; if (i + 1 < stageNum) el.className = 'stage-node done'; @@ -36,34 +81,43 @@ export function setBleStage(stageNum, detail, barState) { else el.className = 'stage-node'; } // Set detail text - if (stageNum >= 1 && stageNum <= 6) { + if (stageNum >= 1 && stageNum <= stageCount) { var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]); if (detailEl && detail !== undefined) detailEl.textContent = detail; } // Update status bar - var bar = document.getElementById('stageStatusBar'); + var bar = getStageStatusBar(); if (bar) { - var cls = barState || (finished ? 'ok' : (stageNum > 0 ? 'active' : '')); + var cls = barState || (finished ? 'ok' : 'active'); bar.className = 'stage-status-bar' + (cls ? ' ' + cls : ''); } } export function setBleStageText(text) { - var txt = document.getElementById('stageStatusText'); + var txt = getStageStatusText(); if (txt) txt.textContent = text; } -export function setBleStageError(detail) { - // Mark current active stage as error - for (var i = 0; i < bleStageIds.length; i++) { - var el = document.getElementById(bleStageIds[i]); - if (el && el.className === 'stage-node live') { - el.className = 'stage-node err'; - var detailEl = document.getElementById(bleStageDetailIds[i]); - if (detailEl && detail) detailEl.textContent = detail; - } - } - var bar = document.getElementById('stageStatusBar'); - if (bar) bar.className = 'stage-status-bar err'; - setBleStageText('错误: ' + (detail || '')); +/** @description 一次性更新状态栏的样式与文本(避免每次通知都重复 getElementById)。 */ +export function setBleStageStatus(state, text) { + var bar = getStageStatusBar(); + if (bar) bar.className = 'stage-status-bar' + (state ? ' ' + state : ''); + var txt = getStageStatusText(); + if (txt && text !== undefined) txt.textContent = text; } + +export function setBleStageError(detail) { + // 直接用 currentBleStageIndex 定位错误节点(由 setBleStage 维护), + // 不再依赖 className === 'stage-node live' 字符串匹配——后者在 className + // 顺序变化或被外部改动时会漏标错误阶段。 + var stageCount = bleStageIds.length; + if (currentBleStageIndex >= 1 && currentBleStageIndex <= stageCount) { + var el = document.getElementById(bleStageIds[currentBleStageIndex - 1]); + if (el) el.className = 'stage-node err'; + var detailEl = document.getElementById(bleStageDetailIds[currentBleStageIndex - 1]); + if (detailEl && detail) detailEl.textContent = detail; + } + var bar = getStageStatusBar(); + if (bar) bar.className = 'stage-status-bar err'; + setBleStageText('错误: ' + (detail || '未知错误')); +} \ No newline at end of file diff --git a/tools/js/ble/state.js b/tools/js/ble/state.js index 246a737..490d535 100644 --- a/tools/js/ble/state.js +++ b/tools/js/ble/state.js @@ -71,6 +71,11 @@ export function currentTarget() { return TARGETS[window.flashTarget] || TARGETS.esp32; } +/** BLE 通道是否正在上传/自动刷写。集中判定,避免各处重复 state.uploading || state.autoFlashing。 */ +export function isBleUploading() { + return !!(state.uploading || state.autoFlashing); +} + export function currentPayloadSize() { const cfg = currentTarget(); return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480)); @@ -110,26 +115,23 @@ export function setReadyHint(html) { export function setConnectionState(connected) { const idle = connected && state.selectedFile && !state.uploading; - // 仅在 BLE 模式下更新共享连接指示器,避免覆盖 WiFi 轮询的状态 + // 仅在 BLE 模式下更新共享连接指示器与 BLE 专用徽章,避免覆盖 WiFi 轮询的状态 if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接'; - } - // BLE 专用连接徽章 — 仅在 BLE 模式下更新,避免覆盖 WiFi 状态 - if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { - if (els.connState) els.connState.textContent = connected ? '已连接' : '未连接'; - if (els.connState && els.connState.parentElement) { - els.connState.parentElement.className = connected ? 'pill online' : 'pill offline'; + if (els.connState) { + els.connState.textContent = connected ? '已连接' : '未连接'; + if (els.connState.parentElement) { + els.connState.parentElement.className = connected ? 'pill online' : 'pill offline'; + } } - els.connBadge.textContent = connected ? 'online' : 'offline'; - els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`; - els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪'; - } - if (connected) { - els.connectBtn.disabled = true; - } else { - els.connectBtn.disabled = false; + if (els.connBadge) { + els.connBadge.textContent = connected ? 'online' : 'offline'; + els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`; + } + if (els.gattState) els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪'; } + els.connectBtn.disabled = connected; els.disconnectBtn.disabled = !connected; els.autoFlashBtn.disabled = !idle; els.abortBtn.disabled = !state.uploading; diff --git a/tools/js/ble/transfer.js b/tools/js/ble/transfer.js index 65c0288..7b9f1b0 100644 --- a/tools/js/ble/transfer.js +++ b/tools/js/ble/transfer.js @@ -55,6 +55,16 @@ function buildPacket(seq, totalSize, payload, cmd) { return packet; } +/** @description 将 64 字符十六进制 SHA-256 字符串转为 32 字节数组。 */ +function sha256HexToBytes(shaHex) { + const hex = shaHex.toUpperCase(); + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) { // ★ 中止快速退出:若 0x20 ABORTED 已先于本调用到达(ackWaiter 当时为 null), // 这里立即 reject,避免在已中止的会话上盲等超时。 @@ -101,7 +111,7 @@ export async function uploadFirmware() { const freshFile = await state.selectedFileHandle.getFile(); await updateFileInfo(freshFile); var actualFile = state.selectedFile || freshFile; - bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); + bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D'); } catch (e) { bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); } @@ -151,13 +161,13 @@ export async function uploadFirmware() { throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式'); } - bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`); + bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`, 'D'); setProgress(0, `[${cfg.label}] 正在读取固件文件...`); setBleStage(4, '读取固件...', null); const fileBytes = new Uint8Array(await file.arrayBuffer()); const totalSize = fileBytes.length; const packetTotal = Math.ceil(totalSize / payloadSize); - bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`); + bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`, 'D'); if (totalSize !== file.size) { bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W'); } @@ -169,7 +179,7 @@ export async function uploadFirmware() { fileSha256 = state.fileSha256.toLowerCase(); } else { // 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置) - bleLog('OTA', '正在计算文件 SHA-256 哈希...'); + bleLog('OTA', '正在计算文件 SHA-256 哈希...', 'D'); const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); @@ -186,7 +196,7 @@ export async function uploadFirmware() { state.activePacketTotal = packetTotal; els.sentCount.textContent = '0'; setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`); - bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`); + bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`, 'D'); const MAX_WINDOW_RETRIES = 3; // 整分区预擦可长达数十秒;首包 ACK 超时必须覆盖 begin/erase @@ -214,7 +224,7 @@ export async function uploadFirmware() { if (seq === 0) { try { await state.dataInChar.writeValueWithResponse(packet); - bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'I'); + bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'D'); setBleStageText('设备预擦 Flash 中,请等待…'); } catch (e) { bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E'); @@ -233,7 +243,7 @@ export async function uploadFirmware() { if (state.lastAckBytes < firstBytes) { throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes); } - bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'I'); + bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'D'); setBleStageText('正在写入 ' + packetTotal + ' 包数据...'); continue; } @@ -315,7 +325,6 @@ export async function uploadFirmware() { } catch (e) { bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W'); } - expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize); } if (state.lastAckBytes < expectedBytes) { @@ -340,10 +349,7 @@ export async function uploadFirmware() { if (cfg.label === 'ESP32') { state.deviceSha256 = null; const frontSha = fileSha256.toUpperCase(); - const frontShaBytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16); - } + const frontShaBytes = sha256HexToBytes(fileSha256); const trigger = new Uint8Array(35); trigger[0] = 0xF8; trigger.set(frontShaBytes, 1); @@ -355,7 +361,7 @@ export async function uploadFirmware() { setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...'); try { await state.ctrlChar.writeValue(trigger); - bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验'); + bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验', 'D'); } catch (e) { clearOtaResultWaiter(); state.lastUploadStatus = 'failed'; @@ -381,10 +387,7 @@ export async function uploadFirmware() { // GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result. const frontSha = fileSha256.toUpperCase(); - const frontShaBytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16); - } + const frontShaBytes = sha256HexToBytes(fileSha256); state.deviceSha256 = null; setBleStage(5, '发送触发...', 'active'); setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...'); @@ -402,7 +405,7 @@ export async function uploadFirmware() { await state.ctrlChar.writeValue(cmd); setBleStage(5, '已触发', null); setBleStage(6, '等待设备端校验', 'active'); - bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`); + bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`, 'D'); } catch (_) { clearOtaResultWaiter(); state.lastUploadStatus = 'failed'; @@ -478,7 +481,7 @@ export async function autoFlash() { if (changed) { await updateFileInfo(freshFile); var actualFile = state.selectedFile || freshFile; - bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); + bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D'); } } catch (e) { bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); diff --git a/tools/js/ble/verify.js b/tools/js/ble/verify.js index 79eda9b..6b364d6 100644 --- a/tools/js/ble/verify.js +++ b/tools/js/ble/verify.js @@ -4,18 +4,18 @@ export function showBleVerifyInfo(frontSha, devSha, result) { var panel = document.getElementById('stageVerifyInfo'); if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; } if (frontSha !== undefined) { - var el = document.getElementById('sviFrontSha'); - if (el) el.textContent = frontSha || '-'; + var frontEl = document.getElementById('sviFrontSha'); + if (frontEl) frontEl.textContent = frontSha || '-'; } if (devSha !== undefined) { - var el = document.getElementById('sviDeviceSha'); - if (el) el.textContent = devSha || '-'; + var devEl = document.getElementById('sviDeviceSha'); + if (devEl) devEl.textContent = devSha || '-'; } - if (result !== undefined) { - var el = document.getElementById('sviResult'); - if (el) { - el.textContent = result.text || '-'; - el.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : ''); + if (result) { + var resultEl = document.getElementById('sviResult'); + if (resultEl) { + resultEl.textContent = result.text || '-'; + resultEl.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : ''); } } } @@ -23,4 +23,4 @@ export function showBleVerifyInfo(frontSha, devSha, result) { export function hideBleVerifyInfo() { var panel = document.getElementById('stageVerifyInfo'); if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; } -} +} \ No newline at end of file diff --git a/tools/js/ble_ota.js b/tools/js/ble_ota.js index 5254978..6d93451 100644 --- a/tools/js/ble_ota.js +++ b/tools/js/ble_ota.js @@ -26,23 +26,7 @@ window.bleOta = { }, disconnect: disconnectBle, showToast: showToast, - notifyFileChange: async function(file, handle) { - // ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget - // 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。 - if (state.uploading || state.autoFlashing) { - bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D'); - return; - } - // ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度 - if (typeof window.isUploading !== 'undefined' && window.isUploading) { - bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D'); - return; - } - if (file) { - if (handle) state.selectedFileHandle = handle; - await notifyFileChange(file, handle); - } - }, + notifyFileChange: notifyFileChange, clearFile: clearSelectedFile, refreshButtons: function() { setConnectionState(!!state.server); diff --git a/tools/js/can.js b/tools/js/can.js index 249fbf9..954dadf 100644 --- a/tools/js/can.js +++ b/tools/js/can.js @@ -108,7 +108,11 @@ function setConnectionState(connected, text, buildVersion, buildTime, wifi, upti async function testConnection() { var baseUrl = getBaseUrl(); try { - var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) }); + /* 状态端点禁用 HTTP 缓存,避免命中旧响应 */ + var resp = await fetch(baseUrl + '/system/status', { + signal: AbortSignal.timeout(3000), + cache: 'no-store' + }); if (!resp.ok) { setConnectionState(false, 'HTTP ' + resp.status, '', '', {}, null); return; @@ -209,7 +213,11 @@ async function handleDbcFiles(fileList) { } } - afterDbcSourcesChanged({ resetSelection: true }); + /* 仅在确有新增/覆盖时才 reset 并刷新 UI; + * 全部解析失败时保留用户当前选中/勾选/信号值,只弹错误提示。 */ + if (added || replaced) { + afterDbcSourcesChanged({ resetSelection: true }); + } if (added || replaced) { var msg = 'DBC:'; @@ -317,10 +325,10 @@ function bindEvents() { scheduleCanPersist(); }); } - el.sendSelectedBtn.addEventListener('click', sendSelectedMessage); - el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual); - el.sendManualBtn.addEventListener('click', sendManualFrame); - el.resetManualBtn.addEventListener('click', function () { + if (el.sendSelectedBtn) el.sendSelectedBtn.addEventListener('click', sendSelectedMessage); + if (el.loadPreviewToRawBtn) el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual); + if (el.sendManualBtn) el.sendManualBtn.addEventListener('click', sendManualFrame); + if (el.resetManualBtn) el.resetManualBtn.addEventListener('click', function () { resetManualFields(); scheduleCanPersist(); }); @@ -328,9 +336,10 @@ function bindEvents() { refreshManualHint(); scheduleCanPersist(); } - el.manualData.addEventListener('input', onManualFieldChange); - el.manualDlc.addEventListener('input', onManualFieldChange); - el.manualId.addEventListener('input', onManualFieldChange); + /* 手动帧三字段统一守卫,避免任一元素缺失时后续绑定被中断 */ + ['manualData', 'manualDlc', 'manualId'].forEach(function (id) { + if (el[id]) el[id].addEventListener('input', onManualFieldChange); + }); if (el.manualChannel) el.manualChannel.addEventListener('change', scheduleCanPersist); if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages); if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () { diff --git a/tools/js/can/dbc.js b/tools/js/can/dbc.js index 686eb80..08c3782 100644 --- a/tools/js/can/dbc.js +++ b/tools/js/can/dbc.js @@ -88,7 +88,8 @@ export function bytesToHex(bytes) { /** @description 转义 CSS 选择器中的特殊字符。 */ export function cssEscape(value) { if (window.CSS && CSS.escape) return CSS.escape(value); - return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\\\' + ch; }); + /* fallback:每个非安全字符前加单个反斜杠(CSS 转义),双引号属性值内可安全使用 */ + return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\' + ch; }); } /** @description 去除回车并 trim,用于 DBC 搜索等输入归一化。 */ @@ -232,7 +233,7 @@ export function parseDbc(text) { signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || ''; signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {}; var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name]; - signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? dv : null; + signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? Number(dv) : null; }); }); @@ -251,6 +252,16 @@ export function findSignal(message, signalName) { return null; } +/** + * @description 按 key 在当前合并 DBC 中查找报文(O(1),走 messageMap)。 + * messageMap 由 rebuildMergedDbc() 维护,key 格式与 message.key / state.selectedMessageKey 一致。 + * 无 DBC 或 key 不存在时返回 null。 + */ +export function findMessageByKey(key) { + if (!state.dbc || !state.dbc.messageMap || !key) return null; + return state.dbc.messageMap[key] || null; +} + /** @description 解析 SG_ 行的 multiplex token(M / mN / 空)。 */ export function parseMultiplexToken(token) { if (!token) return { role: null, value: null }; diff --git a/tools/js/can/messages.js b/tools/js/can/messages.js index d01fd4c..7421489 100644 --- a/tools/js/can/messages.js +++ b/tools/js/can/messages.js @@ -7,14 +7,13 @@ * common/utils.js(escapeHtml/formatBytes)、 * signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。 */ -import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717x'; -import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717x'; +import { state, el, padHex, normalizeWhitespace, cssEscape, findMessageByKey } from './dbc.js?v=20260717x'; +import { escapeHtml, formatBytes } from '../common/utils.js?v=20260717x'; import { renderInlineSignalGrid, ensureMessageDefaults, updateEncodedPreview } from './signals.js?v=20260717x'; import { scheduleCanPersist } from './persist.js?v=20260717x'; // ==================== DBC 文件信息 ==================== -/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */ -/** @description 刷新多 DBC 摘要条与文件 chip 列表。 */ +/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小),并刷新多 DBC 摘要条与文件 chip 列表。 */ export function updateDbcFileInfo() { var sources = state.dbcSources || []; var has = sources.length > 0; @@ -54,9 +53,9 @@ export function updateDbcFileInfo() { el.dbcFileChips.innerHTML = sources.map(function (s) { var nMsg = (s.dbc && s.dbc.messages) ? s.dbc.messages.length : 0; return '' + - '' + - '' + sanitizeHtml(s.name) + '' + - '' + + '' + + '' + escapeHtml(s.name) + '' + + '' + ''; }).join(''); } @@ -93,7 +92,7 @@ export function filterMessages() { // ==================== 发送节点过滤 ==================== /** @description 收集 DBC 中所有发送节点(去重 + 排序)。 */ -export function collectTransmitters() { +function collectTransmitters() { var set = Object.create(null); var list = []; if (!state.dbc) return list; @@ -106,10 +105,10 @@ export function collectTransmitters() { } /** @description 渲染发送节点过滤面板的复选框列表。 */ -export function renderNodeFilterList() { +function renderNodeFilterList() { if (!el.nodeFilterList) return; var txs = collectTransmitters(); - var kw = (el.nodeFilterSearch && el.nodeFilterSearch.value || '').trim().toLowerCase(); + var kw = (el.nodeFilterSearch ? (el.nodeFilterSearch.value || '') : '').trim().toLowerCase(); if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; }); if (!txs.length) { el.nodeFilterList.innerHTML = '
无匹配节点
'; @@ -125,8 +124,8 @@ export function renderNodeFilterList() { el.nodeFilterList.innerHTML = txs.map(function (name) { var checked = !!state.transmitterFilter[name] ? ' checked' : ''; return ''; }).join(''); @@ -167,7 +166,7 @@ export function updateNodeFilterLabel() { } /** @description 打开/关闭发送节点过滤面板。 */ -export function setNodeFilterPanelOpen(open) { +function setNodeFilterPanelOpen(open) { if (!el.nodeFilterPanel || !el.nodeFilterBtn) return; if (open) { renderNodeFilterList(); @@ -245,23 +244,23 @@ export function renderMessageList() { var comment = msg.comment || ''; if (comment.length > 36) comment = comment.slice(0, 36) + '...'; var rowHtml = '' + - '
' + - '' + - '' + + '
' + + '' + + '' + '' + (index + 1) + '' + - '' + - sanitizeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '' + - '' + - sanitizeHtml(msg.name) + '' + - '' + sanitizeHtml(idHex) + '' + - 'CH' + sanitizeHtml(channelTxt) + '' + - '' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '' + - '' + sanitizeHtml(msg.dlc) + '' + - '' + sanitizeHtml(msg.signals.length) + '' + - '' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '' + - '' + sanitizeHtml(comment || '-') + '' + + '' + + escapeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '' + + '' + + escapeHtml(msg.name) + '' + + '' + escapeHtml(idHex) + '' + + 'CH' + escapeHtml(channelTxt) + '' + + '' + escapeHtml(msg.isExtended ? '扩展' : '标准') + '' + + '' + escapeHtml(msg.dlc) + '' + + '' + escapeHtml(msg.signals.length) + '' + + '' + escapeHtml(msg.transmitter || 'Vector__XXX') + '' + + '' + escapeHtml(comment || '-') + '' + '
'; - var expandHtml = '
' + + var expandHtml = '
' + '
' + '
'; return rowHtml + expandHtml; @@ -331,10 +330,7 @@ function handleMessageRowClick(event) { function toggleMessageExpand(key) { if (!state.dbc) return; - var msg = null; - for (var i = 0; i < state.dbc.messages.length; i++) { - if (state.dbc.messages[i].key === key) { msg = state.dbc.messages[i]; break; } - } + var msg = findMessageByKey(key); if (!msg) return; var willExpand = !state.expandedKeys[key]; if (willExpand) { @@ -384,13 +380,7 @@ export function updateBatchUi() { /** @description 选中指定 key 的报文,并刷新列表/信号编辑/编码预览。 */ export function selectMessage(key) { if (!state.dbc) return; - var msg = null; - for (var i = 0; i < state.dbc.messages.length; i++) { - if (state.dbc.messages[i].key === key) { - msg = state.dbc.messages[i]; - break; - } - } + var msg = findMessageByKey(key); if (!msg) return; state.selectedMessageKey = key; ensureMessageDefaults(msg); diff --git a/tools/js/can/persist.js b/tools/js/can/persist.js index 02209f7..149868d 100644 --- a/tools/js/can/persist.js +++ b/tools/js/can/persist.js @@ -25,9 +25,7 @@ function safeJsonParse(raw) { function transmitterFilterToPlain() { var out = {}; for (var k in state.transmitterFilter) { - if (Object.prototype.hasOwnProperty.call(state.transmitterFilter, k) || state.transmitterFilter[k]) { - out[k] = !!state.transmitterFilter[k]; - } + if (state.transmitterFilter[k]) out[k] = true; } return out; } @@ -196,11 +194,8 @@ export function loadCanSession() { state.transmitterFilterEmpty = !anyTx; /* 选中键若不存在则清空,交由调用方选第一条 */ - if (state.selectedMessageKey && state.dbc && state.dbc.messageMap) { - if (!state.dbc.messageMap[state.selectedMessageKey]) { - state.selectedMessageKey = ''; - } - } else if (state.selectedMessageKey && (!state.dbc || !state.dbc.messages.length)) { + if (state.selectedMessageKey && + (!state.dbc || !state.dbc.messageMap[state.selectedMessageKey])) { state.selectedMessageKey = ''; } diff --git a/tools/js/can/send.js b/tools/js/can/send.js index 494bac5..02c2340 100644 --- a/tools/js/can/send.js +++ b/tools/js/can/send.js @@ -13,7 +13,7 @@ * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。 */ import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717x'; -import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x'; +import { escapeHtml } from '../common/utils.js?v=20260717x'; import { showToast } from '../common/toast.js?v=20260717x'; import { updateBatchUi } from './messages.js?v=20260717x'; import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717x'; @@ -29,13 +29,13 @@ function formatTime(date) { } // ==================== 发送错误码描述 ==================== -export var CAN_SEND_ERR_DESC = { +var CAN_SEND_ERR_DESC = { '-1': '参数错误(通道/ID/DLC 无效)', '-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)', '-3': 'GD32 CAN 总线拒绝', }; -export var GD32_ERR_DESC = { +var GD32_ERR_DESC = { 0xE0: 'payload 过短', 0xE1: '通道号非法', 0xE2: 'BUS_OFF(总线离线,检查接线/终端电阻/对端上电)', @@ -46,7 +46,7 @@ export var GD32_ERR_DESC = { }; /** @description 构建发送响应的可读文本(成功显示 recv,失败拼接 CAN_SEND_ERR_DESC + GD32_ERR_DESC)。 */ -export function buildSendResponseText(result) { +function buildSendResponseText(result) { if (result.status === 'ok') { return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据'; } @@ -133,11 +133,27 @@ function updateResponseBox(box, textEl, result, mode) { el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err'); el.manualHint.innerHTML = result.status === 'ok' ? '原始帧已发送到设备,若有响应则显示在下方。' - : '设备返回错误码 ' + sanitizeHtml(result.code) + '。'; + : '设备返回错误码 ' + escapeHtml(result.code) + '。'; } } // ==================== 发送历史 ==================== +/** + * 构造一条历史条目。集中字段,避免多处重复拼对象。 + */ +function makeHistoryEntry(opts) { + return { + time: formatTime(new Date()), + mode: opts.mode, + ok: opts.ok, + channel: opts.channel, + idHex: opts.idHex, + dlc: opts.dlc, + dataHex: opts.dataHex || '', + resultText: opts.resultText + }; +} + /** * @param {object} entry * @param {{ silent?: boolean }} [opts] silent=true 时不立刻重绘/落盘(批量用) @@ -162,11 +178,11 @@ export function renderHistory() { var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc; return '' + '
' + - '' + sanitizeHtml(item.mode) + '' + - '' + sanitizeHtml(item.time) + '' + - '' + sanitizeHtml(meta) + '' + - '' + sanitizeHtml(item.dataHex || '--') + '' + - '' + sanitizeHtml(item.resultText) + '' + + '' + escapeHtml(item.mode) + '' + + '' + escapeHtml(item.time) + '' + + '' + escapeHtml(meta) + '' + + '' + escapeHtml(item.dataHex || '--') + '' + + '' + escapeHtml(item.resultText) + '' + '
'; }).join(''); } @@ -191,128 +207,115 @@ export async function sendCheckedMessages() { var ok = 0, fail = 0; var silent = { silent: true }; - /* 先全部编码,避免发到一半才因编码失败打断节奏 */ - var prepared = []; - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var msg = null; - for (var m = 0; m < state.dbc.messages.length; m++) { - if (state.dbc.messages[m].key === key) { msg = state.dbc.messages[m]; break; } - } - if (!msg) continue; - ensureMessageDefaults(msg); - var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); - var dataBytes; - try { - dataBytes = encodeMessageData(msg, getSignalValueMap(msg)); - } catch (err) { - fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: false, - channel: channel, - idHex: idHex, - dlc: msg.dlc, - dataHex: '', - resultText: msg.name + ' · 编码失败: ' + err.message - }, silent); - continue; - } - var dataHex = bytesToHex(dataBytes); - var dlc = (typeof msg.dlc === 'number') ? msg.dlc : dataBytes.length; - prepared.push({ - msg: msg, - idHex: idHex, - dlc: dlc, - dataHex: dataHex, - frame: { id: msg.id, dlc: dlc, data: dataHex } - }); + /* 按 key 建索引,避免勾选数百条时 O(n²) 扫描 messages */ + var msgByKey = Object.create(null); + for (var mi = 0; mi < state.dbc.messages.length; mi++) { + var mm = state.dbc.messages[mi]; + msgByKey[mm.key] = mm; } - /* 分块批量接口;不支持时整段回退为无间隔单帧 */ - var useBatchApi = true; - for (var off = 0; off < prepared.length; ) { - var chunk = prepared.slice(off, off + BATCH_CHUNK); - off += chunk.length; - var frames = chunk.map(function (p) { return p.frame; }); - var results = null; - - if (useBatchApi) { + try { + /* 先全部编码,避免发到一半才因编码失败打断节奏 */ + var prepared = []; + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var msg = msgByKey[key]; + if (!msg) continue; + ensureMessageDefaults(msg); + var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + var dataBytes; try { - results = await sendCanBatchRequest(channel, frames); - if (results.length !== chunk.length) { - throw new Error('results 长度不匹配'); - } - } catch (err) { - useBatchApi = false; - results = null; - /* 本 chunk 改单帧紧接 */ - } - } - - if (results) { - for (var r = 0; r < chunk.length; r++) { - var item = chunk[r]; - var result = results[r] || { status: 'error', code: -3 }; - if (result.status === 'ok') ok++; else fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: result.status === 'ok', - channel: channel, - idHex: item.idHex, - dlc: item.dlc, - dataHex: item.dataHex || '', - resultText: item.msg.name + ' · ' + buildSendResponseText(result) - }, silent); - } - continue; - } - - /* 回退:逐帧 HTTP,帧间无 sleep、无重试 */ - for (var s = 0; s < chunk.length; s++) { - var one = chunk[s]; - try { - var oneResult = await sendCanRequest({ - ch: channel, - id: one.frame.id, - dlc: one.frame.dlc, - data: one.frame.data - }); - if (oneResult.status === 'ok') ok++; else fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: oneResult.status === 'ok', - channel: channel, - idHex: one.idHex, - dlc: one.dlc, - dataHex: one.dataHex || '', - resultText: one.msg.name + ' · ' + buildSendResponseText(oneResult) - }, silent); + dataBytes = encodeMessageData(msg, getSignalValueMap(msg)); } catch (err) { fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: false, - channel: channel, - idHex: one.idHex, - dlc: one.dlc, - dataHex: one.dataHex || '', - resultText: one.msg.name + ' · ' + err.message - }, silent); + addHistoryEntry(makeHistoryEntry({ + mode: 'BATCH', ok: false, channel: channel, idHex: idHex, + dlc: msg.dlc, dataHex: '', + resultText: msg.name + ' · 编码失败: ' + err.message + }), silent); + continue; + } + var dataHex = bytesToHex(dataBytes); + var dlc = (typeof msg.dlc === 'number') ? msg.dlc : dataBytes.length; + prepared.push({ + msg: msg, + idHex: idHex, + dlc: dlc, + dataHex: dataHex, + frame: { id: msg.id, dlc: dlc, data: dataHex } + }); + } + + /* 分块批量接口;不支持时整段回退为无间隔单帧 */ + var useBatchApi = true; + for (var off = 0; off < prepared.length; ) { + var chunk = prepared.slice(off, off + BATCH_CHUNK); + off += chunk.length; + var frames = chunk.map(function (p) { return p.frame; }); + var results = null; + + if (useBatchApi) { + try { + results = await sendCanBatchRequest(channel, frames); + if (results.length !== chunk.length) { + throw new Error('results 长度不匹配'); + } + } catch (err) { + useBatchApi = false; + results = null; + /* 本 chunk 改单帧紧接 */ + } + } + + if (results) { + for (var r = 0; r < chunk.length; r++) { + var item = chunk[r]; + var result = results[r] || { status: 'error', code: -3 }; + if (result.status === 'ok') ok++; else fail++; + addHistoryEntry(makeHistoryEntry({ + mode: 'BATCH', ok: result.status === 'ok', channel: channel, + idHex: item.idHex, dlc: item.dlc, dataHex: item.dataHex, + resultText: item.msg.name + ' · ' + buildSendResponseText(result) + }), silent); + } + continue; + } + + /* 回退:逐帧 HTTP,帧间无 sleep、无重试 */ + for (var s = 0; s < chunk.length; s++) { + var one = chunk[s]; + try { + var oneResult = await sendCanRequest({ + ch: channel, + id: one.frame.id, + dlc: one.frame.dlc, + data: one.frame.data + }); + if (oneResult.status === 'ok') ok++; else fail++; + addHistoryEntry(makeHistoryEntry({ + mode: 'BATCH', ok: oneResult.status === 'ok', channel: channel, + idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex, + resultText: one.msg.name + ' · ' + buildSendResponseText(oneResult) + }), silent); + } catch (err) { + fail++; + addHistoryEntry(makeHistoryEntry({ + mode: 'BATCH', ok: false, channel: channel, + idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex, + resultText: one.msg.name + ' · ' + err.message + }), silent); + } } } - } - renderHistory(); - scheduleCanPersist(); - state.batchInFlight = false; - updateBatchUi(); - var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning'); - showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone); + renderHistory(); + scheduleCanPersist(); + var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning'); + showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone); + } finally { + state.batchInFlight = false; + updateBatchUi(); + } } // ==================== 选中报文发送 ==================== @@ -338,16 +341,12 @@ export async function sendSelectedMessage() { try { var result = await sendCanRequest(payload); updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc'); - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'DBC', - ok: result.status === 'ok', - channel: channel, + addHistoryEntry(makeHistoryEntry({ + mode: 'DBC', ok: result.status === 'ok', channel: channel, idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), - dlc: msg.dlc, - dataHex: bytesToHex(state.generatedData), + dlc: msg.dlc, dataHex: bytesToHex(state.generatedData), resultText: buildSendResponseText(result) - }); + })); showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); } catch (err) { if (el.dbcResp) { @@ -355,16 +354,12 @@ export async function sendSelectedMessage() { el.dbcResp.className = 'can-response err'; el.dbcRespText.textContent = err.message; } - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'DBC', - ok: false, - channel: channel, + addHistoryEntry(makeHistoryEntry({ + mode: 'DBC', ok: false, channel: channel, idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), - dlc: msg.dlc, - dataHex: bytesToHex(state.generatedData), + dlc: msg.dlc, dataHex: bytesToHex(state.generatedData), resultText: err.message - }); + })); showToast('发送失败: ' + err.message, 'error'); } } @@ -397,7 +392,7 @@ export function refreshManualHint() { el.manualHint.innerHTML = '原始 HEX 合法,当前长度 ' + (dataHex.length / 2) + ' 字节。'; } catch (err) { el.manualHint.className = 'helper-note err'; - el.manualHint.innerHTML = sanitizeHtml(err.message); + el.manualHint.innerHTML = escapeHtml(err.message); } } @@ -427,16 +422,12 @@ export async function sendManualFrame() { data: dataHex }); updateResponseBox(el.manualResp, el.manualRespText, result, 'manual'); - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'RAW', - ok: result.status === 'ok', - channel: channel, + addHistoryEntry(makeHistoryEntry({ + mode: 'RAW', ok: result.status === 'ok', channel: channel, idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), - dlc: dlc, - dataHex: dataHex, + dlc: dlc, dataHex: dataHex, resultText: buildSendResponseText(result) - }); + })); showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); } catch (err) { if (el.manualResp) { @@ -444,16 +435,12 @@ export async function sendManualFrame() { el.manualResp.className = 'can-response err'; el.manualRespText.textContent = err.message; } - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'RAW', - ok: false, - channel: channel, + addHistoryEntry(makeHistoryEntry({ + mode: 'RAW', ok: false, channel: channel, idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), - dlc: dlc, - dataHex: dataHex, + dlc: dlc, dataHex: dataHex, resultText: err.message - }); + })); showToast('发送失败: ' + err.message, 'error'); } } diff --git a/tools/js/can/signals.js b/tools/js/can/signals.js index e6840ea..288031a 100644 --- a/tools/js/can/signals.js +++ b/tools/js/can/signals.js @@ -5,7 +5,7 @@ * * 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。 */ -import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717x'; +import { state, el, padHex, bytesToHex, cssEscape, findSignal, findMessageByKey } from './dbc.js?v=20260717x'; import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x'; import { scheduleCanPersist } from './persist.js?v=20260717x'; @@ -53,10 +53,7 @@ function getDefaultSignalValue(signal) { /** @description 获取当前选中的报文对象(无选中返回 null)。 */ export function getSelectedMessage() { if (!state.dbc || !state.selectedMessageKey) return null; - for (var i = 0; i < state.dbc.messages.length; i++) { - if (state.dbc.messages[i].key === state.selectedMessageKey) return state.dbc.messages[i]; - } - return null; + return findMessageByKey(state.selectedMessageKey); } function getMultiplexerSignal(message) { @@ -111,6 +108,7 @@ export function renderInlineSignalGrid(message, host) { function renderSignalRow(message, signal, index) { var bag = getSignalValueMap(message); var enumKeys = Object.keys(signal.enumMap || {}); + var enumCount = enumKeys.length; var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola'); var rangeText = ''; if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) { @@ -123,7 +121,7 @@ function renderSignalRow(message, signal, index) { var comment = signal.comment || '-'; if (comment.length > 48) comment = comment.slice(0, 48) + '...'; var inputHtml = ''; - if (enumKeys.length) { + if (enumCount) { inputHtml = '