feat(protocol): 更新SPI Link协议至v2版本并优化CAN通信
- 将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字节,增加任务堆栈监控说明
This commit is contained in:
@@ -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 协议
|
||||
|
||||
|
||||
+73
-229
@@ -1,39 +1,39 @@
|
||||
# CAN 通信协议文档
|
||||
|
||||
> 更新日期:2026-06-29
|
||||
> 更新日期:2026-07-16
|
||||
> 涉及固件:GD32 DCU_APP + ESP32 ESP32_COMM
|
||||
> SPI 时钟:4 MHz | 应答帧最大 payload:123 字节(kTxBufSize=128 - overhead=5)
|
||||
> 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` |
|
||||
|
||||
+46
-138
@@ -1,10 +1,9 @@
|
||||
/*
|
||||
* ESP32-C3FH4 OTA 中继 - 程序入口
|
||||
* 四个 FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT
|
||||
* FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT / CAN 反馈
|
||||
*
|
||||
* 使用各模块的单例类进行初始化和任务创建。
|
||||
* 使用各模块单例初始化并创建任务;入口只做装配,不实现协议。
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#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 全局协调器已初始化");
|
||||
|
||||
/* SPI 是中继/CAN 反馈根基:失败则无法完成核心职责,按关键失败处理 */
|
||||
{
|
||||
esp_err_t ret = SpiMaster::instance().init();
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_ERR("MAIN", "SPI 总线初始化失败: %d", ret);
|
||||
} else {
|
||||
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 会自动回退到上一个有效分区
|
||||
/* 任务就绪后尽快标记 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_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", "========================================");
|
||||
|
||||
@@ -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 固件团队确认)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+67
-40
@@ -6,12 +6,17 @@
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "app_config.h"
|
||||
#include "esp_timer.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
/** 截断/回绕 WARN 最小间隔(30s) */
|
||||
static TickType_t cpuStatsWarnIntervalTicks()
|
||||
{
|
||||
return pdMS_TO_TICKS(30000);
|
||||
}
|
||||
|
||||
CpuStats& CpuStats::instance()
|
||||
{
|
||||
static CpuStats inst;
|
||||
@@ -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<UBaseType_t>(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<UBaseType_t>(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>((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>((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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+442
-215
@@ -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 <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netdb.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。
|
||||
* @param out_len 输出 JSON 字符串长度。
|
||||
* @return 动态分配的 JSON 字符串,调用者须 free()。
|
||||
* 单次持锁:peek 元数据 →(可选)按堆缩小条数 → pop+转义拼 JSON。
|
||||
* capacity 按真实 raw 长度估算,避免 48×320 虚高导致 OTA 期返空。
|
||||
*/
|
||||
char *DebugLog::ringDrainJson(int *out_len)
|
||||
{
|
||||
if (!out_len) return NULL;
|
||||
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; }
|
||||
}
|
||||
if (truncated) {
|
||||
// 确保 "..." + '"' + ']' + '\0' (共 6 字节) 有空间才写入标记
|
||||
if (pos + 6 <= capacity) {
|
||||
buf[pos++] = '.';
|
||||
buf[pos++] = '.';
|
||||
buf[pos++] = '.';
|
||||
}
|
||||
}
|
||||
pos = jsonEscapeAppend(buf, pos, capacity, entry, entry_len);
|
||||
if (pos < capacity - 1) {
|
||||
buf[pos++] = '"';
|
||||
} else if (pos < capacity) {
|
||||
buf[pos - 1] = '"'; /* 极端:覆盖末字节闭合 */
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
+46
-28
@@ -10,6 +10,9 @@
|
||||
* 不是直接监听 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 <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
// 调试日志配置(DEBUG_LOG_IP/PORT/ENABLED 已在 app_config.h 中定义)
|
||||
// 调试日志配置(DEBUG_LOG_IP/PORT 在 app_config.h)
|
||||
|
||||
// UART0 调试配置
|
||||
#define UART_DEBUG_TX_GPIO 21 ///< 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
|
||||
|
||||
@@ -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] = {}; ///< 各任务是否已告警(去重)
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* - health_monitor 巡检主动告警(低水位时 DEBUG_ERR,突破 OTA 门控)
|
||||
* - http_handlers /system/status 被动报告(前端每秒轮询展示)
|
||||
* 两处复用同一份任务表,避免散落维护。
|
||||
*
|
||||
* 表内含转发关键:ota_relay、spi_worker(帧缓冲热路径)。
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
|
||||
+11
-39
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +56,7 @@ bool OtaCoordinator::lock() const
|
||||
if (!m_mutex) return false;
|
||||
bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE);
|
||||
if (!ok) {
|
||||
DEBUG_WARN(TAG, "lock() 超时 (%u ms),本次状态变更被跳过",
|
||||
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) {
|
||||
if (Gd32FlashStore::instance().getPartition() == nullptr) {
|
||||
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: 整分区预擦完成");
|
||||
}
|
||||
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 正在存储中,拒绝触发中继");
|
||||
/* 白名单:仅 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证固件是否确实已存储(通过 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) {
|
||||
if (h) {
|
||||
if (m_sysState != OTA_SYS_GD32_RELAYING) {
|
||||
m_sysState = OTA_SYS_GD32_RELAYING;
|
||||
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
|
||||
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;
|
||||
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));
|
||||
if (OtaRelay::probeAndCacheAppAlive(500, &uptime)) {
|
||||
if (out_version) {
|
||||
*out_version = OtaRelay::getSnapshot().version;
|
||||
}
|
||||
return err;
|
||||
if (out_uptime) {
|
||||
*out_uptime = uptime;
|
||||
}
|
||||
DEBUG_INFO(TAG, "GD32 APP 探测成功: alive, uptime=%" PRIu32 "s", uptime);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (out_version) {
|
||||
*out_version = gd32_version_t{};
|
||||
}
|
||||
if (out_uptime) {
|
||||
*out_uptime = 0;
|
||||
}
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
+211
-57
@@ -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,14 +451,30 @@ 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;
|
||||
}
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局 deadline 在进入 TRANSFERRING 时才启动(见 handleTransferring)。
|
||||
@@ -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<volatile uint8_t *>(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)
|
||||
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 : "<empty>", 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 ((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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 先写入「正在发第 N 包」,前端 /system/status 能看到卡在哪 */
|
||||
m_transferCurrentSeq = seq;
|
||||
if ((seq & kSeqReportMask) == 0U || seq + 1U >= m_transferTotalPackets) {
|
||||
setRelaySeq(seq);
|
||||
}
|
||||
|
||||
// 发送单包并等待 ACK(内含重试循环)
|
||||
if (sendRelayPacketWithRetry(seq, tx_buf, chunk_size, &result)) {
|
||||
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; // 中止
|
||||
return result;
|
||||
}
|
||||
|
||||
setRelaySeq(seq + 1);
|
||||
flash_off += (uint32_t)chunk_slot[cur];
|
||||
/* 与 sendRelayPacketWithRetry 内累加对齐(绝对赋值,防漂移) */
|
||||
m_transferFlashOffset = flash_off;
|
||||
|
||||
if ((seq & kSeqReportMask) == 0U || seq + 1U >= m_transferTotalPackets) {
|
||||
setRelaySeq(seq + 1U);
|
||||
}
|
||||
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 接口回调通知协调器中继结束
|
||||
|
||||
+27
-15
@@ -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,
|
||||
|
||||
+17
-14
@@ -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) ||
|
||||
|
||||
+216
-57
@@ -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<char *>(malloc(static_cast<size_t>(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<can_channel_t>(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<uint32_t>(cJSON_GetNumberValue(id_item));
|
||||
uint8_t dlc = dlc_item ? static_cast<uint8_t>(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<uint8_t>(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<size_t>(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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+109
-36
@@ -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 <string.h>
|
||||
#include "canfd_bridge.h"
|
||||
@@ -9,7 +18,18 @@
|
||||
namespace {
|
||||
|
||||
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,48 +44,57 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 16) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 8) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>(ext_id & 0xFFU);
|
||||
if (dlc > 0U) {
|
||||
if (dlc > 0U && data != nullptr) {
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
}
|
||||
|
||||
/* 独立缓冲收响应:调用方 recv_buf 为空时也能拿到 ERROR 细分码。 */
|
||||
uint8_t local_resp[72];
|
||||
uint8_t *spi_rx = (recv_buf != nullptr && recv_max > 0U) ? recv_buf : local_resp;
|
||||
const size_t spi_rx_max = (recv_buf != nullptr && recv_max > 0U)
|
||||
? static_cast<size_t>(recv_max)
|
||||
: sizeof(local_resp);
|
||||
|
||||
uint8_t resp_type = 0U;
|
||||
size_t resp_len = 0U;
|
||||
/* 两步法: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<int>(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<int>(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) {
|
||||
/** @brief 小端读 uint32(0xC1 固定头/条目 id)。 */
|
||||
static inline uint32_t readU32Le(const uint8_t *p)
|
||||
{
|
||||
return static_cast<uint32_t>(p[0])
|
||||
| (static_cast<uint32_t>(p[1]) << 8)
|
||||
| (static_cast<uint32_t>(p[2]) << 16)
|
||||
@@ -76,9 +105,10 @@ static inline uint32_t readU32Le(const uint8_t *p) {
|
||||
|
||||
/**
|
||||
* @brief 发送一帧 CANFD 数据,可选等待并读取响应。
|
||||
* 通过 SPI 协议帧发送给 GD32 APP,并读取 ACK/ERROR 响应。
|
||||
*
|
||||
* 返回值: >=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<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
|
||||
const uint16_t entrySize = static_cast<uint16_t>(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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+80
-30
@@ -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 <string.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_random.h"
|
||||
#include "aes/esp_aes.h"
|
||||
#include "app_config.h"
|
||||
#include "protocol.h"
|
||||
#include "monitor/debug_log.h"
|
||||
@@ -20,8 +18,9 @@
|
||||
// ============================================================
|
||||
// Protocol 单例
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* @brief 获取 Protocol 单例实例。
|
||||
* @brief 获取 Protocol 单例实例(Meyers 单例)。
|
||||
* @return Protocol 单例引用。
|
||||
*/
|
||||
Protocol& Protocol::instance()
|
||||
@@ -30,24 +29,46 @@ Protocol& Protocol::instance()
|
||||
return inst;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<volatile uint8_t *>(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);
|
||||
}
|
||||
|
||||
+56
-23
@@ -1,31 +1,37 @@
|
||||
/*
|
||||
* 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 <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#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:
|
||||
@@ -36,40 +42,67 @@ public:
|
||||
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);
|
||||
|
||||
/**
|
||||
* @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
|
||||
+130
-201
@@ -8,7 +8,9 @@
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#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<Job *>(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;
|
||||
}
|
||||
return job.result;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
vSemaphoreDelete(heap_job->done);
|
||||
heap_job->done = nullptr;
|
||||
free(heap_job);
|
||||
return 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<SpiMaster *>(arg);
|
||||
if (!self) { return; }
|
||||
int level = readReadyLevelRaw();
|
||||
if (level == 0) {
|
||||
/* 下降沿 */
|
||||
/* 只关心低电平(下降沿后采样):有数据待读 */
|
||||
if (readReadyLevelRaw() != 0) {
|
||||
return;
|
||||
}
|
||||
if (self->m_suppressRingService) {
|
||||
/* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */
|
||||
} else if (self->m_linkState.acceptRing()) {
|
||||
/* GD32 按铃:仅 IDLE 态接受 */
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
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];
|
||||
|
||||
+31
-53
@@ -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);
|
||||
|
||||
+189
-88
@@ -4,11 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 CAN 通信控制台</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/base.css?v=20260717t">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260717t">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260717t">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260717t">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260717t">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -20,99 +20,62 @@
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></span>
|
||||
<span class="pill" id="canIpPill">IP --</span>
|
||||
<span class="pill" id="canRssiPill">RSSI --</span>
|
||||
<span class="pill" id="canUptimePill">运行 --</span>
|
||||
<span class="pill" id="canVersionPill">v--</span>
|
||||
<span class="pill" id="canChannelPill">CH0</span>
|
||||
<span class="pill" id="dbcSummaryPill">未导入 DBC</span>
|
||||
<span class="pill" id="messageSummaryPill">0 报文</span>
|
||||
<span class="pill" id="fbModePill">模式: --</span>
|
||||
<span class="pill" id="fbPollStatus">轮询: --</span>
|
||||
</div>
|
||||
<div class="header-sub can-header-actions">
|
||||
<a class="pill can-link-pill" href="index.html">返回 OTA 控制台</a>
|
||||
<a class="pill can-link-pill" href="can_monitor.html">CAN 接收监控</a>
|
||||
<a class="pill can-link-pill" href="can_monitor.html" title="独立全屏监控页">监控全屏</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="can-top-layout">
|
||||
<section class="card can-toolbar-card">
|
||||
<div class="card-head"><span class="ico">🧰</span> 工作台设置</div>
|
||||
<div class="card-body">
|
||||
<div class="can-toolbar-grid">
|
||||
<div class="can-toolbar-block">
|
||||
<label class="form-label" for="toolIp">ESP32 IP</label>
|
||||
<div class="input-group">
|
||||
<input class="input" type="text" id="toolIp" value="192.168.31.127" placeholder="输入 IP 地址">
|
||||
<button class="btn btn-outline" id="testConnBtn" type="button">测试</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- DBC 并入报文列表:无文件时列表内大拖放区;有文件时标题栏紧凑摘要 + 更换 -->
|
||||
<input type="file" id="dbcFileInput" accept=".dbc,text/plain" multiple style="display:none;">
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-channel">
|
||||
<label class="form-label" for="defaultChannel">默认通道</label>
|
||||
<select class="input" id="defaultChannel">
|
||||
<div class="can-editor-layout">
|
||||
<div class="can-main-col">
|
||||
<section class="card can-table-card" id="dbcDropZone">
|
||||
<div class="card-head can-msg-list-head">
|
||||
<span class="ico">📚</span>
|
||||
<span>报文列表</span>
|
||||
<!-- 已导入:合计 + 文件 chip(可逐个移除) -->
|
||||
<div class="dbc-summary-strip dbc-summary-inline" id="dbcFileInfo" hidden>
|
||||
<span class="dbc-summary-meta">
|
||||
<span><b id="dbcFileCount">0</b>文件</span>
|
||||
<span><b id="dbcMessageCount">0</b>报文</span>
|
||||
<span><b id="dbcSignalCount">0</b>信号</span>
|
||||
<span><b id="dbcNodeCount">0</b>节点</span>
|
||||
<span id="dbcFileSize">0 B</span>
|
||||
</span>
|
||||
<span class="dbc-file-chips" id="dbcFileChips"></span>
|
||||
</div>
|
||||
<div class="can-msg-head-actions">
|
||||
<button class="btn btn-ghost btn-sm" id="clearDbcBtn" type="button" title="清除全部 DBC" hidden>全部清除</button>
|
||||
<button class="btn btn-primary btn-sm" id="importDbcBtn" type="button">导入 DBC</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 通道 / 搜索 / 勾选 / 节点 / 批量 同一工具栏 -->
|
||||
<div class="can-msg-toolbar" id="canMsgToolbar">
|
||||
<label class="can-msg-channel" for="defaultChannel">
|
||||
<span class="can-msg-channel-label">通道</span>
|
||||
<select class="input can-msg-channel-select" id="defaultChannel" title="默认发送通道">
|
||||
<option value="0">CH0</option>
|
||||
<option value="1">CH1</option>
|
||||
<option value="2">CH2</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="can-msg-search-wrap">
|
||||
<input class="input can-msg-search" type="text" id="dbcSearch"
|
||||
placeholder="搜索报文名 / ID / 节点 / 信号" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-upload">
|
||||
<label class="form-label">DBC 文件</label>
|
||||
<div class="upload-zone upload-zone-sm dbc-dropzone" id="dbcDropZone">
|
||||
<div class="upload-ico">📥</div>
|
||||
<h4>导入 DBC</h4>
|
||||
<p>点击或拖拽 .dbc 文件</p>
|
||||
</div>
|
||||
<input type="file" id="dbcFileInput" accept=".dbc,text/plain" style="display:none;">
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-search">
|
||||
<label class="form-label" for="dbcSearch">报文搜索</label>
|
||||
<input class="input" type="text" id="dbcSearch" placeholder="按报文名、ID、节点或信号名过滤">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-info can-file-info-inline" id="dbcFileInfo">
|
||||
<div class="file-info-row">
|
||||
<span class="can-file-name">📄 <span id="dbcFileName">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" id="clearDbcBtn" type="button">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>报文</span><b id="dbcMessageCount">0</b></div>
|
||||
<div class="file-meta-item"><span>信号</span><b id="dbcSignalCount">0</b></div>
|
||||
<div class="file-meta-item"><span>节点</span><b id="dbcNodeCount">0</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="dbcFileSize">0 B</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-foot">
|
||||
<div class="conn-indicator err" id="connIndicator">
|
||||
<span class="light"></span>
|
||||
<span id="connIndicatorTxt">等待连接测试</span>
|
||||
</div>
|
||||
<div class="helper-note" id="connectionNote">
|
||||
当前页面会直接调用 <code>/system/status</code> 和 <code>/can/send</code>。
|
||||
</div>
|
||||
<div class="helper-note" id="dbcHint">
|
||||
当前解析器会自动识别常见 DBC 的 <code>BO_</code>、<code>SG_</code>、<code>VAL_</code>、
|
||||
<code>CM_</code> 和 <code>SIG_VALTYPE_</code>。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card can-history-card">
|
||||
<div class="card-head"><span class="ico">🕘</span> 最近发送</div>
|
||||
<div class="card-body">
|
||||
<div class="history-list history-list-compact" id="historyList">
|
||||
<div class="status-empty">尚未发送任何 CAN 帧。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="can-editor-layout">
|
||||
<section class="card can-table-card">
|
||||
<div class="card-head">
|
||||
<span class="ico">📚</span>
|
||||
<span>报文列表</span>
|
||||
<div class="card-head-actions can-batch-actions">
|
||||
<div class="can-batch-actions">
|
||||
<span class="pill" id="selectedCountPill">已勾选 0</span>
|
||||
<div class="can-node-filter" id="nodeFilterWrap">
|
||||
<button class="btn btn-outline btn-sm can-node-filter-btn" id="nodeFilterBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
||||
@@ -143,17 +106,31 @@
|
||||
</div>
|
||||
<div class="card-body can-table-card-body">
|
||||
<div class="message-list" id="messageList">
|
||||
<div class="status-empty">等待导入 DBC...</div>
|
||||
<div class="dbc-empty-drop" id="dbcEmptyDrop">
|
||||
<div class="upload-ico">📥</div>
|
||||
<h4>导入 DBC 开始编辑报文</h4>
|
||||
<p>点击此处或拖拽 <code>.dbc</code> 文件到列表区域</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="helper-note" id="dbcHint" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card can-history-card">
|
||||
<div class="card-head"><span class="ico">🕘</span> 最近发送</div>
|
||||
<div class="card-body">
|
||||
<div class="history-list history-list-compact" id="historyList">
|
||||
<div class="status-empty">尚未发送任何 CAN 帧。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="can-sidepanel">
|
||||
<section class="card">
|
||||
<section class="card can-preview-card">
|
||||
<div class="card-head"><span class="ico">🛰️</span> 帧预览</div>
|
||||
<div class="card-body">
|
||||
<div class="can-frame-panel can-frame-panel-compact">
|
||||
<div class="can-panel-kicker">编码结果</div>
|
||||
<div class="can-frame-hex" id="generatedHex">--</div>
|
||||
<div class="can-byte-strip" id="generatedBytes"></div>
|
||||
<div class="helper-note" id="encodeHint">选择报文后,信号值会实时编码为 CAN 数据。</div>
|
||||
@@ -169,7 +146,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card can-raw-card">
|
||||
<div class="card-head"><span class="ico">✍️</span> 原始帧发送</div>
|
||||
<div class="card-body">
|
||||
<div class="row-2 can-raw-grid">
|
||||
@@ -207,16 +184,140 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧滑出:CAN 接收监控(与 OTA 页「堆与任务栈」同样交互) -->
|
||||
<aside class="sys-drawer can-monitor-drawer" id="canMonitorDrawer" aria-label="CAN 接收监控">
|
||||
<div class="sys-drawer-rail" title="悬停展开 CAN 接收监控">
|
||||
<span class="sys-drawer-rail-ico">📊</span>
|
||||
<span class="sys-drawer-rail-text">CAN 接收监控</span>
|
||||
</div>
|
||||
<div class="sys-drawer-panel card">
|
||||
<div class="card-head sys-drawer-head">
|
||||
<span class="ico">📊</span> CAN 接收监控
|
||||
</div>
|
||||
<div class="card-body sys-drawer-body">
|
||||
<div class="status-section-title">接收模式</div>
|
||||
<div class="input-group" style="margin-bottom:12px;flex-wrap:wrap;">
|
||||
<button class="btn btn-primary btn-sm" id="btnModeAllPass" type="button">全收模式</button>
|
||||
<button class="btn btn-outline btn-sm" id="btnModeFilter" type="button">过滤模式</button>
|
||||
<button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2">CAN0→1/2 桥接</button>
|
||||
</div>
|
||||
<div class="helper-note" id="monHelper" style="margin-bottom:14px;">
|
||||
地址与顶栏一致;1Hz <code>/can/feedback</code>。悬停抽屉保持展开。
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">三路 CAN 通道(1Hz)</div>
|
||||
<div class="feedback-grid feedback-grid-drawer">
|
||||
<div class="feedback-channel collapsed" id="fbCh0">
|
||||
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
|
||||
<span class="ch-dot ch0"></span> CAN0
|
||||
<span class="feedback-expand-caret">▾</span>
|
||||
</div>
|
||||
<div class="feedback-primary">
|
||||
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb0State">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb0Rx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb0Tx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb0Drop">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb0Err">--</span></div>
|
||||
</div>
|
||||
<div class="feedback-detail">
|
||||
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb0Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb0Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb0Bus">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb0LastId">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb0Dlc">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb0Flags">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb0Seq">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb0Delta">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb0LastData">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-channel collapsed" id="fbCh1">
|
||||
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
|
||||
<span class="ch-dot ch1"></span> CAN1
|
||||
<span class="feedback-expand-caret">▾</span>
|
||||
</div>
|
||||
<div class="feedback-primary">
|
||||
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb1State">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb1Rx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb1Tx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb1Drop">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb1Err">--</span></div>
|
||||
</div>
|
||||
<div class="feedback-detail">
|
||||
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb1Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb1Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb1Bus">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb1LastId">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb1Dlc">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb1Flags">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb1Seq">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb1Delta">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb1LastData">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-channel collapsed" id="fbCh2">
|
||||
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
|
||||
<span class="ch-dot ch2"></span> CAN2
|
||||
<span class="feedback-expand-caret">▾</span>
|
||||
</div>
|
||||
<div class="feedback-primary">
|
||||
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb2State">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb2Rx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb2Tx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb2Drop">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb2Err">--</span></div>
|
||||
</div>
|
||||
<div class="feedback-detail">
|
||||
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb2Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb2Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb2Bus">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb2LastId">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb2Dlc">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb2Flags">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb2Seq">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb2Delta">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb2LastData">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title" style="margin-top:16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<span>每 ID 快照表</span>
|
||||
<select class="input" id="idsnapChannel" style="width:100px;text-transform:none;">
|
||||
<option value="0">CAN0</option>
|
||||
<option value="1">CAN1</option>
|
||||
<option value="2">CAN2</option>
|
||||
</select>
|
||||
<span class="pill" id="idsnapStatus">--</span>
|
||||
</div>
|
||||
<div class="idsnap-table-wrap">
|
||||
<table class="idsnap-table" id="idsnapTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>CAN ID</th><th>DLC</th>
|
||||
<th>FD</th><th>BRS</th><th>ESI</th><th>数据 (HEX)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="idsnapBody">
|
||||
<tr><td colspan="7" class="status-empty">等待数据...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="toast" id="toast">
|
||||
<span class="toast-ico" id="toastIco">✓</span>
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/can.js?v=20260715a"></script>
|
||||
<script type="module" src="js/can.js?v=20260717t"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+33
-13
@@ -4,11 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 CAN 接收监控</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/base.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260716s">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -67,14 +67,19 @@
|
||||
<div class="card-head"><span class="ico">📊</span> 三路 CAN 通道反馈(1Hz)</div>
|
||||
<div class="card-body">
|
||||
<div class="feedback-grid">
|
||||
|
||||
<div class="feedback-channel" id="fbCh0">
|
||||
<div class="feedback-ch-head"><span class="ch-dot ch0"></span> CAN0</div>
|
||||
<div class="feedback-channel collapsed" id="fbCh0">
|
||||
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
|
||||
<span class="ch-dot ch0"></span> CAN0
|
||||
<span class="feedback-expand-caret">▾</span>
|
||||
</div>
|
||||
<div class="feedback-primary">
|
||||
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb0State">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb0Rx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb0Tx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb0Drop">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb0Err">--</span></div>
|
||||
</div>
|
||||
<div class="feedback-detail">
|
||||
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb0Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb0Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb0Bus">--</span></div>
|
||||
@@ -85,14 +90,21 @@
|
||||
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb0Delta">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb0LastData">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-channel" id="fbCh1">
|
||||
<div class="feedback-ch-head"><span class="ch-dot ch1"></span> CAN1</div>
|
||||
<div class="feedback-channel collapsed" id="fbCh1">
|
||||
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
|
||||
<span class="ch-dot ch1"></span> CAN1
|
||||
<span class="feedback-expand-caret">▾</span>
|
||||
</div>
|
||||
<div class="feedback-primary">
|
||||
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb1State">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb1Rx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb1Tx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb1Drop">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb1Err">--</span></div>
|
||||
</div>
|
||||
<div class="feedback-detail">
|
||||
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb1Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb1Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb1Bus">--</span></div>
|
||||
@@ -103,14 +115,21 @@
|
||||
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb1Delta">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb1LastData">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-channel" id="fbCh2">
|
||||
<div class="feedback-ch-head"><span class="ch-dot ch2"></span> CAN2</div>
|
||||
<div class="feedback-channel collapsed" id="fbCh2">
|
||||
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
|
||||
<span class="ch-dot ch2"></span> CAN2
|
||||
<span class="feedback-expand-caret">▾</span>
|
||||
</div>
|
||||
<div class="feedback-primary">
|
||||
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb2State">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb2Rx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb2Tx">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb2Drop">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb2Err">--</span></div>
|
||||
</div>
|
||||
<div class="feedback-detail">
|
||||
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb2Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb2Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb2Bus">--</span></div>
|
||||
@@ -121,6 +140,7 @@
|
||||
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb2Delta">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb2LastData">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +182,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/can_monitor.js?v=20260715a"></script>
|
||||
<script type="module" src="js/can_monitor.js?v=20260716v"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,6 +35,7 @@ html, body {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* subtle noise-grid background */
|
||||
|
||||
+756
-130
File diff suppressed because it is too large
Load Diff
+79
-13
@@ -183,20 +183,37 @@
|
||||
|
||||
.heap-bar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.heap-bar-text {
|
||||
order: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
min-width: 165px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 百分比数值与文案同一行;进度条单独占下一行 */
|
||||
.heap-bar-pct {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.heap-bar-track {
|
||||
flex: 1;
|
||||
order: 3;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 7px;
|
||||
@@ -210,22 +227,13 @@
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(90deg, var(--accent), #5eead4);
|
||||
transition: width .4s ease;
|
||||
}
|
||||
.heap-bar-fill.warn {
|
||||
background: linear-gradient(90deg, #f59e0b, #d97706);
|
||||
}
|
||||
.heap-bar-fill.danger {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.heap-bar-pct {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.heap-detail-row {
|
||||
display: flex;
|
||||
@@ -253,6 +261,26 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
/* 默认露出约 3 条(按占用排序后的最高项),其余纵向滚动 */
|
||||
.status-task-list.list-scroll-top3 {
|
||||
--status-task-row-est: 6.75rem; /* 单条卡片近似高度(含 meta) */
|
||||
max-height: calc(3 * var(--status-task-row-est) + 2 * 8px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.status-task-list.list-scroll-top3::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.status-task-list.list-scroll-top3::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.status-task-list.list-scroll-top3::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.status-task {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
@@ -315,6 +343,42 @@
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
/* 区块内二级折叠:默认 collapsed 收起,点击标题展开 */
|
||||
.status-subfold {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.status-subfold-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 6px 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.status-subfold-head:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.status-subfold-title {
|
||||
margin: 0 !important;
|
||||
flex: 1;
|
||||
}
|
||||
.status-subfold-head .collapse-arrow {
|
||||
flex: none;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.status-subfold.collapsed .collapse-arrow {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.status-subfold.collapsed .status-subfold-body {
|
||||
display: none;
|
||||
}
|
||||
.status-subfold-body {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.status-json {
|
||||
margin-top: 0;
|
||||
padding: 12px;
|
||||
@@ -329,6 +393,8 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
resize: vertical; /* 展开后可拖动缩放高度 */
|
||||
min-height: 80px;
|
||||
}
|
||||
.status-empty {
|
||||
padding: 12px;
|
||||
|
||||
@@ -46,6 +46,91 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* === 右侧滑出抽屉:堆与任务栈 ===
|
||||
* 总宽 = peek 把手 + 面板;默认 translateX(面板宽) 只露把手,hover 整板探出 */
|
||||
.sys-drawer {
|
||||
--sys-drawer-panel-w: min(380px, calc(100vw - 48px));
|
||||
--sys-drawer-peek: 36px;
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 0;
|
||||
z-index: 80;
|
||||
width: calc(var(--sys-drawer-panel-w) + var(--sys-drawer-peek));
|
||||
height: calc(100vh - 88px);
|
||||
max-height: calc(100vh - 88px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
transform: translateX(var(--sys-drawer-panel-w));
|
||||
transition: transform .28s cubic-bezier(.22, 1, .36, 1);
|
||||
filter: drop-shadow(-6px 0 18px rgba(0, 0, 0, .35));
|
||||
}
|
||||
.sys-drawer:hover,
|
||||
.sys-drawer:focus-within {
|
||||
transform: translateX(0);
|
||||
}
|
||||
/* 平时露出的竖条把手 */
|
||||
.sys-drawer-rail {
|
||||
flex: 0 0 var(--sys-drawer-peek);
|
||||
width: var(--sys-drawer-peek);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 4px;
|
||||
background: linear-gradient(180deg, #1a2a3a 0%, var(--bg-card) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-right: none;
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
cursor: default;
|
||||
}
|
||||
.sys-drawer-rail-ico {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.sys-drawer-rail-text {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .12em;
|
||||
color: var(--text-dim);
|
||||
text-transform: none;
|
||||
}
|
||||
.sys-drawer:hover .sys-drawer-rail-text,
|
||||
.sys-drawer:focus-within .sys-drawer-rail-text {
|
||||
color: var(--accent);
|
||||
}
|
||||
.sys-drawer-panel {
|
||||
flex: 1 1 var(--sys-drawer-panel-w);
|
||||
width: var(--sys-drawer-panel-w);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
.sys-drawer-head {
|
||||
cursor: default;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sys-drawer-head .card-head-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.sys-drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* === CARDS === */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
/* === RESPONSIVE === */
|
||||
@media (max-width: 1100px) {
|
||||
.main-layout { grid-template-columns: 1fr; }
|
||||
.sys-drawer {
|
||||
--sys-drawer-panel-w: min(340px, calc(100vw - 40px));
|
||||
--sys-drawer-peek: 32px;
|
||||
top: 64px;
|
||||
height: calc(100vh - 72px);
|
||||
max-height: calc(100vh - 72px);
|
||||
}
|
||||
.can-monitor-drawer {
|
||||
--sys-drawer-panel-w: min(400px, calc(100vw - 40px));
|
||||
}
|
||||
.badge-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.can-toolbar-grid,
|
||||
.can-toolbar-foot,
|
||||
@@ -13,6 +23,14 @@
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 16px; }
|
||||
.sys-drawer {
|
||||
--sys-drawer-panel-w: calc(100vw - 36px);
|
||||
--sys-drawer-peek: 28px;
|
||||
top: 56px;
|
||||
height: calc(100vh - 60px);
|
||||
max-height: calc(100vh - 60px);
|
||||
}
|
||||
.sys-drawer-rail-text { font-size: 11px; }
|
||||
.header-bar { flex-direction: column; align-items: flex-start; }
|
||||
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
|
||||
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
|
||||
@@ -27,20 +45,33 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
.can-selection-bar,
|
||||
.can-selection-actions,
|
||||
.history-item-top {
|
||||
.can-selection-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.can-msg-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.history-item-row {
|
||||
grid-template-columns: auto 56px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
.history-item-hex,
|
||||
.history-item-foot {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
.can-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
.can-frame-hex {
|
||||
font-size: 15px;
|
||||
}
|
||||
/* 禁止再强制 980px 最小宽,避免报文/信号表横向滑动 */
|
||||
.message-table,
|
||||
.signal-table {
|
||||
min-width: 980px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
-81
@@ -9,11 +9,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 OTA 中继控制台</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260715a">
|
||||
<link rel="stylesheet" href="css/base.css?v=20260716o">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260716o">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260716o">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260716o">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260716o">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -288,55 +288,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">🧠</span> 堆与任务栈
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-section-title">CPU 占用</div>
|
||||
<div class="heap-group cpu-group">
|
||||
<div class="heap-group-title">ESP32 Runtime <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(FreeRTOS)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="cpuUsageText">等待采样...</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill cpu-fill" id="cpuUsageFill"></span></span>
|
||||
<span class="heap-bar-pct" id="cpuUsagePct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="cpuIdlePct">--</b></span>
|
||||
<span>窗口: <b id="cpuSampleMs">--</b></span>
|
||||
<span>任务数: <b id="cpuTaskCount">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-task-list cpu-task-list" id="cpuTaskList">
|
||||
<div class="status-empty">等待下一次状态刷新...</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">堆概况</div>
|
||||
<div class="heap-group">
|
||||
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="memInternalUsage">--</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span>
|
||||
<span class="heap-bar-pct" id="memInternalPct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="mInternalHeap">--</b></span>
|
||||
<span>最低空闲: <b id="mInternalMinHeap">--</b></span>
|
||||
<span>最大块: <b id="mInternalLargestBlock">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">任务栈水位</div>
|
||||
<div class="status-task-list" id="taskStackList">
|
||||
<div class="status-empty">等待状态刷新...</div>
|
||||
</div>
|
||||
<div class="status-section-title">原始 JSON</div>
|
||||
<pre class="status-json" id="statusJson">--</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAN workspace entry -->
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
@@ -363,38 +314,17 @@
|
||||
<div class="can-launch-actions">
|
||||
<a class="btn btn-primary can-launch-primary" href="can.html">进入 CAN 页面</a>
|
||||
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('can.html','_blank','noopener')">新窗口打开</button>
|
||||
<a class="btn btn-outline can-launch-secondary" href="can_monitor.html">CAN 接收监控</a>
|
||||
<a class="btn btn-outline can-launch-secondary" href="can.html" title="发送页右侧抽屉内嵌监控">CAN 发送 + 监控</a>
|
||||
<a class="btn btn-outline can-launch-secondary" href="can_monitor.html">监控全屏</a>
|
||||
</div>
|
||||
<div class="can-launch-foot">
|
||||
<span class="can-launch-foot-label">说明</span>
|
||||
<span>手工 CAN 表单已迁到独立页面,避免和 OTA 控制流混在一起;SPI 调试仍保留在当前页。</span>
|
||||
<span>手工 CAN 表单已迁到独立页面,避免和 OTA 控制流混在一起。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPI panel -->
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">⚡</span> SPI 发送
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||
<input class="input" type="text" id="spiType" value="01" maxlength="2" placeholder="type" title="帧类型 (hex)" style="width:52px;flex:none;">
|
||||
<input class="input" type="text" id="spiData" value="" placeholder="payload hex..." style="flex:1;min-width:100px;">
|
||||
<input class="input" type="number" id="spiTimeout" value="1000" min="100" max="10000" title="超时 ms" style="width:68px;flex:none;">
|
||||
<span style="font-size:11px;color:var(--text-dim);">ms</span>
|
||||
<button class="btn btn-primary" onclick="sendSpiFrame()" style="padding:4px 14px;">发送</button>
|
||||
<button class="btn btn-outline" onclick="clearSpiFields()" style="padding:4px 10px;">清空</button>
|
||||
</div>
|
||||
<div class="helper-note" id="spiHint">type(hex) + payload → 自动构建 SOF+LEN+CRC 帧</div>
|
||||
<div id="spiResp" style="display:none;margin-top:8px;padding:8px 10px;background:var(--bg-elevated);border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:12px;">
|
||||
<span id="spiRespText" style="color:var(--accent);"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log panel -->
|
||||
<div class="card log-panel">
|
||||
<div class="card-head" style="justify-content:space-between;">
|
||||
@@ -425,14 +355,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧滑出:堆与任务栈(默认露出一条,悬停展开) -->
|
||||
<aside class="sys-drawer" id="sysDrawer" aria-label="堆与任务栈">
|
||||
<div class="sys-drawer-rail" title="悬停展开堆与任务栈">
|
||||
<span class="sys-drawer-rail-ico">🧠</span>
|
||||
<span class="sys-drawer-rail-text">堆与任务栈</span>
|
||||
</div>
|
||||
<div class="sys-drawer-panel card">
|
||||
<div class="card-head sys-drawer-head">
|
||||
<span class="ico">🧠</span> 堆与任务栈
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" type="button" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
</div>
|
||||
<div class="card-body sys-drawer-body">
|
||||
<div class="status-section-title">CPU 占用</div>
|
||||
<div class="heap-group cpu-group">
|
||||
<div class="heap-group-title">ESP32 Runtime <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(FreeRTOS)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="cpuUsageText">等待采样...</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill cpu-fill" id="cpuUsageFill"></span></span>
|
||||
<span class="heap-bar-pct" id="cpuUsagePct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="cpuIdlePct">--</b></span>
|
||||
<span>窗口: <b id="cpuSampleMs">--</b></span>
|
||||
<span>任务数: <b id="cpuTaskCount">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-task-list cpu-task-list list-scroll-top3" id="cpuTaskList" title="按占用降序,默认显示前 3 项,下滑查看其余">
|
||||
<div class="status-empty">等待下一次状态刷新...</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">堆概况</div>
|
||||
<div class="heap-group">
|
||||
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="memInternalUsage">--</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span>
|
||||
<span class="heap-bar-pct" id="memInternalPct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="mInternalHeap">--</b></span>
|
||||
<span>最低空闲: <b id="mInternalMinHeap">--</b></span>
|
||||
<span>最大块: <b id="mInternalLargestBlock">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">任务栈水位 <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim);">(峰值占用 Top,下滑查看)</span></div>
|
||||
<div class="status-task-list list-scroll-top3" id="taskStackList" title="按峰值占用降序,默认显示前 3 项,下滑查看其余">
|
||||
<div class="status-empty">等待状态刷新...</div>
|
||||
</div>
|
||||
<div class="status-subfold collapsed" id="statusJsonFold">
|
||||
<div class="status-subfold-head" role="button" tabindex="0"
|
||||
onclick="this.parentElement.classList.toggle('collapsed')"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.parentElement.classList.toggle('collapsed');}">
|
||||
<span class="status-section-title status-subfold-title">原始 JSON</span>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="status-subfold-body">
|
||||
<pre class="status-json" id="statusJson">--</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast">
|
||||
<span class="toast-ico" id="toastIco">✓</span>
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/app.js?v=20260716c"></script>
|
||||
<script type="module" src="js/ota/console-mode.js?v=20260716c"></script>
|
||||
<script type="module" src="js/ble_ota.js?v=20260716c"></script>
|
||||
<script type="module" src="js/app.js?v=20260716p"></script>
|
||||
<script type="module" src="js/ota/console-mode.js?v=20260716p"></script>
|
||||
<script type="module" src="js/ble_ota.js?v=20260716p"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-14
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+251
-102
@@ -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 = '正在请求 <code>/system/status</code> 检查设备状态...';
|
||||
}
|
||||
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 <b>' + resp.status + '</b>,请确认设备地址和 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: <code>' + sanitizeHtml(wifi.ip || '--') + '</code>,RSSI: <b>' +
|
||||
sanitizeHtml(wifi.rssi !== undefined && wifi.rssi !== -127 ? (wifi.rssi + ' dBm') : '--') +
|
||||
'</b>,运行: <b>' + sanitizeHtml(formatUptime(data.uptime_sec)) + '</b>';
|
||||
}
|
||||
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 = '无法访问 <code>/system/status</code>:' + 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 已解析:<b>' + dbc.messages.length + '</b> 条报文,<b>' +
|
||||
dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0) +
|
||||
'</b> 个信号。';
|
||||
}
|
||||
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();
|
||||
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 = '';
|
||||
if (el.dbcHint) {
|
||||
el.dbcHint.className = 'helper-note';
|
||||
el.dbcHint.innerHTML = '当前解析器会自动识别常见 DBC 的 <code>BO_</code>、<code>SG_</code>、<code>VAL_</code>、<code>CM_</code> 和 <code>SIG_VALTYPE_</code>。';
|
||||
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 = '';
|
||||
}
|
||||
|
||||
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);
|
||||
if (el.defaultChannel) {
|
||||
el.defaultChannel.addEventListener('change', function () {
|
||||
el.manualChannel.value = el.defaultChannel.value;
|
||||
el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
|
||||
if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value;
|
||||
if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
|
||||
renderMessageList();
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
scheduleCanPersist();
|
||||
});
|
||||
el.dbcDropZone.addEventListener('click', function () { el.dbcFileInput.click(); });
|
||||
}
|
||||
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 () {
|
||||
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 file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null;
|
||||
if (file) handleDbcFile(file).catch(handleDbcError);
|
||||
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 file = event.target && event.target.files ? event.target.files[0] : null;
|
||||
if (file) handleDbcFile(file).catch(handleDbcError);
|
||||
var files = event.target && event.target.files ? event.target.files : null;
|
||||
if (files && files.length) handleDbcFiles(files).catch(handleDbcError);
|
||||
event.target.value = '';
|
||||
});
|
||||
el.clearDbcBtn.addEventListener('click', clearDbc);
|
||||
el.dbcSearch.addEventListener('input', filterMessages);
|
||||
}
|
||||
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();
|
||||
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();
|
||||
+91
-7
@@ -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>),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://<ip>)。 */
|
||||
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 + ' 报文';
|
||||
|
||||
+67
-21
@@ -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 '' +
|
||||
'<span class="dbc-file-chip" data-source-id="' + sanitizeHtml(s.id) + '" title="' + sanitizeHtml(s.name) + ' · ' + nMsg + ' 报文">' +
|
||||
'<span class="dbc-file-chip-name">' + sanitizeHtml(s.name) + '</span>' +
|
||||
'<button type="button" class="dbc-file-chip-remove" data-remove-id="' + sanitizeHtml(s.id) + '" title="移除此文件">×</button>' +
|
||||
'</span>';
|
||||
}).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 = '<div class="status-empty">等待导入 DBC...</div>';
|
||||
if (!state.dbc || !state.dbcSources.length) {
|
||||
el.messageList.innerHTML =
|
||||
'<div class="dbc-empty-drop" id="dbcEmptyDrop">' +
|
||||
'<div class="upload-ico">📥</div>' +
|
||||
'<h4>导入 DBC 开始编辑报文</h4>' +
|
||||
'<p>支持一次选择或拖入 <b>多个</b> <code>.dbc</code> 文件</p>' +
|
||||
'</div>';
|
||||
updateBatchUi();
|
||||
return;
|
||||
}
|
||||
@@ -210,7 +249,10 @@ export function renderMessageList() {
|
||||
'<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + sanitizeHtml(msg.key) + '"' + checked + '></span>' +
|
||||
'<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + sanitizeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' +
|
||||
'<span class="message-col message-col-index">' + (index + 1) + '</span>' +
|
||||
'<span class="message-col message-col-name" title="' + sanitizeHtml(msg.name) + '">' + sanitizeHtml(msg.name) + '</span>' +
|
||||
'<span class="message-col message-col-dbc" title="' + sanitizeHtml(msg.sourceName || '-') + '">' +
|
||||
sanitizeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '</span>' +
|
||||
'<span class="message-col message-col-name" title="' + sanitizeHtml(msg.name) + '">' +
|
||||
sanitizeHtml(msg.name) + '</span>' +
|
||||
'<span class="message-col message-col-id">' + sanitizeHtml(idHex) + '</span>' +
|
||||
'<span class="message-col message-col-channel">CH' + sanitizeHtml(channelTxt) + '</span>' +
|
||||
'<span class="message-col message-col-type">' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' +
|
||||
@@ -230,6 +272,7 @@ export function renderMessageList() {
|
||||
'<span class="message-col message-col-check"></span>' +
|
||||
'<span class="message-col message-col-toggle"></span>' +
|
||||
'<span class="message-col message-col-index">#</span>' +
|
||||
'<span class="message-col message-col-dbc">DBC名称</span>' +
|
||||
'<span class="message-col message-col-name">报文名称</span>' +
|
||||
'<span class="message-col message-col-id">标识符</span>' +
|
||||
'<span class="message-col message-col-channel">通道</span>' +
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
+134
-43
@@ -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<object[]>} 与 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 '' +
|
||||
'<div class="history-item">' +
|
||||
'<div class="history-item-top">' +
|
||||
'<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' +
|
||||
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + sanitizeHtml(item.mode) + '</span>' +
|
||||
'<span class="history-item-time">' + sanitizeHtml(item.time) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="history-item-main">' +
|
||||
'<b>CH' + sanitizeHtml(item.channel) + '</b> · ' +
|
||||
sanitizeHtml(item.idHex) + ' · DLC ' + sanitizeHtml(item.dlc) +
|
||||
'</div>' +
|
||||
'<div class="history-item-hex">' + sanitizeHtml(item.dataHex || '--') + '</div>' +
|
||||
'<div class="history-item-foot">' + sanitizeHtml(item.resultText) + '</div>' +
|
||||
'<span class="history-item-main" title="' + sanitizeHtml(meta) + '">' + sanitizeHtml(meta) + '</span>' +
|
||||
'<span class="history-item-hex" title="' + sanitizeHtml(item.dataHex || '') + '">' + sanitizeHtml(item.dataHex || '--') + '</span>' +
|
||||
'<span class="history-item-foot" title="' + sanitizeHtml(item.resultText || '') + '">' + sanitizeHtml(item.resultText) + '</span>' +
|
||||
'</div>';
|
||||
}).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,29 +204,82 @@ 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
|
||||
var dlc = (typeof msg.dlc === 'number') ? msg.dlc : dataBytes.length;
|
||||
prepared.push({
|
||||
msg: msg,
|
||||
idHex: idHex,
|
||||
dlc: dlc,
|
||||
dataHex: dataHex,
|
||||
frame: { id: msg.id, dlc: dlc, data: dataHex }
|
||||
});
|
||||
}
|
||||
|
||||
/* 分块批量接口;不支持时整段回退为无间隔单帧 */
|
||||
var useBatchApi = true;
|
||||
for (var off = 0; off < prepared.length; ) {
|
||||
var chunk = prepared.slice(off, off + BATCH_CHUNK);
|
||||
off += chunk.length;
|
||||
var frames = chunk.map(function (p) { return p.frame; });
|
||||
var results = null;
|
||||
|
||||
if (useBatchApi) {
|
||||
try {
|
||||
results = await sendCanBatchRequest(channel, frames);
|
||||
if (results.length !== chunk.length) {
|
||||
throw new Error('results 长度不匹配');
|
||||
}
|
||||
} catch (err) {
|
||||
useBatchApi = false;
|
||||
results = null;
|
||||
/* 本 chunk 改单帧紧接 */
|
||||
}
|
||||
}
|
||||
|
||||
if (results) {
|
||||
for (var r = 0; r < chunk.length; r++) {
|
||||
var item = chunk[r];
|
||||
var result = results[r] || { status: 'error', code: -3 };
|
||||
if (result.status === 'ok') ok++; else fail++;
|
||||
addHistoryEntry({
|
||||
time: formatTime(new Date()),
|
||||
mode: 'BATCH',
|
||||
ok: result.status === 'ok',
|
||||
channel: channel,
|
||||
idHex: idHex,
|
||||
dlc: msg.dlc,
|
||||
dataHex: dataHex,
|
||||
resultText: msg.name + ' · ' + buildSendResponseText(result)
|
||||
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({
|
||||
@@ -196,17 +287,17 @@ export async function sendCheckedMessages() {
|
||||
mode: 'BATCH',
|
||||
ok: false,
|
||||
channel: channel,
|
||||
idHex: idHex,
|
||||
dlc: msg.dlc,
|
||||
dataHex: dataHex,
|
||||
resultText: msg.name + ' · ' + err.message
|
||||
});
|
||||
}
|
||||
/* 帧间间隔,避免 GD32 SPI RXORERR(最后一帧不需要等) */
|
||||
if (i < keys.length - 1) {
|
||||
await new Promise(function (r) { setTimeout(r, BATCH_INTERVAL_MS); });
|
||||
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');
|
||||
|
||||
+31
-4
@@ -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);
|
||||
|
||||
+76
-18
@@ -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,20 +33,36 @@ 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) {} }
|
||||
state.fbAbortCtrl = new AbortController();
|
||||
var fbPollTimer = setTimeout(function () { try { state.fbAbortCtrl.abort(); } catch (_e) {} }, 3000);
|
||||
@@ -51,7 +71,7 @@ async function pollFeedback() {
|
||||
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';
|
||||
@@ -103,8 +121,14 @@ function renderChannel(i, ch) {
|
||||
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 + '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 = '未知';
|
||||
@@ -123,7 +147,6 @@ function renderChannel(i, ch) {
|
||||
}
|
||||
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];
|
||||
@@ -152,7 +175,8 @@ function renderChannelEmpty(i) {
|
||||
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 (!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();
|
||||
}
|
||||
|
||||
@@ -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 = '<div class="status-empty">当前采样没有任务增量</div>';
|
||||
} 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 = '<div class="status-empty">当前状态未包含任务栈信息</div>';
|
||||
} 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;
|
||||
|
||||
+31
-11
@@ -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) {
|
||||
// ★ 统一走 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;
|
||||
addLog('E', '系统', 'ESP32 重启超时,设备未恢复');
|
||||
showToast('ESP32 重启超时,请检查设备', 'error');
|
||||
}
|
||||
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 <FW_VERSION> 启动(勿写死 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.*,无需再二次赋值 */
|
||||
|
||||
+327
-7
@@ -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<boolean>} 是否已成功恢复
|
||||
*/
|
||||
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();
|
||||
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);
|
||||
|
||||
@@ -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,24 +138,34 @@ export function cleanupAfterOtaFailure(reason) {
|
||||
setActivePhase(null);
|
||||
window.currentOtaTarget = null;
|
||||
|
||||
// 4. 恢复上传按钮
|
||||
var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn';
|
||||
// 4. 恢复上传按钮(两枚都解禁,避免 chip 判错时另一侧仍灰)
|
||||
['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) {
|
||||
var btn = document.getElementById(btnId);
|
||||
if (btn) {
|
||||
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 = '';
|
||||
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
|
||||
// 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位)
|
||||
if (typeof window.resumePollingAfterUpload === 'function') {
|
||||
window.resumePollingAfterUpload();
|
||||
}
|
||||
|
||||
if (!alreadyFailed) {
|
||||
addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因'));
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */
|
||||
export function setActivePhase(phase) {
|
||||
|
||||
+25
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+201
-41
@@ -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'; }
|
||||
cleanupAfterOtaFailure('校验失败');
|
||||
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), {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user