From 5db3f66b8e7a4c6e893fe0c630a200610c4abd04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Thu, 16 Jul 2026 23:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(protocol):=20=E6=9B=B4=E6=96=B0SPI=20Link?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E8=87=B3v2=E7=89=88=E6=9C=AC=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96CAN=E9=80=9A=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将SPI OTA帧格式升级为SPI Link v2,采用新的帧格式: | SOF0 0xA5 | SOF1 0x5A | VER 0x02 | FLAGS | SESSION 4B LE | TX_SEQ | ACK_SEQ | CREDIT | TYPE | LEN 2B LE | RSV 2B | PAYLOAD | CRC32 4B LE | - 新增V2_OVERHEAD=20字节开销,APP最大payload为236字节,BOOT为108字节 - 修改帧类型定义:ENTER_BOOT从0xB5合并至0xB0,新增CAN业务类型0xC0~C3 - 更新CAN协议文档,支持SPI Link v2-only模式,调整最大业务payload计算 - 扩展0xC1帧功能,支持双向通信和1Hz主动推送ID快照 - 添加0xC3完整ID快照帧类型,包含seq/rx_count等完整字段 - 更新协议文档中的错误码定义和反馈payload格式 refactor(main): 重构主程序入口并添加CAN反馈任务 - 在main.cpp中添加CAN反馈任务,更新启动流程注释 - 创建createCriticalTask辅助函数,统一关键任务创建逻辑 - 调整SPI初始化顺序,确保CAN反馈任务依赖正确 - 优化错误处理机制,关键任务失败时立即重启 - 将GD32版本探测移至ota_relay任务处理 - 更新版本号至0.3.48,调整编译时间和Git哈希 config: 更新SPI和BLE配置参数 - 添加SPI主机相关配置参数,包括CS GPIO、READY GPIO等引脚定义 - 配置SPI缓冲区大小:APP为256字节,BOOT为128字节 - 更新BLE滑动窗口参数,ACK窗口设为32,数据队列深度为72 - 调整SPI工作队列大小至5120字节,增加任务堆栈监控说明 --- README.md | 18 +- docs/can_protocol.md | 304 +++------- main/core/main.cpp | 196 ++----- main/include/app_config.h | 27 +- main/include/version.h | 8 +- main/monitor/cpu_stats.cpp | 107 ++-- main/monitor/cpu_stats.h | 8 + main/monitor/debug_log.cpp | 669 ++++++++++++++-------- main/monitor/debug_log.h | 76 ++- main/monitor/health_monitor.cpp | 2 +- main/monitor/task_stack_check.cpp | 5 + main/monitor/task_stack_check.h | 2 + main/ota/esp32_ota_core.cpp | 50 +- main/ota/esp32_ota_core.h | 2 +- main/ota/ota_coordinator.cpp | 187 +++---- main/ota/ota_coordinator.h | 22 +- main/relay/ota_relay.cpp | 290 +++++++--- main/relay/ota_relay.h | 42 +- main/server/ble_ota.cpp | 31 +- main/server/http_handlers.cpp | 273 +++++++-- main/server/http_handlers.h | 2 + main/server/wifi_ota.cpp | 3 +- main/storage/gd32_flash_store.cpp | 29 +- main/storage/gd32_flash_store.h | 6 +- main/transport/canfd_bridge.cpp | 159 ++++-- main/transport/canfd_bridge.h | 32 +- main/transport/protocol.cpp | 112 ++-- main/transport/protocol.h | 87 ++- main/transport/spi_master.cpp | 341 +++++------- main/transport/spi_master.h | 84 ++- tools/can.html | 281 +++++++--- tools/can_monitor.html | 130 +++-- tools/css/base.css | 1 + tools/css/can.css | 886 +++++++++++++++++++++++++----- tools/css/components.css | 92 +++- tools/css/layout.css | 85 +++ tools/css/responsive.css | 37 +- tools/index.html | 156 +++--- tools/js/app.js | 34 +- tools/js/ble/file.js | 10 +- tools/js/ble/transfer.js | 4 +- tools/js/can.js | 393 +++++++++---- tools/js/can/dbc.js | 98 +++- tools/js/can/messages.js | 88 ++- tools/js/can/persist.js | 238 ++++++++ tools/js/can/send.js | 205 +++++-- tools/js/can/signals.js | 35 +- tools/js/can_monitor.js | 136 +++-- tools/js/common/connection.js | 16 +- tools/js/common/polling.js | 44 +- tools/js/common/utils.js | 7 +- tools/js/ota/console-mode.js | 31 +- tools/js/ota/file-handle.js | 338 +++++++++++- tools/js/ota/progress.js | 30 +- tools/js/ota/session.js | 32 +- tools/js/ota/upload-esp32.js | 240 ++++++-- tools/js/ota/upload-gd32.js | 5 +- 57 files changed, 4728 insertions(+), 2098 deletions(-) create mode 100644 tools/js/can/persist.js diff --git a/README.md b/README.md index eff98a4..7f30faa 100644 --- a/README.md +++ b/README.md @@ -218,24 +218,30 @@ ESP32_COMM/ ## 通信协议 -### SPI OTA 帧格式(底层传输) +### SPI Link v2 帧格式(底层传输,唯一现行) -`esp32_ota_core.cpp` 中的 `otaBuildFrame` / `otaParseFrame`: +真相源:`spi_link/spi_link_v2.h` · ESP32 `SpiMaster::otaBuildFrame` / `otaFindFrame`: ``` -| SOF(0xA5, 1B) | TYPE(1B) | LEN(2B LE) | PAYLOAD(LEN) | CRC8(1B) | +| SOF0 0xA5 | SOF1 0x5A | VER 0x02 | FLAGS | SESSION 4B LE | TX_SEQ | ACK_SEQ | CREDIT | TYPE | LEN 2B LE | RSV 2B | PAYLOAD | CRC32 4B LE | ``` +- 开销 `V2_OVERHEAD=20`(Header 16 + CRC32 4,MPEG-2 无反射) +- APP 缓冲 256 → 最大 payload **236**;BOOT 缓冲 128 → 最大 payload **108** +- ESP32 读预算:`spiRespReadBudget()` 按 HELLO role → APP=256 / BOOT=128 +- **无 v1 CRC8 路径** + | TYPE | 名称 | 用途 | |------|------|------| +| 0x00 | LINK_HELLO | 建链 / HELLO_ACK | | 0x01 | OTA_CMD_REQ | OTA 请求帧 | | 0x02 | OTA_CMD_DATA | OTA 数据帧 | | 0x03 | OTA_CMD_CANCEL | 取消 OTA | -| 0xB0 | APP_ENTER_BOOT_CMD | 通知 GD32 APP 进入 Bootloader | -| 0xB5 | APP_ENTER_BOOT_ACK_OK | GD32 APP 确认进入 Bootloader | -| 0x80 | RSP_ACK | 响应:数据就绪 | +| 0xB0 | ENTER_BOOT | 通知 GD32 APP 进入 Bootloader(应答 TYPE 亦 0xB0,payload 0xB5) | +| 0x80 | RSP_ACK | 响应:成功 | | 0x81 | RSP_WAIT | 响应:GD32 忙,请等待 | | 0x82 | RSP_ERROR | 响应:错误 | +| 0xC0~C3 | CAN 业务 | 发送 / 反馈 / 模式 / ID 快照 | ### CANFD 3 通道 SPI 协议 diff --git a/docs/can_protocol.md b/docs/can_protocol.md index 45d1d0b..c4db22a 100644 --- a/docs/can_protocol.md +++ b/docs/can_protocol.md @@ -1,39 +1,39 @@ # CAN 通信协议文档 -> 更新日期:2026-06-29 -> 涉及固件:GD32 DCU_APP + ESP32 ESP32_COMM -> SPI 时钟:4 MHz | 应答帧最大 payload:123 字节(kTxBufSize=128 - overhead=5) +> 更新日期:2026-07-16 +> 涉及固件:GD32 DCU_APP + ESP32 ESP32_COMM +> SPI:**Link v2 only** · 时钟 4 MHz +> APP 最大业务 payload:`kTxBufSize(256) - V2_OVERHEAD(20) = 236` +> BOOT 最大业务 payload:`kTxBufSize(128) - V2_OVERHEAD(20) = 108` --- ## 1. 概述 -ESP32 通过 SPI 协议与 GD32 通信,实现三路 CAN/CANFD 的收发控制。CAN 相关 SPI 命令占用 `0xC0` ~ `0xC2` 帧类型。 +ESP32 通过 SPI Link v2 与 GD32 通信,实现三路 CAN/CANFD 的收发控制。CAN 相关 SPI 命令占用 `0xC0` ~ `0xC3`。 | 帧类型 | 名称 | 方向 | 用途 | |--------|------|------|------| | `0xC0` | kTypeCanSend | ESP32 → GD32 | 发送一帧 CAN 数据 | -| `0xC1` | kTypeCanFeedback | ESP32 → GD32 | 拉取不重复 ID 快照表(分页) | +| `0xC1` | kTypeCanFeedback | 双向 | 通道统计 + ID 快照页;GD32 亦可 1Hz 主动推送 | | `0xC2` | kTypeCanSetMode | ESP32 → GD32 | 切换三路 CAN 接收过滤模式 | +| `0xC3` | kTypeIdSnapshot | ESP32 → GD32 | 完整 ID 条目(含 seq/rx_count) | --- -## 2. SPI 协议帧格式 +## 2. SPI 协议帧格式(v2) -所有 SPI 帧遵循统一格式: +所有 SPI 帧遵循 SPI Link v2(`spi_link/spi_link_v2.h`): ``` -[SOF:1B][TYPE:1B][LEN_LO:1B][LEN_HI:1B][PAYLOAD:NB][CRC8:1B] +[SOF0 A5][SOF1 5A][VER 02][FLAGS][SESSION 4LE][TX_SEQ][ACK_SEQ][CREDIT][TYPE][LEN 2LE][RSV 2][PAYLOAD][CRC32 4LE] ``` -- SOF = `0xA5` -- LEN = payload 长度(小端 16 位) -- CRC8 = 对 SOF+TYPE+LEN+PAYLOAD 的校验 +- 开销 `V2_OVERHEAD = 20` +- CRC32 MPEG-2(`0x04C11DB7`,无反射) +- **无** v1 单字节 SOF + CRC8 路径 -应答帧 TYPE: -- `0x80` = ACK(成功) -- `0x81` = WAIT(OTA 中间态) -- `0x82` = ERROR +应答 TYPE 常用:`0x80` ACK · `0x81` WAIT · `0x82` ERROR --- @@ -44,253 +44,97 @@ ESP32 通过 SPI 协议与 GD32 通信,实现三路 CAN/CANFD 的收发控制 ``` [0] channel 0/1/2 [1..4] ext_id 扩展帧 ID(Big-Endian,29 位) -[5..N] data CAN 数据负载(1~64 字节) +[5..N] data CAN 数据负载(0~64 字节;可无,对应 DLC=0) ``` +最短 payload 为 **5** 字节(仅 ch+id,DLC=0,例如 `CANFD_BOOT`)。 + ### 应答 payload ``` -[0] 0x00(ACK)或错误码 +[0] 0x00(ACK)或错误码 E0~E5 ``` +| 码 | 含义 | +|----|------| +| E0 | payload 过短(< 5 字节) | +| E1 | 通道非法 | +| E2 | BUS_OFF | +| E3 | TEC 过高(>96,对端 ACK 失败率高) | +| E4 | 邮箱满 | +| E5 | 参数无效 | + --- -## 4. 0xC1 — 不重复 ID 快照表(核心协议) +## 4. 0xC1 — 通道统计 + ID 快照(核心) -每秒 ESP32 对每通道发 2 次请求(page=0 和 page=1),拉取该通道所有**不重复 CAN ID** 的最新帧快照。 +GD32 可 1Hz 主动 `requestEsp32Read(0xC1)` 推送;ESP32 亦支持被动查询。 +打包上限:`kFeedbackPayloadLen = kTxBufSize - V2_OVERHEAD = 236`。 -### 请求 payload +### 请求 payload(被动) ``` [0] channel 0/1/2 -[1] page 页号:0 = 条目 0~7,1 = 条目 8~15 +[1] page 0 = 条目 0~7,1 = 条目 8~15 ``` -### 应答 payload(变长,最大 123 字节) +### 应答 / 推送 payload(变长) + +固定头 **28** 字节 + 条目区: ``` -[0] channel 回显通道号 -[1] page 回显页号 -[2] total_count 该通道实际 ID 条目总数(0~16) -[3] entry_count 本页实际条目数 -[4] mode 当前 CAN 接收模式(0=全收, 1=过滤) -[5..8] snapshot_seq 快照序号(LE,每秒 +1) -[9..12] rx_count 通道累计接收帧数(LE) -[13..16] tx_count 通道累计发送帧数(LE) -[17..20] rx_drop_count 通道累计丢帧(LE) -[21..24] error_count 通道累计错误(LE) -[25..] 条目逐条拼接(见下) +[0] channel +[1] page +[2] total_count +[3] entry_count +[4] mode 0=全收 1=过滤 2=桥接 +[5..8] snapshot_seq LE +[9..12] rx_count LE +[13..16] tx_count LE +[17..20] rx_drop_count LE +[21..24] error_count LE +[25] tec +[26] rec +[27] bus_state bit0=init … bit3=bus_off bit4=EW +[28..] 条目 ``` -### 单条目格式(变长) +### 单条目(8 + dlc) ``` -[+0..+3] id CAN 报文 ID(LE) -[+4] dlc 数据长度(1~64) -[+5] flags bit0=FD, bit1=BRS, bit2=ESI -[+6..+7] reserved 保留(0x00) -[+8..+N] data 报文数据(dlc 字节) -``` - -条目总大小 = 8 + dlc 字节。 -- 标准 CAN(dlc=8):14 字节/条 → 每页最多 8 条 -- CANFD(dlc=64):72 字节/条 → 每页最多 1 条 - -### 拉取时序 - -``` -ESP32 GD32 - |--- 0xC1 [ch=0, page=0] --->| - |<-- ACK [统计+条目0~7] -----| - |--- 0xC1 [ch=0, page=1] --->| - |<-- ACK [条目8~15] ---------| - |--- 0xC1 [ch=1, page=0] --->| - |<-- ACK [统计+条目0~7] -----| - |--- 0xC1 [ch=1, page=1] --->| - |<-- ACK [条目8~15] ---------| - |--- 0xC1 [ch=2, page=0] --->| - |<-- ACK [统计+条目0~7] -----| - |--- 0xC1 [ch=2, page=1] --->| - |<-- ACK [条目8~15] ---------| - | (1 秒后重复) -``` - -每轮 6 次 SPI 事务,SPI 总线占用 ~5ms(0.5%)。 - -### total_count 与 entry_count 的关系 - -- `total_count` = 该通道当前所有不重复 ID 的总数(0~16) -- `entry_count` = 本页实际返回的条目数(≤8) -- 当 `total_count ≤ 8` 时,page=1 的 `entry_count=0`,ESP32 不再拉第二页 -- 当 `total_count = 0` 时,表示该通道未收到任何 CAN 帧 - ---- - -## 5. 0xC2 — 切换接收过滤模式 - -### 请求 payload - -``` -[0] mode 0 = 全部接收(kModeAllPass) - 1 = 按 ID 过滤(kModeFilterId) -``` - -### 应答 payload - -``` -[0] actual_mode GD32 实际生效的模式 -``` - -模式切换影响三路 CAN 通道(全局生效,不是单通道)。 - ---- - -## 6. GD32 端实现要点 - -### 不重复 ID 快照表(canfd.h) - -```cpp -struct IdSnapshotEntry { - uint32_t id; - uint32_t seq; // 更新序号 - uint32_t rx_count; // 该 ID 累计接收 - uint8_t dlc, fd_flag, brs, esi; - uint8_t data[64]; - bool occupied; -}; - -struct IdSnapshotTable { - IdSnapshotEntry entries[16]; // CANFD_ID_SNAPSHOT_MAX - uint8_t count; - uint32_t seq; -}; -``` - -### 中断更新逻辑(canfd.cpp::handleRxInterrupt) - -每收到一帧 CAN: -1. 遍历 `s_idSnapshot[channel].entries[0..count-1]`,查找 ID 匹配的条目 -2. 找到 → 更新 data/dlc/flags/rx_count++ -3. 未找到且 count < 16 → 新建条目 -4. 未找到且 count = 16 → FIFO 淘汰最旧条目(index 0),整体左移,新条目插入末尾 - -### SPI 处理器(app_spi_cmd_can_feedback.cpp) - -- `kFeedbackPayloadLen = 123`(kTxBufSize 128 - overhead 5) -- 固定头 25 字节 + 变长条目逐条拼接 -- 条目不足时 `entry_count < kPageEntries`,ESP32 侧据此判断是否需要拉下一页 - ---- - -## 7. ESP32 端实现要点 - -### 数据流 - -``` -GD32 CAN 中断 - → s_idSnapshot[ch].entries[匹配ID] - ↓ (1Hz 快照刷新) - refreshFeedbackSnapshot → seq++ - ↓ (ESP32 1Hz 轮询) - 0xC1 × 3通道 × 2页 = 6次 SPI/s - ↓ - can_feedback_task 缓存到 s_latest[ch] - ↓ - /can/feedback HTTP → JSON - ↓ - can_monitor.js 表格渲染 -``` - -### SPI 轮询(can_feedback_task.cpp) - -- 固定 1Hz 循环,每通道发 2 次 0xC1(page=0 和 page=1) -- OTA 进行中时暂停(`s_paused` 标志,由 OtaCoordinator 和 OtaRelay 设置) -- 结果缓存在 `s_latest[3]`(`can_feedback_result_t`),线程安全(互斥锁) - -### HTTP 接口 - -| 端点 | 方法 | 用途 | -|------|------|------| -| `/can/feedback` | GET | 三路 CAN 通道统计 + ID 快照表 | -| `/can/mode` | GET | 查询当前接收模式 | -| `/can/mode` | POST | 切换模式 `{"mode":0}` 或 `{"mode":1}` | -| `/can/send` | POST | 发送一帧 CAN 数据 | - -### /can/feedback 响应格式 - -```json -{ - "channels": [ - { - "ch": 0, - "valid": true, - "mode": 0, - "seq": 1234, - "rx": 5678, - "tx": 0, - "drop": 0, - "err": 0, - "count": 5, - "entries": [ - { - "id": "0x18DA00F1", - "dlc": 8, - "fd": 0, - "brs": 0, - "esi": 0, - "data": "0102030405060708" - } - ] - }, - { "ch": 1, "valid": false }, - { "ch": 2, "valid": false } - ] -} +[+0..+3] id LE +[+4] dlc +[+5] flags bit0=FD bit1=BRS bit2=ESI +[+6..+7] reserved +[+8..] data ``` --- -## 8. 前端(tools/can_monitor.html) +## 5. 0xC2 — 模式切换 -### 页面结构 - -1. **顶部状态栏**:连接状态、当前模式、轮询状态 -2. **三路通道面板**(CH0/CH1/CH2): - - 状态(活跃/无帧) - - rx/tx/drop/err 统计 - - seq(活性指示) - - 当前 ID 数量 -3. **每 ID 快照表**: - - 通道下拉选择 - - 每行一个不重复 ID - - 列:CAN ID | DLC | FD | BRS | ESI | 数据 (HEX) - - 1Hz 自动刷新 - -### 导航 - -- OTA 控制台 (`index.html`) → "CAN 接收监控" 按钮 -- CAN 发送控制台 (`can.html`) → header "CAN 接收监控" 链接 -- CAN 接收监控 (`can_monitor.html`) → header 可跳回 CAN 发送或 OTA 控制台 +``` +req[0] mode 0=全收 1=过滤 2=CAN0→1/2 桥接 +rsp[0] actual 生效 mode +``` --- -## 9. 资源消耗 +## 6. 0xC3 — ID 快照(完整字段) -| 资源 | 消耗 | -|------|------| -| GD32 SRAM(快照表) | 3.8KB / 128KB = 3% | -| GD32 中断开销(16 条遍历) | < 1μs | -| SPI 事务/秒 | 6 次(3 通道 × 2 页) | -| SPI 总线占用 | ~5ms/s = 0.5% | -| ESP32 RAM(缓存) | ~4KB | -| OTA 期间 CAN 轮询 | 自动暂停 | +固定头 4B + 条目 `(16 + dlc)`(含 id/seq/rx_count/dlc/flags/rsv/data)。 +上限同 APP:`256 - V2_OVERHEAD = 236`。 --- -## 10. 已废弃 +## 7. 实现索引 -| 项目 | 状态 | 说明 | -|------|------|------| -| `0xC3 kTypeIdSnapshot` | 保留定义但不使用 | 功能已合并到 0xC1 | -| `/can/idsnap` | 已移除 | 数据由 `/can/feedback` 统一提供 | -| `canFdPullIdSnapshot()` | 已移除 | 改用 `canFdPollFeedback()` 分页拉取 | +| 端 | 文件 | +|----|------| +| GD32 0xC1/C2 | `DCU_APP/.../app_spi_cmd_can_feedback.cpp` | +| GD32 0xC3 | `app_spi_cmd_id_snapshot.cpp` | +| GD32 0xC0 | `app_spi_cmd_can_bridge.cpp` | +| ESP32 解析/桥 | `ESP32_COMM/main/transport/canfd_bridge.*` | +| ESP32 读预算 | `spi_master.cpp` → `spiRespReadBudget()` | +| 帧格式 | `spi_link/spi_link_v2.h` | +| 图示文档 | `SPEC/communication_ids.html` · `SPEC/spi_flow_diagram.html` | diff --git a/main/core/main.cpp b/main/core/main.cpp index b200454..f534d43 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -1,10 +1,9 @@ /* * ESP32-C3FH4 OTA 中继 - 程序入口 - * 四个 FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT + * FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT / CAN 反馈 * - * 使用各模块的单例类进行初始化和任务创建。 + * 使用各模块单例初始化并创建任务;入口只做装配,不实现协议。 */ -#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_chip_info.h" @@ -27,194 +26,103 @@ #include "monitor/can_feedback_task.h" /** - * @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。 + * @brief 创建关键任务;失败则打 ERR 并 ESP_ERROR_CHECK 触发重启。 + */ +static void createCriticalTask(TaskFunction_t entry, const char *name, + uint32_t stack, UBaseType_t prio) +{ + BaseType_t xRet = xTaskCreatePinnedToCore( + entry, name, stack, NULL, prio, NULL, 0); + if (xRet != pdPASS) { + DEBUG_ERR("MAIN", "创建关键任务 %s 失败", name); + ESP_ERROR_CHECK(ESP_FAIL); + } + DEBUG_INFO("MAIN", "任务已创建: %s (prio=%u stack=%lu)", + name, (unsigned)prio, (unsigned long)stack); +} + +/** + * @brief 程序主入口:初始化组件并创建 FreeRTOS 任务。 */ extern "C" void app_main(void) { esp_chip_info_t chip_info; esp_chip_info(&chip_info); - // ESP32 OTA 分区互斥锁内聚到 Esp32OtaCore Esp32OtaCore::initMutex(); - // 初始化网络接口 + 事件循环(必须最早) ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); - // 初始化调试日志(UART0 先就绪,UDP socket 延迟到首次发送时创建) DebugLog::instance().init(); - // WiFi 提前初始化(调度器启动前) - bool wifi_sta_ok = WifiOta::instance().earlyInit(); + /* earlyInit:true=已启动 STA 连接流程;false=将走 AP(或 CONFIG 直选 AP) */ + const bool wifi_early_started_sta = WifiOta::instance().earlyInit(); HealthMonitor::instance().init(); DEBUG_INFO("MAIN", "健康监控状态已初始化"); DEBUG_INFO("MAIN", "========================================"); - DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v1.0 启动"); + DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动", FW_VERSION); DEBUG_INFO("MAIN", "Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip_info.revision); - DEBUG_INFO("MAIN", "Free heap: %lu bytes", esp_get_free_heap_size()); + DEBUG_INFO("MAIN", "Free heap: %lu bytes", (unsigned long)esp_get_free_heap_size()); DEBUG_INFO("MAIN", "CPU Freq: %d MHz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ); - DEBUG_INFO("MAIN", "WiFi: %s", wifi_sta_ok ? "STA 连接中" : "将使用 AP 模式"); + DEBUG_INFO("MAIN", "WiFi early: %s(最终 STA/AP 由 wifi_ota 任务确认)", + wifi_early_started_sta ? "STA 连接中" : "非 STA 优先/将切 AP"); DEBUG_INFO("MAIN", "========================================"); - // 健康监控任务(温度 + WiFi RSSI) - BaseType_t xRet; - xRet = xTaskCreatePinnedToCore( - HealthMonitor::taskEntry, - "health_mon", - TASK_STACK_HEALTH_MON, - NULL, - configMAX_PRIORITIES - 4, - NULL, - 0 - ); - if (xRet != pdPASS) { - // 关键任务:负责温度与 WiFi RSSI 上报,缺失会让运维陷入盲区, - // 无法及时发现硬件异常,必须重启恢复 - DEBUG_ERR("MAIN", "创建 health_monitor_task 失败"); - ESP_ERROR_CHECK(ESP_FAIL); // 关键任务失败:终止启动并触发看门狗重启 - } else { - DEBUG_INFO("MAIN", "健康监控任务已创建"); - } + /* 健康监控:温度/RSSI;缺失则运维盲区 → 关键 */ + createCriticalTask(HealthMonitor::taskEntry, "health_mon", + TASK_STACK_HEALTH_MON, configMAX_PRIORITIES - 4); - /* CPU 占用 1Hz 采样(非致命;失败时 /system/status 的 cpu.valid 保持 false) */ + /* CPU 1Hz 采样:非致命 */ CpuStats::instance().start(); Protocol::instance().init(); DEBUG_INFO("MAIN", "协议层初始化完成"); - // OTA 全局协调器初始化(在 OTA task 创建前,确保 mutex 就绪) OtaCoordinator::instance().init(); - // 注册 BLE 射频共存监听器(WifiOta 实现 IBleCoexListener) OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance()); DEBUG_INFO("MAIN", "OTA 全局协调器已初始化"); - esp_err_t ret = SpiMaster::instance().init(); - if (ret != ESP_OK) { - DEBUG_ERR("MAIN", "SPI 总线初始化失败: %d", ret); - } else { + /* SPI 是中继/CAN 反馈根基:失败则无法完成核心职责,按关键失败处理 */ + { + esp_err_t ret = SpiMaster::instance().init(); + if (ret != ESP_OK) { + DEBUG_ERR("MAIN", "SPI 总线初始化失败: %s", esp_err_to_name(ret)); + ESP_ERROR_CHECK(ret); + } DEBUG_INFO("MAIN", "SPI 总线初始化完成"); } - // OTA 中继任务(中高优先级) - xRet = xTaskCreatePinnedToCore( - OtaRelay::taskEntry, - "ota_relay", - TASK_STACK_OTA_RELAY, - NULL, - configMAX_PRIORITIES - 2, - NULL, - 0 // 核0 - ); - if (xRet != pdPASS) { - // 关键任务:OTA 中继是本设备核心职责(经 SPI 派发固件分片到目标 MCU), - // 缺失则 OTA 刷写主流程彻底瘫痪,必须重启恢复 - DEBUG_ERR("MAIN", "创建 ota_relay_task 失败"); - ESP_ERROR_CHECK(ESP_FAIL); // 关键任务失败:终止启动并触发看门狗重启 - } else { - DEBUG_INFO("MAIN", "OTA 中继任务已创建"); - } + createCriticalTask(OtaRelay::taskEntry, "ota_relay", + TASK_STACK_OTA_RELAY, configMAX_PRIORITIES - 2); - // WiFi OTA 服务器任务 - xRet = xTaskCreatePinnedToCore( - WifiOta::taskEntry, - "wifi_ota", - TASK_STACK_WIFI_OTA, - NULL, - configMAX_PRIORITIES - 3, - NULL, - 0 - ); - if (xRet != pdPASS) { - // 关键任务:WiFi OTA 是 OTA 刷写主通道之一,缺失则 WiFi 侧固件升级不可用, - // 系统失去主要刷写路径,必须重启恢复 - DEBUG_ERR("MAIN", "创建 wifi_ota_task 失败"); - ESP_ERROR_CHECK(ESP_FAIL); // 关键任务失败:终止启动并触发看门狗重启 - } else { - DEBUG_INFO("MAIN", "WiFi OTA 任务已创建"); - } + createCriticalTask(WifiOta::taskEntry, "wifi_ota", + TASK_STACK_WIFI_OTA, configMAX_PRIORITIES - 3); - // BLE OTA 任务 - xRet = xTaskCreatePinnedToCore( - BleOta::taskEntry, - "ble_ota", - TASK_STACK_BLE_OTA, - NULL, - configMAX_PRIORITIES - 3, - NULL, - 0 // ESP32-C3 单核,固定核0 - ); - if (xRet != pdPASS) { - // 关键任务:BLE OTA 是 OTA 刷写主通道之一(与 WiFi OTA 互为冗余), - // 缺失则 BLE 侧固件升级完全不可用,必须重启恢复 - // 注:此处为 BLE OTA 主服务任务,区别于 ble_ota.cpp 内部的 - // ble_ota_data_worker 子任务(后者可降级,不在本文件创建) - DEBUG_ERR("MAIN", "创建 ble_ota_task 失败"); - ESP_ERROR_CHECK(ESP_FAIL); // 关键任务失败:终止启动并触发看门狗重启 - } else { - DEBUG_INFO("MAIN", "BLE OTA 任务已创建"); - } + createCriticalTask(BleOta::taskEntry, "ble_ota", + TASK_STACK_BLE_OTA, configMAX_PRIORITIES - 3); - // CAN 1Hz 反馈轮询任务(依赖 SPI 已初始化) if (startCanFeedbackTask()) { DEBUG_INFO("MAIN", "CAN 反馈轮询任务已创建"); } else { DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)"); } - // OTA 回滚保护:标记当前固件有效 - // 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数 - // 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区 - esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback(); - if (ota_valid_err == ESP_OK) { - DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效"); - } else { - DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动", esp_err_to_name(ota_valid_err)); + /* 任务就绪后尽快标记 app 有效,避免后续探测拖太久导致回滚窗口拉长 */ + { + esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback(); + if (ota_valid_err == ESP_OK) { + DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效"); + } else { + DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动", + esp_err_to_name(ota_valid_err)); + } } DEBUG_INFO("MAIN", "========================================"); DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中"); - - // 启动最后步骤:读取 GD32 APP 版本号 - // 先 hello() 建立 SPI v2 会话,再 probeGd32AppOnce 读取版本 - // GD32 APP 完整初始化需 5-6 秒,使用 20 次重试 × 300ms 窗口 - { - DEBUG_INFO("MAIN", "启动 GD32 版本探测..."); - gd32_version_t gd32_ver = {}; - bool session_established = false; - bool gd32_ok = false; - - for (uint32_t attempt = 0; attempt < 20; ++attempt) { - vTaskDelay(pdMS_TO_TICKS(300)); - - if (!session_established) { - esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP); - if (hr == ESP_OK) { - session_established = true; - } else { - DEBUG_INFO("MAIN", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1); - continue; - } - } - - esp_err_t gd32_ret = OtaCoordinator::instance().probeGd32AppOnce(&gd32_ver, nullptr); - if (gd32_ret == ESP_OK) { - DEBUG_INFO("MAIN", "GD32 APP: v%d.%d.%d.%d (BOOT v%d.%d.%d.%d)", - gd32_ver.app_major, gd32_ver.app_minor, - gd32_ver.app_patch, gd32_ver.app_build, - gd32_ver.boot_major, gd32_ver.boot_minor, - gd32_ver.boot_patch, gd32_ver.boot_build); - gd32_ok = true; - break; - } - DEBUG_INFO("MAIN", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1); - } - - if (!gd32_ok) { - DEBUG_WARN("MAIN", "GD32 APP 版本读取失败(OtaRelay 任务将重试)"); - } - } - + DEBUG_INFO("MAIN", "GD32 版本探测由 ota_relay 任务负责(hello+probe)"); DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)", DEBUG_LOG_IP, DEBUG_LOG_PORT); DEBUG_INFO("MAIN", "========================================"); diff --git a/main/include/app_config.h b/main/include/app_config.h index c5d60c4..a4de9a3 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -53,8 +53,8 @@ inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服 // ============================================================ inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; ///< httpd:原 10KB,peak~5KB → 8KB(余量~3KB) inline constexpr uint32_t TASK_STACK_WIFI_OTA = 4096; ///< wifi_ota 主循环:原 8KB,peak~1.4KB -inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(实测 peak~1KB;完整 GD32 中继后再确认水位) -inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:无单独水位,6144→5120 小幅收 +inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(轻载 peak~1KB;转发时看 /system/status.task_stack) +inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:5KB(帧缓冲热路径;已纳入 task_stack 巡检) inline constexpr uint32_t TASK_STACK_BLE_OTA = 3072; ///< ble_ota 主任务:peak~1KB inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker:peak~1.1KB(负载峰值曾~1.9KB) inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.2KB @@ -92,6 +92,25 @@ inline constexpr size_t HEAP_WARNING_MIN = 10240; ///< 堆内 inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; ///< UDP 日志目标 IP inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口 +// ============================================================ +// SPI 主机(ESP32 ↔ GD32)可调参数 +// 协议帧格式见 spi_link_v2.h;引脚与超时集中在此便于板级调整 +// ============================================================ +inline constexpr int SPI_MASTER_CS_GPIO = 4; ///< CS 片选 GPIO +inline constexpr int SPI_READY_GPIO = 10; ///< DATA_READY 通知线 GPIO(↔ GD32 PB6) +inline constexpr int SPI_MODE = 0; ///< CPOL=0 CPHA=0 +inline constexpr int SPI_QUEUE_SIZE = 4; ///< SPI 事务队列深度 +inline constexpr size_t SPI_OTA_FRAME_MAX = 256; ///< 单 CS 最大帧长(APP 满缓冲) +inline constexpr size_t SPI_BOOT_TX_BUF_SIZE = 128; ///< BOOT TX DMA 满缓冲(读预算用) +inline constexpr size_t SPI_APP_TX_BUF_SIZE = 256; ///< APP TX DMA 满缓冲(读预算用) + +// ============================================================ +// OTA 协调器 +// ============================================================ +inline constexpr uint32_t OTA_COORD_LOCK_TIMEOUT_MS = 100; ///< 协调器 mutex 超时(ms) +/** 忙状态自愈:须 > 中继转发预算上限(约 120s)+ 握手/SIZE 余量。 */ +inline constexpr uint32_t OTA_COORD_BUSY_WATCHDOG_MS = 150000; + // ============================================================ // BLE OTA 配置 // ============================================================ @@ -110,8 +129,12 @@ inline constexpr uint8_t BLE_OTA_CTRL_ABORT_OTA = 0xFB; ///< BLE 控 inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; ///< BLE 数据通道:GD32 固件包标识 inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; ///< BLE 数据通道:ESP32 固件包标识 inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; ///< BLE 单包数据载荷大小(字节) +// 滑动 ACK 窗口(须与前端 tools/js/ble/constants.js BLE_OTA_ACK_WINDOW 保持一致) +inline constexpr size_t BLE_OTA_ACK_WINDOW = 32; // Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW) inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32; +// 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批 +inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 8); // ============================================================ // 安全常量(需与 GD32 固件团队确认) diff --git a/main/include/version.h b/main/include/version.h index 161748d..f782dfe 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 47 -#define FW_VERSION_STRING "0.3.47" +#define FW_VERSION_PATCH 48 +#define FW_VERSION_STRING "0.3.48" // 编译时间 -#define BUILD_TIME "2026-07-16 01:10:05" +#define BUILD_TIME "2026-07-16 22:59:52" // Git 信息 -#define GIT_HASH "a47a027" +#define GIT_HASH "51c89b3" diff --git a/main/monitor/cpu_stats.cpp b/main/monitor/cpu_stats.cpp index 269c83d..2202328 100644 --- a/main/monitor/cpu_stats.cpp +++ b/main/monitor/cpu_stats.cpp @@ -6,12 +6,17 @@ #include #include #include -#include #include "app_config.h" #include "esp_timer.h" #include "monitor/debug_log.h" +/** 截断/回绕 WARN 最小间隔(30s) */ +static TickType_t cpuStatsWarnIntervalTicks() +{ + return pdMS_TO_TICKS(30000); +} + CpuStats& CpuStats::instance() { static CpuStats inst; @@ -78,91 +83,113 @@ static bool taskNameIsIdle(const char* name) void CpuStats::sampleOnce() { - UBaseType_t task_count = uxTaskGetNumberOfTasks(); - if (task_count == 0) { + const UBaseType_t live = uxTaskGetNumberOfTasks(); + if (live == 0) { return; } - task_count = std::min(task_count + 2U, (UBaseType_t)CPU_TASK_SNAPSHOT_MAX); - TaskStatus_t* tasks = (TaskStatus_t*)calloc(task_count, sizeof(TaskStatus_t)); - if (!tasks) { - return; + /* P1:任务数超出快照上限时节流告警(否则静默丢任务) */ + if (live > (UBaseType_t)CPU_TASK_SNAPSHOT_MAX) { + const TickType_t now_tick = xTaskGetTickCount(); + if (m_lastTruncWarnTick == 0 || + (now_tick - m_lastTruncWarnTick) >= cpuStatsWarnIntervalTicks()) { + m_lastTruncWarnTick = now_tick; + DEBUG_WARN("CPU", + "任务数 %lu > 快照上限 %u,本拍仅统计前 %u 个任务", + (unsigned long)live, + (unsigned)CPU_TASK_SNAPSHOT_MAX, + (unsigned)CPU_TASK_SNAPSHOT_MAX); + } } + /* P1:固定成员缓冲,免每拍 heap */ + const UBaseType_t buf_slots = + std::min(live + 2U, (UBaseType_t)CPU_TASK_SNAPSHOT_MAX); + configRUN_TIME_COUNTER_TYPE total_runtime = 0; - UBaseType_t captured = uxTaskGetSystemState(tasks, task_count, &total_runtime); + const UBaseType_t captured = + uxTaskGetSystemState(m_taskStatusBuf, buf_slots, &total_runtime); const int64_t now_us = esp_timer_get_time(); - if (captured == 0 || captured > task_count) { + if (captured == 0 || captured > buf_slots) { DEBUG_WARN("CPU", "uxTaskGetSystemState 异常: captured=%lu buf=%lu", - (unsigned long)captured, (unsigned long)task_count); - free(tasks); + (unsigned long)captured, (unsigned long)buf_slots); return; } CpuUsageSnapshot snap = {}; snap.available = true; + /* + * P2:先用 m_lastSamples 算本窗口,再写回基线(无需栈上 previous 拷贝)。 + * total 回绕(total_runtime < m_lastTotal)时丢弃本窗,只重建基线。 + */ const bool had_previous = m_hasLast; - const configRUN_TIME_COUNTER_TYPE previous_total = m_lastTotal; - const int64_t previous_time_us = m_lastTimeUs; - const size_t previous_count = m_lastCount; - TaskSample previous_samples[CPU_TASK_SNAPSHOT_MAX]; - for (size_t i = 0; i < previous_count && i < CPU_TASK_SNAPSHOT_MAX; ++i) { - previous_samples[i] = m_lastSamples[i]; + const bool wrapped = had_previous && total_runtime < m_lastTotal; + if (wrapped) { + const TickType_t now_tick = xTaskGetTickCount(); + if (m_lastWrapWarnTick == 0 || + (now_tick - m_lastWrapWarnTick) >= cpuStatsWarnIntervalTicks()) { + m_lastWrapWarnTick = now_tick; + DEBUG_WARN("CPU", + "run-time 计数回绕,丢弃本采样窗 total=%" PRIu64 " prev=%" PRIu64, + (uint64_t)total_runtime, + (uint64_t)m_lastTotal); + } } - /* 先更新基线,再算本窗口(与旧 HTTP 路径语义一致) */ - size_t copy_count = std::min((size_t)captured, CPU_TASK_SNAPSHOT_MAX); - for (size_t i = 0; i < copy_count; ++i) { - m_lastSamples[i].task_number = tasks[i].xTaskNumber; - m_lastSamples[i].runtime = tasks[i].ulRunTimeCounter; - } - m_lastCount = copy_count; - m_lastTotal = total_runtime; - m_lastTimeUs = now_us; - m_hasLast = true; - - const bool can_compute = had_previous && total_runtime >= previous_total && now_us > previous_time_us; - const uint64_t total_delta = can_compute ? runtimeDelta(total_runtime, previous_total) : 0U; + const bool can_compute = + had_previous && !wrapped && now_us > m_lastTimeUs; + const uint64_t total_delta = + can_compute ? runtimeDelta(total_runtime, m_lastTotal) : 0U; uint64_t idle_delta = 0U; if (can_compute && total_delta > 0U) { for (UBaseType_t i = 0; i < captured; ++i) { configRUN_TIME_COUNTER_TYPE last_runtime = 0; bool found = false; - for (size_t j = 0; j < previous_count; ++j) { - if (previous_samples[j].task_number == tasks[i].xTaskNumber) { - last_runtime = previous_samples[j].runtime; + for (size_t j = 0; j < m_lastCount; ++j) { + if (m_lastSamples[j].task_number == m_taskStatusBuf[i].xTaskNumber) { + last_runtime = m_lastSamples[j].runtime; found = true; break; } } if (!found) { - continue; + continue; /* 新任务:本窗无 delta */ } - uint64_t delta = runtimeDelta(tasks[i].ulRunTimeCounter, last_runtime); + /* 单任务计数回绕时 runtimeDelta→0,本任务本窗记 0 */ + uint64_t delta = runtimeDelta(m_taskStatusBuf[i].ulRunTimeCounter, last_runtime); double pct = (double)delta * 100.0 / (double)total_delta; - if (taskNameIsIdle(tasks[i].pcTaskName)) { + if (taskNameIsIdle(m_taskStatusBuf[i].pcTaskName)) { idle_delta += delta; } if (snap.task_count < CPU_TASK_SNAPSHOT_MAX) { CpuTaskMetric& m = snap.tasks[snap.task_count++]; - const char* n = tasks[i].pcTaskName ? tasks[i].pcTaskName : ""; + const char* n = m_taskStatusBuf[i].pcTaskName ? m_taskStatusBuf[i].pcTaskName : ""; strncpy(m.name, n, sizeof(m.name) - 1); m.name[sizeof(m.name) - 1] = '\0'; - m.task_number = tasks[i].xTaskNumber; + m.task_number = m_taskStatusBuf[i].xTaskNumber; m.runtime_delta = delta; m.usage_pct = pct; } } snap.valid = true; - snap.sample_ms = (uint32_t)((now_us - previous_time_us) / 1000); + snap.sample_ms = (uint32_t)((now_us - m_lastTimeUs) / 1000); snap.idle_pct = std::min(100.0, (double)idle_delta * 100.0 / (double)total_delta); snap.usage_pct = std::max(0.0, 100.0 - snap.idle_pct); } - free(tasks); + /* 更新基线(首拍 / 正常窗 / 回绕丢窗后均重建) */ + const size_t copy_count = std::min((size_t)captured, CPU_TASK_SNAPSHOT_MAX); + for (size_t i = 0; i < copy_count; ++i) { + m_lastSamples[i].task_number = m_taskStatusBuf[i].xTaskNumber; + m_lastSamples[i].runtime = m_taskStatusBuf[i].ulRunTimeCounter; + } + m_lastCount = copy_count; + m_lastTotal = total_runtime; + m_lastTimeUs = now_us; + m_hasLast = true; portENTER_CRITICAL(&m_mux); m_published = snap; diff --git a/main/monitor/cpu_stats.h b/main/monitor/cpu_stats.h index 1d3b2fe..2d8eb1d 100644 --- a/main/monitor/cpu_stats.h +++ b/main/monitor/cpu_stats.h @@ -70,4 +70,12 @@ private: configRUN_TIME_COUNTER_TYPE m_lastTotal = 0; int64_t m_lastTimeUs = 0; bool m_hasLast = false; + + /** uxTaskGetSystemState 目标缓冲(固定、免每拍 calloc) */ + TaskStatus_t m_taskStatusBuf[CPU_TASK_SNAPSHOT_MAX] = {}; + + /** 任务数截断 WARN 节流(tick) */ + TickType_t m_lastTruncWarnTick = 0; + /** 运行时计数回绕 WARN 节流(tick) */ + TickType_t m_lastWrapWarnTick = 0; }; diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index 8b77b2b..235e8cb 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -12,79 +12,165 @@ * 时序说明: * esp_netif_init() 创建 tcpip 线程后才可安全使用 socket * 本模块在首次 send() 时才创建 socket(延迟创建) - * WiFi 连接前 sendto() 会失败(无路由),日志仍可写入 UART 与 HTTP 日志缓冲 - * WiFi 连接后 UDP 立即生效,无需额外操作 + * WiFi 未获 IP 时跳过 sendto;日志仍写 UART 与 HTTP ring + * + * 线程:仅任务上下文;禁止 ISR 调用 DEBUG_*(内部 portMAX_DELAY mutex)。 */ #include #include +#include #include -#include #include +#include + #include "freertos/task.h" #include "freertos/semphr.h" #include "esp_log.h" #include "esp_err.h" #include "esp_system.h" #include "esp_heap_caps.h" +#include "esp_netif.h" #include "driver/uart.h" #include "debug_log.h" static const char *TAG = "DEBUG_LOG"; +static TickType_t warnIntervalTicks() +{ + return pdMS_TO_TICKS(10000); +} +static constexpr int kUdpSndTimeoutMs = 200; +static constexpr uint8_t kUdpFailRecreate = 8; + // ============================================================ -// 单例 +// 单例 / 工具 // ============================================================ -/** - * @brief 获取 DebugLog 单例实例。 - * @return DebugLog 引用。 - */ DebugLog& DebugLog::instance() { static DebugLog inst; return inst; } -// ============================================================ -// 日志环形缓冲区(供 HTTP /logs 拉取) -// ============================================================ +bool DebugLog::isKeepAliveTag(const char *tag) +{ + if (!tag) { + return false; + } + /* OTA / OTA_COORD 等;上传路径 WIFI;总线 SPI */ + if (strncmp(tag, "OTA", 3) == 0) { + return true; + } + if (strcmp(tag, "SPI") == 0 || strcmp(tag, "WIFI") == 0) { + return true; + } + return false; +} + +char *DebugLog::allocEmptyJson(int *out_len) +{ + char *empty = (char *)malloc(3); + if (!empty) { + return nullptr; + } + empty[0] = '['; + empty[1] = ']'; + empty[2] = '\0'; + if (out_len) { + *out_len = 2; + } + return empty; +} + /** - * @brief 获取当前环形日志缓冲区中的日志条数。 - * @return 日志条数。 + * JSON 字符串内容转义写入 buf[pos..];返回新 pos。 + * 控制字符除 \n\r\t 外写成 \u00XX;空间不足时截断并尽量写 "..."。 */ +int DebugLog::jsonEscapeAppend(char *buf, int pos, int capacity, const char *src, int src_len) +{ + const int content_start = pos; + const int hard_limit = capacity - 6; /* 预留 "..." + '"' + ']' + '\0' 量级 */ + bool truncated = false; + + for (int i = 0; i < src_len; ++i) { + if (pos >= hard_limit) { + truncated = true; + break; + } + /* 单条转义上限,避免异常长行占满 drain */ + if (pos - content_start >= LOG_RING_SIZE * 2) { + truncated = true; + break; + } + + const unsigned char c = (unsigned char)src[i]; + if (c == '"') { + if (pos + 2 > hard_limit) { truncated = true; break; } + buf[pos++] = '\\'; + buf[pos++] = '"'; + } else if (c == '\\') { + if (pos + 2 > hard_limit) { truncated = true; break; } + buf[pos++] = '\\'; + buf[pos++] = '\\'; + } else if (c == '\n') { + if (pos + 2 > hard_limit) { truncated = true; break; } + buf[pos++] = '\\'; + buf[pos++] = 'n'; + } else if (c == '\r') { + if (pos + 2 > hard_limit) { truncated = true; break; } + buf[pos++] = '\\'; + buf[pos++] = 'r'; + } else if (c == '\t') { + if (pos + 2 > hard_limit) { truncated = true; break; } + buf[pos++] = '\\'; + buf[pos++] = 't'; + } else if (c < 0x20) { + /* 其它控制符 → \u00XX(6 字符) */ + if (pos + 6 > hard_limit) { truncated = true; break; } + static const char hex[] = "0123456789abcdef"; + buf[pos++] = '\\'; + buf[pos++] = 'u'; + buf[pos++] = '0'; + buf[pos++] = '0'; + buf[pos++] = hex[(c >> 4) & 0xF]; + buf[pos++] = hex[c & 0xF]; + } else { + buf[pos++] = (char)c; + } + } + + if (truncated && pos + 3 <= hard_limit) { + buf[pos++] = '.'; + buf[pos++] = '.'; + buf[pos++] = '.'; + } + return pos; +} + +// ============================================================ +// 环形缓冲(Locked 变体假定已持 m_logRingMutex) +// ============================================================ int DebugLog::ringCount() { - if (!m_logRingMutex) return 0; + if (!m_logRingMutex) { + return 0; + } xSemaphoreTake(m_logRingMutex, portMAX_DELAY); - int count = m_logRingCount; + const int count = m_logRingCount; xSemaphoreGive(m_logRingMutex); return count; } -/** - * @brief 弹出一条最旧日志并从环形缓冲区移除。 - * @param out 输出缓冲区。 - * @param out_size 输出缓冲区大小。 - * @param out_len 输出日志长度。 - * @return true 表示成功弹出一条日志,false 表示缓冲区为空或参数无效。 - */ -bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len) +bool DebugLog::ringPopOldestLocked(char *out, size_t out_size, int *out_len) { if (out_len) { *out_len = 0; } - if (!m_logRingMutex || !out || out_size == 0) { + if (!out || out_size == 0 || m_logRingCount <= 0) { return false; } - xSemaphoreTake(m_logRingMutex, portMAX_DELAY); - - if (m_logRingCount <= 0) { - xSemaphoreGive(m_logRingMutex); - return false; - } - - int idx = (m_logRingHead - m_logRingCount + LOG_RING_SLOTS) % LOG_RING_SLOTS; + const int idx = (m_logRingHead - m_logRingCount + LOG_RING_SLOTS) % LOG_RING_SLOTS; int len = m_logRingLens[idx]; if (len < 0) { len = 0; @@ -105,132 +191,212 @@ bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len) m_logRingLens[idx] = 0; m_logRingCount--; - - xSemaphoreGive(m_logRingMutex); return true; } -/** - * @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。 - * @param out_len 输出 JSON 字符串长度。 - * @return 动态分配的 JSON 字符串,调用者须 free()。 - */ -char* DebugLog::ringDrainJson(int *out_len) +bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len) { - if (!out_len) return NULL; + if (out_len) { + *out_len = 0; + } + if (!m_logRingMutex || !out || out_size == 0) { + return false; + } + + xSemaphoreTake(m_logRingMutex, portMAX_DELAY); + const bool ok = ringPopOldestLocked(out, out_size, out_len); + xSemaphoreGive(m_logRingMutex); + return ok; +} + +/** 预览即将 drain 的条数与原始字节总和(用于估 capacity)。 */ +int DebugLog::ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const +{ + if (total_raw_len) { + *total_raw_len = 0; + } + int n = m_logRingCount; + if (n > max_n) { + n = max_n; + } + if (n <= 0) { + return 0; + } + + int sum = 0; + for (int i = 0; i < n; ++i) { + const int idx = (m_logRingHead - m_logRingCount + i + LOG_RING_SLOTS) % LOG_RING_SLOTS; + int len = m_logRingLens[idx]; + if (len < 0) { + len = 0; + } + if (len >= LOG_RING_SIZE) { + len = LOG_RING_SIZE - 1; + } + sum += len; + } + if (total_raw_len) { + *total_raw_len = sum; + } + return n; +} + +void DebugLog::ringPushLocked(const char *data, int len) +{ + if (!data || len <= 0) { + return; + } + + int copy_len = len; + if (copy_len >= LOG_RING_SIZE) { + copy_len = LOG_RING_SIZE - 1; + } + + memcpy(m_logRing[m_logRingHead], data, (size_t)copy_len); + m_logRing[m_logRingHead][copy_len] = '\0'; + m_logRingLens[m_logRingHead] = copy_len; + + m_logRingHead = (m_logRingHead + 1) % LOG_RING_SLOTS; + if (m_logRingCount < LOG_RING_SLOTS) { + m_logRingCount++; + } +} + +void DebugLog::ringPush(const char *data, int len) +{ + if (!m_logRingMutex || len <= 0) { + return; + } + xSemaphoreTake(m_logRingMutex, portMAX_DELAY); + ringPushLocked(data, len); + xSemaphoreGive(m_logRingMutex); +} + +/** + * 单次持锁:peek 元数据 →(可选)按堆缩小条数 → pop+转义拼 JSON。 + * capacity 按真实 raw 长度估算,避免 48×320 虚高导致 OTA 期返空。 + */ +char *DebugLog::ringDrainJson(int *out_len) +{ + if (!out_len) { + return nullptr; + } *out_len = 0; - /* 堆紧时不 malloc:返回堆上最小 "[]",调用方仍 free。 */ const uint32_t free_now = esp_get_free_heap_size(); - if (free_now < kDrainMinFree) { - char *empty = (char *)malloc(3); - if (!empty) return NULL; - empty[0] = '['; - empty[1] = ']'; - empty[2] = '\0'; - *out_len = 2; - return empty; + if (free_now < kDrainMinFree || !m_logRingMutex) { + return allocEmptyJson(out_len); } - int count = ringCount(); - /* OTA 沉默失败排查需要一次多拉;上限 24 条 */ - static constexpr int kMaxDrain = 24; - if (count > kMaxDrain) count = kMaxDrain; - int capacity = count * (LOG_RING_SIZE * 2) + 16; - if (capacity < 16) capacity = 16; + xSemaphoreTake(m_logRingMutex, portMAX_DELAY); + int total_raw = 0; + int drain_n = ringPeekDrainMetaLocked(LOG_DRAIN_MAX, &total_raw); + if (drain_n <= 0) { + xSemaphoreGive(m_logRingMutex); + return allocEmptyJson(out_len); + } + + /* 转义最坏 ~2x + 每条 "" 与逗号 + 数组括号 */ + auto calc_capacity = [](int n, int raw) -> int { + int cap = raw * 2 + n * 4 + 16; + if (cap < 16) { + cap = 16; + } + return cap; + }; + + int capacity = calc_capacity(drain_n, total_raw); const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - if (largest < (size_t)capacity) { - char *empty = (char *)malloc(3); - if (!empty) return NULL; - empty[0] = '['; - empty[1] = ']'; - empty[2] = '\0'; - *out_len = 2; - return empty; + + /* 堆不够则减条数重估(仍持锁,peek 只读;减 n 时从最旧开始仍一致) */ + while (drain_n > 0 && largest < (size_t)capacity) { + drain_n = drain_n / 2; + if (drain_n == 0) { + break; + } + total_raw = 0; + drain_n = ringPeekDrainMetaLocked(drain_n, &total_raw); + capacity = calc_capacity(drain_n, total_raw); } - char *buf = (char*)malloc(capacity); - if (!buf) return NULL; + if (drain_n <= 0 || largest < (size_t)capacity) { + xSemaphoreGive(m_logRingMutex); + return allocEmptyJson(out_len); + } + + /* 持锁期间尽量短:只 pop 到栈上临时区会爆栈;改为直接写入 malloc 缓冲。 + * 先释放锁再 malloc,再二次持锁 pop——中间可能插入新日志,只 drain 最旧 N 条即可。 */ + xSemaphoreGive(m_logRingMutex); + + char *buf = (char *)malloc((size_t)capacity); + if (!buf) { + return allocEmptyJson(out_len); + } + + xSemaphoreTake(m_logRingMutex, portMAX_DELAY); + + /* 重新核对:ring 可能变少 */ + if (m_logRingCount <= 0) { + xSemaphoreGive(m_logRingMutex); + free(buf); + return allocEmptyJson(out_len); + } + if (drain_n > m_logRingCount) { + drain_n = m_logRingCount; + } + if (drain_n > LOG_DRAIN_MAX) { + drain_n = LOG_DRAIN_MAX; + } int pos = 0; buf[pos++] = '['; bool first = true; char entry[LOG_RING_SIZE]; - int entryLen = 0; - while (ringPopOldest(entry, sizeof(entry), &entryLen) && entryLen > 0) { - // 逗号写入前做边界检查,预留 4 字节给 ']' + '\0' 及末尾余量 + for (int k = 0; k < drain_n; ++k) { + /* 先估空间再 pop,避免 pop 后写不下导致丢条 */ + const int need_min = first ? 4 : 5; /* ,"x" 或 "x" 量级 + 闭合余量 */ + if (pos + need_min >= capacity - 2) { + break; + } + + int entry_len = 0; + if (!ringPopOldestLocked(entry, sizeof(entry), &entry_len) || entry_len <= 0) { + break; + } + if (!first) { - if (pos + 1 >= capacity - 4) break; buf[pos++] = ','; } first = false; buf[pos++] = '"'; - - // 单条日志转义后超过 LOG_RING_SIZE * 2 时截断,标记 "..." 后缀 - int content_start = pos; - bool truncated = false; - for (int i = 0; i < entryLen && pos < capacity - 4; i++) { - if (pos - content_start >= LOG_RING_SIZE * 2) { - truncated = true; - break; - } - char c = entry[i]; - if (c == '"') { buf[pos++] = '\\'; buf[pos++] = '"'; } - else if (c == '\\') { buf[pos++] = '\\'; buf[pos++] = '\\'; } - else if (c == '\n') { buf[pos++] = '\\'; buf[pos++] = 'n'; } - else if (c == '\r') { buf[pos++] = '\\'; buf[pos++] = 'r'; } - else if (c == '\t') { buf[pos++] = '\\'; buf[pos++] = 't'; } - else if ((unsigned char)c >= 0x20) { buf[pos++] = c; } + pos = jsonEscapeAppend(buf, pos, capacity, entry, entry_len); + if (pos < capacity - 1) { + buf[pos++] = '"'; + } else if (pos < capacity) { + buf[pos - 1] = '"'; /* 极端:覆盖末字节闭合 */ } - if (truncated) { - // 确保 "..." + '"' + ']' + '\0' (共 6 字节) 有空间才写入标记 - if (pos + 6 <= capacity) { - buf[pos++] = '.'; - buf[pos++] = '.'; - buf[pos++] = '.'; - } - } - buf[pos++] = '"'; } - buf[pos++] = ']'; - buf[pos] = '\0'; + + if (pos + 1 < capacity) { + buf[pos++] = ']'; + buf[pos] = '\0'; + } else if (capacity >= 3) { + buf[capacity - 2] = ']'; + buf[capacity - 1] = '\0'; + pos = capacity - 1; + } + + xSemaphoreGive(m_logRingMutex); + *out_len = pos; return buf; } -/** - * @brief 向环形日志缓冲区中推送一条日志数据。 - * @param data 日志数据指针。 - * @param len 数据长度。 - */ -void DebugLog::ringPush(const char *data, int len) -{ - if (!m_logRingMutex || len <= 0) return; - - xSemaphoreTake(m_logRingMutex, portMAX_DELAY); - - int copy_len = len; - if (copy_len >= LOG_RING_SIZE) copy_len = LOG_RING_SIZE - 1; - - memcpy(m_logRing[m_logRingHead], data, copy_len); - m_logRing[m_logRingHead][copy_len] = '\0'; - m_logRingLens[m_logRingHead] = copy_len; - - m_logRingHead = (m_logRingHead + 1) % LOG_RING_SLOTS; - if (m_logRingCount < LOG_RING_SLOTS) m_logRingCount++; - - xSemaphoreGive(m_logRingMutex); -} - // ============================================================ -// UART 调试日志 +// UART // ============================================================ -/** - * @brief 初始化 UART0 调试串口。 - * @return esp_err_t 错误码。 - */ esp_err_t DebugLog::uartInit() { if (m_uartInitialized) { @@ -248,7 +414,8 @@ esp_err_t DebugLog::uartInit() esp_err_t ret = uart_driver_install(uart_port, 1024, 0, 0, NULL, 0); if (ret != ESP_OK) { - ESP_LOGE(TAG, "UART0 驱动安装失败: %s", esp_err_to_name(ret)); + /* 可能 console 已占用 UART0——降级为 ring-only */ + ESP_LOGW(TAG, "UART0 驱动安装失败(可能 console 占用): %s", esp_err_to_name(ret)); return ret; } @@ -266,49 +433,34 @@ esp_err_t DebugLog::uartInit() } m_uartInitialized = true; - // 标记 UART 真正可用,send() 依据此标志决定是否调用 uart_write_bytes m_uartAvailable = true; ESP_LOGD(TAG, "UART0 调试输出已初始化 (TX=GPIO%d, RX=GPIO%d, %d baud)", UART_DEBUG_TX_GPIO, UART_DEBUG_RX_GPIO, UART_DEBUG_BAUD_RATE); - return ESP_OK; } -/** - * @brief 通过 UART0 直接发送原始字节数据。 - * @param data 待发送数据指针。 - * @param len 数据长度。 - * @return esp_err_t 错误码。 - */ esp_err_t DebugLog::uartSend(const uint8_t *data, size_t len) { - if (!m_uartInitialized) { + if (!m_uartInitialized || !m_uartAvailable) { return ESP_ERR_INVALID_STATE; } - int ret = uart_write_bytes(UART_NUM_0, data, len); + const int ret = uart_write_bytes(UART_NUM_0, data, len); if (ret < 0) { return ESP_FAIL; } - return ESP_OK; } // ============================================================ -// 初始化(UART 立即可用,UDP socket 延迟到首次发送时创建) +// 初始化 / UDP // ============================================================ -/** - * @brief 初始化日志系统,包括 UART 输出、UDP 目标和内部缓冲区。 - * @return esp_err_t 错误码。 - */ esp_err_t DebugLog::init() { if (m_initialized) { return ESP_OK; } - // 检查 uartInit 返回值;失败时仍让 ring buffer 模式工作, - // 但记录"UART 不可用"日志,并在 send() 中跳过 uart_write_bytes 调用。 esp_err_t uart_ret = uartInit(); if (uart_ret != ESP_OK) { ESP_LOGW(TAG, "UART 不可用,仅 ring buffer 模式 (err=%s)", esp_err_to_name(uart_ret)); @@ -316,8 +468,13 @@ esp_err_t DebugLog::init() } m_logRingMutex = xSemaphoreCreateMutex(); + if (!m_logRingMutex) { + ESP_LOGE(TAG, "ring mutex 创建失败,HTTP /logs 将不可用"); + /* 仍标记 initialized,避免 log() 反复 init;UART 路径可工作 */ + m_initialized = true; + return ESP_ERR_NO_MEM; + } - // 目标地址:单播到上位机 memset(&m_debugAddr, 0, sizeof(m_debugAddr)); m_debugAddr.sin_family = AF_INET; m_debugAddr.sin_port = htons(DEBUG_LOG_PORT); @@ -325,121 +482,173 @@ esp_err_t DebugLog::init() m_initialized = true; ESP_LOGI(TAG, "调试日志已初始化,上位机 %s:%d", DEBUG_LOG_IP, DEBUG_LOG_PORT); - return ESP_OK; } -/** - * @brief 确保 UDP socket 已创建(延迟创建)。 - * @return esp_err_t 错误码。 - */ +bool DebugLog::udpNetifHasIp() const +{ + esp_netif_ip_info_t info = {}; + esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (sta && esp_netif_get_ip_info(sta, &info) == ESP_OK && info.ip.addr != 0) { + return true; + } + esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + if (ap && esp_netif_get_ip_info(ap, &info) == ESP_OK && info.ip.addr != 0) { + return true; + } + return false; +} + +void DebugLog::closeUdpSocket() +{ + int s = -1; + portENTER_CRITICAL(&m_udpMux); + s = m_udpSock; + m_udpSock = -1; + portEXIT_CRITICAL(&m_udpMux); + if (s >= 0) { + close(s); + } +} + esp_err_t DebugLog::ensureUdpSocket() { + portENTER_CRITICAL(&m_udpMux); if (m_udpSock >= 0) { + portEXIT_CRITICAL(&m_udpMux); return ESP_OK; } + portEXIT_CRITICAL(&m_udpMux); - m_udpSock = socket(AF_INET, SOCK_DGRAM, 0); - if (m_udpSock < 0) { - ESP_LOGE(TAG, "创建 UDP socket 失败"); + const int s = socket(AF_INET, SOCK_DGRAM, 0); + if (s < 0) { + ESP_LOGE(TAG, "创建 UDP socket 失败 errno=%d", errno); return ESP_FAIL; } - ESP_LOGI(TAG, "UDP socket 创建成功"); + /* 避免 sendto 在无路由/对端不可达时长时间阻塞日志路径 */ + struct timeval tv = {}; + tv.tv_sec = kUdpSndTimeoutMs / 1000; + tv.tv_usec = (kUdpSndTimeoutMs % 1000) * 1000; + (void)setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + portENTER_CRITICAL(&m_udpMux); + if (m_udpSock >= 0) { + /* 竞态:另一任务已建好 */ + portEXIT_CRITICAL(&m_udpMux); + close(s); + return ESP_OK; + } + m_udpSock = s; + portEXIT_CRITICAL(&m_udpMux); + + ESP_LOGI(TAG, "UDP socket 创建成功 (SNDTIMEO=%dms)", kUdpSndTimeoutMs); return ESP_OK; } -/** - * @brief 发送原始日志数据到底层输出通道。 - * @param data 待发送数据指针。 - * @param len 数据长度。 - * @return esp_err_t 错误码。 - */ esp_err_t DebugLog::send(const uint8_t *data, size_t len) { + if (!data || len == 0) { + return ESP_ERR_INVALID_ARG; + } + esp_err_t udp_ret = ESP_FAIL; esp_err_t uart_ret = ESP_FAIL; const uint32_t free_now = esp_get_free_heap_size(); - // UART 通道(UART 不可用时跳过 uart_write_bytes 调用) if (m_uartAvailable) { uart_ret = uartSend(data, len); } - /* UDP 通道:堆紧时跳过 sendto,避免申请 LWIP TX pbuf 与 HTTP 回包抢堆。 - * WiFi 未连接时 sendto 失败也静默跳过。 - * ★ 修复:堆不足跳过时写一条告警到 ring buffer,让前端 /logs 可见。 */ - if (m_initialized && free_now >= kSkipUdpFree) { + /* UDP:堆紧 / 无 IP 时跳过 sendto */ + if (m_initialized && free_now >= kSkipUdpFree && udpNetifHasIp()) { if (m_udpSock < 0) { - ensureUdpSocket(); + (void)ensureUdpSocket(); } - if (m_udpSock >= 0) { - size_t send_len = len > 1024 ? 1024 : len; - int ret = ::sendto(m_udpSock, data, send_len, 0, - (struct sockaddr *)&m_debugAddr, sizeof(m_debugAddr)); + int sock = -1; + portENTER_CRITICAL(&m_udpMux); + sock = m_udpSock; + portEXIT_CRITICAL(&m_udpMux); + + if (sock >= 0) { + const size_t send_len = len > 1024 ? 1024 : len; + const int ret = ::sendto(sock, data, send_len, 0, + (struct sockaddr *)&m_debugAddr, sizeof(m_debugAddr)); if (ret >= 0) { udp_ret = ESP_OK; + m_udpFailStreak = 0; + } else { + if (m_udpFailStreak < 255) { + m_udpFailStreak++; + } + if (m_udpFailStreak >= kUdpFailRecreate) { + closeUdpSocket(); + m_udpFailStreak = 0; + const TickType_t now = xTaskGetTickCount(); + if (m_lastUdpFailWarnTick == 0 || + (now - m_lastUdpFailWarnTick) >= warnIntervalTicks()) { + m_lastUdpFailWarnTick = now; + static const char kMsg[] = "[W][LOG] UDP 连续失败,已重建 socket\n"; + ringPush(kMsg, (int)sizeof(kMsg) - 1); + } + } } } - } else if (m_initialized) { - /* 堆不足导致 UDP 被跳过——通过 ring buffer 记录,避免上位机无法感知 */ - static uint32_t s_lastUdpSkipWarnMs = 0; - uint32_t now_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); - /* 限制告警频率:每 10 秒最多一条,避免刷屏 */ - if ((now_ms - s_lastUdpSkipWarnMs) > 10000) { - s_lastUdpSkipWarnMs = now_ms; + } else if (m_initialized && free_now < kSkipUdpFree) { + const TickType_t now = xTaskGetTickCount(); + if (m_lastUdpSkipWarnTick == 0 || + (now - m_lastUdpSkipWarnTick) >= warnIntervalTicks()) { + m_lastUdpSkipWarnTick = now; static const char kUdpSkipMsg[] = "[W][LOG] UDP 发送已暂停: 堆内存不足\n"; - ringPush(kUdpSkipMsg, sizeof(kUdpSkipMsg) - 1); + ringPush(kUdpSkipMsg, (int)sizeof(kUdpSkipMsg) - 1); } } - // 同时写入环形缓冲区(供 HTTP /logs 拉取)——静态,不占堆 ringPush((const char *)data, (int)len); if (udp_ret == ESP_OK || uart_ret == ESP_OK) { return ESP_OK; } - /* 仅 ring 成功也算可用(OTA 堆紧时常见) */ if (m_logRingMutex) { return ESP_OK; } return ESP_FAIL; } -/** - * @brief 按日志级别与标签格式化输出一条日志。 - * @param level 日志级别。 - * @param tag 日志标签。 - * @param fmt 格式化字符串。 - * @param ... 可变参数。 - */ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, ...) { if (!m_initialized) { - init(); + (void)init(); } - if (level > logLevel) { + if (level > m_logLevel) { return; } - /* 堆门控:OTA/SPI 的 INFO 必须进 ring,否则 WebUI 只能看到“沉默失败”。 */ const uint32_t free_now = esp_get_free_heap_size(); - const bool keep_ota_spi = (tag != nullptr && - (strcmp(tag, "OTA") == 0 || strcmp(tag, "SPI") == 0)); - if (!keep_ota_spi) { - if (free_now < kDropWarnFree && level > DEBUG_LOG_ERR) { - return; // 无关模块:仅 ERR + const bool keep = isKeepAliveTag(tag); + + /* + * 堆门控: + * - ERR/WARN:永不因堆丢弃(排障必需) + * - keep-alive(OTA 前缀 / SPI / WIFI):INFO 保留;极紧时仍丢 DBG + * - 其它:free < kDropInfoFree 丢 INFO/DBG + */ + if (level >= DEBUG_LOG_INFO) { + if (level >= DEBUG_LOG_DBG && free_now < kDropWarnFree) { + return; } - if (free_now < kDropInfoFree && level > DEBUG_LOG_WARN) { - return; // 无关模块:丢 INFO/DBG + if (!keep) { + if (free_now < kDropInfoFree) { + return; + } + } else if (level >= DEBUG_LOG_DBG && free_now < kDropInfoFree) { + /* keep-alive DBG:中等堆紧也丢,减刷屏 */ + return; } - } else if (free_now < kDropWarnFree && level >= DEBUG_LOG_DBG) { - return; // OTA/SPI:极紧时仍丢 DBG,INFO/WARN/ERR 保留 } - char prefix[24]; const char *level_str; switch (level) { case DEBUG_LOG_ERR: level_str = "E"; break; @@ -448,29 +657,47 @@ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, .. case DEBUG_LOG_DBG: level_str = "D"; break; default: level_str = "?"; break; } - snprintf(prefix, sizeof(prefix), "[%s][%s]", level_str, tag); - /* 栈缓冲与 LOG_RING_SIZE 对齐,减轻 httpd 等任务栈压。 */ - va_list args; - va_start(args, fmt); - char msg[160]; - vsnprintf(msg, sizeof(msg), fmt, args); - va_end(args); - - char log_buf[192]; - int total = snprintf(log_buf, sizeof(log_buf), "%s %s\n", prefix, msg); - - // ★ snprintf 截断时返回"本应写入的长度"(可能 > sizeof(log_buf)), - // 直接传给 send() 会越界读取栈外内存 → 破坏相邻任务 TCB → "Stack protection fault" - // (崩溃栈中出现 HTTP User-Agent / "/ota/relay" 等字符串即此症状)。 - // 截断到实际缓冲区大小,避免越界读。 - if (total < 0) { + /* 单缓冲:与 LOG_RING_SIZE 对齐,避免 msg[]+log_buf[] 双栈 */ + char log_buf[LOG_RING_SIZE]; + const char *safe_tag = tag ? tag : "?"; + int prefix_len = snprintf(log_buf, sizeof(log_buf), "[%s][%s] ", level_str, safe_tag); + if (prefix_len < 0) { return; } - if ((size_t)total >= sizeof(log_buf)) { - total = (int)sizeof(log_buf) - 1; - log_buf[total] = '\0'; + if ((size_t)prefix_len >= sizeof(log_buf)) { + /* 前缀已截断:保证可发送的以 \\n 结尾的串 */ + log_buf[sizeof(log_buf) - 2] = '\n'; + log_buf[sizeof(log_buf) - 1] = '\0'; + (void)send((const uint8_t *)log_buf, sizeof(log_buf) - 1); + return; } - send((const uint8_t *)log_buf, total); + va_list args; + va_start(args, fmt); + int body = vsnprintf(log_buf + prefix_len, sizeof(log_buf) - (size_t)prefix_len, fmt, args); + va_end(args); + if (body < 0) { + return; + } + + size_t used = (size_t)prefix_len + (size_t)body; + if (used >= sizeof(log_buf)) { + used = sizeof(log_buf) - 1; + log_buf[used] = '\0'; + } + + /* 保证换行结尾(截断时覆盖末字节) */ + if (used == 0 || log_buf[used - 1] != '\n') { + if (used + 1 < sizeof(log_buf)) { + log_buf[used++] = '\n'; + log_buf[used] = '\0'; + } else { + log_buf[sizeof(log_buf) - 2] = '\n'; + log_buf[sizeof(log_buf) - 1] = '\0'; + used = sizeof(log_buf) - 1; + } + } + + (void)send((const uint8_t *)log_buf, used); } diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index b8446e6..a9c0581 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -8,8 +8,11 @@ * * 注意:当前 tools/WebUI 日志面板走的是 HTTP /logs, * 不是直接监听 UDP 6666。 - * + * * UART0: TX=GPIO21, RX=GPIO20, 115200 波特率 + * + * 线程模型:DEBUG_* / log() / send() 仅允许在任务上下文调用 + * (内部使用 portMAX_DELAY 互斥锁,禁止 ISR)。 */ #ifndef DEBUG_LOG_H #define DEBUG_LOG_H @@ -23,7 +26,7 @@ #include #include -// 调试日志配置(DEBUG_LOG_IP/PORT/ENABLED 已在 app_config.h 中定义) +// 调试日志配置(DEBUG_LOG_IP/PORT 在 app_config.h) // UART0 调试配置 #define UART_DEBUG_TX_GPIO 21 ///< UART0 TX GPIO 引脚号 @@ -46,13 +49,9 @@ typedef enum { */ class DebugLog { public: - /** - * @brief 获取 DebugLog 单例实例。 - * @return DebugLog 引用。 - */ static DebugLog& instance(); - /** @brief 初始化日志系统,包括 UART 输出、UDP 目标和内部缓冲区。 */ + /** @brief 初始化日志系统(UART + ring;UDP socket 延迟创建)。 */ esp_err_t init(); /** @brief 初始化 UART0 调试串口。 */ @@ -61,10 +60,10 @@ public: /** @brief 通过 UART0 直接发送一段原始字节数据。 */ esp_err_t uartSend(const uint8_t *data, size_t len); - /** @brief 发送原始日志数据到底层输出通道。 */ + /** @brief 发送原始日志数据到底层输出通道(任务上下文)。 */ esp_err_t send(const uint8_t *data, size_t len); - /** @brief 按日志级别与标签格式化输出一条日志。 */ + /** @brief 按日志级别与标签格式化输出一条日志(任务上下文)。 */ void log(debug_log_level_t level, const char *tag, const char *fmt, ...); /** @brief 获取当前环形日志缓冲区中的日志条数。 */ @@ -74,14 +73,14 @@ public: bool ringPopOldest(char *out, size_t out_size, int *out_len); /** - * @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。 + * @brief 将环形缓冲区日志导出为 JSON 数组字符串。 * @param out_len 输出 JSON 字符串长度。 - * @return 动态分配的 JSON 字符串,调用者须 free()。 + * @return 动态分配的 JSON 字符串,调用者须 free();失败返回 NULL。 */ char* ringDrainJson(int *out_len); - /** @brief 当前日志级别(运行时可改)。 */ - debug_log_level_t logLevel = DEBUG_LOG_INFO; + void setLogLevel(debug_log_level_t level) { m_logLevel = level; } + debug_log_level_t getLogLevel() const { return m_logLevel; } private: DebugLog() = default; @@ -90,40 +89,59 @@ private: DebugLog& operator=(const DebugLog&) = delete; int m_udpSock = -1; - struct sockaddr_in m_debugAddr; + struct sockaddr_in m_debugAddr = {}; bool m_initialized = false; bool m_uartInitialized = false; - // 记录 UART 是否真正可用(uartInit 失败时为 false,跳过 uart_write_bytes)。 - // m_uartInitialized 仅表示曾尝试初始化,m_uartAvailable 才是写日志的判据。 + /** uartInit 成功后才为 true;send() 据此跳过 uart_write_bytes */ bool m_uartAvailable = false; + debug_log_level_t m_logLevel = DEBUG_LOG_INFO; /* 环形日志:OTA 中继期需保留关键 INFO,64×160≈10KB BSS。 */ static constexpr int LOG_RING_SLOTS = 64; static constexpr int LOG_RING_SIZE = 160; - char m_logRing[LOG_RING_SLOTS][LOG_RING_SIZE]; - int m_logRingLens[LOG_RING_SLOTS]; + /** 单次 /logs drain 上限(与 SLOTS 更接近,减 burst 滞后) */ + static constexpr int LOG_DRAIN_MAX = 48; + char m_logRing[LOG_RING_SLOTS][LOG_RING_SIZE] = {}; + int m_logRingLens[LOG_RING_SLOTS] = {}; int m_logRingHead = 0; int m_logRingCount = 0; - SemaphoreHandle_t m_logRingMutex = NULL; + SemaphoreHandle_t m_logRingMutex = nullptr; /* 堆门控(防 OTA 时 UDP/格式化抢 LWIP 堆): - * free < kDropWarnFree → 非 OTA/SPI 只保留 ERR - * free < kDropInfoFree → 非 OTA/SPI 丢 INFO/DBG(OTA/SPI 的 INFO 仍进 ring) + * ERR/WARN 永不因堆门控丢弃 + * free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO + * free < kDropWarnFree → 全局丢 DBG * free < kSkipUdpFree → 不 sendto(仍 UART+ring) - * free < kDrainMinFree → /logs 尽量仍返回 ring(降低门槛,避免“沉默失败”) */ + * free < kDrainMinFree → /logs 返 "[]" 避免大块 malloc + * keep-alive tag:OTA 前缀、SPI、WIFI */ static constexpr uint32_t kDropWarnFree = 6U * 1024U; static constexpr uint32_t kDropInfoFree = 10U * 1024U; - static constexpr uint32_t kSkipUdpFree = 6U * 1024U; // ★ 修复:从 10KB 降至 6KB,sendto 本身不需大块堆 - static constexpr uint32_t kDrainMinFree = 4U * 1024U; // ★ 修复:从 8KB 降至 4KB,降低 /logs 返空的概率 + static constexpr uint32_t kSkipUdpFree = 6U * 1024U; + static constexpr uint32_t kDrainMinFree = 4U * 1024U; + + /** UDP 跳过/失败告警节流与 socket 重建 */ + TickType_t m_lastUdpSkipWarnTick = 0; + TickType_t m_lastUdpFailWarnTick = 0; + uint8_t m_udpFailStreak = 0; + portMUX_TYPE m_udpMux = portMUX_INITIALIZER_UNLOCKED; esp_err_t ensureUdpSocket(); + void closeUdpSocket(); + bool udpNetifHasIp() const; void ringPush(const char *data, int len); + void ringPushLocked(const char *data, int len); + bool ringPopOldestLocked(char *out, size_t out_size, int *out_len); + int ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const; + + static bool isKeepAliveTag(const char *tag); + static char *allocEmptyJson(int *out_len); + static int jsonEscapeAppend(char *buf, int pos, int capacity, const char *src, int src_len); }; -// 便捷宏 — 直接调用单例方法 -#define DEBUG_ERR(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_ERR, tag, fmt, ##__VA_ARGS__) ///< 错误级别日志宏 -#define DEBUG_WARN(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_WARN, tag, fmt, ##__VA_ARGS__) ///< 警告级别日志宏 -#define DEBUG_INFO(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_INFO, tag, fmt, ##__VA_ARGS__) ///< 信息级别日志宏 -#define DEBUG_DBG(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_DBG, tag, fmt, ##__VA_ARGS__) ///< 调试级别日志宏 +// 便捷宏 — 直接调用单例方法(禁止在 ISR 中使用) +#define DEBUG_ERR(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_ERR, tag, fmt, ##__VA_ARGS__) +#define DEBUG_WARN(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_WARN, tag, fmt, ##__VA_ARGS__) +#define DEBUG_INFO(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_INFO, tag, fmt, ##__VA_ARGS__) +#define DEBUG_DBG(tag, fmt, ...) DebugLog::instance().log(DEBUG_LOG_DBG, tag, fmt, ##__VA_ARGS__) #endif // DEBUG_LOG_H diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index be3ac18..64d4e7b 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -32,7 +32,7 @@ // 避免 5 秒巡检刷屏。health_mon 任务从不被 OTA 暂停,DEBUG_ERR 经 UDP/UART // 直发,不受 OTA 门控抑制。 static constexpr float STACK_WARN_RATIO = 0.20f; ///< 剩余水位告警阈值(栈大小比例) -static constexpr size_t STACK_TASK_MAX = 8; ///< 受监控任务上限 +static constexpr size_t STACK_TASK_MAX = 12; ///< 受监控任务上限(含 spi_worker 等) static bool s_stackWarned[STACK_TASK_MAX] = {}; ///< 各任务是否已告警(去重) // ============================================================ diff --git a/main/monitor/task_stack_check.cpp b/main/monitor/task_stack_check.cpp index 4477999..ac02cde 100644 --- a/main/monitor/task_stack_check.cpp +++ b/main/monitor/task_stack_check.cpp @@ -12,6 +12,7 @@ #include "ota_relay.h" #include "wifi_ota.h" #include "ble_ota.h" +#include "spi_master.h" size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max) { @@ -30,9 +31,13 @@ size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max) if (n < max) { out[n++] = { "cpu_stats", CpuStats::instance().getTaskHandle(), TASK_STACK_CPU_STATS }; } + /* 转发热路径:ota_relay(业务)+ spi_worker(帧缓冲,此前监控缺口) */ if (n < max) { out[n++] = { "ota_relay", OtaRelay::getTaskHandle(), TASK_STACK_OTA_RELAY }; } + if (n < max) { + out[n++] = { "spi_worker", SpiMaster::instance().getWorkerTaskHandle(), TASK_STACK_SPI_WORKER }; + } if (n < max) { out[n++] = { "wifi_ota", WifiOta::instance().getTaskHandle(), TASK_STACK_WIFI_OTA }; } diff --git a/main/monitor/task_stack_check.h b/main/monitor/task_stack_check.h index 665374d..db379fd 100644 --- a/main/monitor/task_stack_check.h +++ b/main/monitor/task_stack_check.h @@ -5,6 +5,8 @@ * - health_monitor 巡检主动告警(低水位时 DEBUG_ERR,突破 OTA 门控) * - http_handlers /system/status 被动报告(前端每秒轮询展示) * 两处复用同一份任务表,避免散落维护。 + * + * 表内含转发关键:ota_relay、spi_worker(帧缓冲热路径)。 */ #pragma once diff --git a/main/ota/esp32_ota_core.cpp b/main/ota/esp32_ota_core.cpp index ed973ae..a223139 100644 --- a/main/ota/esp32_ota_core.cpp +++ b/main/ota/esp32_ota_core.cpp @@ -172,36 +172,9 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) return beginCleanupAndReturn(ESP_FAIL); } - /* ★ 整分区预擦(非 SEQUENTIAL 边写边擦): - * 1) 扇区循环 erase + 喂狗/yield,避免 TASK_WDT 与 WiFi 饿死 - * 2) esp_ota_resume(erase_size≠SEQUENTIAL) → need_erase=false,写路径纯 write - * 3) 显式 invalidate inactive slot(resume 不像 begin 那样自动做) - * 预擦会在 begin 阶段占用数秒~数十秒,之后收流写 Flash 更快更稳。 */ - const size_t part_size = m_partition->size; - const size_t sector = m_partition->erase_size ? m_partition->erase_size : 4096U; - DEBUG_INFO(TAG, "整分区预擦: %s size=%u sector=%u free=%lu...", - m_partition->label, (unsigned)part_size, (unsigned)sector, - (unsigned long)esp_get_free_heap_size()); - const int64_t t0 = esp_timer_get_time(); - for (size_t off = 0; off < part_size; ) { - size_t chunk = sector; - if (off + chunk > part_size) { - chunk = part_size - off; - } - esp_err_t erase_err = esp_partition_erase_range(m_partition, off, chunk); - if (erase_err != ESP_OK) { - DEBUG_ERR(TAG, "整分区预擦失败 @%u: %s", - (unsigned)off, esp_err_to_name(erase_err)); - return beginCleanupAndReturn(erase_err); - } - off += chunk; - (void)esp_task_wdt_reset(); - taskYIELD(); - } - const int64_t t1 = esp_timer_get_time(); - DEBUG_INFO(TAG, "整分区预擦完成 耗时 %lld ms", - (long long)((t1 - t0) / 1000)); - + /* ★ 边写边擦(OTA_WITH_SEQUENTIAL_WRITES),不再整分区预擦: + * 旧方案在 HTTP 只收到 JSON 头后同步擦 1.25MB,浏览器 TCP 窗口堵死、进度卡开头。 + * sequential:begin 立即返回,擦除摊到 esp_ota_write,与收流并行(对 WiFi 更友好)。 */ #if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) || defined(CONFIG_APP_ROLLBACK_ENABLE) { esp_err_t inv = esp_ota_invalidate_inactive_ota_data_slot(); @@ -212,17 +185,16 @@ esp_err_t Esp32OtaCore::begin(size_t total_size) } #endif - /* erase_size 传 0(≠ OTA_WITH_SEQUENTIAL_WRITES)→ 写时不再擦扇区 */ - esp_err_t err = esp_ota_resume(m_partition, /*erase_size=*/0, - /*image_offset=*/0, &m_handle); - const int64_t t2 = esp_timer_get_time(); + const int64_t t0 = esp_timer_get_time(); + esp_err_t err = esp_ota_begin(m_partition, OTA_WITH_SEQUENTIAL_WRITES, &m_handle); + const int64_t t1 = esp_timer_get_time(); if (err != ESP_OK) { - DEBUG_ERR(TAG, "esp_ota_resume 失败: %s (耗时 %lld ms)", - esp_err_to_name(err), (long long)((t2 - t1) / 1000)); + DEBUG_ERR(TAG, "esp_ota_begin(SEQUENTIAL) 失败: %s (耗时 %lld ms)", + esp_err_to_name(err), (long long)((t1 - t0) / 1000)); return beginCleanupAndReturn(err); } - DEBUG_INFO(TAG, "esp_ota_resume 完成 耗时 %lld ms free=%lu", - (long long)((t2 - t1) / 1000), + DEBUG_INFO(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu", + (long long)((t1 - t0) / 1000), (unsigned long)esp_get_free_heap_size()); m_received = 0; @@ -231,7 +203,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 字节 (full-partition erase)", + DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase-on-write)", m_partition->label, (unsigned)total_size); return ESP_OK; } diff --git a/main/ota/esp32_ota_core.h b/main/ota/esp32_ota_core.h index fee1fa8..8b9a92f 100644 --- a/main/ota/esp32_ota_core.h +++ b/main/ota/esp32_ota_core.h @@ -5,7 +5,7 @@ * begin → write*(N) → finalize / abort * * 统一以 WiFi 流程为基准: - * - begin:整分区预擦后 esp_ota_resume(写路径不再边写边擦) + * - begin:esp_ota_begin(OTA_WITH_SEQUENTIAL_WRITES) 边写边擦(避免 HTTP 头卡预擦) * - SHA-256 在 finalize 时从 OTA 分区读回计算(不在上传期间增量计算) * - finalize 顺序: 读回 SHA-256 → 比对前端 SHA → esp_ota_end → esp_ota_set_boot_partition → 可选重启 * - m_ended 标志位防止 esp_ota_end 成功后再次 esp_ota_abort 同一 handle diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 81a8db2..09d38fd 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -9,7 +9,6 @@ #include "esp32_ota_core.h" #include "relay/ota_relay.h" #include "storage/gd32_flash_store.h" -#include "transport/spi_master.h" #include "server/ble_ota.h" #include "monitor/debug_log.h" #include "esp_system.h" @@ -57,8 +56,8 @@ bool OtaCoordinator::lock() const if (!m_mutex) return false; bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE); if (!ok) { - DEBUG_WARN(TAG, "lock() 超时 (%u ms),本次状态变更被跳过", - (unsigned)pdTICKS_TO_MS(kLockTimeout)); + DEBUG_ERR(TAG, "lock() 超时 (%u ms),本次状态变更被跳过", + (unsigned)pdTICKS_TO_MS(kLockTimeout)); } return ok; } @@ -76,16 +75,16 @@ void OtaCoordinator::unlock(bool handle) const // ============================================================ void OtaCoordinator::watchdogCheckLocked() { - // 仅对"忙"状态(可能卡死的)做检查 + // 仅对"忙"状态(可能卡死的)做检查。 + // REBOOTING:已 set_boot,即将 esp_restart,不入狗,避免 abort 成 IDLE 与重启竞态。 switch (m_sysState) { case OTA_SYS_GD32_STORING: case OTA_SYS_GD32_RELAYING: case OTA_SYS_ESP32_RECEIVING: case OTA_SYS_ESP32_FINALIZING: - case OTA_SYS_ESP32_REBOOTING: break; default: - return; // IDLE/STORED/COMPLETE/ERROR 无需检查 + return; // IDLE/STORED/COMPLETE/ERROR/REBOOTING 无需检查 } if (m_busySinceTick == 0) { @@ -162,9 +161,7 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch return ESP_ERR_INVALID_STATE; } DEBUG_WARN(TAG, "GD32 存储仍在进行中(同通道),中止旧存储并重新开始"); - Gd32FlashStore::instance().metaClear(); - m_sysState = OTA_SYS_IDLE; - m_busySinceTick = 0; + doAbortLocked(); // 清 meta + IDLE,与 abort 路径一致 // epoch 将在下方设置 GD32_STORING 后递增 } @@ -221,27 +218,17 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t return err; } - // 3. 整分区预擦(不再边写边擦):prepare 阶段擦完 gd32_ota 分区, - // 收流时 flashWriteChunk 仅 write,吞吐更稳。会阻塞数秒~数十秒。 + // 3. 复位擦除水位;不在 prepare 整区预擦。 + // 旧方案在 HTTP JSON 头后同步擦 gd32_ota,TCP 堵在开头。 + // 现由 flashWriteChunk → ensureErasedForWrite 按需擦扇区,与收流交织。 Gd32FlashStore::instance().beginWriteSession(); - { - const esp_partition_t *gd32_part = Gd32FlashStore::instance().getPartition(); - const uint32_t erase_bytes = gd32_part ? (uint32_t)gd32_part->size : 0U; - if (erase_bytes == 0U) { - DEBUG_ERR(TAG, "prepareGd32Storage: 找不到 gd32_ota 分区"); - abortCurrent(); - return ESP_ERR_NOT_FOUND; - } - DEBUG_INFO(TAG, "prepareGd32Storage: 整分区预擦 %u 字节 (fw=%" PRIu32 ")...", - (unsigned)erase_bytes, total_size); - err = Gd32FlashStore::instance().erasePartition(erase_bytes); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "prepareGd32Storage: 整分区预擦失败: %s", esp_err_to_name(err)); - abortCurrent(); - return err; - } - DEBUG_INFO(TAG, "prepareGd32Storage: 整分区预擦完成"); + if (Gd32FlashStore::instance().getPartition() == nullptr) { + DEBUG_ERR(TAG, "prepareGd32Storage: 找不到 gd32_ota 分区"); + abortCurrent(); + return ESP_ERR_NOT_FOUND; } + DEBUG_INFO(TAG, "prepareGd32Storage: 会话就绪 fw=%" PRIu32 "(按需擦除,无整区预擦)", + total_size); // 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0) clearGd32Meta(); @@ -284,55 +271,39 @@ esp_err_t OtaCoordinator::finishGd32StorageAfterReceive() return finalizeGd32Checksum(); } -// ============================================================ -// GD32 存储操作(传输层统一入口) -// ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到 -// Gd32FlashStore::instance(),消除跨 TU 调用开销。 -// ============================================================ - // ============================================================ // GD32 中继阶段 // ============================================================ esp_err_t OtaCoordinator::triggerGd32Relay() { bool h = lock(); + if (!h) { + DEBUG_ERR(TAG, "triggerGd32Relay: lock 超时"); + return ESP_ERR_INVALID_STATE; + } - // ★ 卡死自愈检查 - if (h) watchdogCheckLocked(); + watchdogCheckLocked(); - // 校验:必须处于"已存储"或"已中继完成"状态 - if (h && m_sysState != OTA_SYS_GD32_STORED && m_sysState != OTA_SYS_IDLE) { - if (m_sysState == OTA_SYS_GD32_STORING) { - DEBUG_WARN(TAG, "GD32 正在存储中,拒绝触发中继"); - unlock(h); - return ESP_ERR_INVALID_STATE; - } - if (m_sysState == OTA_SYS_GD32_RELAYING) { - DEBUG_WARN(TAG, "GD32 中继已在进行中"); - unlock(h); - return ESP_ERR_INVALID_STATE; - } - if (m_sysState == OTA_SYS_ESP32_RECEIVING || - m_sysState == OTA_SYS_ESP32_FINALIZING || - m_sysState == OTA_SYS_ESP32_REBOOTING) { - DEBUG_WARN(TAG, "ESP32 OTA 进行中,拒绝中继触发"); - unlock(h); - return ESP_ERR_INVALID_STATE; - } + /* 白名单:仅 STORED / IDLE(NVS 有完整固件)/ COMPLETE(允许再中继)可触发 */ + if (m_sysState != OTA_SYS_GD32_STORED && + m_sysState != OTA_SYS_IDLE && + m_sysState != OTA_SYS_GD32_COMPLETE) { + DEBUG_WARN(TAG, "拒绝触发中继: 当前状态 %s (%d) 不在白名单", + stateToString(m_sysState), (int)m_sysState); + unlock(h); + return ESP_ERR_INVALID_STATE; } - // 验证固件是否确实已存储(通过 NVS 元数据) ota_meta_t meta; - if (h && (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || + if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || meta.total_size == 0 || - meta.stored_size < meta.total_size)) { + meta.stored_size < meta.total_size) { DEBUG_WARN(TAG, "GD32 固件未完整存储,拒绝触发中继"); unlock(h); return ESP_ERR_INVALID_STATE; } - // 检查 OtaRelay 是否空闲 - if (h && OtaRelay::getState() != RELAY_IDLE && + if (OtaRelay::getState() != RELAY_IDLE && OtaRelay::getState() != RELAY_DONE && OtaRelay::getState() != RELAY_ERROR) { DEBUG_WARN(TAG, "OtaRelay 正忙 (state=%d),拒绝触发", (int)OtaRelay::getState()); @@ -340,47 +311,42 @@ esp_err_t OtaCoordinator::triggerGd32Relay() return ESP_ERR_INVALID_STATE; } - // 向 OtaRelay task 发送启动消息 QueueHandle_t dq = OtaRelay::getDispatchQueue(); - if (h && !dq) { + if (!dq) { DEBUG_ERR(TAG, "OtaRelay dispatchQueue 未就绪"); unlock(h); return ESP_ERR_INVALID_STATE; } - if (!h) { - unlock(h); - return ESP_ERR_INVALID_STATE; - } - - m_sysState = OTA_SYS_GD32_RELAYING; - m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻 + /* 先入队再标 RELAYING:失败时状态与 CAN 反馈都不变 */ pauseCanFeedbackTask(true); - DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING"); - uint32_t cmd = 1; BaseType_t ret = xQueueSend(dq, &cmd, pdMS_TO_TICKS(100)); if (ret != pdTRUE) { DEBUG_ERR(TAG, "向 OtaRelay 发送启动消息失败"); - m_sysState = OTA_SYS_GD32_STORED; - m_busySinceTick = 0; // 回退到 STORED(非忙) + pauseCanFeedbackTask(false); unlock(h); return ESP_FAIL; } + m_sysState = OTA_SYS_GD32_RELAYING; + m_busySinceTick = xTaskGetTickCount(); + DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING"); unlock(h); return ESP_OK; } -/** @brief 通知协调器:GD32 中继已开始,更新系统状态为 GD32_RELAYING。 */ +/** @brief 中继 task 已真正开始:若 trigger 已标 RELAYING 则只补 pause;否则兜底设状态。 */ void OtaCoordinator::notifyRelayStarted() { bool h = lock(); - if (h && m_sysState != OTA_SYS_GD32_RELAYING) { - m_sysState = OTA_SYS_GD32_RELAYING; - m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻 + if (h) { + if (m_sysState != OTA_SYS_GD32_RELAYING) { + m_sysState = OTA_SYS_GD32_RELAYING; + m_busySinceTick = xTaskGetTickCount(); + DEBUG_INFO(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING"); + } pauseCanFeedbackTask(true); - DEBUG_INFO(TAG, "中继已开始, 系统状态 → GD32_RELAYING"); } unlock(h); } @@ -466,9 +432,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann return ESP_ERR_INVALID_STATE; } DEBUG_WARN(TAG, "ESP32 OTA 仍在接收中(同通道),中止旧会话并重新开始"); - Esp32OtaCore::instance().abort(); - m_sysState = OTA_SYS_IDLE; - m_busySinceTick = 0; + doAbortLocked(); // abort core + IDLE + 恢复 CAN 反馈 // epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效 } @@ -558,6 +522,12 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe void OtaCoordinator::setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg) { m_sysState = s; + /* REBOOTING 不参与 busy watchdog,清零起点避免误中止 */ + if (s == OTA_SYS_ESP32_REBOOTING) { + m_busySinceTick = 0; + } else if (s == OTA_SYS_ESP32_FINALIZING && m_busySinceTick == 0) { + m_busySinceTick = xTaskGetTickCount(); + } DEBUG_INFO(TAG, "%s", dbg_msg); } @@ -667,12 +637,24 @@ void OtaCoordinator::healOrphanStates() bool need_resume = false; bool h = lock(); if (h) { - watchdogCheckLocked(); + ota_system_state_t before = m_sysState; + watchdogCheckLocked(); // 超时则 doAbortLocked → IDLE // finalize 失败后 core 已空、协调器仍 FINALIZING 的假占用:status 轮询即可自愈 if (m_sysState == OTA_SYS_ESP32_FINALIZING && !Esp32OtaCore::instance().isActive()) { DEBUG_WARN(TAG, "status 路径发现 ESP32_FINALIZING 残留,自愈 → IDLE"); doAbortLocked(); + } + /* ERROR 非忙:status 轮询软清到 IDLE,避免 UI 长期卡 ERROR(新 begin 本也可覆盖) */ + if (m_sysState == OTA_SYS_ERROR) { + DEBUG_INFO(TAG, "status 路径 ERROR → IDLE(软清)"); + m_sysState = OTA_SYS_IDLE; + m_busySinceTick = 0; + m_gd32StorageDone = false; + pauseCanFeedbackTask(false); + } + // 任意自愈到 IDLE:恢复 WiFi OTA 期间挂起的 BLE(watchdog 旧路径漏 resume) + if (before != OTA_SYS_IDLE && m_sysState == OTA_SYS_IDLE) { need_resume = true; } unlock(h); @@ -806,28 +788,27 @@ OtaSnapshot OtaCoordinator::getSnapshot() const // ============================================================ // GD32 APP 探测 // ============================================================ -// 注:本函数与 OtaRelay::probeAndCacheAppAlive 逻辑相似,但因依赖方向 -// (OtaCoordinator → OtaRelay)不应调用 OtaRelay 的内部实现细节, -// 故此处保留独立实现。 esp_err_t OtaCoordinator::probeGd32AppOnce(gd32_version_t *out_version, uint32_t *out_uptime) { + /* 统一走 OtaRelay 公开探测,避免与 probeAndCacheAppAlive 双份逻辑 */ uint32_t uptime = 0; - gd32_version_t ver = {}; - - esp_err_t err = SpiMaster::instance().appPing(500, &uptime, &ver); - if (err == ESP_OK) { - OtaRelay::setGd32AppAlive(true); - OtaRelay::setGd32Version(ver); - if (out_version) *out_version = ver; - if (out_uptime) *out_uptime = uptime; + if (OtaRelay::probeAndCacheAppAlive(500, &uptime)) { + if (out_version) { + *out_version = OtaRelay::getSnapshot().version; + } + if (out_uptime) { + *out_uptime = uptime; + } DEBUG_INFO(TAG, "GD32 APP 探测成功: alive, uptime=%" PRIu32 "s", uptime); - } else { - OtaRelay::setGd32AppAlive(false); - if (out_version) *out_version = ver; - if (out_uptime) *out_uptime = 0; - DEBUG_WARN(TAG, "GD32 APP 探测失败: %s", esp_err_to_name(err)); + return ESP_OK; } - return err; + if (out_version) { + *out_version = gd32_version_t{}; + } + if (out_uptime) { + *out_uptime = 0; + } + return ESP_FAIL; } // ============================================================ @@ -835,12 +816,12 @@ esp_err_t OtaCoordinator::probeGd32AppOnce(gd32_version_t *out_version, uint32_t // ============================================================ void OtaCoordinator::reset() { + // ★ 与 abortCurrent 共用 doAbortLocked:清 Esp32OtaCore / relay / meta,勿只改枚举 bool h = lock(); if (h) { - m_sysState = OTA_SYS_IDLE; - m_gd32StorageDone = false; - m_busySinceTick = 0; // ★ 复位 - DEBUG_INFO(TAG, "协调器已完全复位"); + doAbortLocked(); + DEBUG_INFO(TAG, "协调器已完全复位(含子系统 abort)"); } unlock(h); + wifiOtaResumeBleIfNeeded(); } diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index 14b0d09..285a0de 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -124,7 +124,8 @@ public: esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel); /** - * @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 整分区预擦 + 清空 meta + 写入初始 meta。 + * @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位擦除水位 + 清空 meta + 写入初始 meta。 + * 扇区擦除由 flashWriteChunk/ensureErasedForWrite 按需进行(避免 HTTP 头卡整区预擦)。 * 收流时 write 路径只写 Flash(ensureErased 仅作水位兜底)。 * WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。 * @param total_size 固件总字节数。 @@ -337,9 +338,9 @@ public: /** * @brief 自愈孤儿忙态(供 /system/status 等轮询路径调用)。 - * - 跑忙状态 watchdog(超时完整 abort → IDLE) - * - ESP32_FINALIZING 但 Esp32OtaCore 已空闲:判定为 SHA 失败等残留,复位 IDLE - * 不影响真正正在 finalize(core.isActive())的会话。 + * - 忙状态 watchdog(超时完整 abort → IDLE;不含 REBOOTING) + * - ESP32_FINALIZING 但 core 已空闲:残留 → IDLE + * - ERROR → IDLE 软清,避免 UI 长期卡错误态 */ void healOrphanStates(); @@ -476,9 +477,7 @@ private: bool lock() const; void unlock(bool handle) const; - // ★ 卡死自愈检查:若忙状态停留超过 watchdog 超时,强制转为 ERROR。 - // 用于在中继 task 因任何原因(崩溃/SPI 死锁/看门狗复位残留)未能回调 - // notifyRelayComplete/notifyEsp32*Complete 时,让协调器自动脱离卡死状态。 + // ★ 卡死自愈检查:忙状态超时则 doAbortLocked → IDLE(不含 REBOOTING)。 // 必须在已持锁状态下调用。 void watchdogCheckLocked(); @@ -513,11 +512,12 @@ private: // BLE 共存监听器(由 WifiOta 实现并注册) IBleCoexListener *m_bleCoexListener = nullptr; - static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(100); + static constexpr TickType_t kLockTimeout = + pdMS_TO_TICKS(OTA_COORD_LOCK_TIMEOUT_MS); - // ★ 忙状态 watchdog 超时。GD32 中继理论最大耗时 ≈ 60s(全局超时) + 15s(APP存活轮询) + 1s(bootloader) ≈ 76s, - // 取 90 s,正常流程远不会触及,仅在 task 卡死时触发自愈。 - static constexpr TickType_t kBusyWatchdogTicks = pdMS_TO_TICKS(90U * 1000U); + // ★ 忙状态 watchdog:须覆盖最长中继预算;数值见 app_config.h + static constexpr TickType_t kBusyWatchdogTicks = + pdMS_TO_TICKS(OTA_COORD_BUSY_WATCHDOG_MS); }; #endif // OTA_COORDINATOR_H diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 6efcedc..6d1e8da 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -49,8 +49,8 @@ static ota_result_t commonReject(relay_event_t e, bool allowHealth = false) static constexpr uint32_t kMaxResendOnNack = 10U; // ACK 失败/轮询耗尽重发上限 static constexpr uint32_t kMaxResendOnStructError = 3U; // 结构化错误重发上限 static constexpr uint32_t kLastPacketAckSeq = 0xF0F0F0F0U; // 末包 ACK 序号约定 -// 10 秒无成功帧停刷:跨包级别的背压/卡死检测,比单包 15s 超时更早触发。 -static constexpr uint32_t kStallTimeoutMs = 8000U; +// 10 秒无成功帧停刷:跨包级别的背压/卡死检测,比单包 kAckWaitMs 更早触发。 +static constexpr uint32_t kStallTimeoutMs = 10000U; // ============================================================ // 静态成员定义 @@ -315,7 +315,6 @@ ota_result_t OtaRelay::handleSizeNeg(relay_event_t event, const uint8_t *payload m_transferTotalPackets = (meta.total_size + GD32_OTA_PACKET_SIZE - 1U) / GD32_OTA_PACKET_SIZE; m_transferCurrentSeq = 0; m_transferFlashOffset = 0; - m_transferRetryCount = 0; /* total 提前写入,前端可显示「共 N 包」;seq 保持 0 直到真正开始转发 */ setRelayTotalPackets(m_transferTotalPackets); setRelaySeq(0); @@ -452,13 +451,29 @@ ota_result_t OtaRelay::forwardFirmware() /* 中继全程禁止 worker 把 OTA ACK/WAIT 当 EVENT 偷读 */ SpiMaster::instance().setSuppressRingService(true); - // 固件完整性校验 + /* 完整性门控(避免与 /ota/relay·BLE 触发前的 verifyGd32Firmware 二次全盘 SHA): + * - meta 必须完整 stored≥total + * - checksum 非全零:触发路径已 finalize+比对,此处信任 meta,跳过再扫 Flash + * - checksum 全零:兜底做 metaVerifyChecksum(或 finalize 后再比) */ + if (meta.total_size == 0U || meta.stored_size < meta.total_size) { + DEBUG_ERR("OTA", "固件未完整存储 stored=%" PRIu32 " total=%" PRIu32, + meta.stored_size, meta.total_size); + reset(); + return OTA_RESULT_ERR_STORAGE; + } { - esp_err_t verify_err = Gd32FlashStore::instance().metaVerifyChecksum(); - if (verify_err != ESP_OK) { - DEBUG_ERR("OTA", "固件完整性校验失败,中止转发"); - reset(); - return OTA_RESULT_ERR_CRC; + static const uint8_t kZeroSha[32] = {0}; + if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) { + DEBUG_WARN("OTA", "meta.checksum 为空,中继前补做全盘 SHA 校验"); + esp_err_t verify_err = Gd32FlashStore::instance().metaVerifyChecksum(); + if (verify_err != ESP_OK) { + DEBUG_ERR("OTA", "固件完整性校验失败,中止转发"); + reset(); + return OTA_RESULT_ERR_CRC; + } + } else { + DEBUG_INFO("OTA", "跳过二次全盘 SHA(meta.checksum 已就绪,%" PRIu32 " B)", + meta.total_size); } } @@ -496,11 +511,22 @@ ota_result_t OtaRelay::forwardFirmware() // ============================================================ void OtaRelay::clearSessionCryptoState() { + /* 抗优化清零:避免编译器去掉「无读回」的敏感缓冲擦除 */ + auto wipe = [](void *p, size_t n) { + if (p == nullptr || n == 0U) { + return; + } + volatile uint8_t *v = static_cast(p); + while (n > 0U) { + *v++ = 0; + --n; + } + }; Protocol::sessionKeyClear(&m_sessionCtx); - memset(&m_sessionCtx, 0, sizeof(session_ctx_t)); - memset(m_kSession, 0, sizeof(m_kSession)); - memset(m_nonceServer, 0, sizeof(m_nonceServer)); - memset(m_gd32UidRuntime, 0, sizeof(m_gd32UidRuntime)); + wipe(&m_sessionCtx, sizeof(m_sessionCtx)); + wipe(m_kSession, sizeof(m_kSession)); + wipe(m_nonceServer, sizeof(m_nonceServer)); + wipe(m_gd32UidRuntime, sizeof(m_gd32UidRuntime)); } // ============================================================ @@ -515,7 +541,6 @@ void OtaRelay::reset() m_transferTotalPackets = 0; m_transferCurrentSeq = 0; m_transferFlashOffset = 0; - m_transferRetryCount = 0; m_transferDeadline = 0; clearSessionCryptoState(); SpiMaster::instance().setSuppressRingService(false); @@ -567,10 +592,19 @@ int OtaRelay::aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, */ void OtaRelay::logRespPayload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len) { + /* 默认 logLevel=INFO 时 DBG 不输出;勿先建 hex[193] 白占 ota_relay 栈 */ + if (DebugLog::instance().getLogLevel() < DEBUG_LOG_DBG) { + return; + } char hex[193] = {0}; size_t n = (len > 32U) ? 32U : len; - for (size_t i = 0; i < n; ++i) - snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", payload[i]); + if (payload) { + for (size_t i = 0; i < n; ++i) { + snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", payload[i]); + } + } else { + n = 0; + } DEBUG_DBG("OTA", "%s resp: type=0x%02X len=%u payload[%u]=%s%s", stage, resp_type, (unsigned)len, (unsigned)n, n > 0 ? hex : "", len > n ? "..." : ""); @@ -763,8 +797,23 @@ bool OtaRelay::parseAndVerifyChallengePayload(const uint8_t *resp_payload, size_ */ bool OtaRelay::aes128EcbEncrypt48Bytes(const uint8_t *key, const uint8_t *in, uint8_t *out) { - for (int i = 0; i < 3; ++i) - if (aes128EcbEncryptBlockLocal(key, in + i * 16, out + i * 16) != 0) return false; + /* 单次 init/setkey 加密 3 块,避免每块重建 AES 上下文(转发热路径) */ + if (!key || !in || !out) { + return false; + } + esp_aes_context ctx; + esp_aes_init(&ctx); + if (esp_aes_setkey(&ctx, key, 128) != 0) { + esp_aes_free(&ctx); + return false; + } + for (int i = 0; i < 3; ++i) { + if (esp_aes_crypt_ecb(&ctx, ESP_AES_ENCRYPT, in + i * 16, out + i * 16) != 0) { + esp_aes_free(&ctx); + return false; + } + } + esp_aes_free(&ctx); return true; } @@ -1011,22 +1060,34 @@ ota_result_t OtaRelay::doSendFirmwareSize(uint32_t size) } /** - * @brief 协议子步骤:逐包转发固件数据给 GD32。 - * 流程:从 Flash 读取 → AES 加密 → SPI 发送 → 等待 ACK → 失败则重发。 - * 每 OTA_PACKET_HEALTH_CHECK_INTERVAL 包执行一次健康检查。 + * @brief 准备单包:读 Flash → AES → 填 64B tx_buf。 + * flash_offset 显式传入,便于在等 ACK 时预组下一包(不推进 m_transferFlashOffset)。 */ -ota_result_t OtaRelay::prepareRelayPacket(uint32_t seq, const ota_meta_t &meta, uint8_t tx_buf[64], size_t *out_chunk_size) +ota_result_t OtaRelay::prepareRelayPacket(uint32_t seq, uint32_t flash_offset, const ota_meta_t &meta, + uint8_t tx_buf[64], size_t *out_chunk_size) { - uint8_t plain48[GD32_OTA_PACKET_SIZE]; - memset(plain48, 0xFF, sizeof(plain48)); - size_t chunk_size = GD32_OTA_PACKET_SIZE; - if (seq == m_transferTotalPackets - 1) { - chunk_size = meta.total_size - (seq * GD32_OTA_PACKET_SIZE); + if (!tx_buf) { + return OTA_RESULT_ERR_STORAGE; } - esp_err_t err = Gd32FlashStore::instance().flashReadChunk(m_transferFlashOffset, plain48, chunk_size); + size_t chunk_size = GD32_OTA_PACKET_SIZE; + if (seq + 1U >= m_transferTotalPackets) { + const uint32_t done = seq * GD32_OTA_PACKET_SIZE; + if (done >= meta.total_size) { + return OTA_RESULT_ERR_STORAGE; + } + chunk_size = meta.total_size - done; + } + + uint8_t plain48[GD32_OTA_PACKET_SIZE]; + /* 仅末包需要 0xFF 填充;满包直接读满 48B */ + if (chunk_size < GD32_OTA_PACKET_SIZE) { + memset(plain48, 0xFF, sizeof(plain48)); + } + + esp_err_t err = Gd32FlashStore::instance().flashReadChunk(flash_offset, plain48, chunk_size); if (err != ESP_OK) { - DEBUG_ERR("OTA", "Flash 读取失败, offset=%" PRIu32 ", err=%d", m_transferFlashOffset, err); + DEBUG_ERR("OTA", "Flash 读取失败, offset=%" PRIu32 ", err=%d", flash_offset, err); return OTA_RESULT_ERR_STORAGE; } @@ -1042,7 +1103,7 @@ ota_result_t OtaRelay::prepareRelayPacket(uint32_t seq, const ota_meta_t &meta, return OTA_RESULT_ERR_AUTH; } - uint32_t if_end = (seq == m_transferTotalPackets - 1) ? 0xFFFFFFFFU : 0x00000000U; + const uint32_t if_end = (seq + 1U >= m_transferTotalPackets) ? 0xFFFFFFFFU : 0x00000000U; tx_buf[56] = (uint8_t)(if_end & 0xFFU); tx_buf[57] = (uint8_t)((if_end >> 8) & 0xFFU); tx_buf[58] = (uint8_t)((if_end >> 16) & 0xFFU); @@ -1053,12 +1114,15 @@ ota_result_t OtaRelay::prepareRelayPacket(uint32_t seq, const ota_meta_t &meta, seq, (unsigned)chunk_size, if_end, tx_buf[60], tx_buf[61], tx_buf[62], tx_buf[63]); - m_transferCurrentSeq = seq; - if (out_chunk_size) *out_chunk_size = chunk_size; + /* 不在此写 m_transferCurrentSeq:预组下一包时不应污染“当前发送序号” */ + if (out_chunk_size) { + *out_chunk_size = chunk_size; + } return OTA_RESULT_OK; } -bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], size_t chunk_size, ota_result_t *out_result) +bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], size_t chunk_size, + ota_result_t *out_result, NextPacketPrep *next_prep) { uint8_t resp_type = 0; uint8_t resp_payload[32] = {0}; @@ -1069,18 +1133,37 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], * - GD32 ISR 收 DATA 后立刻回 WAIT,主循环 AES/Flash 后再 ACK。 * - 一旦见过 WAIT,说明包已进从机;再发 DATA = DUP-SEQ,只会反复 WAIT。 * - 因此:见 WAIT 后只 poll;仅「完全无响应」才允许重发 DATA。 + * - 等 ACK 期间可预组下一包(与从机处理重叠)。 */ constexpr uint32_t kMaxDataSends = 3U; constexpr uint32_t kAckWaitMs = 6000U; /* 单次发送后等 ACK 上限(覆盖 GD32 增量 drain) */ - constexpr uint32_t kPollSliceMs = 80U; + constexpr uint32_t kPollSliceMs = 50U; /* 略紧:DATA_READY 驱动,缩短空切片 */ TickType_t pkt_start = xTaskGetTickCount(); bool saw_wait = false; + auto maybe_prep_next = [&]() { + if (!next_prep || !next_prep->ready || *next_prep->ready) { + return; + } + if (!next_prep->meta || !next_prep->buf || !next_prep->chunk_size) { + return; + } + if (next_prep->next_seq >= m_transferTotalPackets) { + return; + } + ota_result_t pr = prepareRelayPacket(next_prep->next_seq, next_prep->next_flash_offset, + *next_prep->meta, next_prep->buf, next_prep->chunk_size); + if (pr == OTA_RESULT_OK) { + *next_prep->ready = true; + } + /* 预组失败不致命:外层会再 prepare */ + }; + for (uint32_t send_i = 0; send_i < kMaxDataSends; ++send_i) { /* 已见 WAIT:禁止再发 DATA,只延长 poll */ if (saw_wait && send_i > 0U) { - DEBUG_INFO("OTA", "包 %" PRIu32 " 已见 WAIT,继续 poll 不重发", seq); + DEBUG_DBG("OTA", "包 %" PRIu32 " 已见 WAIT,继续 poll 不重发", seq); } else { resp_len = 0; resp_type = 0; @@ -1090,6 +1173,8 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], logRespPayload("DATA_PACKET", resp_type, resp_payload, resp_len); if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) { saw_wait = true; + /* 从机已收下本包,立刻利用等待时间预组下一包 */ + maybe_prep_next(); } if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK && isStructuredAckWithSeq(resp_payload, resp_len, expected_ack)) { @@ -1109,6 +1194,9 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], last_hb = xTaskGetTickCount(); } + /* 优先在 poll 间隙预组,再读 SPI(READY 未拉时也能吃掉 Flash/AES 时间) */ + maybe_prep_next(); + esp_err_t err = SpiMaster::instance().otaPollResponse( &resp_type, resp_payload, sizeof(resp_payload), &resp_len, kPollSliceMs); @@ -1132,6 +1220,10 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], return true; } saw_wait = false; /* 允许带错误码的重发 */ + /* 结构化错误后下一包内容可能变化,丢弃预组 */ + if (next_prep && next_prep->ready) { + *next_prep->ready = false; + } break; } /* 陈旧 ACK,继续 poll */ @@ -1143,7 +1235,6 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], if (send_i + 1U >= kMaxDataSends) { break; } - /* 把下一轮当作「只 poll」:send_i 增加但 saw_wait 使跳过 transfer */ continue; } if (send_i + 1U < kMaxDataSends) { @@ -1170,13 +1261,28 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) return OTA_RESULT_ERR_STORAGE; } - uint8_t tx_buf[64] = {0}; - ota_result_t result = OTA_RESULT_OK; + /* 双缓冲:当前发送槽 + 预组槽(等 ACK 时填充) */ + uint8_t tx_slot[2][64] = {}; + size_t chunk_slot[2] = {0, 0}; + int cur = 0; + bool next_ready = false; + size_t next_chunk = 0; + + uint32_t flash_off = 0; + m_transferFlashOffset = 0; + + ota_result_t result = prepareRelayPacket(0, flash_off, meta, tx_slot[cur], &chunk_slot[cur]); + if (result != OTA_RESULT_OK) { + return result; + } + TickType_t last_success_tick = xTaskGetTickCount(); + /* 进度:首包 / 每 8 包 / 末包更新,减 status 锁竞争 */ + constexpr uint32_t kSeqReportMask = 7U; for (uint32_t seq = 0; seq < m_transferTotalPackets; seq++) { - // 健康检查(每 OTA_PACKET_HEALTH_CHECK_INTERVAL 包 + 首末包) - if ((seq % OTA_PACKET_HEALTH_CHECK_INTERVAL) == 0 || seq == 0 || seq == m_transferTotalPackets - 1) { + if ((seq % OTA_PACKET_HEALTH_CHECK_INTERVAL) == 0 || + seq == 0 || seq + 1U >= m_transferTotalPackets) { health_status_t health = HealthMonitor::instance().checkBeforeOtaRelay(); if (health == HEALTH_MON_SHUTDOWN || health == HEALTH_MON_CRITICAL) { DEBUG_ERR("OTA", "健康检查危险,中止 OTA"); @@ -1184,38 +1290,65 @@ ota_result_t OtaRelay::doRelayPackets(TickType_t deadline_tick) } } - // 全局超时检查 if (deadline_tick != 0 && xTaskGetTickCount() >= deadline_tick) { - DEBUG_ERR("OTA", "全局超时: 包 %" PRIu32 "/%" PRIu32 " 已超限,中止转发", seq, m_transferTotalPackets); - return OTA_RESULT_ERR_USER_ABORT; - } - - // 停刷检测:自上一包成功 ACK 起超过 kStallTimeoutMs 则中止 - if ((xTaskGetTickCount() - last_success_tick) >= pdMS_TO_TICKS(kStallTimeoutMs)) { - DEBUG_ERR("OTA", "10 秒无成功帧, 中止转发: seq=%" PRIu32 "/%" PRIu32, seq, m_transferTotalPackets); + DEBUG_ERR("OTA", "全局超时: 包 %" PRIu32 "/%" PRIu32 " 已超限,中止转发", + seq, m_transferTotalPackets); return OTA_RESULT_ERR_TIMEOUT; } - size_t chunk_size = 0; - result = prepareRelayPacket(seq, meta, tx_buf, &chunk_size); - if (result != OTA_RESULT_OK) { + + if ((xTaskGetTickCount() - last_success_tick) >= pdMS_TO_TICKS(kStallTimeoutMs)) { + DEBUG_ERR("OTA", "%" PRIu32 " ms 无成功帧, 中止转发: seq=%" PRIu32 "/%" PRIu32, + kStallTimeoutMs, seq, m_transferTotalPackets); + return OTA_RESULT_ERR_TIMEOUT; + } + + /* 当前包必须已在 cur 槽;若预组未命中则现场准备 */ + if (seq > 0U) { + if (next_ready) { + cur ^= 1; + chunk_slot[cur] = next_chunk; + next_ready = false; + } else { + result = prepareRelayPacket(seq, flash_off, meta, tx_slot[cur], &chunk_slot[cur]); + if (result != OTA_RESULT_OK) { + return result; + } + } + } + + m_transferCurrentSeq = seq; + if ((seq & kSeqReportMask) == 0U || seq + 1U >= m_transferTotalPackets) { + setRelaySeq(seq); + } + + NextPacketPrep prep{}; + const int prep_idx = cur ^ 1; + prep.next_seq = seq + 1U; + prep.next_flash_offset = flash_off + (uint32_t)chunk_slot[cur]; + prep.meta = &meta; + prep.buf = tx_slot[prep_idx]; + prep.chunk_size = &next_chunk; + prep.ready = &next_ready; + NextPacketPrep *prep_arg = (seq + 1U < m_transferTotalPackets) ? &prep : nullptr; + + if (sendRelayPacketWithRetry(seq, tx_slot[cur], chunk_slot[cur], &result, prep_arg)) { + DEBUG_ERR("OTA", "中止于包 %" PRIu32 "/%" PRIu32 " result=%d", + seq, m_transferTotalPackets, (int)result); return result; } - /* 先写入「正在发第 N 包」,前端 /system/status 能看到卡在哪 */ - setRelaySeq(seq); + flash_off += (uint32_t)chunk_slot[cur]; + /* 与 sendRelayPacketWithRetry 内累加对齐(绝对赋值,防漂移) */ + m_transferFlashOffset = flash_off; - // 发送单包并等待 ACK(内含重试循环) - if (sendRelayPacketWithRetry(seq, tx_buf, chunk_size, &result)) { - DEBUG_ERR("OTA", "中止于包 %" PRIu32 "/%" PRIu32 " result=%d", - seq, m_transferTotalPackets, (int)result); - return result; // 中止 + if ((seq & kSeqReportMask) == 0U || seq + 1U >= m_transferTotalPackets) { + setRelaySeq(seq + 1U); } - - setRelaySeq(seq + 1); last_success_tick = xTaskGetTickCount(); } + setRelaySeq(m_transferTotalPackets); DEBUG_INFO("OTA", "全部数据包已转发: %" PRIu32 " 包", m_transferTotalPackets); - return result; + return OTA_RESULT_OK; } // ============================================================ @@ -1398,12 +1531,31 @@ void OtaRelay::taskEntry(void *pvParameters) return; } - // ★ 启动时初始 GD32 探测:读取 GD32 APP 版本号与存活状态 - // GD32 APP 启动需要一定时间,给其 3 秒准备窗口后发起探测 + /* 启动 GD32 探测(原 main.cpp 与此处双份;现统一在本任务): + * APP 完整 init 约 5–6s;先 hello 建 v2 session,再 appPing。 */ { - DEBUG_INFO("OTA", "启动初始 GD32 探测..."); - vTaskDelay(pdMS_TO_TICKS(3000)); // 等待 GD32 APP 就绪 - probeAndCacheAppAlive(2000); + DEBUG_INFO("OTA", "启动初始 GD32 探测 (hello+probe)..."); + bool session_ok = false; + bool alive = false; + for (uint32_t attempt = 0; attempt < 20U; ++attempt) { + vTaskDelay(pdMS_TO_TICKS(300)); + if (!session_ok) { + if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK) { + session_ok = true; + } else { + DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U); + continue; + } + } + if (probeAndCacheAppAlive(2000)) { + alive = true; + break; + } + DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/20)", attempt + 1U); + } + if (!alive) { + DEBUG_WARN("OTA", "启动 GD32 探测未成功(中继前仍可再 hello)"); + } } while (1) { @@ -1425,10 +1577,12 @@ void OtaRelay::taskEntry(void *pvParameters) if (result == OTA_RESULT_OK) { // 成功日志已在 doWaitForAppAlive 输出,此处不再重复 vTaskDelay(pdMS_TO_TICKS(3000)); + } else if (result == OTA_RESULT_ERR_TIMEOUT) { + DEBUG_ERR("OTA", "OTA 超时中止 (TIMEOUT=%d,含全局预算/停刷/单包)", (int)result); } else if (result == OTA_RESULT_ERR_USER_ABORT) { - DEBUG_ERR("OTA", "OTA 全局超时中止 (OTA_RESULT_ERR_USER_ABORT=%d)", result); + DEBUG_ERR("OTA", "OTA 用户中止 (USER_ABORT=%d)", (int)result); } else { - DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result); + DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", (int)result); } instance().reset(); // 通过 IRelayListener 接口回调通知协调器中继结束 diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index c991f7d..02c90d2 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -207,6 +207,14 @@ public: */ static QueueHandle_t getResultQueue(); + /** + * @brief 探测 GD32 APP 存活并缓存版本(公开,供 OtaCoordinator / 启动探测共用)。 + * @param timeout_ms appPing 超时(毫秒)。 + * @param out_uptime 可选,输出 GD32 uptime(秒)。 + * @return true 探测成功,false 失败。 + */ + static bool probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime = nullptr); + // ── 线程安全锁 ── static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用) static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间 @@ -320,26 +328,39 @@ private: /** * @brief 准备单包:读 Flash + 组装 tx_buf + 加密 + CRC(doRelayPackets 子步骤)。 * @param seq 当前包序号。 + * @param flash_offset 本包在 gd32_ota 分区中的起始偏移(不依赖 m_transferFlashOffset,便于预组下一包)。 * @param meta 固件元数据(用于计算末包 chunk_size)。 * @param tx_buf 输出缓冲区(64 字节)。 - * @param out_chunk_size 输出本包有效字节数(用于 sendRelayPacketWithRetry 推进 offset)。 + * @param out_chunk_size 输出本包有效字节数。 * @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_STORAGE 读 Flash 失败,OTA_RESULT_ERR_AUTH 加密失败。 */ - ota_result_t prepareRelayPacket(uint32_t seq, const ota_meta_t &meta, uint8_t tx_buf[64], size_t *out_chunk_size); + ota_result_t prepareRelayPacket(uint32_t seq, uint32_t flash_offset, const ota_meta_t &meta, + uint8_t tx_buf[64], size_t *out_chunk_size); + + /** 等 ACK 期间可选预组下一包(与 GD32 处理重叠,隐藏本端 Flash/AES 延迟)。 */ + struct NextPacketPrep { + uint32_t next_seq = 0; + uint32_t next_flash_offset = 0; + const ota_meta_t *meta = nullptr; + uint8_t *buf = nullptr; ///< 64 字节 + size_t *chunk_size = nullptr; + bool *ready = nullptr; + }; + /** * @brief 发送单包并等待 ACK(doRelayPackets 子步骤)。 * - * 内含重试循环,使用以下命名常量: - * kMaxResendOnNack=10(ACK 失败/轮询耗尽重发上限) - * kMaxResendOnStructError=3(结构化错误重发上限) + * 见 WAIT 后只 poll 不重发 DATA。可选 next_prep:在等 ACK 的 poll 间隙预组下一包。 * * @param seq 当前包序号。 * @param tx_buf 已组装好的 64 字节发送缓冲区。 * @param chunk_size 本包有效字节数(成功时用于推进 m_transferFlashOffset)。 * @param[out] out_result 失败时写入错误码(OK 时不修改)。 + * @param next_prep 可选;非空则在 poll 循环内至多预组一次下一包。 * @return true 应中止外层循环(result 已设);false 继续下一包。 */ - bool sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], size_t chunk_size, ota_result_t *out_result); + bool sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], size_t chunk_size, + ota_result_t *out_result, NextPacketPrep *next_prep = nullptr); /** * @brief 发送结束指令并验证 APP 存活(顺序调用 doSendFinishOta + doWaitForAppAlive). * @return OTA_RESULT_OK 成功,负值表示错误。 @@ -361,20 +382,11 @@ private: uint32_t m_transferTotalPackets = 0; uint32_t m_transferCurrentSeq = 0; uint32_t m_transferFlashOffset = 0; - uint32_t m_transferRetryCount = 0; TickType_t m_transferDeadline = 0; // ── 加密/工具方法(不变)── static int aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out); static void logRespPayload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len); - /** - * @brief 探测 GD32 APP 存活并缓存版本(统一 OtaRelay 内部探测逻辑). - * 成功打印版本号,失败打印 warning。 - * @param timeout_ms appPing 超时(毫秒)。 - * @param out_uptime 可选,输出 GD32 uptime(秒)。 - * @return true 探测成功,false 失败。 - */ - static bool probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime = nullptr); esp_err_t waitUntilNotWait(const char *stage, uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms, diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 7e8f1c9..92e9b36 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -57,17 +57,12 @@ #define BLE_OTA_FINISH_GD32_RELAY_DONE 0x11 #define BLE_OTA_FINISH_ABORTED 0x20 // 用户主动中止(BLE 不断开) #define BLE_OTA_FINISH_ERR_FINALIZE 0x81 -#define BLE_OTA_ACK_WINDOW 32 #define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用 #define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用 #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 转发失败 - -// ── 数据队列:将 OTA 数据处理从 NimBLE host task 卸载到工作线程 ── -// 避免 flash 操作(esp_ota_begin / esp_ota_write)阻塞 BLE 协议栈导致连接断开。 -// 深度略大于窗口:seq=0 整分区预擦期间前端若仍误发,可多扛几批,减少“队列满丢包→重传风暴”。 -#define BLE_OTA_DATA_QUEUE_LEN (BLE_OTA_ACK_WINDOW * 2 + 8) +// BLE_OTA_ACK_WINDOW / BLE_OTA_DATA_QUEUE_LEN / BLE_OTA_REORDER_WINDOW → app_config.h struct BleOtaDataBatch { uint8_t *data; @@ -105,6 +100,8 @@ 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); @@ -182,7 +179,9 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, packBleFinishValue(session_id, 1)); BleOta::instance().m_esp32RebootPending = false; - esp32_reorder_free(); + // 无效会话:协调器可能仍假占用,走统一会话复位 + ble_ota_session_reset(); + ble_ota_restore_wifi_ps(); vTaskDelete(NULL); return; } @@ -217,18 +216,22 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, packBleFinishValue(session_id, 0)); BleOta::instance().m_esp32RebootPending = false; - // ★ finalize 内部已 abort 并释放 mutex,这里补充通知协调器复位状态机, - // 避免 m_sysState 卡在 ESP32_FINALIZING 导致下次 OTA 被假拒绝。 - OtaCoordinator::instance().abortCurrent(); + // ★ finalizeForReboot 失败路径已 abortCurrent(协调器→IDLE + core 释放)。 + // 此处只清 BLE 会话侧 reorder,避免二次 abort 干扰新会话。 esp32_reorder_free(); + BleOta::instance().m_esp32LastSeq = 0xFFFFU; + BleOta::instance().m_esp32LastCmd = 0; + ble_ota_restore_wifi_ps(); } else { DEBUG_ERR(TAG, "[FINISH] OTA 收尾失败: %s", esp_err_to_name(fr.err)); sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, packBleFinishValue(session_id, 1)); BleOta::instance().m_esp32RebootPending = false; - // ★ 同上:finalize 已 abort core,这里通知协调器复位状态机 - OtaCoordinator::instance().abortCurrent(); + // ★ 同上:协调器已由 finalizeForReboot 复位,仅清 BLE 侧残留 esp32_reorder_free(); + BleOta::instance().m_esp32LastSeq = 0xFFFFU; + BleOta::instance().m_esp32LastCmd = 0; + ble_ota_restore_wifi_ps(); } vTaskDelete(NULL); @@ -1222,7 +1225,7 @@ static bool process_esp32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, // reorder 可能一次 flush 到下个窗口边界,例如 seq=99 使 expected 直达128; // 此时必须立即 ACK 127,否则后续重复包都不产生 progress,前端只能超时。 // - // ★ seq=0 强制 ACK:begin 含整分区预擦,可能耗时数秒~数十秒。 + // ★ seq=0 强制 ACK:begin 后首包需尽快 ACK,前端才能继续窗口。 // 前端必须等“首包已落盘”ACK 后再发窗口内其余包,否则队列满丢包→重传极慢。 uint32_t completedPackets = (afterReceived + BLE_OTA_CHUNK_PAYLOAD - 1U) / BLE_OTA_CHUNK_PAYLOAD; @@ -1390,7 +1393,7 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size, meta.stored_size = stored_size; meta.last_seq = seq; const bool transfer_complete = stored_size >= meta.total_size; - // seq=0:prepare 含整分区预擦,写完首包必须 ACK,前端才能继续发后续窗口 + // seq=0:prepare 后写完首包必须 ACK,前端才能继续发后续窗口 const bool checkpoint = (seq == 0U) || ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U) || diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index 271b1ba..313b88f 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -53,6 +53,36 @@ static void bytesToHexUpper(const uint8_t *in, size_t n, char *out) out[n * 2] = '\0'; } +/** + * @brief 统一 JSON 错误响应(避免前端 resp.json() 被 httpd 明文坑)。 + * @param http_status 如 "400 Bad Request" / "403 Forbidden" / "500 Internal Server Error" + * @param code 机器可读错误码(snake_case) + * @param message 人类可读说明(勿含未转义双引号) + */ +static esp_err_t http_send_json_error(httpd_req_t *req, const char *http_status, + const char *code, const char *message) +{ + char body[320]; + snprintf(body, sizeof(body), + "{\"status\":\"error\",\"code\":\"%s\",\"message\":\"%s\"}", + code ? code : "error", + message ? message : "error"); + httpd_resp_set_status(req, http_status ? http_status : "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, body, HTTPD_RESP_USE_STRLEN); + return ESP_FAIL; +} + +/** @brief OTA 收流:堆紧时让 LWIP 回收,再 httpd_req_recv。 */ +static void otaUploadPaceIfHeapTight(void) +{ + const uint32_t free_pre = esp_get_free_heap_size(); + if (free_pre < 8192U) { + (void)esp_task_wdt_reset(); + vTaskDelay(pdMS_TO_TICKS(free_pre < 4096U ? 5U : 1U)); + } +} + void http_set_cors_headers(httpd_req_t *req) { httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); @@ -74,8 +104,9 @@ esp_err_t esp32_ota_update_handler(httpd_req_t *req) http_set_cors_headers(req); Esp32OtaCore &core = Esp32OtaCore::instance(); if (!core.isActive()) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA not started"); - return ESP_FAIL; + /* 常见场景:finalize 已成功并重启 → 新固件无 OTA 会话 → 迟到的 /ota/update。 */ + return http_send_json_error(req, "400 Bad Request", + "ota_not_started", "OTA not started"); } char frontend_sha_hex[65] = {0}; @@ -114,8 +145,8 @@ esp_err_t esp32_ota_update_handler(httpd_req_t *req) esp_err_t sha_err = core.computeDeviceSha(device_sha_hex); if (sha_err != ESP_OK) { DEBUG_ERR("OTA", "verify-only: SHA 计算失败: %s", esp_err_to_name(sha_err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(sha_err)); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "sha_compute_failed", esp_err_to_name(sha_err)); } char ok_resp[256] = {0}; snprintf(ok_resp, sizeof(ok_resp), @@ -153,7 +184,8 @@ esp_err_t esp32_ota_update_handler(httpd_req_t *req) httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, err_resp, strlen(err_resp)); } else { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(fr.err)); + return http_send_json_error(req, "500 Internal Server Error", + "finalize_failed", esp_err_to_name(fr.err)); } return ESP_FAIL; } @@ -214,8 +246,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req) char peek; int peek_ret = httpd_req_recv(req, &peek, 1); if (peek_ret <= 0) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "接收失败"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "recv_failed", "receive failed"); } bool is_esp32_ota = false; @@ -257,8 +289,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req) if (is_esp32_ota) { if (total_size == 0) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing total_size"); - return ESP_FAIL; + return http_send_json_error(req, "400 Bad Request", + "missing_total_size", "missing total_size"); } /* ESP32 路径不再需要 pre_read(JSON 已消费,固件从流继续读) */ @@ -267,11 +299,11 @@ esp_err_t ota_upload_handler(httpd_req_t *req) esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_WIFI); if (err != ESP_OK) { if (err == ESP_ERR_INVALID_STATE) { - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); - } else { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); + return http_send_json_error(req, "403 Forbidden", + "ota_occupied", "ESP32 OTA occupied by another channel"); } - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "ota_begin_failed", "OTA begin failed"); } Esp32OtaCore &core = Esp32OtaCore::instance(); @@ -290,9 +322,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) ? (req->content_len - json_header_len) : 0); /* 浏览器 upload 进度到 100% 只表示 TCP 发完;设备仍在 recv+Flash。 - * Flash 扇区擦除期间 cache 暂停,必须让 WiFi/LWIP 有机会跑,否则 TCP 卡死。 - * C3 上 WiFi+BLE 双栈堆极紧:free 过低时 pbuf 申请失败 → 半路卡住/超时。 - * 因此 pacing 按 free heap 自适应,而不是固定每 N 块 delay。 */ + * sequential 擦写期间仍需让 LWIP 有机会跑;堆紧时 pacing。 */ const int64_t t_recv0 = esp_timer_get_time(); uint32_t write_chunks = 0U; @@ -302,14 +332,7 @@ esp_err_t ota_upload_handler(httpd_req_t *req) to_read = OTA_BLOCK_SIZE; } - /* 堆过紧时先让 LWIP 回收 TCP 窗口,再 recv,避免 pbuf 分配失败 */ - { - const uint32_t free_pre = esp_get_free_heap_size(); - if (free_pre < 8192U) { - (void)esp_task_wdt_reset(); - vTaskDelay(pdMS_TO_TICKS(free_pre < 4096U ? 5U : 1)); - } - } + otaUploadPaceIfHeapTight(); int ret = httpd_req_recv(req, (char *)fw_buf, to_read); if (ret <= 0) { @@ -317,8 +340,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req) ret, (unsigned)total_received, req->content_len, (unsigned long)esp_get_free_heap_size()); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "http_recv_failed", "HTTP recv failed"); } err = core.write(fw_buf, (size_t)ret); @@ -327,8 +350,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req) esp_err_to_name(err), (unsigned)total_firmware_written, (unsigned long)esp_get_free_heap_size()); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "ota_write_failed", "OTA write failed"); } total_firmware_written += (size_t)ret; @@ -389,18 +412,16 @@ esp_err_t ota_upload_handler(httpd_req_t *req) if (prep_err == ESP_ERR_INVALID_SIZE) { DEBUG_ERR("WIFI", "firmware size invalid: valid=%u max=%u", (unsigned)gd32_valid_size, (unsigned)OTA_MAX_FIRMWARE_SIZE); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "firmware too large or alignment overflow"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "firmware_too_large", "firmware too large or alignment overflow"); } else if (prep_err == ESP_ERR_INVALID_STATE) { DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)"); - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "system busy with another OTA operation"); - return ESP_FAIL; + return http_send_json_error(req, "403 Forbidden", + "system_busy", "system busy with another OTA operation"); } else if (prep_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); - char err_msg[96]; - snprintf(err_msg, sizeof(err_msg), "prepare gd32_ota failed: %s", esp_err_to_name(prep_err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "prepare_failed", esp_err_to_name(prep_err)); } // ★ 捕获当前 epoch,用于失败清理时检查会话是否仍属于本 handler @@ -426,10 +447,9 @@ esp_err_t ota_upload_handler(httpd_req_t *req) if (err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s", first_chunk_len, esp_err_to_name(err)); - // ★ 首块写入失败:用 abortCurrentIfEpoch 避免误中止新会话 OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "gd32_write_failed", "write gd32_ota failed"); } received = first_chunk_len; gd32_offset += (uint32_t)first_chunk_len; @@ -443,24 +463,24 @@ esp_err_t ota_upload_handler(httpd_req_t *req) size_t to_read = req->content_len - received; if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + otaUploadPaceIfHeapTight(); + int ret = httpd_req_recv(req, (char *)buf, to_read); if (ret <= 0) { DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d", received, req->content_len); - // ★ 客户端断开(刷新网页/关浏览器/网络掉):用 abortCurrentIfEpoch 避免误中止新会话 OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "upload_interrupted", "upload interrupted"); } esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(gd32_offset, buf, (size_t)ret); if (err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 write failed: offset=%u len=%d err=%s", (unsigned)gd32_offset, ret, esp_err_to_name(err)); - // ★ 写入失败:同上,用 abortCurrentIfEpoch OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "gd32_write_failed", "write gd32_ota failed"); } received += ret; @@ -481,7 +501,13 @@ esp_err_t ota_upload_handler(httpd_req_t *req) // 与 BLE 共用 finishGd32StorageAfterReceive:complete + SHA esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); if (sha_err != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); + /* 存盘已完成,仍回 status=ok;relay 阶段会再比前端 SHA。 + * 此处必须醒目告警,避免只当 INFO 被忽略。 */ + DEBUG_WARN("WIFI", + "GD32 上传完成但 SHA-256 预计算失败: %s(仍返回 stored ok,/ota/relay 将再校验)", + esp_err_to_name(sha_err)); + DEBUG_ERR("WIFI", "GD32 finishGd32StorageAfterReceive failed: %s", + esp_err_to_name(sha_err)); } } @@ -512,8 +538,8 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req) ota_meta_t meta; memset(&meta, 0, sizeof(meta)); if (OtaCoordinator::instance().loadGd32Meta(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "firmware not fully stored"); - return ESP_FAIL; + return http_send_json_error(req, "400 Bad Request", + "firmware_not_stored", "firmware not fully stored"); } char frontend_sha_hex[65] = {0}; @@ -535,9 +561,8 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req) } } if (frontend_sha_hex[0] == '\0') { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, - "missing sha256 in body (frontend SHA-256 required)"); - return ESP_FAIL; + return http_send_json_error(req, "400 Bad Request", + "missing_sha256", "missing sha256 in body (frontend SHA-256 required)"); } uint8_t frontend_sha[32] = {0}; @@ -562,15 +587,15 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req) httpd_resp_send(req, err_resp, strlen(err_resp)); return ESP_FAIL; } else if (verify_err != ESP_OK) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SHA compute failed"); - return ESP_FAIL; + return http_send_json_error(req, "500 Internal Server Error", + "sha_compute_failed", "SHA compute failed"); } esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); if (trigger_err != ESP_OK) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + return http_send_json_error(req, "400 Bad Request", + "relay_trigger_failed", "relay trigger failed - system busy or firmware not ready"); - return ESP_FAIL; } char resp[256]; @@ -807,6 +832,140 @@ esp_err_t can_send_handler(httpd_req_t *req) return ESP_OK; } +/** + * @brief POST /can/send_batch — 同通道连续发送多帧,单次 HTTP 完成。 + * + * JSON: { "ch": 0, "frames": [ { "id": n, "dlc": n, "data": "HEX" }, ... ] } + * 返回: { "status":"ok", "results":[ { "status":"ok"|"error", ... }, ... ] } + * 帧间无人为延时;单帧 SPI 超时 400ms(批量优先吞吐)。 + */ +esp_err_t can_send_batch_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + constexpr int kMaxBody = 12288; + constexpr int kMaxFrames = 48; + constexpr uint32_t kPerFrameTimeoutMs = 400U; + + int content_len = req->content_len; + if (content_len <= 0 || content_len > kMaxBody) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "body 长度无效或过大"); + return ESP_FAIL; + } + + char *buf = static_cast(malloc(static_cast(content_len) + 1U)); + if (!buf) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存不足"); + return ESP_FAIL; + } + + int received = 0; + while (received < content_len) { + int ret = httpd_req_recv(req, buf + received, content_len - received); + if (ret <= 0) { + free(buf); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败"); + return ESP_FAIL; + } + received += ret; + } + buf[received] = '\0'; + + cJSON *root = cJSON_Parse(buf); + free(buf); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); + return ESP_FAIL; + } + + cJSON *ch_item = cJSON_GetObjectItem(root, "ch"); + cJSON *frames = cJSON_GetObjectItem(root, "frames"); + if (!ch_item || !cJSON_IsArray(frames)) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "需要 ch 与 frames 数组"); + return ESP_FAIL; + } + + can_channel_t ch = static_cast(cJSON_GetNumberValue(ch_item)); + const int frame_count = cJSON_GetArraySize(frames); + if (frame_count <= 0 || frame_count > kMaxFrames) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "frames 数量无效(1~48)"); + return ESP_FAIL; + } + + cJSON *out_root = cJSON_CreateObject(); + cJSON *results = cJSON_CreateArray(); + cJSON_AddStringToObject(out_root, "status", "ok"); + cJSON_AddItemToObject(out_root, "results", results); + + for (int i = 0; i < frame_count; i++) { + cJSON *fr = cJSON_GetArrayItem(frames, i); + cJSON *item = cJSON_CreateObject(); + cJSON_AddItemToArray(results, item); + + if (!cJSON_IsObject(fr)) { + cJSON_AddStringToObject(item, "status", "error"); + cJSON_AddNumberToObject(item, "code", -1); + cJSON_AddNumberToObject(item, "gd32_err", 0); + continue; + } + + cJSON *id_item = cJSON_GetObjectItem(fr, "id"); + cJSON *data_item = cJSON_GetObjectItem(fr, "data"); + cJSON *dlc_item = cJSON_GetObjectItem(fr, "dlc"); + if (!id_item) { + cJSON_AddStringToObject(item, "status", "error"); + cJSON_AddNumberToObject(item, "code", -1); + cJSON_AddNumberToObject(item, "gd32_err", 0); + continue; + } + + uint32_t ext_id = static_cast(cJSON_GetNumberValue(id_item)); + uint8_t dlc = dlc_item ? static_cast(cJSON_GetNumberValue(dlc_item)) : 0U; + uint8_t tx_data[CAN_MAX_DLC] = {0}; + uint8_t tx_dlc = 0U; + if (data_item && cJSON_IsString(data_item)) { + tx_dlc = static_cast(parseHexToBytes(cJSON_GetStringValue(data_item), + tx_data, CAN_MAX_DLC)); + } + if (tx_dlc == 0U && dlc > 0U) { + tx_dlc = (dlc > CAN_MAX_DLC) ? CAN_MAX_DLC : dlc; + } + + uint8_t resp_data[CAN_MAX_DLC] = {0}; + uint8_t gd32_err = 0U; + int resp_len = canFdSend(ch, ext_id, tx_data, tx_dlc, + resp_data, sizeof(resp_data), kPerFrameTimeoutMs, &gd32_err); + + if (resp_len >= 0) { + char resp_hex[CAN_MAX_DLC * 2 + 1] = {0}; + bytesToHexUpper(resp_data, static_cast(resp_len), resp_hex); + cJSON_AddStringToObject(item, "status", "ok"); + cJSON_AddNumberToObject(item, "sent", tx_dlc); + cJSON_AddNumberToObject(item, "recv_len", resp_len); + cJSON_AddStringToObject(item, "recv", resp_hex); + } else { + cJSON_AddStringToObject(item, "status", "error"); + cJSON_AddNumberToObject(item, "code", resp_len); + cJSON_AddNumberToObject(item, "gd32_err", gd32_err); + } + } + + cJSON_Delete(root); + char *printed = cJSON_PrintUnformatted(out_root); + cJSON_Delete(out_root); + if (!printed) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "JSON 输出失败"); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, printed, strlen(printed)); + free(printed); + return ESP_OK; +} + esp_err_t spi_send_handler(httpd_req_t *req) { http_set_cors_headers(req); @@ -1019,8 +1178,8 @@ esp_err_t system_status_handler(httpd_req_t *req) size_t default_heap_total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT); // 任务栈指标复用 health_monitor 共享的任务表(collectTaskStackMetrics), // 避免与巡检逻辑两地维护;http_server 在本 handler 上下文中,句柄即当前任务。 - TaskStackMetric task_metrics[8]; - size_t task_metrics_count = collectTaskStackMetrics(task_metrics, 8); + TaskStackMetric task_metrics[12]; + size_t task_metrics_count = collectTaskStackMetrics(task_metrics, 12); // http_server 行的句柄在 httpd 线程中取当前任务更准确 for (size_t i = 0; i < task_metrics_count; ++i) { if (strcmp(task_metrics[i].name, "http_server") == 0) { diff --git a/main/server/http_handlers.h b/main/server/http_handlers.h index 7075796..8bf1600 100644 --- a/main/server/http_handlers.h +++ b/main/server/http_handlers.h @@ -16,6 +16,8 @@ esp_err_t restart_handler(httpd_req_t *req); // Utility handlers esp_err_t logs_handler(httpd_req_t *req); esp_err_t can_send_handler(httpd_req_t *req); +/** @brief 批量 CAN 发送:一次 HTTP 连续发多帧,减少帧间 HTTP 往返延迟。 */ +esp_err_t can_send_batch_handler(httpd_req_t *req); esp_err_t spi_send_handler(httpd_req_t *req); esp_err_t system_status_handler(httpd_req_t *req); esp_err_t system_probe_handler(httpd_req_t *req); diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 787568e..b462b2b 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -446,6 +446,7 @@ esp_err_t WifiOta::httpServerStart() {"/system/status", HTTP_GET, system_status_handler}, {"/system/probe", HTTP_GET, system_probe_handler}, {"/can/send", HTTP_POST, can_send_handler}, + {"/can/send_batch", HTTP_POST, can_send_batch_handler}, {"/can/feedback", HTTP_GET, can_feedback_handler}, {"/can/mode", HTTP_GET, can_mode_handler}, {"/can/mode", HTTP_POST, can_mode_handler}, @@ -468,7 +469,7 @@ esp_err_t WifiOta::httpServerStart() httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri); } - DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /restart, /logs, /system/status, /system/probe, /can/send, /spi/send, /can/feedback, /can/mode"); + DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /restart, /logs, /system/status, /system/probe, /can/send, /can/send_batch, /spi/send, /can/feedback, /can/mode"); return ESP_OK; } diff --git a/main/storage/gd32_flash_store.cpp b/main/storage/gd32_flash_store.cpp index 44eb72f..ac2d421 100644 --- a/main/storage/gd32_flash_store.cpp +++ b/main/storage/gd32_flash_store.cpp @@ -114,6 +114,7 @@ esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len) addr += chunk; m_erasedUpTo = addr; (void)esp_task_wdt_reset(); + taskYIELD(); /* 长时间兜底擦时让 WiFi/LWIP 有机会跑 */ } return ESP_OK; } @@ -121,6 +122,13 @@ esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len) /** @brief 向 gd32_ota 分区写入数据块,自动处理非 4 字节对齐的读写。 */ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len) { + if (len == 0U) { + return ESP_OK; + } + if (data == nullptr) { + return ESP_ERR_INVALID_ARG; + } + const esp_partition_t *part = getPartition(); if (!part) return ESP_ERR_NOT_FOUND; @@ -136,7 +144,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, return ESP_ERR_INVALID_SIZE; } - // 正常路径:prepare 已整分区预擦;此处仅兜底(水位未覆盖时才擦) + // 按需擦除目标扇区(与收流交织;prepare 不再整区预擦) esp_err_t erase_err = ensureErasedForWrite(aligned_offset, aligned_len); if (erase_err != ESP_OK) { return erase_err; @@ -192,8 +200,22 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, /** @brief 从 gd32_ota 分区读取数据块。 */ esp_err_t Gd32FlashStore::flashReadChunk(uint32_t offset, uint8_t *data, size_t len) { + if (len == 0U) { + return ESP_OK; + } + if (data == nullptr) { + return ESP_ERR_INVALID_ARG; + } + const esp_partition_t *part = getPartition(); if (!part) return ESP_ERR_NOT_FOUND; + + /* 防坏 meta / 错误 offset 读越界 */ + if (offset >= part->size || len > (size_t)(part->size - offset)) { + DEBUG_ERR("OTA", "flashReadChunk越界: offset=%" PRIu32 " len=%zu part=%" PRIu32, + offset, len, part->size); + return ESP_ERR_INVALID_SIZE; + } return esp_partition_read(part, offset, data, len); } @@ -206,7 +228,7 @@ esp_err_t Gd32FlashStore::erasePartition(uint32_t size) DEBUG_ERR("OTA", "erasePartition: size=%" PRIu32 " > partition=%" PRIu32, size, part->size); return ESP_ERR_INVALID_SIZE; } - // 按扇区擦除并喂狗,避免大块 erase 长时间占住调用任务 + // 按扇区擦除并喂狗/yield,避免大块 erase 长时间占住调用任务饿死 WiFi uint32_t done = 0; while (done < size) { uint32_t chunk = kFlashSectorSize; @@ -215,6 +237,8 @@ esp_err_t Gd32FlashStore::erasePartition(uint32_t size) } esp_err_t err = esp_partition_erase_range(part, done, chunk); if (err != ESP_OK) { + DEBUG_ERR("OTA", "erasePartition: erase @%" PRIu32 " len=%" PRIu32 " failed: %s", + done, chunk, esp_err_to_name(err)); return err; } done += chunk; @@ -222,6 +246,7 @@ esp_err_t Gd32FlashStore::erasePartition(uint32_t size) m_erasedUpTo = done; } (void)esp_task_wdt_reset(); + taskYIELD(); } return ESP_OK; } diff --git a/main/storage/gd32_flash_store.h b/main/storage/gd32_flash_store.h index 706e789..10e7f9a 100644 --- a/main/storage/gd32_flash_store.h +++ b/main/storage/gd32_flash_store.h @@ -72,7 +72,7 @@ public: /** * @brief 新一轮 GD32 固件写入会话开始时调用:复位擦除水位。 - * prepareGd32Storage 随后会 erasePartition(整分区)。 + * 后续 flashWriteChunk 经 ensureErasedForWrite 按需擦扇区。 */ void beginWriteSession(); @@ -152,8 +152,8 @@ private: const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化) - // ── 擦除水位:prepare 整分区预擦后 m_erasedUpTo==part->size; - // flashWriteChunk 内 ensureErased 为安全兜底(水位已覆盖则立即返回)。 + // ── 擦除水位:beginWriteSession 后从 0 起; + // flashWriteChunk → ensureErasedForWrite 按需推进(不再依赖 prepare 整区预擦)。 static constexpr uint32_t kFlashSectorSize = 4096U; uint32_t m_erasedUpTo = 0; esp_err_t ensureErasedForWrite(uint32_t offset, size_t len); diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 329bebe..78c73e1 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -1,5 +1,14 @@ /* * canfd_bridge.cpp - CANFD 3通道通信桥接实现 + * + * SPI 业务类型: + * 0xC0 发 CAN 帧(canFdSend) + * 0xC1 反馈页解析(canFdParseFeedbackPage;由 GD32 主动推送,本文件不轮询) + * 0xC2 设 RX 模式(canFdSetRxMode) + * + * 0xC1 线格式与 GD32 app_spi_cmd_can_feedback.cpp 对齐: + * 固定头 28B + 条目 id(4LE)+dlc(1)+flags(1)+rsvd(2)+data(dlc) + * 业务 payload 上限 256 - V2_OVERHEAD(20) = 236 */ #include #include "canfd_bridge.h" @@ -8,8 +17,19 @@ namespace { -constexpr uint8_t kSpiCanFrameType = 0xC0U; +constexpr uint8_t kSpiCanFrameType = 0xC0U; +constexpr uint8_t kSpiCanSetModeType = 0xC2U; +constexpr uint8_t kFeedbackFixedHeader = 28U; +constexpr uint8_t kFeedbackMaxPage = 1U; /* GD32 仅 page 0/1 */ +/** + * @brief 经 SPI 发送一帧 CAN,并解析 ACK/ERROR。 + * + * 两步法:transact 发命令(丢弃延迟一拍陈旧响应)+ poll 真实响应。 + * CAN 帧非幂等,命令只发一次,不重发。 + * + * @return >=0 ACK 时响应负载长度;-2 SPI 失败;-3 GD32 ERROR。 + */ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, const uint8_t *data, uint8_t dlc, uint8_t *recv_buf, uint8_t recv_max, @@ -24,61 +44,71 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, tx_buf[tx_len++] = static_cast((ext_id >> 16) & 0xFFU); tx_buf[tx_len++] = static_cast((ext_id >> 8) & 0xFFU); tx_buf[tx_len++] = static_cast(ext_id & 0xFFU); - if (dlc > 0U) { + if (dlc > 0U && data != nullptr) { memcpy(&tx_buf[tx_len], data, dlc); tx_len += dlc; } + /* 独立缓冲收响应:调用方 recv_buf 为空时也能拿到 ERROR 细分码。 */ + uint8_t local_resp[72]; + uint8_t *spi_rx = (recv_buf != nullptr && recv_max > 0U) ? recv_buf : local_resp; + const size_t spi_rx_max = (recv_buf != nullptr && recv_max > 0U) + ? static_cast(recv_max) + : sizeof(local_resp); + uint8_t resp_type = 0U; size_t resp_len = 0U; - /* 两步法:transact 发命令(丢弃延迟一拍的过期响应)+ otaPollResponse 读真实响应。 - * CAN 帧非幂等,命令只发一次,不重发。 */ esp_err_t ret = SpiMaster::instance().transact( kSpiCanFrameType, tx_buf, tx_len, - &resp_type, recv_buf, recv_max, &resp_len, timeout_ms); + &resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms); if (ret != ESP_OK) { DEBUG_WARN("CAN", "CAN_SEND SPI失败: ch=%u id=0x%08lX err=%s", (unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret)); - if (gd32_err_out) *gd32_err_out = 0U; /* SPI 失败时清零 */ - return -2; /* SPI 通信失败 */ + if (gd32_err_out) { + *gd32_err_out = 0U; + } + return -2; } if (resp_type == SPI_OTA_RSP_ACK) { + if (gd32_err_out) { + *gd32_err_out = 0U; + } if (recv_buf == nullptr || recv_max == 0U) { - if (gd32_err_out) *gd32_err_out = 0U; return static_cast(resp_len); } const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max; - if (gd32_err_out) *gd32_err_out = 0U; return static_cast(copied_len); } - /* resp_type == ERROR: 读取 GD32 返回的错误码 */ - uint8_t gd32_err = (resp_len > 0 && recv_buf) ? recv_buf[0] : 0xFF; - if (gd32_err_out) *gd32_err_out = gd32_err; /* 透传细分错误码 */ + + /* ERROR:细分码在 payload[0];不依赖调用方是否提供 recv_buf */ + const uint8_t gd32_err = (resp_len > 0U) ? spi_rx[0] : 0xFFU; + if (gd32_err_out) { + *gd32_err_out = gd32_err; + } DEBUG_WARN("CAN", "CAN_SEND GD32拒绝: ch=%u id=0x%08lX resp=0x%02X err=0x%02X", (unsigned)channel, (unsigned long)ext_id, resp_type, gd32_err); - return -3; /* GD32 返回 ERROR */ + return -3; } -constexpr uint8_t kSpiCanFeedbackType = 0xC1U; -constexpr uint8_t kSpiCanSetModeType = 0xC2U; -constexpr size_t kFeedbackPayloadLen = 123U; // 与 GD32 协议表对齐 (kTxBufSize=128 - overhead=5) - -static inline uint32_t readU32Le(const uint8_t *p) { - return static_cast(p[0]) - | (static_cast(p[1]) << 8) - | (static_cast(p[2]) << 16) - | (static_cast(p[3]) << 24); +/** @brief 小端读 uint32(0xC1 固定头/条目 id)。 */ +static inline uint32_t readU32Le(const uint8_t *p) +{ + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); } } // namespace /** * @brief 发送一帧 CANFD 数据,可选等待并读取响应。 - * 通过 SPI 协议帧发送给 GD32 APP,并读取 ACK/ERROR 响应。 - * 返回值: >=0 成功(接收数据长度), -1 参数错误, -2 SPI通信失败, -3 GD32拒绝。 - * gd32_err_out 在返回 -3 时输出 GD32 细分错误码,其它情况清零。 + * + * 返回值: >=0 成功(接收数据长度), -1 参数错误, -2 SPI通信失败, -3 GD32拒绝。 + * gd32_err_out 在返回 -3 时输出 GD32 细分错误码,其它情况清零。 + * dlc==0 时允许 data 为 nullptr(仅发 ID 的远程帧/空数据帧)。 */ int canFdSend(can_channel_t channel, uint32_t ext_id, const uint8_t *data, uint8_t dlc, @@ -86,21 +116,31 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, uint32_t timeout_ms, uint8_t *gd32_err_out) { - if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) { - if (gd32_err_out) *gd32_err_out = 0U; + if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC) { + if (gd32_err_out) { + *gd32_err_out = 0U; + } return -1; } - return sendCanFrameOverSpi(channel, ext_id, data, dlc, recv_buf, recv_max, timeout_ms, gd32_err_out); + /* dlc>0 时 data 必填;dlc==0 允许空指针 */ + if (dlc > 0U && data == nullptr) { + if (gd32_err_out) { + *gd32_err_out = 0U; + } + return -1; + } + return sendCanFrameOverSpi(channel, ext_id, data, dlc, + recv_buf, recv_max, timeout_ms, gd32_err_out); } /** * @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。 * - * GD32 APP 通过 requestEsp32Read(0xC1) 主动推送每页反馈,ESP32 收到后 - * 调用本函数解析: - * page=0 → 重置 out,填充 28 字节固定头 + 首批条目; - * page=1 → 在已有条目后追加。 - * 调用方按通道维护 entryIdx,page=0 时会被重置为 0。 + * page=0 → 清空 entries、重置 entryIdx,填充 28 字节固定头 + 首批条目; + * page=1 → 校验 channel 一致后在已有条目后追加。 + * 调用方按通道维护 entryIdx。 + * + * @return 0 成功;-1 参数/页号/通道/截断导致无法解析。 */ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, can_feedback_result_t *out, uint8_t *entryIdx) @@ -109,8 +149,7 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, return -1; } - constexpr uint8_t kFixedHeader = 28U; - if (len < kFixedHeader) { + if (len < kFeedbackFixedHeader) { return -1; } @@ -119,10 +158,15 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, if (channel >= CAN_CHANNEL_MAX) { return -1; } + /* GD32 仅 page 0/1;非法页拒绝,避免当追加污染缓冲 */ + if (page > kFeedbackMaxPage) { + return -1; + } - /* page=0:重置并解析固定头 */ if (page == 0U) { + /* 防 total 变少时 HTTP 读到上一轮残留 ID */ *entryIdx = 0U; + memset(out->entries, 0, sizeof(out->entries)); out->stats.channel = channel; out->stats.mode = payload[4]; out->stats.snapshot_seq = readU32Le(&payload[5]); @@ -134,13 +178,21 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, out->stats.rec = payload[26]; out->stats.bus_state = payload[27]; out->total_count = payload[2]; + } else { + /* page>=1:必须与 page0 已建立的通道一致 */ + if (out->stats.channel != channel) { + return -1; + } } - /* 解析本页条目并追加 */ const uint8_t entryCount = payload[3]; - uint16_t offset = kFixedHeader; + const uint8_t idx_before = *entryIdx; + uint16_t offset = kFeedbackFixedHeader; + for (uint8_t i = 0U; i < entryCount && *entryIdx < CANFD_ID_SNAPSHOT_MAX; ++i) { - if (offset + 8U > len) break; + if (offset + 8U > len) { + break; + } id_snapshot_entry_t &e = out->entries[*entryIdx]; e.id = readU32Le(&payload[offset]); @@ -149,11 +201,16 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, e.fd_flag = (flags & 0x01U) ? 1U : 0U; e.brs = (flags & 0x02U) ? 1U : 0U; e.esi = (flags & 0x04U) ? 1U : 0U; + /* 线协议不携带 per-ID seq/rx_count,显式置 0 避免脏读 */ + e.seq = 0U; + e.rx_count = 0U; const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX) ? e.dlc : static_cast(CANFD_ID_SNAPSHOT_DATA_MAX); const uint16_t entrySize = static_cast(8U + dataLen); - if (offset + entrySize > len) break; + if (offset + entrySize > len) { + break; + } memcpy(e.data, &payload[offset + 8U], dataLen); for (uint8_t d = dataLen; d < CANFD_ID_SNAPSHOT_DATA_MAX; ++d) { @@ -164,15 +221,26 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, (*entryIdx)++; } + /* 声明了条目但一个都没完整解析 → 截断/坏帧 */ + if (entryCount > 0U && *entryIdx == idx_before) { + return -1; + } + out->valid = 1; return 0; } +/** + * @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。 + * @return 0 成功;-1 参数非法或 SPI/应答失败。 + */ int canFdSetRxMode(can_rx_mode_t mode, can_rx_mode_t *actual, uint32_t timeout_ms) { - if (mode != CAN_RX_MODE_ALL_PASS && mode != CAN_RX_MODE_FILTER_ID && mode != CAN_RX_MODE_BRIDGE_0_TO_12) { + if (mode != CAN_RX_MODE_ALL_PASS && mode != CAN_RX_MODE_FILTER_ID + && mode != CAN_RX_MODE_BRIDGE_0_TO_12) { + DEBUG_WARN("CAN", "SET_MODE 非法 mode=%u", (unsigned)mode); return -1; } @@ -181,8 +249,7 @@ int canFdSetRxMode(can_rx_mode_t mode, uint8_t resp_type = 0U; size_t resp_len = 0U; - /* 两步法破解延迟一拍:发命令一次 + poll 真实响应。 - * 0xC2 幂等,但两步法避免无效重试。 */ + /* 两步法:发命令一次 + poll 真实响应。0xC2 幂等,但避免无效重试。 */ esp_err_t ret = SpiMaster::instance().transact( kSpiCanSetModeType, &req, 1U, @@ -194,5 +261,11 @@ int canFdSetRxMode(can_rx_mode_t mode, } return 0; } + + DEBUG_WARN("CAN", "SET_MODE 失败: want=%u spi=%s resp=0x%02X len=%u", + (unsigned)mode, + esp_err_to_name(ret), + (unsigned)resp_type, + (unsigned)resp_len); return -1; } diff --git a/main/transport/canfd_bridge.h b/main/transport/canfd_bridge.h index a31a880..a92e48b 100644 --- a/main/transport/canfd_bridge.h +++ b/main/transport/canfd_bridge.h @@ -30,7 +30,7 @@ typedef struct { can_channel_t channel; ///< CAN 通道 (0~2) uint32_t ext_id; ///< 扩展帧 ID (29bit, Big-Endian 存储) uint8_t data[CAN_MAX_DLC]; ///< CAN 数据负载 (最大 64 字节) - uint8_t dlc; ///< 数据长度 (1~64) + uint8_t dlc; ///< 数据长度 (0~64) } can_frame_t; /** @@ -38,8 +38,8 @@ typedef struct { * 内部通过 SpiMaster 的公共 SPI 接口完成传输。 * @param channel CAN 通道编号(0~2)。 * @param ext_id 扩展帧 ID(29 位)。 - * @param data CAN 数据负载。 - * @param dlc 数据长度。 + * @param data CAN 数据负载;dlc==0 时可为 nullptr。 + * @param dlc 数据长度(0~64);0 表示空数据帧,仍发送 ID。 * @param recv_buf 接收数据缓冲区(可为 nullptr)。 * @param recv_max 接收缓冲区最大长度。 * @param timeout_ms 超时时间(毫秒)。 @@ -61,11 +61,14 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, /** * @brief 单个不重复 ID 的最新帧快照条目。 + * + * 0xC1 线协议条目:id(4LE)+dlc(1)+flags(1)+rsvd(2)+data(dlc)。 + * seq / rx_count 未上线,解析后恒为 0(保留字段便于日后扩协议)。 */ typedef struct { - uint32_t id; - uint32_t seq; - uint32_t rx_count; + uint32_t id; ///< CAN ID + uint32_t seq; ///< 线协议未携带,解析后恒 0 + uint32_t rx_count; ///< 线协议未携带,解析后恒 0 uint8_t dlc; uint8_t fd_flag; uint8_t brs; @@ -77,8 +80,6 @@ typedef struct { // CAN 接收过滤模式 & 1Hz 反馈快照 // 与 GD32 DCU_APP SPI 协议帧 0xC1/0xC2 配套 // ============================================================ -#define SPI_CAN_FEEDBACK_LAST_DATA_BYTES 93 ///< 反馈快照中最近一帧数据字节数(受 SPI 128B 应答帧约束(kTxBufSize=128 - overhead=5 - 前缀30 = 93)) - typedef enum { CAN_RX_MODE_ALL_PASS = 0, ///< 默认:禁用 ID 过滤,三路全部接收 CAN_RX_MODE_FILTER_ID = 1, ///< 按 CONFIG_CAN_RX_FILTER_ID/MASK 做标准帧过滤 @@ -115,24 +116,27 @@ typedef struct { * @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。 * * GD32 APP 通过 requestEsp32Read(0xC1) 主动推送每页反馈,ESP32 收到后 - * 调用本函数解析。page=0 时重置 out 并填充固定头 + 首批条目; - * page=1 时在已有条目后追加。每页解析后置 out->valid=1。 + * 调用本函数解析: + * - page=0:清空 entries、重置 entryIdx,填固定头 + 首批条目; + * - page=1:校验 channel 与 page0 一致后追加; + * - page>1 或声明条目却截断无法解析 → 失败。 + * 成功时置 out->valid=1。 * * @param payload GD32 推送帧的 payload([channel, page, total, count, mode, seq, ...])。 * @param len payload 长度。 * @param out 累积结果(跨 page 0/1 累积)。 * @param entryIdx 当前已填充条目数(page 0 时被重置为 0,调用方按通道维护)。 - * @return 0 成功,<0 失败(payload 过短或通道非法)。 + * @return 0 成功,<0 失败(payload 过短、页号/通道非法或条目截断)。 */ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, can_feedback_result_t *out, uint8_t *entryIdx); /** - * @brief 切换 GD32 三路 CAN 的接收过滤模式。 - * @param mode 目标模式(0=全收,1=按 ID 过滤)。 + * @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。 + * @param mode 目标模式(0=全收,1=按 ID 过滤,2=CAN0 桥接到 1/2)。 * @param actual 可选输出,GD32 实际生效模式。 * @param timeout_ms SPI 事务超时。 - * @return 0 成功,<0 失败。 + * @return 0 成功,<0 失败(失败时打 WARN 日志)。 */ int canFdSetRxMode(can_rx_mode_t mode, can_rx_mode_t *actual, diff --git a/main/transport/protocol.cpp b/main/transport/protocol.cpp index a74f4c1..2b7fd7d 100644 --- a/main/transport/protocol.cpp +++ b/main/transport/protocol.cpp @@ -1,18 +1,16 @@ /* - * protocol.cpp - 协议层实现 - * T2.1, T2.2, T1.3 - * ESP-IDF 6.0 适配版本 - 使用硬件 AES-128-CBC + 硬件 RNG + * protocol.cpp - OTA 握手辅助实现 * - * 加密实现使用 ESP32-C3 硬件 AES 加速器(通过 mbedtls port) + * 当前保留的接口: + * - init :硬件 RNG 填充扰码常数 + * - scrambleGenerate :server nonce 生成 + * - sessionKeyInit :会话密钥写入 + * - sessionKeyClear :会话密钥安全擦除 * - * 当前保留的接口:init、scrambleGenerate(nonce 生成)、 - * sessionKeyInit / sessionKeyClear(会话密钥生命周期管理)。 - * 帧构建、AES-CBC 加解密、CRC16 等中间件代码已移除。 + * 帧构建、AES-ECB/CBC 加解密、CRC16 等中间件代码已迁出(见 ota_relay / spi_link_v2)。 */ #include -#include #include "esp_random.h" -#include "aes/esp_aes.h" #include "app_config.h" #include "protocol.h" #include "monitor/debug_log.h" @@ -20,8 +18,9 @@ // ============================================================ // Protocol 单例 // ============================================================ + /** - * @brief 获取 Protocol 单例实例。 + * @brief 获取 Protocol 单例实例(Meyers 单例)。 * @return Protocol 单例引用。 */ Protocol& Protocol::instance() @@ -31,23 +30,45 @@ Protocol& Protocol::instance() } /** - * @brief 初始化协议层,打印初始化信息并设置初始化标志。 + * @brief 抗优化密钥/敏感缓冲擦除。 + * + * 普通 memset 可能被编译器当作「无后续读取」而消除; + * 通过 volatile 逐字节写 0,保证运行时真正清零。 + * + * @param p 起始地址;nullptr 忽略。 + * @param n 字节数。 + */ +void Protocol::secureWipe(void *p, size_t n) +{ + if (p == nullptr || n == 0U) { + return; + } + volatile uint8_t *v = static_cast(p); + while (n > 0U) { + *v++ = 0; + --n; + } +} + +/** + * @brief 初始化协议层,打印初始化信息并设置初始化标志。 + * + * 使用硬件 RNG 填充 m_scrambleConst。完整常数不再以 INFO 打 hex, + * 避免 UDP/串口日志侧信道;需要核对时开 DEBUG_DBG。 */ void Protocol::init() { - if (m_initialized) return; + if (m_initialized) { + return; + } - DEBUG_INFO("PROT", "=== 初始化协议层 ==="); - DEBUG_INFO("PROT", "协议层功能: nonce 扰码 + 会话密钥管理"); + DEBUG_INFO("PROT", "=== 初始化协议层(nonce 扰码 + 会话密钥管理)==="); esp_fill_random(m_scrambleConst, SCRAMBLE_CONST_LEN); - { - char hex[SCRAMBLE_CONST_LEN * 2 + 1] = {0}; - for (size_t i = 0; i < SCRAMBLE_CONST_LEN; ++i) - snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", m_scrambleConst[i]); - DEBUG_INFO("PROT", "SCRAMBLE_CONST (RNG): %s", hex); - } + /* 不把完整扰码常数打到 INFO/UDP:减小日志侧信道;需要调试时开 DBG。 */ + DEBUG_DBG("PROT", "SCRAMBLE_CONST ready (%u B from HW RNG)", + (unsigned)SCRAMBLE_CONST_LEN); m_initialized = true; } @@ -57,13 +78,28 @@ void Protocol::init() * @param timestamp 当前时间戳(tick)。 * @param mem_addr 内存地址(增加随机性)。 * @param rand_seed 随机种子。 - * @param nonce_out 输出 16 字节 nonce 缓冲区。 + * @param nonce_out 输出 16 字节 nonce 缓冲区;为 nullptr 时直接返回。 + * + * 混合步骤概要: + * 1) state = timestamp|mem_addr|rand_seed|scrambleConst[0..3](小端) + * 2) 3 轮:逐字节 XOR 全表 scrambleConst + 循环左移 1 + * 3) 2 轮:按 32-bit 字 XOR 完整 4B scrambleConst(memcpy 避免未对齐访问) + * + * @note 非标准 KDF;仅 OTA 握手用。未 init 时自动 init()。 */ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, uint32_t rand_seed, uint8_t *nonce_out) { + if (nonce_out == nullptr) { + return; + } + if (!m_initialized) { + init(); + } + uint8_t state[16]; + /* 宿主小端布局;ESP32 / GD32 均为 LE,与对端一致。 */ memcpy(&state[0], ×tamp, 4); memcpy(&state[4], &mem_addr, 4); memcpy(&state[8], &rand_seed, 4); @@ -72,14 +108,22 @@ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, for (int round = 0; round < 3; round++) { for (int i = 0; i < 16; i++) { state[i] ^= m_scrambleConst[i]; - state[i] = (state[i] << 1) | (state[i] >> 7); + state[i] = (uint8_t)((state[i] << 1) | (state[i] >> 7)); } } + /* 按 32-bit 字再混一轮完整 4B 扰码(memcpy 避免未对齐 uint32_t*)。 */ for (int round = 0; round < 2; round++) { - uint32_t *words = (uint32_t *)state; for (int i = 0; i < 4; i++) { - words[i] ^= m_scrambleConst[i * 4 % 16] | ((uint32_t)m_scrambleConst[i * 4 + 1] << 8); + uint32_t w = 0; + memcpy(&w, &state[i * 4], 4); + const uint32_t mix = + (uint32_t)m_scrambleConst[i * 4] + | ((uint32_t)m_scrambleConst[i * 4 + 1] << 8) + | ((uint32_t)m_scrambleConst[i * 4 + 2] << 16) + | ((uint32_t)m_scrambleConst[i * 4 + 3] << 24); + w ^= mix; + memcpy(&state[i * 4], &w, 4); } } @@ -91,24 +135,30 @@ void Protocol::scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, * @param sess 会话上下文指针。 * @param k_session 16 字节会话密钥。 * @return 0 成功,-1 参数无效。 + * + * 先 secureWipe 再拷贝,避免旧 key 残留。 */ int Protocol::sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session) { - if (!sess || !k_session) return -1; - - memset(sess, 0, sizeof(session_ctx_t)); - memcpy(sess->key, k_session, 16); + if (sess == nullptr || k_session == nullptr) { + return -1; + } + secureWipe(sess, sizeof(*sess)); + memcpy(sess->key, k_session, SESSION_KEY_LEN); return 0; } /** * @brief 清除会话上下文中的密钥数据。 - * @param sess 会话上下文指针。 + * @param sess 会话上下文指针;为 nullptr 时忽略。 + * + * 使用 secureWipe,避免编译器优化掉「无读回的」密钥清零。 */ void Protocol::sessionKeyClear(session_ctx_t *sess) { - if (sess) { - memset(sess->key, 0, 16); + if (sess == nullptr) { + return; } + secureWipe(sess->key, SESSION_KEY_LEN); } diff --git a/main/transport/protocol.h b/main/transport/protocol.h index 929014f..92197ff 100644 --- a/main/transport/protocol.h +++ b/main/transport/protocol.h @@ -1,75 +1,108 @@ /* - * protocol.h - 帧格式、CRC16、AES 加密解密 - * T2.2, T1.3 - * ESP-IDF 6.0 适配版本 - 使用硬件 AES-128-CBC + 硬件 RNG + * protocol.h - OTA 握手辅助(nonce 扰码 + 会话密钥生命周期) * - * Protocol 类封装协议层功能。 + * 本模块刻意保持轻量(原「帧格式 / CRC16 / AES-CBC」中间件已迁出): + * - 不负责 SPI Link v2 帧 / CRC32 + * - 不负责 AES 加解密(OTA 数据包 AES-ECB 在 ota_relay 内) + * - 仅提供:RNG 扰码常数、server nonce 生成、session key 存清 + * + * AES 主密钥 MASTER_KEY[] 见 app_config.h。 */ #ifndef PROTOCOL_H #define PROTOCOL_H #include -#include +#include #include "app_config.h" -// 安全常量(定义在 app_config.h 中) -inline constexpr size_t MASTER_KEY_HERE_LEN = 16; ///< AES-128 主密钥长度(字节) -inline constexpr size_t CHIP_UID_LEN = 12; ///< GD32 芯片 UID 长度(字节) +/** @brief AES-128 / 会话密钥长度(字节)。 */ +inline constexpr size_t SESSION_KEY_LEN = 16; -// AES 会话上下文 +/** + * @brief OTA 会话密钥上下文。 + * + * 仅缓存协商得到的 K_session;真正加解密在 ota_relay 中用该 key 做 AES-ECB。 + */ typedef struct { - uint8_t key[16]; // 会话密钥 K_session + uint8_t key[SESSION_KEY_LEN]; ///< 会话密钥 K_session(16 字节) } session_ctx_t; /** - * @brief 协议层类(单例) + * @brief OTA 握手辅助类(单例) * - * 封装 CRC16、AES-128-CBC、帧构建/解析、会话管理等功能。 - * 大部分方法为静态工具方法,单例仅持有初始化标志。 + * 持有 per-boot 扰码常数 m_scrambleConst; + * sessionKeyInit / sessionKeyClear 为无状态静态工具,不依赖单例成员。 */ class Protocol { public: /** - * @brief 获取 Protocol 单例实例。 + * @brief 获取 Protocol 单例实例。 * @return Protocol 单例引用。 */ static Protocol& instance(); /** - * @brief 初始化协议层。 + * @brief 初始化协议层(幂等)。 + * + * 使用硬件 RNG 填充 m_scrambleConst,供 scrambleGenerate 混合使用。 + * 可重复调用;已初始化则直接返回。 */ void init(); /** - * @brief 生成基于时间戳、地址和随机种子的扰码 nonce。 - * @param timestamp 时间戳。 - * @param mem_addr 内存地址。 + * @brief 生成基于时间戳、地址和随机种子的 16 字节 server nonce。 + * @param timestamp 时间戳(如 FreeRTOS tick)。 + * @param mem_addr 内存地址(增加熵,通常取栈/缓冲区地址)。 * @param rand_seed 随机种子。 - * @param nonce_out 输出的 16 字节 nonce 缓冲区。 + * @param nonce_out 输出缓冲区,至少 16 字节;为 nullptr 时直接返回。 + * + * @note 非标准 KDF,仅作 OTA 握手 nonce,勿当通用密钥派生。 + * @note 若尚未 init(),内部会自动调用 init()。 */ void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, - uint32_t rand_seed, uint8_t *nonce_out); + uint32_t rand_seed, uint8_t *nonce_out); + /** - * @brief 会话密钥初始化,将密钥拷贝到会话上下文。 + * @brief 会话密钥初始化,将密钥拷贝到会话上下文。 * @param sess 会话上下文指针。 - * @param k_session 会话密钥数据。 + * @param k_session 会话密钥数据(至少 SESSION_KEY_LEN 字节)。 * @return 成功返回 0,参数无效返回 -1。 */ static int sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session); + /** - * @brief 清除会话上下文中的密钥数据。 - * @param sess 会话上下文指针。 + * @brief 清除会话上下文中的密钥数据(抗优化擦除)。 + * @param sess 会话上下文指针;为 nullptr 时忽略。 */ static void sessionKeyClear(session_ctx_t *sess); private: + /** + * @brief 私有默认构造函数。 + */ Protocol() = default; + /** + * @brief 私有析构函数。 + */ ~Protocol() = default; + /** + * @brief 禁止复制构造。 + */ Protocol(const Protocol&) = delete; + /** + * @brief 禁止复制赋值。 + */ Protocol& operator=(const Protocol&) = delete; - uint8_t m_scrambleConst[SCRAMBLE_CONST_LEN] = {0}; - bool m_initialized = false; + /** + * @brief 抗编译器优化的内存擦除(volatile 逐字节写 0)。 + * @param p 起始地址;nullptr 忽略。 + * @param n 字节数。 + */ + static void secureWipe(void *p, size_t n); + + uint8_t m_scrambleConst[SCRAMBLE_CONST_LEN] = {0}; ///< per-boot 扰码常数(init 时 RNG 填充) + bool m_initialized = false; ///< 是否已 init }; -#endif // PROTOCOL_H \ No newline at end of file +#endif // PROTOCOL_H diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index d05ee6d..32c4f0b 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -8,7 +8,9 @@ */ #include #include +#include #include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "freertos/queue.h" @@ -29,13 +31,16 @@ static constexpr size_t kSpiTransferMaxBytes = 256U; +/** @brief submitJob 堆 Job 与 worker 之间的 finished/abandoned 交接。 */ +static portMUX_TYPE s_spiJobMux = portMUX_INITIALIZER_UNLOCKED; + /** * @brief 发送路径 CS 时钟字节数。 * Boot TX DMA 按满缓冲预装(kTxBufSize=128),主机过短时钟会: * - 截断延迟一拍的响应; * - 两段变长读若中途释放 CS,Boot 会把 s_txPending 当已交付清掉 → 后续全 0xFF。 - * 下限 64:覆盖 HELLO_ACK(~26)/短 ACK;上限 SPI_OTA_FRAME_MAX。 - * App 大帧仍可由 poll 路径按 128/256 读。 + * 下限 64:覆盖 HELLO_ACK(~26)/短 ACK;上限 SPI_OTA_FRAME_MAX(256)。 + * APP 侧 TX 满缓冲 256:poll/ring 读预算见 spiRespReadBudget()。 */ static size_t spiXferBytes(size_t frame_len) { @@ -49,10 +54,19 @@ static size_t spiXferBytes(size_t frame_len) return n; } -/** @brief 读响应时单 CS 时钟预算(Boot 满缓冲 128;App 可达 256)。 */ +/** + * @brief 读响应时单 CS 时钟预算(按最近 HELLO 的 GD32 role 分支)。 + * - BOOT:TX DMA 满缓冲 128 → 读 128,避免过长时钟 underrun + * - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断 CRC + * unknown 偏 APP:宁长勿短(BOOT 短帧在 256 时钟下尾部 0xFF,仍可 parse)。 + */ static size_t spiRespReadBudget(void) { - return 128U; + const uint8_t role = SpiMaster::instance().gd32Role(); + if (role == V2_ROLE_BOOT) { + return SPI_BOOT_TX_BUF_SIZE; + } + return SPI_APP_TX_BUF_SIZE; } /** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。 @@ -63,7 +77,7 @@ static int IRAM_ATTR readReadyLevelRaw(void) return (REG_READ(GPIO_IN_REG) >> SPI_READY_GPIO) & 0x1; } -/* 前向声明:waitSpiDataReady 定义在文件下方(waitSpiReady 之后), +/* 前向声明:waitSpiDataReady 定义在文件下方, * 但 serviceRingOnce / otaPollResponseUnlocked 等更早出现的方法需要它。 */ static esp_err_t waitSpiDataReady(uint32_t timeout_ms); @@ -244,15 +258,37 @@ void SpiMaster::executeJob(Job &job) case JobKind::WaitBoot: job.result = waitForBootloaderReady(job.timeout_ms); break; - case JobKind::ServiceRing: - job.result = serviceRingOnce(); - break; default: job.result = ESP_ERR_INVALID_ARG; break; } } +void SpiMaster::finishHeapJob(Job *job) +{ + if (job == nullptr) { + return; + } + /* 先 give,再标 finished;提交方 take 成功后会 spin 等 finished 再 free。 */ + if (job->done) { + (void)xSemaphoreGive(job->done); + } + if (!job->heap_allocated) { + return; + } + taskENTER_CRITICAL(&s_spiJobMux); + job->finished = true; + const bool free_it = job->abandoned; + taskEXIT_CRITICAL(&s_spiJobMux); + if (free_it) { + if (job->done) { + vSemaphoreDelete(job->done); + job->done = nullptr; + } + free(job); + } +} + esp_err_t SpiMaster::submitJob(Job &job) { if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) { @@ -260,31 +296,72 @@ esp_err_t SpiMaster::submitJob(Job &job) return job.result; } - StaticSemaphore_t done_buf; - job.done = xSemaphoreCreateBinaryStatic(&done_buf); - if (job.done == nullptr) { - executeJob(job); - return job.result; + /* 堆 Job + 动态 binary sem:避免调用方超时返回后栈上 Job/done 被 worker UAF。 */ + Job *heap_job = static_cast(calloc(1, sizeof(Job))); + if (heap_job == nullptr) { + DEBUG_ERR("SPI", "submitJob: calloc failed kind=%u", (unsigned)job.kind); + return ESP_ERR_NO_MEM; + } + *heap_job = job; + heap_job->heap_allocated = true; + heap_job->finished = false; + heap_job->abandoned = false; + heap_job->result = ESP_FAIL; + heap_job->done = xSemaphoreCreateBinary(); + if (heap_job->done == nullptr) { + free(heap_job); + DEBUG_ERR("SPI", "submitJob: done sem create failed"); + return ESP_ERR_NO_MEM; } - (void)xSemaphoreTake(job.done, 0); - Job *ptr = &job; const TickType_t qwait = pdMS_TO_TICKS( (job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 2000U); - if (xQueueSend(m_jobQueue, &ptr, qwait) != pdTRUE) { + if (xQueueSend(m_jobQueue, &heap_job, qwait) != pdTRUE) { DEBUG_WARN("SPI", "worker job queue full/timeout kind=%u", (unsigned)job.kind); + vSemaphoreDelete(heap_job->done); + free(heap_job); return ESP_ERR_TIMEOUT; } - /* 唤醒可能阻塞在 notify wait 的 worker */ xTaskNotify(m_workerTask, 0x10U, eSetBits); const TickType_t dwait = pdMS_TO_TICKS( (job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 10000U); - if (xSemaphoreTake(job.done, dwait) != pdTRUE) { - DEBUG_ERR("SPI", "worker job done timeout kind=%u", (unsigned)job.kind); - return ESP_ERR_TIMEOUT; + if (xSemaphoreTake(heap_job->done, dwait) == pdTRUE) { + const esp_err_t result = heap_job->result; + /* worker 在 give 后才写 finished;等其退出临界区后再由本侧独占 free。 */ + for (;;) { + taskENTER_CRITICAL(&s_spiJobMux); + const bool fin = heap_job->finished; + taskEXIT_CRITICAL(&s_spiJobMux); + if (fin) { + break; + } + vTaskDelay(1); + } + vSemaphoreDelete(heap_job->done); + heap_job->done = nullptr; + free(heap_job); + return result; } - return job.result; + + /* 超时:标记 abandoned;若 worker 已跑完则本侧 free,否则 worker finishHeapJob free。 */ + taskENTER_CRITICAL(&s_spiJobMux); + heap_job->abandoned = true; + const bool already_done = heap_job->finished; + taskEXIT_CRITICAL(&s_spiJobMux); + + if (already_done) { + (void)xSemaphoreTake(heap_job->done, 0); + const esp_err_t result = heap_job->result; + vSemaphoreDelete(heap_job->done); + heap_job->done = nullptr; + free(heap_job); + return result; + } + + DEBUG_ERR("SPI", "worker job done timeout kind=%u(堆 Job 交 worker 释放)", + (unsigned)job.kind); + return ESP_ERR_TIMEOUT; } void SpiMaster::workerEntry(void *arg) @@ -312,9 +389,7 @@ void SpiMaster::workerLoop() Job *job = nullptr; if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(1)) == pdTRUE && job != nullptr) { executeJob(*job); - if (job->done) { - xSemaphoreGive(job->done); - } + finishHeapJob(job); continue; } @@ -333,76 +408,38 @@ void SpiMaster::workerLoop() } /** - * @brief GPIO 边沿中断回调(SPIready 线跳变)。 - * - * - 下降沿:GD32 主动按铃(请求 ESP32 来读)。若当前空闲则置 - * m_gd32RequestPending 并唤醒 SPI 任务。 - * - 上升沿:GD32 释放 SPIready(WAITING_RESPONSE 态),唤醒等待任务。 + * @brief GPIO 边沿中断回调(DATA_READY / PB6)。 * + * 仅处理下降沿:GD32 有待读数据(按铃)。若本端 IDLE 则置 + * m_gd32RequestPending 并唤醒 SPI worker。上升沿不再使用(旧 waitSpiReady 已删)。 * ISR 内仅做标志翻转 + 任务通知,不做 SPI 传输。 */ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) { SpiMaster *self = static_cast(arg); if (!self) { return; } - int level = readReadyLevelRaw(); - if (level == 0) { - /* 下降沿 */ - if (self->m_suppressRingService) { - /* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */ - } else if (self->m_linkState.acceptRing()) { - /* GD32 按铃:仅 IDLE 态接受 */ - self->m_gd32RequestPending = true; - if (self->m_spiTaskHandle) { - BaseType_t hp = pdFALSE; - xTaskNotifyFromISR(self->m_spiTaskHandle, 0x01U, - eSetBits, &hp); - portYIELD_FROM_ISR(hp); - } - } - } else { - /* 上升沿 */ - if (self->m_linkState.state() == HandshakeState::WAITING_RESPONSE) { - /* GD32 释放 SPIready,可发起新 CS */ - if (self->m_spiTaskHandle) { - BaseType_t hp = pdFALSE; - xTaskNotifyFromISR(self->m_spiTaskHandle, 0x02U, - eSetBits, &hp); - portYIELD_FROM_ISR(hp); - } - } + /* 只关心低电平(下降沿后采样):有数据待读 */ + if (readReadyLevelRaw() != 0) { + return; } -} - -/** - * @brief 等 SPIready 适合发起下一笔主机事务(v2 DATA_READY 语义)。 - * - * v2:PB6 低 = GD32 有待读数据;高 = 队列空/空闲。 - * - 发新命令前:若 PB6 已高,立即返回;若仍低,可能是未读走的响应, - * 短暂等待其升高(BOOT 交付后应拉高)。超时不阻塞——CS 本身互斥。 - * - 注意:poll 读响应路径不应调用本函数等 HIGH;应等 LOW 或直接读。 - */ -esp_err_t SpiMaster::waitSpiReady(uint32_t timeout_ms) -{ - if (timeout_ms == 0U) { return ESP_OK; } - - /* 已高,立即返回 */ - if (readReadyLevelRaw() == 1) { return ESP_OK; } - - /* PB6 低:可能有未读响应。短等升高;BOOT 修好交付清 pending 后应很快。 - * 超时返回 ESP_ERR_TIMEOUT,禁止调用方假装“已就绪”后盲发。 */ - const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); - while (xTaskGetTickCount() < deadline) { - if (readReadyLevelRaw() == 1) { return ESP_OK; } - vTaskDelay(pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1); + if (self->m_suppressRingService) { + /* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */ + return; + } + if (!self->m_linkState.acceptRing()) { + return; + } + self->m_gd32RequestPending = true; + if (self->m_spiTaskHandle) { + BaseType_t hp = pdFALSE; + xTaskNotifyFromISR(self->m_spiTaskHandle, 0x01U, eSetBits, &hp); + portYIELD_FROM_ISR(hp); } - m_stats.ready_timeouts++; - return ESP_ERR_TIMEOUT; } /** * @brief 等 GD32 拉低 PB6(有待读响应)。 - * v2 DATA_READY:低 = 有数据,与 waitSpiReady(等高)语义相反。 + * v2 DATA_READY:低 = 有数据;高 = 队列空。TX 前不等 HIGH(CS 即互斥)。 * @return ESP_OK 已低;ESP_ERR_TIMEOUT 超时仍为高(调用方可再决定是否盲 poll)。 */ static esp_err_t waitSpiDataReady(uint32_t timeout_ms) @@ -538,7 +575,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len, * * 不用两段 KEEP_ACTIVE:Boot 在 CS 释放时无论是否读完都清 s_txPending, * 中途断 CS 会丢 HELLO_ACK,后续 MISO 全 0xFF(线上已复现)。 - * 预算 128B 对齐 Boot TX DMA 满缓冲;App 大 EVENT 仍可到 256(out_max)。 + * 时钟预算见 spiRespReadBudget():BOOT=128,APP=256。 */ esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len, uint32_t timeout_ms) @@ -762,12 +799,12 @@ esp_err_t SpiMaster::init() ready_cfg.mode = GPIO_MODE_INPUT; /* v2: 仅输入,不再开漏输出 */ ready_cfg.pull_up_en = GPIO_PULLUP_ENABLE; ready_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; - ready_cfg.intr_type = GPIO_INTR_ANYEDGE; /* 双边沿中断 */ + ready_cfg.intr_type = GPIO_INTR_NEGEDGE; /* 仅下降沿:有数据待读 */ esp_err_t gpio_ret = gpio_config(&ready_cfg); if (gpio_ret != ESP_OK) { DEBUG_WARN("SPI", "SPI Ready 握手引脚 GPIO%d 配置失败: %s", SPI_READY_GPIO, esp_err_to_name(gpio_ret)); } else { - DEBUG_INFO("SPI", "SPI Ready 开漏握手已配置: GPIO%d (线与 GD32 PB6)", SPI_READY_GPIO); + DEBUG_INFO("SPI", "DATA_READY 输入已配置: GPIO%d (← GD32 PB6,低=有数据)", SPI_READY_GPIO); } /* 安装 GPIO ISR 服务(全局仅安装一次)并注册边沿回调 */ @@ -883,15 +920,9 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s } } - /* 同步单 CS 传输:按帧长发送;延迟一拍下本笔 RX 为陈旧响应。 */ - m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - /* v2 延迟一拍:PB6 低表示 GD32 有上一笔响应待读,step1 的 transferClocked - * 会自然把它当陈旧响应读走,不需要额外 drain(drain 会多消耗一个 CS 周期, - * 可能误吃掉主循环刚排好的 ACK 导致后续包永久丢 ACK)。 - * - * ★ 不调 waitSpiReady(等 PB6 变高):v2 DATA_READY 下 PB6 低 = GD32 有待发 - * 响应,OTA 中继期间几乎持续为低。旧代码每包忙等 200ms 超时,21 包累计 - * ~4.2s 纯浪费,最终拖垮转发预算导致卡死。CS 本身即互斥,无需额外就绪等待。 */ + /* 同步单 CS 传输:按帧长发送;延迟一拍下本笔 RX 为陈旧响应。 + * v2:PB6 低 = 有待读;陈旧响应由本笔 transferClocked 自然读走(勿额外 drain, + * 以免误吃主循环刚排好的 ACK)。CS 即互斥,TX 前不等 PB6 HIGH。 */ m_linkState.enterBusy(HandshakeState::MASTER_SENDING); const size_t xfer_len = spiXferBytes(tx_len); @@ -1161,17 +1192,23 @@ bool SpiMaster::takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_ return false; } - /* 循环等待:信号量到达后重新持锁取,取空则继续等(防虚假唤醒) */ - const TickType_t deadline_ticks = pdMS_TO_TICKS(timeout_ms); + /* 用绝对截止时间:虚假唤醒/抢事件后按剩余时间等,避免每次重新等满 timeout。 */ + const TickType_t start = xTaskGetTickCount(); + const TickType_t total = pdMS_TO_TICKS(timeout_ms); while (true) { - if (xSemaphoreTake(m_eventSem, deadline_ticks) != pdTRUE) { - return false; /* 超时 */ + const TickType_t elapsed = xTaskGetTickCount() - start; + if (elapsed >= total) { + return false; + } + const TickType_t remain = total - elapsed; + if (xSemaphoreTake(m_eventSem, remain) != pdTRUE) { + return false; } LockGuard lock(m_mutex); if (popEvent(type, payload, max_len, len)) { return true; } - /* 信号量被消费但队列空(可能被并发 takeEvent 抢走),继续等 */ + /* 信号量被消费但队列空(可能被并发 takeEvent 抢走),继续等剩余时间 */ } } @@ -1213,112 +1250,6 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t /*expected_type=*/0xFFU); } -/** - * @brief 带重试退避的事务(SPEC §9.2)。 - * - * 同序号重试最多 3 次,退避 10/30/90ms; - * 3 次失败后 HELLO 重同步,再试 1 次。 - * 调用者须已持有锁。 - */ -esp_err_t SpiMaster::transactWithRetry(uint8_t type, const uint8_t *payload, size_t payload_len, - uint8_t *resp_type, uint8_t *resp_payload, - size_t resp_max, size_t *resp_len, uint32_t timeout_ms) -{ - /* 保存原始序号用于重发时恢复 */ - const uint8_t saved_tx_seq = m_txSeq; - static constexpr uint32_t kBackoffMs[] = { 10U, 30U, 90U }; - static constexpr uint32_t kMaxRetries = 3U; - - for (uint32_t retry = 0U; retry <= kMaxRetries; ++retry) { - if (retry > 0U) { - const uint32_t backoff = kBackoffMs[(retry - 1U) < 3U ? (retry - 1U) : 2U]; - vTaskDelay(pdMS_TO_TICKS(backoff)); - m_stats.retries++; - /* 重发时恢复原序号 */ - m_txSeq = saved_tx_seq; - } - - /* 使用两步法(持锁已由调用者完成,用 Unlocked 版本) */ - const uint32_t send_timeout = (timeout_ms > 500U) ? 500U : timeout_ms; - esp_err_t send_ret = otaTransferUnlocked(type, payload, payload_len, - nullptr, nullptr, 0U, nullptr, send_timeout, - /*expect_stale=*/true); - if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) { - if (retry < kMaxRetries) { continue; } - return send_ret; - } - - const uint32_t poll_timeout = (timeout_ms > send_timeout) - ? (timeout_ms - send_timeout) : 500U; - esp_err_t ret = otaPollResponseUnlocked(resp_type, resp_payload, - resp_max, resp_len, poll_timeout, - /*expected_type=*/0xFFU); - if (ret == ESP_OK) { - return ESP_OK; - } - - if (ret == ESP_ERR_TIMEOUT) { - m_stats.ready_timeouts++; - } - - /* 3 次重试后尝试 HELLO 重同步(最后一次循环) */ - if (retry == kMaxRetries - 1U) { - DEBUG_WARN("SPI", "transactWithRetry: %u 次失败, HELLO 重同步...", (unsigned)retry); - m_v2Active = false; - /* HELLO 需要锁——但调用者已持锁。 - * hello() 内部有 LockGuard,会递归死锁。 - * 因此用 hello 的核心逻辑但不加锁:两阶段(发 + dummy 收)。 */ - m_sessionId = esp_random() | 0x00000001U; - m_txSeq = 0; - m_lastSentSeq = 0; - m_rxAckedSeq = 0; - m_expectedRxSeq = 0; - - uint8_t hello_frame[V2_MAX_FRAME]; - size_t hello_len = 0; - if (v2_build_hello_frame(m_sessionId, 0U, hello_frame, &hello_len)) { - /* 延迟一拍连发 2 次,避免 DATA_READY/短读导致全 0xFF */ - const size_t xfer_len = spiXferBytes(hello_len); - for (uint32_t hi = 0U; hi < 2U; ++hi) { - m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - (void)waitSpiReady(SPI_READY_TIMEOUT_MS); - m_linkState.enterBusy(HandshakeState::MASTER_SENDING); - uint8_t tx_p[SPI_OTA_FRAME_MAX]; - uint8_t rx_r[SPI_OTA_FRAME_MAX]; - memset(tx_p, 0xFF, xfer_len); - memcpy(tx_p, hello_frame, hello_len); - memset(rx_r, 0xFF, xfer_len); - esp_err_t hr = transferClocked(tx_p, xfer_len, rx_r, xfer_len, 2000U); - m_linkState.exitBusy(); - if (hr != ESP_OK) { - continue; - } - v2_header_t hdr; - const uint8_t *pl = nullptr; - if (v2_parse_frame(rx_r, xfer_len, &hdr, &pl) && - hdr.type == V2_TYPE_LINK_HELLO && - (hdr.flags & V2_FLAG_RESPONSE) && - hdr.payload_len >= 6U && pl && - v2_get_u32le(&pl[0]) == m_sessionId) { - m_gd32Role = pl[4]; - m_localCredit = pl[5]; - m_remoteCredit = hdr.credit; - m_v2Active = true; - m_stats.resyncs++; - DEBUG_INFO("SPI", "transactWithRetry: HELLO 重同步成功 role=%u", - m_gd32Role); - break; - } - vTaskDelay(pdMS_TO_TICKS(5)); - } - } - } - } - - return ESP_ERR_TIMEOUT; -} - - /** * @brief 通知 GD32 APP 设置 OTA 标志并软复位进入 Bootloader(v2 标准帧)。 * @return ESP_OK 表示成功,否则返回错误码。 @@ -1713,8 +1644,6 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) while (xTaskGetTickCount() <= deadline) { ++attempt; - m_linkState.enterBusy(HandshakeState::WAITING_RESPONSE); - (void)waitSpiReady(SPI_READY_TIMEOUT_MS); m_linkState.enterBusy(HandshakeState::MASTER_SENDING); uint8_t tx_padded[SPI_OTA_FRAME_MAX]; diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index f677bb1..9c896f9 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -4,12 +4,10 @@ * * SpiMaster 类封装 SPI 总线初始化、CANFD 通信和 SPI OTA 协议。 * - * 握手架构(线与 SPIready,GPIO10<->GD32 PB6 开漏单线): - * - 空闲态线 HIGH(双方释放,靠上拉)。 - * - ESP32 主动发起:下拉 SPIready -> 立刻 CS 发请求 -> CS 释放后保持下拉, - * GD32 在 onCsRelease() ISR 内完成帧处理+响应装载后释放 SPIready(高), - * ESP32 GPIO 上升沿中断唤醒后发起 CS#2 读响应。 - * - GD32 主动按铃:下拉 SPIready -> ESP32 下降沿中断唤醒后发起读事务。 + * 握手架构(v2 DATA_READY,GPIO10 ← GD32 PB6 开漏): + * - PB6 仅由 GD32 驱动:低 = 有待读数据,高 = 队列空。ESP32 只输入、不拉线。 + * - 帧同步/许可在帧内 SEQ/ACK_SEQ/CREDIT;CS 本身即互斥,TX 前不再等 HIGH。 + * - GD32 有数据:拉低 DATA_READY → ESP32 下降沿/轮询 → CS 读响应。 */ #ifndef SPI_MASTER_H #define SPI_MASTER_H @@ -22,21 +20,16 @@ #include "freertos/queue.h" #include "freertos/task.h" #include "spi_link_v2.h" +#include "app_config.h" /* SPI_MASTER_CS_GPIO / SPI_READY_GPIO / SPI_OTA_FRAME_MAX 等 */ -// SPI 配置常量 -#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚 -#define SPI_CLOCK_HZ CONFIG_DCU_SPI_CLOCK_HZ // 默认 4MHz,可在 menuconfig 手动提速 -#define SPI_MODE (0) // CPOL=0, CPHA=0 -#define SPI_QUEUE_SIZE (4) ///< SPI 事务队列深度 -#define SPI_READY_GPIO (10) // 线与 SPIready 握手引脚(开漏,GPIO10<->GD32 PB6) -#define SPI_READY_TIMEOUT_MS (200) // 等待 SPIready 上升沿超时(毫秒) -#define SPI_OTA_FRAME_MAX (256) ///< SPI 同步帧最大字节数 +// SPI 时钟来自 menuconfig;其余可调项在 app_config.h +#define SPI_CLOCK_HZ CONFIG_DCU_SPI_CLOCK_HZ -// GD32 SPI 同步帧类型 +// GD32 SPI 业务 TYPE(与 SpiDefs / v2 TYPE 字段一致) #define SPI_OTA_CMD_REQ (0x01) #define SPI_OTA_CMD_DATA (0x02) #define SPI_APP_ENTER_BOOT_CMD (0xB0) -#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5) // GD32 APP 固件须同步修改 ACK 字节为 0xB5 +#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5) #define SPI_OTA_RSP_ACK (0x80) #define SPI_OTA_RSP_WAIT (0x81) #define SPI_OTA_RSP_ERROR (0x82) @@ -103,6 +96,9 @@ public: /** @brief 读取本端剩余可接收 credit(SPEC §10.1,/system/status 展示用)。 */ uint8_t localCredit() const { return m_localCredit; } + /** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */ + TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; } + /** * @brief OTA 中继期间禁止 worker 按铃读。 * @@ -215,24 +211,20 @@ private: spi_device_handle_t m_spiDevice = NULL; SemaphoreHandle_t m_mutex = NULL; - // ── 线与 SPIready 握手状态机 ── - /** 握手状态机枚举。 */ + // ── 本端事务态(非 PB6 电平门控;PB6 仅 DATA_READY 通知)── + /** 本端 SPI 事务状态:仅用于 acceptRing(忙时不接按铃)。 */ enum class HandshakeState : uint8_t { - IDLE = 0, ///< 空闲,线 HIGH - MASTER_SENDING, ///< ESP32 已下拉 SPIready,正在 CS#1 发送请求 - WAITING_RESPONSE, ///< CS#1 完成,等待 GD32 释放 SPIready(上升沿唤醒) - SERVING_GD32_RING, ///< 响应 GD32 按铃,正在读 GD32 请求帧 + IDLE = 0, ///< 空闲,可接受 GD32 按铃 + MASTER_SENDING, ///< 主机正在 CS 发送 + WAITING_RESPONSE, ///< 主机正在 poll/读响应 + SERVING_GD32_RING, ///< 正在读 GD32 EVENT }; /** - * @brief SPI 链路状态机:封装握手状态 + 完成信号逻辑。 + * @brief 本端事务占用标记(v2)。 * - * 线状态语义(对齐 OTA): - * - 线 HIGH = 空闲(双方释放),可发起新事务 - * - 线 LOW = 忙碌(某方下拉占用),禁止发起新事务 - * - * ESP32 作为主动方(transact/appPing):enterBusy() 占用线 → transferClocked → exitBusy() 释放线 - * ESP32 作为按铃响应方(takeEvent):同上 + * DATA_READY(PB6)由 GD32 独占:低=有待读,高=队列空。 + * 本状态机只表示 ESP32 是否正占用 SPI 总线,避免与按铃读并发。 */ class SpiLinkState { public: @@ -279,7 +271,7 @@ private: uint8_t m_eventTail = 0; uint8_t m_eventCount = 0; - // ── 作业提交(调用方栈上 Job + StaticSemaphore,worker 执行)── + // ── 作业提交(跨任务:堆 Job + binary done;worker 串行执行)── enum class JobKind : uint8_t { Transact = 0, Poll, @@ -287,7 +279,6 @@ private: EnterBoot, Ping, WaitBoot, - ServiceRing, }; struct Job { JobKind kind = JobKind::Transact; @@ -305,12 +296,18 @@ private: gd32_version_t *out_version = nullptr; esp_err_t result = ESP_FAIL; SemaphoreHandle_t done = nullptr; + /** submitJob 堆分配的 Job:超时放弃后由 worker 释放,成功路径由提交方释放。 */ + bool heap_allocated = false; + volatile bool finished = false; ///< worker 已 execute + give + volatile bool abandoned = false; ///< 提交方已超时返回,worker 负责 free }; static constexpr UBaseType_t kJobQueueLen = 6; bool onWorker() const; esp_err_t submitJob(Job &job); void executeJob(Job &job); + /** @brief worker 完成堆 Job 后的生命周期收尾(give done + 可能 free)。 */ + void finishHeapJob(Job *job); static void workerEntry(void *arg); void workerLoop(); esp_err_t serviceRingOnce(); @@ -348,18 +345,6 @@ private: SemaphoreHandle_t m_lock; }; - /** - * @brief 等 GD32 释放 SPIready(上升沿),中断驱动非忙等。 - * - * 调用前须将 m_linkState 置为 WAITING_RESPONSE(enterBusy),ISR 上升沿检测到该状态 - * 后发 0x02 通知唤醒本任务。GD32 写 Flash 期间保持 PB6 低,本函数阻塞, - * 写完拉高 → 唤醒 → 继续,自然避免 overrun。 - * - * @param timeout_ms 超时(毫秒),0 表示不等待。 - * @return ESP_OK 线已高;ESP_ERR_TIMEOUT 超时。 - */ - esp_err_t waitSpiReady(uint32_t timeout_ms); - /** @brief GPIO 边沿中断回调(注册到 ESP-IDF GPIO ISR 服务)。 */ static void IRAM_ATTR onReadyEdgeISR(void *arg); @@ -386,12 +371,12 @@ private: uint32_t timeout_ms, uint32_t trans_flags); /** - * @brief 变长读:同 CS 先读 16B Header,再按 LEN 读 payload+CRC。 - * 调用者须已持锁;仅在 DATA_READY 已低(有数据)时调用,禁止盲读。 + * @brief 读一帧响应:单 CS 固定时钟预算(BOOT=128 / APP=256),再 v2_parse 截真实长度。 + * 调用者须已持锁。不用两段 KEEP_ACTIVE(Boot 中途断 CS 会清 pending → 全 0xFF)。 * @param out_frame 完整帧输出(含头+payload+CRC) * @param out_max 输出缓冲容量 * @param out_len 实际帧长 - * @param timeout_ms 单段 SPI 超时 + * @param timeout_ms 单次 SPI 超时 */ esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len, uint32_t timeout_ms); @@ -417,13 +402,6 @@ private: size_t resp_max, size_t *resp_len, uint32_t timeout_ms, bool expect_stale = false); - /** @brief 带重试退避的事务(SPEC §9.2)。 - * 同序号重试最多 3 次,退避 10/30/90ms; - * 3 次失败后 HELLO 重同步,再试 1 次。 - * 调用者须已持有锁。 */ - esp_err_t transactWithRetry(uint8_t type, const uint8_t *payload, size_t payload_len, - uint8_t *resp_type, uint8_t *resp_payload, - size_t resp_max, size_t *resp_len, uint32_t timeout_ms); // OTA 帧工具(静态,无状态) static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len, uint8_t *out, size_t *out_len); diff --git a/tools/can.html b/tools/can.html index feb7905..80551e6 100644 --- a/tools/can.html +++ b/tools/can.html @@ -4,11 +4,11 @@ ESP32 CAN 通信控制台 - - - - - + + + + +
@@ -20,99 +20,62 @@
未连接 + IP -- + RSSI -- + 运行 -- v-- CH0 未导入 DBC 0 报文 + 模式: -- + 轮询: --
-
-
-
🧰 工作台设置
-
-
-
- -
- - -
-
- -
- - -
- -
- -
-
📥
-

导入 DBC

-

点击或拖拽 .dbc 文件

-
- -
- - -
- -
-
- 📄 - - -
-
-
报文0
-
信号0
-
节点0
-
大小0 B
-
-
- -
-
- - 等待连接测试 -
-
- 当前页面会直接调用 /system/status 和 /can/send。 -
-
- 当前解析器会自动识别常见 DBC 的 BO_、SG_、VAL_、 - CM_ 和 SIG_VALTYPE_。 -
-
-
-
-
-
🕘 最近发送
-
-
-
尚未发送任何 CAN 帧。
-
-
-
-
+ +
-
-
+
+
+
📚 报文列表 -
+ + +
+ + +
+
+ +
+ +
+ +
+
已勾选 0
-
等待导入 DBC...
+
+
📥
+

导入 DBC 开始编辑报文

+

点击此处或拖拽 .dbc 文件到列表区域

+
+
+
+
🕘 最近发送
+
+
+
尚未发送任何 CAN 帧。
+
+
+
+
+
+ + +
✓ 操作成功
- + diff --git a/tools/can_monitor.html b/tools/can_monitor.html index 5f9f290..da5fd50 100644 --- a/tools/can_monitor.html +++ b/tools/can_monitor.html @@ -4,11 +4,11 @@ ESP32 CAN 接收监控 - - - - - + + + + +
@@ -67,59 +67,79 @@
📊 三路 CAN 通道反馈(1Hz)
- - -
@@ -425,14 +355,78 @@
+ + +
✓ 操作成功
- - - + + + diff --git a/tools/js/app.js b/tools/js/app.js index c5e70fa..d703983 100644 --- a/tools/js/app.js +++ b/tools/js/app.js @@ -36,9 +36,8 @@ import { import { handleFileSelect, pickFileViaFSAPI, setupDropZone, parseIntelHex, isIntelHexText, convertHexFileToBin, - processFile, clearFile + processFile, clearFile, resumePersistedFirmwareWithGesture } from './ota/file-handle.js'; -import { sendSpiFrame, clearSpiFields, updateSpiHint } from './ota/spi.js'; import { startUpload, verifyEsp32Firmware, restartEsp32 } from './ota/upload-esp32.js'; import { triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch, @@ -69,7 +68,14 @@ function isOtaBusyError(status, detail) { text.indexOf('系统忙') >= 0; } function getEspIp() { - return document.getElementById('espIp').value.trim() || '192.168.31.127'; + var el = document.getElementById('espIp'); + var ip = el && el.value ? el.value.trim() : ''; + if (!ip) { + try { ip = (localStorage.getItem('dcu_esp_ip') || '').trim(); } catch (_) {} + } + if (!ip) ip = '192.168.31.127'; + try { localStorage.setItem('dcu_esp_ip', ip); } catch (_) {} + return ip; } function getBaseUrl() { return 'http://' + getEspIp(); @@ -77,8 +83,11 @@ function getBaseUrl() { function updateUploadButtonLabel() { var esp32Btn = document.getElementById('uploadEsp32Btn'); var gd32Btn = document.getElementById('uploadGd32Btn'); - if (esp32Btn) esp32Btn.disabled = !window.esp32File || window.isUploading; - if (gd32Btn) gd32Btn.disabled = !window.gd32File || window.isUploading; + /* 仅 XHR 上传期禁用(isUploading)。校验/中继/重启期允许按钮恢复可点, + * startUpload 用 otaSessionActive() 拒绝并发会话。 */ + var xhrBusy = !!window.isUploading; + if (esp32Btn) esp32Btn.disabled = !window.esp32File || xhrBusy; + if (gd32Btn) gd32Btn.disabled = !window.gd32File || xhrBusy; } function resetStageFlow() { ['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) { @@ -163,16 +172,13 @@ window.updateSystemStatusUi = updateSystemStatusUi; window.handleFileSelect = handleFileSelect; window.pickFileViaFSAPI = pickFileViaFSAPI; +window.resumePersistedFirmwareWithGesture = resumePersistedFirmwareWithGesture; window.parseIntelHex = parseIntelHex; window.isIntelHexText = isIntelHexText; window.convertHexFileToBin = convertHexFileToBin; window.processFile = processFile; window.clearFile = clearFile; -window.sendSpiFrame = sendSpiFrame; -window.clearSpiFields = clearSpiFields; -window.updateSpiHint = updateSpiHint; - window.startUpload = startUpload; window.verifyEsp32Firmware = verifyEsp32Firmware; window.restartEsp32 = restartEsp32; @@ -223,10 +229,6 @@ window.isOtaBusyError = isOtaBusyError; // ==================== 顶层副作用(原 app.js 末尾的初始化) ==================== setupDropZone('uploadZoneEsp32', 'esp32'); setupDropZone('uploadZoneGd32', 'gd32'); -var spiTypeEl = document.getElementById('spiType'); -var spiDataEl = document.getElementById('spiData'); -if (spiTypeEl) spiTypeEl.addEventListener('input', updateSpiHint); -if (spiDataEl) spiDataEl.addEventListener('input', updateSpiHint); // 启动轮询(必须在所有模块加载完成、window.OtaSessionState 已挂载后调用, // 否则 polling.js 的 scheduleStatusPoll 会因 window.OtaSessionState 未定义而抛 TypeError) @@ -235,7 +237,12 @@ startPolling(); // IP 输入变化时清零失败计数并立即刷新状态 var ipInput = document.getElementById('espIp'); if (ipInput) { + try { + var savedIp = localStorage.getItem('dcu_esp_ip'); + if (savedIp && savedIp.trim()) ipInput.value = savedIp.trim(); + } catch (_) {} ipInput.addEventListener('change', function () { + try { localStorage.setItem('dcu_esp_ip', ipInput.value.trim() || '192.168.31.127'); } catch (_) {} window.consecutivePollFailures = 0; addLog('I', '系统', 'IP 已修改,重新启动轮询...'); if (!window.statusPollInFlight) { @@ -248,5 +255,4 @@ if (ipInput) { setTimeout(function () { updateUploadButtonLabel(); addLog('I', '系统', '控制台初始化完成,等待固件文件...'); - updateSpiHint(); }, 500); \ No newline at end of file diff --git a/tools/js/ble/file.js b/tools/js/ble/file.js index e8fe928..a18b93c 100644 --- a/tools/js/ble/file.js +++ b/tools/js/ble/file.js @@ -145,10 +145,16 @@ export async function pickFileViaFSAPI() { return false; } try { - const handles = await window.showOpenFilePicker({ + const pickerOpts = { + id: 'dcu-fw-ble', types: [{ description: '固件文件', accept: { 'application/octet-stream': ['.bin', '.hex'] } }], multiple: false - }); + }; + /* 再次选择时定位到刷新/上次已选文件所在目录 */ + if (state.selectedFileHandle) { + pickerOpts.startIn = state.selectedFileHandle; + } + const handles = await window.showOpenFilePicker(pickerOpts); if (handles && handles.length > 0) { const handle = handles[0]; const file = await handle.getFile(); diff --git a/tools/js/ble/transfer.js b/tools/js/ble/transfer.js index 52b2d36..14f8eb7 100644 --- a/tools/js/ble/transfer.js +++ b/tools/js/ble/transfer.js @@ -447,8 +447,10 @@ export async function uploadFirmware() { clearTimeout(state.ackWaiter.timer); state.ackWaiter = null; } - if (state.lastUploadStatus === 'failed') { + if (state.lastUploadStatus === 'failed' || state.lastUploadStatus === 'aborted') { clearOtaResultWaiter(); + // 终态后允许下一次 uploadFirmware 干净起步(abortRequested 在下次入口也会清) + // 此处不强制清 abortRequested:中止循环可能仍在 unwind } state.uploading = false; state.uploadTargetKey = null; diff --git a/tools/js/can.js b/tools/js/can.js index e6df236..6a62f8d 100644 --- a/tools/js/can.js +++ b/tools/js/can.js @@ -3,8 +3,8 @@ * * CAN 页面的编排逻辑: * - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象) - * - setConnectionState:CAN 页面专用连接状态更新(canConnPill 等元素) - * - testConnection:CAN 页面专用连接检测(GET /system/status,操作 el.connectionNote 等) + * - setConnectionState:顶栏 pills(连接/IP/RSSI/运行/版本) + * - testConnection:GET /system/status,静默刷新顶栏(5s) * - clearDbc / handleDbcFile / handleDbcError:DBC 文件读取与解析流程编排 * - bindEvents:绑定所有事件监听器(调用子模块的 bindNodeFilterEvents 等) * - init:页面初始化入口 @@ -16,19 +16,26 @@ * (操作不同的 DOM 元素集),此处保留原 can.js 版本以保证行为一致。 */ -import { state, el, getBaseUrl, parseDbc, readDbcText, buildSummary } from './can/dbc.js'; +/* 子模块 import 带同一 ?v=,避免浏览器缓存旧 dbc.js(无 getDeviceIp)且保证单例 state */ +import { + state, el, getBaseUrl, getDeviceIp, setDeviceIp, parseDbc, readDbcText, buildSummary, + rebuildMergedDbc, makeDbcSourceId +} from './can/dbc.js?v=20260717t'; import { updateDbcFileInfo, filterMessages, bindNodeFilterEvents, renderMessageList, - selectMessage -} from './can/messages.js'; + selectMessage, + recomputeTransmitterFilterEmpty, + updateNodeFilterLabel, + updateBatchUi +} from './can/messages.js?v=20260717t'; import { ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview -} from './can/signals.js'; +} from './can/signals.js?v=20260717t'; import { renderHistory, sendSelectedMessage, @@ -37,9 +44,15 @@ import { sendManualFrame, resetManualFields, loadPreviewToManual -} from './can/send.js'; -import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js'; -import { showToast } from './common/toast.js'; +} from './can/send.js?v=20260717t'; +import { + scheduleCanPersist, + loadCanSession, + bindCanPersistLifecycle +} from './can/persist.js?v=20260717t'; +import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717t'; +import { showToast } from './common/toast.js?v=20260717t'; +import { initCanMonitor } from './can_monitor.js?v=20260717t'; // ==================== 本地工具 ==================== // 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h), @@ -50,13 +63,15 @@ import { showToast } from './common/toast.js'; function cacheElements() { [ 'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData', - 'testConnBtn', 'connIndicator', 'connIndicatorTxt', 'connectionNote', 'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount', - 'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcSearch', 'dbcHint', 'clearDbcBtn', + 'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcFileCount', 'dbcFileChips', + 'dbcSearch', 'dbcHint', 'clearDbcBtn', + 'importDbcBtn', 'replaceDbcBtn', 'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint', 'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn', 'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco', - 'toastMsg', 'canConnPill', 'canConnTxt', 'canVersionPill', 'canChannelPill', + 'toastMsg', 'canConnPill', 'canConnTxt', 'canIpPill', 'canRssiPill', 'canUptimePill', + 'canVersionPill', 'canChannelPill', 'dbcSummaryPill', 'messageSummaryPill', 'selectedCountPill', 'selectAllBox', 'expandAllBtn', 'collapseAllBtn', 'sendCheckedBtn', 'nodeFilterWrap', 'nodeFilterBtn', 'nodeFilterLabel', 'nodeFilterPanel', 'nodeFilterSearch', @@ -66,16 +81,25 @@ function cacheElements() { }); } -// ==================== 连接状态 ==================== -function setConnectionState(connected, text, buildVersion, buildTime) { +// ==================== 连接状态(仅顶栏 pills,与工作台去重) ==================== +function setConnectionState(connected, text, buildVersion, buildTime, wifi, uptimeSec) { if (el.canConnPill) { el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline'); } if (el.canConnTxt) el.canConnTxt.textContent = text; - if (el.connIndicator) { - el.connIndicator.className = 'conn-indicator ' + (connected ? 'ok' : 'err'); + wifi = wifi || {}; + if (el.canIpPill) { + var ip = wifi.ip || getDeviceIp(); + el.canIpPill.textContent = 'IP ' + (ip || '--'); + } + if (el.canRssiPill) { + var rssiOk = wifi.rssi !== undefined && wifi.rssi !== null && wifi.rssi !== -127; + el.canRssiPill.textContent = rssiOk ? ('RSSI ' + wifi.rssi + ' dBm') : 'RSSI --'; + } + if (el.canUptimePill) { + el.canUptimePill.textContent = '运行 ' + (uptimeSec !== undefined && uptimeSec !== null + ? formatUptime(uptimeSec) : '--'); } - if (el.connIndicatorTxt) el.connIndicatorTxt.textContent = text; if (el.canVersionPill) { el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--'; el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : ''; @@ -84,79 +108,27 @@ function setConnectionState(connected, text, buildVersion, buildTime) { async function testConnection() { var baseUrl = getBaseUrl(); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note'; - el.connectionNote.innerHTML = '正在请求 /system/status 检查设备状态...'; - } try { var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) }); if (!resp.ok) { - setConnectionState(false, 'HTTP ' + resp.status, '', ''); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note err'; - el.connectionNote.innerHTML = '状态接口返回 HTTP ' + resp.status + ',请确认设备地址和 HTTP 服务。'; - } - showToast('设备响应异常: HTTP ' + resp.status, 'error'); + setConnectionState(false, 'HTTP ' + resp.status, '', '', {}, null); return; } var data = await resp.json(); state.latestStatus = data; var wifi = data.wifi || {}; + if (wifi.ip) setDeviceIp(wifi.ip); var connected = !!wifi.connected; var mode = wifi.mode || 'STA'; var text = connected ? (mode + ' 已连接') : (mode + ' 未连接'); - setConnectionState(connected, text, data.version, data.build_time); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note ' + (connected ? 'ok' : 'warn'); - el.connectionNote.innerHTML = - 'IP: ' + sanitizeHtml(wifi.ip || '--') + ',RSSI: ' + - sanitizeHtml(wifi.rssi !== undefined && wifi.rssi !== -127 ? (wifi.rssi + ' dBm') : '--') + - ',运行: ' + sanitizeHtml(formatUptime(data.uptime_sec)) + ''; - } - showToast(connected ? 'ESP32 当前在线' : '设备已响应,但 WiFi 未连接', connected ? 'success' : 'warning'); + setConnectionState(connected, text, data.version, data.build_time, wifi, data.uptime_sec); } catch (err) { - setConnectionState(false, '连接失败', '', ''); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note err'; - el.connectionNote.innerHTML = '无法访问 /system/status:' + sanitizeHtml(err.message); - } - showToast('连接失败: ' + err.message, 'error'); + setConnectionState(false, '连接失败', '', '', {}, null); } } -// ==================== DBC 文件流程 ==================== -async function handleDbcFile(file) { - if (!file) return; - var text = await readDbcText(file); - var dbc = parseDbc(text); - state.dbcFile = file; - state.dbcText = text; - state.dbc = dbc; - state.selectedMessageKey = ''; - state.signalValues = {}; - state.checkedKeys = {}; - state.expandedKeys = {}; - state.transmitterFilter = Object.create(null); - state.transmitterFilterEmpty = true; - updateDbcFileInfo(file, dbc); - buildSummary(); - filterMessages(); - if (dbc.messages.length) selectMessage(dbc.messages[0].key); - else renderSelectedMessage(); - if (el.dbcHint) { - el.dbcHint.className = 'helper-note ok'; - el.dbcHint.innerHTML = 'DBC 已解析:' + dbc.messages.length + ' 条报文,' + - dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0) + - ' 个信号。'; - } - showToast('DBC 导入成功', 'success'); -} - -function clearDbc() { - state.dbcFile = null; - state.dbcText = ''; - state.dbc = null; - state.filteredMessages = []; +// ==================== DBC 文件流程(支持多文件叠加) ==================== +function resetSelectionState() { state.selectedMessageKey = ''; state.signalValues = {}; state.checkedKeys = {}; @@ -164,22 +136,125 @@ function clearDbc() { state.transmitterFilter = Object.create(null); state.transmitterFilterEmpty = true; state.generatedData = new Uint8Array(0); - if (el.dbcFileInput) el.dbcFileInput.value = ''; - if (el.dbcSearch) el.dbcSearch.value = ''; - updateDbcFileInfo(null, null); +} + +function afterDbcSourcesChanged(opts) { + opts = opts || {}; + rebuildMergedDbc(); + if (opts.resetSelection) resetSelectionState(); + updateDbcFileInfo(); buildSummary(); - renderMessageList(); - renderSelectedMessage(); + filterMessages(); + if (state.dbc && state.dbc.messages.length) { + if (opts.resetSelection || !state.selectedMessageKey) { + selectMessage(state.dbc.messages[0].key); + } else { + renderSelectedMessage(); + updateEncodedPreview(); + } + } else { + renderSelectedMessage(); + } + if (el.dbcHint) { + el.dbcHint.hidden = true; + el.dbcHint.textContent = ''; + } + if (!opts.skipPersist) scheduleCanPersist(); +} + +/** + * 添加一个或多个 DBC。同名文件覆盖旧源;不同名叠加。 + * @param {FileList|File[]} fileList + */ +async function handleDbcFiles(fileList) { + var files = Array.prototype.slice.call(fileList || []).filter(function (f) { + return f && f.name; + }); + if (!files.length) return; + + var added = 0; + var replaced = 0; + var errors = []; + + for (var i = 0; i < files.length; i++) { + var file = files[i]; + try { + var text = await readDbcText(file); + var dbc = parseDbc(text); + if (!dbc.messages.length) { + errors.push(file.name + '(无报文)'); + continue; + } + var existingIdx = -1; + for (var j = 0; j < state.dbcSources.length; j++) { + if (state.dbcSources[j].name === file.name) { + existingIdx = j; + break; + } + } + var entry = { + id: existingIdx >= 0 ? state.dbcSources[existingIdx].id : makeDbcSourceId(file.name), + name: file.name, + size: file.size || 0, + text: text, /* 原文,供刷新后恢复 */ + dbc: dbc + }; + if (existingIdx >= 0) { + state.dbcSources[existingIdx] = entry; + replaced++; + } else { + state.dbcSources.push(entry); + added++; + } + } catch (err) { + errors.push(file.name + '(' + (err && err.message ? err.message : err) + ')'); + } + } + + afterDbcSourcesChanged({ resetSelection: true }); + + if (added || replaced) { + var msg = 'DBC:'; + if (added) msg += '新增 ' + added; + if (added && replaced) msg += ','; + if (replaced) msg += '覆盖 ' + replaced; + msg += ',合计 ' + state.dbcSources.length + ' 个文件'; + showToast(msg, 'success'); + } + if (errors.length) { + showToast('部分失败: ' + errors.slice(0, 2).join('; '), 'warning'); + if (el.dbcHint) { + el.dbcHint.hidden = false; + el.dbcHint.className = 'helper-note err'; + el.dbcHint.innerHTML = '导入问题:' + sanitizeHtml(errors.join(';')); + } + } +} + +function removeDbcSource(sourceId) { + state.dbcSources = state.dbcSources.filter(function (s) { return s.id !== sourceId; }); + afterDbcSourcesChanged({ resetSelection: true }); + if (el.generatedHex) el.generatedHex.textContent = '--'; + if (el.generatedBytes) el.generatedBytes.innerHTML = ''; + showToast(state.dbcSources.length ? '已移除 1 个 DBC' : '已清除全部 DBC', 'success'); +} + +function clearDbc() { + state.dbcSources = []; + state.dbc = null; + state.filteredMessages = []; + resetSelectionState(); + if (el.dbcFileInput) el.dbcFileInput.value = ''; + if (el.dbcSearch) el.dbcSearch.value = ''; + afterDbcSourcesChanged({ resetSelection: true }); + /* 历史/原始帧表单仍保留并写回 localStorage */ if (el.generatedHex) el.generatedHex.textContent = '--'; if (el.generatedBytes) el.generatedBytes.innerHTML = ''; - if (el.dbcHint) { - el.dbcHint.className = 'helper-note'; - el.dbcHint.innerHTML = '当前解析器会自动识别常见 DBC 的 BO_、SG_、VAL_、CM_ 和 SIG_VALTYPE_。'; - } } function handleDbcError(err) { if (el.dbcHint) { + el.dbcHint.hidden = false; el.dbcHint.className = 'helper-note err'; el.dbcHint.innerHTML = 'DBC 解析失败:' + sanitizeHtml(err.message); } @@ -188,42 +263,79 @@ function handleDbcError(err) { // ==================== 事件绑定 ==================== function bindEvents() { - el.testConnBtn.addEventListener('click', testConnection); - el.defaultChannel.addEventListener('change', function () { - el.manualChannel.value = el.defaultChannel.value; - el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; - renderMessageList(); - renderSelectedMessage(); - updateEncodedPreview(); - }); - el.dbcDropZone.addEventListener('click', function () { el.dbcFileInput.click(); }); - el.dbcDropZone.addEventListener('dragover', function (event) { - event.preventDefault(); - el.dbcDropZone.classList.add('dragover'); - }); - el.dbcDropZone.addEventListener('dragleave', function () { - el.dbcDropZone.classList.remove('dragover'); - }); - el.dbcDropZone.addEventListener('drop', function (event) { - event.preventDefault(); - el.dbcDropZone.classList.remove('dragover'); - var file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null; - if (file) handleDbcFile(file).catch(handleDbcError); - }); - el.dbcFileInput.addEventListener('change', function (event) { - var file = event.target && event.target.files ? event.target.files[0] : null; - if (file) handleDbcFile(file).catch(handleDbcError); - }); - el.clearDbcBtn.addEventListener('click', clearDbc); - el.dbcSearch.addEventListener('input', filterMessages); + if (el.defaultChannel) { + el.defaultChannel.addEventListener('change', function () { + if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value; + if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; + renderMessageList(); + renderSelectedMessage(); + updateEncodedPreview(); + scheduleCanPersist(); + }); + } + function openDbcPicker() { + if (el.dbcFileInput) el.dbcFileInput.click(); + } + if (el.importDbcBtn) el.importDbcBtn.addEventListener('click', openDbcPicker); + if (el.replaceDbcBtn) el.replaceDbcBtn.addEventListener('click', openDbcPicker); + if (el.dbcDropZone) { + el.dbcDropZone.addEventListener('dragover', function (event) { + event.preventDefault(); + el.dbcDropZone.classList.add('dragover'); + }); + el.dbcDropZone.addEventListener('dragleave', function (event) { + if (!el.dbcDropZone.contains(event.relatedTarget)) { + el.dbcDropZone.classList.remove('dragover'); + } + }); + el.dbcDropZone.addEventListener('drop', function (event) { + event.preventDefault(); + el.dbcDropZone.classList.remove('dragover'); + var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null; + if (files && files.length) handleDbcFiles(files).catch(handleDbcError); + }); + el.dbcDropZone.addEventListener('click', function (event) { + var removeBtn = event.target.closest('[data-remove-id]'); + if (removeBtn) { + event.preventDefault(); + event.stopPropagation(); + removeDbcSource(removeBtn.getAttribute('data-remove-id')); + return; + } + if (state.dbcSources.length) return; + if (event.target.closest('button, a, input, select, label')) return; + openDbcPicker(); + }); + } + if (el.dbcFileInput) { + el.dbcFileInput.addEventListener('change', function (event) { + var files = event.target && event.target.files ? event.target.files : null; + if (files && files.length) handleDbcFiles(files).catch(handleDbcError); + event.target.value = ''; + }); + } + if (el.clearDbcBtn) el.clearDbcBtn.addEventListener('click', clearDbc); + if (el.dbcSearch) { + el.dbcSearch.addEventListener('input', function () { + filterMessages(); + scheduleCanPersist(); + }); + } el.sendSelectedBtn.addEventListener('click', sendSelectedMessage); el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual); el.sendManualBtn.addEventListener('click', sendManualFrame); - el.resetManualBtn.addEventListener('click', resetManualFields); - el.manualData.addEventListener('input', refreshManualHint); - el.manualDlc.addEventListener('input', refreshManualHint); - el.manualId.addEventListener('input', refreshManualHint); - el.toolIp.addEventListener('change', testConnection); + el.resetManualBtn.addEventListener('click', function () { + resetManualFields(); + scheduleCanPersist(); + }); + function onManualFieldChange() { + refreshManualHint(); + scheduleCanPersist(); + } + el.manualData.addEventListener('input', onManualFieldChange); + el.manualDlc.addEventListener('input', onManualFieldChange); + el.manualId.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 () { var visible = state.filteredMessages || []; @@ -233,16 +345,19 @@ function bindEvents() { visible.forEach(function (m) { delete state.checkedKeys[m.key]; }); } renderMessageList(); + scheduleCanPersist(); }); if (el.expandAllBtn) el.expandAllBtn.addEventListener('click', function () { var visible = state.filteredMessages || []; visible.forEach(function (m) { state.expandedKeys[m.key] = true; ensureMessageDefaults(m); }); renderMessageList(); + scheduleCanPersist(); }); if (el.collapseAllBtn) el.collapseAllBtn.addEventListener('click', function () { var visible = state.filteredMessages || []; visible.forEach(function (m) { delete state.expandedKeys[m.key]; }); renderMessageList(); + scheduleCanPersist(); }); bindNodeFilterEvents(); } @@ -251,15 +366,49 @@ function bindEvents() { function init() { cacheElements(); bindEvents(); - setConnectionState(false, '未连接', '', ''); - if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; - if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value; + bindCanPersistLifecycle(); + + var restoredInfo = loadCanSession(); + setConnectionState(false, '未连接', '', '', { ip: getDeviceIp() }, null); + if (el.canChannelPill && el.defaultChannel) { + el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; + } + if (el.manualChannel && el.defaultChannel && !restoredInfo.restored) { + el.manualChannel.value = el.defaultChannel.value; + } + + recomputeTransmitterFilterEmpty(); + updateNodeFilterLabel(); + updateDbcFileInfo(); buildSummary(); - renderMessageList(); - renderSelectedMessage(); + filterMessages(); + if (state.dbc && state.dbc.messages.length) { + if (state.selectedMessageKey) { + selectMessage(state.selectedMessageKey); + } else { + selectMessage(state.dbc.messages[0].key); + } + } else { + renderSelectedMessage(); + } + updateBatchUi(); refreshManualHint(); renderHistory(); + + if (restoredInfo.restored && (restoredInfo.dbcCount || restoredInfo.historyCount)) { + var tip = '已恢复会话'; + if (restoredInfo.dbcCount) tip += ':' + restoredInfo.dbcCount + ' 个 DBC'; + if (restoredInfo.historyCount) { + tip += (restoredInfo.dbcCount ? ',' : ':') + restoredInfo.historyCount + ' 条发送记录'; + } + showToast(tip, 'success'); + } + setTimeout(testConnection, 150); + /* 周期性刷新顶栏 STA/IP/RSSI/运行时间(与监控 1Hz 解耦,避免重复 pill) */ + setInterval(testConnection, 5000); + /* 右侧抽屉:CAN 接收监控 */ + initCanMonitor(); } init(); \ No newline at end of file diff --git a/tools/js/can/dbc.js b/tools/js/can/dbc.js index 39281a2..68d3f96 100644 --- a/tools/js/can/dbc.js +++ b/tools/js/can/dbc.js @@ -9,8 +9,9 @@ // ==================== 共享状态 ==================== export var state = { - dbcFile: null, - dbcText: '', + /** @type {{ id: string, name: string, size: number, dbc: object }[]} */ + dbcSources: [], + /** 合并视图 { messages, nodeNames, messageMap };由 rebuildMergedDbc() 维护 */ dbc: null, filteredMessages: [], selectedMessageKey: '', @@ -30,10 +31,32 @@ export var state = { export var el = {}; // ==================== 基础工具 ==================== -/** @description 拼接设备基础 URL(http://),ip 取自 el.toolIp,默认 192.168.31.127。 */ +var LS_ESP_IP = 'dcu_esp_ip'; +var DEFAULT_ESP_IP = '192.168.31.127'; + +/** @description 设备 IP:优先 localStorage(与 OTA 页共用),否则默认。 */ +export function getDeviceIp() { + try { + var saved = localStorage.getItem(LS_ESP_IP); + if (saved && saved.trim()) return saved.trim(); + } catch (_) {} + if (el.toolIp && el.toolIp.value && el.toolIp.value.trim()) { + return el.toolIp.value.trim(); + } + return DEFAULT_ESP_IP; +} + +/** @description 记住设备 IP(status 回报或其它页写入)。 */ +export function setDeviceIp(ip) { + if (!ip || !String(ip).trim()) return; + var v = String(ip).trim(); + try { localStorage.setItem(LS_ESP_IP, v); } catch (_) {} + if (el.toolIp) el.toolIp.value = v; +} + +/** @description 拼接设备基础 URL(http://)。 */ export function getBaseUrl() { - var ip = (el.toolIp && el.toolIp.value ? el.toolIp.value.trim() : '') || '192.168.31.127'; - return 'http://' + ip; + return 'http://' + getDeviceIp(); } /** @description 解析手动输入的 CAN ID(支持 0x/hex/十进制)。 */ @@ -239,15 +262,76 @@ export function parseMultiplexToken(token) { return { role: null, value: null }; } +/** + * 将多个 DBC 源合并为统一 messages 列表。 + * 每条报文 key = sourceId:idRaw:name,避免不同文件同 ID/同名冲突。 + */ +export function rebuildMergedDbc() { + if (!state.dbcSources.length) { + state.dbc = null; + return null; + } + var messages = []; + var nodeSet = Object.create(null); + var nodeNames = []; + var messageMap = {}; + for (var s = 0; s < state.dbcSources.length; s++) { + var src = state.dbcSources[s]; + var dbc = src.dbc; + if (!dbc || !dbc.messages) continue; + for (var n = 0; n < (dbc.nodeNames || []).length; n++) { + var nn = dbc.nodeNames[n]; + if (nn && !nodeSet[nn]) { + nodeSet[nn] = 1; + nodeNames.push(nn); + } + } + for (var i = 0; i < dbc.messages.length; i++) { + var msg = dbc.messages[i]; + var merged = { + idRaw: msg.idRaw, + name: msg.name, + dlc: msg.dlc, + transmitter: msg.transmitter, + signals: msg.signals, + comment: msg.comment, + id: msg.id, + isExtended: msg.isExtended, + sourceId: src.id, + sourceName: src.name, + key: src.id + ':' + msg.idRaw + ':' + msg.name + }; + messages.push(merged); + messageMap[merged.key] = merged; + } + } + state.dbc = { messages: messages, nodeNames: nodeNames, messageMap: messageMap }; + return state.dbc; +} + +/** @description 生成唯一 source id(同名文件可多次添加时追加 #2) */ +export function makeDbcSourceId(fileName) { + var base = String(fileName || 'dbc').replace(/[^\w.\-]+/g, '_'); + var id = base; + var n = 2; + while (state.dbcSources.some(function (s) { return s.id === id; })) { + id = base + '#' + n; + n++; + } + return id; +} + /** @description 更新顶部 DBC / 报文数量摘要 pill。 */ export function buildSummary() { - if (!state.dbc) { + if (!state.dbc || !state.dbcSources.length) { if (el.dbcSummaryPill) el.dbcSummaryPill.textContent = '未导入 DBC'; if (el.messageSummaryPill) el.messageSummaryPill.textContent = '0 报文'; return; } + var nFiles = state.dbcSources.length; if (el.dbcSummaryPill) { - el.dbcSummaryPill.textContent = (state.dbc.nodeNames.length || 0) + ' 节点'; + el.dbcSummaryPill.textContent = + nFiles + ' 文件 · ' + (state.dbc.nodeNames.length || 0) + ' 节点'; } if (el.messageSummaryPill) { el.messageSummaryPill.textContent = state.dbc.messages.length + ' 报文'; diff --git a/tools/js/can/messages.js b/tools/js/can/messages.js index b43f033..7e5df9d 100644 --- a/tools/js/can/messages.js +++ b/tools/js/can/messages.js @@ -7,30 +7,59 @@ * common/utils.js(escapeHtml/formatBytes)、 * signals.js(renderInlineSignalGrid/ensureMessageDefaults/renderSelectedMessage/updateEncodedPreview)。 */ -import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js'; -import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js'; -import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js'; +import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717t'; +import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717t'; +import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js?v=20260717t'; +import { scheduleCanPersist } from './persist.js?v=20260717t'; // ==================== DBC 文件信息 ==================== /** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */ -export function updateDbcFileInfo(file, dbc) { - if (!el.dbcFileInfo) return; - if (!file || !dbc) { - el.dbcFileInfo.classList.remove('show'); - el.dbcFileName.textContent = '-'; - el.dbcMessageCount.textContent = '0'; - el.dbcSignalCount.textContent = '0'; - el.dbcNodeCount.textContent = '0'; - el.dbcFileSize.textContent = '0 B'; +/** @description 刷新多 DBC 摘要条与文件 chip 列表。 */ +export function updateDbcFileInfo() { + var sources = state.dbcSources || []; + var has = sources.length > 0; + var dbc = state.dbc; + + if (el.dbcFileInfo) { + el.dbcFileInfo.hidden = !has; + el.dbcFileInfo.classList.toggle('show', has); + } + /* 有文件时仍显示「添加」,文案改成添加;「全部清除」紧挨添加按钮 */ + if (el.importDbcBtn) { + el.importDbcBtn.hidden = false; + el.importDbcBtn.textContent = has ? '添加 DBC' : '导入 DBC'; + } + if (el.clearDbcBtn) el.clearDbcBtn.hidden = !has; + if (el.dbcDropZone) el.dbcDropZone.classList.toggle('has-dbc', has); + + if (!has || !dbc) { + if (el.dbcFileCount) el.dbcFileCount.textContent = '0'; + if (el.dbcMessageCount) el.dbcMessageCount.textContent = '0'; + if (el.dbcSignalCount) el.dbcSignalCount.textContent = '0'; + if (el.dbcNodeCount) el.dbcNodeCount.textContent = '0'; + if (el.dbcFileSize) el.dbcFileSize.textContent = '0 B'; + if (el.dbcFileChips) el.dbcFileChips.innerHTML = ''; return; } + var signalCount = dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0); - el.dbcFileInfo.classList.add('show'); - el.dbcFileName.textContent = file.name || '-'; - el.dbcMessageCount.textContent = String(dbc.messages.length); - el.dbcSignalCount.textContent = String(signalCount); - el.dbcNodeCount.textContent = String(dbc.nodeNames.length); - el.dbcFileSize.textContent = formatBytes(file.size || 0); + var totalSize = sources.reduce(function (sum, s) { return sum + (s.size || 0); }, 0); + if (el.dbcFileCount) el.dbcFileCount.textContent = String(sources.length); + if (el.dbcMessageCount) el.dbcMessageCount.textContent = String(dbc.messages.length); + if (el.dbcSignalCount) el.dbcSignalCount.textContent = String(signalCount); + if (el.dbcNodeCount) el.dbcNodeCount.textContent = String(dbc.nodeNames.length); + if (el.dbcFileSize) el.dbcFileSize.textContent = formatBytes(totalSize); + + if (el.dbcFileChips) { + el.dbcFileChips.innerHTML = sources.map(function (s) { + var nMsg = (s.dbc && s.dbc.messages) ? s.dbc.messages.length : 0; + return '' + + '' + + '' + sanitizeHtml(s.name) + '' + + '' + + ''; + }).join(''); + } } // ==================== 报文过滤 ==================== @@ -109,6 +138,7 @@ export function renderNodeFilterList() { else delete state.transmitterFilter[name]; recomputeTransmitterFilterEmpty(); filterMessages(); + scheduleCanPersist(); }); } } @@ -143,9 +173,11 @@ export function setNodeFilterPanelOpen(open) { renderNodeFilterList(); el.nodeFilterPanel.hidden = false; el.nodeFilterBtn.setAttribute('aria-expanded', 'true'); + if (el.nodeFilterWrap) el.nodeFilterWrap.classList.add('is-open'); } else { el.nodeFilterPanel.hidden = true; el.nodeFilterBtn.setAttribute('aria-expanded', 'false'); + if (el.nodeFilterWrap) el.nodeFilterWrap.classList.remove('is-open'); } } @@ -173,12 +205,14 @@ export function bindNodeFilterEvents() { recomputeTransmitterFilterEmpty(); renderNodeFilterList(); filterMessages(); + scheduleCanPersist(); }); if (el.nodeFilterNoneBtn) el.nodeFilterNoneBtn.addEventListener('click', function () { state.transmitterFilter = Object.create(null); state.transmitterFilterEmpty = true; renderNodeFilterList(); filterMessages(); + scheduleCanPersist(); }); } @@ -186,8 +220,13 @@ export function bindNodeFilterEvents() { /** @description 渲染报文列表表格(表头 + 行 + 展开区)。 */ export function renderMessageList() { if (!el.messageList) return; - if (!state.dbc) { - el.messageList.innerHTML = '
等待导入 DBC...
'; + if (!state.dbc || !state.dbcSources.length) { + el.messageList.innerHTML = + '
' + + '
📥
' + + '

导入 DBC 开始编辑报文

' + + '

支持一次选择或拖入 多个 .dbc 文件

' + + '
'; updateBatchUi(); return; } @@ -210,7 +249,10 @@ export function renderMessageList() { '' + '' + '' + (index + 1) + '' + - '' + sanitizeHtml(msg.name) + '' + + '' + + sanitizeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '' + + '' + + sanitizeHtml(msg.name) + '' + '' + sanitizeHtml(idHex) + '' + 'CH' + sanitizeHtml(channelTxt) + '' + '' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '' + @@ -230,6 +272,7 @@ export function renderMessageList() { '' + '' + '#' + + 'DBC名称' + '报文名称' + '标识符' + '通道' + @@ -260,6 +303,7 @@ export function renderMessageList() { if (this.checked) state.checkedKeys[k] = true; else delete state.checkedKeys[k]; updateBatchUi(); + scheduleCanPersist(); }); } var toggles = el.messageList.querySelectorAll('.message-toggle'); @@ -314,6 +358,7 @@ function toggleMessageExpand(key) { toggle.classList.toggle('expanded', willExpand); toggle.textContent = willExpand ? '\u25BE' : '\u25B8'; } + scheduleCanPersist(); } // ==================== 批量 UI ==================== @@ -352,4 +397,5 @@ export function selectMessage(key) { renderMessageList(); renderSelectedMessage(); updateEncodedPreview(); + scheduleCanPersist(); } diff --git a/tools/js/can/persist.js b/tools/js/can/persist.js new file mode 100644 index 0000000..c6f2283 --- /dev/null +++ b/tools/js/can/persist.js @@ -0,0 +1,238 @@ +/** + * persist.js - CAN 控制台会话持久化(刷新后恢复) + * + * 保存到 localStorage:多 DBC 原文、信号编辑值、勾选/展开、发送节点过滤、 + * 最近发送历史、通道/搜索/原始帧表单。 + * + * 依赖:dbc.js(state/el/parseDbc/rebuildMergedDbc) + */ +import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717t'; + +var LS_KEY = 'dcu_can_session_v1'; +var MAX_HISTORY = 200; +var SAVE_DEBOUNCE_MS = 350; +var saveTimer = null; +var restoring = false; + +function safeJsonParse(raw) { + try { + return JSON.parse(raw); + } catch (_) { + return null; + } +} + +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]; + } + } + return out; +} + +function collectUiFields() { + return { + defaultChannel: el.defaultChannel ? el.defaultChannel.value : '0', + manualChannel: el.manualChannel ? el.manualChannel.value : '0', + manualId: el.manualId ? el.manualId.value : '', + manualDlc: el.manualDlc ? el.manualDlc.value : '8', + manualData: el.manualData ? el.manualData.value : '', + dbcSearch: el.dbcSearch ? el.dbcSearch.value : '' + }; +} + +function applyUiFields(ui) { + if (!ui) return; + if (el.defaultChannel && ui.defaultChannel != null) { + el.defaultChannel.value = String(ui.defaultChannel); + } + if (el.manualChannel && ui.manualChannel != null) { + el.manualChannel.value = String(ui.manualChannel); + } else if (el.manualChannel && el.defaultChannel) { + el.manualChannel.value = el.defaultChannel.value; + } + if (el.manualId && ui.manualId != null) el.manualId.value = String(ui.manualId); + if (el.manualDlc && ui.manualDlc != null) el.manualDlc.value = String(ui.manualDlc); + if (el.manualData && ui.manualData != null) el.manualData.value = String(ui.manualData); + if (el.dbcSearch && ui.dbcSearch != null) el.dbcSearch.value = String(ui.dbcSearch); + if (el.canChannelPill && el.defaultChannel) { + el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; + } +} + +function buildPayload(options) { + options = options || {}; + var sources = (state.dbcSources || []).map(function (s) { + return { + id: s.id, + name: s.name, + size: s.size || 0, + text: s.text || '' + }; + }).filter(function (s) { return s.text; }); + + var history = Array.isArray(state.history) ? state.history.slice(0, MAX_HISTORY) : []; + if (options.historyLimit != null) { + history = history.slice(0, options.historyLimit); + } + + return { + v: 1, + ts: Date.now(), + dbcSources: sources, + selectedMessageKey: state.selectedMessageKey || '', + signalValues: state.signalValues || {}, + checkedKeys: state.checkedKeys || {}, + expandedKeys: state.expandedKeys || {}, + transmitterFilter: transmitterFilterToPlain(), + history: history, + ui: collectUiFields() + }; +} + +function writePayload(payload) { + localStorage.setItem(LS_KEY, JSON.stringify(payload)); +} + +/** @description 立即写入 localStorage(空间不足时逐步丢弃历史/信号)。 */ +export function saveCanSessionNow() { + if (restoring) return; + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = null; + } + try { + writePayload(buildPayload()); + return; + } catch (e1) { + /* quota:先砍历史 */ + } + try { + writePayload(buildPayload({ historyLimit: 40 })); + return; + } catch (e2) { + /* 再砍信号值,只保 DBC + 勾选 */ + } + try { + var slim = buildPayload({ historyLimit: 0 }); + slim.signalValues = {}; + slim.history = []; + writePayload(slim); + } catch (e3) { + /* 放弃 */ + } +} + +/** @description 防抖保存(编辑信号/勾选等高频操作)。 */ +export function scheduleCanPersist() { + if (restoring) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function () { + saveTimer = null; + saveCanSessionNow(); + }, SAVE_DEBOUNCE_MS); +} + +/** @description 清空持久化(全部清除 DBC 时)。 */ +export function clearCanSessionPersist() { + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = null; + } + try { + localStorage.removeItem(LS_KEY); + } catch (_) {} +} + +/** + * @description 从 localStorage 恢复会话到 state + 表单。 + * @returns {{ restored: boolean, dbcCount: number, historyCount: number }} + */ +export function loadCanSession() { + var empty = { restored: false, dbcCount: 0, historyCount: 0 }; + var raw = null; + try { + raw = localStorage.getItem(LS_KEY); + } catch (_) { + return empty; + } + if (!raw) return empty; + var data = safeJsonParse(raw); + if (!data || data.v !== 1) return empty; + + restoring = true; + try { + state.dbcSources = []; + var list = Array.isArray(data.dbcSources) ? data.dbcSources : []; + for (var i = 0; i < list.length; i++) { + var src = list[i]; + if (!src || !src.text) continue; + try { + var dbc = parseDbc(src.text); + if (!dbc.messages || !dbc.messages.length) continue; + state.dbcSources.push({ + id: src.id || ('restored_' + i), + name: src.name || ('saved_' + (i + 1) + '.dbc'), + size: src.size || src.text.length, + text: src.text, + dbc: dbc + }); + } catch (_) { + /* 单文件损坏跳过 */ + } + } + rebuildMergedDbc(); + + state.signalValues = (data.signalValues && typeof data.signalValues === 'object') + ? data.signalValues : {}; + state.checkedKeys = (data.checkedKeys && typeof data.checkedKeys === 'object') + ? data.checkedKeys : {}; + state.expandedKeys = (data.expandedKeys && typeof data.expandedKeys === 'object') + ? data.expandedKeys : {}; + state.selectedMessageKey = data.selectedMessageKey || ''; + state.history = Array.isArray(data.history) ? data.history.slice(0, MAX_HISTORY) : []; + + state.transmitterFilter = Object.create(null); + if (data.transmitterFilter && typeof data.transmitterFilter === 'object') { + Object.keys(data.transmitterFilter).forEach(function (k) { + state.transmitterFilter[k] = !!data.transmitterFilter[k]; + }); + } + var anyTx = false; + for (var tk in state.transmitterFilter) { + if (state.transmitterFilter[tk]) { anyTx = true; break; } + } + 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)) { + state.selectedMessageKey = ''; + } + + applyUiFields(data.ui); + + return { + restored: true, + dbcCount: state.dbcSources.length, + historyCount: state.history.length + }; + } finally { + restoring = false; + } +} + +/** @description 绑定 beforeunload / 可见性,降低丢失。 */ +export function bindCanPersistLifecycle() { + window.addEventListener('beforeunload', function () { + saveCanSessionNow(); + }); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') saveCanSessionNow(); + }); +} diff --git a/tools/js/can/send.js b/tools/js/can/send.js index d877f40..26d5290 100644 --- a/tools/js/can/send.js +++ b/tools/js/can/send.js @@ -12,11 +12,12 @@ * 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。 */ -import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js'; -import { escapeHtml as sanitizeHtml } from '../common/utils.js'; -import { showToast } from '../common/toast.js'; -import { updateBatchUi } from './messages.js'; -import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js'; +import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717t'; +import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717t'; +import { showToast } from '../common/toast.js?v=20260717t'; +import { updateBatchUi } from './messages.js?v=20260717t'; +import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717t'; +import { scheduleCanPersist } from './persist.js?v=20260717t'; // ==================== 本地工具(保持与原 can.js 一致) ==================== function formatTime(date) { @@ -84,6 +85,33 @@ async function sendCanRequest(payload) { return json; } +/** + * @description 一次 HTTP 连续发送多帧(设备 /can/send_batch)。 + * @returns {Promise} 与 frames 等长的 results + */ +async function sendCanBatchRequest(channel, frames) { + var resp = await fetch(getBaseUrl() + '/can/send_batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ch: channel, frames: frames }), + signal: AbortSignal.timeout(Math.max(15000, frames.length * 500)) + }); + var text = await resp.text(); + var json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch (err) { + throw new Error('批量响应不是合法 JSON: ' + text); + } + if (!resp.ok) { + throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '批量请求失败')); + } + if (!json.results || !Array.isArray(json.results)) { + throw new Error('批量响应缺少 results'); + } + return json.results; +} + // ==================== 响应框 ==================== function updateResponseBox(box, textEl, result, mode) { if (!box || !textEl) return; @@ -99,13 +127,20 @@ function updateResponseBox(box, textEl, result, mode) { } // ==================== 发送历史 ==================== -function addHistoryEntry(entry) { +/** + * @param {object} entry + * @param {{ silent?: boolean }} [opts] silent=true 时不立刻重绘/落盘(批量用) + */ +function addHistoryEntry(entry, opts) { state.history.unshift(entry); - if (state.history.length > 20) state.history.length = 20; + /* 批量发送会快速产生大量条目,上限放宽便于回看 */ + if (state.history.length > 200) state.history.length = 200; + if (opts && opts.silent) return; renderHistory(); + scheduleCanPersist(); } -/** @description 渲染发送历史列表。 */ +/** @description 渲染发送历史列表(单行紧凑:状态|时间|CH·ID·DLC|HEX|结果)。 */ export function renderHistory() { if (!el.historyList) return; if (!state.history.length) { @@ -113,24 +148,27 @@ export function renderHistory() { return; } el.historyList.innerHTML = state.history.map(function (item) { + var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc; return '' + - '
' + - '
' + + '
' + '' + sanitizeHtml(item.mode) + '' + '' + sanitizeHtml(item.time) + '' + - '
' + - '
' + - 'CH' + sanitizeHtml(item.channel) + ' · ' + - sanitizeHtml(item.idHex) + ' · DLC ' + sanitizeHtml(item.dlc) + - '
' + - '
' + sanitizeHtml(item.dataHex || '--') + '
' + - '
' + sanitizeHtml(item.resultText) + '
' + + '' + sanitizeHtml(meta) + '' + + '' + sanitizeHtml(item.dataHex || '--') + '' + + '' + sanitizeHtml(item.resultText) + '' + '
'; }).join(''); } // ==================== 批量发送 ==================== -/** @description 批量发送所有勾选的报文(30ms 帧间间隔防 GD32 SPI RXORERR)。 */ +/** 单批最多帧数(与设备 /can/send_batch 上限一致) */ +var BATCH_CHUNK = 48; + +/** + * @description 批量发送所有勾选的报文。 + * 优先一次 HTTP /can/send_batch 在设备侧紧接连发;失败则回退单帧紧接。 + * 批量过程中不重绘历史,结束后统一刷新。 + */ export async function sendCheckedMessages() { if (!state.dbc) { showToast('请先导入 DBC', 'warning'); return; } var keys = Object.keys(state.checkedKeys); @@ -140,10 +178,10 @@ export async function sendCheckedMessages() { updateBatchUi(); var channel = parseInt(el.defaultChannel.value, 10) || 0; var ok = 0, fail = 0; - /* BATCH 间隔:GD32 SPI 从机用 RBNE 中断逐字节接收,无 RX DMA, - * 连续高频 CS 事务易触发 RXORERR -> 帧字节丢失 -> CRC 失败 -> 0xEE。 - * 30ms 间隔给 GD32 足够时间完成 onCsRelease 清理 + EXTI 重新待命。 */ - var BATCH_INTERVAL_MS = 30; + var silent = { silent: true }; + + /* 先全部编码,避免发到一半才因编码失败打断节奏 */ + var prepared = []; for (var i = 0; i < keys.length; i++) { var key = keys[i]; var msg = null; @@ -166,47 +204,100 @@ export async function sendCheckedMessages() { idHex: idHex, dlc: msg.dlc, dataHex: '', - resultText: '编码失败: ' + err.message - }); + resultText: msg.name + ' · 编码失败: ' + err.message + }, silent); continue; } var dataHex = bytesToHex(dataBytes); - try { - var result = await sendCanRequest({ - ch: channel, - id: msg.id, - dlc: msg.dlc, - data: dataHex - }); - if (result.status === 'ok') ok++; else fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: result.status === 'ok', - channel: channel, - idHex: idHex, - dlc: msg.dlc, - dataHex: dataHex, - resultText: msg.name + ' · ' + buildSendResponseText(result) - }); - } catch (err) { - fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: false, - channel: channel, - idHex: idHex, - dlc: msg.dlc, - dataHex: dataHex, - resultText: msg.name + ' · ' + err.message - }); + 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 改单帧紧接 */ + } } - /* 帧间间隔,避免 GD32 SPI RXORERR(最后一帧不需要等) */ - if (i < keys.length - 1) { - await new Promise(function (r) { setTimeout(r, BATCH_INTERVAL_MS); }); + + 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); + } 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); + } } } + + renderHistory(); + scheduleCanPersist(); state.batchInFlight = false; updateBatchUi(); var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning'); diff --git a/tools/js/can/signals.js b/tools/js/can/signals.js index ef3c3da..b46f1ea 100644 --- a/tools/js/can/signals.js +++ b/tools/js/can/signals.js @@ -6,8 +6,9 @@ * * 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。 */ -import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js'; -import { escapeHtml as sanitizeHtml } from '../common/utils.js'; +import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717t'; +import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717t'; +import { scheduleCanPersist } from './persist.js?v=20260717t'; // ==================== 信号值存取 ==================== /** @description 确保报文有 signalValues bag,并为每个信号填充默认值。返回 bag。 */ @@ -182,6 +183,7 @@ function handleSignalInputEvent(evt) { if (inlineGrid) renderInlineSignalGrid(message, inlineGrid); } if (key === state.selectedMessageKey) updateEncodedPreview(); + scheduleCanPersist(); } function bindSignalInputs(container, message) { @@ -240,10 +242,21 @@ function renderByteStrip(bytes) { // ==================== 信号编解码(raw ↔ physical) ==================== /** @description 将报文 + 信号值编码为 Uint8Array。 */ export function encodeMessageData(message, signalValues) { - var bytes = new Uint8Array(message.dlc); + var dlc = message && message.dlc != null ? Number(message.dlc) : 0; + if (!Number.isFinite(dlc) || dlc < 0) dlc = 0; + if (dlc > 64) dlc = 64; + /* DLC=0:无数据区(如 CANFD_BOOT),不写信号,直接空帧 */ + if (dlc === 0) { + return new Uint8Array(0); + } + var bytes = new Uint8Array(dlc); var activeSignals = getActiveSignals(message); for (var i = 0; i < activeSignals.length; i++) { var signal = activeSignals[i]; + /* 信号完全落在 DLC 外则跳过,避免把空帧/定义异常拖成批量失败 */ + if (!signalFitsInDlc(signal, dlc)) { + continue; + } var parsed = parseSignalInput(signal, signalValues[signal.name]); if (signal.valueType === 'float' || signal.valueType === 'double') { writeFloatSignal(bytes, signal, parsed.numeric); @@ -254,6 +267,19 @@ export function encodeMessageData(message, signalValues) { return bytes; } +/** @description 粗判信号位域是否至少有一部分落在 dlc 字节内(越界整段则跳过)。 */ +function signalFitsInDlc(signal, dlc) { + if (!signal || dlc <= 0) return false; + var maxBit = dlc * 8 - 1; + if (signal.byteOrder === 'little') { + var end = signal.startBit + signal.length - 1; + return signal.startBit >= 0 && signal.startBit <= maxBit && end <= maxBit; + } + /* Motorola:用起始位所在字节粗判;更严的检查仍在 setBitByIndex */ + var startByte = Math.floor(signal.startBit / 8); + return startByte >= 0 && startByte < dlc && signal.length > 0; +} + function parseSignalInput(signal, inputValue) { var value = inputValue; if (value === '' || value == null) value = getDefaultSignalValue(signal); @@ -377,7 +403,8 @@ function setBitByIndex(bytes, bitIndex, bitValue) { var byteIndex = Math.floor(bitIndex / 8); var bitInByte = bitIndex % 8; if (byteIndex < 0 || byteIndex >= bytes.length) { - throw new Error('信号写入越界,bit=' + bitIndex); + throw new Error('信号写入越界,bit=' + bitIndex + + '(缓冲区 ' + bytes.length + ' 字节 / DLC=' + bytes.length + ')'); } if (bitValue) bytes[byteIndex] |= (1 << bitInByte); else bytes[byteIndex] &= ~(1 << bitInByte); diff --git a/tools/js/can_monitor.js b/tools/js/can_monitor.js index 511c5ef..e5b8446 100644 --- a/tools/js/can_monitor.js +++ b/tools/js/can_monitor.js @@ -1,6 +1,9 @@ -import { showToast } from './common/toast.js'; +/** + * can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用) + */ +import { showToast } from './common/toast.js?v=20260717t'; -var state = { fbTimer: null }; +var state = { fbTimer: null, fbAbortCtrl: null }; var el = {}; @@ -16,7 +19,8 @@ var ids = [ 'fb2LastId', 'fb2Dlc', 'fb2Flags', 'fb2LastData', 'btnModeAllPass', 'btnModeFilter', 'btnModeBridge', 'idsnapChannel', 'idsnapStatus', 'idsnapBody', - 'toast', 'toastIco', 'toastMsg' + 'toast', 'toastIco', 'toastMsg', + 'toolIp' /* can.html 共用 IP */ ]; function cacheElements() { @@ -29,29 +33,45 @@ function sanitize(text) { } function getBaseUrl() { - var ip = (el.monIp && el.monIp.value ? el.monIp.value.trim() : '') || '192.168.31.127'; + var ip = ''; + if (el.monIp && el.monIp.value) { + ip = el.monIp.value.trim(); + } else if (el.toolIp && el.toolIp.value) { + ip = el.toolIp.value.trim(); + } else { + try { + var saved = localStorage.getItem('dcu_esp_ip'); + if (saved && saved.trim()) ip = saved.trim(); + } catch (_) {} + } + if (!ip) ip = '192.168.31.127'; return 'http://' + ip; } function setConnState(ok, text) { + /* 嵌入 can.html 时顶栏已有 canConnPill,不再用 monConn 重复占位 */ if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline'); if (el.monConnTxt) el.monConnTxt.textContent = text; if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err'); if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text; + /* 轮询状态即监控连通性(与顶栏 canConn 去重时仍更新 fbPollStatus) */ + if (!el.monConnPill && el.fbPollStatus && !ok) { + el.fbPollStatus.textContent = '轮询: 离线'; + el.fbPollStatus.className = 'pill offline'; + } } // ── 1Hz 轮询 /can/feedback ───────────────────────────────── async function pollFeedback() { - // 取消上一次 in-flight 请求,防止 SPI 慢响应堆积导致 UI 瞬间刷新多次 - if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch(_e) {} } + if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch (_e) {} } state.fbAbortCtrl = new AbortController(); - var fbPollTimer = setTimeout(function() { try { state.fbAbortCtrl.abort(); } catch(_e) {} }, 3000); + var fbPollTimer = setTimeout(function () { try { state.fbAbortCtrl.abort(); } catch (_e) {} }, 3000); try { var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal }); if (!resp.ok) return; var data = await resp.json(); var channels = data.channels || []; - var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value) : 0; + var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value, 10) : 0; for (var i = 0; i < 3; i++) { var ch = channels[i]; @@ -62,7 +82,6 @@ async function pollFeedback() { renderChannel(i, ch); } - // 渲染选中通道的 ID 快照表 var sel = channels[selectedCh]; if (sel && sel.valid && sel.entries) { renderIdTable(sel.entries, sel.count || sel.entries.length); @@ -82,8 +101,7 @@ async function pollFeedback() { clearTimeout(fbPollTimer); } catch (e) { clearTimeout(fbPollTimer); - // 自身 AbortController 触发的取消不算失败,静默返回 - if (e && e.name === "AbortError") return; + if (e && e.name === 'AbortError') return; if (el.fbPollStatus) { el.fbPollStatus.textContent = '轮询: 离线'; el.fbPollStatus.className = 'pill offline'; @@ -99,12 +117,18 @@ function renderChannel(i, ch) { el[p + 'State'].textContent = hasIds ? '活跃 (' + ch.count + ' ID)' : '无帧(总线空闲)'; el[p + 'State'].className = 'fb-val ' + (hasIds ? 'ok' : 'info'); } - if (el[p + 'Rx']) el[p + 'Rx'].textContent = ch.rx; - if (el[p + 'Tx']) el[p + 'Tx'].textContent = ch.tx; + if (el[p + 'Rx']) el[p + 'Rx'].textContent = ch.rx; + if (el[p + 'Tx']) el[p + 'Tx'].textContent = ch.tx; if (el[p + 'Drop']) { el[p + 'Drop'].textContent = ch.drop; el[p + 'Drop'].className = 'fb-val' + (ch.drop > 0 ? ' warn' : ''); } - if (el[p + 'Err']) { el[p + 'Err'].textContent = ch.err; el[p + 'Err'].className = 'fb-val' + (ch.err > 0 ? ' err' : ''); } - if (el[p + 'Tec']) { el[p + 'Tec'].textContent = (ch.tec !== undefined && ch.tec !== null) ? ch.tec : '--'; el[p + 'Tec'].className = 'fb-val' + (ch.tec > 0 ? ' warn' : ''); } - if (el[p + 'Rec']) { el[p + 'Rec'].textContent = (ch.rec !== undefined && ch.rec !== null) ? ch.rec : '--'; el[p + 'Rec'].className = 'fb-val' + (ch.rec > 0 ? ' warn' : ''); } + if (el[p + 'Err']) { el[p + 'Err'].textContent = ch.err; el[p + 'Err'].className = 'fb-val' + (ch.err > 0 ? ' err' : ''); } + if (el[p + 'Tec']) { + el[p + 'Tec'].textContent = (ch.tec !== undefined && ch.tec !== null) ? ch.tec : '--'; + el[p + 'Tec'].className = 'fb-val' + (ch.tec > 0 ? ' warn' : ''); + } + if (el[p + 'Rec']) { + el[p + 'Rec'].textContent = (ch.rec !== undefined && ch.rec !== null) ? ch.rec : '--'; + el[p + 'Rec'].className = 'fb-val' + (ch.rec > 0 ? ' warn' : ''); + } if (el[p + 'Bus']) { if (ch.bus_state === undefined || ch.bus_state === null) { el[p + 'Bus'].textContent = '未知'; @@ -118,23 +142,22 @@ function renderChannel(i, ch) { if (bs & 8) parts.push('BUS_OFF'); if (bs & 16) parts.push('ERR_WARN'); el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : '正常'); - el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4|16)) ? ' warn' : ''); + el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4 | 16)) ? ' warn' : ''); } } - if (el[p + 'Seq']) el[p + 'Seq'].textContent = ch.seq; + if (el[p + 'Seq']) el[p + 'Seq'].textContent = ch.seq; if (el[p + 'Delta']) { el[p + 'Delta'].textContent = ch.count || 0; el[p + 'Delta'].className = 'fb-val'; } - // 最新帧摘要 var entries = ch.entries; if (entries && entries.length) { var last = entries[entries.length - 1]; - if (el[p + 'LastId']) { el[p + 'LastId'].textContent = last.id; el[p + 'LastId'].className = 'fb-val mono'; } - if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = last.dlc; - if (el[p + 'Flags']) el[p + 'Flags'].textContent = (last.fd ? 'FD' : '-') + ' / ' + (last.brs ? 'BRS' : '-') + ' / ' + (last.esi ? 'ESI' : '-'); + if (el[p + 'LastId']) { el[p + 'LastId'].textContent = last.id; el[p + 'LastId'].className = 'fb-val mono'; } + if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = last.dlc; + if (el[p + 'Flags']) el[p + 'Flags'].textContent = (last.fd ? 'FD' : '-') + ' / ' + (last.brs ? 'BRS' : '-') + ' / ' + (last.esi ? 'ESI' : '-'); if (el[p + 'LastData']) { el[p + 'LastData'].textContent = last.data || '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } } else { - if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } - if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = '--'; - if (el[p + 'Flags']) el[p + 'Flags'].textContent = '--'; + if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } + if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = '--'; + if (el[p + 'Flags']) el[p + 'Flags'].textContent = '--'; if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } } if (i === 0 && el.fbModePill) { @@ -146,13 +169,14 @@ function renderChannel(i, ch) { function renderChannelEmpty(i) { var p = 'fb' + i; if (el[p + 'State']) { el[p + 'State'].textContent = 'SPI 离线'; el[p + 'State'].className = 'fb-val err'; } - ['Rx','Tx','Drop','Err','Tec','Rec','Bus','Seq','Delta','Dlc','Flags'].forEach(function (f) { + ['Rx', 'Tx', 'Drop', 'Err', 'Tec', 'Rec', 'Bus', 'Seq', 'Delta', 'Dlc', 'Flags'].forEach(function (f) { if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; } }); - if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } + if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } } -function renderIdTable(entries, count) { + +function renderIdTable(entries) { var tbody = el.idsnapBody; if (!tbody) return; if (!entries || !entries.length) { @@ -181,7 +205,6 @@ function renderIdTableEmpty() { if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; } } -// ── 连接测试 ───────────────────────────────────────────── async function testConnection() { try { var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) }); @@ -194,7 +217,6 @@ async function testConnection() { } } -// ── 模式切换 ───────────────────────────────────────────── async function setCanMode(mode) { try { var resp = await fetch(getBaseUrl() + '/can/mode', { @@ -214,19 +236,55 @@ async function setCanMode(mode) { } } -// ── 初始化 ─────────────────────────────────────────────── -function init() { +/** + * 初始化监控逻辑。可在 can_monitor.html(独立)或 can.html(抽屉)中调用。 + * 幂等:重复调用不会叠加多个 interval。 + */ +export function initCanMonitor() { cacheElements(); + /* 无监控 DOM 时跳过(纯 can 发送页历史形态) */ + if (!el.fb0State && !el.idsnapBody) return; + + if (state.fbTimer) { + clearInterval(state.fbTimer); + state.fbTimer = null; + } + setConnState(false, '未连接'); - if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection); - if (el.monIp) el.monIp.addEventListener('change', testConnection); - if (el.btnModeAllPass) el.btnModeAllPass.addEventListener('click', function () { setCanMode(0); }); - if (el.btnModeFilter) el.btnModeFilter.addEventListener('click', function () { setCanMode(1); }); - if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); }); - if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback); + if (!state.bound) { + state.bound = true; + if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection); + if (el.monIp) el.monIp.addEventListener('change', testConnection); + /* 嵌入 can.html 时 IP 与发送台共用 */ + if (el.toolIp && !el.monIp) { + el.toolIp.addEventListener('change', function () { + pollFeedback(); + }); + } + if (el.btnModeAllPass) el.btnModeAllPass.addEventListener('click', function () { setCanMode(0); }); + if (el.btnModeFilter) el.btnModeFilter.addEventListener('click', function () { setCanMode(1); }); + if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); }); + if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback); + + /* 通道卡:默认折叠详情,点标题展开/收起 */ + document.querySelectorAll('.feedback-channel .feedback-ch-head').forEach(function (head) { + var toggle = function (e) { + e.preventDefault(); + var card = head.closest('.feedback-channel'); + if (card) card.classList.toggle('collapsed'); + }; + head.addEventListener('click', toggle); + head.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') toggle(e); + }); + }); + } setTimeout(testConnection, 150); pollFeedback(); state.fbTimer = setInterval(pollFeedback, 1000); } -init(); +/* 独立页 can_monitor.html:有 monIp 时自启;嵌入 can.html 由 can.js 调用 initCanMonitor() */ +if (document.getElementById('monIp')) { + initCanMonitor(); +} diff --git a/tools/js/common/connection.js b/tools/js/common/connection.js index 411d070..124e547 100644 --- a/tools/js/common/connection.js +++ b/tools/js/common/connection.js @@ -126,11 +126,11 @@ export function updateHeapAndTaskStatusUi(data) { setCpuFill(usagePct); if (taskList) { - var visibleTasks = tasks.slice(0, 8); - if (!visibleTasks.length) { + /* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动 */ + if (!tasks.length) { taskList.innerHTML = '
当前采样没有任务增量
'; } else { - taskList.innerHTML = visibleTasks.map(function (task) { + taskList.innerHTML = tasks.map(function (task) { var pct = Number(task.usage_pct) || 0; var delta = Number(task.runtime_delta) || 0; var fillClass = pct >= 85 ? 'err' : pct >= 70 ? 'warn' : ''; @@ -177,6 +177,16 @@ export function updateHeapAndTaskStatusUi(data) { if (!names.length) { taskList.innerHTML = '
当前状态未包含任务栈信息
'; } else { + /* 峰值占用比例降序;容器默认露出最高 3 条,其余滚动 */ + names.sort(function (a, b) { + var ia = taskStack[a] || {}; + var ib = taskStack[b] || {}; + var sa = Number(ia.stack_size_bytes) || 0; + var sb = Number(ib.stack_size_bytes) || 0; + var pa = sa > 0 ? ((Number(ia.peak_used_bytes) || 0) * 100 / sa) : 0; + var pb = sb > 0 ? ((Number(ib.peak_used_bytes) || 0) * 100 / sb) : 0; + return pb - pa; + }); taskList.innerHTML = names.map(function (name) { var item = taskStack[name] || {}; var stackSize = Number(item.stack_size_bytes) || 0; diff --git a/tools/js/common/polling.js b/tools/js/common/polling.js index 6511ce6..8a6b88b 100644 --- a/tools/js/common/polling.js +++ b/tools/js/common/polling.js @@ -25,7 +25,7 @@ * window.updateSystemStatusUi / window.updateEsp32StagesFromPhase * window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState * window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset - * window.updateUploadButtonLabel / window.updateSpiHint + * window.updateUploadButtonLabel */ import { addLog } from './log.js'; import { showToast } from './toast.js'; @@ -140,13 +140,18 @@ export async function pollStatus() { window.consecutivePollFailures = 0; addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); showToast('设备已恢复在线', 'success'); - // 设备离线后恢复在线,清理残留的 OTA 会话状态 - if (window.otaSessionState !== window.OtaSessionState.IDLE) { + // 仅清理终态残留。禁止对活跃会话 otaSessionReset: + // 旧逻辑任意非 IDLE 都 reset -> 中继中一次 status 超时恢复后会把 RELAYING + // 清成 IDLE,UI 不再跟踪;REBOOTING 被清掉后永远进不了启动完成。 + var ssRec = window.otaSessionState; + if (ssRec === window.OtaSessionState.FAILED || + ssRec === window.OtaSessionState.DONE) { window.otaSessionReset(); - addLog('I', '系统', '已清理 OTA 会话状态'); + addLog('I', '系统', '已清理终态 OTA 会话残留'); } } - if (window.isEsp32Rebooting) { + if (window.isEsp32Rebooting || + window.otaSessionState === window.OtaSessionState.REBOOTING) { var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); if (!window.rebootObservedDown && rbElapsed < 10) { // pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询 @@ -161,7 +166,12 @@ export async function pollStatus() { addLog('I', '系统', 'ESP32 已恢复在线'); showToast('ESP32 已恢复在线', 'success'); // ★ 状态机:ESP32 重启完成 → DONE - if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE); + if (window.otaSessionState === window.OtaSessionState.REBOOTING) { + window.otaSessionTransition(window.OtaSessionState.DONE); + } + if (typeof window.updateUploadButtonLabel === 'function') { + window.updateUploadButtonLabel(); + } } } try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); } @@ -225,11 +235,16 @@ export async function pollStatus() { window.rebootObservedDown = true; // 确认设备已下线(正在重启) var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); if (elapsed > 60) { - window.otaSessionTransition(window.OtaSessionState.FAILED, 'ESP32 重启超时'); - window.isEsp32Rebooting = false; - window.rebootObservedDown = false; + // ★ 统一走 cleanupAfterOtaFailure:清 xhr/定时器/按钮/阶段,避免只改 state 留下假死 UI addLog('E', '系统', 'ESP32 重启超时,设备未恢复'); showToast('ESP32 重启超时,请检查设备', 'error'); + if (typeof window.cleanupAfterOtaFailure === 'function') { + window.cleanupAfterOtaFailure('ESP32 重启超时'); + } else { + window.otaSessionTransition(window.OtaSessionState.FAILED, 'ESP32 重启超时'); + window.isEsp32Rebooting = false; + window.rebootObservedDown = false; + } updateConnectionUi(false, '未连接'); window.applyDisconnectedRuntimeState(); } else { @@ -302,13 +317,19 @@ export async function pollLogs() { // esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断 // "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动, // 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。 - if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay v1\.0 启动/.test(m[3])) { + /* 与 main 启动 banner 对齐:ESP32-C3 OTA Relay 启动(勿写死 v1.0) */ + if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay .*启动/.test(m[3])) { window.isEsp32Rebooting = false; window.rebootObservedDown = false; window.updateEsp32StagesFromPhase(6); // 启动完成 addLog('I', '系统', 'ESP32 已恢复在线(启动 banner 收到)'); showToast('ESP32 已恢复在线', 'success'); - if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE); + if (window.otaSessionState === window.OtaSessionState.REBOOTING) { + window.otaSessionTransition(window.OtaSessionState.DONE); + } + if (typeof window.updateUploadButtonLabel === 'function') { + window.updateUploadButtonLabel(); + } } /* * 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。 @@ -331,7 +352,6 @@ export async function pollLogs() { setTimeout(function () { window.updateUploadButtonLabel(); addLog('I', '系统', '控制台初始化完成,等待固件文件...'); - window.updateSpiHint(); // IP 输入变化时清零失败计数并立即刷新状态 var ipInput = document.getElementById('espIp'); if (ipInput) { diff --git a/tools/js/common/utils.js b/tools/js/common/utils.js index 6513ee3..e1194a1 100644 --- a/tools/js/common/utils.js +++ b/tools/js/common/utils.js @@ -5,10 +5,13 @@ */ // ==================== 工具函数 ==================== /** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */ +/** @description 字节格式化为 B / KB(不升到 MB,避免 1024KB 显示成 1.00MB)。 */ export function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return '--'; if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; + var kb = bytes / 1024; + if (Math.abs(kb - Math.round(kb)) < 1e-9) return Math.round(kb) + ' KB'; + return kb.toFixed(1) + ' KB'; } /** @description 格式化 Date 对象为 HH:MM:SS.mmm 时间字符串。 */ export function formatTime(date) { diff --git a/tools/js/ota/console-mode.js b/tools/js/ota/console-mode.js index 3550bdf..c902f5c 100644 --- a/tools/js/ota/console-mode.js +++ b/tools/js/ota/console-mode.js @@ -5,12 +5,20 @@ * - 覆盖 app.js 中的 switchStageFlow,支持 BLE 阶段流程 * - 提供 setConsoleMode / selectFlashTarget 等供 HTML onclick 调用 * - 维护共享固件文件状态 (sharedFirmwareFiles) 并在 WiFi/BLE 间同步 + * - 页面刷新后恢复上次升级目标与固件选择 * * 依赖:app.js (通过 window.* 或 bare 引用) 提供 switchStageFlow、 * handleFileSelect、clearFile、pickFileViaFSAPI、addLog、showToast、setActivePhase 等。 */ +import { + restorePersistedFirmwareSelection, + persistFlashTarget, + loadPersistedFlashTarget +} from './file-handle.js'; + window.connectionMode = window.connectionMode || 'wifi'; -window.flashTarget = window.flashTarget || 'esp32'; +/* 刷新后优先用上次目标,避免默认回到 ESP32 丢掉 GD32 选择上下文 */ +window.flashTarget = loadPersistedFlashTarget() || window.flashTarget || 'esp32'; window.sharedFirmwareFiles = window.sharedFirmwareFiles || { esp32: { file: null, handle: null }, gd32: { file: null, handle: null } @@ -84,6 +92,7 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { return; } window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32'; + persistFlashTarget(window.flashTarget); applySharedModeUi(); syncBleFileFromSelectedTarget(); if (window.connectionMode === 'ble') { @@ -102,18 +111,21 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { window.handleSharedFileSelect = function (event, chip) { var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null; if (file) { + /* 传统 input 无持久 handle,刷新后无法自动恢复该次选择 */ window.sharedFirmwareFiles[chip] = { file: file, handle: null }; } if (typeof window.handleFileSelect === 'function') { window.handleFileSelect(event, chip); } window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32'; + persistFlashTarget(window.flashTarget); applySharedModeUi(); setTimeout(syncBleFileFromSelectedTarget, 0); }; window.pickSharedFirmware = function (chip) { window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32'; + persistFlashTarget(window.flashTarget); applySharedModeUi(); if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') { var nativePicker = window.showOpenFilePicker; @@ -260,14 +272,11 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { }; applySharedModeUi(); + /* 刷新后恢复上次固件与目标(FSAPI handle + localStorage 目标) */ + restorePersistedFirmwareSelection().then(function () { + if (typeof window.switchStageFlow === 'function' && window.connectionMode !== 'ble') { + window.switchStageFlow(window.flashTarget || 'esp32'); + } + }).catch(function () {}); })(); - -// 暴露给 HTML onclick 调用的函数(ES Module 迁移:模块作用域不再自动成为全局变量) -window.setConsoleMode = setConsoleMode; -window.selectFlashTarget = selectFlashTarget; -window.handleSharedFileSelect = handleSharedFileSelect; -window.pickSharedFirmware = pickSharedFirmware; -window.clearSharedFirmware = clearSharedFirmware; -window.setBleFirmwareInputFile = setBleFirmwareInputFile; -window.syncBleFileFromSelectedTarget = syncBleFileFromSelectedTarget; -window.applySharedModeUi = applySharedModeUi; +/* HTML onclick 用的 API 已在 IIFE 内挂到 window.*,无需再二次赋值 */ diff --git a/tools/js/ota/file-handle.js b/tools/js/ota/file-handle.js index 22a0eb6..97a698f 100644 --- a/tools/js/ota/file-handle.js +++ b/tools/js/ota/file-handle.js @@ -21,13 +21,297 @@ window.gd32File = null; window.esp32FileHandle = null; // FileSystemFileHandle for hot reload window.gd32FileHandle = null; // FileSystemFileHandle for hot reload +/** localStorage:升级目标等轻量偏好(刷新后恢复) */ +var LS_FLASH_TARGET = 'dcu_ota_flash_target'; +/** localStorage:上次文件名/大小(无权限时仍可展示,提示一点恢复) */ +var LS_FILE_META = 'dcu_ota_fw_meta'; +/** IndexedDB:FileSystemFileHandle 可结构化克隆,刷新后可恢复同一文件 */ +var IDB_NAME = 'dcu_ota_fw_v1'; +var IDB_STORE = 'file_handles'; + +/** + * 刷新后权限常为 prompt,requestPermission 必须在用户手势里才成功。 + * 此处暂存待授权 handle,点击上传区时再 requestPermission 并直接 getFile。 + */ +window._pendingFwHandles = window._pendingFwHandles || { esp32: null, gd32: null }; + +function openHandleDb() { + return new Promise(function (resolve, reject) { + if (!window.indexedDB) { + reject(new Error('indexedDB unavailable')); + return; + } + var req = indexedDB.open(IDB_NAME, 1); + req.onupgradeneeded = function () { + var db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE); + } + }; + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error || new Error('idb open failed')); }; + }); +} + +function idbPutHandle(chip, handle) { + return openHandleDb().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(IDB_STORE, 'readwrite'); + if (handle) { + tx.objectStore(IDB_STORE).put(handle, chip); + } else { + tx.objectStore(IDB_STORE).delete(chip); + } + tx.oncomplete = function () { resolve(); }; + tx.onerror = function () { reject(tx.error); }; + }); + }).catch(function () { /* 无痕/禁用存储时静默 */ }); +} + +function idbGetHandle(chip) { + return openHandleDb().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(IDB_STORE, 'readonly'); + var req = tx.objectStore(IDB_STORE).get(chip); + req.onsuccess = function () { resolve(req.result || null); }; + req.onerror = function () { reject(req.error); }; + }); + }).catch(function () { return null; }); +} + +/** 持久化升级目标(esp32/gd32) */ +export function persistFlashTarget(chip) { + try { + if (chip === 'esp32' || chip === 'gd32') { + localStorage.setItem(LS_FLASH_TARGET, chip); + } + } catch (_) {} +} + +export function loadPersistedFlashTarget() { + try { + var t = localStorage.getItem(LS_FLASH_TARGET); + return (t === 'gd32' || t === 'esp32') ? t : null; + } catch (_) { + return null; + } +} + +/** 保存 FSAPI handle,刷新后可默认恢复该文件 */ +export function persistFileHandle(chip, handle) { + if (chip !== 'esp32' && chip !== 'gd32') return Promise.resolve(); + return idbPutHandle(chip, handle || null); +} + +function readFileMetaMap() { + try { + var raw = localStorage.getItem(LS_FILE_META); + if (!raw) return {}; + var o = JSON.parse(raw); + return o && typeof o === 'object' ? o : {}; + } catch (_) { + return {}; + } +} + +function persistFileMeta(chip, file) { + if (chip !== 'esp32' && chip !== 'gd32') return; + try { + var map = readFileMetaMap(); + if (file && file.name) { + map[chip] = { + name: file.name, + size: file.size || 0, + lastModified: file.lastModified || 0 + }; + } else { + delete map[chip]; + } + localStorage.setItem(LS_FILE_META, JSON.stringify(map)); + } catch (_) {} +} + +function loadFileMeta(chip) { + var map = readFileMetaMap(); + return map[chip] || null; +} + +/** 仅 query,不 request(刷新无用户手势时 request 会失败) */ +async function queryHandlePermission(handle) { + if (!handle || typeof handle.getFile !== 'function') return 'denied'; + try { + if (typeof handle.queryPermission === 'function') { + return await handle.queryPermission({ mode: 'read' }); + } + /* 无 query API 时尝试直接 getFile */ + await handle.getFile(); + return 'granted'; + } catch (_) { + return 'denied'; + } +} + +/** + * 在用户点击手势中:对上次 handle 重新授权并恢复文件(无需再走选文件对话框)。 + * @returns {Promise} 是否已成功恢复 + */ +export async function resumePersistedFirmwareWithGesture(chip) { + if (chip !== 'esp32' && chip !== 'gd32') return false; + var handle = (window._pendingFwHandles && window._pendingFwHandles[chip]) || + (chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle) || + null; + if (!handle) { + try { + handle = await idbGetHandle(chip); + } catch (_) { + handle = null; + } + } + if (!handle || typeof handle.getFile !== 'function') return false; + + try { + var perm = 'granted'; + if (typeof handle.queryPermission === 'function') { + perm = await handle.queryPermission({ mode: 'read' }); + } + if (perm !== 'granted' && typeof handle.requestPermission === 'function') { + perm = await handle.requestPermission({ mode: 'read' }); + } + if (perm !== 'granted') { + addLog('W', '文件', chip.toUpperCase() + ' 未授予读取权限,请在弹窗中允许,或重新选择文件'); + return false; + } + var file = await handle.getFile(); + applyRestoredFile(chip, handle, file, { silent: true }); + if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; + addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name); + showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success'); + return true; + } catch (e) { + addLog('W', '文件', chip.toUpperCase() + ' 恢复失败: ' + (e && e.message ? e.message : e)); + return false; + } +} + +function applyRestoredFile(chip, handle, file, options) { + if (chip === 'esp32') { + window.esp32FileHandle = handle; + window.esp32File = file; + } else { + window.gd32FileHandle = handle; + window.gd32File = file; + } + if (window.sharedFirmwareFiles) { + window.sharedFirmwareFiles[chip] = { file: file, handle: handle }; + } + processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})); + if (typeof window.syncBleFileFromSelectedTarget === 'function') { + window.syncBleFileFromSelectedTarget(); + } + if (typeof window.updateUploadButtonLabel === 'function') { + window.updateUploadButtonLabel(); + } +} + +/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */ +function showPendingReauthUi(chip, handle, meta) { + if (chip === 'esp32') window.esp32FileHandle = handle; + else window.gd32FileHandle = handle; + if (window._pendingFwHandles) window._pendingFwHandles[chip] = handle; + if (window.sharedFirmwareFiles) { + window.sharedFirmwareFiles[chip] = { file: null, handle: handle }; + } + + var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; + var info = document.getElementById('fileInfo' + suffix); + var nameEl = document.getElementById('fileName' + suffix); + var typeEl = document.getElementById('fileType' + suffix); + var sizeEl = document.getElementById('fileSize' + suffix); + var name = (meta && meta.name) || (handle && handle.name) || '上次固件'; + if (info) info.classList.add('show'); + if (nameEl) nameEl.textContent = name + '(点击上传区重新授权)'; + if (typeEl) typeEl.textContent = '待授权'; + if (sizeEl) sizeEl.textContent = meta && meta.size ? formatBytes(meta.size) : '--'; + var shaEl = document.getElementById('fileSha256' + suffix); + if (shaEl) shaEl.textContent = '刷新后需点击授权'; + if (typeof window.updateUploadButtonLabel === 'function') { + window.updateUploadButtonLabel(); + } +} + +/** + * 页面刷新后:恢复升级目标 + 上次 FSAPI 固件。 + * 权限仍 granted → 静默恢复;否则保留 handle,等用户点击上传区再授权。 + */ +export async function restorePersistedFirmwareSelection() { + var target = loadPersistedFlashTarget(); + if (target) { + window.flashTarget = target; + } + + var chips = ['esp32', 'gd32']; + for (var i = 0; i < chips.length; i++) { + var chip = chips[i]; + try { + var handle = await idbGetHandle(chip); + if (!handle) continue; + var meta = loadFileMeta(chip); + var perm = await queryHandlePermission(handle); + if (perm === 'granted') { + var file = await handle.getFile(); + applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true }); + if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; + addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name); + continue; + } + /* prompt/denied:不在此处 requestPermission(无用户手势会失败) */ + showPendingReauthUi(chip, handle, meta); + addLog('I', '文件', + chip.toUpperCase() + ' 上次固件「' + + ((meta && meta.name) || handle.name || '未知') + + '」需点击上传区重新授权(浏览器安全限制)'); + } catch (e) { + addLog('W', '文件', chip.toUpperCase() + ' 恢复上次固件失败: ' + (e && e.message ? e.message : e)); + } + } + + if (typeof window.applySharedModeUi === 'function') { + window.applySharedModeUi(); + } + if (typeof window.syncBleFileFromSelectedTarget === 'function') { + window.syncBleFileFromSelectedTarget(); + } + if (typeof window.updateUploadButtonLabel === 'function') { + window.updateUploadButtonLabel(); + } +} + export function handleFileSelect(event, chip) { var file = event.target.files[0]; - if (file) processFile(file, chip); + if (!file) return; + /* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */ + if (chip === 'esp32') window.esp32FileHandle = null; + else window.gd32FileHandle = null; + if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; + persistFileHandle(chip, null); + processFile(file, chip); } // 通过 File System Access API 选择文件(支持热重载) export async function pickFileViaFSAPI(chip) { + /* + * 仅在「刷新后待授权」或「尚无已加载 File」时尝试一键恢复。 + * 已有可用文件时再点上传区 → 打开选文件对话框(可换文件)。 + */ + var pending = window._pendingFwHandles && window._pendingFwHandles[chip]; + var hasLoadedFile = !!(chip === 'esp32' ? window.esp32File : window.gd32File); + if (pending || !hasLoadedFile) { + var resumed = await resumePersistedFirmwareWithGesture(chip); + if (resumed) { + return true; + } + } + if (!window.showOpenFilePicker) { addLog('W', '文件', '当前浏览器不支持 File System Access API,请手动重新选择文件'); return false; @@ -36,16 +320,30 @@ export async function pickFileViaFSAPI(chip) { var acceptMap = chip === 'esp32' ? { 'application/octet-stream': ['.bin'] } : { 'application/octet-stream': ['.bin', '.hex'] }; - var handles = await window.showOpenFilePicker({ + var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle; + if (!prevHandle && window._pendingFwHandles) { + prevHandle = window._pendingFwHandles[chip] || null; + } + var opts = { + /* 记住该 id 上次目录;再配合 startIn 定位到刷新前的文件 */ + id: chip === 'esp32' ? 'dcu-fw-esp32' : 'dcu-fw-gd32', types: [{ description: '固件文件', accept: acceptMap }], multiple: false - }); + }; + if (prevHandle) { + opts.startIn = prevHandle; + } + var handles = await window.showOpenFilePicker(opts); if (handles && handles.length > 0) { var handle = handles[0]; var file = await handle.getFile(); if (chip === 'esp32') window.esp32FileHandle = handle; else window.gd32FileHandle = handle; + if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; processFile(file, chip); + persistFileHandle(chip, handle); + persistFileMeta(chip, file); + persistFlashTarget(chip); addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name); return true; } @@ -246,10 +544,25 @@ export function ensureFileShaCached(file, chip, optArrayBuffer) { } // ==================== 文件处理 ==================== -/** @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 */ -export function processFile(file, chip) { +/** + * @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 + * @param {File} file + * @param {'esp32'|'gd32'} chip + * @param {{silent?: boolean, skipHandlePersist?: boolean}=} options + * silent: 恢复场景不弹 toast;skipHandlePersist: 已从 IDB 读出时不再写回 + */ +export function processFile(file, chip, options) { + var opts = options || {}; + var silent = !!opts.silent; var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; var isHexByName = file.name.toLowerCase().endsWith('.hex'); + if (!opts.skipHandlePersist) { + persistFlashTarget(chip); + } + /* 元数据始终更新,便于刷新后展示「上次文件名」 */ + if (file && file.name) { + persistFileMeta(chip, file); + } if (chip === 'gd32' && isHexByName) { // GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象 convertHexFileToBin(file).then(function(result) { @@ -269,7 +582,7 @@ export function processFile(file, chip) { var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name); addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size)); - showToast('HEX 已自动转换为 BIN', 'success'); + if (!silent) showToast('HEX 已自动转换为 BIN', 'success'); document.getElementById('fileInfo' + suffix).classList.add('show'); document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; @@ -278,7 +591,7 @@ export function processFile(file, chip) { ensureFileShaCached(result.file, 'gd32'); }).catch(function(err) { addLog('E', '文件', 'HEX 解析失败: ' + err.message); - showToast('HEX 文件解析失败: ' + err.message, 'error'); + if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error'); window.gd32File = null; document.getElementById('fileInfo' + suffix).classList.remove('show'); window.updateUploadButtonLabel(); @@ -294,8 +607,12 @@ export function processFile(file, chip) { isHexByName ? 'Intel HEX' : 'Binary'; document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); window.updateUploadButtonLabel(); - addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); - showToast(chip.toUpperCase() + ' 文件已选择', 'success'); + if (silent) { + addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')'); + } else { + addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); + showToast(chip.toUpperCase() + ' 文件已选择', 'success'); + } // 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算), // 上传时若文件未变 ensureFileShaCached 命中直接复用。 ensureFileShaCached(file, chip); @@ -311,6 +628,9 @@ export function clearFile(chip) { window.gd32File = null; window.gd32FileHandle = null; window._gd32FileSha256 = null; window._gd32FileShaMeta = null; } + if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; + persistFileHandle(chip, null); + persistFileMeta(chip, null); document.getElementById('fileInput' + suffix).value = ''; document.getElementById('fileInfo' + suffix).classList.remove('show'); var shaElem = document.getElementById('fileSha256' + suffix); diff --git a/tools/js/ota/progress.js b/tools/js/ota/progress.js index 179b872..7b6b518 100644 --- a/tools/js/ota/progress.js +++ b/tools/js/ota/progress.js @@ -111,6 +111,8 @@ export function updateEsp32StagesError() { * @param {string} reason 失败原因描述 */ export function cleanupAfterOtaFailure(reason) { + // 幂等:已在 FAILED 且无活跃 XHR 时只刷新文案,避免 abort 事件二次递归清状态 + var alreadyFailed = window.otaSessionState === window.OtaSessionState.FAILED; var chip = window.activeUploadChip || window.otaSessionTarget || window.currentOtaTarget || window.flashTarget || 'esp32'; // 0. 状态机:转入 FAILED(先于标志重置) @@ -136,23 +138,33 @@ export function cleanupAfterOtaFailure(reason) { setActivePhase(null); window.currentOtaTarget = null; - // 4. 恢复上传按钮 - var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn'; - var btn = document.getElementById(btnId); - if (btn) { + // 4. 恢复上传按钮(两枚都解禁,避免 chip 判错时另一侧仍灰) + ['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) { + var btn = document.getElementById(btnId); + if (!btn) return; btn.disabled = false; - if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText; - } + if (window.uploadBtnOrigText && + ((chip === 'gd32' && btnId === 'uploadGd32Btn') || + (chip !== 'gd32' && btnId === 'uploadEsp32Btn'))) { + btn.textContent = window.uploadBtnOrigText; + } + }); window.activeUploadChip = null; window.otaSessionTarget = null; window.uploadBtnOrigText = ''; - window.updateUploadButtonLabel(); + if (typeof window.updateUploadButtonLabel === 'function') { + window.updateUploadButtonLabel(); + } // 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位) - window.resumePollingAfterUpload(); + if (typeof window.resumePollingAfterUpload === 'function') { + window.resumePollingAfterUpload(); + } - addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因')); + if (!alreadyFailed) { + addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因')); + } } /** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */ diff --git a/tools/js/ota/session.js b/tools/js/ota/session.js index c3b61b2..1510da3 100644 --- a/tools/js/ota/session.js +++ b/tools/js/ota/session.js @@ -123,14 +123,32 @@ export function otaSessionTransition(newState, reason) { } else if (newState === OtaSessionState.UPLOADING) { otaFailReason = ''; } - // 同步旧标志(渐进式迁移):UPLOADING 对应 isUploading=true - if (newState === OtaSessionState.UPLOADING) window.isUploading = true; - else if (newState === OtaSessionState.IDLE || - newState === OtaSessionState.DONE || - newState === OtaSessionState.FAILED) window.isUploading = false; + // isUploading 仅表示 XHR 上传中;VERIFYING/RELAYING/REBOOTING 必须清掉, + // 否则 updateUploadButtonLabel 会把「上传并升级」一直 disabled。 + window.isUploading = (newState === OtaSessionState.UPLOADING); // REBOOTING 复用 isEsp32Rebooting(pollStatus 重启检测依赖) - if (newState === OtaSessionState.REBOOTING) window.isEsp32Rebooting = true; - else if (newState !== OtaSessionState.VERIFYING) window.isEsp32Rebooting = false; + // VERIFYING 保持 isEsp32Rebooting=false;FAILED/IDLE/DONE 必须清掉,避免 residual 假「重启中」 + if (newState === OtaSessionState.REBOOTING) { + window.isEsp32Rebooting = true; + } else if (newState === OtaSessionState.VERIFYING) { + /* 校验阶段不改 reboot 标志 */ + } else { + window.isEsp32Rebooting = false; + } + if (prev !== newState && typeof addLog === 'function') { + addLog('D', 'OTA', '会话 ' + prev + ' → ' + newState + + (reason ? ' (' + reason + ')' : '')); + } + // 终态/离开上传态后刷新按钮(避免卡在 disabled) + if (typeof window.updateUploadButtonLabel === 'function' && + (newState === OtaSessionState.IDLE || + newState === OtaSessionState.DONE || + newState === OtaSessionState.FAILED || + newState === OtaSessionState.VERIFYING || + newState === OtaSessionState.RELAYING || + newState === OtaSessionState.REBOOTING)) { + window.updateUploadButtonLabel(); + } } /** diff --git a/tools/js/ota/upload-esp32.js b/tools/js/ota/upload-esp32.js index 8006c67..0a3368a 100644 --- a/tools/js/ota/upload-esp32.js +++ b/tools/js/ota/upload-esp32.js @@ -242,21 +242,10 @@ export async function startUpload(chip) { }); // 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈 xhr.timeout = 300000; - xhr.addEventListener('timeout', function () { - stopUploadWaitHeartbeat(); - if (window.activeXhr) { - cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)'); - showToast('上传超时:设备可能忙或连接被踢,请重试', 'error'); - } - }); xhr.addEventListener('load', function () { stopUploadWaitHeartbeat(); - window.isUploading = false; - window.activeXhr = null; - window.resumePollingAfterUpload(); - uploadBtn.disabled = false; - uploadBtn.textContent = window.uploadBtnOrigText; - window.updateUploadButtonLabel(); + /* 成功路径摘掉 activeXhr;失败路径留给 cleanupAfterOtaFailure 统一摘掉并 abort 幂等 */ + if (window.activeXhr === xhr) window.activeXhr = null; if (xhr.status === 200) { /* 校验 body:避免空响应/网关误 200 被当成上传成功 */ var bodyTxt = (xhr.responseText || '').trim(); @@ -273,6 +262,11 @@ export async function startUpload(chip) { cleanupAfterOtaFailure('响应异常'); return; } + /* 成功:恢复轮询/按钮(会话仍 VERIFYING/RELAYING,isUploading 由 transition 清) */ + window.resumePollingAfterUpload(); + uploadBtn.disabled = false; + uploadBtn.textContent = window.uploadBtnOrigText; + window.updateUploadButtonLabel(); // 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复 if (chip === 'esp32') { addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…'); @@ -308,16 +302,25 @@ export async function startUpload(chip) { }); xhr.addEventListener('error', function () { stopUploadWaitHeartbeat(); - if (window.activeXhr) { - cleanupAfterOtaFailure('网络错误'); + // cleanup 会先摘 activeXhr 再 abort;仅当本 xhr 仍是活动请求或会话仍在 UPLOADING 时清理 + if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) { showToast('上传过程发生网络错误', 'error'); + cleanupAfterOtaFailure('网络错误'); } }); xhr.addEventListener('abort', function () { stopUploadWaitHeartbeat(); - if (window.activeXhr) { - cleanupAfterOtaFailure('已中止'); + // clearOtaSessionResiduals 先置 activeXhr=null 再 abort → 此处勿二次 cleanup + if (window.activeXhr === xhr) { showToast('上传已中止', 'error'); + cleanupAfterOtaFailure('已中止'); + } + }); + xhr.addEventListener('timeout', function () { + stopUploadWaitHeartbeat(); + if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) { + showToast('上传超时:设备可能忙或连接被踢,请重试', 'error'); + cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)'); } }); xhr.open('POST', url); @@ -331,6 +334,126 @@ export async function startUpload(chip) { // ==================== ESP32 OTA 完成升级 ==================== // 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮) +// +// 竞态说明(易误报“应用失败”): +// 1) finalize 成功 → 回 JSON ok → 100ms 后 esp_restart +// 2) 响应丢失 / 浏览器只收到半截 / 请求迟到打到已重启的新固件 +// → 正文可能是明文或 JSON 的 "OTA not started" +// 3) 此时 /system/status 已是 IDLE,uptime 往往很小 +// 旧逻辑:resp.json() 抛错 → 探测到 IDLE → cleanupAfterOtaFailure +// 正确:上传已成功的 VERIFYING 会话里,优先按「已应用并重启」推进 REBOOTING。 + +/** @description 进入 ESP32 重启等待(校验成功或推断已应用)。 */ +function enterEsp32RebootingUi(reason, opts) { + opts = opts || {}; + var resultEl = document.getElementById('sviResult'); + if (resultEl && !opts.keepResult) { + resultEl.textContent = opts.resultText || '已应用,等待重启'; + resultEl.className = 'svi-val ok'; + } + addLog('I', 'OTA', reason || '设备重启中…'); + showToast(opts.toast || '设备重启中…', 'success'); + updateEsp32StagesFromPhase(4); + updateEsp32StagesFromPhase(5); + window.rebootObservedDown = !!opts.observedDown; + window.rebootStartTime = Date.now(); + otaSessionTransition(OtaSessionState.REBOOTING); +} + +/** + * /ota/update 结果不明确时探测设备真实状态。 + * @param {string} detail 原始错误摘要 + * @param {{notStarted?: boolean}} flags + */ +async function resolveEsp32VerifyAmbiguous(detail, flags) { + flags = flags || {}; + addLog('W', 'OTA', '应用结果不明确: ' + (detail || 'unknown') + ',探测设备状态…'); + + var sawOffline = false; + var delays = [0, 800, 1500, 2500, 4000]; + for (var i = 0; i < delays.length; i++) { + if (delays[i] > 0) { + await new Promise(function (r) { setTimeout(r, delays[i]); }); + } + try { + var probe = await fetch(window.getBaseUrl() + '/system/status', { + signal: AbortSignal.timeout(4000) + }); + if (!probe.ok) { + sawOffline = true; + continue; + } + var st = await probe.json(); + var o = st.ota || {}; + var ssc = Number.isFinite(o.sys_state_code) ? o.sys_state_code : -1; + var uptime = Number(st.uptime_sec); + if (!Number.isFinite(uptime)) uptime = -1; + + // 7=REBOOTING + if (ssc === 7) { + enterEsp32RebootingUi('探测到设备 REBOOTING,按重启中处理', { + toast: '设备正在重启…', + observedDown: false + }); + return; + } + // 5=RECEIVING / 6=FINALIZING:仍在收尾,再等几轮 + if (ssc === 5 || ssc === 6) { + addLog('I', 'OTA', '设备仍在 ESP32 OTA (sys=' + ssc + '),继续等待…'); + continue; + } + // IDLE/其它:上传本会话已成功过;短 uptime 或曾离线 → 几乎一定是已 finalize 并重启 + // 「OTA not started」在 VERIFYING 上下文下同理(新固件无会话)。 + var freshBoot = uptime >= 0 && uptime < 120; + if (flags.notStarted || freshBoot || sawOffline) { + enterEsp32RebootingUi( + flags.notStarted + ? ('设备回报 OTA not started(uptime=' + uptime + + 's)— 上传已完成,按已应用并重启处理') + : ('探测到设备已在线 (sys=' + ssc + ', uptime=' + uptime + + 's) — 按已应用并重启处理'), + { + toast: '固件可能已应用,等待启动完成…', + observedDown: sawOffline, + resultText: '已应用(响应未完整返回,按重启完成确认)' + } + ); + return; + } + // 长 uptime + IDLE 且非 notStarted:更像请求失败、设备从未进入 finalize + addLog('E', 'OTA', '应用请求失败且设备非重启态 (sys=' + ssc + + ', uptime=' + uptime + 's): ' + (detail || '')); + var resultElFail = document.getElementById('sviResult'); + if (resultElFail) { + resultElFail.textContent = '✗ 请求失败: ' + (detail || 'unknown'); + resultElFail.className = 'svi-val err'; + } + showToast('应用请求失败: ' + (detail || 'unknown'), 'error'); + cleanupAfterOtaFailure('应用请求失败'); + return; + } catch (_probeErr) { + sawOffline = true; + } + } + + // 多轮无响应,或一直停在 FINALIZING + if (sawOffline) { + enterEsp32RebootingUi('设备无响应,按重启中观察(可能 TCP 被 esp_restart 掐断)', { + toast: '设备无响应,等待重启恢复…', + observedDown: true, + resultText: '等待设备重启…' + }); + return; + } + addLog('E', 'OTA', '应用阶段设备长时间仍忙或状态不明: ' + (detail || '')); + var resultElBusy = document.getElementById('sviResult'); + if (resultElBusy) { + resultElBusy.textContent = '✗ 请求中断,设备仍忙'; + resultElBusy.className = 'svi-val err'; + } + cleanupAfterOtaFailure('应用请求中断(设备仍忙)'); +} + /** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */ export async function verifyEsp32Firmware() { updateEsp32StagesFromPhase(3); // 校验中 @@ -356,9 +479,29 @@ export async function verifyEsp32Firmware() { signal: AbortSignal.timeout(120000) }); - var data = await resp.json(); + // 禁止裸 resp.json():失败体可能是 httpd 明文或半截响应 + var bodyTxt = ''; + try { + bodyTxt = await resp.text(); + } catch (te) { + await resolveEsp32VerifyAmbiguous('读取响应失败: ' + te.message, {}); + return; + } + var data = null; + if (bodyTxt && bodyTxt.trim()) { + try { + data = JSON.parse(bodyTxt); + } catch (_pe) { + data = null; + } + } - if (data.status === 'ok') { + var notStarted = + (data && (data.code === 'ota_not_started' || + /OTA not started/i.test(String(data.message || '')))) || + /OTA not started/i.test(bodyTxt || ''); + + if (resp.ok && data && data.status === 'ok') { addLog('I', 'OTA', '设备端 SHA-256: ' + (data.sha256 || '-')); if (data.sha256) { var deviceSha = document.getElementById('sviDeviceSha'); @@ -374,32 +517,49 @@ export async function verifyEsp32Firmware() { resultEl.className = 'svi-val ok'; } } - // 服务器已设置启动分区并自动重启,前端直接进入重启等待状态 - addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...'); - showToast('校验通过,设备重启中...', 'success'); - updateEsp32StagesFromPhase(4); // 应用固件 - window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线 - updateEsp32StagesFromPhase(5); // 重启中 - window.rebootStartTime = Date.now(); - // ★ 状态机:校验通过 → 进入 REBOOTING(内部同步 isEsp32Rebooting = true) - otaSessionTransition(OtaSessionState.REBOOTING); - } else { - var detail = data.message || (data.device ? '不匹配' : '未知错误'); + enterEsp32RebootingUi('SHA-256 校验通过,设备自动重启中...', { + toast: '校验通过,设备重启中...', + keepResult: true, + observedDown: false + }); + return; + } + + // SHA 明确不匹配 + if (data && (data.status === 'error' || !resp.ok) && + /mismatch|不匹配/i.test(String(data.message || bodyTxt || ''))) { + var detail = data.message || 'SHA-256 不匹配'; addLog('E', 'OTA', '校验失败: ' + detail); showToast('校验失败: ' + detail, 'error'); - var resultEl = document.getElementById('sviResult'); - if (resultEl) { resultEl.textContent = '✗ ' + detail; resultEl.className = 'svi-val err'; } + if (data.device) { + var dSha = document.getElementById('sviDeviceSha'); + if (dSha) dSha.textContent = data.device; + } + var resultElMm = document.getElementById('sviResult'); + if (resultElMm) { + resultElMm.textContent = '✗ ' + detail; + resultElMm.className = 'svi-val err'; + } cleanupAfterOtaFailure('校验失败'); + return; } + + // 迟到请求打到已重启固件 / 非 JSON 响应:勿当硬失败 + if (notStarted) { + await resolveEsp32VerifyAmbiguous( + bodyTxt.slice(0, 120) || 'OTA not started', + { notStarted: true } + ); + return; + } + + var failDetail = (data && data.message) || + formatHttpErrorDetail(bodyTxt) || + ('HTTP ' + resp.status); + await resolveEsp32VerifyAmbiguous(failDetail, {}); } catch (e) { - // fetch 异常仅代表请求未完成,不能推断设备已重启: - // 设备可能仍在 finalize(SHA 计算耗时)、可能已崩溃、可能网络抖动。 - // 如实报告失败,由 pollStatus 探测设备真实状态。 - addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)'); - showToast('应用请求失败: ' + e.message, 'error'); - var resultEl = document.getElementById('sviResult'); - if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; } - cleanupAfterOtaFailure('应用请求失败'); + // fetch 异常 ≠ 一定失败:finalize 成功后 esp_restart 会掐断 TCP + await resolveEsp32VerifyAmbiguous(e && e.message ? e.message : String(e), {}); } } diff --git a/tools/js/ota/upload-gd32.js b/tools/js/ota/upload-gd32.js index 825b564..a11eb2f 100644 --- a/tools/js/ota/upload-gd32.js +++ b/tools/js/ota/upload-gd32.js @@ -56,9 +56,9 @@ export function emitRelayProgress(rseq, rtot, opts) { if (rseq === window.relayWatchLastSeq) return false; window.relayWatchLastSeq = rseq; var pct = Math.min(100, Math.round(rseq * 100 / rtot)); + /* 进度只走进度条/阶段文案,不写调试日志(避免 ring/网页刷屏) */ setProgress(60 + Math.round(pct * 0.30), '已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)'); - addLog('I', 'OTA', '转发 ' + rseq + '/' + rtot + ' (' + pct + '%)'); return true; } @@ -104,6 +104,9 @@ export function markGd32OtaUiComplete(msg) { window.otaSessionState === OtaSessionState.FAILED) { otaSessionTransition(OtaSessionState.DONE); } + if (typeof window.updateUploadButtonLabel === 'function') { + window.updateUploadButtonLabel(); + } addLog('I', 'OTA', msg || 'GD32 刷写完成'); showToast(msg || 'GD32 刷写完成', 'success'); }