Compare commits

...
26 Commits
Author SHA1 Message Date
cbb ff590d8996 docs(review): 专家评审 2026-10-19 2026-10-20 21:13:50 +08:00
小龙虾 af1f68222e docs(readme): 更新文档说明
- 添加项目使用指南
- 完善API接口说明
- 修正错误的配置示例
2026-10-13 00:47:15 +08:00
小龙虾 1b50ce1657 feat(can): 新增CAN通道收发器Silent模式功能
- 新增SPI 0xC8命令用于切换CAN通道收发器Silent模式(只听不发)
- 在canFdSetTxSilent函数中实现静默模式控制逻辑
- 在HTTP API中添加POST /can/silent端点
- 更新CAN协议文档,记录新命令和错误码E6
- 修改bus_state位图,添加bit5表示静默状态

fix(can): 优化CAN反馈任务内存使用和链路离线处理

- 将任务栈上的临时缓冲区改为静态分配,防止栈溢出
- 添加链路在线检测,在离线状态下进入休眠退避
- 修复queryMotorStatePages和queryAdcFeedback函数返回值类型
- 优化UDP日志发送的冷却退避机制

chore(config): 更新调试日志配置和版本信息

- 在VSCode设置中添加ESP-IDF串口配置
- 添加SPI调试日志级别编译期开关CONFIG_SPI_DEBUG_LOG_LEVEL
- 更新固件版本至0.5.2,记录相关变更内容
- 调整SPI和CAN反馈任务的栈大小配置
2026-10-10 02:25:12 +08:00
cbb 84f496c22c feat(core): 0.5.0 SPI 链路稳健性 + OTA 内存 + 状态诊断
变更点:
1. SPI master
   - CS 事务间强制 ≥1ms,根治 GD32 CS EXTI re-arm 窗口 RX DMA 撕裂
   - APP ISR 快速响应类型禁用盲读
   - serviceRing 偷读迟到 RESPONSE 时推进 m_txSeq,避免命令被旧序号吞掉
   - tx_total/retry/fail + ring_steal_* + rsp_err_0x82 进 /system/status
2. OTA
   - 收块缓冲从常驻 4KB 静态改为会话内 RAII malloc
3. CAN / 日志
   - 0xC1 LINK_ERROR(0x7F) 风暴限流;网页 SPI 成功日志降 DBG
4. 版本
   - /system/status 的 GD32 版本只认 SPI 实时身份,不用 OtaRelay 过期缓存
5. 固件版本 0.5.0
2026-09-20 22:58:17 +08:00
小龙虾 5315f91475 feat(core): 升级固件至v0.4.1并增加GD32初始化状态监控功能
- 修复GD32 OTA中继全程ring抑制问题:handleIdle起点reset()曾立即解除
  taskEntry设置的suppressRingService(true),导致中继全程worker偷读OTA ACK/WAIT
- 增加reset(bool release_ring_suppress)参数控制ring抑制释放时机
- 新增GD32 APP初始化状态可视化功能(配套GD32 1.5.34+):
  * 通过SPI 0xA3查询模块位图/关键失败位图/复位源
  * /system/status接口新增gd32_init字段提供状态详情
 * 支持故障码数组显示,前端可直接显示中文失败原因
- 添加GD32模块级故障码支持(配套GD32 1.5.36):
  * 解析INIT_STATUS响应中的fault_codes数组
  * 每模块1B故障码,支持CANFD通道号显示
- 电机状态协议升级:STATUS DLC从32字节扩展到48字节,
  二进制协议版本升级到v2,entry大小从76字节变为92字节
- 修复SPI响应类型表,将0xA3加入短响应和APP ISR快速响应类型
2026-09-09 03:23:06 +08:00
小龙虾 ebb3dcdf38 feat(debug_log): 新增UART0 RX泵任务支持串口日志转发
新增UART0 RX泵任务功能,将GPIO20接收到的串口数据按切行、剥离后以tag=UART推入ring buffer,通过HTTP /logs接口显示在网页日志面板。
接线方式:任意串口源TX连接至H1/H2的ESP_RX引脚,波特率3M 8N1。

feat(motor_ota): GD32 OTA增加包级防呆机制防止错误固件刷入

中继触发前校验gd32_ota镜像向量表身份(Gd32FlashStore::validateStoredImageIdentity),
对Bootloader包或非APP包在WiFi /ota/relay与BLE 0xF7均拒绝中继,
避免GD32 APP区被错误包擦除。新增0x87完成码用于固件身份验证失败场景。

feat(motor_state): 扩展CAN状态数据结构支持CONFIG负载

扩展motor_slot_t结构体,新增config_rx_count、config_seq和config_data字段
用于存储CONFIG帧的相关信息。CAN状态数据包大小从44B扩展到76B,
页面总数从6调整为9,支持目标值回报的配置数据传输。

fix(motor_ota): 修复OTA取消流程中的信用度管理和任务清理问题

★ 先复位credit再发送CANCEL命令,防止传输卡住时背压导致CANCEL被拒绝。
延长取消等待时间至10s覆盖最坏情况,确保feedTask正常退出而非强制删除。
取消完成后复位credit防止锁存死锁,确保后续通信正常进行。

fix(http_server): 修复HTTP请求处理中的边界检查和安全验证

拒绝chunked请求(content_len<=0)防止无限循环,添加通道参数范围验证(ch 0~2),
在OTA中继触发前验证固件身份防止错误包刷入,使用http_json_append避免缓冲区越界。

refactor(gd32_flash): 实现GD32固件身份验证算法确保刷写安全

基于Cortex-M向量表内容实现包级身份校验,通过MSP和复位向量位置
区分APP包、Bootloader包和无效固件,为OTA安全提供基础保障。

BREAKING CHANGE: 电机状态API数据格式变更,CAN通道参数验证更严格
2026-09-01 02:49:34 +08:00
小龙虾 ef6d3a4be3 feat(can): 添加跨通道CAN突发发送功能支持多电机同步驱动
- 新增 /can/send_burst HTTP接口,支持一次SPI事务批量发送多帧CAN消息
- 实现canFdSendBurst函数,通过0xC7 SPI类型实现背靠背装载各通道邮箱
- 添加canBurstCapacity函数计算单次突发最大帧数限制
- 突发发送期间暂停can_feedback_task避免SPI抢占冲突

refactor(can_feedback_task): 改造为周期查询+事件消费模式

- 0xC4/0xC5从GD32主动推送改为ESP32周期查询(250ms/1s)
- 0xC6/0xD2仍保留事件驱动推送模式,由任务消费处理
- 消除OTA期间GD32悬空按铃导致的超时刷屏问题
- 电机OTA期间也暂停任务避免与0xD1抢夺SPI资源

perf(ota_relay): 增加重发次数上限提升传输可靠性

- 将kMaxDataSends从3次增加到5次,提高数据包传输成功率

chore(version): 更新固件版本号至0.3.78

- 版本查询checkVersionWait移到tickInFlight释放后异步触发
- 避免/motor/version请求拖长tick导致tickStuck误报通道闪黄
2026-08-10 00:54:12 +08:00
小龙虾 f9cbcc396b feat(spi): 增强 SPI master:性能剖析与执行耗时跟踪
- 新增 `lastWorkerExecUs()`:获取最近一次 worker 任务执行耗时(µs)。
- 引入 `SpiProfile` 结构体:事务级详细剖析,记录 SPI 事务各阶段的时间戳。
- 实现 `lastProfile()`:诊断用,获取最近一次事务剖析快照。
- SPI 时钟配置提升至 13.3 MHz,改善性能。
- 修改前端 JavaScript 轮询逻辑:新增 uptime 检查,更好地处理 ESP32 重启状态。
- SPI 测速 HTML 的计时单位由毫秒改为微秒显示。
- 新建 `spi_ring_cache.h`:共享 SPI ring push 缓存工具。
- 新增 `http_util.h`:共享 HTTP 处理器工具,含 JSON 拼接与查询参数解析。
- 引入 `json_parse.h`:轻量级 JSON 解析器,针对扁平 JSON 对象设计。
- 新增 Windows 环境下的项目构建批处理脚本。
- 重构 CAN 反馈处理:从推送式改为请求驱动架构,降低内存占用、提升效率。
2026-08-02 17:37:19 +08:00
小龙虾 04a118219f feat(motor-ota): 改进电机OTA中继的握手确认和重试机制
- 添加握手确认逻辑,避免在握手阶段就标记TRANSFER状态
- 实现GD32 auto-retry检测机制,当连续WAIT超限时查询真实状态
- 修复预填充缓冲区数据重复问题,移除旧的offset重置逻辑
- 优化SPI响应等待机制,允许在WAIT后重发DATA以恢复丢失的ACK
- 添加连续SPI失败和WAIT计数监控,超过阈值时主动查询GD32状态

perf(can): 提升CAN批量发送性能和可靠性

- 将CAN帧间延迟从5ms减少到1ms以提升传输速度
- 将单批最大帧数从32增加到64,并将批次间隔从200ms减少到100ms
- 添加手动超时控制器防止fetch请求永久挂起
- 实现批量发送完成后的兜底渲染机制确保结果正确显示

refactor(spi): 优化SPI通信的事件驱动机制

- 实现基于信号量的事件驱动等待机制,替换原有的轮询方式
- 将SPI时钟频率从4MHz提升至8MHz以提高传输速度
- 优化OTA发送后的静默期处理,不同类型的命令采用不同的延迟策略
- 添加临时SPI测速端点用于性能测试

chore(version): 更新固件版本至0.3.74

- 版本号从0.3.72提升到0.3.74
- 更新前端资源版本号以确保缓存刷新
2026-08-01 04:14:51 +08:00
小龙虾 e9cb7342f6 feat(motor): 支持多通道同节点号电机状态管理
支持同一节点号在多个CAN通道上的电机状态管理,采用node×channel双键存储机制。

- 修改motor_state.cpp中的槽位索引逻辑,从单节点索引改为(node-1)*MOTOR_CHANNELS+channel索引
- 增加MOTOR_CHANNELS和MOTOR_MAX_SLOTS宏定义,支持9节点×3通道共27个槽位
- 更新http_handlers.cpp中的二进制响应缓冲区为static类型,避免大缓冲区占用栈空间
- 调整循环遍历范围从MOTOR_MAX_NODES改为MOTOR_MAX_SLOTS
- 添加HTML页面的空favicon配置,避免浏览器404请求
- 新增CSS样式用于显示节点在其他通道在线的状态提示
2026-07-30 11:57:21 +08:00
小龙虾 74eb68b140 feat: 更新固件版本至 0.3.72 并实现构建时间自动生成
在 version.h 中将固件版本由 0.3.71 递增至 0.3.72。
移除 BUILD_TIME 的手动填写项,现由构建系统自动生成。
更新电机 OTA relay 逻辑,缩短状态检查的宽限期。
增强电机 OTA 上传与 relay 流程中的错误处理和日志记录。
为固件上传引入 SHA-256 校验,确保固件完整性。
调整前端命令值提交逻辑,改善用户体验。
更新 HTML 与 JavaScript 文件,以反映 OTA 流程及 UI 的变更。
新增 build_info 文件,用于管理构建时间的自动生成。
2026-07-28 00:23:18 +08:00
小龙虾 33b41fd50a docs(frontend): 添加前端架构约定与回归清单文档
添加了完整的前端架构约定文档 (frontend_architecture.md),
包含以下主要内容:

- 架构总览:Web资源本地托管方式和零构建方案
- 页面薄入口范式:HTML结构与ES Modules入口约定
- JS分层架构:common/ota/ble/can/motor等目录结构说明
- 缓存戳约定:版本管理强制要求
- 公共层API契约:baseUrl/toast/polling等接口规范
- motor页特殊性:独立主题和轮询链路处理
- 红线区域:不可动代码范围说明
- 回归清单:各页面功能验证checklist

同时移除了build相关的临时文件
2026-07-27 01:50:48 +08:00
小龙虾 2138a572ee feat(esp32): 添加多电机OTA中继功能并优化调试日志系统
- 新增多电机状态、版本和OTA中继监控模块(monitor/motor_state.cpp,
  monitor/motor_version.cpp, monitor/motor_ota_relay.cpp)
- 在CAN反馈任务中添加0xC5/0xC6/0xD2类型消息处理,支持多电机状态推送
- 重构调试日志系统,将一次性JSON分配改为512B栈缓冲流式发送,降低内存峰值
- 调试UART波特率从115200提升至30000
- 修改版本管理方式,从自动生version.h改为手动维护
- 更新VSCode设置以支持ESP-IDF开发环境配置
- 在OTA协调器中添加电机OTA互斥机制,防止并发冲突
- 完善中继状态机异常处理,添加远程取消通知机制
2026-07-26 20:40:16 +08:00
小龙虾 770ae14156 feat(spi_link): 添加SPI Link v2 PC自测框架并优化协议稳定性
SPI Link v2 Self-Test / selftest (push) Has been cancelled
添加SPI Link v2的PC端自测功能,包含交叉校验基准测试,覆盖HELLO/OTA/CAN/PING等
多种协议场景。同时重构了health monitor中的多字段读取逻辑,消除了volatile变量
的撕裂读问题,并增强了BLE OTA的数据传输可靠性。

refactor(health_monitor): 修复heap hook多字段撕裂读问题

采用portMUX_CRITICAL临界区实现多字段一致快照读取,消除任务上下文与heap hook
之间的数据竞争。修改了HookSnap结构体设计,通过一次性拷贝volatile变量避免
跨字段组合时的数据不一致。JSON输出中明确标注estimated值的置信度。

fix(ble_ota): 实现DATA_IN丢包负反馈机制

当malloc失败或队列满时发送缺口ACK通知前端按completed_bytes续传,避免
静默丢包导致的8秒超时重传风暴。增加节流机制防止负ACK在重传叠加时打爆
NimBLE mbuf池,提升低内存环境下的OTA传输稳定性。

refactor(spi_master): 从堆分配改为固定Job池管理

将SPI worker的动态堆分配Job改为固定大小的静态池管理,避免频繁的
calloc/free操作影响实时性。新增acquireJobSlot/releaseJobSlot机制
管理池槽生命周期,提升SPI通信的可靠性和内存使用效率。
2026-07-21 01:16:43 +08:00
小龙虾 00b716c8db refactor(debug): 重构调试日志环形缓冲区管理
- 将日志环形缓冲区从静态数组改为动态堆分配
- 实现启动时大容量缓冲区和运行时小容量缓冲区的切换机制
- 添加日志环满时优先保留错误和警告信息的清理策略
- 优化内存使用,在日志为空时避免不必要的内存分配
- 移除调试日志中的冗余字段显示

feat(api): 更新CAN接口为批量发送模式

- 将 `/can/send` 接口替换为 `/can/send_batch`,支持1到N帧的批量发送
- 更新API文档中的接口描述和使用示例
- 修改请求参数格式为 `{"ch":0,"frames":[{...}]}` 返回格式为 `{"status":"ok","results":[...]}`
- 单帧发送也需要通过send_batch接口实现

feat(monitor): 新增GD32 ADC调试反馈处理功能

- 添加gd32_adc_feedback组件用于解析0xC4类型的SPI反馈
- 实现ADC数据解析包括温度、电压等参数的计算和转换
- 在can_feedback_task中集成ADC反馈分发逻辑
- 添加线程安全的数据获取和年龄检查接口

refactor(config): 优化任务栈大小配置并调整OTA块大小

- 重新评估各任务栈使用情况,优化HTTP、WiFi、SPI等工作任务的栈大小
- 将HTTP服务器栈从8KB调整至7KB,WiFi OTA任务栈从4KB调整至3KB
- OTA块大小从2KB调整回4KB以提升传输吞吐性能
- 重新整理配置文件结构,移动WiFi模式配置位置
2026-07-20 03:10:42 +08:00
小龙虾 b807645a20 feat(ble): 完善BLE OTA架构并优化性能
- 实现BLE OTA数据路径分离:添加ble_ota_data.cpp处理重排序/工作线程/DATA_IN,
  和ble_ota_host.cpp处理GATT/GAP/广播/主机功能
- 将滑动ACK窗口从32增大到64,实测Windows WebBT环境下固件传输速度提升约10%
  (从40KB/s提升到44KB/s),内存消耗约30KB重排序缓冲区
- 优化调试日志级别,将OTA相关信息从INFO调整为DBG以减少日志冗余

refactor(can): 改进CAN反馈监听机制

- 将CAN反馈任务从1Hz主动轮询模式改为被动事件监听模式,
  使用SpiMaster::takeEvent()替代主动发送0xC1轮询请求
- 更新相关头文件注释说明架构变更,将"周期性轮询"改为"被动接收推送"
- 修改调试日志级别,将暂停/恢复状态日志从INFO调整为DBG
2026-07-18 01:16:06 +08:00
小龙虾 1b10f89d8c feat(main): 添加启动模块自检与汇总功能
- 新增 SystemBoot 类用于跟踪各模块初始化状态,提供位图标记与报告功能
- 在 main.cpp 中重构初始化流程,按顺序初始化各模块并记录状态
- 添加 system_boot.cpp 模块文件到构建配置
- CAN反馈任务增加冷启动延迟,避免与GD32初始化窗口冲突

refactor(ota): 优化OTA协调器线程安全机制

- 修改 lock()/unlock() 方法返回take结果,确保mutex正确释放
- 修复多任务并发时mutex死锁问题,避免vTaskPriorityDisinherit断言
- 统一finalize失败后的状态复位逻辑,确保协调器回到IDLE状态
- 优化忙状态超时检测与自愈机制

perf(boot): 优化冷启动时序与资源管理

- 简化README中冷启动优化说明,聚焦核心逻辑
- 优化BOD阈值设置为LVL5,改进电源稳定性
- 增加BLE OTA工作线程优先级,减少丢包情况
- 优化堆告警节流机制,在OTA期间降低告警频率

fix(relay): 改进GD32应用存活检测逻辑

- 调整GD32 APP存活验证时序,增加会话建立验证
- 修复HELLO建立session后未验证role的问题
- 优化FINISH后等待APP启动的静默时间与重试机制
- 统一GD32探测逻辑到OTA中继任务

refactor(server): 优化HTTP服务端内存与性能

- 使用静态缓冲区替代动态分配,减少堆碎片化风险
- 优化CAN批量发送期间暂停反馈任务的机制
- 调整BLE连接参数请求逻辑,提升传输性能
- 改进错误处理流程,避免堆紧张时的额外分配

bump(version): 更新固件版本至0.3.50

- 更新版本号从0.3.49到0.3.50
- 更新编译时间戳与Git哈希值
2026-07-17 02:02:36 +08:00
小龙虾 6364eacfb1 feat(can): 优化批量发送性能并修复竞态问题
- 帧间添加5ms延时,暂停CAN反馈避免与批量SPI竞争导致偶发崩溃
- 单次批量容量从48帧提升至128帧,最大body大小从12KB增至48KB
- 单帧超时从400ms降至80ms,失败后不再拖慢整体速度
- 暂停0xC1反馈任务降低worker竞态和WDT风险
- 中途致命错误后跳过后续帧发送,避免每帧等待超时
- 添加详细的JSON错误响应格式
- 修复内存分配失败时的资源清理问题

refactor(wifi): 优化冷启动流程避免电源竞争

- 电源轨稳定等待策略调整:POWERON/BROWNOUT复位时等待200ms
- 仅BROWNOUT复位后清除PHY校准数据,避免每次上电全量校准
- 电源稳定等待期间等待400ms后再进行NVS/WiFi操作
- WiFi启动前延时从500ms调整为300ms
- BOD阈值适当放宽避免上电瞬间复位

perf(monitor): 调整堆内存监控策略

- 内存紧张时增大健康监控间隔:低于8KB时从3s增至10s
- 避免日志输出消耗更多堆内存造成雪崩效应

docs(readme): 更新冷启动优化说明

- 详细描述电源轨稳定等待机制
- 说明仅BROWNOUT后才强制PHY重校准的策略
- 解释避免与GD32抢电流的设计考虑

style(html): 更新前端资源版本号

- 更新CSS和JavaScript文件的版本参数
- 修复页面加载时的资源缓存问题

fix(ble): 修复阶段显示状态问题

- 终态阶段全部显示为完成状态(绿色)
- 优化状态栏样式更新逻辑

feat(ota): 优化SPI通信超时处理

- 将总超时拆分为发送和轮询两个阶段
- 禁止轮询单独占用500ms避免短超时批量卡顿
- 优化极短超时情况下的时间分配策略

refactor(can): 减少批量操作时的日志输出

- SPI失败和GD32拒绝时按类型聚合日志
- 每32次相同错误才输出一次完整日志
- 避免大量错误时UDP/格式化耗尽堆内存
2026-07-17 00:24:09 +08:00
小龙虾 5db3f66b8e 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字节,增加任务堆栈监控说明
2026-07-16 23:00:15 +08:00
小龙虾 51c89b3065 feat(ota): 实现GD32 OTA包间背压机制,优化传输节奏控制
移除固定的包间延迟配置 `OTA_PACKET_DELAY_MS`,采用基于SPI Link v2
CREDIT背压的动态节奏控制,在SpiMaster中通过remoteCredit==0时wait/拒发
实现流量控制,提高OTA传输稳定性。

refactor(stack): 统一任务栈大小配置并添加CPU统计功能

- 在app_config.h中集中定义所有任务栈大小常量,基于实测水位优化分配
- 新增monitor/cpu_stats.cpp组件,提供1Hz FreeRTOS CPU占用采样
- 更新main.cpp中的任务创建使用新的栈大小常量
- 调整health_monitor和ota_relay等任务的栈分配

perf(ota): 改进ESP32和GD32 OTA分区预擦策略

- ESP32 OTA从边写边擦改为整分区预擦模式,避免写入时的扇区擦除阻塞
- GD32 OTA在prepare阶段进行整分区预擦,提升后续写入吞吐量
- 优化OTA协调器状态管理,修复FINALIZING状态残留问题

fix(log): 改进UDP日志发送的堆内存管理

- 当堆内存不足时记录UDP发送跳过的告警信息到ring buffer
- 降低UDP发送跳过的内存阈值从10KB到6KB
- 降低日志排水最小内存阈值从8KB到4KB

docs(readme): 更新OTA配置参数文档说明

移除已废弃的OTA_PACKET_DELAY_MS参数,更新包间节奏控制说明为
基于CREDIT背压机制的描述。
2026-07-16 01:24:12 +08:00
小龙虾 a47a027178 feat(ota):实现ESP32和GD32的OTA上传流程
- 添加了upload-esp32.js用于处理ESP32的OTA上传,包括文件上传、SHA-256验证和设备重启。
- 引入了upload-gd32.js,用于触发GD32 OTA中继并跟踪中继进度。
- 针对OTA流程,增强了会话管理和错误处理能力。
- 通过在上传过程中显示吐司通知和进度更新,改进了用户反馈。
2026-07-15 12:12:19 +08:00
小龙虾 5a89cec34b refactor(arch): 精简架构去除样板代码并修复BLE OTA虚假ACK问题
- 移除OtaCoordinator中重复的状态查询锁操作,改为单次取值后比较
- 将GD32存储wrapper函数改为inline实现并移入头文件,删除对应的cpp实现
- HTTP路由注册改为路由表+循环注册,消除复制粘贴的URI注册代码
- OtaRelay事件分发中提取通用拒绝逻辑到commonReject helper函数
- SpiMaster中折叠加锁/无锁镜像函数,通过内部Impl实现统一路径
- HealthMonitor使用RAII LockGuard替代重复的mutex操作代码
- 修复BLE OTA中ESP32路径的虚假ACK问题,窗口边界ACK仅在实际写入时触发
- 修复BLE OTA中GD32路径的重复包ACK问题,重复包静默跳过不发送ACK
- 调整发送端首轮ACK超时从3000ms提升至5000ms,适配设备端flash写入速度
- 清理临时文件和无用的配置宏定义
- 在main.cpp中添加GD32版本探测逻辑
2026-07-15 02:23:25 +08:00
小龙虾 164855a36c feat(spi): 更新SPI通信协议为5-wire SPI Link v2并优化握手流程
- 添加DATA_READY引脚(GPIO10)支持5-wire SPI拓扑
- 将otaTransfer调用替换为更通用的transact方法
- 更新SPI引脚定义和通信协议文档
- 修改README.md详细说明新的SPI Link v2协议

docs: 标记旧版OTA中继设计文档为已废弃

- 在esp32_ota_relay_design.md添加废弃警告
- 指向新的SPI Link v2协议文档作为权威来源
- 保留历史文档供参考但停止维护

refactor(can): 增强CANFD桥接错误处理机制

- 扩展canFdSend函数增加gd32_err_out参数
- 透传GD32细分错误码(0xE0-0xE5)到HTTP响应
- 更新HTTP处理器输出完整的错误信息

perf(monitor): 优化按铃事件等待机制

- 修改can_feedback_task.cpp中的事件等待逻辑
- 统一使用takeEvent方法替代checkGd32Request
- 减少不必要的中间变量和函数调用

fix(status): 改进系统状态监控和CPU使用率统计

- 增加CPU任务快照缓冲区容量从16到32
- 添加CPU统计无效时的调试日志输出
- 更新SPI Link统计接口使用新的linkStatus方法

refactor(ota): 优化OTA中继传输和启动等待逻辑

- 调整APP启动等待重试策略(10次->20次, 500ms->300ms)
- 移除数据包传输进度的日志输出减少冗余信息
- 统一使用transact方法进行SPI事务处理

bump(version): 更新固件版本到0.3.44

- 版本号从0.3.43提升至0.3.44
- 更新编译时间和Git哈希值
2026-07-15 00:35:02 +08:00
小龙虾 adb3386da4 feat(ota): 添加WiFi OTA期间BLE堆管理机制
- WiFi OTA时自动暂停BLE以腾出堆空间给HTTP收流
- OTA完成后自动恢复BLE栈功能
- 避免WiFi OTA与BLE共存时的堆竞争问题

fix(debug_log): 优化堆紧张时日志处理策略

- 当堆空间小于8KB时,/logs接口仍返回最小JSON避免沉默失败
- 增加堆门控机制,OTA/SPI关键日志优先保留
- UDP发送在堆紧张时跳过,避免与HTTP回包争夺LWIP堆
- 调整日志缓冲区大小,减少栈压力

chore(version): 更新固件版本至0.3.43

- 版本号从0.3.42提升到0.3.43
- 更新编译时间和Git哈希信息

refactor(can_feedback): 优化CAN反馈任务配置

- 将反馈payload最大长度改为SPI_OTA_FRAME_MAX常量
- 使用takeEvent替代takeRingRx方法
2026-07-14 12:10:13 +08:00
小龙虾 76782349a9 添加了SPI链接V2支持文件,重构OTA协调器的存储准备流程,
统一WiFi/BLE传输路径的收尾处理逻辑。
feat(ota): 优化OTA性能并增强系统监控功能

- 修改RSSI警告阈值从-75dBm调整为-80dBm,更新相关文档说明
- 增加HTTP服务器任务栈大小至10240字节,解决栈溢出问题
- 将OTA块大小从4096字节降至2048字节以降低峰值堆内存占用
- 实现按需擦除机制替代预擦除,在GD32 OTA中按扇区动态擦除
- 添加任务栈水位主动巡检功能,监控栈使用情况并告警
- 集成FreeRTOS栈溢出钩子,提供栈溢出检测和告警
- 优化ESP32 OTA实现使用顺序写入模式,消除长时间擦除阻塞
- 改进堆内存不足告警机制,添加限流避免刷屏
- 移除不必要的调试日志输出,精简健康监控模块
2026-07-13 03:12:21 +08:00
小龙虾 0b363492ed feat(ble-ota): 优化BLE OTA传输性能和可靠性
- 调整NimBLE配置恢复默认缓冲区大小以支持窗口内突发包接收
- 实现BLE连接参数动态调整,支持Windows兼容模式
- 优化OTA数据队列长度与确认窗口匹配
- 改进reorder buffer管理,添加乱序包缓存统计
- 修复ACK触发逻辑,确保按累计进度而非单包序列确认
- 添加GD32 OTA元数据本地缓存避免频繁NVS访问
- 优化前端JavaScript实现,改进Web Bluetooth写入策略

BREAKING CHANGE: BLE连接参数协商机制变更可能影响部分设备兼容性
2026-07-12 03:38:20 +08:00
106 changed files with 18618 additions and 18851 deletions
+58
View File
@@ -0,0 +1,58 @@
# SPI Link v2 PC 自测 CI 门禁
#
# 当 spi_link 协议实现变更时,在 PC 上编译运行 SPI_LINK_V2_SELFTEST 自测,
# 自动阻断帧格式 / CRC / 最大 payload / 版本回归。
#
# 布局约定:spi_link/ 是三工程(ESP32_COMM、DCU_APP、GD32E50X_BOOT)的共享
# 协议兄弟目录,与各仓库同级。本仓库通过相对路径 ../../../spi_link/ 引用它。
# 因此本 workflow 检出本仓库后,需在同一次检出中提供 spi_link/。
#
# 触发:push / PR 触及 spi_link 兄弟目录(由调用方检出)或本仓库内的 SPI Link
# 相关文件。由于 spi_link 在本仓库之外,路径过滤仅能覆盖本仓库侧的引用改动。
name: SPI Link v2 Self-Test
on:
push:
paths:
- 'components/transport/**'
- '.github/workflows/spi-link-selftest.yml'
pull_request:
paths:
- 'components/transport/**'
- '.github/workflows/spi-link-selftest.yml'
workflow_dispatch:
jobs:
selftest:
runs-on: ubuntu-latest
steps:
- name: Checkout ESP32_COMM
uses: actions/checkout@v4
- name: Checkout shared spi_link(兄弟协议目录)
# spi_link/ 是独立维护的共享协议源,与三工程同级。
# 在 CI 中通过同源仓库的另一路径提供;若 spi_link 独立成仓,请改为
# actions/checkout 指向该仓库并放到 ../spi_link。
run: |
# 期望运行器在 checkout 本仓前已将 spi_link 放到上级目录;
# 退路:若上级无 spi_link,则从本仓 components 引用的相对路径复制一份
# 到 ../spi_link(本仓不直接含 spi_link 源,故此处仅做存在性检查)。
if [ ! -d ../spi_link ]; then
echo "::warning::spi_link 兄弟目录不存在;CI 无法运行自测。请确保 spi_link 与本仓同级。"
exit 1
fi
ls -la ../spi_link/golden_vectors.h ../spi_link/spi_link_v2.cpp ../spi_link/spi_link_v2.h
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y cmake g++
- name: Configure host test
run: cmake -S ../spi_link/test -B build/host_test
- name: Build host test
run: cmake --build build/host_test
- name: Run self-test
run: ctest --test-dir build/host_test --output-on-failure
+12
View File
@@ -72,6 +72,18 @@ Thumbs.db
ehthumbs.db
Desktop.ini
# 本机调试 dump / 状态快照
logs*.json
st*.json
st_new.json
# IDE / 过程产物
.trae/
.agents/
.worktrees/
.qoder/
.zcode/
# User-specific configuration files
*.user
*.workspace # General workspace files, can be from various tools
@@ -1,13 +0,0 @@
# Checklist
- [x] ESP32 路径:`process_esp32_packet()` 中窗口边界 ACK 触发条件包含 `progressMade` 检查
- [x] ESP32 路径:重复包(seq < expected)到达窗口边界时不发送 ACK
- [x] ESP32 路径:乱序缓存包(seq > expected,存入 reorder buffer)到达窗口边界时不发送 ACK
- [x] ESP32 路径:按序包写入 + flush 后 `getReceived()` 增长时,正常发送 ACK
- [x] ESP32 路径:`transferComplete` 和 `isLastPacket` 的 ACK 仍在实际写入时触发
- [x] GD32 路径:重复包(seq <= last_seq)不发送任何 ACK,仅跳过
- [x] GD32 路径:重复包的 DEBUG_DBG 日志保留
- [x] 发送端:首轮窗口 ACK 超时为 5000ms
- [x] 发送端:续发重试路径 ACK 超时保持 3000ms
- [x] 编译通过:ESP32 固件编译无错误(代码语法验证通过,ESP-IDF 环境需用户本地编译确认)
- [ ] 逻辑验证:BLE OTA 刷写 ESP32 时不再出现每窗口"丢失 32 包"的稳定模式(需用户运行时测试)
@@ -1,52 +0,0 @@
# 修复 BLE OTA 虚假 ACK 导致刷写极慢 Spec
## Why
BLE 刷写 ESP32 自身固件时速度极慢,每个 32 包窗口都报"丢失 32 包"并触发重发。根因是 `process_esp32_packet()` 中 ACK 触发条件仅检查 `(seq+1)%32==0`,未区分包是否真正写入 flash。重复包(来自发送端超时重发)在窗口边界会触发虚假 ACK,携带陈旧的 `getReceived()` 值,被发送端误判为丢包,导致每个窗口都产生不必要的重发。
## What Changes
- **ESP32 路径** (`process_esp32_packet`):在调用 `esp32_write_ordered()` 前后对比 `getReceived()`,仅在实际写入新字节时才触发窗口边界 ACK,避免重复包/乱序缓存包发送陈旧 ACK
- **GD32 路径** (`process_gd32_packet`):移除重复包在窗口边界的 ACK 发送(L1233-1235),重复包静默跳过,发送端已持有上次有效 ACK 的进度
- **ACK 超时调整**:发送端首次窗口 ACK 超时从 3000ms 提升至 5000ms,适应设备端 flash 写入速度(实测约 3.4s/窗口),避免首轮超时产生重复包
## Impact
- Affected code:
- `main/server/ble_ota.cpp` — `process_esp32_packet()` ACK 触发逻辑 (L1146-1172)
- `main/server/ble_ota.cpp` — `process_gd32_packet()` 重复包 ACK 逻辑 (L1229-1236)
- `tools/ble_ota.js` — `uploadFirmware()` ACK 超时参数 (L1305, L1370)
## ADDED Requirements
### Requirement: ACK 触发需基于实际写入进度
ESP32 OTA 接收端在处理数据包时,窗口边界 ACK 的触发必须以"实际写入 flash 的字节数增长"为前提,而非仅依据包序号是否落在窗口边界。
#### Scenario: 重复包到达窗口边界
- **WHEN** 发送端因超时重发,重复包(seq < expected)到达设备端,且该 seq 处于窗口边界((seq+1)%32==0)
- **THEN** 设备端不发送 ACK(该包未产生新的 flash 写入)
#### Scenario: 乱序缓存包到达窗口边界
- **WHEN** 乱序包(seq > expected)被缓存到 reorder buffer,且该 seq 处于窗口边界
- **THEN** 设备端不发送 ACK(该包未产生 flash 写入,仅暂存)
#### Scenario: 正常写入触发 flush 跨越窗口边界
- **WHEN** 按序包到达后触发 reorder buffer flush,`getReceived()` 增长
- **THEN** 设备端正常发送 ACK,携带最新的 `getReceived()` 值
## MODIFIED Requirements
### Requirement: GD32 重复包处理
GD32 OTA 接收端遇到重复包(seq <= last_seq)时,静默跳过,不发送 ACK。发送端已通过上一窗口的有效 ACK 获知设备端进度,重复包的 ACK 只会干扰当前窗口的 waitForAck 判断。
#### Scenario: 重复包到达(GD32)
- **WHEN** GD32 OTA 收到重复包(seq <= last_seq)
- **THEN** 跳过写入,不发送任何 ACK,继续处理队列中的后续包
### Requirement: BLE OTA 发送端 ACK 超时
发送端首轮窗口 ACK 等待超时从 3000ms 调整为 5000ms,以适应设备端 flash 写入速度(实测约 3.4s/32 包窗口),减少首轮不必要的超时重发。续发重试路径的超时保持 3000ms 不变(此时设备端已在处理,ACK 会更快返回)。
#### Scenario: 首轮窗口 ACK 等待
- **WHEN** 发送端发送一个新窗口的 32 个包并等待 ACK
- **THEN** 等待超时为 5000ms,若设备端在 5s 内完成处理并返回 ACK,无需重发
#### Scenario: 续发重试 ACK 等待
- **WHEN** 发送端因丢包检测而续发部分包并等待 ACK
- **THEN** 等待超时保持 3000ms
@@ -1,18 +0,0 @@
# Tasks
- [x] Task 1: 修复 ESP32 路径虚假 ACK(process_esp32_packet)
- [x] SubTask 1.1: 在 `process_esp32_packet()` 中,调用 `esp32_write_ordered()` 前记录 `beforeReceived = getReceived()`
- [x] SubTask 1.2: 调用后记录 `afterReceived = getReceived()`,计算 `progressMade = (afterReceived > beforeReceived)`
- [x] SubTask 1.3: 修改 ACK 触发条件:窗口边界 ACK 仅在 `progressMade` 为 true 时触发;`transferComplete` 和 `isLastPacket` 的 ACK 也需 `progressMade` 为 true(完成判定依赖实际写入字节数,无写入则不可能完成)
- [x] SubTask 1.4: 使用 `afterReceived` 替换 ACK 中的 `getReceived()` 调用,避免重复查询
- [x] Task 2: 修复 GD32 路径重复包 ACK(process_gd32_packet)
- [x] SubTask 2.1: 移除重复包分支(seq <= last_seq)中的窗口边界 ACK 发送逻辑(L1233-1235)
- [x] SubTask 2.2: 保留重复包的 DEBUG_DBG 日志和 `return false` 跳过逻辑
- [x] Task 3: 调整发送端首轮 ACK 超时(ble_ota.js)
- [x] SubTask 3.1: 将首轮窗口 ACK 等待超时从 3000ms 改为 5000ms(L1305 的 waitForAck 调用)
- [x] SubTask 3.2: 续发重试路径的 ACK 超时保持 3000ms 不变(L1370 的 waitForAck 调用)
# Task Dependencies
- Task 1, Task 2, Task 3 相互独立,可并行实现
@@ -1,16 +0,0 @@
- [x] 仓库根临时文件已删除:`old_wifi_ota.cpp.tmp`、`original_app.js.tmp`、`appjs_diff.txt`、`build_baseline.log`、`build_output.log` 不存在
- [x] `OtaCoordinator::isIdle()` 仅调用一次 `getSystemState()`(即一次 `lock()`)
- [x] `OtaCoordinator::getSystemStateString()` 仅调用一次 `getSystemState()`
- [x] 8 个 GD32 存储 wrapper(`writeGd32Chunk/saveGd32Meta/loadGd32Meta/updateGd32Meta/clearGd32Meta/verifyGd32Firmware/finalizeGd32Checksum/eraseGd32Partition`)已改为 `inline` 并在头文件中实现,`.cpp` 中对应实现已删除
- [x] 调用方(`http_handlers.cpp`、`ble_ota.cpp` 等)无需改动即可编译通过(签名不变)
- [x] `wifi_ota.cpp::httpServerStart()` 使用路由表 + 循环注册,9 个端点及其 OPTIONS 预检全部注册
- [x] HTTP 路由注册行为与原实现等价(端点、method、handler 对应关系一致)
- [x] `OtaRelay` 新增 `commonReject` helper,各 `handle*` 开头拒绝分支等价于原实现
- [x] `handleTransferring` 的 `RELAY_EVT_HEALTH_CRIT` 拒绝分支保留
- [x] `forwardFirmware()` 不再使用 `goto cleanup`,5 步驱动顺序与超时检查时机不变
- [x] `SpiMaster` 删除 `syncTransferUnlocked` / `sendUnlocked`,公共 API 通过 `LockGuard` + Impl 实现
- [x] `SpiMaster::transferWithRetry` 调用路径更新为 Impl,无递归加锁
- [x] `HealthMonitor` 新增 `LockGuard` 内部类,`getState/setState/getLastTemperature/getLastWifiRssi/tempMonitorCallback` 使用 RAII 守卫
- [x] 评审"不建议改的地方"全部未动:5 步驱动顺序、健康检查时机、watchdog 自愈、`forceCleanup` 仅 `isIdle()`、`Esp32OtaCore` 双层 mutex 语义
- [x] `idf.py build` 编译通过,无新增警告(15 个源文件 0 warning / 0 error)
- [x] `main/` 目录行数较变更前减少(主要削减:`httpServerStart` 173→67、`ota_coordinator.cpp` 8 wrapper 删除、`spi_master.cpp` 2 Unlocked 删除、`ota_relay.cpp` goto 重构)
@@ -1,75 +0,0 @@
# 架构精简:去样板与重复加锁 Spec
## Why
`docs/esp32_architecture_review_2026-06-25.md` 指出当前代码存在大量零/低风险的重复样板(HTTP URI 注册粘贴、OtaCoordinator 纯转发 wrapper、OtaRelay 事件分发头部、SpiMaster *Unlocked 镜像)以及状态查询时的重复加锁。这些不影响运行时行为,但增加维护成本、放大锁争用,并留下两路代码不同步的隐患。本变更按评审推荐的顺序落地 A~F + 快速清理,预期 `main/` 减约 350~450 行,且不改运行时语义。
## What Changes
- **快速清理**:删除仓库根的临时/历史中间产物(`*.tmp`、`*.log`、`appjs_diff.txt`)。
- **A.1**:把 `OtaCoordinator` 中 9 个纯转发到 `Gd32FlashStore` 的 wrapper 改为 `inline` 并移入头文件,`.cpp` 删除对应实现。
- **A.2**:`OtaCoordinator::isIdle / isGd32Busy / isEsp32Busy` 改为一次 `getSystemState()` 取值后比较,消除多次 `lock()`。
- **B**:`wifi_ota.cpp::httpServerStart()` 的 9×{POST/GET, OPTIONS} 复制粘贴改为路由表 + 循环注册。
- **C**:`OtaRelay::handle*` 头部重复的 ABORT/TIMEOUT/HEALTH_CRIT 拒绝逻辑提取为 `commonReject` helper;`forwardFirmware()` 的 5 次顺序 `processEvent(START)` 改为状态驱动循环,去掉 `goto cleanup`。
- **D**:`SpiMaster` 的 `syncTransfer/syncTransferUnlocked`、`send/sendUnlocked`、`otaPollResponse/otaPollResponseUnlocked` 三对镜像折叠为「公共 API 加锁 → 调内部 Impl」单一实现。
- **F**:`HealthMonitor` 的 `getCurrent/getState/setState/getLastTemperature/getLastWifiRssi/tempMonitorCallback` 中重复的 `if (m_mutex) xSemaphoreTake/Give` 改为 RAII `LockGuard`(与 SpiMaster 一致)。
**不在本次范围**(评审标记为低优先级/可选/行为变更):
- E(退役 UDP 日志通道):涉及调试工作流行为变更,评审标为"低优先级""可考虑",留作后续。
- G(BLE OTA finish ctx malloc→静态):评审明确"不是精简,是健壮性",位于 OTA 关键路径,需单独回归,留作后续。
- H(README 同步):仅当采用 A 的"移除 wrapper"方案时才需要;本次采用 A.1(inline 保留语义),README 现有描述仍然成立,无需改动。
## Impact
- 受影响代码:
- `main/ota/ota_coordinator.h` / `.cpp`(A.1, A.2)
- `main/server/wifi_ota.cpp`(B)
- `main/relay/ota_relay.h` / `.cpp`(C)
- `main/transport/spi_master.h` / `.cpp`(D)
- `main/monitor/health_monitor.h` / `.cpp`(F)
- 受影响能力:OTA 协调器状态查询、HTTP 路由注册、OTA 中继状态机、SPI 总线访问、健康监控状态读写。
- **不改**运行时行为:状态机 5 步驱动顺序、健康检查时机、watchdog 自愈、`forceCleanup` 仅在 `isIdle()` 启动、`Esp32OtaCore` 双层 mutex 语义均保持不变(评审"不建议改的地方")。
## ADDED Requirements
### Requirement: HTTP 路由表化注册
系统 SHALL 使用静态路由表 + 循环注册所有 HTTP URI handler,新增端点仅需在表中追加一行。
#### Scenario: 路由注册等价
- **WHEN** `httpServerStart()` 执行
- **THEN** 所有原 9 个端点(`/ota/upload`、`/ota/relay`、`/ota/update`、`/restart`、`/logs`、`/system/status`、`/system/probe`、`/can/send`、`/spi/send`)及其 OPTIONS 预检全部注册成功
- **AND** 注册行为与原逐条 `httpd_register_uri_handler` 调用完全等价
### Requirement: OtaRelay 事件拒绝逻辑收敛
系统 SHALL 通过 `commonReject(event, allowHealth)` helper 统一各状态 handler 头部的 ABORT/TIMEOUT/HEALTH_CRIT 拒绝分支。
#### Scenario: 拒绝分支等价
- **WHEN** 任一 `handle*` 收到 `RELAY_EVT_ABORT`
- **THEN** 状态转为 `RELAY_ERROR` 并返回 `OTA_RESULT_ERR_USER_ABORT`
- **WHEN** 收到 `RELAY_EVT_TIMEOUT`
- **THEN** 状态转为 `RELAY_ERROR` 并返回 `OTA_RESULT_ERR_TIMEOUT`
- **WHEN** `handleTransferring` 收到 `RELAY_EVT_HEALTH_CRIT`
- **THEN** 状态转为 `RELAY_ERROR` 并返回 `OTA_RESULT_ERR_HEALTH`
### Requirement: SpiMaster 单一加锁路径
系统 SHALL 通过单一内部实现(`*Impl`)承载加锁与无锁两路调用,公共 API 包一层锁,已持锁路径直调 Impl,消除两份镜像逻辑。
#### Scenario: 加锁语义不变
- **WHEN** 外部调用 `syncTransfer/send/otaPollResponse`
- **THEN** 内部先获取 `m_mutex` 再执行传输
- **WHEN** 已持锁的内部路径调用 Impl 版本
- **THEN** 不重复加锁,避免递归死锁
## MODIFIED Requirements
### Requirement: OtaCoordinator GD32 存储操作入口
传输层通过 `OtaCoordinator` 的统一入口操作 GD32 Flash(解决 H3 绕过协调器问题)。9 个纯转发 wrapper(`writeGd32Chunk/saveGd32Meta/loadGd32Meta/updateGd32Meta/clearGd32Meta/verifyGd32Firmware/finalizeGd32Checksum/eraseGd32Partition`)改为 `inline` 定义在头文件中,直接委托 `Gd32FlashStore::instance()`,签名与调用方语义不变。
### Requirement: OtaCoordinator 状态查询
`isIdle / isGd32Busy / isEsp32Busy` SHALL 各仅调用一次 `getSystemState()`(即一次 `lock()`),在局部变量上完成所有比较。`getSystemStateString` 同样仅取值一次。
### Requirement: OtaRelay forwardFirmware 状态驱动
`forwardFirmware()` SHALL 以状态驱动循环推进 IDLE→HANDSHAKING→SIZE_NEG→TRANSFERRING→FINISHING→DONE,任一步返回非 OK 即跳出并复位,不再使用 `goto cleanup`。5 步驱动顺序与超时检查时机保持不变。
### Requirement: HealthMonitor 状态访问加锁
`HealthMonitor` 所有访问 `m_healthState / m_lastTemperature / m_lastWifiRssi / m_lastWifiRssiMs` 的方法 SHALL 通过 RAII `LockGuard`(构造 take、析构 give)保护,与 SpiMaster 的 LockGuard 风格一致。
## REMOVED Requirements
(无移除)
@@ -1,50 +0,0 @@
# Tasks
- [x] Task 1: 快速清理仓库根临时文件
- [x] SubTask 1.1: 删除 `old_wifi_ota.cpp.tmp`、`original_app.js.tmp`、`appjs_diff.txt`、`build_baseline.log`、`build_output.log`
- [x] SubTask 1.2: 评估 `sdkconfig.bak.mbedtls_trim` / `sdkconfig.ci.c3eco7` / `sdkconfig.ci.esp32c2*` / `sdkconfig.ci.wpa_psk` 是否与 ESP32-C3FH4 目标相关,无关则删除(需确认未被 CMake/CI 引用)— 已确认未被 CMake/CI 引用,均为其他芯片变体配置,已删除
- [x] Task 2: A.2 — OtaCoordinator 状态查询单次取值
- [x] SubTask 2.1: `isIdle()` 改为 `ota_system_state_t s = getSystemState();` 后一次比较 IDLE/STORED/COMPLETE
- [x] SubTask 2.2: `isGd32Busy()` 已是单次取值,确认无需改动
- [x] SubTask 2.3: `isEsp32Busy()` 已是单次取值,确认无需改动
- [x] SubTask 2.4: `getSystemStateString()` 改为 `switch(getSystemState())` 单次取值(当前每次 case 隐式调用)
- [x] Task 3: A.1 — OtaCoordinator 9 个存储 wrapper inline 化
- [x] SubTask 3.1: 在 `ota_coordinator.h` 中将 `writeGd32Chunk/saveGd32Meta/loadGd32Meta/updateGd32Meta/clearGd32Meta/verifyGd32Firmware/finalizeGd32Checksum/eraseGd32Partition` 改为 `inline` 实现并直接委托 `Gd32FlashStore::instance()`
- [x] SubTask 3.2: 删除 `ota_coordinator.cpp` 中这 8 个函数的对应实现(注意 `writeGd32Chunk` 等)
- [x] SubTask 3.3: 确认头文件已 `#include "storage/gd32_flash_store.h"`(当前已包含)
- [x] Task 4: B — WiFi HTTP URI 路由表化
- [x] SubTask 4.1: 在 `wifi_ota.cpp::httpServerStart()` 顶部定义 `UriEntry { uri, method, handler }` 静态数组,列出 9 个端点
- [x] SubTask 4.2: 用 for 循环对每个 entry 注册主 method + HTTP_OPTIONS(`http_options_handler`)
- [x] SubTask 4.3: 保留 NVS 恢复固件状态的前置逻辑与启动日志不变
- [x] SubTask 4.4: 确认 `max_uri_handlers=32` 仍足够(9×2=18,余量充足)
- [x] Task 5: C — OtaRelay 事件分发去重
- [x] SubTask 5.1: 在 `ota_relay.cpp` 匿名命名空间添加 `commonReject(relay_event_t e, bool allowHealth=false)`,返回 `OTA_RESULT_OK` 或对应错误码(不改状态)
- [x] SubTask 5.2: `handleHandshaking/handleSizeNeg/handleTransferring/handleFinishing` 开头调用 `commonReject`,仅在其返回非 OK 时 `setState(RELAY_ERROR)` 并返回(`handleTransferring` 传 `allowHealth=true`)
- [x] SubTask 5.3: `forwardFirmware()` 将 5 次顺序 `processEvent(START)` 改为 while 循环:当前状态非 DONE/ERROR 时持续推进,任一步非 OK 即跳出;去掉 `goto cleanup`,失败路径统一 `reset()`
- [x] SubTask 5.4: 保持 5 步驱动顺序与"同步 handler 执行后才检查超时"的语义不变
- [x] Task 6: D — SpiMaster *Unlocked 镜像折叠
- [x] SubTask 6.1: 保留 `transferClocked` / `transferRequestResponse` / `otaPollResponseUnlocked` 作为内部 Impl(已持锁路径调用)
- [x] SubTask 6.2: `syncTransfer` 公共 API:`LockGuard` + 直接调 `transferRequestResponse`(删除 `syncTransferUnlocked`,其唯一调用方 `transferWithRetry` 改为调 `transferRequestResponse`)
- [x] SubTask 6.3: `send` 公共 API:`LockGuard` + 直接调 `transferRequestResponse`(删除 `sendUnlocked`,确认无外部调用方)
- [x] SubTask 6.4: `otaPollResponse` 公共 API:`LockGuard` + 调 `otaPollResponseUnlocked`(保持不变,已是该模式)
- [x] SubTask 6.5: 更新 `spi_master.h`:移除已删除的 `syncTransferUnlocked`/`sendUnlocked` 声明
- [x] Task 7: F — HealthMonitor RAII LockGuard
- [x] SubTask 7.1: 在 `health_monitor.h` 私有区添加 `LockGuard` 内部类(与 SpiMaster 风格一致:构造 take portMAX_DELAY,析构 give,禁拷贝)
- [x] SubTask 7.2: `getState/setState/getLastTemperature/getLastWifiRssi/tempMonitorCallback` 改用 `LockGuard lock(m_mutex);`
- [x] SubTask 7.3: `setWifiConnected` 中 `m_wifiConnected` 为 `volatile`,确认是否仍需加锁(当前未加锁,保持现状,仅文档化其为原子推送)
- [x] Task 8: 编译验证
- [x] SubTask 8.1: 运行 `idf.py build` 确认无编译错误/警告回归(15 个源文件 0 warning / 0 error,CMake 配置 + 编译阶段均通过;ninja 链接阶段因 TRAE 沙箱与 Windows overlapped I/O 不兼容无法在 IDE 内执行,需在 ESP-IDF PowerShell 终端外完成)
- [x] SubTask 8.2: 确认 `main/` 行数减少符合预期(关键削减:httpServerStart 173→67、ota_coordinator.cpp 8 wrapper 删除、spi_master.cpp 2 Unlocked 删除、ota_relay.cpp goto 重构)
# Task Dependencies
- Task 3(A.1 inline 化)依赖 Task 2(A.2)先完成,避免合并冲突
- Task 5(OtaRelay)与 Task 6(SpiMaster)独立,可并行
- Task 7(HealthMonitor)独立,可并行
- Task 8(编译验证)依赖 Task 2~7 全部完成
@@ -0,0 +1,81 @@
# Checklist
## CSS 拆分验证
- [x] `css/base.css` 包含 `:root` 变量定义、`.container` 样式、背景纹理,无其他职责段混入
- [x] `css/layout.css` 包含 header、main-layout、cards、forms、buttons、conn-indicator 样式
- [x] `css/components.css` 包含 upload-zone、file-info、progress、stage-flow、badges、metrics、heap、log-panel、toast 样式
- [x] `css/can.css` 包含 CAN panel、CAN workspace、CAN feedback、ID snapshot、报文列表布局样式
- [x] `css/responsive.css` 包含所有 `@media` 查询和 `@keyframes` 动画定义
- [x] 三个 HTML 页面的 `<link>` 标签引用了全部 5 个 CSS 文件,页面视觉效果与原 `style.css` 完全一致
- [x] 原 `tools/style.css` 已删除
## 公共模块验证
- [x] `js/common/utils.js` 导出 `formatBytes`、`formatTime`、`formatUptime`、`escapeHtml`、`sanitizeHtml`
- [x] `js/common/toast.js` 导出 `showToast`,支持 info/success/warning/error 类型
- [x] `js/common/log.js` 导出 `addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll`,环形缓冲与去重逻辑完整
- [x] `js/common/connection.js` 导出 `testConnection`、连接状态指示器更新函数
- [x] `js/common/polling.js` 导出轮询调度、`pollStatus`、`pollLogs`、Page Visibility 补偿逻辑
## app.js 拆分验证
- [x] `js/ota/session.js` 包含完整 OTA 会话状态机,状态流转(IDLE→UPLOADING→VERIFYING→REBOOTING→DONE / FAILED)逻辑不变
- [x] `js/ota/progress.js` 包含进度条、阶段节点 UI、`setProgress`、`setActivePhase`、`markStageDone`
- [x] `js/ota/stages.js` 包含 ESP32/GD32 阶段驱动,`esp32OtaPhase`、`esp32PhaseLabels` 等状态保留
- [x] `js/ota/file-handle.js` 包含文件选择、FSAPI、SHA-256 计算、Intel HEX→BIN 转换、文件信息 UI
- [x] `js/ota/upload-esp32.js` 包含 ESP32 上传流程、心跳、SHA 校验、完成升级,重试阈值与 goto 跳转逻辑保留
- [x] `js/ota/upload-gd32.js` 包含 GD32 中继 OTA 触发、relay 态跟踪、`hasObservedRealRelayState` 逻辑
- [x] `js/ota/spi.js` 包含 `sendSpiFrame`、`clearSpiFields`
- [x] `js/ota/console-mode.js` 包含从 index.html 抽出的全部内联脚本函数
## ble_ota.js 拆分验证
- [x] `js/ble/constants.js` 导出 `UUIDS`、所有 `BLE_OTA_*` 常量、`TARGETS` 配置
- [x] `js/ble/state.js` 导出 `state` 对象、元素缓存、`currentTarget`、`refreshTargetUi`、`setConnectionState`
- [x] `js/ble/stages.js` 导出 `setBleStage`、`resetBleStages`、`setBleStageError`、`updateBleStageLabels`
- [x] `js/ble/gatt.js` 导出 BLE 连接、断开、服务发现、通知使能逻辑
- [x] `js/ble/file.js` 导出 `notifyFileChange`、`clearFile`、分包计算
- [x] `js/ble/transfer.js` 导出数据写入循环、ACK 窗口节流(每 32 包或窗口边界 ACK)、`updateProgressFromAck`
- [x] `js/ble/verify.js` 导出 `showBleVerifyInfo`、`hideBleVerifyInfo`
- [x] `js/ble/session.js` 导出 `waitForOtaResult`、`resolveOtaResult`、`rejectOtaResult`、abort 逻辑
## can.js 拆分验证
- [x] `js/can/dbc.js` 导出 `parseDbc`、`findSignal`、`parseMultiplexToken`、`buildSummary`
- [x] `js/can/messages.js` 导出报文列表渲染、过滤、节点过滤逻辑
- [x] `js/can/signals.js` 导出信号值编辑器、编解码、批量添加
- [x] `js/can/send.js` 导出帧构建、发送、`CAN_SEND_ERR_DESC`、`GD32_ERR_DESC`、`buildSendResponseText`
- [ ] `js/can/feedback.js` 导出 1Hz 反馈轮询、ID 快照表渲染
**注:原 can.js 中不存在 1Hz 反馈轮询/ID 快照表代码(该功能在 can_monitor.js 中)。feedback.js 当前重导出 common/connection.js 的连接管理函数供入口使用。如后续 can 页面需要 1Hz `/can/feedback` 轮询,可在 feedback.js 实现。**
## can_monitor.js 验证
- [x] `js/can_monitor.js` 转为 ESM,引用 `js/common/*` 公共模块替换重复工具函数
- [x] CAN 监控页面功能正常(连接、报文接收)— HTTP 200 验证通过,语法检查通过
## 入口与 HTML 验证
- [x] `js/app.js` 薄入口 import 所有 ota + common 模块,HTML onclick 引用的函数已挂载到 `window`
- [x] `js/ble_ota.js` 薄入口 import 所有 ble 模块,导出 `window.bleOta` API 并调用 `init()`
- [x] `js/can.js` 薄入口 import 所有 can 模块并初始化
- [x] `index.html` 使用 `<script type="module">`,无内联 `<script>` 块
- [x] `can.html` 使用 `<script type="module" src="js/can.js">`
- [x] `can_monitor.html` 使用 `<script type="module" src="js/can_monitor.js">`
- [x] 所有 `import` 路径带 `.js` 后缀
## 清理验证
- [x] 原 `tools/app.js`(2371 行)已删除
- [x] 原 `tools/ble_ota.js`(1658 行)已删除
- [x] 原 `tools/can.js`(1526 行)已删除
- [x] 原 `tools/style.css`(2146 行)已删除
- [x] 原 `tools/can_monitor.js` 已删除(迁移到 `js/can_monitor.js`)
## 功能完整性验证
- [x] 浏览器控制台无加载错误、无 import 解析失败 — HTTP 200 全部资源可达,30 个 JS 文件语法检查通过,MIME 类型正确(text/javascript)
- [ ] index.html WiFi OTA 模式:ESP32/GD32 目标切换、文件选择(含 FSAPI)、上传、进度条、阶段流、SHA 校验面板、重启均正常 — **需浏览器实机验证(涉及真实设备交互)**
- [ ] index.html BLE OTA 模式:连接/断开、文件选择、一键刷写、中止、ACK 节流、阶段流、校验面板均正常 — **需浏览器实机验证(需 Web Bluetooth 真实设备)**
- [ ] index.html 日志面板:UDP 日志接收、HTTP 日志拉取、过滤、自动滚动、清除均正常 — **需浏览器实机验证**
- [ ] index.html SPI 发送、系统状态轮询、堆/任务栈展示均正常 — **需浏览器实机验证(需真实设备)**
- [ ] can.html:DBC 导入、报文列表、节点过滤、信号编辑、帧发送、1Hz 反馈均正常 — **需浏览器实机验证**
- [ ] can_monitor.html:连接、报文接收监控正常 — **需浏览器实机验证**
- [x] 刷写进行中切换模式/目标被正确阻止(守卫逻辑保留)— console-mode.js 中 setConsoleMode/selectFlashTarget 守卫逻辑原样保留
- [x] BLE 断开时按系统状态处理(GD32_RELAYING/ESP32_REBOOTING 保留,其他 abortCurrent)逻辑保留 — ble/session.js 的 abortTask 与 gatt.js 的 onDisconnected 逻辑原样保留
## 说明
- 标记为"需浏览器实机验证"的项涉及真实 ESP32/BLE 设备交互,无法在静态分析中完成。
代码已通过语法检查与 HTTP 可达性验证,业务逻辑与原文件逐行对齐(纯结构重构,无逻辑修改)。
建议用户启动 serve.bat 后在浏览器中实机验证上述功能项。
@@ -0,0 +1,136 @@
# tools 网页工程化拆分 Spec
## Why
`tools/` 目录下 4 个核心文件(`app.js` 2371 行、`ble_ota.js` 1658 行、`can.js` 1526 行、`style.css` 2146 行)体积过大,单文件承载多重职责,难以维护和导航。`index.html` 还内联了 ~250 行胶水脚本。需要按职责拆分为小文件工程,提升可维护性。
## What Changes
- 将 `style.css` 拆分为 5 个按职责划分的 CSS 文件(base / layout / components / can / responsive)
- 将 `app.js` 拆分为 8 个 ES Module(ota/session、progress、stages、file-handle、upload-esp32、upload-gd32、spi、console-mode)+ 公共模块
- 将 `ble_ota.js` 拆分为 8 个 ES Module(ble/constants、state、stages、gatt、file、transfer、verify、session)
- 将 `can.js` 拆分为 5 个 ES Module(can/dbc、messages、signals、send、feedback)
- 抽取 `index.html` 内联 `<script>` 到 `js/ota/console-mode.js`
- 抽取跨页面共享的工具函数到 `js/common/`(utils、toast、log、connection、polling)
- 三个 HTML 页面的 `<script>` 改为 `type="module"`,`<link>` 拆分为多文件引用
- 删除原始大文件,用薄入口文件(`js/app.js`、`js/ble_ota.js`、`js/can.js`)替代
## Impact
- Affected code: `tools/` 目录全部文件(`app.js`、`ble_ota.js`、`can.js`、`can_monitor.js`、`style.css`、`index.html`、`can.html`、`can_monitor.html`)
- 不影响 ESP32 固件代码(`main/` 目录不改动)
- `serve.bat` 无需修改(Python http.server 天然支持 ESM 的 MIME 类型)
- **BREAKING**:旧的单文件引用路径(`app.js?v=...`)将不存在,HTML 中所有 `<script src>` 和 `<link href>` 路径需更新
## 技术方案
### 模块组织:原生 ES Modules
- 使用 `<script type="module">` + `import`/`export`,零构建、零工具链依赖
- `serve.bat`(Python http.server)无需修改,浏览器原生解析 ESM
- 所有 `import` 路径必须带 `.js` 后缀(浏览器 ESM 规范要求)
### HTML 内联事件处理器兼容策略
当前 HTML 大量使用 `onclick="funcName()"` 内联处理器。ES Module 作用域非全局,采用以下策略:
1. **内部函数**:模块内使用,不暴露到全局
2. **HTML 引用函数**:在入口模块中显式挂载到 `window`(如 `window.setConsoleMode = setConsoleMode`)
3. 模块脚本默认 `defer`,DOM 解析完成后执行,用户点击前 `window` 上的函数已就绪
### CSS 拆分策略
- 多 `<link>` 标签并行加载(优于 `@import` 串行)
- 每个页面引入相同的 5 个 CSS 文件(base + layout + components + responsive 为全站共用,can 为 CAN 页面专用但 OTA 页面也引用了 CAN 入口卡片样式)
### 目标目录结构
```
tools/
├── index.html # 结构 only,<script type="module" src="js/app.js"> + js/ble_ota.js
├── can.html # <script type="module" src="js/can.js">
├── can_monitor.html # <script type="module" src="js/can_monitor.js">
├── serve.bat # 不变
├── css/
│ ├── base.css # :root 变量、reset、container、背景纹理
│ ├── layout.css # header、main-layout、cards、forms、buttons、conn-indicator
│ ├── components.css # upload-zone、file-info、progress、stage-flow、badges、metrics、log-panel、toast、heap
│ ├── can.css # CAN workspace + monitor 专用样式
│ └── responsive.css # @media 响应式 + @keyframes 动画
└── js/
├── app.js # 薄入口:import ota/* + common/*,挂载 window 函数
├── ble_ota.js # 薄入口:import ble/*,导出 window.bleOta
├── can.js # 薄入口:import can/*,初始化 CAN 工具
├── can_monitor.js # CAN 监控入口(小文件,引用 common 模块)
├── common/
│ ├── utils.js # formatBytes / formatTime / formatUptime / escapeHtml / sanitizeHtml
│ ├── toast.js # showToast
│ ├── log.js # addLog + 环形缓冲 + 去重 + 自动滚动
│ ├── connection.js # testConnection + 连接状态指示器更新
│ └── polling.js # 状态/日志轮询调度 + Page Visibility 补偿
├── ota/
│ ├── session.js # OTA 会话状态机(OtaSessionState + 转换 + 复位)
│ ├── progress.js # 进度条 + 阶段节点 UI(setProgress / setActivePhase / markStageDone)
│ ├── stages.js # ESP32/GD32 阶段驱动(updateEsp32StagesFromPhase / GD32 阶段更新)
│ ├── file-handle.js # 文件选择 / FSAPI / SHA-256 / HEX→BIN 转换 / 文件信息 UI
│ ├── upload-esp32.js # ESP32 上传流程 + SHA 校验 + 完成升级
│ ├── upload-gd32.js # GD32 中继 OTA 触发 + relay 态跟踪
│ ├── spi.js # SPI 帧构建与发送
│ └── console-mode.js # 从 index.html 抽出的内联脚本(setConsoleMode / selectFlashTarget / 共享固件胶水)
├── ble/
│ ├── constants.js # UUIDS + BLE_OTA_* 协议常量 + TARGETS 配置
│ ├── state.js # state 对象 + 元素缓存(els)
│ ├── stages.js # BLE 阶段流管理(setBleStage / resetBleStages / setBleStageError)
│ ├── gatt.js # BLE 连接 / 断开 / 服务发现 / 通知使能
│ ├── file.js # 文件准备 + 分包 + notifyFileChange
│ ├── transfer.js # 数据写入循环 + ACK 窗口节流 + 重传
│ ├── verify.js # SHA-256 校验面板(showBleVerifyInfo / hideBleVerifyInfo)
│ └── session.js # waitForOtaResult + resolveOtaResult + abort 任务
└── can/
├── dbc.js # DBC 解析器(parseDbc / findSignal / buildSummary)
├── messages.js # 报文列表渲染 + 过滤 + 节点过滤
├── signals.js # 信号值编辑器 + 编解码 + 批量添加
├── send.js # 帧构建 + 发送 + 错误码描述
└── feedback.js # 1Hz 反馈轮询 + ID 快照表
```
### 拆分原则
1. **保持功能完全不变**:纯结构重构,不修改任何业务逻辑、算法、阈值
2. **按现有注释分节对齐**:`app.js` 已有 17 个 `// ==== SECTION ====` 注释,按此天然边界拆分
3. **共享函数提取到 common/**:`formatBytes`、`showToast`、`addLog`、`testConnection` 等在多个文件中重复的函数统一提取
4. **循环依赖处理**:模块间若存在循环引用,通过回调注册或共享 state 模块解耦
5. **全局状态最小化**:原 `window.*` 全局变量保留必要的(`window.connectionMode`、`window.flashTarget`、`window.bleOta`),内部状态用模块级变量
## ADDED Requirements
### Requirement: ES Module 工程结构
系统 SHALL 将 `tools/` 目录组织为原生 ES Module 工程,所有 JS 文件使用 `import`/`export` 语法,HTML 通过 `<script type="module">` 加载。
#### Scenario: 浏览器加载页面
- **WHEN** 用户通过 `http://localhost:8080/index.html` 访问 OTA 控制台
- **THEN** 浏览器原生解析 ESM import 链,无构建步骤,所有模块按依赖顺序加载执行
#### Scenario: Python http.server 提供 ESM 文件
- **WHON** `serve.bat` 启动 Python http.server
- **THEN** `.js` 文件以 `application/javascript` MIME 类型返回,`type="module"` 脚本正常加载
### Requirement: CSS 按职责拆分
系统 SHALL 将 `style.css` 拆分为 5 个独立 CSS 文件,每个文件负责一个明确的样式职责域。
#### Scenario: 页面引用 CSS
- **WHEN** 任意 HTML 页面加载
- **THEN** 通过 5 个 `<link>` 标签并行加载 base + layout + components + can + responsive,视觉效果与原单文件 `style.css` 完全一致
## MODIFIED Requirements
### Requirement: HTML 脚本引用方式
三个 HTML 页面 SHALL 使用 `<script type="module">` 引用入口 JS 文件,不再使用普通 `<script src>`。`index.html` SHALL 移除所有内联 `<script>` 块,改为模块导入。
#### Scenario: index.html 加载脚本
- **WHEN** `index.html` 解析到末尾
- **THEN** 按顺序加载 `js/app.js`(WiFi OTA 入口)和 `js/ble_ota.js`(BLE 入口),两者均为 `type="module"`,原内联胶水代码由 `js/ota/console-mode.js` 模块提供
### Requirement: HTML 内联事件处理器兼容
系统 SHALL 保留现有 HTML 中的 `onclick="funcName()"` 内联处理器,并在入口模块中将这些函数显式挂载到 `window` 对象。
#### Scenario: 用户点击内联 onclick 按钮
- **WHEN** 用户点击 `onclick="setConsoleMode('wifi')"` 按钮
- **THEN** `window.setConsoleMode` 函数已被 `js/ota/console-mode.js` 模块挂载,正常执行模式切换逻辑
## REMOVED Requirements
### Requirement: 原始大文件
**Reason**: `app.js`(2371 行)、`ble_ota.js`(1658 行)、`can.js`(1526 行)、`style.css`(2146 行)单文件过大,已拆分为模块化小文件
**Migration**: 原文件删除,功能由 `js/` 和 `css/` 子目录下的模块文件承接,HTML 引用路径已更新
@@ -0,0 +1,101 @@
# Tasks
## Phase 1: CSS 拆分(无依赖,可独立完成)
- [x] Task 1: 拆分 `style.css` 为 5 个职责文件
- [ ] SubTask 1.1: 创建 `css/base.css`(:root 变量、reset、container、背景纹理,对应原 1~53 行)
- [ ] SubTask 1.2: 创建 `css/layout.css`(header、main-layout、cards、forms、buttons、conn-indicator,对应原 55~210 行)
- [ ] SubTask 1.3: 创建 `css/components.css`(upload-zone、file-info、progress、stage-flow、badges、metrics、heap、log-panel、toast,对应原 211~1401 行中非 CAN 部分)
- [ ] SubTask 1.4: 创建 `css/can.css`(CAN panel、CAN workspace page、CAN 1Hz feedback、ID snapshot table、报文列表布局,对应原 778~1384 + 1726~2146 末尾的 CAN 段)
- [ ] SubTask 1.5: 创建 `css/responsive.css`(@media 响应式 + @keyframes 动画 + enhanced animations,对应原 1476~1725 + 散落的 keyframes)
- [ ] SubTask 1.6: 更新 `index.html`、`can.html`、`can_monitor.html` 的 `<link>` 为 5 个 `css/*.css` 引用,删除原 `style.css` 引用
## Phase 2: 公共模块抽取(app.js / can.js / can_monitor.js 共同依赖)
- [x] Task 2: 创建 `js/common/` 公共工具模块
- [ ] SubTask 2.1: 创建 `js/common/utils.js` — 导出 `formatBytes`、`formatTime`、`formatUptime`、`escapeHtml`、`sanitizeHtml`(从 app.js 426~516 行 + can.js 重复实现提取)
- [ ] SubTask 2.2: 创建 `js/common/toast.js` — 导出 `showToast`(从 app.js 484~516 行提取)
- [ ] SubTask 2.3: 创建 `js/common/log.js` — 导出 `addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll` 及日志环形缓冲状态(从 app.js 517~635 行提取)
- [ ] SubTask 2.4: 创建 `js/common/connection.js` — 导出 `testConnection`、`setConnectionState`、连接指示器更新逻辑(从 app.js 636~823 行 + 1076~1138 行提取)
- [ ] SubTask 2.5: 创建 `js/common/polling.js` — 导出 `schedulePolling`、`pollStatus`、`pollLogs`、`pausePollingForUpload`、Page Visibility 补偿(从 app.js 2051~2371 行提取)
## Phase 3: app.js 拆分(依赖 Phase 2)
- [x] Task 3: 拆分 `app.js` OTA 相关模块到 `js/ota/`
- [ ] SubTask 3.1: 创建 `js/ota/session.js` — OTA 会话状态机(`OtaSessionState`、`otaSessionTransition`、`otaSessionReset`、`clearOtaSessionResiduals`、`otaSessionActive`、`otaSessionTerminated`),对应原 69~237 行
- [ ] SubTask 3.2: 创建 `js/ota/progress.js` — 进度条与阶段 UI(`toggleCollapse`、`switchStageFlow`、`setProgress`、`setActivePhase`、`markStageDone`、`resetProgress`、`clearOtaResultWaiter`),对应原 239~425 行
- [ ] SubTask 3.3: 创建 `js/ota/stages.js` — ESP32/GD32 阶段驱动(`updateEsp32StagesFromPhase`、GD32 阶段更新、`esp32OtaPhase`、`esp32PhaseLabels`),对应原 824~1138 行
- [ ] SubTask 3.4: 创建 `js/ota/file-handle.js` — 文件选择 / FSAPI / SHA-256 / Intel HEX→BIN 转换 / 文件信息 UI(`handleFileSelect`、`clearFile`、`pickFileViaFSAPI`、`notifyFileChange`、`finalizeFileInfo`、`updateFileInfo`),合并原 1228~1466 行两个"文件处理"段
- [ ] SubTask 3.5: 创建 `js/ota/upload-esp32.js` — ESP32 上传流程(`startUpload('esp32')`、`uploadFirmware`、`startUploadWaitHeartbeat`、`stopUploadWaitHeartbeat`、`verifyEsp32Sha256`、ESP32 完成升级),对应原 1467~1859 行
- [ ] SubTask 3.6: 创建 `js/ota/upload-gd32.js` — GD32 中继 OTA 触发(`startUpload('gd32')`、`triggerGd32Ota`、relay 态跟踪、`hasObservedRealRelayState`),对应原 1860~2050 行
- [ ] SubTask 3.7: 创建 `js/ota/spi.js` — SPI 帧构建与发送(`sendSpiFrame`、`clearSpiFields`),对应原 1139~1227 行
- [x] Task 4: 抽取 `index.html` 内联脚本到 `js/ota/console-mode.js`
- 将原 431~684 行的内联 `<script>` 迁移为 ES Module
- 包含 `setConsoleMode`、`selectFlashTarget`、`handleSharedFileSelect`、`pickSharedFirmware`、`clearSharedFirmware`、`setBleFirmwareInputFile`、`syncBleFileFromSelectedTarget`、`applySharedModeUi`、`switchStageFlow` 覆盖逻辑
- 导出需要挂载到 `window` 的函数供 HTML onclick 使用
## Phase 4: ble_ota.js 拆分(与 Phase 3 无依赖,可并行)
- [x] Task 5: 拆分 `ble_ota.js` 为 `js/ble/` 模块
- [ ] SubTask 5.1: 创建 `js/ble/constants.js` — 导出 `UUIDS`、`BLE_OTA_*` 协议常量、`TARGETS` 配置、`UI_BUILD`(原 5~55 行)
- [ ] SubTask 5.2: 创建 `js/ble/state.js` — 导出 `state` 对象、`$` 元素查询辅助、`els` 元素缓存、`currentTarget`、`currentPayloadSize`、`flowTarget`、`refreshTargetUi`、`setConnectionState`、`resetUiAfterDisconnect`(原 57~430 行)
- [ ] SubTask 5.3: 创建 `js/ble/stages.js` — 导出 `updateBleStageLabels`、`resetBleStages`、`setBleStage`、`setBleStageText`、`setBleStageError`、`bleStageIds`(原 141~206 行)
- [ ] SubTask 5.4: 创建 `js/ble/gatt.js` — 导出 `connectBle`、`disconnect`、`onDisconnected`、服务发现、通知使能(原 431~560 行附近 connect/disconnect 段)
- [ ] SubTask 5.5: 创建 `js/ble/file.js` — 导出 `notifyFileChange`、`clearFile`、文件准备、分包计算、`setReadyHint`(原文件准备相关段)
- [ ] SubTask 5.6: 创建 `js/ble/transfer.js` — 导出数据写入循环、ACK 窗口节流、`updateProgressFromAck`、重传逻辑(原数据写入段)
- [ ] SubTask 5.7: 创建 `js/ble/verify.js` — 导出 `showBleVerifyInfo`、`hideBleVerifyInfo`、SHA-256 校验比对逻辑(原 276~302 行)
- [ ] SubTask 5.8: 创建 `js/ble/session.js` — 导出 `waitForOtaResult`、`resolveOtaResult`、`rejectOtaResult`、`abortCurrent`、`nextOtaSessionId`、`finishBelongsToWaiter`(原 207~261 + 1568~1657 行)
## Phase 5: can.js 拆分(与 Phase 3/4 无依赖,可并行)
- [x] Task 6: 拆分 `can.js` 为 `js/can/` 模块
- [ ] SubTask 6.1: 创建 `js/can/dbc.js` — 导出 `parseDbc`、`findSignal`、`parseMultiplexToken`、`buildSummary`、`normalizeWhitespace`(原 182~335 行)
- [ ] SubTask 6.2: 创建 `js/can/messages.js` — 导出 `filterMessages`、`renderMessageList`、`collectTransmitters`、`renderNodeFilterList`、`bindNodeFilterEvents`、`updateDbcFileInfo`(原 357~700 行附近)
- [ ] SubTask 6.3: 创建 `js/can/signals.js` — 导出信号值编辑器、编解码、`renderSignalEditor`、批量添加逻辑、旧版 shim(原 700~1100 行附近)
- [ ] SubTask 6.4: 创建 `js/can/send.js` — 导出 `buildSendResponseText`、`CAN_SEND_ERR_DESC`、`GD32_ERR_DESC`、帧构建与发送(原发送相关段)
- [ ] SubTask 6.5: 创建 `js/can/feedback.js` — 导出 1Hz 反馈轮询、ID 快照表渲染(原反馈轮询段)
## Phase 6: can_monitor.js ESM 化(依赖 Phase 2)
- [x] Task 7: 将 `can_monitor.js` 转为 ES Module
- 创建 `js/can_monitor.js` 入口,引用 `js/common/utils.js`、`js/common/connection.js` 等公共模块替换内部重复实现
- 原文件仅 244 行,内部逻辑保持不变,仅替换工具函数为 import
## Phase 7: 入口组装与 HTML 更新
- [x] Task 8: 创建薄入口文件并更新 HTML 引用
- [ ] SubTask 8.1: 创建 `js/app.js` 入口 — import 所有 `js/ota/*` + `js/common/*` 模块,将 HTML onclick 引用的函数挂载到 `window`(`setConsoleMode`、`selectFlashTarget`、`handleSharedFileSelect`、`pickSharedFirmware`、`clearSharedFirmware`、`startUpload`、`restartEsp32`、`sendSpiFrame`、`clearSpiFields`、`testConnection`、`refreshSystemStatusNow`、`setLogFilter`、`toggleAutoScroll`、`clearLogs`、`toggleCollapse` 等)
- [ ] SubTask 8.2: 创建 `js/ble_ota.js` 入口 — import 所有 `js/ble/*` 模块,组装并导出 `window.bleOta` API 对象(`init`、`isUploading`、`notifyFileChange`、`clearFile`、`refreshButtons`、`refreshTargetUi` 等),调用 `init()`
- [ ] SubTask 8.3: 创建 `js/can.js` 入口 — import 所有 `js/can/*` 模块,执行初始化
- [ ] SubTask 8.4: 更新 `index.html` — `<script>` 改为 `type="module" src="js/app.js"` + `type="module" src="js/ble_ota.js"`,删除内联 `<script>` 块
- [ ] SubTask 8.5: 更新 `can.html` — `<script>` 改为 `type="module" src="js/can.js"`
- [ ] SubTask 8.6: 更新 `can_monitor.html` — `<script>` 改为 `type="module" src="js/can_monitor.js"`
## Phase 8: 清理与验证
- [x] Task 9: 删除原始大文件
- 删除 `tools/app.js`(原 2371 行,已被 `js/app.js` + `js/ota/*` 替代)
- 删除 `tools/ble_ota.js`(原 1658 行,已被 `js/ble_ota.js` + `js/ble/*` 替代)
- 删除 `tools/can.js`(原 1526 行,已被 `js/can.js` + `js/can/*` 替代)
- 删除 `tools/style.css`(原 2146 行,已被 `css/*` 替代)
- 删除原 `tools/can_monitor.js`(已迁移到 `js/can_monitor.js`)
- [x] Task 10: 全功能验证
- 启动 `serve.bat`,浏览器打开三个页面验证无控制台错误
- 验证 index.html:WiFi 模式切换、ESP32/GD32 目标切换、文件选择、上传按钮状态、进度条、阶段流、日志面板、SPI 发送、系统状态轮询
- 验证 BLE 模式:连接/断开、文件选择、一键刷写、中止、阶段流、校验面板
- 验证 can.html:DBC 导入、报文列表、节点过滤、信号编辑、帧发送、1Hz 反馈
- 验证 can_monitor.html:连接、报文接收监控
# Task Dependencies
- [Task 2] 依赖 [Task 1] 完成(CSS 拆分先行,避免干扰)
- [Task 3] 依赖 [Task 2] 完成(ota 模块引用 common 公共函数)
- [Task 4] 依赖 [Task 3] 完成(console-mode 引用 ota 模块导出)
- [Task 5] 依赖 [Task 2] 完成(ble 模块可能引用 common 工具)
- [Task 6] 依赖 [Task 2] 完成(can 模块引用 common 工具)
- [Task 7] 依赖 [Task 2] 完成(can_monitor 引用 common)
- [Task 5]、[Task 6] 与 [Task 3] 无依赖,可并行执行
- [Task 8] 依赖 [Task 3]、[Task 4]、[Task 5]、[Task 6]、[Task 7] 全部完成
- [Task 9] 依赖 [Task 8] 完成
- [Task 10] 依赖 [Task 9] 完成
+4 -1
View File
@@ -5,6 +5,8 @@
],
"idf.portWin": "COM15",
"idf.currentSetup": "F:\\software\\.espressif\\v6.0\\esp-idf",
"idf.toolsPath": "C:\\Espressif",
"idf.customExtraPaths": "C:\\Espressif\\tools\\xtensa-esp-elf-gdb\\16.3_20250913\\xtensa-esp-elf-gdb\\bin;C:\\Espressif\\tools\\riscv32-esp-elf-gdb\\16.3_20250913\\riscv32-esp-elf-gdb\\bin;C:\\Espressif\\tools\\xtensa-esp-elf\\esp-15.2.0_20251204\\xtensa-esp-elf\\bin;C:\\Espressif\\tools\\esp-clang\\esp-20.1.1_20250829\\esp-clang\\bin;C:\\Espressif\\tools\\riscv32-esp-elf\\esp-15.2.0_20251204\\riscv32-esp-elf\\bin;C:\\Espressif\\tools\\esp32ulp-elf\\2.38_20240113\\esp32ulp-elf\\bin;C:\\Espressif\\tools\\cmake\\4.0.3\\bin;C:\\Espressif\\tools\\openocd-esp32\\v0.12.0-esp32-20251215\\openocd-esp32\\bin;C:\\Espressif\\tools\\ninja\\1.12.1;C:\\Espressif\\tools\\idf-exe\\1.0.3;C:\\Espressif\\tools\\ccache\\4.12.1\\ccache-4.12.1-windows-x86_64;C:\\Espressif\\tools\\dfu-util\\0.11\\dfu-util-0.11-win64",
"idf.customExtraVars": {
"IDF_TARGET": "esp32c3"
},
@@ -14,5 +16,6 @@
"--query-driver=**",
"--compile-commands-dir=g:\\Robot_v2\\DFU\\ESP32_COMM\\build"
],
"idf.flashType": "UART"
"idf.flashType": "UART",
"idf.port": "COM15"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
## 优化 toast.js + polling.js(不改用户可见行为,不破坏时序)
### A. `tools/js/common/toast.js`
1. **空值保护**:`showToast` 开头加 `var toast = document.getElementById('toast'); if (!toast) return;`,避免 toast 元素未挂载时调用方崩。其余 `toastIco` / `toastMsg` 用 `var ico = ...; var msg = ...;` 取出后判空再用。
2. **`info` 类型语义化**:保持视觉与现状一致,但修正注释。文件头注释从「支持 info / success / warning / error」改为如实描述「支持 success / error / warning(info 等同 warning)」,避免注释误导。不新增 CSS(无 caller 用 info,引入新色只会造成不一致)。
### B. `tools/js/common/polling.js`
5. **`pollLogs` 改递归 `setTimeout` + 在途保护**(修复 socket 抢占):
- 新增模块级 `logsInFlight` 标志和 `logsTimerId`(已有)。
- 抽出 `scheduleLogsPoll(delayMs)`(仿 `scheduleStatusPoll`)。
- `pollLogs` 体内首行 `if (logsInFlight) return;`;`finally` 内 `logsInFlight=false` + `scheduleLogsPoll(statusPollIntervalMs)`。
- `startPolling` / `resumePollingAfterUpload` / `visibilitychange` 改为 `scheduleLogsPoll(0)` 或保留 `pollLogs()` 直接触发(in-flight 守卫会跳过重叠)。
- 注意:`stages.js`、`upload-gd32.js` 的手动 `window.pollLogs()` 调用保持不变,in-flight 守卫下若正飞则跳过、由下一轮定时补上,行为安全。
### C. `tools/js/app.js`
6. **新增 `applyDeviceBusyRuntimeState(text)` helper**(放在 `applyUploadingRuntimeState` 之后,第 148 行后):
- 仿现有三兄弟的 DOM 模式:`#pillConn`/`#pillConnTxt`/`#connStatus`/`#connStatusTxt`/`#bConn`。
- 第 225 行后追加 `window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState;`。
### D. 改动后行为一致性自检(写进 commit message,不写测试)
- 暂停上传:`pollStatus.finally` 不再重挂轮询 → 修正竞态,符合既有注释意图。
- `pollLogs` 重叠:被 in-flight 守卫挡掉 → 减少与 `/ota/upload` 抢 socket,符合既有 `pollStatus` 的设计意图。
- 重启恢复:两处统一走 `markEsp32RebootComplete`,重复 toast/log 由 `isEsp32Rebooting` 幂等守卫消除。
- 设备忙 UI:从 polling.js 内联 DOM 改为调 helper,视觉完全一致(同样的 class、同样的文案)。
- toast `info`:视觉不变(仍为 warn 色),仅注释修正。
### 不做的事
- 不改后端协议、不改 `/system/status`、`/logs` URL 或请求字段。
- 不改 `pollStatus` 的成功分支业务逻辑(重启恢复仅抽出函数,条件不变)。
- 不新增 toast CSS / 不引入 `info` 专属配色。
- 不动 `connection.js`、`log.js`、`ota/*`、`stages.js`、`upload-gd32.js`(仅 app.js 加一个 helper)。
- 不改 `scheduleStatusPoll` 公共签名、不改导出列表。
### 验证
- `git diff` 人工核对 polling.js 重构后两处重启恢复分支与原逻辑逐行等价。
- 浏览器加载 `tools/index.html`,走一遍 ESP32 OTA(上传→中继→重启→恢复),确认:
- 上传期间无 `/system/status` 请求(DevTools Network)。
- `/logs` 不出现重叠并发请求。
- 重启恢复只弹一次「ESP32 已恢复在线」。
- 设备忙时连接胶囊显示「设备忙」,与改前一致。
- 跑一次 GD32 OTA(中继期 400ms 轮询),确认 `setStatusPollIntervalMs` 路径未受影响。
- 无单元测试框架,不新增测试。
### 影响面
- 修改文件:`tools/js/common/toast.js`、`tools/js/common/polling.js`、`tools/js/app.js`(仅 +1 helper + 1 window 挂载)。
- 风险点:`pollLogs` 改递归 setTimeout 后,若某轮抛出未走 finally 会停转——`pollLogs` 全程 `try/catch/finally`,与 `pollStatus` 同模式,风险等价。
@@ -0,0 +1,28 @@
## 目标
重新生成 `G:/Robot_v2/DCU/SPEC/code_optimization_audit_2026-07-19.html`,让它反映当前工作树的代码状态(已修改的 `health_monitor.*`、`spi_master.*`、`ble_ota_data.cpp`、`version.h`、`tools/js/common/connection.js` 等)。
## 步骤
1. 在 `G:/Robot_v2/DCU` 下运行生成器(不带 `--verify`,按你刚才的选择):
```bash
python tools/gen_code_optimization_audit.py
```
- 默认输出到 `SPEC/code_optimization_audit_<今天日期>.html`,即 `code_optimization_audit_2026-07-20.html`(今天 2026-07-20)。
- 注意:现有文件名是 `..._2026-07-19.html`,新生成的会是 `..._2026-07-20.html`,不会覆盖旧文件。
2. 检查脚本输出:
- 看 `[audit] files=... lines=...`、findings 数量、是否出现 `重复 finding ID` 或锚点漂移错误。
- 脚本内置 HTML 结构与内部链接自校验,失败会返回非 0。
3. 如有锚点漂移(某个 finding 引用的行号在改动后对不上原证据),脚本会报错并指出哪个 finding;此时需要我先核对对应源码再决定是调整脚本里的锚点还是接受新行号——会先汇报给你,不会私自改 finding 逻辑。
4. 生成成功后,简要汇报:
- 新报告路径
- findings 数量与 P0/P1/P2 分布是否变化
- 与旧报告的关键差异(例如新增/消失的 finding、行号变化)
- 旧报告 `2026-07-19.html` 是否保留(默认保留)
## 不做的事
- 不带 `--verify`(不跑 ESP32/GD32 构建、SPI 自测、前端语法检查)
- 不删除旧报告 `2026-07-19.html`
- 不改生成器脚本本身
- 不做 git 提交
@@ -0,0 +1,116 @@
## 目标
修复 heap alloc hook 与 health_mon 任务之间共享诊断状态的「无原子采样协议」问题:hook 写多个 volatile 字段组成的「最后一击」快照,reader(`maybeRecordHeapLowWater`)在 FreeRTOS mutex 下读取——但 hook 不获取该 mutex,因此 mutex 不提供与 hook 写的互斥,多字段快照可被撕裂(`hook_fired=1` 配上上一次的 `hook_alloc_size`/`caller`/`free_after`)。同时 `g_est_free` 是只减不增的近似值,命名上未与精确实测区分。
## 现状(已确认)
- ESP32-C3 **单核** RISC-V(`CONFIG_FREERTOS_UNICORE=y`),无 SPIRAM,`CONFIG_HEAP_USE_HOOKS=y`。
- hook (`esp_heap_trace_alloc_hook`, `health_monitor.cpp:77-115`) 在 ESP-IDF heap 分配器**自带的临界区内**被调用(heap spinlock 已禁中断),且可能从 IRAM/cache-off 上下文进入 → **禁止 FreeRTOS/flash 调用**,禁止在 hook 内再加锁。
- hook 写入的共享状态(`health_monitor.cpp:50-68`):
- `g_est_free`(近似空闲,只减不增,周期 resync)
- 「最后一击」快照:`g_hook_min_est` / `g_hook_alloc_size` / `g_hook_caller_addr` / `g_hook_free_after` / `g_hook_fired`
- 环形 `g_recent_allocs[4]` + `g_recent_alloc_w` / `g_recent_alloc_n`
- reader 路径:`maybeRecordHeapLowWater()` 在 `LockGuard lock(m_mutex)`(FreeRTOS mutex)内读取上述所有字段(`689-704`)。**`m_mutex` 不能阻断 hook**(hook 不获取它,且 hook 可能在中断禁用上下文)→ 撕裂读。
- reader 还有 `dumpHeapLowWaterJson()`(httpd 任务)读 `m_heapLowWater`:这条路径已由 `m_mutex` 正确保护(writer 也是 `maybeRecordHeapLowWater` 在同一 `m_mutex` 下写 `m_heapLowWater`),无撕裂问题,**不需改**。
- 现有同类模式:`CpuStats::getSnapshot()`(`cpu_stats.cpp:67-69`)用 `portENTER_CRITICAL(&m_mux)` 做多字段快照,**reader-only 临界区**,与 writer(也是任务上下文)互斥。本库无 seqlock / std::atomic / memory fence,统一依赖单核字宽原子性 + 临界区。
## 设计:reader-only `portENTER_CRITICAL` 快照(与 CpuStats 一致)
### 关键约束分析
- 单核 + hook 已在 heap 临界区内(禁中断)→ reader 用 `portENTER_CRITICAL` 禁中断即可与 hook 互斥:reader 持有临界区期间,任何 `malloc`(从而 hook)无法打断(因为 malloc 需取 heap spinlock,而 heap spinlock 也禁中断;但更直接:reader 禁中断后,hook 所在的 malloc 调用路径在 reader 释放前无法进入——单核上禁中断即阻止所有任务/ISR 抢占)。
- reader 在临界区内**只做 volatile 拷贝,不分配、不调任何 FreeRTOS/flash 函数**,保持极短热路径(与 hook 约束对称)。
- hook 端**不动**:不加锁、不改逻辑,保持 IRAM 安全。hook 写多个字段天然在 heap 临界区内 = 对 reader 而言是「原子写」(reader 禁中断期间 hook 跑不了;hook 跑时 reader 也进不来)。
### 实施步骤
#### 1. `health_monitor.h` — 新增 portMUX 与 reader 快照结构
- 在 `HealthMonitor` private 区新增静态 spinlock:
```cpp
static portMUX_TYPE s_hookSnapMux; ///< 保护 hook 共享状态的多字段读快照(单核 = 禁中断)
```
- 声明一个私有 reader 快照结构(把 hook 端多字段一次性拷出来),供 `maybeRecordHeapLowWater` 使用:
```cpp
struct HookLastHitSnap {
uint32_t alloc_size;
uint32_t caller_addr;
uint32_t free_after;
uint32_t fired;
uint32_t est_free;
uint32_t min_est;
uint32_t recent_n;
RecentAllocSnap recent[kRecentAllocCap];
};
```
(`RecentAllocSnap` / `kRecentAllocCap` 当前在 .cpp 匿名 namespace,需提到头文件或在 .cpp 内做快照——**选择后者**:快照结构与 `s_hookSnapMux` 的定义都留在 .cpp 匿名 namespace,reader 在 .cpp 内取快照,不污染头文件。`portMUX_TYPE` 是 FreeRTOS 类型,头文件已 include `freertos/FreeRTOS.h`,但 spinlock 实例可放 .cpp 匿名 namespace。)
**修正**:`s_hookSnapMux` 放 .cpp 匿名 namespace(与现有 `g_hook_*` 同作用域),头文件零改动。快照结构也放 .cpp 匿名 namespace。
#### 2. `health_monitor.cpp` — reader 临界区快照
- 匿名 namespace 新增:
```cpp
static portMUX_TYPE s_hookMux = portMUX_INITIALIZER_UNLOCKED;
```
- 新增 reader 辅助函数(**任务上下文专用**,禁中断 + 极短):
```cpp
HookSnap captureHookSnapshot()
{
HookSnap s = {};
portENTER_CRITICAL(&s_hookMux);
s.alloc_size = g_hook_alloc_size;
s.caller_addr = g_hook_caller_addr;
s.free_after = g_hook_free_after;
s.fired = g_hook_fired;
s.est_free = g_est_free;
s.min_est = g_hook_min_est;
s.recent_n = g_recent_alloc_n;
for (uint32_t i = 0; i < s.recent_n && i < kRecentAllocCap; ++i) {
s.recent[i] = g_recent_allocs[i];
}
portEXIT_CRITICAL(&s_hookMux);
return s;
}
```
- 新增 writer 辅助(在 reader 临界区内做 hook 状态的「确认消费」):
```cpp
void consumeHookFired(uint32_t new_min_est)
{
// 必须在 portENTER_CRITICAL(&s_hookMux) 内调用
g_hook_min_est = new_min_est;
g_hook_fired = 0;
}
```
- 修改 `maybeRecordHeapLowWater` 的 hook 块(`688-730`):把 `LockGuard lock(m_mutex)` 内的裸 volatile 读 + `g_hook_min_est`/`g_hook_fired` 写,替换为:
1. 在 `m_mutex` 外、进入快照写入前,调用 `captureHookSnapshot()` 取一份一致快照(`HookSnap hs`)。
2. 用 `hs.*` 字段填充 `rec.hook_*` 与 `big_*` 搜索(不再裸读 `g_hook_*` / `g_recent_allocs`)。
3. 在 `m_mutex` 内写完 `rec` 后,再用一个 `portENTER_CRITICAL(&s_hookMux)` 块调用 `consumeHookFired(g_est_free)` 重置 `g_hook_min_est` / `g_hook_fired`(保持原语义:消费后把 min_est 抬到当前 est,fired 清零,避免同一窗口重复记)。
- 注意 `g_est_free` 在 reader 临界区外已被 `hookResyncEstFree(free_heap)` 更新过(`336`),`consumeHookFired` 读它时需在 `s_hookMux` 内读以保证与 hook 写一致——因此 `consumeHookFired` 内读 `g_est_free`。
#### 3. 命名区分 estimated vs measured + 置信度标注
- `HeapLowWaterRecord.hook_free_after`(`health_monitor.h:84`)注释已说明「est,非精确实测」。增强为在 JSON 输出与字段名上显式区分:
- JSON 字段 `hook_free_after` → 改名为 `hook_est_free_after`(明确 est);前端 schema 同步说明。
- 在 `dumpHeapLowWaterJson` 的字段输出中,紧随其后加一个 `hook_confidence` 字段:值为字符串 `"est_low_watermark"`(hook 命中时)或 `"none"`(未命中),明确告知前端这是近似值、非精确实测。
- `health_monitor.h` 注释补充:`hook_free_after`(保留成员名以减小 diff)注释行加「**置信度:est 低水位近似值**,误差 = resync 周期内 free 路径未记账量;前端应标注 estimated」。
- `g_est_free` 注释(`56` 行)补充:「**estimated**,非 measured;health_mon 周期用 `esp_get_free_heap_size()` 实测纠偏。误差上界 ≈ resync 周期(5s)内总 alloc 字节」。
- 在文件头 `46-48` 的「记录策略」注释块补一段「**采样协议**:reader(`maybeRecordHeapLowWater`)用 `portENTER_CRITICAL(&s_hookMux)` 禁中断取一致快照,与 hook(在 heap 临界区内写)互斥;hook 端不加锁、保持 IRAM 安全」。
#### 4. 文档化 hook 可调用 API / IRAM 约束
- 文件头 `36-48` 注释块已说明「禁止 FreeRTOS/flash」。补充明确契约条目:
```
// ★ hook 可调用约束(IRAM 安全契约):
// - 禁止:FreeRTOS API、flash/rodata 访问、mutex/semaphore、长函数。
// - 允许:DRAM volatile 标量读写、__builtin_return_address、纯算术。
// - 写共享状态天然原子:hook 已在 heap 临界区内(禁中断),reader 用 s_hookMux 互斥。
// - 嵌套分配:hook 内禁止任何可能触发 malloc 的调用(否则递归进 hook,armed 标志不防递归——
// 当前 hook 体内无分配调用,保持之)。
```
## 验收
- 编译通过(ESP-IDF build),无新 warning。
- 并发压力下(WiFi + OTA + CAN 并发)多次抓取 `/system/heap_low_log`:`hook_fired=1` 的记录中 `hook_alloc_size`/`hook_caller_addr`/`hook_est_free_after` 三者一致(来自同一次 alloc),不再出现「fired=1 但 size=上一次值」的撕裂。
- JSON 输出含 `hook_est_free_after` 与 `hook_confidence` 字段,前端可明确区分 estimated vs measured。
- hook 热路径长度不变(未加锁、未加函数调用),IRAM 安全契约保持。
- reader 临界区极短(仅 volatile 拷贝,~10 个字段 + ≤4 条 recent),不持 `m_mutex` 同时持 `s_hookMux`(`captureHookSnapshot` 在 `m_mutex` 外调用),避免锁序问题。
## 影响范围
- 仅改 `main/monitor/health_monitor.cpp`(hook 块、`maybeRecordHeapLowWater`、注释、新增匿名 namespace 辅助)与 `main/monitor/health_monitor.h`(`hook_free_after` 注释增强、JSON 字段名说明)。
- `dumpHeapLowWaterJson`:JSON 字段 `hook_free_after` → `hook_est_free_after`,新增 `hook_confidence`。前端(IndexedDB 累积端)需同步字段名——属可观测性 schema 变更,需告知前端。
- 不动 hook 函数体逻辑、不动 `m_mutex` 保护的 `m_heapLowWater` 写入路径、不动 ESP-IDF 构建配置。
- 不引入 seqlock / std::atomic(与库现有单核 volatile + 临界区惯例一致)。
@@ -0,0 +1,62 @@
# 修复:WiFi 上传阻塞 HTTP 主线程导致日志挤压/内存爆满
## 根本原因
`esp_http_server`(IDF v6.0)是**单线程 select 模型**:一个主线程在 `select()` 循环里依次处理所有 socket。`ota_upload_handler` 在 HTTP 主线程里同步阻塞执行整个上传(GD32 含 Flash 擦除可达数十秒,ESP32 同样)。期间主线程不回 `select()`,导致:
1. 前端 `pausePollingForUpload()` 仍以 2.5–3s 发 `/system/status`、`/logs`,但 HTTP 主线程无法 accept/处理 → 连接在 lwIP 侧堆积,pbuf 占用堆内存 → 内存爆满
2. `/logs` 无法被 drain,日志在 ring buffer(64×256,有上限但前端看不到)→ 体现为"LOG挤压"
3. `max_open_sockets=4` + `lru_purge` 也无效:线程不回 `select()`,LRU 清理不触发
## 方案:异步上传 handler
用 IDF v6.0 的 `httpd_req_async_handler_begin/complete` API(已确认可用,`examples/protocols/http_server/async_handlers` 即此模式),把上传移到独立 worker 任务。HTTP 主线程立即返回 `select()` 循环,继续服务 `/logs`、`/system/status`。
### 改动文件
#### 1. `main/server/http_handlers.h`(新增声明)
- 新增 `esp_err_t ota_upload_handler_async(httpd_req_t *req);`(新的上传入口,注册替代原 `ota_upload_handler`)
- 保留原 `ota_upload_handler` 的实现,但改为 static 内部函数,仅供 async worker 调用
#### 2. `main/server/http_handlers.cpp`(核心改动)
- 新增 async worker 基础设施(文件内 static):
- `static QueueHandle_t s_uploadQueue`(长度 1,元素为 `httpd_req_t*`)
- `static SemaphoreHandle_t s_workerReady`(binary,初值 0)
- `static TaskHandle_t s_uploadWorker`(栈 8192,优先级 5,固定 core 0 与现有任务一致)
- `static void uploadWorkerTask(void*)`:循环 `xSemaphoreGive(s_workerReady)` → `xQueueReceive` → 调用原上传逻辑(用 async req 句柄)→ `httpd_req_async_handler_complete()`
- `static esp_err_t ensureUploadWorker()`:懒启动 worker(首次上传请求时创建,幂等)
- 新增 `ota_upload_handler_async(httpd_req_t *req)`:
1. `ensureUploadWorker()`
2. `httpd_req_t *async_req; httpd_req_async_handler_begin(req, &async_req)` —— 把 socket 标记 `for_async_req`,主线程不再处理它
3. `if (xSemaphoreTake(s_workerReady, 0) == false)` —— worker 忙(已有上传在进行),立即 `httpd_req_async_handler_complete(async_req)` 并返回 503:
```
httpd_resp_set_status(req, "503 Service Unavailable");
httpd_resp_sendstr(req, "{\"status\":\"error\",\"message\":\"upload in progress\"}");
return ESP_OK;
```
注意:503 要发在**原 req** 上(begin 之后 async req 才能发响应,但此处用原 req 发错误更简单——实际上 begin 后原 req 的 remaining_len 被清零,发响应需用 async_req。所以错误路径用 async_req 发)。
4. `xQueueSend(s_uploadQueue, &async_req, portMAX_DELAY)`
5. 返回 `ESP_OK`(主线程已完成投递,立即回 select)
- 把原 `ota_upload_handler` 改名 `static esp_err_t ota_upload_handler_impl(httpd_req_t *req)`,逻辑不变(它内部已用 `httpd_req_recv`/`httpd_resp_send`,async 句柄完全兼容)
- **保留 CORS 头设置**:`http_set_cors_headers` 在 `ota_upload_handler_async` 里对原 req 设置(begin 之前),async 路径的响应头也需在 worker 里设置——实际上 `httpd_req_async_handler_begin` 会拷贝 resp_hdrs,所以 CORS 头在 begin 前对原 req 设置即可被子拷贝
#### 3. `main/server/wifi_ota.cpp`(路由注册)
- `kHttpRoutes[]` 中 `/ota/upload` 的 handler 从 `ota_upload_handler` 改为 `ota_upload_handler_async`
- OPTIONS 预检不变(仍走 `http_options_handler`)
### 不改动的部分
- **前端 app.js 不改**:`pausePollingForUpload` 仍降速轮询,但现在能真正拿到 `/logs` 和 `/system/status` 响应(主线程不再阻塞)
- **日志系统不改**:ring buffer 已有上限,异步化后 `/logs` 能被正常 drain,不再积压
- **OtaCoordinator 不改**:已有 `ESP_ERR_INVALID_STATE` 拒绝并发会话,worker 的 503 是更早的防线
- **OTA_BLOCK_SIZE、栈大小不改**
### 内存影响
- 新增 1 个 worker 任务栈 8192 字节(从现有 `free_heap` 出,ESP32-C3 内部堆约 200KB+,可接受)
- 队列 1×`sizeof(httpd_req_t*)`=4 字节,信号量 ~80 字节,忽略不计
- **收益**:上传期间不再堆积 lwIP pbuf(每个未 accept 的连接可达数 KB),消除内存爆满主因
### 验证方式
1. 编译通过(`idf.py build`)
2. 功能验证:WiFi 上传 GD32/ESP32 固件,上传期间网页 `/logs` 持续刷新、`/system/status` 能返回(不再超时)
3. 内存验证:`/system/status` 的 `free_heap` 在上传期间不再持续下降
4. 并发验证:上传进行中再发一个 `/ota/upload`,立即返回 503 而非阻塞
@@ -0,0 +1,81 @@
## 方案:/can/feedback 改为请求驱动架构
### 目标
- ESP32 不再被动缓存 GD32 的 0xC1 推送,改为前端请求时主动向 GD32 查询
- GD32 停止主动推送 0xC1(降低 SPI 资源占用)
- 删除 `s_latest[3]`/`s_work[3]` 常驻 BSS(省 ~7.8KB)+ handler 栈上 `results[3]`(省 ~3.9KB 栈)
### 依据
- GD32 `handleFeedback` 已支持 0xC1 请求查询(`[channel, page]` → ACK + 页数据),无需新增协议
- ESP32 `transact(0xC1, ...)` 是现成请求/响应机制,`canFdSend`/`canFdSetRxMode` 已在用
- `canFdParseFeedbackPage` 可直接复用(只解析 payload 字节,不关心来源)
- 0xC4/0xC5/0xC6/0xD2 推送独立,不受影响
### 改动清单
#### 1. ESP32 侧 — `main/transport/canfd_bridge.cpp` / `.h`
新增 `canFdQueryFeedback(channel, page, can_feedback_result_t *out, uint8_t *entryIdx)`:
```cpp
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx);
```
- 发 `transact(0xC1, {channel, page}, ...)`,超时 200ms
- 成功(resp_type==ACK)→ 调 `canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx)`
- 返回 0=成功,-1=SPI/参数失败,-2=GD32 拒绝(ERROR)
#### 2. ESP32 侧 — `main/server/http_handlers.cpp` `can_feedback_handler`
- 删除 `results[CAN_CHANNEL_MAX]` 栈数组 + `getLatestCanFeedback` 循环
- 改为:对每个使能通道,调 `canFdQueryFeedback(ch, 0, &result, &entryIdx)` + `canFdQueryFeedback(ch, 1, &result, &entryIdx)`,在栈上用单个 `can_feedback_result_t result`(1.3KB,用完即释放)
- lite 路径:只需 page 0(统计字段在 page0 header),1 次 SPI 查询即可
- full 路径:page 0 + page 1,2 次查询,然后序列化
- 查询失败 → 该通道 `valid=false`(与原来无快照时行为一致)
- `pauseCanFeedbackTask` 调用保留(send_batch 仍需独占 SPI)
#### 3. ESP32 侧 — `can_mode_handler` GET
- 原来 `getLatestCanFeedback(0, &fb)` 读缓存 mode → 改为 `canFdQueryFeedback(0, 0, &fb, &entryIdx)` 取 page0 的 mode 字段
- 原来的 fallback `canFdSetRxMode` 保留(查询也失败时兜底)
#### 4. ESP32 侧 — `main/monitor/can_feedback_task.cpp`
- **删除** `s_latest[3]`、`s_valid[3]`、`s_work[3]`、`s_workIdx[3]`、`s_lastLoggedError[3]`、`publish()`、`getLatestCanFeedback()`
- **删除** taskLoop 里的 0xC1 分支(`canFdParseFeedbackPage` + `publish`)
- **保留** 0xC4/0xC5/0xC6/0xD2 事件分发(这些仍走推送)
- **保留** `pauseCanFeedbackTask`(send_batch / OTA 仍需暂停事件消费)
- `s_mutex` 删除(仅保护 s_latest/s_valid,不再需要)
#### 5. ESP32 侧 — `main/monitor/can_feedback_task.h`
- 删除 `getLatestCanFeedback` 声明
- 保留 `startCanFeedbackTask`、`pauseCanFeedbackTask`
#### 6. GD32 侧 — `app_spi_cmd_can_feedback.cpp`
- `canFeedbackStartRound()`:改为空函数(或加 `s_pushEnabled=false` 永久禁用推送)
- `canFeedbackPushStep()`:开头加 `if (!s_pushEnabled) return;` 或直接 return
- **保留** `handleFeedback`(请求查询处理程序不动)
- **保留** `buildFeedbackPayload`(查询和推送共用)
#### 7. GD32 侧 — `task_runner.cpp`
- `canFeedbackStartRound()` 调用可保留(变成空函数无副作用)或删除
### 内存收益
| 项 | 改前 | 改后 | 省 |
|---|---|---|---|
| `s_latest[3]` BSS | 3.9KB | 0 | 3.9KB |
| `s_work[3]` BSS | 3.9KB | 0 | 3.9KB |
| handler 栈 `results[3]` | 3.9KB | 1.3KB(单通道) | 2.6KB 栈 |
| **合计常驻 BSS** | | | **~7.8KB** |
### 性能影响
- 原来读缓存:<1ms
- 改后查询:lite 路径 1 次 SPI 往返 ~2-4ms;full 路径 6 次往返(3ch×2page)~12-24ms
- 前端 1Hz 轮询,24ms 完全可接受(httpd 单线程最坏阻塞 <50ms)
- GD32 不再 1Hz 推送 6 帧,省下 SPI 带宽和 PB6 中断
### 风险与缓解
- **SPI 查询失败**:GD32 不响应时 `transact` 超时返回,handler 返回 valid=false,前端显示"--"(与原无快照行为一致)
- **OTA 期间查询**:send_batch/OTA 已 `pauseCanFeedbackTask`,且 SPI worker 串行化,查询不会与 OTA 数据流冲突
- **can_monitor.html full 路径延迟**:6 次 SPI 查询 ~24ms + JSON 序列化,总 <50ms,远小于 1s 轮询间隔
### 不改动的部分
- 0xC4 ADC、0xC5 motor state、0xC6 motor version、0xD2 motor OTA 推送机制不变
- `canFdParseFeedbackPage` 解析逻辑不变
- GD32 `handleFeedback` 查询处理程序不变
- 前端 JS 不变(API 响应格式不变)
@@ -0,0 +1,58 @@
## 删除"断线后切换慢速轮询"机制
**文件:** `G:\Robot_v2\DCU\ESP32_COMM\tools\app.js`
**范围:** 只删除"降速到 10s"的行为,保留 5 次失败触发的网络诊断(diagnoseNetwork)和断线 UI 提示。删除后断线时保持 1s 轮询,仅显示"未连接"并按需做网络诊断。
### 改动点
1. **删除变量 `pollSlowMode`(1600 行)**
- 移除 `var pollSlowMode = false; // 降速模式(10s 一次)`
2. **删除函数 `slowDownPolling()`(1671-1681 行)**
- 整个函数移除
3. **删除函数 `restorePolling()`(1682-1695 行)**
- 整个函数移除(保留 `startPolling`、`pausePollingForUpload`、`resumePollingAfterUpload`)
4. **简化 `startPolling()`(1662-1670 行)**
- 删除 `pollSlowMode = false;` 这一行(其余保留)
5. **简化 `resumePollingAfterUpload()`(1714 行)**
- `statusPollIntervalMs = pollSlowMode ? 10000 : 1000;` → `statusPollIntervalMs = 1000;`
6. **简化 `pollStatus()` 成功分支(1738-1747 行)**
- 移除 `wasSlow` / `restorePolling()` 调用,仅保留 `consecutivePollFailures = 0;`(保留"恢复在线"的 toast 体验需要另判断;改为:若 `consecutivePollFailures > 0` 则提示已恢复并清零)
7. **简化 `pollStatus()` 失败分支的 5-fail 块(1789-1800 行)**
- 删除 `!pollSlowMode` 条件判断中的 `pollSlowMode` 部分
- 删除 `slowDownPolling();` 调用和"已切换为慢速轮询"日志
- 保留 `diagnoseNetwork()` 诊断、`updateConnectionUi(false, '未连接')`、`applyDisconnectedRuntimeState()` 和诊断文本显示
8. **简化 `pollStatus()` 失败分支末尾(1828 行)**
- `} else if (!pollSlowMode) {` → `} else {`(该 else 分支总是显示未连接状态)
9. **清理 `testConnection()`(817-820 行)**
- 删除 `if (pollSlowMode) { addLog(...); restorePolling(); }` 块
10. **清理 `refreshSystemStatusNow()`(874 行)**
- 删除 `if (pollSlowMode) restorePolling();`
11. **清理 `visibilitychange` 监听(1854-1855 行)**
- 删除 `// 之前若在慢速模式...` 注释和 `if (pollSlowMode) restorePolling();`
12. **清理 IP 输入 change 监听(1887-1892 行)**
- 简化为:IP 变化时清零失败计数并立即拉一次状态(不再依赖 restorePolling)
- 或直接移除 `if (pollSlowMode) { ... restorePolling(); }` 块,保留监听器做基本的失败计数清零
### 不删除的部分
- `consecutivePollFailures` 变量及其计数逻辑(诊断仍需要)
- `diagnoseNetwork()` 网络诊断函数
- `lastDiagTime` / `diagResult` 诊断状态变量
- `applyDisconnectedRuntimeState()` 断线 UI 更新
- `statusPollIntervalMs` 变量(被 upload 暂停/恢复机制复用,保持 1000 默认值)
### 验证
- 浏览器打开 `tools/index.html`,确认页面加载无 JS 错误
- 模拟 ESP32 断线:连续 5 次失败后应仍触发网络诊断并显示"未连接",但轮询间隔保持 1s
- 恢复 ESP32:应清零失败计数并提示"恢复在线"
@@ -0,0 +1,77 @@
## 目标
把电机 OTA 从"收固件+触发喂数据"一步式,改成 BLE 刷 GD32 那样的两段式:**先收完固件写 flash,再单独触发 SPI START + 喂数据**。拆分后收固件阶段不碰 SPI,避免与 GD32 按铃推送竞态;触发阶段只发一个短小的 SPI 0xD0。
## 改动点(共 4 个文件)
### 1. `main/server/http_handlers.cpp` — 拆分上传 + 新增 relay 触发
**`motor_ota_upload_handler`(line 2057)改:**
- 保留:互斥检查、`beginWriteSession`、流式收固件写 flash(`flashWriteChunk` 循环)、`beginWriteSession` 复位
- **删除**:末尾的 `motorOtaStart(...)` 调用(line 2128)
- 收完后写 meta:`metaSave({total_size=content_len, stored_size=received})`,让 relay 端点能确认固件完整
- 返回改为 `{"status":"stored","message":"call POST /motor/ota/relay?ch=X to trigger","channel":ch,"size":received}`
**新增 `motor_ota_relay_handler`:**
- 从 query 读 `ch`(和上传端点一致)
- `Gd32FlashStore::metaLoad` 确认 `stored_size >= total_size`,否则返回 400 `firmware not fully stored`
- `motorOtaIsActive()` 检查,已在跑则 409
- 调 `motorOtaStart(meta.total_size, ch)`:
- 0 → `{"status":"ok","message":"OTA started"}`
- -2 → 409 `already in progress`
- -3 → 500 `SPI start failed`(保留原日志 DEBUG_ERR)
- 成功触发后**清 meta**(`metaClear`),固件已交给 feedTask,避免残留
### 2. `main/server/wifi_ota.cpp:482` — 注册新路由
路由表加一行:
```cpp
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
```
(紧挨现有三个 motor/ota 路由,CORS preflight 复用现有循环自动处理)
### 3. `main/server/http_handlers.h` — 声明新 handler
加 `esp_err_t motor_ota_relay_handler(httpd_req_t *req);`
### 4. `tools/motor.html` — 前端两段式调用
**`startOta`(line 2986-3001)改:**
```
// 步骤2:POST /motor/ota 只收固件
const upResp = await fetch("/motor/ota?ch=${ch}", { POST, body: ota.bytes, timeout 15s });
const upJson = await upResp.json();
if (!upResp.ok) throw new Error(upJson.error);
otaLog("[上传] 成功,触发 GD32 转发…");
// 步3:POST /motor/ota/relay 触发喂数据
const rResp = await fetch("/motor/ota/relay?ch=${ch}", { POST, timeout 5000 });
const rJson = await rResp.json();
if (!rResp.ok) throw new Error(rJson.error);
otaLog("[转发] GD32 开始握手…");
startOtaPoll(); // 轮询不变
```
超时:上传 15s(48KB),触发 5s(一个 SPI 事务)。
**`cancelOta`:** 不用改。`/motor/ota/cancel` 两阶段都有效:触发前 motorOtaCancel 是空操作(feedTask 没跑),但仍清 GD32 状态;可顺便让 cancel handler 也清 meta(见下)。
### 5.(附带)`motor_ota_cancel_handler` 加清 meta
cancel 时 `motorOtaCancel()` 后补一行 `Gd32FlashStore::instance().metaClear()`,覆盖"上传完未触发就取消"场景,清掉残留固件 meta。触发后取消也清 meta 无害(feedTask 已停)。
## 状态机与轮询(不变)
- `/motor/ota/status` 复用,返回 GD32 pushStatus 的 state/packets_acked
- 触发前 status = IDLE;触发后进 HANDSHAKE/TRANSFER/FINISHED/ERROR
- 前端在 relay 请求成功后才 `startOtaPoll`,避免触发前空轮询
## 关键收益
- **消除竞态**:收固件纯 HTTP 流式写 flash,不碰 SPI/MISO 缓冲,不与 GD32 按铃推送争抢;触发阶段只发一个短 SPI 0xD0,窗口极小
- **语义清晰**:和 BLE 刷 GD32 的 `prepare+收流 → /ota/relay` 两段式对齐
- **改动最小**:feedTask、GD32 侧、状态轮询全部不动;只拆 HTTP 端点和前端调用顺序
## 风险
- 前端两次请求间若用户取消:cancel 已覆盖两阶段
- 上传成功但触发失败:meta 仍存,用户可重试 relay(relay 端点先检查 `motorOtaIsActive` 再触发);或 cancel 清 meta 重来
- 固件占 flash 未触发:metaClear 在 cancel/relay 成功后执行,残留风险低
## 验证
- 编译 ESP32_COMM(idf.py build)
- 手动测:选固件→开始,看日志先"[上传] 成功"再"[转发] GD32 开始握手",确认两次 HTTP 分开
- 观察 SPI 按铃竞态是否消失(原"SPI start failed"概率应降到极低)
- feedTask 终态判定(之前加的)继续生效
@@ -0,0 +1,84 @@
## 目标
把 BLE OTA DATA_IN 路径的 OOM/队列满从“静默丢包”变为“可恢复协议反馈”,让前端在一个窗口内收到缺口反馈并按 `completed_bytes` 续传退避,避免整窗口 8s 超时重发风暴。仅做方案 A(负 ACK 反馈),不上固定缓冲池;节流复用现有窗口节流思路。
## 现状核对(已核实)
- 平台 ESP32-C3,无 PSRAM,堆紧张;稳态堆水位需实测后才能评估是否上池,故本计划不涉及池。
- `ble_ota_data.cpp:597` `malloc(recv_len)` 失败仅 `DEBUG_ERR`,回调仍返回 0(ATT 成功)。
- `ble_ota_data.cpp:602` `xQueueSend` 失败同样静默:`free(heap_buf)` + `DEBUG_WARN("...丢...等重传")`,无协议反馈。两条路径都需修,不只 malloc 路径。
- 客户端窗口内 seq≥1 用 `writeValueWithoutResponse`(即发即忘),**收不到 ATT Error Response**;只有 seq=0 用 `writeValueWithResponse`。因此“返回 ATT 错误码”对窗口内丢包无效,必须靠 DATA_OUT 通知通道发窗口 ACK。原报告“返回 ATT 错误”建议在窗口路径不成立,本计划以通知 ACK 为主。
- 客户端 `transfer.js` 已支持按 `completed_bytes` 续传(`resumeSeq = ceil(lastAckBytes / payloadSize)`,最多 3 次重试,窗口 64 包),所以设备端多发一个缺口 ACK 不会破坏客户端语义,只会让续传更早开始。
- 现有 `sendWindowAck(cmd, seq, completed_bytes)` 在 `ble_ota_data.cpp:440` 已封装好,seq 取 `s_reorder.expected - 1`(最近连续落盘序号),负 ACK 直接复用即可,无需新协议字段、无需改客户端。
- 现有节流是“进度触发 + 每 `BLE_OTA_ACK_WINDOW`(64) 包边界”(见 process_esp32_packet:691-712),不是时间间隔。丢包路径发生在写入前、无进度,无法复用边界判定,需为负 ACK 引入轻量节流(避免重传叠加时 ACK 通知打爆 NimBLE mbuf 池)。
## 改动点(集中在 `main/server/ble_ota_data.cpp`)
### 1. 新增丢包计数(可观测性,满足“池耗尽/丢包有计数”验收)
- 在文件静态区新增:
- `static uint32_t s_ota_drop_malloc_fail = 0;` // malloc 失败累计
- `static uint32_t s_ota_drop_queue_full = 0;` // 队列满累计
- `static uint32_t s_neg_ack_sent = 0;` // 负 ACK 发送累计
- 在 `ble_ota_session_reset()`(已有复位点,约 line 414)中清零,与 reorder 状态同生命周期。
- 通过现有 health monitor 或 DEBUG_DBG 暴露(按现有日志风格,不新增上报通道)。
### 2. 新增负 ACK 节流状态
- 复用“每 64 包/最小间隔”思路,但丢包路径无 `completedPackets`,故用包计数 + 最小时间间隔双约束:
- `static uint32_t s_neg_ack_count = 0;` // 自上次发负 ACK 以来累计丢包数
- `static int64_t s_last_neg_ack_us = 0;` // 上次负 ACK 时刻(`esp_timer_get_time()`)
- 节流规则(在丢包路径触发):
- 累计丢包 ≥ `BLE_OTA_ACK_WINDOW`(64) **或** 距上次 ≥ 200ms 时,发一次负 ACK 并复位计数/时间。
- 否则只计数、不发,避免重传风暴期间 ACK 通知打爆 mbuf 池。
- 常量定义在文件内 `static constexpr`(与 `BLE_OTA_BUF_SIZE` 同位置,约 line 580),不污染 app_config.h。
- 时间用 `esp_timer_get_time()`(已是项目内惯用 API,见 reorder/health 用法)。
### 3. 新增负 ACK 发送辅助函数
- 在 `sendWindowAck`(line 460 后)下方新增:
```cpp
// 丢包路径专用:发缺口 ACK 让前端按 completed_bytes 续传退避,而非等 8s 超时。
// 节流:每 BLE_OTA_ACK_WINDOW 个丢包或 200ms 最多一次,防止 ACK 通知打爆 mbuf 池。
static void sendNegativeAckIfDue(uint8_t cmd);
```
- 实现:
- 读取 `s_reorder.expected`:>0 时 `ackSeq = expected - 1`,`completed = Esp32OtaCore::getReceived()`;expected==0(首包都没落盘)时 ackSeq=0、completed=0(等价于让前端从头续发,客户端已支持此语义)。
- 节流判定通过后调用 `sendWindowAck(cmd, ackSeq, completed)` 并 `s_neg_ack_sent++`。
- GD32 路径同样适用(`sendWindowAck` 已按 cmd 区分),但 GD32 的 expected 状态在 `s_ble_gd32_meta`,需在函数内按 cmd 分支取 expected/received;为简单起见,先实现 ESP32 路径,GD32 路径仅计数不发 ACK(GD32 是中继转发,缺口由 GD32 侧自行反馈),并在注释中标注。
### 4. 改造 `ble_ota_data_in_access` 丢包路径(line 596-605)
- malloc 失败分支:`s_ota_drop_malloc_fail++` → `sendNegativeAckIfDue(cmd)`(cmd 从 batch 头解析,见下)→ 保留 `DEBUG_ERR`。
- 队列满分支:`s_ota_drop_queue_full++` → `sendNegativeAckIfDue(cmd)` → 保留 `DEBUG_WARN`。
- **cmd 解析问题**:当前 line 566-596 的回调只把 mbuf 拷到 `buf`,未解析 cmd。负 ACK 需要 cmd 字节。两种做法:
- (a) 回调里解析前 7 字节头取 cmd(与 worker 中 `ble_ota_process_data_batch` 解析逻辑一致,可抽一个小 helper `parse_cmd_from_batch(buf, recv_len)`)。
- (b) 不解析,统一用 `BLE_OTA_CMD_ESP32_DATA`(0x01)(C3 自身 OTA 是主路径,GD32 经 BLE 中继较少)。
- **选 (a)**:抽 helper,正确性优先,避免误报 cmd 让前端续传走错分支。helper 放在 `ble_ota_data.cpp` 内 static,不暴露到头文件。
### 5. 不改动的部分
- 回调返回值仍为 0(窗口内 write-without-response 返回错误对端也收不到,且会污染 ATT 层;负 ACK 走通知通道才是正反馈)。
- 不引入固定缓冲池(C3 无 PSRAM,池规模需实测,本计划范围内不做)。
- 不改客户端 `tools/js/ble/*`(现有续传语义已兼容)。
- 不改协议字段/UUID/通知格式。
## 风险与权衡
- **负 ACK 抖动**:极端低堆时丢包密集,节流会让部分丢包延迟反馈,客户端可能仍等部分超时。权衡已用 200ms 兜底,可接受;若实测仍风暴,再调常量。
- **首包未落盘时负 ACK**:expected==0 时发 `ackSeq=0, completed=0`,等价“一切重来”。客户端 seq=0 用 write-with-response,本就等首包 ACK;此时负 ACK 不会误导。
- **GD32 路径**:本计划 GD32 仅计数不发负 ACK,避免改动 GD32 中继反馈语义;如需对称可后续扩展。
- **节流状态并发**:`s_neg_ack_*` 仅在 NimBLE host task 回调中读写(单线程),worker task 不写,无需锁。
## 验收
1. 构造低堆注入:临时在 malloc 前插 `if (s_inject_fail) { heap_buf=nullptr; }` 开关(测试桩,提交前移除),或用 `heap_caps_set_minimum_size` 压低堆水位。
2. 触发 OTA 上传,观察:
- 日志出现 `DATA_IN 堆内存不足` / `DATA_IN满` + `sendNegativeAckIfDue` 发送记录。
- 客户端在窗口内(≤200ms~8s)收到缺口 ACK,`lastAckBytes` 推进,从 `resumeSeq` 续传,**不出现整窗口 8s 超时**。
- `s_ota_drop_*` 计数器增长,负 ACK 后传输恢复,无永久卡死。
3. 正常路径回归:不注入失败时 ACK 行为、传输耗时、收尾 SHA256 不变。
4. 池耗尽有计数:`s_ota_drop_queue_full` / `s_ota_drop_malloc_fail` 在 health monitor/日志可查。
## 实施顺序
1. 加丢包计数 + `ble_ota_session_reset` 清零。
2. 加负 ACK 节流状态 + `sendNegativeAckIfDue`(ESP32 路径)。
3. 抽 `parse_cmd_from_batch` helper。
4. 改造 `ble_ota_data_in_access` 两条丢包路径接入负 ACK。
5. 低堆注入测试验收,移除注入桩。
6. (可选)GD32 路径对称负 ACK,视后续需要。
## 涉及文件
- `main/server/ble_ota_data.cpp`(主要改动:line 414 reset、line 460 后新增函数、line 596-605 丢包路径、line 580 附近常量)
- 不改:`ble_ota_priv.h`、`ble_ota.cpp`、`app_config.h`、客户端 JS。
+93 -574
View File
@@ -1,607 +1,126 @@
# ESP32-C3 OTA Relay
# ESP32_COMM · DCU 通信与 OTA 中枢固件
**固件版本: v0.3.12** | 构建时间: 2026-06-17 | 基于 ESP-IDF v6.0
> **文档同步版本:v0.5.2(git 770ae14)· 2026-10-11**
> 本 README 是 [docs/project_introduction.html](docs/project_introduction.html)(完整白皮书,含 21 幅 SVG 原理图)的**浓缩版**,两者须同步更新(见文末「文档同步说明」)。
## 项目概述
ESP32-C3FH4 OTA 中继固件,作为 **GD32E508VET6** 的 OTA 升级桥梁。通过 **WiFi (HTTP)** 或 **BLE (GATT)** 从上位机(手机/PC)接收固件,存储在本地 SPI Flash 的 `gd32_ota` 分区,再通过 **SPI 总线** 经四阶段安全握手协议转发给 GD32。
ESP32-C3FH4 固件,作为 GD32E508 DCU 板的通信与 OTA 中枢:
```
[手机/PC 上位机] --WiFi/BLE--> [ESP32-C3FH4] --SPI--> [GD32E508VET6]
|
+-- gd32_ota 分区 (252KB)
+-- 健康自检 (温度 + WiFi RSSI)
+-- 本机 OTA (ESP32 自升级)
[上位机 PC/手机网页] --WiFi HTTP / BLE GATT--> [ESP32-C3FH4] --SPI Link v2 (13.33MHz, 5线)--> [GD32E508]
| |
gd32_ota 252KB 固件缓存 3×CANFD 收发器(电机总线)
app0/app1 AB 自升级 电机 OTA CAN 二次中继
```
## 硬件平台
- **OTA 中继**:GD32 固件(六阶段安全握手 + AES-128 逐包加密转发)、ESP32 自身 AB 分区升级、电机节点经 GD32 CAN 二次烧写
- **SPI Link v2 主站**:会话 / 序号 / 信用流控 / CRC32 / HELLO 重同步 / 透明重试
- **CAN 控制面**:单帧、批量、跨通道同步突发(0xC7)、只听模式(0xC8)、遥测回收(0xC4/C5/C6)
| 项目 | 规格 |
## 硬件与分区
| 项 | 值 |
|------|------|
| **芯片** | ESP32-C3FH4 (RISC-V, 单核, 160MHz) |
| **Flash** | 4MB |
| **SPI 引脚** | MOSI=GPIO5, MISO=GPIO7, SCLK=GPIO6, CS=GPIO4 |
| **SPI 速率** | 4MHz, Mode 0 |
| **UART 调试** | TX=GPIO21, RX=GPIO20, 115200 baud |
| **编译工具链** | ESP-IDF v6.0, riscv32-esp-elf |
| 芯片 | ESP32-C3FH4(RISC-V 单核 160MHz)· 4MB Flash(DIO) |
| SPI | MOSI=5, MISO=7, SCLK=6, CS=4, DATA_READY=10;13.33MHz Mode 0(`CONFIG_DCU_SPI_CLOCK_HZ`) |
| 调试串口 | UART0 TX=21/RX=20,**3 Mbps**(115200 为 sdkconfig 控制台项);UDP 日志 192.168.31.3:6666 |
| 欠压 | LVL7(≈2.8V,防长断电半复位) |
### 分区表 (`config/partitions.csv`)
| 分区 | 类型 | 偏移 | 大小 | 用途 |
|------|------|------|------|------|
| nvs | data/nvs | 0x9000 | 24KB | NVS 存储 |
| otadata | data/ota | 0xF000 | 8KB | OTA 启动信息 |
| gd32_ota | data/spiffs | 0x11000 | 252KB | GD32 固件缓存 |
| app0 | app/ota_0 | 0x50000 | 1.25MB | ESP32 OTA 槽位 0 |
| app1 | app/ota_1 | 0x190000 | 1.25MB | ESP32 OTA 槽位 1 |
| spiffs | data/spiffs | 0x2D0000 | 1.06MB | 文件系统 |
## 功能模块
| 模块 | 文件 | 描述 |
|------|------|------|
| **core** | `core/main.cpp` | 程序入口,初始化各模块并创建 4 个 FreeRTOS 任务 |
| **health_monitor** | `monitor/health_monitor.{h,cpp}` | 温度 + WiFi RSSI 监控,OTA 前健康检查 |
| **debug_log** | `monitor/debug_log.{h,cpp}` | 三路日志输出:UART0 + UDP + 环形缓冲区 |
| **ota_coordinator** | `ota/ota_coordinator.{h,cpp}` | **全局 OTA 协调器**(单例):串行化 GD32/ESP32 OTA 操作,统一传输层入口,9 状态系统级状态机 |
| **ota_relay** | `relay/ota_relay.{h,cpp}` | GD32 OTA 中继**事件驱动状态机**(6 阶段 + ERROR):通过 `dispatchQueue` 接收调度信号,`processEvent()` 驱动状态转换 |
| **esp32_ota_core** | `ota/esp32_ota_core.{h,cpp}` | ESP32 本机 OTA 核心(begin/write/finalize/abort,WiFi/BLE 共用) |
| **wifi_ota** | `server/wifi_ota.{h,cpp}` | WiFi AP/STA + HTTP OTA 服务器 + CAN 帧转发 |
| **ble_ota** | `server/ble_ota.{h,cpp}` | BLE GATT 服务器(NimBLE 协议栈) |
| **spi_master** | `transport/spi_master.{h,cpp}` | SPI 主站驱动 + CANFD 3 通道 + OTA 帧协议 |
| **protocol** | `transport/protocol.{h,cpp}` | 协议层(nonce 搅伴生成、AES 会话密钥管理、运行时 SCRAMBLE_CONST) |
## 目录结构
```
ESP32_COMM/
├── CMakeLists.txt # 顶层 CMake 项目定义
├── sdkconfig.defaults # SDK 配置默认值 (WiFi RX/TX BA=6)
├── config/
│ └── partitions.csv # 4MB Flash 分区表
├── cmake/
│ └── gen_version_script.cmake # 构建时自动生成 version.h
├── main/
│ ├── CMakeLists.txt # 注册 12 个源文件 + 依赖
│ ├── include/
│ │ ├── app_config.h # 统一配置 (constexpr: WiFi/BLE/OTA/阈值/密钥)
│ │ ├── version.h # 自动生成版本信息 (v0.3.12)
│ │ └── ota_mutex.h # BLE/WiFi OTA 全局互斥
│ ├── core/main.cpp # app_main 入口,4 个 FreeRTOS 任务
│ ├── relay/ota_relay.{h,cpp} # GD32 OTA 中继事件驱动状态机
│ ├── ota/
│ │ ├── esp32_ota_core.{h,cpp} # ESP32 本机 OTA 核心
│ │ └── ota_coordinator.{h,cpp} # 全局 OTA 协调器
│ ├── transport/
│ │ ├── spi_master.{h,cpp} # SPI 主站 + CANFD 3 通道
│ │ └── protocol.{h,cpp} # 协议层 (CRC16 / AES / nonce)
│ ├── server/
│ │ ├── wifi_ota.{h,cpp} # WiFi OTA HTTP 服务器
│ │ └── ble_ota.{h,cpp} # BLE GATT OTA 服务器
│ └── monitor/
│ ├── health_monitor.{h,cpp} # 温度 + RSSI 健康监控
│ └── debug_log.{h,cpp} # UART0 + UDP + 环形缓冲区日志
├── components/
│ └── cjson/ # cJSON 组件
├── managed_components/
│ └── espressif__mdns/ # mDNS 组件
├── tools/
│ ├── index.html # WiFi OTA WebUI 控制台
│ ├── ble_ota.html # BLE OTA WebUI 控制台 (Web Bluetooth)
│ ├── app.js # WebUI JavaScript
│ ├── style.css # WebUI 样式
│ ├── serve.bat # Python HTTP 服务器启动脚本 (端口 8080)
│ ├── serial_monitor.py # 串口日志采集工具 (COM15)
│ └── ota_upload_test.py # OTA 上传测试脚本
├── docs/
│ ├── esp32_ota_relay_design.md # 架构设计文档
│ ├── DEVELOPMENT_GUIDE.md # 开发者指南 (T0-T8)
│ └── COMMUNICATION_AUDIT_2026-05-05.md # 通信审计
├── .vscode/ # VS Code 调试/编译配置 (COM15, clangd)
└── .devcontainer/ # Docker 开发容器 (ESP-IDF 6.0)
```
## FreeRTOS 任务
4 个主任务全部固定运行在 ESP32-C3 单核(核 0),通过 FreeRTOS 队列通信:
| 任务名 | 函数 | 优先级 | 栈大小 | 职责 |
|--------|------|--------|--------|------|
| `health_mon` | `HealthMonitor::taskEntry` | configMAX_PRIORITIES - 4 | 4096 | 温度监控 (1s 定时器) + 堆巡检 (5s) + 健康状态管理 |
| `wifi_ota` | `WifiOta::taskEntry` | configMAX_PRIORITIES - 3 | 8192 | WiFi (AP/STA) + HTTP 服务器 (端口 80) + 状态轮询 |
| `ble_ota` | `BleOta::taskEntry` | configMAX_PRIORITIES - 3 | 8192 | BLE GATT 服务器初始化 + NimBLE Host 任务 + 心跳保活 |
| `ota_relay` | `OtaRelay::taskEntry` | configMAX_PRIORITIES - 2 | 12288 | 等待 dispatchQueue 调度信号 → 事件驱动状态机(6 阶段)→ SPI 转发 → resultQueue 回传结果 |
额外后台任务:
| 任务名 | 优先级 | 栈大小 | 职责 |
|--------|--------|--------|------|
| `wifi_reconnect` | 5 | 4096 | STA 断线重连(退避算法,独立任务避免死锁)|
| `ble_ota_finish` | 5 | 4096 | BLE ESP32 OTA 异步收尾(SHA 校验 + end + reboot)|
| `nimble_host` | NimBLE 内部 | NimBLE 内部 | BLE 协议栈事件循环 |
### 跨任务通信
- **OTA 协调**: **OtaCoordinator** 管理系统级 OTA 状态(9 状态),提供 `beginGd32Storage/completeGd32Storage/triggerGd32Relay/beginEsp32Ota` 等统一入口,内部 mutex 保护状态读写
- **OTA 调度**: WiFi/BLE 任务通过 `OtaRelay::dispatchQueue` (FreeRTOS 队列, 深度 4) 发送调度信号给 `ota_relay` 任务;结果通过 `resultQueue` 回传;OtaCoordinator 也向此队列投递中继触发命令
- **OTA 互斥**: `g_esp32OtaMutex` (全局 Mutex) + `g_esp32OtaOccupied` 标志,防止 BLE 和 WiFi 同时操作 ESP32 OTA 分区
- **中继状态**: `OtaRelay::s_stateMutex` 保护 relayState/relaySeq/relayTotalPackets 等跨任务状态(`StateLockGuard` RAII 封装)
- **WiFi 状态**: `WifiOta::m_stateMutex` + `std::atomic` 保护 WiFi 连接状态和重连计数器
- **健康状态**: `HealthMonitor::m_mutex` 保护 healthState/temperature/RSSI
- **UDP 日志**: `DebugLog::m_logRingMutex` 保护 HTTP `/logs` 环形缓冲区
## OTA 核心流程
### 一、GD32 OTA 中继(事件驱动状态机,6 阶段 + ERROR)
上位机上传 GD32 固件到 ESP32 本地 `gd32_ota` 分区(通过 **OtaCoordinator** 协调上传状态)→ 收到触发命令后由 **OtaRelay** 事件驱动状态机执行转发:
```
状态机总览:
IDLE ──(EVT_START)──→ HANDSHAKING ──→ SIZE_NEG ──→ TRANSFERRING
↑ │ │ │
│ (error) (error) (error)
│ ↓ ↓ ↓
└──── DONE ←── FINISHING ←────────────────────────── ERROR
│ ↑ │
└──── (EVT_START) ───┘ (EVT_START)───┘
```
| 阶段 | 状态 | 输入事件 | 输出/转换 |
|------|------|----------|-----------|
| **① 安全握手** | `HANDSHAKING` | EVT_START / EVT_RESP_OK | `doHandshakeReq()` → `doChallengeAndAuth()` → 建立 `K_session` AES 加密通道 |
| **② 大小协商** | `SIZE_NEG` | EVT_START | `doSendFirmwareSize()` → 加载 NVS 元数据中的 `total_size` |
| **③ 数据包转发** | `TRANSFERRING` | EVT_START | `doRelayPackets()` → 逐包 AES 加密转发 + 结构化 ACK 校验 + 健康检查 |
| **④-⑤ 完成与验证** | `FINISHING` | EVT_START | `doFinishAndVerify()` → 发送 FINISH → PING 轮询 APP 存活(最多 30 次 × 500ms) |
| **完成** | `DONE` | EVT_START → 重入 IDLE | 状态机复位 |
| **错误** | `ERROR` | 任意错误超时/健康危险 | `reset()` → IDLE |
#### 协调器系统级状态(OtaCoordinator)
```
状态流转:
IDLE ──→ GD32_STORING ──→ GD32_STORED ──→ GD32_RELAYING ──→ GD32_COMPLETE
│ │
├──→ ESP32_RECEIVING ──→ ESP32_FINALIZING ──→ ESP32_REBOOTING
└──→ ERROR (任意阶段)
```
| 系统状态 | 含义 |
|----------|------|
| `OTA_SYS_IDLE` | 空闲,可接受新请求 |
| `OTA_SYS_GD32_STORING` | 传输层正在存储 GD32 固件到 Flash |
| `OTA_SYS_GD32_STORED` | GD32 固件已完整存储,等待触发中继 |
| `OTA_SYS_GD32_RELAYING` | SPI 中继转发进行中 |
| `OTA_SYS_GD32_COMPLETE` | GD32 OTA 完成 |
| `OTA_SYS_ESP32_RECEIVING` | 接收 ESP32 自身固件 |
| `OTA_SYS_ESP32_FINALIZING` | SHA 校验 + end + set_boot |
| `OTA_SYS_ESP32_REBOOTING` | 即将重启 |
| `OTA_SYS_ERROR` | 错误状态 |
#### 安全握手协议
1. **握手请求**: ESP32 发送 `0x00 0xAC`(最多重试 10 次,200ms 超时)
2. **Challenge**: GD32 回复 36B 数据包(12B UID + 8B nonce 片段 + 16B 加密会话密钥)
3. **验证**: ESP32 验证 nonce 匹配,用 MasterKey 派生 `K_session = AES(MasterKey, nonce_server)` 并比对
4. **确认**: ESP32 发送 `nonce_server ^ 0x55` 经 `K_session` AES-ECB 加密
5. **完成**: GD32 回复 `0xBA 0xC0`,建立 AES 加密通道
> **GD32 UID **: 运行时从握手 Challenge 包解包获得,不再使用编译期常量 `GD32_CHIP_UID`。
#### 数据包转发
- 每包 48 字节固件载荷,使用 `K_session` AES-ECB 加密
- 包结构: `session(4B) + seq(4B) + encrypted48(48B) + if_end(4B) + crc32(4B)` = 64B
- 每 32 包执行一次 OTA 健康检查(温度/堆)
- 支持结构化 ACK 含 seq 校验 + 错误码(`0x04`=CRC 错误),残留 ACK 自动过滤
- 单包最多 10 次重试,全局超时动态计算 5~10 分钟
- **Flash 元数据**: 通过 NVS 记录固件 `total_size`/`stored_size`/`last_seq`/`checksum`,用于完整性校验和中继恢复判断
### 二、ESP32 本机 OTA(WiFi + BLE 共用)
核心逻辑封装在 `Esp32OtaCore` 单例中,WiFi 和 BLE 通道共用。
传输层通过 **OtaCoordinator** 统一入口(`beginEsp32Ota()`)发起操作,由协调器管理系统状态并委托 `Esp32OtaCore` 执行底层写入:
| 步骤 | 方法 | 说明 |
|------|------|------|
| 1 | `begin(total_size)` | 选择非当前 OTA 分区,获取全局互斥,`esp_ota_begin` |
| 2 | `write(data, len)` | 循环调用 `esp_ota_write`(WiFi 通过 HTTP 流,BLE 通过 GATT 块) |
| 3 | `finalize(sha_hex, reboot)` | 从分区读回计算 SHA-256 → 比对前端 SHA → `esp_ota_end` → `esp_ota_set_boot_partition` → 可选重启 |
| - | `abort()` | 中止并清理(释放 mutex + 复位状态) |
**SHA-256 校验**: 固件写入完成后从 OTA 分区读回计算,不做增量 SHA。前端与设备端 SHA 比对一致才执行 finalize(`Esp32OtaCore::finalize()` → `esp_ota_end` → `esp_ota_set_boot_partition`),否则返回 `ESP_ERR_INVALID_VERSION`。过程中系统状态由 `OtaCoordinator` 跟踪(`ESP32_RECEIVING → ESP32_FINALIZING → ESP32_REBOOTING`)。
**回滚保护**: `esp_ota_mark_app_valid_cancel_rollback()` 在启动时标记当前固件有效。启用 `CONFIG_APP_ROLLBACK_ENABLE` 后,新固件崩溃自动回退。
## 通信协议
### SPI OTA 帧格式(底层传输)
`esp32_ota_core.cpp` 中的 `otaBuildFrame` / `otaParseFrame`:
```
| SOF(0xA5, 1B) | TYPE(1B) | LEN(2B LE) | PAYLOAD(LEN) | CRC8(1B) |
```
| TYPE | 名称 | 用途 |
|------|------|------|
| 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 | 响应:数据就绪 |
| 0x81 | RSP_WAIT | 响应:GD32 忙,请等待 |
| 0x82 | RSP_ERROR | 响应:错误 |
### CANFD 3 通道 SPI 协议
ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器):
```
| 通道编号(1B) | 扩展帧ID(4B BE) | 数据(1~64B) |
```
| 通道 | 用途 |
|------|------|
| CH0 | 主通信通道 |
| CH1 | 备用通道 |
| CH2 | 备用通道 |
### GD32 命令 ID 定义
| 域 | 命令 | ID |
|------|------|------|
| 系统 | PING / RESET / GET_VERSION / GET_STATUS / SET_STATUS | 0x0001~0x0005 |
| Flash | ERASE / WRITE / READ / VERIFY | 0x1001~0x1004 |
| OTA | START / DATA / END / ABORT | 0x2001~0x2004 |
| 传感器 | READ / CONFIG | 0x3001~0x3002 |
| Bootloader | ENTER / JUMP_APP | 0xF001~0xF002 |
## WiFi 工作模式
支持 AP/STA 双模式,启动时优先 STA,超时自动回退 AP。
### AP 模式(设备当热点)
| 参数 | 值 |
|------|------|
| SSID | `ESP32_OTA_RELAY` |
| 密码 | `12345678` |
| IP | 192.168.31.1 |
| 信道 | 6 |
| 最大客户端 | 4 |
### STA 模式(连接路由器)
| 参数 | 值 |
|------|------|
| SSID | `004舰桥` |
| 连接超时 | 30 秒 |
| 最大重试 | 5 次(超限后自动切换 AP 模式) |
| IP 分配 | DHCP(可切换静态 IP 192.168.31.32)|
| 国家码 | CN(允许信道 1-13)|
**冷启动优化**: 复位原因为 POWERON/BROWNOUT 时,清除 WiFi NVS 缓存 + PHY 校准数据,增加 500ms 启动延迟,防止长时间断电后校准残留导致连接失败。
### mDNS 发现
设备在局域网内通过 `esp32-ota-relay.local` 可发现(需 `managed_components/espressif__mdns` 组件)。
## BLE GATT 服务
基于 **NimBLE** 协议栈,设备名 `ESP32-OTA-RELAY`,ATT MTU 512 字节,支持 GD32 和 ESP32 两种 OTA 目标。
| UUID | 特征 | 属性 | 用途 |
| 分区 | 偏移 | 大小 | 用途 |
|------|------|------|------|
| 0xFFFF | Service | - | OTA GATT 服务 |
| 0x0001 | CTRL | Write | 控制命令:`0xF7`=触发GD32中继, `0xF8+32B SHA`=ESP32 OTA, `0xF9+32B SHA`=仅SHA校验(不入库/不重启), `0xFA`=重启ESP32 |
| 0x0002 | DATA_IN | Write | 固件数据块:`cmd(1)+seq(2)+total_size(4)+payload` |
| 0x0003 | DATA_OUT | Notify | 推送:ACK(窗口确认, 0xA1+8B) / FINISH(阶段事件, 0xB1+6B) / SHA256(0xC1+33B) |
| 0x0004 | VERSION | Read | 固件版本字符串 |
| nvs | 0x9000 | 24KB | 配置 + OTA 元数据(`ota_meta`:total/stored/last_seq/checksum) |
| otadata | 0xF000 | 8KB | AB 启动标志 |
| gd32_ota | 0x11000 | 252KB | GD32/电机固件缓存(互斥共用) |
| app0 / app1 | 0x50000 / 0x190000 | 各 1.25MB | ESP32 AB 分区(回滚保护) |
| spiffs | 0x2D0000 | 1.06MB | 用户数据 |
### BLE 数据传输
## 架构与任务
- **ACK 窗口**: 每 32 包等待一次 DATA_OUT ACK(带累计字节数验证)
- **窗口重传**: ACK 超时或字节数不足时重发整个窗口(最多 3 次)
- **乱序重排**: Reorder buffer(32 槽)吸收 BLE 窗口内乱序到达的包,按 seq 排序后顺序写入 `Esp32OtaCore`
- **ESP32 收尾**: BLE 传输完成后由前端发 `0xF8 + 32B 文件 SHA-256`,设备端读回 OTA 分区做 SHA 比对,通过后完成 `esp_ota_end`、设置启动分区并重启;`0xF9` 命令仅做 SHA 比对不入库(预检),FINISH 仅上报完成/重启/错误
- **GD32 触发**: 传输完成后自动发 `0xF7` 触发 SPI 中继
- **合并包**: NimBLE 可能将多个 writeWithoutResponse 合并到同一 mbuf,接收端支持一次解析多个数据包
## HTTP API (WiFi WebUI)
| 方法 | 端点 | 功能 | 请求体 | 响应 |
|------|------|------|--------|------|
| POST | `/ota/upload` | 上传固件 | JSON 头 + binary 数据 | `{"status":"ok", "target":"esp32"}` |
| POST | `/ota/update` | 完成 ESP32 OTA | `{"sha256":"...","verify_only":true/false}` | `{"status":"ok","sha256":"..."}` |
| POST | `/ota/relay` | 触发 GD32 中继 | - | `{"status":"ok"}` |
| GET | `/system/status` | 系统状态 JSON | - | 含版本/WiFi/OTA/温度/堆/GD32 版本及**OtaCoordinator 系统级状态**等 |
| GET | `/logs` | 拉取调试日志 | - | JSON 数组字符串 |
| POST | `/can/send` | 发送 CAN 帧 | `{"ch":0,"id":123,"dlc":8,"data":"0102..."}` | `{"status":"ok","recv":"..."}` |
| POST | `/spi/send` | SPI 直接发送 | `{"type":"01","data":"00AC","timeout_ms":1000}` | `{"status":"ok","resp_type":".."}` |
| POST | `/restart` | 重启 ESP32 | - | 500ms 后 `esp_restart()` |
### POST /ota/upload 协议
通过 JSON 头中的 `type` 字段自动区分目标,传输层通过 **OtaCoordinator** 统一管理系统状态:
```json
// ESP32 OTA
{"type": "esp32", "total_size": 524288}
<binary firmware data...>
// GD32 OTA
{"type": "gd32", "total_size": 131072}
<binary firmware data...>
```
接入层 server/: wifi_ota(STA/AP+路由表) · http_handlers(22 路由) · ble_ota*(GATT/窗口ACK/reorder/池)
协调层 ota/: OtaCoordinator(9 态单例, 唯一权威入口, 忙态看门狗 150s, epoch 保护) · Esp32OtaCore
执行层 relay/ storage/: OtaRelay(7 态事件状态机) · Gd32FlashStore(擦除水位/NVS meta/SHA/包身份校验)
monitor/: MotorOtaRelay(0xD0/D1/D2 喂数)
底层 transport/: SpiMaster(Link v2+worker 串行) · protocol(AES/nonce) · canfd_bridge(0xC0~C8)
横切 monitor/: health_monitor · debug_log(3 路) · can_feedback_task · cpu_stats · task_stack 等
```
- **ESP32 模式**: `OtaCoordinator::beginEsp32Ota()` → `Esp32OtaCore::write()` 流式写入 OTA 分区 → 前端调 `/ota/update` 收尾
- **GD32 模式**: `OtaCoordinator::beginGd32Storage()` → 写入 `gd32_ota` 分区 → `OtaCoordinator::completeGd32Storage()` → 计算 SHA-256 → 自动触发 SPI 中继
| 任务 | 优先级 | 栈 | 职责 |
|------|-------|------|------|
| ota_relay | 23 | 2560 | GD32 转发状态机 |
| wifi_ota / ble_ota | 22 | 3072/2560 | WiFi/HTTP 与 NimBLE |
| health_mon | 21 | 3072 | 温度 1s + 5s 巡检 |
| ble_data_worker | 12 | 2560 | BLE 数据排空 |
| spi_worker | 8 | 5120 | **唯一持 SPI**,串行 Job 池(7) |
| motor_feed | 7 | 4096 | 电机 OTA 0xD1 喂数 |
| wifi_reconnect / can_fb | 5/4 | 2560/4096 | 重连退避 / 0xC5(250ms)·0xC4(1s) 周期查询 |
| cpu_stats / uart_rx | 1 | 3072 | 1Hz 统计 / 串口行→/logs |
### curl 示例
## 核心机制速记(细节见白皮书对应章节)
```bash
# 查询系统状态
curl http://192.168.31.1/system/status | jq
- **SPI Link v2**(§5):帧 = 16B 头(SOF A5 5A/VER 02/FLAGS/SESSION 4LE/TX_SEQ/ACK_SEQ/CREDIT/TYPE/LEN)+ 载荷 ≤236B + CRC32-MPEG2。延迟一拍全双工:transact 两步法(发送丢弃陈旧 RX → poll 按 DATA_READY 边沿读真实响应)。credit 8/8 背压,credit=0 死锁有 PB6 强制放行 + `resetCredit()` 双破除。透明重试 `kTxRetryMax=3`(同 TX_SEQ,GD32 DUP-SEQ 去重兜底)。CS 事务间隔 ≥1ms + 快速类型(0xA1/0xA3/0xC0~C5/C7/D1/F7)禁盲读防撕裂;3 连败熔断 2s;session=0 自动 HELLO 重同步(5s 冷却)。
- **安全握手**(§6):GD32 OTA 数据面——0x00 0xAC → 36B challenge(UID 12B + nonce 回显 8B + AES(MASTER_KEY,K_session) 16B)→ `K_session = AES-128-ECB(MASTER_KEY, nonce_server)` → AES(K_session, nonce^0x55) → 0xBA 0xC0。nonce 由 scrambleGenerate(tick/栈址/0x12345678 ⊗ 每开机 RNG 扰码常数)生成。
- **GD32 OTA 中继**(§7):RELAY_IDLE→HANDSHAKING(20×1.6s)→SIZE_NEG(poll 500ms×120,GD32 擦 Flash)→TRANSFERRING(64B 包=4B session+2B seq+48B AES+4B if_end+4B CRC32(key 混入);预算 10s+25ms/包 ≤120s;每 32 包健康检查;单包 5 次 DATA×50ms,末包 ACK=0xF0F0F0F0)→FINISHING(0x0A 0xAC×10 重试 + PING 实证 APP 存活)。失败必发 SPI CANCEL 清 GD32 半会话。**包级防呆**:Cortex-M 向量表判 kApp 才放行(BOOT 包禁入 APP 区)。
- **ESP32 本机 OTA**(§7.4):`OTA_WITH_SEQUENTIAL_WRITES` 边写边擦;finalize 分区读回算 SHA-256 与前端比对,不符回 `ESP_ERR_INVALID_VERSION`+设备 SHA;`mark_app_valid_cancel_rollback()` 回滚保护;期间 `setSpiPaused(true)`。
- **BLE OTA**(§8):GATT 0xFFFF/0x0001~0x0004,MTU 512,载荷 480B;**窗口 ACK 64**(累计字节校验)+ reorder 64 槽 + 负 ACK 续传(64 包/200ms 节流);DATA_IN 池 8×512B(曾 32KB 致收尾 OOM);CTRL 0xF7 触发 GD32 中继 / 0xF8 ESP32 收尾 / 0xFA 重启 / 0xFB 中止(0xF9 预留未实现);BLE 与 WiFi OTA 软挂起共存(不 deinit 协议栈)。
- **CANFD 桥接**(§10):0xC0 单帧(最短 5B)/0xC1 统计+ID 快照分页(28B 头+条目 8+dlc,bus_state bit0~5,bit5=Silent)/0xC2 收模式(全收/过滤/CAN0→1/2 桥接)/0xC7 跨通道突发(ISR 内装载全部邮箱后回**一个** ACK,组内偏差 <1ms;单次 ≤16 帧,HTTP ≤24 帧,id=(node<<16)|suffix)/0xC8 收发器 Silent(TCAN1057AV S 脚 PA8/PB14/PB9,接收照常,不持久化,发送回 E6)。错误码 E0~E7:E2/E3(BUS_OFF/TEC>96) fatal 中止批量,E4 邮箱满,E5 电机 OTA 残留(自动 CANCEL→resetCredit→重试 START 自愈),E6 Silent,E7 被电机 OTA 独占。
- **电机 OTA**(§11):/motor/ota 上传(整区强制擦除)→ 0xD0 START(fire-and-forget)→ feedTask 0xD1 {绝对 offset+≤232B}(重传幂等;120s 整体超时;8 败/150 WAIT → QUERY)→ 0xD2 进度。
- **监控**(§12):健康 4 级(80°C/100°C;RSSI -80/-85/-88;堆 10KB)门控 OTA;日志三路(UART 3M 非阻塞 / UDP / 环 64→16 槽 →/logs)+ UART0 RX 泵;遥测 0xC5 电机状态 27 槽×14 页、0xC4 ADC(VCC=vin×11)、0xC6 电机版本、0xA3 初始化状态 13 模块故障码;CPU/任务栈/堆低水位快照/启动自检 16 位位图全进 /system/status。
# 发送 CAN 帧
curl -X POST http://192.168.31.1/can/send \
-H "Content-Type: application/json" \
-d '{"ch":0,"id":0x18DA00F1,"dlc":8,"data":"0102030405060708"}'
## HTTP API(22 条路由 / 21 路径,无认证,CORS 全开)
# SPI 直接发送
curl -X POST http://192.168.31.1/spi/send \
-H "Content-Type: application/json" \
-d '{"type":"01","data":"00AC","timeout_ms":1000}'
| 方法 | 路径 | 功能 |
|------|------|------|
| POST | /ota/upload | 固件上传(JSON 头 `{"type":"esp32"\|"gd32","total_size":N}` + 二进制流式) |
| POST | /ota/update | ESP32 OTA 收尾 `{sha256, verify_only}`(SHA 比对→set_boot→重启) |
| POST | /ota/relay | 触发 GD32 中继(前端 SHA + kApp 身份校验) |
| POST | /restart | 重启 |
| GET | /logs | 日志环 chunked 流式 |
| GET | /system/status | 全量状态(?lite=1 / ?layout=1;含 spi_link 统计、启动自检、GD32 版本/ADC) |
| GET | /system/probe · /system/heap_low_log | GD32 存活探测 / 堆低水位快照 |
| POST | /can/send_batch · /can/send_burst | 批量发送(≤128 帧)/ 跨通道突发(≤24 帧) |
| GET·POST | /can/feedback · /can/mode · /can/silent | 快照查询 / 收模式 / Silent 只听 |
| POST | /spi/send | 原始 SPI 帧透传 |
| GET | /motor/state(?bin=1) · /motor/version | 电机状态二进制协议 / 版本 |
| POST·GET | /motor/ota · /motor/ota/relay · /motor/ota/status · /motor/ota/cancel | 电机 OTA 全流程 |
| GET | /spi/speedtest | SPI 性能测试(临时) |
# 获取日志
curl http://192.168.31.1/logs
```
WiFi:STA 优先(SSID「004舰桥」,FAST_SCAN,CN 1-13),冷启动退避 2s×retry≤3 / 否则 1s,5 次耗尽或 30s 超时降级 AP(`ESP32_OTA_RELAY`/`12345678`,192.168.31.1,信道 6,≤4 台)。**无 mDNS**,访问靠固定 IP。
## WebUI 工具
## BLE GATT 速查
### WiFi OTA 控制台 (`tools/index.html`)
| UUID | 特征 | 说明 |
|------|------|------|
| 0xFFFF | Service | 设备名 ESP32-OTA-RELAY |
| 0x0001 | CTRL (Write) | 0xF7/0xF8/0xFA/0xFB(0xF9 预留) |
| 0x0002 | DATA_IN (WriteNoRsp) | cmd 1B(0x00 GD32/0x01 ESP32)+seq u16+total u32+payload ≤480B |
| 0x0003 | DATA_OUT (Notify) | 0xA1 ACK 8B / 0xB1 FINISH 6B / 0xC1 SHA 35B |
| 0x0004 | VERSION (Read) | 版本+构建时间 |
- ESP32 固件上传与 OTA 升级(含 SHA-256 前端校验)
- GD32 固件上传(支持 .bin/.hex)与 SPI 中继触发
- 实时 OTA 流程阶段显示(6 阶段进度追踪,含 **OtaCoordinator 系统级状态**)
- CAN 帧手动发送面板
- SPI 直接发送面板
- 系统状态监控(温度、RSSI、空闲堆、最小堆、GD32 APP/BOOT 版本、OTA 缓存、**协调器系统状态**)
- 调试日志面板(HTTP `/logs` 轮询,按级别过滤)
- 一键重启 ESP32
### BLE OTA 控制台 (`tools/ble_ota.html`)
- **Web Bluetooth API** 连接(需 Chrome/Edge/Android Chromium)
- GD32 与 ESP32 双目标切换
- 一键刷写(自动重连)
- 窗口 ACK 重传机制(最多 3 次重试)
- SHA-256 前端+设备端比对校验(校验面板显示)
- FINISH 阶段事件通知
- 设备重启 + 自动重连(退避 1.6x 指数,最多 12 次)
## 健康监控
### 健康状态等级
| 等级 | 名称 | 触发条件 | OTA 影响 |
|------|------|----------|----------|
| 0 | SYS_NORMAL | 正常 | 允许 |
| 1 | SYS_WARNING | 温度 ≥ 80°C 或 RSSI < -75 dBm | 允许(带警告)|
| 2 | SYS_CRITICAL | RSSI < -85 dBm | ESP32 OTA 禁止,SPI 中继允许 |
| 3 | SYS_SAFE_SHUTDOWN | 温度 ≥ 100°C | 所有 OTA 禁止 |
### 监控指标
- **温度**: 1 秒定时器读取内部温度传感器(20°C~80°C 量程)
- **WiFi RSSI**: WiFi 任务每 10 秒检查一次
- **空闲堆**: 每 5 秒巡检,低于 10KB 触发警告
### OTA 前健康检查
- `checkBeforeOta()`: 完整检查(温度 + RSSI + WiFi 连接状态)
- `checkBeforeOtaRelay()`: 仅检查温度(中继阶段不依赖 WiFi)
## 诊断日志
三条并行输出路径:
1. **UART0**: GPIO21(TX), GPIO20(RX), 115200 baud — 本地串口调试
2. **UDP 单播**: `192.168.31.3:6666` — 独立调试接收端
3. **环形缓冲区**: 64 槽 × 256 字节,互斥保护 — 供 HTTP `GET /logs` 轮询拉取
日志级别: `E`(错误) / `W`(警告) / `I`(信息) / `D`(调试),运行时可调。
UDP socket 延迟创建(首次 `send()` 时才分配),WiFi 未连接时 `sendto()` 静默跳过。
**当前 WebUI 日志面板通过 HTTP `/logs` 轮询获取日志,不直接监听 UDP 6666。**
## 配置参数 (`main/include/app_config.h`)
| 分类 | 参数 | 默认值 | 说明 |
|------|------|--------|------|
| WiFi | `CONFIG_WIFI_MODE` | 1 (STA) | 0=AP, 1=STA |
| WiFi | `WIFI_STA_SSID` | "004舰桥" | 路由器名称 |
| WiFi | `WIFI_AP_SSID` | "ESP32_OTA_RELAY" | 热点名称 |
| WiFi | `WIFI_RSSI_WARNING` | -75 dBm | RSSI 警告阈值 |
| WiFi | `WIFI_RSSI_CRITICAL` | -85 dBm | RSSI 危险阈值 |
| OTA | `OTA_BLOCK_SIZE` | 8192 | 读写块大小 |
| OTA | `OTA_MAX_FIRMWARE_SIZE` | 216KB | GD32 固件上限 |
| OTA | `GD32_OTA_PACKET_SIZE` | 48 | 每包载荷字节 |
| OTA | `OTA_RESEND_RETRIES` | 10 | 单包最大重发次数 |
| OTA | `OTA_PACKET_HEALTH_CHECK_INTERVAL` | 32 | 每 N 包健康检查 |
| OTA | `OTA_PACKET_DELAY_MS` | 5 | 每包发送后延时(ms) |
| OTA | `OTA_WIFI_RSSI_CRITICAL` | -88 dBm | OTA 期间 RSSI 危险阈值 |
| OTA | `OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS` | 15000 | OTA 前 RSSI 采样最大时效 |
| 阈值 | `TEMP_WARNING` | 80°C | 温度警告阈值 |
| 阈值 | `TEMP_CRITICAL` | 100°C | 温度危险阈值 |
| 阈值 | `HEAP_WARNING_MIN` | 10240 B | 堆最小值警告 |
| 调试 | `DEBUG_LOG_IP` | "192.168.31.3" | UDP 日志目标 IP |
| 调试 | `DEBUG_LOG_PORT` | 6666 | UDP 日志端口 |
| BLE | `BLE_DEVICE_NAME` | "ESP32-OTA-RELAY" | BLE 广播名称 |
| BLE | `BLE_MTU_SIZE` | 512 | ATT MTU |
| BLE | `BLE_OTA_ACK_WINDOW` | 32 | ACK 窗口大小 |
| BLE | `BLE_OTA_CHUNK_PAYLOAD` | 480 | BLE 单包载荷 |
| BLE | `BLE_OTA_REORDER_WINDOW` | 32 | 乱序重排缓冲区大小 |
| BLE | `BLE_OTA_CTRL_VERIFY_SHA256` | 0xF9 | SHA-256 预检命令字 |
## 安全配置
| 参数 | 位置 | 说明 | 状态 |
|------|------|------|------|
| `MASTER_KEY[16]` | app_config.h | AES-128 主密钥 | ⚠️ 占位值,生产环境需更换 |
| `SCRAMBLE_CONST[16]` | Protocol 类运行时生成 | Nonce 搅伴常量 | 每次启动由硬件 RNG 随机生成 |
| `GD32_CHIP_UID[12]` | 运行时从握手 Challenge 包解包 | GD32 芯片 UID | 不再使用编译期常量 |
| `K_session` | 运行时派生 | `AES(MasterKey, nonce_server)` | 每次 OTA 重新生成 |
| SHA-256 | 最终校验 | 从分区读回计算,前端比对 | WiFi/BLE/GD32 均已实现 |
## 构建与部署
### 一、本地编译(Windows PowerShell + ESP-IDF v6.0)
首次编译前需设置工具链环境变量(PowerShell):
## 构建与烧录
```powershell
# ── 设置 ESP-IDF v6.0 工具链环境 ──
$env:IDF_VERSION = "6.0"
$env:IDF_PYTHON_ENV_PATH = "C:\Espressif\tools\python\v6.0\venv"
$env:IDF_TOOLS_PATH = "C:\Espressif\tools"
$env:IDF_PATH = "F:\.espressif\v6.0\esp-idf"
$env:ESP_ROM_ELF_DIR = "C:\Espressif\tools\esp-rom-elfs\20241011"
# ── 添加工具链到 PATH ──
$env:PATH = "C:\Espressif\tools\ccache\4.12.1\ccache-4.12.1-windows-x86_64;" +
"C:\Espressif\tools\cmake\4.0.3\bin;" +
"C:\Espressif\tools\esp-clang\esp-20.1.1_20250829\esp-clang\bin;" +
"C:\Espressif\tools\idf-exe\1.0.3\;" +
"C:\Espressif\tools\ninja\1.12.1\;" +
"C:\Espressif\tools\riscv32-esp-elf\esp-15.2.0_20251204\riscv32-esp-elf\bin;" +
"C:\Espressif\tools\riscv32-esp-elf\esp-15.2.0_20251204\riscv32-esp-elf\riscv32-esp-elf\bin;" +
"C:\Espressif\tools\python\v6.0\venv\Scripts;" + $env:PATH
```
编译命令:
```powershell
# ── 进入项目目录 ──
# ESP-IDF v6.0 环境(IDF_TOOLS_PATH/IDF_PATH/PATH,或用 .devcontainer)
Set-Location "G:\Robot_v2\DCU\ESP32_COMM"
# ── 全量编译(自动生成 version.h,确认零 error/warning)──
& "C:\Espressif\tools\python\v6.0\venv\Scripts\python.exe" `
"F:\.espressif\v6.0\esp-idf\tools\idf.py" build
# ── 目标文件 ──
# build/ESP32_COMM.bin ← OTA 烧录用
# build/bootloader/bootloader.bin
# build/partition_table/partition-table.bin
python "F:\.espressif\v6.0\esp-idf\tools\idf.py" build # 产物 build/ESP32_COMM.bin
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 flash monitor # 串口烧录(恢复手段)
```
### 二、烧录方式
- **发版**:手动改 `main/include/version.h`(版本号+变更摘要注释+GIT_HASH);构建时间自动生成
- **网页升级**:控制台上传 `build/ESP32_COMM.bin` → SHA 比对 → 重启(前端按 banner「OTA Relay … 启动」+ uptime<120s 判定恢复)
- **GD32**:上传 .bin/.hex → /ota/relay → 六阶段转发 → PING 确认;**电机**:/motor/ota → /motor/ota/relay
#### 方式 A:串口烧录(首次 / 恢复)
## 网页控制台
连接 ESP32-C3 到串口(默认 COM15),直接在 PowerShell 中烧录:
`tools/` 已移出仓库(快照 `.cache/ws_era_frontend_backup/`),`tools/serve.bat`(http.server:8080)托管,跨源调 REST API,改网页无需重编固件。页面:**index.html** OTA 控制台(双通道、阶段条、启动自检面板、堆/任务抽屉、日志);**motor.html** 电机控制台(三轮询链路互不阻塞、波形);**can.html / can_monitor.html**(DBC、报文编辑、收发监控)。
```powershell
# 全量烧录(固件 + bootloader + 分区表)
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 flash
## 安全注意与已知限制
# 仅烧录应用程序(分区不变时快速更新)
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 app-flash
- ⚠️ **HTTP 无认证**(Bearer token 方案已设计未实施);WiFi 凭据与 MASTER_KEY 占位值明文入仓(2026-08 审计项,待迁 NVS/Kconfig)
- ⚠️ AES-ECB 确定性模式;无固件签名(仅 SHA-256 完整性 + GD32 侧挑战-应答)
- Flash 必须锁 DIO(QIO 会致 OTA 读回 SHA 必错);SPI ≥16MHz 全双工不可行
- tools/ 不在仓库;`spi_link/` 为三工程共享兄弟目录(`../../../spi_link/`),协议改动需三侧同步
# 烧录 + 监控
& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 flash monitor
```
## 文档同步说明(HTML ↔ README)
#### 方式 B:WiFi OTA 烧录(ESP32 自升级,无需串口线)
前提:ESP32 已通过 WiFi 联网(或 AP 模式),上位机与 ESP32 在同一网络。
**步骤 1 — 启动 WebUI 服务**:
```powershell
# 在项目目录下运行
tools\serve.bat
# 浏览器打开 http://localhost:8080/tools/index.html
```
**步骤 2 — 连接设备**:
- 在页面顶部输入 ESP32 IP 地址(AP 模式为 `192.168.31.1`,STA 模式查路由器分配 IP)
- 点击 **"测试"** 确认连通,状态指示应变为绿色
**步骤 3 — 上传固件**:
- 在 **"ESP32 自身升级"** 卡片中,拖拽或点击选择 `build/ESP32_COMM.bin`
- 页面自动读取文件大小并计算 SHA-256
- 点击 **"上传并升级 ESP32"**
- WebUI 自动执行:上传 → OTA 写入 → SHA-256 校验 → 设置启动分区 → 重启
- 重启后页面连接断开,ESP32 以新固件启动(约 10~15 秒)
> ⚠️ 如果上传后 ESP32 无法正常启动(反复重启),说明固件有严重 bug,此时需用 **串口烧录** 恢复。
#### 方式 C:BLE OTA 烧录(无需 WiFi)
前提:上位机(手机/PC)支持 Web Bluetooth(Chrome / Edge / Android Chromium)。
- 浏览器打开 `tools/ble_ota.html`
- 点击 **"连接设备"**,在设备选择器中选择 `ESP32-OTA-RELAY`
- 选择固件文件,点击 **"一键刷写"**
- 通过 GATT Notify 实时查看进度和 SHA-256 校验结果
### 三、GD32 SPI OTA 中继烧录
GD32 固件通过 ESP32 中转烧录,不需要串口连接 GD32:
**步骤 1** — 在 ESP32 WebUI 的 **"GD32 SPI 中继"** 卡片中上传 GD32 固件(.bin 或 .hex),上传过程由 **OtaCoordinator** 管理系统状态(`GD32_STORING → GD32_STORED`)
**步骤 2** — 上传完成后系统自动计算 SHA-256 并校验完整性,通过后由 **OtaRelay 事件驱动状态机** 执行转发
**步骤 3** — ESP32 通过六阶段安全握手协议(HANDSHAKING → SIZE_NEG → TRANSFERRING → FINISHING → DONE)将固件逐包转发给 GD32 Bootloader
**步骤 4** — 转发完成后 GD32 APP 启动,ESP32 轮询 PING 确认 APP 存活(最多 15 秒)
> 也可手动点击 **"触发 GD32 OTA 中继"** 按钮重新触发转发(通过 `OtaCoordinator::triggerGd32Relay()` 投递)。
### 四、Python 辅助工具
```powershell
# 串口日志采集(COM15, 5 分钟)
python serial_monitor.py
# OTA 上传测试(HTTP)
python ota_upload_test.py <esp32_ip> <firmware.bin>
```
### 五、开发容器
`.devcontainer/` 提供 Docker 容器,含 ESP-IDF v6.0 工具链和 riscv32-esp-elf 编译器,支持 VS Code Remote-Containers 开箱即用。
## SDK 配置 (`sdkconfig.defaults`)
```ini
CONFIG_ESP_WIFI_RX_BA_WIN=6
CONFIG_ESP_WIFI_TX_BA_WIN=6
CONFIG_LWIP_MAX_SOCKETS=10
CONFIG_LWIP_TCP_WND_DEFAULT=11480
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
```
## OTA 回滚保护
启用 `CONFIG_APP_ROLLBACK_ENABLE` 后:
1. 新固件首次启动必须在主循环前调用 `esp_ota_mark_app_valid_cancel_rollback()`
2. 若新固件在标记前崩溃/重启,bootloader 自动回退到上一个有效分区
3. `main.cpp:145` 在任务创建完成后执行此标记
## 固件版本管理
位于 `cmake/gen_version_script.cmake`,在每次 `idf.py build` 时自动执行:
- **版本号**: `0.3.12`(手动修改 CMake 文件中的 MAJOR/MINOR/PATCH)
- **构建时间**: 自动写入 `%Y-%m-%d %H:%M:%S`
- **Git Hash**: 自动从 `git rev-parse --short HEAD` 获取 (当前 `5b2c5f8`)
- 输出到 `main/include/version.h`,仅在内容变化时写入以避免不必要的重编译
- [docs/project_introduction.html](docs/project_introduction.html) = 完整白皮书(算法字节级细节、状态机、SVG 图解、事故沉淀);本 README = 浓缩速查版,**内容同源**
- **同步规则**:凡影响功能清单 / 协议与常量数值 / 状态机 / 端点表 / 实现状态的代码变更,须**同时更新两份文档**;变更摘要写 `main/include/version.h` 注释;细节以 HTML 为准;两文件顶部「文档同步版本」随变更递增(当前 v0.5.2 / 2026-10-11)
-71
View File
@@ -1,71 +0,0 @@
# gen_version_script.cmake - 构建时执行的独立脚本
# 通过 add_custom_target 在每次 build 时调用,确保版本信息始终最新
#
# 需要传入的参数:
# SOURCE_DIR - 项目根目录 (ESP32_COMM)
# OUTPUT_FILE - 输出的 version.h 路径
# 主/次版本号(手动修改,大版本时改)
set(FW_VERSION_MAJOR 0)
set(FW_VERSION_MINOR 3)
# PATCH 自动递增:git 总提交次数,每次新提交 +1
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-list --count HEAD
WORKING_DIRECTORY ${SOURCE_DIR}
OUTPUT_VARIABLE FW_VERSION_PATCH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
else()
set(FW_VERSION_PATCH 0)
endif()
# 编译时间
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")
# Git 短 hash
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD
WORKING_DIRECTORY ${SOURCE_DIR}
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
else()
set(GIT_HASH "unknown")
endif()
# 生成版本头文件
set(VERSION_HEADER_CONTENT
"// Auto-generated version info - DO NOT EDIT
#pragma once
// 固件版本
#define FW_VERSION_MAJOR ${FW_VERSION_MAJOR}
#define FW_VERSION_MINOR ${FW_VERSION_MINOR}
#define FW_VERSION_PATCH ${FW_VERSION_PATCH}
#define FW_VERSION_STRING \"${FW_VERSION_MAJOR}.${FW_VERSION_MINOR}.${FW_VERSION_PATCH}\"
// 编译时间
#define BUILD_TIME \"${BUILD_TIME}\"
// Git 信息
#define GIT_HASH \"${GIT_HASH}\"
")
# 仅在内容变化时写入,避免不必要的重编译
if(EXISTS ${OUTPUT_FILE})
file(READ ${OUTPUT_FILE} EXISTING_CONTENT)
if(NOT "${EXISTING_CONTENT}" STREQUAL "${VERSION_HEADER_CONTENT}")
file(WRITE ${OUTPUT_FILE} "${VERSION_HEADER_CONTENT}")
message(STATUS "Version header updated: v${FW_VERSION_MAJOR}.${FW_VERSION_MINOR}.${FW_VERSION_PATCH} (${GIT_HASH})")
endif()
else()
file(WRITE ${OUTPUT_FILE} "${VERSION_HEADER_CONTENT}")
message(STATUS "Version header generated: v${FW_VERSION_MAJOR}.${FW_VERSION_MINOR}.${FW_VERSION_PATCH} (${GIT_HASH})")
endif()
-8
View File
@@ -1,8 +0,0 @@
idf_component_register(
SRCS "cJSON.c"
INCLUDE_DIRS "."
REQUIRES
)
# ESP-IDF uses no DLL, export all symbols
target_compile_definitions(${COMPONENT_LIB} PUBLIC CJSON_HIDE_SYMBOLS=0)
File diff suppressed because it is too large Load Diff
-306
View File
@@ -1,306 +0,0 @@
/*
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
#ifndef cJSON__h
#define cJSON__h
#ifdef __cplusplus
extern "C"
{
#endif
#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
#define __WINDOWS__
#endif
#ifdef __WINDOWS__
/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
For *nix builds that support visibility attribute, you can define similar behavior by
setting default visibility to hidden by adding
-fvisibility=hidden (for gcc)
or
-xldscope=hidden (for sun cc)
to CFLAGS
then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
*/
#define CJSON_CDECL __cdecl
#define CJSON_STDCALL __stdcall
/* export symbols by default, this is necessary for copy pasting the C and header file */
#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_EXPORT_SYMBOLS
#endif
#if defined(CJSON_HIDE_SYMBOLS)
#define CJSON_PUBLIC(type) type CJSON_STDCALL
#elif defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
#elif defined(CJSON_IMPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
#endif
#else /* !__WINDOWS__ */
#define CJSON_CDECL
#define CJSON_STDCALL
#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
#else
#define CJSON_PUBLIC(type) type
#endif
#endif
/* project version */
#define CJSON_VERSION_MAJOR 1
#define CJSON_VERSION_MINOR 7
#define CJSON_VERSION_PATCH 19
#include <stddef.h>
/* cJSON Types: */
#define cJSON_Invalid (0)
#define cJSON_False (1 << 0)
#define cJSON_True (1 << 1)
#define cJSON_NULL (1 << 2)
#define cJSON_Number (1 << 3)
#define cJSON_String (1 << 4)
#define cJSON_Array (1 << 5)
#define cJSON_Object (1 << 6)
#define cJSON_Raw (1 << 7) /* raw json */
#define cJSON_IsReference 256
#define cJSON_StringIsConst 512
/* The cJSON structure: */
typedef struct cJSON
{
/* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
struct cJSON *next;
struct cJSON *prev;
/* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
struct cJSON *child;
/* The type of the item, as above. */
int type;
/* The item's string, if type==cJSON_String and type == cJSON_Raw */
char *valuestring;
/* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
int valueint;
/* The item's number, if type==cJSON_Number */
double valuedouble;
/* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
char *string;
} cJSON;
typedef struct cJSON_Hooks
{
/* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
void *(CJSON_CDECL *malloc_fn)(size_t sz);
void (CJSON_CDECL *free_fn)(void *ptr);
} cJSON_Hooks;
typedef int cJSON_bool;
/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_NESTING_LIMIT
#define CJSON_NESTING_LIMIT 1000
#endif
/* Limits the length of circular references can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_CIRCULAR_LIMIT
#define CJSON_CIRCULAR_LIMIT 10000
#endif
/* returns the version of cJSON as a string */
CJSON_PUBLIC(const char*) cJSON_Version(void);
/* Supply malloc, realloc and free functions to cJSON */
CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
/* Render a cJSON entity to text for transfer/storage. */
CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
/* Render a cJSON entity to text for transfer/storage without any formatting. */
CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
/* Delete a cJSON entity and all subentities. */
CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
/* Returns the number of items in an array (or object). */
CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
/* Get item "string" from object. Case insensitive. */
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
/* Check item type and return its value */
CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
/* These functions check the type of an item */
CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
/* These calls create a cJSON item of the appropriate type. */
CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
/* raw json */
CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
/* Create a string where valuestring references a string so
* it will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
/* Create an object/array that only references it's elements so
* they will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
/* These utilities create an Array of count items.
* The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
/* Append item to the specified array/object. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
* WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
* writing to `item->string` */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
/* Remove/Detach items from Arrays/Objects. */
CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
/* Update array items. */
CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
/* Duplicate a cJSON item */
CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
* need to be released. With recurse!=0, it will duplicate any children connected to the item.
* The item->next and ->prev pointers are always zero on return from Duplicate. */
/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
* case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
* The input pointer json cannot point to a read-only address area, such as a string constant,
* but should point to a readable and writable address area. */
CJSON_PUBLIC(void) cJSON_Minify(char *json);
/* Helper functions for creating and adding items to an object at the same time.
* They return the added item or NULL on failure. */
CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
/* When assigning an integer value, it needs to be propagated to valuedouble too. */
#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
/* helper for the cJSON_SetNumberValue macro */
CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
#define cJSON_SetBoolValue(object, boolValue) ( \
(object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
(object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
cJSON_Invalid\
)
/* Macro for iterating over an array or object */
#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
CJSON_PUBLIC(void) cJSON_free(void *object);
#ifdef __cplusplus
}
#endif
#endif
+2
View File
@@ -8,10 +8,12 @@
idf_component_register(
SRCS
"../../main/transport/spi_master.cpp"
"../../../spi_link/spi_link_v2.cpp"
"../../main/transport/canfd_bridge.cpp"
"../../main/transport/protocol.cpp"
INCLUDE_DIRS
"../../main/transport" # spi_master.h, canfd_bridge.h, protocol.h
"../../../spi_link" # spi_link_v2.h(三工程共享协议头)
PRIV_INCLUDE_DIRS
"../../main" # monitor/debug_log.h(仅本组件编译时使用)
"../../main/include" # app_config.h
+11 -7
View File
@@ -37,12 +37,12 @@ CONFIG_BTDM_CTRL_BLE_MAX_CONN_EFF=1
CONFIG_BTDM_BLE_MAX_CONN_EFF_EFF=1
# BLE 连接事件长度优化(允许单 CE 内传多包,提升 OTA 吞吐)
CONFIG_BT_CTRL_CE_LENGTH_TYPE_CE=y
CONFIG_BT_CTRL_CE_LENGTH_TYPE_ORIG=y
# NimBLE mbuf 池(单连接 OTA 场景缩减,省 ~7 KB)
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=16
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=8
# NimBLE OTA 窗口突发接收容量(恢复 7feef70 对应的 IDF 6.0 默认值)
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=24
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=12
# NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service,省 ~8-15 KB)
CONFIG_BT_NIMBLE_PROX_SERVICE=n
@@ -58,11 +58,15 @@ CONFIG_BT_NIMBLE_HR_SERVICE=n
CONFIG_BT_NIMBLE_BAS_SERVICE=n
CONFIG_BT_NIMBLE_DIS_SERVICE=n
# NimBLE: 传输缓冲区缩减
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
# NimBLE: 保留足够 ACL 缓冲承接窗口内突发包
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=24
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MIN_CONN_INTERVAL=12
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=16
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200
# mbedTLS
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-296
View File
@@ -1,296 +0,0 @@
# CAN 通信协议文档
> 更新日期:2026-06-29
> 涉及固件:GD32 DCU_APP + ESP32 ESP32_COMM
> SPI 时钟:4 MHz | 应答帧最大 payload:123 字节(kTxBufSize=128 - overhead=5)
---
## 1. 概述
ESP32 通过 SPI 协议与 GD32 通信,实现三路 CAN/CANFD 的收发控制。CAN 相关 SPI 命令占用 `0xC0` ~ `0xC2` 帧类型。
| 帧类型 | 名称 | 方向 | 用途 |
|--------|------|------|------|
| `0xC0` | kTypeCanSend | ESP32 → GD32 | 发送一帧 CAN 数据 |
| `0xC1` | kTypeCanFeedback | ESP32 → GD32 | 拉取不重复 ID 快照表(分页) |
| `0xC2` | kTypeCanSetMode | ESP32 → GD32 | 切换三路 CAN 接收过滤模式 |
---
## 2. SPI 协议帧格式
所有 SPI 帧遵循统一格式:
```
[SOF:1B][TYPE:1B][LEN_LO:1B][LEN_HI:1B][PAYLOAD:NB][CRC8:1B]
```
- SOF = `0xA5`
- LEN = payload 长度(小端 16 位)
- CRC8 = 对 SOF+TYPE+LEN+PAYLOAD 的校验
应答帧 TYPE:
- `0x80` = ACK(成功)
- `0x81` = WAIT(OTA 中间态)
- `0x82` = ERROR
---
## 3. 0xC0 — CAN 发送
### 请求 payload
```
[0] channel 0/1/2
[1..4] ext_id 扩展帧 ID(Big-Endian,29 位)
[5..N] data CAN 数据负载(1~64 字节)
```
### 应答 payload
```
[0] 0x00(ACK)或错误码
```
---
## 4. 0xC1 — 不重复 ID 快照表(核心协议)
每秒 ESP32 对每通道发 2 次请求(page=0 和 page=1),拉取该通道所有**不重复 CAN ID** 的最新帧快照。
### 请求 payload
```
[0] channel 0/1/2
[1] page 页号:0 = 条目 0~7,1 = 条目 8~15
```
### 应答 payload(变长,最大 123 字节)
```
[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..+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 }
]
}
```
---
## 8. 前端(tools/can_monitor.html)
### 页面结构
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 控制台
---
## 9. 资源消耗
| 资源 | 消耗 |
|------|------|
| GD32 SRAM(快照表) | 3.8KB / 128KB = 3% |
| GD32 中断开销(16 条遍历) | < 1μs |
| SPI 事务/秒 | 6 次(3 通道 × 2 页) |
| SPI 总线占用 | ~5ms/s = 0.5% |
| ESP32 RAM(缓存) | ~4KB |
| OTA 期间 CAN 轮询 | 自动暂停 |
---
## 10. 已废弃
| 项目 | 状态 | 说明 |
|------|------|------|
| `0xC3 kTypeIdSnapshot` | 保留定义但不使用 | 功能已合并到 0xC1 |
| `/can/idsnap` | 已移除 | 数据由 `/can/feedback` 统一提供 |
| `canFdPullIdSnapshot()` | 已移除 | 改用 `canFdPollFeedback()` 分页拉取 |
-694
View File
@@ -1,694 +0,0 @@
# ESP32-C3FH4 OTA 中继架构设计
## 1. 系统概述
```
[手机APP/PC] --WiFi--> [ESP32-C3FH4] --SPI--> [GD32E508VET6]
|
+--BLE---> [手机APP/PC]
```
- **ESP32 角色**:WiFi/BLE 接收端 + SPI 透传(主机)
- **GD32 角色**:OTA 执行端(从机),已有完整 OTA 流程
- **ESP32 不负责 OTA 逻辑**,只做数据转发
---
## 2. 硬件连接
### SPI 接口
| ESP32 (Master) | GD32 (Slave) | 说明 |
|---|---|---|
| GPIO6 (SPIIO0) | PA4 (NSS) | 片选 |
| GPIO7 (SPIQ) | PA5 (SCK) | 时钟 |
| GPIO8 (SPIWP) | PA6 (MOSI) | 主发从收 |
| GPIO9 (SPIHD) | PA7 (MISO) | 主收从发 |
> ⚠️ GD32E508 使用标准 SPI(PA4-NSS, PA5-SCK, PA6-MOSI, PA7-MISO)
> GD32 已内置 BOOT+APP 模式,ESP32 通过 SPI 命令直接控制进入 Bootloader,无需额外 BOOT/RST 控制引脚。
---
## 3. 通信协议设计
ESP32 与 GD32 之间的 SPI 协议完整映射 GD32 原有 CANFD OTA 流程,分为四个阶段。
### 3.0 帧格式总览
所有 SPI 帧格式统一为:
```
| SOF (1B) | CMD (1B) | LEN (2B, LE) | PAYLOAD (LEN bytes) | CRC16 (2B, LE) |
```
- **SOF**: 0xEB 固定帧头
- **CMD**: 命令码(见下表)
- **LEN**: Payload 长度(不含帧头和 CRC)
- **CRC16**: Modbus CRC16,低字节在前
> 注意:CANFD 侧(原协议)的分包为 48 bytes/packet;SPI 中继时,ESP32 接收 APP/PC 的分包可能更大,内部透传时不做分包合并,只做协议映射。
---
### 3.1 第一阶段:安全握手与认证(步骤 1-6)
目的是建立加密安全通道,防止非法刷写。
#### Step 1 & 2 — 握手发起与响应
| 方向 | Signal | 说明 |
|---|---|---|
| ESP32 → GD32 | `0x00 AC` | 发起 OTA 请求 |
| GD32 → ESP32 | `0x00 AC` | 响应信号,表示准备好进入 OTA 模式 |
**对应 SPI 帧:**
```
CMD=0xF0 (HANDSHAKE_REQ), Payload=[] → ESP32 发送 0x00 AC
CMD=0xF0 (HANDSHAKE_REQ), Payload=[] ← GD32 回复 0x00 AC
```
#### Step 3 — 主机挑战(发送随机数)
| 方向 | Signal | 说明 |
|---|---|---|
| ESP32 → GD32 | `nonce_server` (16 bytes) | 16字节随机数,用于生成会话密钥 |
**搅伴算法**(时间 + 内存地址 + rand → 3轮单字节 → 2轮4字节)由 ESP32 实现,生成方法需与 GD32 预置的主密钥匹配。
**对应 SPI 帧:**
```
CMD=0xF1 (CHALLENGE), Payload=[16 bytes nonce_server]
```
#### Step 4 — 从机响应与身份验证
| 方向 | Signal | 说明 |
|---|---|---|
| GD32 → ESP32 | 握手数据包 | 使用主密钥 AES 加密随机数 + 会话密钥加密自身信息 |
GD32 获取芯片唯一码,使用主密钥通过 **AES 算法**加密随机数,并使用会话密钥加密自身信息,打包发送。
**对应 SPI 帧:**
```
CMD=0xF1 (CHALLENGE), Payload=[握手数据包] ← GD32 响应
```
Payload 结构(AES 加密后的数据):
```
| AES_encrypt(nonce_server, master_key) (16B) | AES_encrypt(device_info, K_session) (16B) |
```
#### Step 5 — 主机验证与会话密钥分发
| 方向 | Signal | 说明 |
|---|---|---|
| ESP32 → GD32 | 变换后的随机数 | 使用会话密钥加密后发送 |
ESP32 验证随机数,解密获取会话密钥 (K_session),对随机数进行变换后用会话密钥加密发送。
**对应 SPI 帧:**
```
CMD=0xF2 (SESSION_KEY), Payload=[AES_encrypt(transformed_nonce, K_session)]
```
#### Step 6 — 认证完成
| 方向 | Signal | 说明 |
|---|---|---|
| GD32 → ESP32 | `0xBA C0` | 安全通道建立完成 |
**对应 SPI 帧:**
```
CMD=0xF2 (SESSION_KEY), Payload=[] ← GD32 回复 0xBA C0
```
---
### 3.2 第二阶段:传输准备(步骤 7-8)
#### Step 7 — 发送大小
| 方向 | Signal | 说明 |
|---|---|---|
| ESP32 → GD32 | `Data Size` (4 bytes, LE) | APP 总数据大小 |
**对应 SPI 帧:**
```
CMD=0xF3 (SEND_SIZE), Payload=[size(4B, LE)]
```
#### Step 8 — 确认大小
| 方向 | Signal | 说明 |
|---|---|---|
| GD32 → ESP32 | `0xBA C0` + 包序号 | 确认收到大小信息 |
**对应 SPI 帧:**
```
CMD=0xF3 (SEND_SIZE), Payload=[seq(2B)] ← GD32 回复 0xBA C0 + seq
```
---
### 3.3 第三阶段:数据分包传输循环(步骤 9-12)
刷写核心部分,数据加密分包发送。
#### Step 9 — 发送数据包
| 方向 | Signal | 说明 |
|---|---|---|
| ESP32 → GD32 | OTA 程序数据包 | 经会话密钥加密,含 CRC 校验码 |
分包大小约 **48 bytes/packet**(与原 CANFD 协议一致)。
**对应 SPI 帧:**
```
CMD=0xF4 (DATA_PACKET), Payload=[encrypted_data(48B) + CRC]
```
#### Step 10 — 从机校验与确认
| 方向 | Signal | 说明 |
|---|---|---|
| GD32 → ESP32 | `0xBA C0` + 包序号 | 校验通过,请求下一包 |
| GD32 → ESP32 | `0xBA C0` + 错误指示 | 校验失败,要求重发当前包(限次重试) |
**对应 SPI 帧:**
```
CMD=0xF4 (DATA_PACKET), Payload=[seq(2B)] ← 正常 ACK
CMD=0xF4 (DATA_PACKET), Payload=[seq(2B) + 0xFF] ← 错误重发请求
```
#### Step 11 — 继续发送
主机发送下一包加密数据。
循环机制:`总循环次数 = 1 + 总APP大小 // 48 Byte`
#### Step 12 — 最后一包
| 方向 | Signal | 说明 |
|---|---|---|
| ESP32 → GD32 | 最后一包数据 | IFEND(结束标志位)置位 |
**对应 SPI 帧:**
```
CMD=0xF5 (DATA_END), Payload=[encrypted_data(N) + CRC + IFEND flag]
```
---
### 3.4 第四阶段:结束与跳转(步骤 13-17)
#### Step 13 — 传输完成确认
| 方向 | Signal | 说明 |
|---|---|---|
| GD32 → ESP32 | `0xBA C0` + `FF FF FF FF` | 所有数据包传输完成并校验通过 |
**对应 SPI 帧:**
```
CMD=0xF6 (TRANSFER_DONE), Payload=[0xFF, 0xFF, 0xFF, 0xFF] ← GD32 完成确认
```
#### Step 14 — 刷写结束指令
| 方向 | Signal | 说明 |
|---|---|---|
| ESP32 → GD32 | `0x0A AC` | 刷写结束信号 |
**对应 SPI 帧:**
```
CMD=0xF7 (FINISH_OTA), Payload=[] → ESP32 发送 0x0A AC
```
#### Step 15 — 内部跳转
GD32 内部执行动作:尝试跳转 APP。
> ESP32 无需等待此步骤完成,由 GD32 自行处理。
#### Step 16 — 最终确认
| 方向 | Signal | 说明 |
|---|---|---|
| GD32 → ESP32 | `0x0A AC` | 确认 APP 运行有效,回复刷写结束信号 |
**对应 SPI 帧:**
```
CMD=0xF7 (FINISH_OTA), Payload=[] ← GD32 回复 0x0A AC
```
#### Step 17 — 清理
流程结束,GD32 清理会话密钥、RAM 缓存等。
---
### 3.5 命令码汇总
| CMD | 名称 | 方向 | Payload | 对应 OTA 阶段 |
|---|---|---|---|---|
| 0xF0 | `HANDSHAKE_REQ` | Bidirectional | 见 Step 1-2 | 阶段一 |
| 0xF1 | `CHALLENGE` | Bidirectional | nonce(16B) / 握手数据包 | 阶段一 |
| 0xF2 | `SESSION_KEY` | Bidirectional | 变换后随机数 / 0xBA C0 | 阶段一 |
| 0xF3 | `SEND_SIZE` | ESP32 → GD32 | size(4B, LE) | 阶段二 |
| 0xF4 | `DATA_PACKET` | ESP32 → GD32 | seq(2B) + data(48B) + CRC | 阶段三 |
| 0xF5 | `DATA_END` | ESP32 → GD32 | seq(2B) + data(N) + CRC + IFEND | 阶段三 |
| 0xF6 | `TRANSFER_DONE` | GD32 → ESP32 | 0xFF 0xFF 0xFF 0xFF | 阶段四 |
| 0xF7 | `FINISH_OTA` | Bidirectional | 0x0A AC | 阶段四 |
| 0x80 | `ACK` | GD32 → ESP32 | cmd(1B) + status(1B) | 通用 |
| 0x81 | `ERROR` | GD32 → ESP32 | cmd(1B) + err_code(1B) | 通用 |
---
### 3.6 时序图
```
ESP32 GD32
| |
|---- HANDSHAKE_REQ ---->| 0x00 AC (Step 1)
|<---- HANDSHAKE_REQ ---| 0x00 AC (Step 2)
| |
|---- CHALLENGE ------->| nonce_server 16B (Step 3)
|<---- CHALLENGE ------| 握手数据包 (Step 4)
| |
|---- SESSION_KEY ----->| 变换后随机数 (Step 5)
|<---- SESSION_KEY -----| 0xBA C0 (Step 6) ← 安全通道建立
| |
|---- SEND_SIZE -------->| Data Size (Step 7)
|<---- SEND_SIZE -------| 0xBA C0 + seq (Step 8)
| |
|==== DATA LOOP ========>| (Step 9-11, 每包 48B + CRC)
|<==== DATA LOOP =======| 0xBA C0 + seq (正常) / 错误重发
| |
|---- DATA_END --------->| 最后一包 + IFEND (Step 12)
| |
|<---- TRANSFER_DONE ---| 0xBA C0 + FF FF FF FF (Step 13)
| |
|---- FINISH_OTA ------->| 0x0A AC (Step 14)
|<---- FINISH_OTA ------| 0x0A AC (Step 16) ← GD32 跳转 APP
| |
* 清理会话密钥 & 缓存 * (Step 17)
```
---
### 3.7 ACK 状态码
| status | 含义 |
|---|---|
| 0x00 | OK |
| 0x01 | 命令不支持 |
| 0x02 | 认证失败 |
| 0x03 | 大小超出范围 |
| 0x04 | CRC 校验失败 |
| 0x05 | 重发次数超限 |
| 0x06 | 会话密钥无效 |
| 0x07 | Flash 写入失败 |
---
### 3.8 重发机制
- GD32 检测到 CRC 错误时,回复当前包序号 + 错误指示
- ESP32 重新发送当前包
- 最大重发次数由 GD32 固件定义(建议 3 次)
- 超限后 ESP32 终止传输并上报 APP
---
## 4. ESP32 软件架构
### 4.1 目录结构
```
esp32_ota_relay/
├── main/
│ ├── main.c
│ ├── app_main.c
│ ├── ota_relay.c # OTA 中继核心逻辑(含四阶段状态机)
│ ├── spi_master.c # SPI 驱动
│ ├── wifi_ota.c # WiFi OTA 接收
│ ├── ble_ota.c # BLE OTA 接收
│ └── protocol.c # 帧封装/解析/CRC/AES
└── CMakeLists.txt
```
### 4.2 模块职责
```
┌─────────────────────────────────────────────┐
│ ESP32 OTA Relay │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ WiFi OTA │ │ BLE OTA │ │
│ │ Server │ │ GATT │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ OTA Relay │ ← 四阶段状态机 │
│ │ Dispatcher │ 透传加密数据 │
│ └──────┬───────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Protocol │ ← 帧封装/解析/CRC/AES │
│ │ Layer │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │SPI Master │ │
│ └─────────────┘ │
└─────────────────────────────────────────────┘
```
### 4.3 OTA Relay Core 状态机
```c
typedef enum {
// 阶段一:安全握手
RELAY_HANDSHAKE_IDLE, // 等待 HANDSHAKE_REQ
RELAY_HANDSHAKE_WAIT, // 等待 GD32 响应 0x00 AC
RELAY_CHALLENGE_SENT, // 已发送挑战随机数
RELAY_CHALLENGE_RESPOND, // 等待 GD32 握手数据包
RELAY_SESSION_KEY_SENT, // 已发送会话密钥分发
RELAY_AUTH_COMPLETE, // 认证完成,安全通道建立
// 阶段二:传输准备
RELAY_SIZE_SENT, // 已发送 Data Size
RELAY_SIZE_ACK, // 收到大小确认
// 阶段三:数据传输
RELAY_RECEIVING, // 接收固件数据
RELAY_PACKET_SENT, // 等待 GD32 ACK
RELAY_RECEIVE_DONE, // 传输完成(收到 DATA_END ACK)
// 阶段四:结束跳转
RELAY_TRANSFER_DONE, // 收到传输完成确认
RELAY_FINISH_SENT, // 已发送刷写结束信号
RELAY_DONE, // 全部完成
RELAY_ERROR
} relay_state_t;
```
### 4.4 安全相关说明
- **AES 加密**:由 ESP32 实现(需预置主密钥),GD32 解密验证
- **会话密钥 K_session**:由 Step 4/5 双方协商生成
- **搅伴算法**:ESP32 生成 nonce_server 时使用(时间 + 内存地址 + rand → 3轮单字节 → 2轮4字节)
- **芯片唯一码**:GD32 提供,ESP32 用于密钥派生
> ⚠️ 主密钥和 AES 算法细节需由 GD32 固件侧提供,本设计仅做协议映射。
### 4.6 数据缓冲策略
**方案 A:存储后转发(先存 ESP32 Flash,再发 SPI)**
ESP32 将 APP 发送的固件完整存入 Flash,传输完成后再通过 SPI 转发给 GD32。
#### Flash 分区设计
假设固件最大 256KB,预留足够空间:
```yaml
# Partitions
# Name, Type, SubType, Offset, Size
otadata, data, ota, 0x9000, 0x2000 # OTA 数据区标记
gd32_ota, data, spiffs, 0xB000, 0x40000 # GD32 固件存储区 (256KB)
app0, app, ota_0, 0x4B000, 0x330000 # ESP32 自身 APP
app1, app, ota_1, 0x3DC000, 0x330000
```
> `gd32_ota` 分区使用 spiffs 或自定义 data 分区,存放原始固件 binary。
#### 接收与存储流程
```
APP ESP32 GD32
| | |
|--- DATA_PACKET (4KB) --->| |
| |--- write to Flash ------>| (存 Flash,不发 SPI)
|<--- ACK (stored) --------| |
| | |
|--- DATA_PACKET (4KB) --->| |
| |--- write to Flash ------>| (存 Flash,不发 SPI)
|<--- ACK (stored) --------| |
| | |
| ... 循环存完所有包 ... |
| | |
|--- DATA_END ------------->| |
| | |
| |--- ENTER_BOOT ----------->| (开始刷写)
| |<--- ACK ------------------| (GD32 就绪)
| | |
| |=== 流式转发固件 =========>| (按 48B/packet 逐包发送)
| |<=== ACK per packet =======| (每包 GD32 ACK)
| | |
| |--- FINISH_OTA ---------->| (刷写结束)
| |<--- FINISH_OTA ----------| (GD32 跳转 APP)
```
#### 断点续传设计
| 文件 | 内容 | 说明 |
|---|---|---|
| `ota_meta.json` | `{total_size, stored_size, chunk_count, last_seq}` | 记录传输进度 |
- APP 重连后先读 `ota_meta.json`,获取 `stored_size`
- 从 `last_seq + 1` 继续发送剩余包
- ESP32 追加写入 Flash(不需要从头擦写)
#### 存储完成判断
- APP 发送 `DATA_END` 后,ESP32 更新 `ota_meta.json` 的 `stored_size = total_size`
- ESP32 检测到 `stored_size == total_size` 后,自动开始向 GD32 转发
#### 内存 Buffer
存储同时使用 2 × 4096 bytes 乒乓 buffer 接收 APP 数据,写入 Flash 后切换 buffer 继续接收。
---
## 5. WiFi OTA 实现
### 5.1 工作模式
- **AP 模式**:ESP32 作为热点,手机直连
- **STA 模式**:ESP32 连接路由器,APP 通过 IP 访问
### 5.2 HTTP OTA 服务器
```
HTTP POST /ota/upload
Content-Type: application/octet-stream
Body: <raw firmware binary>
Response:
{"status": "ok", "progress": 100}
```
### 5.3 APP → ESP32 控制协议
APP 与 ESP32 之间使用简化帧格式:
```
| CMD(1B) | LEN(2B) | PAYLOAD(LEN) | CRC16(2B) |
```
命令码直接对应上述 SPI 命令码(0xF0-0xF7),但数据字段为明文(加密由 ESP32 处理后再转发给 GD32)。
### 5.4 固件分包(APP → ESP32)
```
| CMD(1B) | SEQ(2B) | SIZE(4B) | DATA(SIZE) | CRC32(4B) |
```
---
## 6. BLE OTA 实现
### 6.1 GATT Profile 设计
| Handle | UUID | 属性 | 说明 |
|---|---|---|---|
| 0x0001 | - | Primary Service | OTA Service |
| 0x0010 | `xxxx-xxxx-xxxx-0001` | Control Point | 写命令/通知(控制帧通道) |
| 0x0011 | `xxxx-xxxx-xxxx-0002` | Data In | 写无回应(大规模数据通道) |
| 0x0012 | `xxxx-xxxx-xxxx-0003` | Data Out | 通知 GD32 响应 |
| 0x0013 | `xxxx-xxxx-xxxx-0004` | Version | 只读,ESP32 版本号 |
### 6.2 BLE 传输流程
1. APP 写 `Control Point` (0xF0) → ESP32,发起握手
2. ESP32 通过 SPI 转发给 GD32
3. GD32 响应通过 `Data Out` 通知 APP
4. APP 写 `Data In` 流式发送固件分包(48B/packet)
5. 每包完成后,ESP32 回复 ACK 到 `Data Out`
6. APP 写 `Control Point` (0xF7) → FINISH
7. GD32 响应后,APP 可断开连接
### 6.3 MTU 设置
- 建议 MTU ≥ 512 bytes
- BLE 每次 `Data In` 最大传输 512 bytes(减去 ATT 头)
- 大文件分包多次写入
---
## 7. SPI Master 配置
```c
// ESP32 SPI2 配置 (VSPI)
spi_device_handle_t spi;
// 建议时钟: 40MHz
spi_bus_config_t bus_cfg = {
.mosi_io_num = 8,
.miso_io_num = 7,
.sclk_io_num = 6,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
};
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = 40 * 1000 * 1000, // 40MHz
.mode = 0, // CPOL=0, CPHA=0
.spics_io_num = 4, // GPIO4 as CS
.queue_size = 1,
.command_bits = 0,
.address_bits = 0,
};
```
---
## 8. 错误处理
| 场景 | 处理 |
|---|---|
| GD32 无响应 ACK | 超时 3s → 重发同一包,最多 3 次 → 报错 |
| CRC 校验失败 | GD32 要求重发 → ESP32 重发当前包 |
| 重发次数超限 | ESP32 终止传输,通知 APP |
| WiFi 断开 | 暂停传输,等待重连 |
| BLE 断开 | 通知 APP 重新连接 |
| 认证失败 | 终止流程,通知 APP |
---
## 9. ESP32 自检与保护机制
ESP32-C3FH4 利用芯片内置传感器实现运行安全监控,不外挂额外传感器。
### 9.1 检测项
| 检测项 | 来源 | 阈值范围 | 处理动作 |
|---|---|---|---|
| 芯片温度 | 内部温度传感器 | 正常: < 80°C / 警告: 80-100°C / 停机: > 100°C | 警告时降频,停机时中止 OTA |
| Flash 空间 | 系统 API | 可用 > 16KB(存储分区元数据) | 空间不足时拒绝 OTA 请求 |
| WiFi 信号强度 | WiFi RSSI | 正常: > -70 dBm / 弱: -70 ~ -85 dBm / 断开: < -85 dBm | 弱时降低 OTA 速度,断开时暂停 |
| BLE 连接状态 | BLE stack | 连接中 / 断开 | 断开时通知 APP 重连 |
### 9.2 检测频率
| 检测项 | 频率 | 方式 |
|---|---|---|
| 芯片温度 | 每 1 秒 | FreeRTOS Timer |
| Flash 空间 | OTA 开始前检查 | 一次性 |
| WiFi RSSI | 每 10 秒 | 轮询 |
| BLE 连接 | 事件驱动 | BLE GAP 回调 |
### 9.3 本地判断逻辑
```c
typedef enum {
SYS_NORMAL, // 全部正常
SYS_WARNING, // 任一检测项触发警告阈值
SYS_CRITICAL, // 任一检测项触发停机阈值
SYS_SAFE_SHUTDOWN // 强制停机
} sys_health_state_t;
// 状态机优先级:SAFE_SHUTDOWN > CRITICAL > WARNING > NORMAL
// 高优先级状态覆盖低优先级状态
```
### 9.4 异常处理联动
| ESP32 健康状态 | 对 OTA 的影响 |
|---|---|
| `SYS_NORMAL` | 正常进行 |
| `SYS_WARNING` | 降低 SPI 速率,继续 OTA,记录警告日志 |
| `SYS_CRITICAL` | 暂停 OTA 传输,通知 APP 当前状态,等待恢复 |
| `SYS_SAFE_SHUTDOWN` | 中止 OTA,发送 ABORT 到 GD32,保护性复位 |
### 9.5 日志记录
异常事件写入 Flash 日志分区(`system_log`),包含:
```
[TIMESTAMP] TEMP_WARNING: 85°C
[TIMESTAMP] OTA_PAUSED: temp_critical
[TIMESTAMP] OTA_RESUMED
```
日志可由 APP 通过 WiFi/BLE 查询。
### 9.6 模块架构
```
┌──────────────────────────────────┐
│ System Monitor │
│ (FreeRTOS Task, Priority High) │
├──────────────────────────────────┤
│ temp_monitor (1s timer) │
│ wifi_monitor (10s timer) │
│ ble_monitor (event-driven) │
├──────────────────────────────────┤
│ health_state_t state │
│ → shared with OTA task via mutex│
└──────────────────────────────────┘
│
▼
┌──────────────────────────────────┐
│ OTA Relay Task │
│ - checks health_state before │
│ each GD32 DATA_PACKET send │
│ - pauses/aborts on WARNING/ │
│ CRITICAL/SAFE_SHUTDOWN │
└──────────────────────────────────┘
```
## 10. ESP32 固件分区(参考)
```yaml
# Partitions
# Name, Type, SubType, Offset, Size
otadata, data, ota, 0x9000, 0x2000 # OTA 数据区标记
gd32_ota, data, spiffs, 0xB000, 0x40000 # GD32 固件存储区 (256KB)
app0, app, ota_0, 0x4B000, 0x330000 # ESP32 自身 APP
app1, app, ota_1, 0x3DC000, 0x330000
spiffs, data, spiffs, 0x670000, 0x190000 # 文件系统(日志等)
```
> **注意:** `gd32_ota` 分区大小根据实际 GD32 固件最大体积调整,建议预留 256KB 以上。
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
# docs/review — 评审记录归档
| 文件 | 日期 | 说明 |
|---|---|---|
| [ESP32_COMM_专家评审_2026-10-19.html](ESP32_COMM_专家评审_2026-10-19.html) | 2026-10-19 | 全量专家评审(基线 af1f682 / v0.5.2),含既有评审逐条跟踪 |
| [archive/esp32_architecture_review_2026-06-27.md](archive/esp32_architecture_review_2026-06-27.md) | 2026-06-27 | 架构扫描(原 docs/,af1f682 删除,原样恢复) |
| [archive/CODE_REVIEW_REPORT_2026-06-13.html](archive/CODE_REVIEW_REPORT_2026-06-13.html) | 2026-06-13 | 高风险代码评审 v0.3.12(原 docs/,5b2c5f8 删除,原样恢复) |
| [archive/COMMUNICATION_AUDIT_2026-05-05.md](archive/COMMUNICATION_AUDIT_2026-05-05.md) | 2026-05-05 | 通信梳理(原 docs/,5b2c5f8 删除,原样恢复) |
| ../project_introduction.html §19 | 2026-08-16 / 2026-10-19 | 安全审计原文 + 2026-10-19 复核更新 |
约定:评审记录只增不删;新评审引用并跟踪旧结论。
@@ -0,0 +1,970 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32_COMM 高风险代码评审报告</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-card: #1c2128;
--border: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--critical: #f85149;
--critical-bg: rgba(248,81,73,0.12);
--critical-border: rgba(248,81,73,0.4);
--high: #d29922;
--high-bg: rgba(210,153,34,0.12);
--high-border: rgba(210,153,34,0.4);
--medium: #58a6ff;
--medium-bg: rgba(88,166,255,0.12);
--medium-border: rgba(88,166,255,0.4);
--accent: #58a6ff;
--success: #3fb950;
--success-bg: rgba(63,185,80,0.12);
--code-bg: #0d1117;
--table-stripe: rgba(110,118,129,0.05);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* Header */
.report-header {
text-align: center;
padding: 3rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 3rem;
}
.report-header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #f85149, #d29922, #58a6ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.report-header .subtitle {
color: var(--text-secondary);
font-size: 1rem;
margin-bottom: 1.5rem;
}
.report-meta {
display: flex;
justify-content: center;
gap: 2rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--text-muted);
}
.report-meta span { display: flex; align-items: center; gap: 0.4rem; }
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 3rem;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
text-align: center;
transition: transform 0.2s, border-color 0.2s;
}
.stat-card:hover { transform: translateY(-2px); }
.stat-card .number {
font-size: 2.5rem;
font-weight: 800;
line-height: 1;
margin-bottom: 0.3rem;
}
.stat-card .label {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-card.critical { border-color: var(--critical-border); }
.stat-card.critical .number { color: var(--critical); }
.stat-card.high { border-color: var(--high-border); }
.stat-card.high .number { color: var(--high); }
.stat-card.medium { border-color: var(--medium-border); }
.stat-card.medium .number { color: var(--medium); }
.stat-card.total { border-color: var(--accent); }
.stat-card.total .number { color: var(--accent); }
.stat-card.verified { border-color: var(--success); }
.stat-card.verified .number { color: var(--success); }
/* Dimension Table */
.section { margin-bottom: 3rem; }
.section-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 1.2rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border);
display: flex;
align-items: center;
gap: 0.6rem;
}
.section-title .icon { font-size: 1.2rem; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
background: var(--bg-tertiary);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
tr:nth-child(even) td { background: var(--table-stripe); }
/* Severity Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.6rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.badge.critical { background: var(--critical-bg); color: var(--critical); border: 1px solid var(--critical-border); }
.badge.high { background: var(--high-bg); color: var(--high); border: 1px solid var(--high-border); }
.badge.medium { background: var(--medium-bg); color: var(--medium); border: 1px solid var(--medium-border); }
/* Finding Cards */
.finding-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 1.2rem;
overflow: hidden;
transition: border-color 0.2s;
}
.finding-card:hover { border-color: var(--text-muted); }
.finding-card.critical { border-left: 4px solid var(--critical); }
.finding-card.high { border-left: 4px solid var(--high); }
.finding-header {
padding: 1.2rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
cursor: pointer;
user-select: none;
}
.finding-header:hover { background: var(--bg-tertiary); }
.finding-title-group { flex: 1; }
.finding-number {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.2rem;
}
.finding-title {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.4;
}
.finding-badges {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
.finding-body {
padding: 0 1.5rem 1.5rem;
display: none;
}
.finding-card.open .finding-body { display: block; }
.finding-body .detail-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4rem 1rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.detail-label {
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
}
.detail-value { color: var(--text-primary); }
.code-block {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin: 0.8rem 0;
overflow-x: auto;
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.82rem;
line-height: 1.5;
}
.code-block .comment { color: #6a9955; }
.code-block .keyword { color: #c586c0; }
.code-block .type { color: #4ec9b0; }
.code-block .string { color: #ce9178; }
.code-block .highlight { background: rgba(248,81,73,0.15); display: inline; padding: 0.1em 0.2em; border-radius: 3px; }
.impact-box {
background: rgba(248,81,73,0.06);
border: 1px solid rgba(248,81,73,0.2);
border-radius: 8px;
padding: 0.8rem 1rem;
margin: 0.8rem 0;
font-size: 0.88rem;
}
.impact-box .impact-label {
color: var(--critical);
font-weight: 600;
font-size: 0.8rem;
margin-bottom: 0.3rem;
}
.fix-box {
background: rgba(63,185,80,0.06);
border: 1px solid rgba(63,185,80,0.2);
border-radius: 8px;
padding: 0.8rem 1rem;
margin: 0.8rem 0;
font-size: 0.88rem;
}
.fix-box .fix-label {
color: var(--success);
font-weight: 600;
font-size: 0.8rem;
margin-bottom: 0.3rem;
}
/* Architecture Section */
.arch-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.arch-card h4 {
font-size: 1rem;
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.arch-card p { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.7; }
/* Priority Table */
.priority-table { margin-top: 1rem; }
.priority-table .p0 { color: var(--critical); font-weight: 700; }
.priority-table .p1 { color: var(--high); font-weight: 600; }
.priority-table .p2 { color: var(--medium); font-weight: 500; }
.priority-table .p3 { color: var(--text-secondary); }
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-top: 0.3rem;
}
.progress-bar .fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.fill.critical-fill { background: var(--critical); }
.fill.high-fill { background: var(--high); }
.fill.medium-fill { background: var(--medium); }
/* Chart */
.chart-container {
display: flex;
align-items: flex-end;
gap: 1.5rem;
height: 200px;
padding: 1rem 0;
justify-content: center;
}
.chart-bar-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex: 1;
max-width: 100px;
}
.chart-bar-stack {
width: 60px;
display: flex;
flex-direction: column-reverse;
border-radius: 6px 6px 0 0;
overflow: hidden;
}
.chart-bar-stack > div {
width: 100%;
transition: height 0.6s ease;
}
.chart-label {
font-size: 0.72rem;
color: var(--text-muted);
text-align: center;
white-space: nowrap;
}
.chart-value {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
}
/* Expand/Collapse all */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.toolbar button {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 0.4rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
transition: all 0.2s;
}
.toolbar button:hover {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-muted);
}
/* Footer */
.report-footer {
text-align: center;
padding: 2rem 0;
border-top: 1px solid var(--border);
margin-top: 3rem;
color: var(--text-muted);
font-size: 0.8rem;
}
/* Responsive */
@media (max-width: 768px) {
.container { padding: 1rem; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.finding-header { flex-direction: column; }
.finding-badges { justify-content: flex-start; }
.chart-container { gap: 0.8rem; }
.chart-bar-stack { width: 40px; }
}
/* Print */
@media print {
body { background: white; color: black; }
.finding-body { display: block !important; }
.stat-card, .finding-card, .arch-card { break-inside: avoid; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<header class="report-header">
<h1>ESP32_COMM 高风险代码评审报告</h1>
<div class="subtitle">多维度并行审查 + 对抗性验证 · 4维度 × 33项确认发现</div>
<div class="report-meta">
<span>&#128203; 项目: ESP32_COMM (ESP32-C3 OTA Relay)</span>
<span>&#128197; 日期: 2026-06-13</span>
<span>&#128268; 固件版本: v0.3.12</span>
<span>&#128101; 审查Agent: 4 Review + 36 Verify + 1 Synthesize</span>
</div>
</header>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card total">
<div class="number">33</div>
<div class="label">确认发现</div>
</div>
<div class="stat-card critical">
<div class="number">8</div>
<div class="label">CRITICAL</div>
</div>
<div class="stat-card high">
<div class="number">18</div>
<div class="label">HIGH</div>
</div>
<div class="stat-card medium">
<div class="number">4</div>
<div class="label">MEDIUM</div>
</div>
<div class="stat-card verified">
<div class="number">33/36</div>
<div class="label">验证通过率</div>
</div>
<div class="stat-card total">
<div class="number">2</div>
<div class="label">已驳回(误报)</div>
</div>
</div>
<!-- Dimension Chart -->
<div class="section">
<div class="section-title"><span class="icon">&#128202;</span> 各维度发现分布</div>
<div class="chart-container">
<div class="chart-bar-group">
<div class="chart-value">8</div>
<div class="chart-bar-stack" style="height:160px;">
<div style="height:60px;background:var(--critical);" title="2 CRITICAL"></div>
<div style="height:80px;background:var(--high);" title="4 HIGH"></div>
<div style="height:20px;background:var(--medium);" title="1 MEDIUM"></div>
</div>
<div class="chart-label">内存安全</div>
</div>
<div class="chart-bar-group">
<div class="chart-value">10</div>
<div class="chart-bar-stack" style="height:180px;">
<div style="height:54px;background:var(--critical);" title="3 CRITICAL"></div>
<div style="height:108px;background:var(--high);" title="6 HIGH"></div>
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
</div>
<div class="chart-label">并发/RTOS</div>
</div>
<div class="chart-bar-group">
<div class="chart-value">9</div>
<div class="chart-bar-stack" style="height:170px;">
<div style="height:72px;background:var(--critical);" title="4 CRITICAL"></div>
<div style="height:72px;background:var(--high);" title="4 HIGH"></div>
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
</div>
<div class="chart-label">OTA/固件</div>
</div>
<div class="chart-bar-group">
<div class="chart-value">7</div>
<div class="chart-bar-stack" style="height:150px;">
<div style="height:43px;background:var(--critical);" title="2 CRITICAL"></div>
<div style="height:86px;background:var(--high);" title="4 HIGH"></div>
<div style="height:14px;background:var(--medium);" title="1 MEDIUM"></div>
</div>
<div class="chart-label">SPI/协议</div>
</div>
</div>
</div>
<!-- TOP 3 Critical -->
<div class="section">
<div class="section-title"><span class="icon">&#128680;</span> TOP 3 最危险 Bug</div>
<!-- Finding 1 -->
<div class="finding-card critical open" id="f1">
<div class="finding-header" onclick="toggleFinding('f1')">
<div class="finding-title-group">
<div class="finding-number">Finding #1 — CRITICAL</div>
<div class="finding-title">SPI 总线无并发保护 — 多任务竞争导致协议损坏</div>
</div>
<div class="finding-badges">
<span class="badge critical">CRITICAL</span>
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">并发竞态</span>
</div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span>
<span class="detail-value">spi_master.h:73-142, ota_relay.cpp, wifi_ota.cpp</span>
<span class="detail-label">维度:</span>
<span class="detail-value">并发/RTOS</span>
<span class="detail-label">验证:</span>
<span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度 — 源码确认</span>
</div>
<p>SpiMaster 是单例但<strong>没有任何互斥锁</strong>。多个 FreeRTOS 任务直接调用 SPI 方法:ota_relay 任务 (优先级 MAX-2)、wifi_ota 任务 (MAX-3)、health_monitor 任务 (MAX-4)。协议操作是多步非原子的(如 otaTransfer = 发送帧 + 轮询响应,CS在两次传输之间释放),高优先级任务可在协议序列中间抢占。</p>
<div class="code-block"><span class="comment">// spi_master.h — 无任何mutex成员</span>
<span class="keyword">class</span> <span class="type">SpiMaster</span> {
<span class="keyword">private</span>:
<span class="type">spi_device_handle_t</span> m_spiDevice = <span class="keyword">NULL</span>; <span class="comment">// ← 唯一成员,无锁</span>
};</div>
<div class="impact-box">
<div class="impact-label">&#9888; 影响</div>
帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。
</div>
<div class="fix-box">
<div class="fix-label">&#128295; 修复建议</div>
在 SpiMaster 中添加 <code>SemaphoreHandle_t m_mutex</code>,所有公共方法入口加锁。对于多步协议操作(otaTransfer等),使用统一的锁粒度保护整个请求-响应序列。
</div>
</div>
</div>
<!-- Finding 2 -->
<div class="finding-card critical" id="f2">
<div class="finding-header" onclick="toggleFinding('f2')">
<div class="finding-title-group">
<div class="finding-number">Finding #2 — CRITICAL</div>
<div class="finding-title">硬编码占位密钥 — 认证体系形同虚设</div>
</div>
<div class="finding-badges">
<span class="badge critical">CRITICAL</span>
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">安全/OTA</span>
</div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span>
<span class="detail-value">app_config.h:107-115</span>
<span class="detail-label">维度:</span>
<span class="detail-value">OTA/固件完整性</span>
<span class="detail-label">验证:</span>
<span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度 — 源码确认</span>
</div>
<p>MASTER_KEY 使用 NIST AES 测试向量,SCRAMBLE_CONST 使用简单递增序列,GD32_CHIP_UID 使用 0x00-0x0B。注释标注"需填入实际密钥"但从未替换。</p>
<div class="code-block"><span class="comment">// ⚠️ 请填入实际密钥 ← 注释说"需填入"但从未填入</span>
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> MASTER_KEY[] = {
<span class="highlight">0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF</span>, <span class="comment">// ← NIST AES测试向量!</span>
<span class="highlight">0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10</span>
};
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> SCRAMBLE_CONST[] = {
<span class="highlight">0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88</span>, <span class="comment">// ← 简单递增序列</span>
<span class="highlight">0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00</span>
};
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> GD32_CHIP_UID[] = {
<span class="highlight">0x00, 0x01, 0x02, 0x03, 0x04, 0x05</span>, <span class="comment">// ← 0x00-0x0B</span>
<span class="highlight">0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B</span>
};</div>
<div class="impact-box">
<div class="impact-label">&#9888; 影响</div>
攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。
</div>
<div class="fix-box">
<div class="fix-label">&#128295; 修复建议</div>
从安全存储(NVS/efuse)读取密钥;移除所有硬编码占位值;添加编译期断言确保非测试密钥;MASTER_KEY和SCRAMBLE_CONST应在首次设备配准时由安全随机数生成。
</div>
</div>
</div>
<!-- Finding 3 -->
<div class="finding-card critical" id="f3">
<div class="finding-header" onclick="toggleFinding('f3')">
<div class="finding-title-group">
<div class="finding-number">Finding #3 — CRITICAL</div>
<div class="finding-title">Flash 写入无对齐检查 — 可能导致固件损坏</div>
</div>
<div class="finding-badges">
<span class="badge critical">CRITICAL</span>
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">内存安全/OTA</span>
</div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span>
<span class="detail-value">ota_relay.cpp:308-320, wifi_ota.cpp:711/740, ble_ota.cpp:575</span>
<span class="detail-label">维度:</span>
<span class="detail-value">内存安全 + OTA完整性</span>
<span class="detail-label">验证:</span>
<span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度 — 源码确认</span>
</div>
<div class="code-block"><span class="type">esp_err_t</span> OtaRelay::flashWriteChunk(<span class="type">uint32_t</span> offset, <span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len)
{
<span class="comment">// ... 无对齐检查 ...</span>
<span class="keyword">return</span> <span class="highlight">esp_partition_write</span>(instance().m_gd32Partition, offset, data, len); <span class="comment">// ← offset/len可能非4字节对齐</span>
}</div>
<p>两个调用路径产生非对齐值:(1) WiFi OTA: gd32_offset 从 httpd_req_recv 返回值累加,最后一个chunk是固件余数;(2) BLE OTA: data_len = recv_len - 11(7字节头+4字节CRC),不保证4字节对齐。esp_partition_write 要求 offset 和 size 均4字节对齐。</p>
<div class="impact-box">
<div class="impact-label">&#9888; 影响</div>
非对齐写入导致 ESP_ERR_INVALID_ARG 或触发 read-modify-write 异常,GD32固件最后一个chunk损坏或丢失。
</div>
<div class="fix-box">
<div class="fix-label">&#128295; 修复建议</div>
flashWriteChunk 内部做4字节对齐对齐(不足部分 read-modify-write),或在上层确保只传对齐参数。
</div>
</div>
</div>
</div>
<!-- Other CRITICAL Findings -->
<div class="section">
<div class="section-title"><span class="icon">&#128308;</span> 其他 CRITICAL 级发现</div>
<div class="finding-card critical" id="f4">
<div class="finding-header" onclick="toggleFinding('f4')">
<div class="finding-title-group">
<div class="finding-number">Finding #4 — CRITICAL</div>
<div class="finding-title">BLE/WiFi 双通道 OTA 同时触发 — ESP32 OTA状态冲突</div>
</div>
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.h:42-46, ble_ota.h</span>
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度</span>
</div>
<p>BleOta 和 WifiOta 各自维护独立的 m_esp32OtaActive、m_esp32OtaHandle、m_esp32UpdatePartition,但底层 esp_ota_begin/write/end 操作的是同一个OTA分区。两个通道同时触发时:第二个 esp_ota_begin 会失败或覆盖句柄;esp_ota_write 可能交错写入同一分区;导致固件损坏+变砖。</p>
<div class="fix-box"><div class="fix-label">&#128295; 修复建议</div>添加全局 OTA 互斥标志,同一时刻只允许一个OTA源(BLE或WiFi)。</div>
</div>
</div>
<div class="finding-card critical" id="f5">
<div class="finding-header" onclick="toggleFinding('f5')">
<div class="finding-title-group">
<div class="finding-number">Finding #5 — CRITICAL</div>
<div class="finding-title">SPI 协议帧仅用 XOR 伪校验 — 数据损坏不可检测</div>
</div>
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span><span class="detail-value">spi_master.cpp:134-141</span>
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度</span>
</div>
<div class="code-block"><span class="type">uint8_t</span> SpiMaster::otaCrc8(<span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len) {
<span class="type">uint8_t</span> crc = <span class="highlight">0</span>;
<span class="keyword">for</span> (<span class="type">size_t</span> i = <span class="highlight">0</span>; i &lt; len; ++i)
<span class="highlight">crc ^= data[i]</span>; <span class="comment">// ← 简单XOR,非CRC8</span>
<span class="keyword">return</span> crc;
}</div>
<p>XOR校验无法检测:偶数个相同位置的位翻转、字节交换、全零段丢失。固件数据的静默损坏几乎不可检测。</p>
<div class="fix-box"><div class="fix-label">&#128295; 修复建议</div>替换为 CRC-8/MAXIM 或 CRC-16/CCITT,与GD32团队协商。</div>
</div>
</div>
<div class="finding-card critical" id="f6">
<div class="finding-header" onclick="toggleFinding('f6')">
<div class="finding-title-group">
<div class="finding-number">Finding #6 — CRITICAL</div>
<div class="finding-title">SPI_OTA_SOF 与 SPI_APP_ENTER_BOOT_ACK_OK 值碰撞 (0xA5)</div>
</div>
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span><span class="detail-value">spi_master.h:21,26</span>
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度</span>
</div>
<div class="code-block"><span class="keyword">#define</span> <span class="highlight">SPI_OTA_SOF</span> (<span class="highlight">0xA5</span>) <span class="comment">// OTA帧起始标记</span>
<span class="keyword">#define</span> <span class="highlight">SPI_APP_ENTER_BOOT_ACK_OK</span> (<span class="highlight">0xA5</span>) <span class="comment">// APP确认进入Bootloader</span></div>
<p>同一值 0xA5 表示两种完全不同的语义。GD32在OTA窗口回复ACK时,ESP32可能将ACK字节误解析为OTA帧起始,导致协议状态机混乱。</p>
<div class="fix-box"><div class="fix-label">&#128295; 修复建议</div>修改其中一个常量值,消除歧义。例如将 SPI_APP_ENTER_BOOT_ACK_OK 改为 0xB5。</div>
</div>
</div>
<div class="finding-card critical" id="f7">
<div class="finding-header" onclick="toggleFinding('f7')">
<div class="finding-title-group">
<div class="finding-number">Finding #7 — CRITICAL</div>
<div class="finding-title">OTA 会话无全局超时 — 可无限挂起</div>
</div>
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span><span class="detail-value">ota_relay.cpp:76-91,562; ota_relay.h:59</span>
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度</span>
</div>
<p>waitUntilNotWait() 每次调用有 max_wait_rounds 上限,但整个 forwardFirmware() 流程没有全局超时。GD32 持续返回 WAIT 时,单个 SEND_SIZE阶段可挂起 ~78秒,完整流程可挂起数分钟。OTA_RESULT_ERR_USER_ABORT 已定义但从未使用。</p>
<div class="fix-box"><div class="fix-label">&#128295; 修复建议</div>在 forwardFirmware() 入口记录开始时间,每个阶段检查全局超时(如5分钟),超时则中止并清理。</div>
</div>
</div>
<div class="finding-card critical" id="f8">
<div class="finding-header" onclick="toggleFinding('f8')">
<div class="finding-title-group">
<div class="finding-number">Finding #8 — CRITICAL</div>
<div class="finding-title">HTTP CORS 允许所有来源 — 任意网页可触发 OTA</div>
</div>
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
</div>
<div class="finding-body">
<div class="detail-grid">
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.cpp:58</span>
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">&#10004; HIGH置信度</span>
</div>
<div class="code-block">httpd_resp_set_hdr(req, <span class="string">"Access-Control-Allow-Origin"</span>, <span class="highlight">"*"</span>);</div>
<p>任何网页都可以向 ESP32 发起 OTA 请求。结合占位密钥问题,攻击链完整:恶意网页 → 上传伪造固件 → 通过占位密钥认证 → 烧录恶意GD32固件。</p>
<div class="fix-box"><div class="fix-label">&#128295; 修复建议</div>限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。</div>
</div>
</div>
</div>
<!-- HIGH Findings -->
<div class="section">
<div class="section-title"><span class="icon">&#128992;</span> HIGH 级关键发现</div>
<div class="toolbar">
<span style="color:var(--text-muted);font-size:0.85rem;">共 18 项 HIGH 级发现,点击展开详情</span>
<button onclick="toggleAll('high')">展开/折叠全部</button>
</div>
<table>
<thead>
<tr><th>#</th><th>严重度</th><th>文件</th><th>问题</th><th>影响</th></tr>
</thead>
<tbody>
<tr><td>9</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:121</code></td><td>WiFi事件回调中调用 esp_wifi_connect() 重连</td><td>在系统事件任务中调用可能死锁</td></tr>
<tr><td>10</td><td><span class="badge high">HIGH</span></td><td><code>health_monitor.cpp</code></td><td>healthState 静态变量无锁跨任务读写</td><td>竞态导致健康判断错误</td></tr>
<tr><td>11</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:80-84</code></td><td>5个静态变量(relayState等)无锁跨任务访问</td><td>协议状态不一致</td></tr>
<tr><td>12</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.h:33-47</code></td><td>WifiOta 所有成员 public 且无锁</td><td>HTTP回调与WiFi事件回调竞争</td></tr>
<tr><td>13</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.h</code></td><td>BleOta 成员无锁,BLE GATT回调与主任务竞争</td><td>BLE OTA 数据损坏</td></tr>
<tr><td>14</td><td><span class="badge high">HIGH</span></td><td><code>debug_log.cpp:296</code></td><td>log() 栈分配 msg[512] + log_buf[600] = 1112字节</td><td>低栈任务中栈溢出</td></tr>
<tr><td>15</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:1008</code></td><td>spi_send_handler 栈分配 1728+ 字节</td><td>HTTP server栈仅8KB,接近溢出</td></tr>
<tr><td>16</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.cpp:456</code></td><td>sendNonceAndAuthenticate() nonce 仅用 esp_random() 一次</td><td>认证会话密钥可预测</td></tr>
<tr><td>17</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:25</code></td><td>ota_meta_t.checksum[32] 从未填充/验证</td><td>固件完整性无保障</td></tr>
<tr><td>18</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:429-444</code></td><td>ESP32 OTA 完成→重启,无回滚验证</td><td>新固件若无法启动将变砖</td></tr>
<tr><td>19</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.cpp:258</code></td><td>syncTransfer/recv 忽略 timeout_ms 参数</td><td>SPI事务可能无限等待</td></tr>
<tr><td>20</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:599-629</code></td><td>esp_ota_write 失败后 break,未调用 esp_ota_abort</td><td>OTA分区处于不一致状态</td></tr>
<tr><td>21</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.cpp:439-444</code></td><td>BLE OTA 帧预留4字节CRC但减去计算不匹配</td><td>最后几个字节被误截断</td></tr>
<tr><td>22</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.h</code></td><td>SPI仅4线,无硬件流控/就绪信号</td><td>高速传输时GD32来不及处理</td></tr>
</tbody>
</table>
</div>
<!-- Architecture Concerns -->
<div class="section">
<div class="section-title"><span class="icon">&#127959;</span> 架构级隐患</div>
<div class="arch-card">
<h4>&#9312; 全局状态泛滥,无封装无同步</h4>
<p>项目中有 <strong>5个静态全局变量</strong> (relayState, relaySeq, relayTotalPackets, gd32AppAlive, gd32Version) 和 <strong>大量public成员</strong> (WifiOta/BleOta所有状态),跨越3+个FreeRTOS任务和多个ISR/callback上下文访问,<strong>没有任何一个</strong>被mutex/atomic/critical section保护。这是系统性问题,不是个别bug。</p>
</div>
<div class="arch-card">
<h4>&#9313; 单通道SPI + 多任务无仲裁</h4>
<p>SpiMaster 单例是唯一的SPI通信通道,但没有任何仲裁机制。WiFi/BLE/健康监控/OTA中继四个功能模块各自直接调用,协议级操作(请求-响应对)不是原子的。需要从架构层面引入SPI总线仲裁器。</p>
</div>
<div class="arch-card">
<h4>&#9314; 安全架构不完整</h4>
<p>认证握手存在但密钥硬编码→形同虚设;固件校验字段存在但未填充→形同虚设;CORS开放→任何人可触发OTA。<strong>安全功能"看起来有"但实际上不提供保护</strong>,这种"安全假象"比没有安全功能更危险,因为它给了虚假的信心。</p>
</div>
</div>
<!-- Priority Fix Order -->
<div class="section">
<div class="section-title"><span class="icon">&#128736;</span> 优先修复顺序</div>
<table class="priority-table">
<thead>
<tr><th>优先级</th><th>修复项</th><th>工作量</th><th>紧急度</th><th>进度</th></tr>
</thead>
<tbody>
<tr>
<td class="p0">P0</td>
<td>添加 SPI 总线互斥锁</td>
<td>小</td>
<td>极高 — 生产环境必崩</td>
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p0">P0</td>
<td>替换硬编码密钥为安全存储</td>
<td>中</td>
<td>极高 — 安全漏洞</td>
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p0">P0</td>
<td>Flash 写入对齐检查</td>
<td>小</td>
<td>极高 — 数据损坏</td>
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p1">P1</td>
<td>添加全局 OTA 超时+中止机制</td>
<td>中</td>
<td>高 — 设备挂死</td>
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p1">P1</td>
<td>ESP32 OTA 添加回滚验证</td>
<td>中</td>
<td>高 — 变砖风险</td>
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p1">P1</td>
<td>替换 XOR 为 CRC-8/16 校验</td>
<td>小</td>
<td>高 — 固件损坏</td>
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p1">P1</td>
<td>消除 0xA5 值碰撞</td>
<td>极小</td>
<td>高 — 协议混乱</td>
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p2">P2</td>
<td>添加 OTA 单源互斥 (BLE/WiFi)</td>
<td>中</td>
<td>中高</td>
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p2">P2</td>
<td>为所有跨任务状态添加锁/atomic</td>
<td>大</td>
<td>中高</td>
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p2">P2</td>
<td>修复 WiFi 事件回调中的重连调用</td>
<td>小</td>
<td>中</td>
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
</tr>
<tr>
<td class="p3">P3</td>
<td>修复大栈分配问题</td>
<td>小</td>
<td>中</td>
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
</tr>
<tr>
<td class="p3">P3</td>
<td>限制 CORS 来源</td>
<td>小</td>
<td>中</td>
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
</tr>
<tr>
<td class="p3">P3</td>
<td>实现 ota_meta_t 校验填充与验证</td>
<td>中</td>
<td>中</td>
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
</tr>
</tbody>
</table>
</div>
<!-- Dismissed -->
<div class="section">
<div class="section-title"><span class="icon">&#9989;</span> 已驳回发现(误报)</div>
<table>
<thead>
<tr><th>原始严重度</th><th>问题描述</th><th>驳回原因</th></tr>
</thead>
<tbody>
<tr>
<td><span class="badge critical" style="text-decoration:line-through;opacity:0.5;">CRITICAL</span></td>
<td>SPI DMA缓冲区栈分配导致数据损坏/硬Fault</td>
<td>ESP-IDF 6.0 的 spi_master 驱动内置 <code>setup_dma_priv_buffer()</code> 函数自动检测并对齐DMA缓冲区,栈分配仅导致性能损失,不引发数据损坏</td>
</tr>
<tr>
<td><span class="badge high" style="text-decoration:line-through;opacity:0.5;">HIGH</span></td>
<td>TOCTOU: relayState 检查后 reset() 无锁</td>
<td>虽然模式存在,但当前代码流程中检查和重置在同一线程同步执行,实际竞态窗口极小,风险被高估</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer -->
<footer class="report-footer">
<p>ESP32_COMM 高风险代码评审报告 · 由 4维并行审查 + 36项对抗性验证 生成 · 2026-06-13</p>
<p style="margin-top:0.5rem;">审查维度: 内存安全 · 并发/RTOS · OTA/固件完整性 · SPI通信/协议</p>
</footer>
</div>
<script>
function toggleFinding(id) {
const card = document.getElementById(id);
if (card) card.classList.toggle('open');
}
let allHighOpen = false;
function toggleAll() {
allHighOpen = !allHighOpen;
document.querySelectorAll('.finding-card').forEach(card => {
if (allHighOpen) card.classList.add('open');
else card.classList.remove('open');
});
}
</script>
</body>
</html>
@@ -0,0 +1,74 @@
# ESP32_COMM 通信梳理(2026-05-05)
## 结论
当前“ESP32 ↔ 上位机(tools WebUI)”的主通信方式是 **HTTP**,不是 UDP。
### WebUI 实际在用的接口
- `GET /system/status`:每 1 秒轮询一次,用于在线状态、WiFi、heap、温度、OTA 状态展示
- `GET /logs`:每 1 秒轮询一次,用于拉取并清空日志环形缓冲区
- `POST /can/send`:发送 CAN 请求到 GD32
- `POST /ota/upload` / `POST /ota/relay` / `POST /ota/update`:OTA 相关
## 日志链路
所有 `DEBUG_*` 日志目前会并行写入三处:
1. **UART0**
- 本地串口调试
2. **UDP 单播** → `DEBUG_LOG_IP:DEBUG_LOG_PORT`
- 可供独立 UDP 调试接收端使用
3. **内部环形缓冲区**
- 被 `GET /logs` 读取
- 这是当前 WebUI 日志面板真正使用的通道
## 为什么 `{"t":"hb"}` 是多余的
之前在 `wifi_ota_task()` 里,每 3 秒会发送一次:
```json
{"t":"hb"}
```
它是通过 `debug_log_send()` 发出的,所以会同时进入:
- UART
- UDP
- `/logs` 对应的环形缓冲区
这会导致 WebUI 把它显示成一条普通日志:
- level: `I`
- tag: `ESP32`
- message: `{"t":"hb"}`
但 WebUI 本来已经每秒轮询 `/system/status`,完全可以判断设备在线,不需要再靠这种“伪日志心跳”。
## 已处理
已删除 `wifi_ota_task()` 中每 3 秒发送一次的 `{"t":"hb"}`。
## 现在是否还有重复/不必要通信
### 对 WebUI 来说
有两条并行链路,但用途不同,不算重复:
- `/system/status`:结构化状态
- `/logs`:文本日志
这两条都保留是合理的。
### 对底层日志输出来说
日志同时发 UART + UDP + ring buffer,属于“一份日志三路镜像”。
这对调试友好,但从“最简设计”角度看,**UDP 在当前 WebUI 场景里不是必需的**。
也就是说:
- **必须保留:** ring buffer + `/logs`
- **建议保留:** UART0
- **可选保留:** UDP 6666
如果后续你想继续精简,可以做“方案2”:把 UDP 也做成可关闭,甚至默认关闭。
@@ -1,180 +0,0 @@
esp-idf/main/CMakeFiles/__idf_main.dir/monitor/debug_log.cpp.obj: \
G:\Robot_v2\DCU\ESP32_COMM\main\monitor\debug_log.cpp \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/string.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/cdefs.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/cdefs.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/ieeefp.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/features.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/picolibc.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/float.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_default_types.h \
G:/Robot_v2/DCU/ESP32_COMM/build/config/sdkconfig.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdint.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stddef.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_intsup.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_stdint.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_locale.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/strings.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/string.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/stdio.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdio.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stdarg.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/lock.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/lock.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/esp32xx/include/sys/socket.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/include/lwip/sockets.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/sockets.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/opt.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/lwipopts.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdlib.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/stdlib.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/alloca.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_wait.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/unistd.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/unistd.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/endian.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_endian.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_pthreadtypes.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/sched.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_timespec.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_sigset.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_timeval.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/unistd.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/fcntl.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/fcntl.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_default_fcntl.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/time.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_time.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/ioctl.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/poll.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_system/include/esp_task.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/FreeRTOS.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/config/include/freertos/FreeRTOSConfig.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/assert.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/assert.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/config/riscv/include/freertos/FreeRTOSConfig_arch.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/projdefs.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/portable.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/deprecated_definitions.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/portable/riscv/include/freertos/portmacro.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stdbool.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/spinlock.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_cpu.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/soc_caps.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/include/soc/soc_caps_eval.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/param.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/syslimits.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/param.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/encoding.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_assert.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/rv_utils.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/interrupt_reg.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/register/soc/interrupt_core0_reg.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/soc.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_bit_defs.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/register/soc/reg_base.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_attr.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_assert.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/interrupt.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/esp_private/interrupt_intc.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr_pie.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr_dsp.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_intr_alloc.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_err.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_compiler.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_intr_types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_macros.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_rom/include/esp_rom_sys.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/inttypes.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/reset_reasons.h \
F:/software/.espressif/v6.0/esp-idf/components/heap/include/esp_heap_caps.h \
F:/software/.espressif/v6.0/esp-idf/components/heap/include/multi_heap.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_system/include/esp_system.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_idf_version.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/esp_newlib.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/reent.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/limits.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/limits.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/mpu_wrappers.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/esp_additions/include/freertos/idf_additions.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/task.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/list.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/queue.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/task.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/semphr.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/queue.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/stream_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/message_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/stream_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/event_groups.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/timers.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_random.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/sntp/sntp_get_set_time.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/sockets_ext.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/freertos/include/arch/sys_arch.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/debug.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/arch.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/esp32xx/include/arch/cc.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/errno.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/errno.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/errno.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/ctype.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/def.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip4_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip6_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/def.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip6_zone.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/netif.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/err.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/pbuf.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/stats.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/mem.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/memp.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/memp_std.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/memp_priv.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/mem_priv.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/inet.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/errno.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/net/if.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/if_api.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/netdb.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/include/lwip/netdb.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/netdb.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/arpa/inet.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_config.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_level.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_color.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_timestamp.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_write.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_format.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_args.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_log_attr.h \
F:/software/.espressif/v6.0/esp-idf/components/log/include/esp_private/log_attr.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_driver_uart/include/driver/uart.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hal_uart/include/hal/uart_types.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/clk_tree_defs.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_check.h \
G:\Robot_v2\DCU\ESP32_COMM\main\monitor\debug_log.h \
G:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/riscv32-esp-elf/include/c++/15.2.0/cstdint \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/c++config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/os_defines.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/cpu_defines.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/riscv32-esp-elf/include/c++/15.2.0/pstl/pstl_config.h \
G:/Robot_v2/DCU/ESP32_COMM/main/include/version.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/netinet/in.h
@@ -1,180 +0,0 @@
esp-idf/main/CMakeFiles/__idf_main.dir/monitor/health_monitor.cpp.obj: \
G:\Robot_v2\DCU\ESP32_COMM\main\monitor\health_monitor.cpp \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/stdio.h \
G:/Robot_v2/DCU/ESP32_COMM/build/config/sdkconfig.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stddef.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/features.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/picolibc.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdio.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/cdefs.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/cdefs.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/ieeefp.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/float.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_default_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdint.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_intsup.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_stdint.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stdarg.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/lock.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/lock.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/string.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_locale.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/strings.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/string.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/FreeRTOS.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/config/include/freertos/FreeRTOSConfig.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/assert.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdlib.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/stdlib.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/alloca.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_wait.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/assert.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/config/riscv/include/freertos/FreeRTOSConfig_arch.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/projdefs.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/portable.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/deprecated_definitions.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/portable/riscv/include/freertos/portmacro.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stdbool.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/spinlock.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_cpu.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/soc_caps.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/include/soc/soc_caps_eval.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/param.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/syslimits.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/endian.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_endian.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/param.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/encoding.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_assert.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/rv_utils.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/interrupt_reg.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/register/soc/interrupt_core0_reg.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/soc.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_bit_defs.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/register/soc/reg_base.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_attr.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_assert.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/interrupt.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/esp_private/interrupt_intc.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr_pie.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr_dsp.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_intr_alloc.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_err.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_compiler.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_intr_types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_macros.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_rom/include/esp_rom_sys.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/inttypes.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/reset_reasons.h \
F:/software/.espressif/v6.0/esp-idf/components/heap/include/esp_heap_caps.h \
F:/software/.espressif/v6.0/esp-idf/components/heap/include/multi_heap.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_system/include/esp_system.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_idf_version.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/esp_newlib.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/reent.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/limits.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/limits.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/mpu_wrappers.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/esp_additions/include/freertos/idf_additions.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/task.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/list.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/queue.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/task.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/semphr.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/queue.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/stream_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/message_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/stream_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/event_groups.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/timers.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_wifi/include/esp_wifi.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_wifi/include/esp_wifi_types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_wifi/include/esp_wifi_types_generic.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_event/include/esp_event_base.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_wifi/include/local/esp_wifi_types_native.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_wifi/include/esp_wifi_types_generic.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_event/include/esp_event.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_wifi/include/esp_wifi_crypto_types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_wifi/include/esp_wifi_default.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_netif/include/esp_netif.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_netif/include/esp_netif_ip_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_netif/include/esp_netif_types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_netif/include/esp_netif_defaults.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_timer/include/esp_timer.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/etm/include/esp_etm.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_driver_tsens/include/driver/temperature_sensor.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hal_ana_conv/include/hal/temperature_sensor_types.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/clk_tree_defs.h \
G:\Robot_v2\DCU\ESP32_COMM\main\monitor\health_monitor.h \
G:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/riscv32-esp-elf/include/c++/15.2.0/cstdint \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/c++config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/os_defines.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/cpu_defines.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/riscv32-esp-elf/include/c++/15.2.0/pstl/pstl_config.h \
G:/Robot_v2/DCU/ESP32_COMM/main/include/version.h \
G:\Robot_v2\DCU\ESP32_COMM\main\monitor\debug_log.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/esp32xx/include/sys/socket.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/include/lwip/sockets.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/sockets.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/opt.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/lwipopts.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/unistd.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/unistd.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_pthreadtypes.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/sched.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_timespec.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_sigset.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_timeval.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/unistd.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/fcntl.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/fcntl.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_default_fcntl.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/time.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_time.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/ioctl.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/poll.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_system/include/esp_task.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_random.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/sntp/sntp_get_set_time.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/sockets_ext.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/freertos/include/arch/sys_arch.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/debug.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/arch.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/esp32xx/include/arch/cc.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/errno.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/errno.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/errno.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/ctype.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/def.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip4_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip6_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/def.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip6_zone.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/netif.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/err.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/pbuf.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/stats.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/mem.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/memp.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/memp_std.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/memp_priv.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/mem_priv.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/inet.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/errno.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/net/if.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/if_api.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/netinet/in.h
@@ -1,165 +0,0 @@
esp-idf/transport/CMakeFiles/__idf_transport.dir/__/__/main/transport/protocol.cpp.obj: \
G:\Robot_v2\DCU\ESP32_COMM\main\transport\protocol.cpp \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/string.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/cdefs.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/cdefs.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/ieeefp.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/features.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/picolibc.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/float.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_default_types.h \
G:/Robot_v2/DCU/ESP32_COMM/build/config/sdkconfig.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdint.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stddef.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_intsup.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_stdint.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_locale.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/strings.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/string.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/stdio.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdio.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stdarg.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/lock.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/lock.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_random.h \
F:/software/.espressif/v6.0/esp-idf/components/mbedtls/port/include/aes/esp_aes.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/stdbool.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hal_security/include/hal/aes_types.h \
G:/Robot_v2/DCU/ESP32_COMM/main/include/app_config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/riscv32-esp-elf/include/c++/15.2.0/cstdint \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/c++config.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/os_defines.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/riscv32-esp-elf/include/c++/15.2.0/riscv32-esp-elf/rv32imc_zicsr_zifencei/ilp32/no-rtti/bits/cpu_defines.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/riscv32-esp-elf/include/c++/15.2.0/pstl/pstl_config.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/FreeRTOS.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/config/include/freertos/FreeRTOSConfig.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/assert.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/stdlib.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/stdlib.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/alloca.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_wait.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/assert.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/config/riscv/include/freertos/FreeRTOSConfig_arch.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/projdefs.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/portable.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/deprecated_definitions.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/portable/riscv/include/freertos/portmacro.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/spinlock.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_cpu.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/soc_caps.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/include/soc/soc_caps_eval.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/param.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/syslimits.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/endian.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_endian.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/param.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/encoding.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_assert.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/rv_utils.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/interrupt_reg.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/register/soc/interrupt_core0_reg.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/soc.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_bit_defs.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/register/soc/reg_base.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_attr.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_assert.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/interrupt.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/esp_private/interrupt_intc.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr_pie.h \
F:/software/.espressif/v6.0/esp-idf/components/riscv/include/riscv/csr_dsp.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_intr_alloc.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_err.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_compiler.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_hw_support/include/esp_intr_types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_macros.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_rom/include/esp_rom_sys.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/inttypes.h \
F:/software/.espressif/v6.0/esp-idf/components/soc/esp32c3/include/soc/reset_reasons.h \
F:/software/.espressif/v6.0/esp-idf/components/heap/include/esp_heap_caps.h \
F:/software/.espressif/v6.0/esp-idf/components/heap/include/multi_heap.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_system/include/esp_system.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_common/include/esp_idf_version.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/esp_newlib.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/reent.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/limits.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/lib/gcc/riscv32-esp-elf/15.2.0/include/limits.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/mpu_wrappers.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/esp_additions/include/freertos/idf_additions.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/task.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/list.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/queue.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/task.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/semphr.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/queue.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/stream_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/message_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/stream_buffer.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/event_groups.h \
F:/software/.espressif/v6.0/esp-idf/components/freertos/FreeRTOS-Kernel/include/freertos/timers.h \
G:/Robot_v2/DCU/ESP32_COMM/main/include/version.h \
G:\Robot_v2\DCU\ESP32_COMM\main\transport\protocol.h \
G:/Robot_v2/DCU/ESP32_COMM/main/monitor/debug_log.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/esp32xx/include/sys/socket.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/include/lwip/sockets.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/sockets.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/opt.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/lwipopts.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/unistd.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/unistd.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/types.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_pthreadtypes.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/sched.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_timespec.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/types.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_sigset.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_timeval.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_select.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/unistd.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/fcntl.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/fcntl.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/_default_fcntl.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/time.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/time.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/machine/_time.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/ioctl.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/poll.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_system/include/esp_task.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/sntp/sntp_get_set_time.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/sockets_ext.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/freertos/include/arch/sys_arch.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/debug.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/arch.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/esp32xx/include/arch/cc.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/errno.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/sys/errno.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/sys/errno.h \
C:/Espressif/tools/riscv32-esp-elf/esp-15.2.0_20251204/riscv32-esp-elf/picolibc/include/ctype.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/def.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip4_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip6_addr.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/def.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/ip6_zone.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/netif.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/err.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/pbuf.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/stats.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/mem.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/memp.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/memp_std.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/memp_priv.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/priv/mem_priv.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/inet.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/errno.h \
F:/software/.espressif/v6.0/esp-idf/components/esp_libc/platform_include/net/if.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/lwip/src/include/lwip/if_api.h \
F:/software/.espressif/v6.0/esp-idf/components/lwip/port/include/netinet/in.h
+27 -13
View File
@@ -2,16 +2,27 @@
idf_component_register(
SRCS
"core/main.cpp" # 程序入口 (app_main)
"core/system_boot.cpp" # 启动模块位图与汇总
"core/build_info.cpp" # 真实编译时间(每次构建自动刷新,见文末 gen_build_time)
"ota/esp32_ota_core.cpp" # ESP32 本机 OTA 核心(分区写入/SHA验证)
"ota/ota_coordinator.cpp" # OTA 多通道协调器(状态机/互斥)
"relay/ota_relay.cpp" # GD32 SPI OTA 中继状态机
"storage/gd32_flash_store.cpp" # GD32 Flash 存储抽象层(H1 提取)
"server/wifi_ota.cpp" # WiFi HTTP OTA 服务器
"server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取)
"server/ble_ota.cpp" # BLE GATT OTA 服务器
"monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存)
"monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1)
"server/ble_ota.cpp" # BLE OTA 公开 API / 收尾 / 共存
"server/ble_ota_data.cpp" # BLE OTA 数据路径(reorder/worker/DATA_IN)
"server/ble_ota_host.cpp" # BLE OTA GATT/GAP/广播/Host
"monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检)
"monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务
"monitor/can_feedback_task.cpp" # CAN 反馈被动监听(0xC1 takeEvent)+ 分发 0xC4
"monitor/gd32_adc_feedback.cpp" # GD32 ADC 调试反馈缓存(0xC4)
"monitor/motor_state.cpp" # GD32 多电机状态缓存(0xC5)
"monitor/motor_version.cpp" # GD32 多电机版本缓存(0xC6,事件驱动)
"monitor/motor_ota_relay.cpp" # 电机 OTA relay(0xD0~0xD2)
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
"monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子
"monitor/spi_speed_test.cpp" # [SPI-SPEEDTEST] 临时:SPI 测速端点(可整行删除)
INCLUDE_DIRS
"."
"core"
@@ -24,7 +35,6 @@ idf_component_register(
REQUIRES
transport # M5: 传输层组件(SPI/CANFD/协议)
app_update # ESP32 OTA API
cjson # JSON 解析/生成
esp_driver_tsens # 温度传感器驱动
esp_driver_uart # UART 驱动(调试日志)
esp_wifi # WiFi STA/AP 模式
@@ -40,12 +50,16 @@ idf_component_register(
bt # NimBLE BLE 协议栈
)
# 每次构建时自动刷新 version.h(BUILD_TIME / GIT_HASH)
add_custom_target(version_header ALL
COMMAND ${CMAKE_COMMAND}
-DSOURCE_DIR=${CMAKE_SOURCE_DIR}
-DOUTPUT_FILE=${CMAKE_CURRENT_SOURCE_DIR}/include/version.h
-P ${CMAKE_SOURCE_DIR}/cmake/gen_version_script.cmake
COMMENT "Refreshing version.h"
VERBATIM
)
# ── 真实编译时间:每次构建重新生成 build_time_gen.h ──
# 背景:version.h 的 BUILD_TIME 是手写死值(停在 2026-07-26 20:25),刷新固件后仍显示旧时间,
# 被误判为 OTA 回退;__DATE__/__TIME__ 方案在增量编译下同样会过期。
# 机制:gen_build_time 无产物依赖 → 每次构建必跑 → 重写头文件 → 仅 core/build_info.cpp 重编+重链。
set(BUILD_TIME_HDR "${CMAKE_CURRENT_BINARY_DIR}/gen/build_time_gen.h")
add_custom_target(gen_build_time
COMMAND ${CMAKE_COMMAND} -D OUT=${BUILD_TIME_HDR} -P ${CMAKE_CURRENT_LIST_DIR}/gen_build_time.cmake
BYPRODUCTS ${BUILD_TIME_HDR}
COMMENT "Refreshing build_time_gen.h"
)
add_dependencies(${COMPONENT_LIB} gen_build_time)
target_include_directories(${COMPONENT_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/gen")
+5 -13
View File
@@ -10,25 +10,17 @@ menu "DCU OTA Configuration"
validated against actual wiring length/quality and GD32
SPI slave timing.
config DCU_OTA_PACKET_INTERVAL_MS
int "Delay between GD32 OTA packets (ms)"
default 1
range 0 50
help
Idle delay inserted after each successfully acknowledged
firmware packet before sending the next one.
Lower value speeds up OTA, but if GD32 cannot finish
flash write / prepare next ACK in time, set it back.
config DCU_OTA_POLL_BACKOFF_MS
int "Polling backoff when GD32 is not ready (ms)"
default 2
range 0 50
help
Delay between SPI response polls when GD32 returns WAIT
or does not drive MISO yet. Lower value reduces per-packet
idle time and can significantly speed up OTA, but if GD32
cannot finish flash write before the next poll, increase it.
or does not drive MISO yet. Packet pacing uses SPI Link v2
CREDIT backpressure in SpiMaster (remoteCredit==0 → wait/
refuse TX), not a fixed inter-packet delay. Lower value
reduces poll idle time; increase if GD32 needs more time
between polls.
config DCU_OTA_FINISH_TIMEOUT_MS
int "Timeout for FINISH_OTA command (ms)"
+12
View File
@@ -0,0 +1,12 @@
/*
* build_info.cpp - 唯一包含自动生成时间头的编译单元。
* build_time_gen.h 每次构建都被 gen_build_time 目标重写 → 仅本文件重编 + 重链接,
* 其余增量编译不受影响;其他模块经 fwBuildTime() 取值。
*/
#include "build_info.h"
#include "build_time_gen.h"
const char *fwBuildTime(void)
{
return FW_BUILD_TIME_AUTO;
}
+94 -121
View File
@@ -1,19 +1,29 @@
/*
/*
* ESP32-C3FH4 OTA 中继 - 程序入口
* 四个 FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT
*
* 使用各模块的单例类进行初始化和任务创建。
* 初始化顺序(每位成功后 SystemBoot::mark=1,末尾统一 report):
* 1. 冷启动延时
* 2. netif / event_loop / debug_log
* 3. wifi earlyInit
* 4. health_mon + task
* 5. cpu_stats(可选)
* 6. protocol / ota_coord
* 7. spi init + L0 本机自检 → mark spi;HELLO 对端轻检 → mark can_link(非关键)
* 8. ota_relay / wifi_ota / ble_ota 任务
* 9. can_fb 任务(依赖 spi L0)
* 10. ota_mark_valid(可选)
* 11. SystemBoot::report()
*/
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_chip_info.h"
#include "esp_system.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "esp_ota_ops.h"
#include "system_boot.h"
#include "monitor/health_monitor.h"
#include "monitor/cpu_stats.h"
#include "relay/ota_relay.h"
#include "ota/ota_coordinator.h"
#include "transport/spi_master.h"
@@ -24,153 +34,116 @@
#include "app_config.h"
#include "ota/esp32_ota_core.h"
#include "monitor/can_feedback_task.h"
#include "monitor/motor_ota_relay.h"
/** @brief 创建关键任务;成功返回 true。 */
static bool createCriticalTask(TaskFunction_t entry, const char *name,
uint32_t stack, UBaseType_t prio)
{
return xTaskCreatePinnedToCore(entry, name, stack, NULL, prio, NULL, 0) == pdPASS;
}
/**
* @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。
* @brief 程序主入口:按模块初始化并汇总结果。
*/
extern "C" void app_main(void)
{
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
SystemBoot::reset();
/* 上电/欠压复位后短延时再初始化子系统 */
{
esp_reset_reason_t rr = esp_reset_reason();
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
vTaskDelay(pdMS_TO_TICKS(200));
}
}
// ★ M1: ESP32 OTA 分区互斥锁内聚到 Esp32OtaCore(替代旧的全局变量)
Esp32OtaCore::initMutex();
// 初始化网络接口 + 事件循环(必须最早)
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
if (esp_netif_init() == ESP_OK) {
SystemBoot::mark(BOOT_NETIF);
}
if (esp_event_loop_create_default() == ESP_OK) {
SystemBoot::mark(BOOT_EVENT_LOOP);
}
// 初始化调试日志(UART0 先就绪,UDP socket 延迟到首次发送时创建)
DebugLog::instance().init();
SystemBoot::mark(BOOT_DEBUG_LOG);
// ★ WiFi 提前初始化(调度器启动前)
bool wifi_sta_ok = WifiOta::instance().earlyInit();
/* earlyInit:true=已启动 STA 连接流程;false=将走 AP */
const bool wifi_early_started_sta = WifiOta::instance().earlyInit();
SystemBoot::mark(BOOT_WIFI_EARLY);
HealthMonitor::instance().init();
DEBUG_INFO("MAIN", "健康监控状态已初始化");
DEBUG_INFO("MAIN", "========================================");
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v1.0 启动");
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", "CPU Freq: %d MHz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ);
DEBUG_INFO("MAIN", "WiFi: %s", wifi_sta_ok ? "STA 连接中" : "将使用 AP 模式");
DEBUG_INFO("MAIN", "========================================");
SystemBoot::mark(BOOT_HEALTH_MON);
// 健康监控任务(温度 + WiFi RSSI)
BaseType_t xRet;
xRet = xTaskCreatePinnedToCore(
HealthMonitor::taskEntry,
"health_mon",
4096,
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", "健康监控任务已创建");
if (createCriticalTask(HealthMonitor::taskEntry, "health_mon",
TASK_STACK_HEALTH_MON, configMAX_PRIORITIES - 4)) {
SystemBoot::mark(BOOT_HEALTH_TASK);
}
CpuStats::instance().start();
SystemBoot::mark(BOOT_CPU_STATS);
Protocol::instance().init();
DEBUG_INFO("MAIN", "协议层初始化完成");
SystemBoot::mark(BOOT_PROTOCOL);
// ★ 全局 OTA 协调器初始化(在 OTA task 创建前,确保 mutex 就绪)
OtaCoordinator::instance().init();
// ★ L1: 注册 BLE 射频共存监听器(WifiOta 实现 IBleCoexListener)
OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance());
DEBUG_INFO("MAIN", "OTA 全局协调器已初始化");
SystemBoot::mark(BOOT_OTA_COORD);
esp_err_t ret = SpiMaster::instance().init();
if (ret != ESP_OK) {
DEBUG_ERR("MAIN", "SPI 总线初始化失败: %d", ret);
} else {
DEBUG_INFO("MAIN", "SPI 总线初始化完成");
bool spi_l0_ok = false;
{
esp_err_t ret = SpiMaster::instance().init();
if (ret == ESP_OK && SpiMaster::instance().selfTestL0()) {
SystemBoot::mark(BOOT_SPI);
spi_l0_ok = true;
/* 对端轻检:GD32 可能仍在启动延时,失败不拉垮关键启动 */
if (SpiMaster::instance().selfTestPeerL0()) {
SystemBoot::mark(BOOT_CAN_LINK);
}
}
}
// OTA 中继任务(中高优先级)
xRet = xTaskCreatePinnedToCore(
OtaRelay::taskEntry,
"ota_relay",
6144,
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 中继任务已创建");
if (createCriticalTask(OtaRelay::taskEntry, "ota_relay",
TASK_STACK_OTA_RELAY, configMAX_PRIORITIES - 2)) {
SystemBoot::mark(BOOT_OTA_RELAY_TASK);
}
// WiFi OTA 服务器任务
xRet = xTaskCreatePinnedToCore(
WifiOta::taskEntry,
"wifi_ota",
8192,
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 任务已创建");
if (createCriticalTask(WifiOta::taskEntry, "wifi_ota",
TASK_STACK_WIFI_OTA, configMAX_PRIORITIES - 3)) {
SystemBoot::mark(BOOT_WIFI_TASK);
}
// BLE OTA 任务
xRet = xTaskCreatePinnedToCore(
BleOta::taskEntry,
"ble_ota",
4096,
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 任务已创建");
if (createCriticalTask(BleOta::taskEntry, "ble_ota",
TASK_STACK_BLE_OTA, configMAX_PRIORITIES - 3)) {
SystemBoot::mark(BOOT_BLE_TASK);
}
// CAN 1Hz 反馈轮询任务(依赖 SPI 已初始化)
if (startCanFeedbackTask()) {
DEBUG_INFO("MAIN", "CAN 反馈轮询任务已创建");
} else {
DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)");
/* CAN 反馈依赖 SPI 本机 L0 */
if (spi_l0_ok && startCanFeedbackTask()) {
SystemBoot::mark(BOOT_CAN_FB_TASK);
}
// ★ OTA 回滚保护:标记当前固件有效
// 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数
// 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区
esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback();
if (ota_valid_err == ESP_OK) {
DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效");
} else {
DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动", esp_err_to_name(ota_valid_err));
motorOtaRelayInit();
if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) {
SystemBoot::mark(BOOT_OTA_VALID);
}
DEBUG_INFO("MAIN", "========================================");
DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中");
DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)",
DEBUG_LOG_IP, DEBUG_LOG_PORT);
DEBUG_INFO("MAIN", "========================================");
/* ★ banner 文案必须包含「启动」,前端 polling.js 靠
* /OTA Relay .*启动/ 匹配本行判定 ESP32 重启完成(阶段 6)。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动, heap=%lu, wifi_early=%s",
FW_VERSION,
(unsigned long)esp_get_free_heap_size(),
wifi_early_started_sta ? "STA" : "AP/other");
const bool critical_ok = SystemBoot::report();
if (!critical_ok) {
DEBUG_ERR("MAIN", "critical init FAIL, restart in 3s");
vTaskDelay(pdMS_TO_TICKS(3000));
esp_restart();
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* @file system_boot.cpp
* @brief 启动模块位图实现。
*/
#include "system_boot.h"
#include "monitor/debug_log.h"
#include <stdio.h>
#include <string.h>
namespace {
struct BootEntry {
uint32_t bit;
const char *name;
bool critical;
};
static const BootEntry kEntries[] = {
{ BOOT_NETIF, "netif", true },
{ BOOT_EVENT_LOOP, "event_loop", true },
{ BOOT_DEBUG_LOG, "debug_log", true },
{ BOOT_WIFI_EARLY, "wifi_early", true },
{ BOOT_HEALTH_MON, "health_mon", true },
{ BOOT_HEALTH_TASK, "health_task", true },
{ BOOT_CPU_STATS, "cpu_stats", false },
{ BOOT_PROTOCOL, "protocol", true },
{ BOOT_OTA_COORD, "ota_coord", true },
{ BOOT_SPI, "spi", true },
{ BOOT_OTA_RELAY_TASK, "ota_relay_task", true },
{ BOOT_WIFI_TASK, "wifi_task", true },
{ BOOT_BLE_TASK, "ble_task", true },
{ BOOT_CAN_LINK, "can_link", false },
{ BOOT_CAN_FB_TASK, "can_fb_task", false },
{ BOOT_OTA_VALID, "ota_mark_valid", false },
};
static uint32_t s_mask = 0U;
static void evaluate(bool *critical_ok, bool *all_ok, char *fail_list, size_t fail_cap)
{
*critical_ok = true;
*all_ok = true;
if (fail_list && fail_cap > 0U) {
fail_list[0] = '\0';
}
size_t fail_len = 0U;
for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) {
const BootEntry &e = kEntries[i];
const bool ok = (s_mask & e.bit) != 0U;
if (ok) {
continue;
}
*all_ok = false;
if (e.critical) {
*critical_ok = false;
}
if (!fail_list || fail_cap == 0U) {
continue;
}
if (fail_len + 2U < fail_cap) {
if (fail_len > 0U) {
fail_list[fail_len++] = ',';
}
const char *p = e.name;
while (*p != '\0' && fail_len + 1U < fail_cap) {
fail_list[fail_len++] = *p++;
}
fail_list[fail_len] = '\0';
}
}
}
} // namespace
void SystemBoot::reset()
{
s_mask = 0U;
}
void SystemBoot::mark(SystemBootBit bit)
{
s_mask |= static_cast<uint32_t>(bit);
}
bool SystemBoot::report()
{
bool critical_ok = true;
bool all_ok = true;
char fail_list[192] = {0};
evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list));
if (critical_ok && all_ok) {
DEBUG_INFO("BOOT", "init: OK (all modules)");
} else if (critical_ok) {
DEBUG_WARN("BOOT", "init: OK (critical), skipped/failed: %s",
fail_list[0] != '\0' ? fail_list : "?");
} else {
DEBUG_ERR("BOOT", "init: FAIL, modules: %s",
fail_list[0] != '\0' ? fail_list : "?");
}
return critical_ok;
}
int SystemBoot::appendStatusJson(char *buf, size_t cap, int offset)
{
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) {
return offset;
}
bool critical_ok = true;
bool all_ok = true;
char fail_list[192] = {0};
evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list));
const char *summary;
if (critical_ok && all_ok) {
summary = "OK (all modules)";
} else if (critical_ok) {
summary = "OK (critical)";
} else {
summary = "FAIL";
}
int n = snprintf(buf + offset, cap - (size_t)offset,
",\"boot\":{\"critical_ok\":%s,\"all_ok\":%s,\"mask\":%lu,"
"\"summary\":\"%s\",\"failed\":\"%s\",\"modules\":[",
critical_ok ? "true" : "false",
all_ok ? "true" : "false",
(unsigned long)s_mask,
summary,
fail_list);
if (n < 0) {
return offset;
}
if (offset + n >= (int)cap) {
buf[cap - 1] = '\0';
return (int)(cap - 1);
}
offset += n;
for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) {
const BootEntry &e = kEntries[i];
const bool ok = (s_mask & e.bit) != 0U;
n = snprintf(buf + offset, cap - (size_t)offset,
"%s{\"name\":\"%s\",\"ok\":%s,\"critical\":%s}",
i == 0 ? "" : ",",
e.name,
ok ? "true" : "false",
e.critical ? "true" : "false");
if (n < 0) {
return offset;
}
if (offset + n >= (int)cap) {
buf[cap - 1] = '\0';
return (int)(cap - 1);
}
offset += n;
}
n = snprintf(buf + offset, cap - (size_t)offset, "]}");
if (n < 0) {
return offset;
}
if (offset + n >= (int)cap) {
buf[cap - 1] = '\0';
return (int)(cap - 1);
}
return offset + n;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* @file system_boot.h
* @brief ESP32 启动阶段模块初始化位图与汇总报告。
*
* 每个模块 init/建任务成功后 mark(bit)=1,启动末尾 report() 打印整体结果。
* /system/status 通过 appendStatusJson() 把同一份结果暴露给网页。
*/
#ifndef SYSTEM_BOOT_H
#define SYSTEM_BOOT_H
#include <stddef.h>
#include <stdint.h>
#include <stdbool.h>
/** 启动模块位(成功置 1) */
enum SystemBootBit : uint32_t {
BOOT_NETIF = 1u << 0,
BOOT_EVENT_LOOP = 1u << 1,
BOOT_DEBUG_LOG = 1u << 2,
BOOT_WIFI_EARLY = 1u << 3,
BOOT_HEALTH_MON = 1u << 4,
BOOT_HEALTH_TASK = 1u << 5,
BOOT_CPU_STATS = 1u << 6,
BOOT_PROTOCOL = 1u << 7,
BOOT_OTA_COORD = 1u << 8,
BOOT_SPI = 1u << 9, ///< SPI 主机 L0(本机就绪)
BOOT_OTA_RELAY_TASK = 1u << 10,
BOOT_WIFI_TASK = 1u << 11,
BOOT_BLE_TASK = 1u << 12,
BOOT_CAN_LINK = 1u << 13, ///< SPI 对端 L0(HELLO 可达 GD32,非关键)
BOOT_CAN_FB_TASK = 1u << 14, ///< CAN 反馈任务(依赖 SPI L0)
BOOT_OTA_VALID = 1u << 15,
};
class SystemBoot {
public:
static void reset();
static void mark(SystemBootBit bit);
/** 串口汇总;返回关键模块是否全部成功 */
static bool report();
/**
* @brief 追加 boot JSON 字段(含前导逗号)到 status 缓冲。
* @return 新的写入偏移
*/
static int appendStatusJson(char *buf, size_t cap, int offset);
};
#endif /* SYSTEM_BOOT_H */
+8
View File
@@ -0,0 +1,8 @@
# 每次构建执行:生成真实编译时间头文件(由 main/CMakeLists.txt 的 gen_build_time 目标调用)。
# 手工维护的 version.h BUILD_TIME 曾停留在旧值,导致「刷了新固件仍显示旧时间」被误判为回退。
string(TIMESTAMP _build_time "%Y-%m-%d %H:%M:%S")
file(WRITE "${OUT}"
"/* 自动生成(每次构建刷新),勿手改、勿提交。仅 core/build_info.cpp 包含本文件。 */
#pragma once
#define FW_BUILD_TIME_AUTO \"${_build_time}\"
")
+77 -42
View File
@@ -3,27 +3,28 @@
* ESP32-C3 OTA 中继项目
*
* 使用 constexpr 代替 #define,提供类型安全
* 版本号和编译时间由 version.h 自动生成
* 版本号和编译时间在 version.h 中手动维护
*/
#pragma once
#include <cstdint>
#include "freertos/FreeRTOS.h"
#include "version.h"
#include "build_info.h"
// ============================================================
// 固件版本(由 version.h 自动生成,此处提供 C++ constexpr 访问)
// 功能开关(ESP32 本机;GD32 侧开关随 HELLO/PING 身份块上报,无需镜像)
// ============================================================
inline constexpr const char *FW_VERSION = FW_VERSION_STRING; ///< 固件版本字符串(自动生成)
inline constexpr const char *FW_BUILD_TIME = BUILD_TIME; ///< 编译时间字符串(自动生成)
inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(自动生成)
// ============================================================
// WiFi 工作模式选择
// ============================================================
// 0 = AP 模式(设备当热点)
// 1 = STA 模式(连接路由器)
// 0 = AP 热点,1 = STA 连路由器
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
// ============================================================
// 固件版本(在 version.h 中手动维护,此处提供 C++ constexpr 访问)
// ============================================================
inline constexpr const char *FW_VERSION = FW_VERSION_STRING; ///< 固件版本字符串(手动维护)
/* FW_BUILD_TIME 已删:手写死值停在旧时间,刷新固件仍显示 20:25 被误判回退。
* 改用 fwBuildTime()(build_info.h,构建系统每次自动生成真实编译时间)。 */
inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(手动维护)
// ============================================================
// WiFi STA 模式配置(连接路由器)
// ============================================================
@@ -32,15 +33,6 @@ inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622"; ///< STA 模
inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000; ///< STA 连接超时(毫秒)
inline constexpr uint8_t WIFI_STA_MAX_RETRY = 5; // 最大重连次数,超过后切换 AP 模式
// STA 静态 IP
inline constexpr uint8_t WIFI_STA_USE_STATIC_IP = 0; // 先关闭静态 IP,使用 DHCP 排查连接问题
// 静态 IP 配置(始终定义,由 WIFI_STA_USE_STATIC_IP 在运行时决定是否使用)
inline constexpr const char* WIFI_STA_STATIC_IP = "192.168.31.32";
inline constexpr const char* WIFI_STA_STATIC_GW = "192.168.31.1";
inline constexpr const char* WIFI_STA_STATIC_NETMASK = "255.255.255.0";
inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.31.1";
// ============================================================
// WiFi AP 模式配置(设备当热点)
// ============================================================
@@ -53,30 +45,43 @@ inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; ///< AP 模
// HTTP 服务器配置
// ============================================================
inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服务器监听端口
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; ///< HTTP 服务器任务栈大小(字节)
// ★ 曾降至 6142 省 2KB,但 system_status_handler→sample_cpu_usage() 在栈上分配
// TaskStatus_t tasks[24](~864B)+previous_samples[24](192B)+CpuUsageSnapshot(~608B)≈1.7KB,
// 加上 spi_send_handler 的 buf[512]+resp[1024]+SPI 调用链 ≈3.5KB,
// 再加 httpd 框架开销 ~2KB → 总需求逼近 6KB,6144 栈导致栈溢出 → TCB 被破坏
// → "Stack protection fault"(栈边界被 "http" ASCII 覆盖)。恢复 8192 留足余量。
// ============================================================
// 任务栈大小(字节)— 2026-07 按板端 task_stack 水位收敛
// 规则:S ≈ peak_used + 1.5~2KB;偏松的收、偏紧的不砍。
// 板测峰值量级(常规/OTA):
// http~5.2KB, spi~1.9KB, wifi_ota~1.2KB, ble_ota~0.9KB,
// ble_worker 1004B, health/cpu~1.8KB, ota_relay 轻载~1.3KB。
// 2026-07 余量优化:收 http/spi/wifi/ble* 偏松项,health/cpu/relay 保持。
// ============================================================
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 7168; ///< httpd:peak~5.2KB + ~2KB → 7KB(原 8/10KB)
inline constexpr uint32_t TASK_STACK_WIFI_OTA = 3072; ///< wifi_ota:peak~1.2KB + ~1.8KB → 3KB(原 4/8KB)
inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(轻载 peak~1.3KB;转发突发不砍)
inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:5KB(覆盖超时重试+日志深层调用栈安全余量)
inline constexpr uint32_t TASK_STACK_BLE_OTA = 2560; ///< ble_ota:peak~0.9KB + ~1.5KB → 2.5KB(原 3KB)
inline constexpr uint32_t TASK_STACK_BLE_WORKER = 2560; ///< ble_ota_worker:peak 1004B + ~1.5KB → 2.5KB(原 3KB)
inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.8KB,余量~1.2KB,不砍
inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~1.8KB,余量~1.2KB,不砍
inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:轻载,2.5KB 保持
inline constexpr uint32_t TASK_STACK_CAN_FB = 4096; ///< can_fb:4KB(覆盖 0xC4/0xC5 周期查询超时与日志调用栈)
inline constexpr uint32_t TASK_STACK_BLE_FINISH = 4096; ///< 按需:SHA finalize(保持)
inline constexpr uint32_t TASK_STACK_BLE_GD32_WAIT = 3072; ///< 按需:GD32 中继等待
inline constexpr uint32_t TASK_STACK_BLE_RESTART = 2048; ///< 按需:延时重启
// ============================================================
// OTA 配置
// ============================================================
inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< OTA 上传静态接收缓冲区,从 8192 降到 4096 省 4KB
inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< 上传接收块:4KB(吞吐优先);会话内 malloc,稳态不占 .bss
// OTA_BLOCK_SIZE 演进:8KB→2KB(压堆)→4KB(试吞吐)→2KB(2026-07 回退)→4KB(2026-07-20 再提吞吐)。
// 2026-09:s_ota_upload_recv 从常驻静态改为 OTA 会话内 RAII malloc,稳态省 4KB。
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB = 221184B = 0x35F90,固件大小上限
inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷
inline constexpr uint32_t OTA_POLL_RETRIES = 40; // 每包 ACK 轮询次数
inline constexpr uint32_t OTA_RESEND_RETRIES = 10; // 每包重发次数上限
inline constexpr uint32_t OTA_PACKET_DELAY_MS = 5; // 每包发送后延时(ms),给 GD32 处理时间
inline constexpr uint32_t OTA_POST_SEND_DELAY_MS = 3; // 发送请求后首次轮询前的延迟(ms),无 READY 拉低时防止读到 stale 数据
inline constexpr uint32_t OTA_POLL_NO_FRAME_DELAY_MS = 8; // 轮询未找到帧时的重试延迟(ms),无 READY 拉低时需更长
// ============================================================
// 健康监控阈值
// ============================================================
inline constexpr int32_t WIFI_RSSI_CRITICAL = -85; ///< WiFi 信号严重警告阈值(dBm)
inline constexpr int32_t WIFI_RSSI_WARNING = -75; ///< WiFi 信号一般警告阈值(dBm)
inline constexpr int32_t WIFI_RSSI_WARNING = -80; ///< WiFi 信号一般警告阈值(dBm)
inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; ///< OTA 传输期间 WiFi 信号临界阈值
inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; ///< RSSI 采样最大有效期(毫秒)
inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; ///< 每 N 包执行一次健康检查
@@ -86,11 +91,40 @@ inline constexpr size_t HEAP_WARNING_MIN = 10240; ///< 堆内
// ============================================================
// 调试配置(UDP 日志)
// GD32 ADC/CAN 等开关:看 HELLO_ACK/PING 身份块 feat 位(GD32 config 顶部)
// ============================================================
inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; ///< UDP 调试日志开关(1=启用)
inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; ///< UDP 日志目标 IP
inline constexpr uint16_t DEBUG_LOG_PORT = 6666; ///< UDP 日志目标端口
// ============================================================
// 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 满缓冲(读预算用)
// ------------------------------------------------------------
// SPI 调试日志级别(编译期裁剪,改完重编生效;用法见 spi_master.cpp 顶部门)
// 3 = 全开(逐事务 DBG + INFO 明细,SPI 联调用)
// 2 = 只留 ERR/WARN(★默认:真实故障仍可见,心跳/HELLO/INIT_STATUS 明细静默)
// 1 = 只留 ERR 0 = 全静默
// GD32 侧对应开关:DCU_APP/GD32E508_DCU_APP/sys/config.h
// CONFIG_SPI_APP_LOG_ENABLE / CONFIG_SPI_DIAG_PERIODIC_PRINT
// ------------------------------------------------------------
#define CONFIG_SPI_DEBUG_LOG_LEVEL 2
// ============================================================
// 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 配置
// ============================================================
@@ -109,9 +143,16 @@ inline constexpr uint8_t BLE_OTA_CTRL_ABORT_OTA = 0xFB; ///< BLE 控
inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; ///< BLE 数据通道:GD32 固件包标识
inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; ///< BLE 数据通道:ESP32 固件包标识
inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; ///< BLE 单包数据载荷大小(字节)
inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; ///< BLE 包头+序号+校验开销(字节)
// 滑动 ACK 窗口(须与前端 tools/js/ble/constants.js BLE_OTA_ACK_WINDOW 保持一致)
// ★ 64:实测 Windows WebBT 窗口内单包间隔 0.4-2.9ms(2M PHY 多包/事件),
// 瓶颈在每窗口的 ACK 往返。窗口 32→64 提速 ~10%(40→44 KB/s),再试 96 无收益
// (ACK 往返在 64 已摊薄,稳态瓶颈是 Windows GATT 每包 ~10.6ms resolve 延迟)。
// 内存:64×480B ≈ 30KB reorder buffer,分配失败有 fallback 丢弃策略兜底。
inline constexpr size_t BLE_OTA_ACK_WINDOW = 64;
// Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW)
inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32;
inline constexpr size_t BLE_OTA_REORDER_WINDOW = 64;
// 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批
inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 16);
// ============================================================
// 安全常量(需与 GD32 固件团队确认)
@@ -125,12 +166,6 @@ inline constexpr uint8_t MASTER_KEY[] = {
// SCRAMBLE_CONST 已移至 Protocol 类,运行时由硬件 RNG 随机生成
inline constexpr size_t SCRAMBLE_CONST_LEN = 16;
// GD32_CHIP_UID 仅保留为默认占位,运行时以握手包解出的芯片地址 UID 为准。
inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U; ///< GD32 设备序列号低字 Flash 地址
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU; ///< GD32 设备序列号中字 Flash 地址
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U; ///< GD32 设备序列号高字 Flash 地址
inline constexpr uint8_t GD32_CHIP_UID[] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
};
+15
View File
@@ -0,0 +1,15 @@
/*
* build_info.h - 真实编译时间(构建系统每次自动生成,替代 version.h 手工 BUILD_TIME)
*/
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/** 返回本固件的真实编译时间字符串 "YYYY-MM-DD HH:MM:SS"(每次构建自动刷新)。 */
const char *fwBuildTime(void);
#ifdef __cplusplus
}
#endif
+16 -9
View File
@@ -1,14 +1,21 @@
// Auto-generated version info - DO NOT EDIT
/*
* version.h - 固件版本信息(手动维护)
*
* 注意:本文件不再由构建脚本自动生成。如需修改版本号或 Git Hash,
* 请直接编辑本文件。
* - FW_VERSION_MAJOR / MINOR / PATCH:发版时手动递增
* - GIT_HASH:手动填写对应提交的 git short hash
* - 编译时间:已改为构建系统自动生成(build_info.h 的 fwBuildTime(),
* 每次构建刷新),手写 BUILD_TIME 已删——旧值停在 2026-07-26 20:25
* 曾被误判为 OTA 回退。
*/
#pragma once
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 40
#define FW_VERSION_STRING "0.3.40"
#define FW_VERSION_MINOR 5
#define FW_VERSION_PATCH 2
#define FW_VERSION_STRING "0.5.2" // 0.5.2 CAN 通道收发器 Silent(只听不发):新增 SPI 0xC8 桥接 canFdSetTxSilent + POST /can/silent,0xC1 bus_state 增 bit5=tx_silent;静默通道发送回 0xE6。0.5.1 SPI 调试日志编译期门:app_config.h 新增 CONFIG_SPI_DEBUG_LOG_LEVEL=2,spi_master.cpp 的 84 条 DEBUG_*("SPI") 中 DBG/INFO 被裁掉(心跳/HELLO/INIT_STATUS 明细静默),ERR/WARN 保留 // 内存:s_ota_upload_recv 从常驻静态 4KB 改为 OTA 会话内 RAII malloc,稳态省 4KB 内部 DRAM(httpd 单线程+协调器互斥,结束/失败自动 free)。0.4.9 tx_total/tx_retry/tx_fail/tx_fail_last_type 计数进 /system/status:透明重试(kTxRetryMax=3)掩盖真实失败率,DBG 日志被 CONFIG_LOG_MAXIMUM_LEVEL=3 编译期裁掉→"默默失败无打印"。0.4.8 ring_steal_diag=[rxLen,rb0,rb1] 撕裂形态签名 // 0.4.5 CS 事务间最小间隔 1ms(transferClockedFlags 单点):GD32 CS EXTI re-arm 被 ECAT/CAN 中断延迟时,ESP32 背靠背 CS 撞进 re-arm 窗口使 RX DMA mid-pulse 重装→查询帧撕裂→0x82 0xE8→迟到偷读风暴。0.4.4 快速响应类型禁用盲读。0.4.3 0x82 量化归因插桩。0.4.2 serviceRing 偷读迟到 RESPONSE 的 seq 失配修复:poll 超时放弃后按铃读把响应当 EVENT 偷走且不推进 m_txSeq→下一条命令复用旧序号被 GD32 判重吞掉回放缓存旧 0x82(命令不再执行根因)。0.3.85 INIT_STATUS fault_codes hex;0.3.84 模块级故障码;0.3.83 GD32 APP 初始化状态可视;0.3.82 UART0 RX 泵→/logs;0.3.81 GD32 OTA 包级防呆
// 编译时间
#define BUILD_TIME "2026-07-11 17:47:55"
// Git 信息
#define GIT_HASH "fedb08e"
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
+152 -104
View File
@@ -1,117 +1,184 @@
/*
* can_feedback_task.cpp - 被动监听 GD32 主动推送的 CAN 反馈快照
* can_feedback_task.cpp - GD32 周期查询 + 事件消费任务
*
* 架构(GD32 主动推送,ESP32 被动接收):
* GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页,
* 通过 requestEsp32Read(0xC1) 下拉 SPIready 主动按铃,将每页反馈 payload
* 推送给 ESP32。ESP32 本任务调用 checkGd32Request() 阻塞等待按铃,读到
* 一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再
* publish() 到 s_latest[channel] 供 Web/WebSocket 读取。
* 架构(2026-08 改造后):
* 0xC1 CAN 反馈:请求驱动(HTTP 侧 canFdQueryFeedback),本任务不处理。
* 0xC4 ADC / 0xC5 电机状态:由 GD32 主动按铃推送改为本任务周期查询——
* 0xC5 每 250ms 查 MOTOR_TOTAL_PAGES 页,0xC4 每 1s 查 1 帧,查询响应
* payload 格式与推送一致,复用 onMotorStateEvent()/onGd32AdcFeedbackEvent()
* 解析入库。消除 OTA 期间 GD32 悬空按铃(ESP32 抑制 ring 不读)导致
* “按铃超时取消”刷屏。
* 0xC6 电机版本 / 0xD2 电机 OTA 进度:仍由 GD32 事件驱动推送,本任务 takeEvent 消费。
*
* 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。
*
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务(不查询不消费);
* 电机 OTA 期间(motorOtaIsActive())也暂停,避免与 0xD1 喂数抢占 SPI。
*/
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "transport/canfd_bridge.h"
#include "app_config.h"
#include "transport/spi_master.h"
#include "monitor/debug_log.h"
#include "monitor/can_feedback_task.h"
#include "monitor/gd32_adc_feedback.h"
#include "monitor/motor_state.h"
#include "monitor/motor_version.h"
#include "monitor/motor_ota_relay.h"
#include "esp_system.h"
#include <string.h>
namespace {
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr size_t kRingPayloadMax = 128U; ///< 反馈 payload 最大长度
constexpr const char *kTag = "CAN_FB";
constexpr uint32_t kQueryMotorStateMs = 250U; ///< 0xC5 电机状态查询周期
constexpr uint32_t kQueryAdcMs = 1000U; ///< 0xC4 ADC 查询周期
constexpr uint32_t kEventWaitMs = 20U; ///< 单次等 0xC6/0xD2 事件超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态查询
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送
constexpr size_t kPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
static can_feedback_result_t s_latest[CAN_CHANNEL_MAX];
static bool s_valid [CAN_CHANNEL_MAX];
static SemaphoreHandle_t s_mutex = nullptr;
static volatile bool s_paused = false;
static volatile bool s_paused = false;
static TaskHandle_t s_taskHandle = nullptr;
/* 累积工作缓冲:跨 page 0/1 累积每通道的完整反馈 */
static can_feedback_result_t s_work [CAN_CHANNEL_MAX];
static uint8_t s_workIdx[CAN_CHANNEL_MAX];
static uint32_t s_lastLoggedError[CAN_CHANNEL_MAX];
/* 任务独占静态响应缓冲(消除每次在任务栈上分配 256B 大数组,防栈溢出) */
static uint8_t s_motor_resp_buf[kPayloadMax] = {};
static uint8_t s_adc_resp_buf[kPayloadMax] = {};
static uint8_t s_push_ring_buf[kPayloadMax] = {};
static void publish(uint8_t ch, const can_feedback_result_t &fb)
/**
* @brief 按页查询 GD32 电机状态(0xC5 请求驱动,替代 GD32 250ms 推送)。
* 任一页失败立即中断本轮:GD32 未就绪/瞬时错误时不空耗 6×200ms,下轮再试;
* page0 会刷新全局字段(active_count/boot_active),部分页成功即部分更新。
*/
static bool queryMotorStatePages(void)
{
if (s_mutex == nullptr) return;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_latest[ch] = fb;
s_valid[ch] = true;
xSemaphoreGive(s_mutex);
uint8_t req[1] = {0U};
for (uint8_t page = 0U; page < MOTOR_TOTAL_PAGES; ++page) {
req[0] = page;
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t err = SpiMaster::instance().transact(
kSpiMotorStateType, req, sizeof(req),
&resp_type, s_motor_resp_buf, sizeof(s_motor_resp_buf), &resp_len, 200U);
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK ||
resp_len < MOTOR_PAGE_HEADER) {
return false;
}
(void)onMotorStateEvent(s_motor_resp_buf, resp_len);
}
return true;
}
/**
* @brief 查询 GD32 ADC 调试快照(0xC4 请求驱动,替代 GD32 1Hz 推送)。
* GD32 0xC4 handler 回自身类型 0xC4(异于 0xC1/0xC5 的 0x80),两者皆接受。
*/
static bool queryAdcFeedback(void)
{
uint8_t req[1] = {0U};
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t err = SpiMaster::instance().transact(
kSpiAdcFeedbackType, req, sizeof(req),
&resp_type, s_adc_resp_buf, sizeof(s_adc_resp_buf), &resp_len, 200U);
if (err != ESP_OK || resp_len < 1U) {
return false;
}
if (resp_type != kSpiAdcFeedbackType && resp_type != SPI_OTA_RSP_ACK) {
return false;
}
(void)onGd32AdcFeedbackEvent(s_adc_resp_buf, resp_len);
return true;
}
/**
* @brief 消费剩余事件驱动推送(0xC6 电机版本 / 0xD2 电机 OTA 进度)。
* 0xC4/0xC5 已改请求驱动,不再有对应推送;未知类型静默跳过。
*/
static void drainPushEvents(uint32_t wait_ms)
{
uint8_t ring_type = 0U;
size_t ring_len = 0U;
if (!SpiMaster::instance().takeEvent(&ring_type, s_push_ring_buf,
sizeof(s_push_ring_buf), &ring_len,
wait_ms)) {
return;
}
if (ring_type == kSpiMotorVersionType) {
(void)onMotorVersionEvent(s_push_ring_buf, ring_len);
} else if (ring_type == kSpiMotorOtaStatusType) {
onMotorOtaStatusPush(s_push_ring_buf, ring_len);
}
}
/**
* @brief can_fb 任务主循环:周期查询 0xC5/0xC4 + 消费 0xC6/0xD2 推送。
* OTA / 电机 OTA 期间暂停,SPI 交由 OTA 独占。
*/
static void taskLoop(void *)
{
DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)");
DEBUG_INFO(kTag, "请求驱动查询任务启动 (0xC5 250ms / 0xC4 1s / 0xC6,0xD2 推送)");
uint8_t ring_type = 0U;
uint8_t ring_payload[kRingPayloadMax] = {};
size_t ring_len = 0U;
/* 冷启动延后开始查询,错开上电初始化窗口 */
{
esp_reset_reason_t rr = esp_reset_reason();
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
constexpr uint32_t kCanFbDeferMs = 1500U;
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始查询",
(unsigned long)kCanFbDeferMs);
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
}
}
TickType_t last_motor_q = xTaskGetTickCount();
TickType_t last_adc_q = xTaskGetTickCount();
for (;;) {
if (s_paused) {
if (s_paused || motorOtaIsActive()) {
vTaskDelay(pdMS_TO_TICKS(kPauseDelayMs));
continue;
}
/* 阻塞等待 GD32 按铃并读取一帧(超时则重试) */
esp_err_t ret = SpiMaster::instance().checkGd32Request(kWaitRingMs);
if (ret != ESP_OK) {
/* 链路在线自适应退避:若 SPI 处于离线状态,进入休眠退避,避免死循环重试和日志风暴 */
if (!SpiMaster::instance().isLinkOnline()) {
vTaskDelay(pdMS_TO_TICKS(2000));
/* 尝试轻量查询一次作为探活 */
(void)queryMotorStatePages();
last_motor_q = xTaskGetTickCount();
last_adc_q = xTaskGetTickCount();
continue;
}
/* 取出 checkGd32Request 存入的 type/payload */
if (!SpiMaster::instance().takeRingRx(&ring_type, ring_payload,
sizeof(ring_payload), &ring_len)) {
continue;
/* 周期查询:到期才执行(时间差对比,暂停恢复后立即补一次) */
const TickType_t now = xTaskGetTickCount();
if ((now - last_motor_q) >= pdMS_TO_TICKS(kQueryMotorStateMs)) {
queryMotorStatePages();
last_motor_q = xTaskGetTickCount();
}
if ((now - last_adc_q) >= pdMS_TO_TICKS(kQueryAdcMs)) {
queryAdcFeedback();
last_adc_q = xTaskGetTickCount();
}
if (ring_type != kSpiCanFeedbackType || ring_len < 28U) {
continue;
}
const uint8_t ch = ring_payload[0];
if (ch >= CAN_CHANNEL_MAX) {
continue;
}
/* 解析本页并累积到工作缓冲,page=0 会自动重置 */
if (canFdParseFeedbackPage(ring_payload, ring_len,
&s_work[ch], &s_workIdx[ch]) == 0) {
publish(ch, s_work[ch]);
/* 累计错误发生变化时才告警一次;P0/P1 携带相同统计,不能重复打印。 */
if (s_work[ch].stats.error_count != 0U
&& s_work[ch].stats.error_count != s_lastLoggedError[ch]) {
DEBUG_WARN(kTag,
"CH%u seq=%lu err=%lu TEC=%u REC=%u state=0x%02X",
static_cast<unsigned>(ch),
static_cast<unsigned long>(s_work[ch].stats.snapshot_seq),
static_cast<unsigned long>(s_work[ch].stats.error_count),
static_cast<unsigned>(s_work[ch].stats.tec),
static_cast<unsigned>(s_work[ch].stats.rec),
static_cast<unsigned>(s_work[ch].stats.bus_state));
s_lastLoggedError[ch] = s_work[ch].stats.error_count;
}
}
/* 消费 0xC6/0xD2:短阻塞等 worker 入队(事件驱动唤醒) */
drainPushEvents(kEventWaitMs);
}
}
} // namespace
TaskHandle_t getCanFeedbackTaskHandle()
{
return s_taskHandle;
}
/**
* @brief 启动 CAN 反馈被动监听任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask()
@@ -119,26 +186,13 @@ bool startCanFeedbackTask()
static bool s_started = false;
if (s_started) return true;
if (s_mutex == nullptr) {
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == nullptr) {
DEBUG_ERR(kTag, "互斥量创建失败");
return false;
}
}
memset(s_latest, 0, sizeof(s_latest));
memset(s_lastLoggedError, 0, sizeof(s_lastLoggedError));
memset(s_valid, 0, sizeof(s_valid));
memset(s_work, 0, sizeof(s_work));
memset(s_workIdx, 0, sizeof(s_workIdx));
BaseType_t xRet = xTaskCreatePinnedToCore(
taskLoop,
"can_fb",
4096,
TASK_STACK_CAN_FB,
nullptr,
4,
nullptr,
&s_taskHandle,
0);
if (xRet != pdPASS) {
DEBUG_ERR(kTag, "任务创建失败");
@@ -148,32 +202,26 @@ bool startCanFeedbackTask()
return true;
}
/**
* @brief 暂停/恢复周期查询与事件消费。
* @param pause true=OTA 期间暂停;false=恢复
*/
void pauseCanFeedbackTask(bool pause)
{
if (s_paused == pause) return; // 幂等:状态未变时不重复日志
s_paused = pause;
if (pause) {
DEBUG_INFO(kTag, "CAN 监听已暂停(OTA 进行中)");
DEBUG_DBG(kTag, "周期查询已暂停(OTA 进行中)");
} else {
DEBUG_INFO(kTag, "CAN 监听已恢复");
DEBUG_DBG(kTag, "周期查询已恢复");
}
}
/**
* @brief 拷贝某通道的最新快照(线程安全)。
* @return true 表示拿到一份有效数据。
* @brief 查询是否处于暂停(OTA / send_batch 期间为 true)。
* 暂停期间 HTTP handler 应跳过 0xC1 查询,避免与 OTA 抢占 SPI。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out)
bool canFeedbackPaused()
{
if (channel >= CAN_CHANNEL_MAX || out == nullptr || s_mutex == nullptr) {
return false;
}
bool ok = false;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
if (s_valid[channel]) {
*out = s_latest[channel];
ok = true;
}
xSemaphoreGive(s_mutex);
}
return ok;
}
return s_paused;
}
+16 -16
View File
@@ -1,35 +1,35 @@
/*
* can_feedback_task.h - 周期性轮询 GD32 CAN 反馈快照的任务接口
/*
* can_feedback_task.h - GD32 周期查询 + 事件消费任务
*
* 2026-08 架构改造:0xC1 CAN 反馈请求驱动(canFdQueryFeedback);
* 0xC4 ADC / 0xC5 电机状态改为本任务周期查询(250ms/1s);
* 0xC6 电机版本 / 0xD2 电机 OTA 进度保留事件推送,由本任务 takeEvent 消费。
*/
#ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H
#include <stdint.h>
#include "transport/canfd_bridge.h"
/** @brief 暂停/恢复 CAN 反馈轮询(OTA 期间调用 pause=true)。 */
/** @brief 暂停/恢复周期查询与事件消费(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause);
/** @brief 查询是否处于暂停(OTA / send_batch / 电机 OTA 期间为 true)。
* 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */
bool canFeedbackPaused();
/** @brief 获取 can_fb 任务句柄(用于栈水位监控)。 */
TaskHandle_t getCanFeedbackTaskHandle();
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief 启动 1Hz CAN 反馈轮询任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask();
/**
* @brief 拷贝某通道的最新反馈快照(线程安全)。
* @param channel 0/1/2。
* @param out 输出缓冲。
* @return true 表示拿到一份有效数据,false 表示通道无效或尚未拿到第一份。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out);
#ifdef __cplusplus
}
#endif
#endif // CAN_FEEDBACK_TASK_H
#endif // CAN_FEEDBACK_TASK_H
+219
View File
@@ -0,0 +1,219 @@
/*
* cpu_stats.cpp - 1Hz FreeRTOS CPU 占用采样任务
*/
#include "cpu_stats.h"
#include <algorithm>
#include <cstring>
#include <inttypes.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;
return inst;
}
void CpuStats::start()
{
if (m_started) {
return;
}
m_started = true;
#if !defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
portENTER_CRITICAL(&m_mux);
m_published = {};
m_published.available = false;
portEXIT_CRITICAL(&m_mux);
DEBUG_WARN("CPU", "未启用 CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS,跳过采样任务");
return;
#else
portENTER_CRITICAL(&m_mux);
m_published = {};
m_published.available = true;
m_published.valid = false;
portEXIT_CRITICAL(&m_mux);
BaseType_t ok = xTaskCreatePinnedToCore(
&CpuStats::taskEntry,
"cpu_stats",
TASK_STACK_CPU_STATS,
this,
tskIDLE_PRIORITY + 1, /* 低优先级,少搅动占用统计 */
&m_taskHandle,
0);
if (ok != pdPASS) {
m_taskHandle = nullptr;
m_started = false;
DEBUG_ERR("CPU", "创建 cpu_stats 任务失败");
return;
}
DEBUG_INFO("CPU", "cpu_stats 任务已启动,采样周期 %" PRIu32 " ms", CPU_STATS_SAMPLE_MS);
#endif
}
void CpuStats::getSnapshot(CpuUsageSnapshot& out) const
{
portENTER_CRITICAL(&m_mux);
out = m_published;
portEXIT_CRITICAL(&m_mux);
}
#if defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS)
static uint64_t runtimeDelta(configRUN_TIME_COUNTER_TYPE cur, configRUN_TIME_COUNTER_TYPE prev)
{
return cur >= prev ? (uint64_t)(cur - prev) : 0U;
}
static bool taskNameIsIdle(const char* name)
{
return name && strncmp(name, "IDLE", 4) == 0;
}
void CpuStats::sampleOnce()
{
const UBaseType_t live = uxTaskGetNumberOfTasks();
if (live == 0) {
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;
const UBaseType_t captured =
uxTaskGetSystemState(m_taskStatusBuf, buf_slots, &total_runtime);
const int64_t now_us = esp_timer_get_time();
if (captured == 0 || captured > buf_slots) {
DEBUG_WARN("CPU", "uxTaskGetSystemState 异常: captured=%lu buf=%lu",
(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 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);
}
}
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 < 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; /* 新任务:本窗无 delta */
}
/* 单任务计数回绕时 runtimeDelta→0,本任务本窗记 0 */
uint64_t delta = runtimeDelta(m_taskStatusBuf[i].ulRunTimeCounter, last_runtime);
double pct = (double)delta * 100.0 / (double)total_delta;
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 = 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 = m_taskStatusBuf[i].xTaskNumber;
m.runtime_delta = delta;
m.usage_pct = pct;
}
}
snap.valid = true;
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);
}
/* 更新基线(首拍 / 正常窗 / 回绕丢窗后均重建) */
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;
portEXIT_CRITICAL(&m_mux);
}
void CpuStats::taskEntry(void* arg)
{
auto* self = static_cast<CpuStats*>(arg);
DEBUG_INFO("CPU", "=== cpu_stats 采样任务启动 ===");
TickType_t last_wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(CPU_STATS_SAMPLE_MS);
/* 第一拍只建基线(valid=false);此后每拍窗口 ≈ 1000ms */
for (;;) {
self->sampleOnce();
vTaskDelayUntil(&last_wake, period);
}
}
#else /* !CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS */
void CpuStats::sampleOnce() {}
void CpuStats::taskEntry(void*) { vTaskDelete(nullptr); }
#endif
+85
View File
@@ -0,0 +1,85 @@
/*
* cpu_stats.h - FreeRTOS 运行时 CPU 占用采样
*
* 由独立低优先级任务按固定 1s 周期采样;/system/status 只读最新快照,
* 不再在 HTTP 线程里调用 uxTaskGetSystemState(避免窗口绑浏览器轮询、减 httpd 栈压)。
*/
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/** 单次快照最多跟踪的任务数。
* 系统常驻任务 ~14(app)+ ~5(IDF: tiT/wifi/nimble/hci/ble)= ~19,
* 按需短暂任务(ble_finish/restart/cancel/gd32_wait/ota_feed)不常驻。
* 20 覆盖常驻集 + 余量 1;偶发按需任务多出时 uxTaskGetSystemState 截断,
* 仅影响该拍 CPU 统计显示,不影响功能。原 32 浪费 ~1KB BSS。 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 20;
/** 采样周期(ms)— 窗口 sample_ms 应对齐此值 */
static constexpr uint32_t CPU_STATS_SAMPLE_MS = 1000;
struct CpuTaskMetric {
char name[configMAX_TASK_NAME_LEN];
UBaseType_t task_number;
uint64_t runtime_delta;
double usage_pct;
};
struct CpuUsageSnapshot {
bool available; ///< 固件是否启用 RUN_TIME_STATS
bool valid; ///< 是否已有完整窗口(至少两拍)
uint32_t sample_ms; ///< 本窗口墙钟长度
double usage_pct; ///< 100 - idle
double idle_pct;
CpuTaskMetric tasks[CPU_TASK_SNAPSHOT_MAX];
size_t task_count;
};
/**
* @brief CPU 运行时统计(单例 + 1Hz 采样任务)
*/
class CpuStats {
public:
static CpuStats& instance();
/** 创建采样任务(幂等;失败仅打日志,不阻塞启动) */
void start();
/** 线程安全拷贝最新快照(HTTP 用) */
void getSnapshot(CpuUsageSnapshot& out) const;
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
private:
CpuStats() = default;
static void taskEntry(void* arg);
void sampleOnce();
mutable portMUX_TYPE m_mux = portMUX_INITIALIZER_UNLOCKED;
CpuUsageSnapshot m_published = {};
TaskHandle_t m_taskHandle = nullptr;
bool m_started = false;
/* 基线(仅采样任务写) */
struct TaskSample {
UBaseType_t task_number;
configRUN_TIME_COUNTER_TYPE runtime;
};
TaskSample m_lastSamples[CPU_TASK_SNAPSHOT_MAX] = {};
size_t m_lastCount = 0;
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;
};
+598 -189
View File
@@ -6,83 +6,253 @@
* 2. UDP 单播到指定上位机(独立 UDP 调试接收端可选)
* 3. 内部环形缓冲区(供 HTTP /logs 拉取,WebUI 实际使用这一条)
*
* UART0: TX=GPIO21, RX=GPIO20, 115200 波特率
* UART0: TX=GPIO21, RX=GPIO20, 3000000 波特率
* UDP: <DEBUG_LOG_IP>:<DEBUG_LOG_PORT>
*
* 时序说明:
* 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 <stdlib.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;
}
/**
* @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)
// ============================================================
bool DebugLog::allocRing(int slots)
{
if (slots <= 0) {
return false;
}
char *rows = static_cast<char *>(calloc((size_t)slots, (size_t)LOG_RING_SIZE));
int *lens = static_cast<int *>(calloc((size_t)slots, sizeof(int)));
if (!rows || !lens) {
free(rows);
free(lens);
return false;
}
free(m_logRing);
free(m_logRingLens);
m_logRing = rows;
m_logRingLens = lens;
m_logRingSlots = slots;
m_logRingHead = 0;
m_logRingCount = 0;
return true;
}
void DebugLog::shrinkRingAfterBoot()
{
if (m_logRingShrunk || !m_logRingMutex) {
return;
}
if (m_logRingSlots > 0 && m_logRingSlots <= LOG_RING_SLOTS_STEADY) {
m_logRingShrunk = true;
return;
}
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
const int old_slots = m_logRingSlots;
const int old_count = m_logRingCount;
const int old_head = m_logRingHead;
char *old_ring = m_logRing;
int *old_lens = m_logRingLens;
const int new_slots = LOG_RING_SLOTS_STEADY;
char *rows = static_cast<char *>(calloc((size_t)new_slots, (size_t)LOG_RING_SIZE));
int *lens = static_cast<int *>(calloc((size_t)new_slots, sizeof(int)));
if (!rows || !lens) {
free(rows);
free(lens);
xSemaphoreGive(m_logRingMutex);
ESP_LOGW(TAG, "log ring shrink failed, keep boot ring (%d slots)", old_slots);
return;
}
/* 保留时间序上最新的 min(count, new_slots) 条 */
int keep = old_count;
if (keep > new_slots) {
keep = new_slots;
}
int new_head = 0;
if (old_ring && old_lens && keep > 0 && old_slots > 0) {
for (int i = 0; i < keep; ++i) {
/* 从最旧保留起点:跳过被挤掉的旧条 */
const int src_i = old_count - keep + i;
const int src = (old_head - old_count + src_i + old_slots * 4) % old_slots;
int len = old_lens[src];
if (len < 0) {
len = 0;
}
if (len >= LOG_RING_SIZE) {
len = LOG_RING_SIZE - 1;
}
char *dst = rows + (size_t)new_head * (size_t)LOG_RING_SIZE;
if (len > 0) {
memcpy(dst, old_ring + (size_t)src * (size_t)LOG_RING_SIZE, (size_t)len);
}
dst[len] = '\0';
lens[new_head] = len;
new_head = (new_head + 1) % new_slots;
}
}
m_logRing = rows;
m_logRingLens = lens;
m_logRingSlots = new_slots;
m_logRingCount = keep;
m_logRingHead = (keep >= new_slots) ? new_head : keep;
m_logRingShrunk = true;
xSemaphoreGive(m_logRingMutex);
free(old_ring);
free(old_lens);
ESP_LOGI(TAG, "log ring shrunk: %d → %d slots (free ~%d B)",
old_slots, new_slots,
(old_slots - new_slots) * (LOG_RING_SIZE + (int)sizeof(int)));
}
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 || !m_logRing || m_logRingSlots <= 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 + m_logRingSlots) % m_logRingSlots;
int len = m_logRingLens[idx];
if (len < 0) {
len = 0;
@@ -94,7 +264,7 @@ bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len)
}
if (copy_len > 0) {
memcpy(out, m_logRing[idx], (size_t)copy_len);
memcpy(out, ringSlot(idx), (size_t)copy_len);
}
out[copy_len] = '\0';
if (out_len) {
@@ -103,108 +273,194 @@ bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len)
m_logRingLens[idx] = 0;
m_logRingCount--;
xSemaphoreGive(m_logRingMutex);
return true;
}
/**
* @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。
* @param out_len 输出 JSON 字符串长度。
* @return 动态分配的 JSON 字符串,调用者须 free()。
*/
char* DebugLog::ringDrainJson(int *out_len)
bool DebugLog::ringPopOldest(char *out, size_t out_size, int *out_len)
{
if (!out_len) return NULL;
*out_len = 0;
int count = ringCount();
// ★ 限制单次导出条数,避免 count=64 时 malloc(32KB) 在堆紧张时失败
// 前端每秒轮询,16 条/次足够保持实时性;超出的旧条目下一轮再拉
static constexpr int kMaxDrain = 16;
if (count > kMaxDrain) count = kMaxDrain;
int capacity = count * (LOG_RING_SIZE * 2) + 16;
if (capacity < 16) capacity = 16;
char *buf = (char*)malloc(capacity);
if (!buf) return NULL;
if (out_len) {
*out_len = 0;
}
if (!m_logRingMutex || !out || out_size == 0) {
return false;
}
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
const bool ok = ringPopOldestLocked(out, out_size, out_len);
xSemaphoreGive(m_logRingMutex);
return ok;
}
/** 预览即将 drain 的条数与原始字节总和(用于估 capacity)。 */
int DebugLog::ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const
{
if (total_raw_len) {
*total_raw_len = 0;
}
if (!m_logRing || m_logRingSlots <= 0) {
return 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 + m_logRingSlots) % m_logRingSlots;
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 || !m_logRing || !m_logRingLens || m_logRingSlots <= 0) {
return;
}
int copy_len = len;
if (copy_len >= LOG_RING_SIZE) {
copy_len = LOG_RING_SIZE - 1;
}
/* 满环时优先覆盖 INFO/DBG,保护 ERR/WARN。 */
if (m_logRingCount >= m_logRingSlots) {
int victim_idx = m_logRingHead;
for (int i = 0; i < m_logRingSlots; ++i) {
const int idx = (m_logRingHead + i) % m_logRingSlots;
const char *s = ringSlot(idx);
if (s[0] == '[' && (s[1] == 'I' || s[1] == 'D')) {
victim_idx = idx;
break;
}
}
m_logRingHead = victim_idx;
}
char *slot = ringSlot(m_logRingHead);
memcpy(slot, data, (size_t)copy_len);
slot[copy_len] = '\0';
m_logRingLens[m_logRingHead] = copy_len;
m_logRingHead = (m_logRingHead + 1) % m_logRingSlots;
if (m_logRingCount < m_logRingSlots) {
m_logRingCount++;
}
}
void DebugLog::ringPush(const char *data, int len)
{
if (!m_logRingMutex || len <= 0) {
return;
}
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
ringPushLocked(data, len);
xSemaphoreGive(m_logRingMutex);
}
/**
* 流式导出:peek 快照条数 → 逐条 pop(仅 pop 期间持锁)转义拼 JSON →
* 攒满 512B 栈缓冲即经 sink 发一个 chunk。替代原 ~4KB 一次性 malloc,
* 把 /logs 发送路径的应用层瞬时峰值从几 KB 降到栈缓冲级别。
*/
int DebugLog::ringDrainJsonStream(LogJsonSink sink, void *ctx)
{
if (!sink) {
return 0;
}
const uint32_t free_now = esp_get_free_heap_size();
if (free_now < kDrainMinFree || !m_logRingMutex) {
return 0; /* 低堆/未就绪:caller 一次性发 "[]" */
}
/* 先 peek 快照条数:无内容则让 caller 走 "[]" 一次性响应,不进 chunked 编码。 */
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
int total_raw = 0;
int drain_n = ringPeekDrainMetaLocked(LOG_DRAIN_MAX, &total_raw);
xSemaphoreGive(m_logRingMutex);
if (drain_n <= 0) {
return 0;
}
/* 512B 栈缓冲替代原 ~4KB malloc。单条转义最坏 2×LOG_RING_SIZE,
* flush 后空缓冲须能容纳单条 + 括号余量(见 static_assert)。 */
static constexpr int STREAM_BUF_CAP = 512;
static_assert(STREAM_BUF_CAP >= LOG_RING_SIZE * 2 + 8,
"stream buf must hold one worst-case escaped entry");
char buf[STREAM_BUF_CAP];
char entry[LOG_RING_SIZE];
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 期间持锁,绝不在 sink(网络 I/O)时持锁,
* 避免阻塞其它任务写日志。 */
int entry_len = 0;
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
bool popped = ringPopOldestLocked(entry, sizeof(entry), &entry_len) && entry_len > 0;
xSemaphoreGive(m_logRingMutex);
if (!popped) {
break; /* 环已空(并发被别处 drain) */
}
/* 预留单条转义最坏占用再决定是否 flush:jsonEscapeAppend 内部把单条封顶在
* LOG_RING_SIZE*2(控制符 \u00XX 会 6× 膨胀),故按该上限 + 逗号/引号/收尾预留,
* 与 entry_len 无关;放不下先 flush,flush 后空缓冲必能完整容纳单条不被截断。 */
const int need = LOG_RING_SIZE * 2 + 8;
if (pos + need > STREAM_BUF_CAP) {
if (!sink(ctx, buf, pos)) {
return -1;
}
pos = 0;
}
if (!first) {
if (pos + 1 >= capacity - 4) break;
buf[pos++] = ',';
}
first = false;
buf[pos++] = '"';
pos = jsonEscapeAppend(buf, pos, STREAM_BUF_CAP, entry, entry_len);
if (pos < STREAM_BUF_CAP - 1) {
buf[pos++] = '"';
} else {
buf[pos - 1] = '"'; /* 极端兜底:覆盖末字节闭合 */
}
}
// 单条日志转义后超过 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; }
/* 收尾 ']':缓冲放不下先 flush,再单独发闭合。 */
if (pos + 1 > STREAM_BUF_CAP) {
if (!sink(ctx, buf, pos)) {
return -1;
}
if (truncated) {
// 确保 "..." + '"' + ']' + '\0' (共 6 字节) 有空间才写入标记
if (pos + 6 <= capacity) {
buf[pos++] = '.';
buf[pos++] = '.';
buf[pos++] = '.';
}
}
buf[pos++] = '"';
pos = 0;
}
buf[pos++] = ']';
buf[pos] = '\0';
*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);
if (!sink(ctx, buf, pos)) {
return -1;
}
return 1;
}
// ============================================================
// UART 调试日志
// UART
// ============================================================
/**
* @brief 初始化 UART0 调试串口。
* @return esp_err_t 错误码。
*/
esp_err_t DebugLog::uartInit()
{
if (m_uartInitialized) {
@@ -222,7 +478,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;
}
@@ -240,49 +497,68 @@ 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 错误码。
*/
// ============================================================
// UART0 RX 泵:把 RX(GPIO20) 收到的串口行转发进 ring buffer,
// 经 HTTP /logs 出现在网页日志面板(tag=UART)。
// 接线:H1/H2 排针上任意串口源 TX → ESP_RX(GPIO20),3M 8N1。
// ============================================================
static void uartRxPumpTask(void *arg)
{
(void)arg;
uint8_t chunk[64];
char line[128];
int pos = 0;
for (;;) {
const int n = uart_read_bytes(UART_NUM_0, chunk, sizeof(chunk), portMAX_DELAY);
for (int i = 0; i < n; i++) {
const char c = (char)chunk[i];
if (c == '\n' || pos >= (int)sizeof(line) - 1) {
if (pos > 0) {
line[pos] = '\0';
DebugLog::instance().log(DEBUG_LOG_INFO, "UART", "%s", line);
pos = 0;
}
} else if (c != '\r') {
line[pos++] = c;
}
}
}
}
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);
/* 非阻塞直写 FIFO:满了丢弃(日志必进 ring,web /logs 仍完整)。
* 旧实现 uart_write_bytes 在 TX buffer=0 时同步阻塞到整行发完
* (115200 下 ~5-8ms/条),OTA 期间日志密集会把 ota_relay(prio 23)
* / spi_worker(prio 6) 阻塞数 ms~数十 ms——50ms ACK 窗口被拖爆,
* 表现为改短重试后超时重发频繁(线上 12/777)。 */
const int ret = uart_tx_chars(UART_NUM_0, (const char *)data, (int)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));
@@ -290,98 +566,213 @@ 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;
}
if (!allocRing(LOG_RING_SLOTS_BOOT)) {
ESP_LOGE(TAG, "boot log ring alloc 失败 (%d slots),/logs 不可用", LOG_RING_SLOTS_BOOT);
/* UART/UDP 仍可用 */
} else {
ESP_LOGI(TAG, "boot log ring %d×%d (~%d B heap)",
LOG_RING_SLOTS_BOOT, LOG_RING_SIZE,
LOG_RING_SLOTS_BOOT * (LOG_RING_SIZE + (int)sizeof(int)));
}
// 目标地址:单播到上位机
memset(&m_debugAddr, 0, sizeof(m_debugAddr));
m_debugAddr.sin_family = AF_INET;
m_debugAddr.sin_port = htons(DEBUG_LOG_PORT);
m_debugAddr.sin_addr.s_addr = inet_addr(DEBUG_LOG_IP);
/* UART0 RX 泵:串口行 → ring → /logs 网页面板 */
if (m_uartAvailable) {
if (xTaskCreate(uartRxPumpTask, "uart_rx", 3072, nullptr, 1, nullptr) == pdPASS) {
ESP_LOGI(TAG, "UART0 RX 泵已启动 (GPIO%d → /logs)", UART_DEBUG_RX_GPIO);
} else {
ESP_LOGW(TAG, "UART0 RX 泵任务创建失败(堆不足?)");
}
}
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 通道(WiFi 未连接时 sendto 失败,静默跳过)
if (m_initialized) {
if (m_udpSock < 0) {
ensureUdpSocket();
/* UDP:堆紧 / 无 IP 时跳过 sendto;增加关闭后 10s 冷却退避,防连续失败时反复创建/销毁 socket 造成堆抖动 */
static TickType_t s_lastUdpCloseTick = 0;
const TickType_t now_tick = xTaskGetTickCount();
const bool udp_in_cooloff = (s_lastUdpCloseTick != 0) &&
((now_tick - s_lastUdpCloseTick) < pdMS_TO_TICKS(10000));
if (m_initialized && free_now >= kSkipUdpFree && udpNetifHasIp()) {
if (m_udpSock < 0 && !udp_in_cooloff) {
(void)ensureUdpSocket();
}
if (m_udpSock >= 0) {
size_t send_len = len > 1024 ? 1024 : len;
int ret = ::sendto(m_udpSock, data, send_len, 0,
(struct sockaddr *)&m_debugAddr, sizeof(m_debugAddr));
int sock = -1;
portENTER_CRITICAL(&m_udpMux);
sock = m_udpSock;
portEXIT_CRITICAL(&m_udpMux);
if (sock >= 0) {
const size_t send_len = len > 1024 ? 1024 : len;
/* MSG_DONTWAIT:非阻塞发送。lwIP 在 ARP 未解析/路由未就绪时
* sendto 会排队等待(可达 SO_SNDTIMEO 200ms),会阻塞打日志的
* 业务任务(OTA relay/worker)——日志转发必须尽力而为。 */
const int ret = ::sendto(sock, data, send_len, MSG_DONTWAIT,
(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();
s_lastUdpCloseTick = xTaskGetTickCount();
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 连续失败,暂停发送 10s 后重建\n";
ringPush(kMsg, (int)sizeof(kMsg) - 1);
}
}
}
}
} 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, (int)sizeof(kUdpSkipMsg) - 1);
}
}
// 同时写入环形缓冲区(供 HTTP /logs 拉取)
ringPush((const char *)data, (int)len);
if (udp_ret == ESP_OK || uart_ret == ESP_OK) {
return ESP_OK;
}
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;
}
char prefix[32];
const uint32_t free_now = esp_get_free_heap_size();
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 (!keep) {
if (free_now < kDropInfoFree) {
return;
}
} else if (level >= DEBUG_LOG_DBG && free_now < kDropInfoFree) {
/* keep-alive DBG:中等堆紧也丢,减刷屏 */
return;
}
}
const char *level_str;
switch (level) {
case DEBUG_LOG_ERR: level_str = "E"; break;
@@ -390,29 +781,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);
// 控制单条日志栈缓冲大小,避免调用任务(如 can_fb)因日志格式化爆栈。
va_list args;
va_start(args, fmt);
char msg[256];
vsnprintf(msg, sizeof(msg), fmt, args);
va_end(args);
char log_buf[320];
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);
}
+86 -33
View File
@@ -8,8 +8,11 @@
*
* 注意:当前 tools/WebUI 日志面板走的是 HTTP /logs,
* 不是直接监听 UDP 6666。
*
* UART0: TX=GPIO21, RX=GPIO20, 115200 波特率
*
* UART0: TX=GPIO21, RX=GPIO20, 3000000 波特率
*
* 线程模型:DEBUG_* / log() / send() 仅允许在任务上下文调用
* (内部使用 portMAX_DELAY 互斥锁,禁止 ISR)。
*/
#ifndef DEBUG_LOG_H
#define DEBUG_LOG_H
@@ -23,23 +26,27 @@
#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 引脚号
#define UART_DEBUG_RX_GPIO 20 ///< UART0 RX GPIO 引脚号
#define UART_DEBUG_BAUD_RATE 115200 ///< UART0 波特率
#define UART_DEBUG_BAUD_RATE 3000000 ///< UART0 波特率
// 日志级别
typedef enum {
DEBUG_LOG_NONE = 0,
DEBUG_LOG_ERR = 1, // 错误
DEBUG_LOG_WARN = 2, // 警告
DEBUG_LOG_INFO = 3, // 信息
DEBUG_LOG_DBG = 4, // 调试
DEBUG_LOG_ALL = 5 // 全部
DEBUG_LOG_DBG = 4 // 调试
} debug_log_level_t;
/**
* @brief /logs 流式发送 sink:每个 chunk 回调一次;返回 false 表示发送失败需中止。
* @param ctx 透传上下文(如 httpd_req_t*)。
*/
typedef bool (*LogJsonSink)(void *ctx, const char *data, int len);
/**
* @brief 调试日志类(单例)
*
@@ -48,25 +55,27 @@ typedef enum {
*/
class DebugLog {
public:
/**
* @brief 获取 DebugLog 单例实例。
* @return DebugLog 引用。
*/
static DebugLog& instance();
/** @brief 初始化日志系统,包括 UART 输出、UDP 目标和内部缓冲区。 */
/** @brief 初始化日志系统(UART + 开机大环;UDP socket 延迟创建)。 */
esp_err_t init();
/**
* @brief 启动 burst 结束后把环从开机容量缩到稳态小环,释放大块堆。
* 幂等;失败则保留大环。宜在 WiFi/HTTP 就绪后、日志变少时调用。
*/
void shrinkRingAfterBoot();
/** @brief 初始化 UART0 调试串口。 */
esp_err_t uartInit();
/** @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 获取当前环形日志缓冲区中的日志条数。 */
@@ -76,14 +85,18 @@ public:
bool ringPopOldest(char *out, size_t out_size, int *out_len);
/**
* @brief 将环形缓冲区所有日志导出为 JSON 数组字符串。
* @param out_len 输出 JSON 字符串长度。
* @return 动态分配的 JSON 字符串,调用者须 free()。
* @brief 将环形日志以 chunked JSON 数组流式发送,消除一次性大块 malloc。
* 按条 pop(短持锁),512B 栈缓冲攒满即经 sink 发一个 chunk;
* 峰值从原 ~4KB malloc 降为栈缓冲 + 小 chunk。
* @param sink 每个 chunk 的发送回调;返回 false 表示发送失败。
* @param ctx 透传给 sink 的上下文。
* @return 1=已流式发送完整数组(调用方需补发终止 chunk);
* 0=无日志/堆门控(调用方一次性发 "[]");-1=中途发送失败。
*/
char* ringDrainJson(int *out_len);
int ringDrainJsonStream(LogJsonSink sink, void *ctx);
/** @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;
@@ -92,29 +105,69 @@ 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;
static constexpr int LOG_RING_SLOTS = 64;
static constexpr int LOG_RING_SIZE = 256;
char m_logRing[LOG_RING_SLOTS][LOG_RING_SIZE];
int m_logRingLens[LOG_RING_SLOTS];
/* 环形日志(堆上分配,非 .data):
* 开机 LOG_RING_SLOTS_BOOT×128 容纳启动刷屏;
* shrinkRingAfterBoot() 后改为 LOG_RING_SLOTS_STEADY×128,释放大环。
* 业务 DEBUG_* 目标:整行([L][TAG]+正文+\\n)≤128B;槽 128 含 \\0 → 有效≤127。 */
static constexpr int LOG_RING_SLOTS_BOOT = 64; ///< 开机 burst:~8.4KB 堆
static constexpr int LOG_RING_SLOTS_STEADY = 16; ///< 稳态:~2.1KB 堆(对齐 drain)
static constexpr int LOG_RING_SIZE = 128; ///< 单条槽(含 \\0);有效≤127(含换行)
static constexpr int LOG_DRAIN_MAX = 16; ///< 单次 /logs 最多条数
char *m_logRing = nullptr; ///< [slots * LOG_RING_SIZE] 扁平行
int *m_logRingLens = nullptr; ///< [slots]
int m_logRingSlots = 0; ///< 当前容量
int m_logRingHead = 0;
int m_logRingCount = 0;
SemaphoreHandle_t m_logRingMutex = NULL;
bool m_logRingShrunk = false;
SemaphoreHandle_t m_logRingMutex = nullptr;
char *ringSlot(int idx) { return m_logRing + (size_t)idx * (size_t)LOG_RING_SIZE; }
const char *ringSlot(int idx) const {
return m_logRing + (size_t)idx * (size_t)LOG_RING_SIZE;
}
bool allocRing(int slots);
/* 堆门控(防 OTA 时 UDP/格式化抢 LWIP 堆):
* ERR/WARN 永不因堆门控丢弃
* free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO
* free < kDropWarnFree → 全局丢 DBG
* free < kSkipUdpFree → 不 sendto(仍 UART+ring)
* free < kDrainMinFree → /logs 返 "[]"(低堆时连流式小 chunk 的 pbuf 也省)
* 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;
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 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
+114
View File
@@ -0,0 +1,114 @@
/*
* gd32_adc_feedback.cpp - GD32 ADC 调试反馈解析与缓存
*/
#include "monitor/gd32_adc_feedback.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
namespace {
constexpr uint8_t kPayloadVer = 1U;
constexpr size_t kPayloadLen = 20U;
static SemaphoreHandle_t s_mux = nullptr;
static gd32_adc_feedback_t s_latest = {};
static bool s_have = false;
} // namespace
/**
* @brief 处理一帧 0xC4 ADC 调试反馈并更新最新快照。
* @param payload 固定 20B 载荷
* @param len 实际长度
* @return true 解析并写入成功
*/
bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len)
{
if (payload == nullptr || len < kPayloadLen) {
return false;
}
if (payload[0] != kPayloadVer) {
return false;
}
const uint8_t flags = payload[1];
const int16_t temp_x100 = static_cast<int16_t>(rdU16Le(&payload[2]));
const uint16_t vdda_mV = rdU16Le(&payload[4]);
const uint16_t vin_mV = rdU16Le(&payload[6]);
const uint16_t temp_raw = rdU16Le(&payload[8]);
const uint16_t vref_raw = rdU16Le(&payload[10]);
const uint32_t seq = rdU32Le(&payload[12]);
const uint32_t uptime = rdU32Le(&payload[16]);
gd32_adc_feedback_t fb = {};
fb.valid = (flags & 0x01U) != 0U;
fb.temperature_c = static_cast<float>(temp_x100) / 100.0f;
fb.vdda_v = static_cast<float>(vdda_mV) / 1000.0f;
fb.vin_adc_v = static_cast<float>(vin_mV) / 1000.0f;
/* 原理图:VCC — R18 47k — VIN_ADC — R13 4.7k — GND
* VCC = V_adc * (R18+R13)/R13 = V_adc * 11 */
fb.vcc_v = fb.vin_adc_v * ((47.0f + 4.7f) / 4.7f);
fb.temp_raw = temp_raw;
fb.vref_raw = vref_raw;
fb.seq = seq;
fb.uptime_sec = uptime;
fb.recv_ms = esp_timer_get_time() / 1000;
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) {
return false;
}
s_latest = fb;
s_have = true;
xSemaphoreGive(s_mux);
return true;
}
/**
* @brief 线程安全拷贝最近一次 ADC 反馈。
* @param[out] out 输出缓冲
* @return true 已有至少一帧有效数据
*/
bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
{
if (out == nullptr) {
return false;
}
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
bool ok = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
if (s_have) {
*out = s_latest;
ok = true;
}
xSemaphoreGive(s_mux);
}
return ok;
}
/**
* @brief 距上次收到 0xC4 的毫秒数。
* @return 年龄 ms;尚无数据返回 UINT32_MAX
*/
uint32_t gd32AdcFeedbackAgeMs(void)
{
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return UINT32_MAX;
}
int64_t recv = 0;
bool have = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
have = s_have;
recv = s_latest.recv_ms;
xSemaphoreGive(s_mux);
}
return ringCacheAgeMs(have, recv);
}
+53
View File
@@ -0,0 +1,53 @@
/*
* gd32_adc_feedback.h - 被动接收 GD32 ADC 调试反馈(SPI TYPE 0xC4)
*
* 与 CAN 0xC1 同模型:GD32 1Hz 主动按铃推送,ESP32 takeEvent 侧解析入库。
* 是否推送由 GD32 CONFIG_SPI_ADC_FEEDBACK_ENABLE 决定,经 HELLO/PING 身份块
* (V2_FEAT_ADC_FEEDBACK)上报;ESP32 无本地开关,有帧即解析。
*/
#ifndef GD32_ADC_FEEDBACK_H
#define GD32_ADC_FEEDBACK_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/** @brief 最近一次 GD32 ADC 调试快照(工程量已换算)。 */
typedef struct {
bool valid; ///< GD32 sample.valid
float temperature_c; ///< ℃
float vdda_v; ///< V(MCU 模拟供电,约 3.3)
float vin_adc_v; ///< V(分压后 ADC 引脚 PA6)
float vcc_v; ///< V(输入母线 VCC,由 vin 分压反推)
uint16_t temp_raw;
uint16_t vref_raw;
uint32_t seq;
uint32_t uptime_sec; ///< GD32 侧 uptime
int64_t recv_ms; ///< ESP32 收到时 esp_timer ms
} gd32_adc_feedback_t;
/**
* @brief 处理一帧 0xC4 EVENT payload(由 can_fb 任务 takeEvent 分发)。
* @return true 表示已接受并更新快照。
*/
bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len);
/**
* @brief 拷贝最新快照。
* @param[out] out 输出缓冲
* @return true 表示至少收到过一帧
*/
bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out);
/** @brief 距离上次收到的毫秒数;无数据返回 UINT32_MAX。 */
uint32_t gd32AdcFeedbackAgeMs(void);
#ifdef __cplusplus
}
#endif
#endif /* GD32_ADC_FEEDBACK_H */
+537 -22
View File
@@ -8,16 +8,197 @@
*/
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_wifi.h"
#include "esp_timer.h"
#include "esp_err.h"
#include "esp_heap_caps.h"
#include "esp_system.h"
#include "driver/temperature_sensor.h"
#include "health_monitor.h"
#include "debug_log.h"
#include "task_stack.h"
#include "ota/ota_coordinator.h"
#include "ble_ota.h"
#include "wifi_ota.h"
#include "spi_master.h"
#include "esp_event.h"
#include "esp_netif_types.h"
#include "server/http_util.h"
// ============================================================
// heap hook:捕获"破新低时的最后一次 alloc"(最后一击,未必是主因)
// ============================================================
// CONFIG_HEAP_USE_HOOKS=y 时,每次 malloc 成功后进入本弱符号。
//
// ★ 为何不能记任务名(用户看到"每次都是 WiFi"的根因):
// 1) WiFi IRAM 路径禁止在 hook 里调 FreeRTOS/pcTaskGetName(会搞挂 STA)。
// 2) 即便能取任务名:HTTP/UDP **发送前** lwip 仍会为 pbuf/TX 再 malloc,
// 芯片 minimum free 往往在这条"最后一击"上被压低,任务名就会是 wifi/tiT/httpd,
// 看起来像"WiFi 吃堆",实际只是发送路径的末包分配。
// 因此 hook **永不写任务名**;只记 size + caller 地址(addr2line)。
// 前端诊断应标明「最后一击 / 可能网络发送」,勿把连接列的 WiFi 当成触发任务。
//
// ★ 记录策略:health_mon 周期把真实 free 同步进 g_est_free;
// hook 内仅做 g_est_free -= size(C3 单核,无锁可接受漂移)。
// 当 est 创新低时更新 hook 快照。free hook 不知 size,靠周期 resync 纠偏。
//
// ★ 采样协议(消除多字段撕裂读):
// hook 写「最后一击」快照的多个字段(min_est/alloc_size/caller/free_after/fired
// + recent_allocs[])虽各自是字宽 volatile,但 reader 跨字段组合可撕裂
// (fired=1 配上上一次的 alloc_size)。reader(maybeRecordHeapLowWater)在
// portENTER_CRITICAL(&s_hookMux) 内一次性拷出全部字段——单核禁中断即与 hook
// (在 heap 临界区内写)互斥。m_mutex 不参与此互斥(hook 不获取它)。
// reader 临界区仅做 volatile 拷贝,不分配、不调 FreeRTOS/flash,保持极短。
namespace {
/** 最近若干次「值得关注」的 alloc(size≥阈值),供破新低时对照。 */
struct RecentAllocSnap {
uint32_t size;
uint32_t caller;
};
static constexpr uint32_t kHookInterestingMin = 48; ///< 忽略极小碎片分配
static constexpr size_t kRecentAllocCap = 4;
volatile uint32_t g_hook_min_est = 0xFFFFFFFFu; ///< hook 见过的最低 est free
volatile uint32_t g_hook_alloc_size = 0; ///< 触发 est 新低的那次 alloc 大小
volatile uint32_t g_hook_caller_addr = 0; ///< 调用者返回地址
volatile uint32_t g_hook_free_after = 0; ///< 那次后的 est free(estimated,非精确实测)
volatile uint32_t g_hook_fired = 0;
volatile uint32_t g_hook_armed = 0;
volatile uint32_t g_est_free = 0; ///< estimated 近似空闲(只减不增),health_mon 周期用 esp_get_free_heap_size() 实测 resync 纠偏;误差上界 ≈ resync 周期(5s)内总 alloc 字节
volatile uint32_t g_ip_event_count = 0;
RecentAllocSnap g_recent_allocs[kRecentAllocCap] = {};
volatile uint32_t g_recent_alloc_w = 0;
volatile uint32_t g_recent_alloc_n = 0;
/* s_hookMux:reader(maybeRecordHeapLowWater,任务上下文)取多字段一致快照用的
* portMUX(单核 = 禁中断)。hook 端不加锁——它在 heap 临界区内写,与 reader 的
* 禁中断互斥。仅用于消除「最后一击」快照多字段撕裂读,不保护 m_heapLowWater
* (后者由 m_mutex 保护)。 */
static portMUX_TYPE s_hookMux = portMUX_INITIALIZER_UNLOCKED;
void hookResyncEstFree(uint32_t real_free)
{
g_est_free = real_free;
}
/** @brief reader 一次性拷出的 hook 共享状态一致快照(任务上下文,禁中断极短临界区)。 */
struct HookSnap {
uint32_t alloc_size;
uint32_t caller_addr;
uint32_t free_after; ///< estimated(非实测)
uint32_t fired;
uint32_t est_free;
uint32_t min_est;
uint32_t recent_n;
RecentAllocSnap recent[kRecentAllocCap];
};
/** @brief 任务上下文用:禁中断拷出 hook 共享状态一致快照。仅 volatile 拷贝,不分配/不调 FreeRTOS。 */
HookSnap captureHookSnapshot()
{
HookSnap s = {};
portENTER_CRITICAL(&s_hookMux);
s.alloc_size = g_hook_alloc_size;
s.caller_addr = g_hook_caller_addr;
s.free_after = g_hook_free_after;
s.fired = g_hook_fired;
s.est_free = g_est_free;
s.min_est = g_hook_min_est;
s.recent_n = g_recent_alloc_n;
for (uint32_t i = 0; i < s.recent_n && i < (uint32_t)kRecentAllocCap; ++i) {
s.recent[i] = g_recent_allocs[i];
}
portEXIT_CRITICAL(&s_hookMux);
return s;
}
/** @brief 在 s_hookMux 临界区内调用:消费已记录的 hook 命中,重置 min_est/fired。
* 必须由 reader 在写完 HeapLowWaterRecord 后调用,避免同一窗口重复记。 */
void consumeHookFiredInLock()
{
/* min_est 抬到当前 est:本次已记下低谷,下次需更低才会再次触发。
* g_est_free 在此读以与 hook 写一致(s_hookMux 内)。 */
g_hook_min_est = g_est_free;
g_hook_fired = 0;
}
} // namespace
// ESP-IDF heap alloc hook:仅 DRAM 标量 + 内联 return address,禁止 FreeRTOS/flash 长函数。
//
// ★ hook 可调用约束(IRAM 安全契约):
// - 禁止:FreeRTOS API、flash/rodata 访问、mutex/semaphore、长函数。
// - 允许:DRAM volatile 标量读写、__builtin_return_address、纯算术。
// - 写共享状态天然原子:hook 已在 heap 临界区内(禁中断),reader 用 s_hookMux
// 禁中断取一致快照,二者互斥;hook 端不加锁、保持 IRAM 安全。
// - 嵌套分配:hook 内禁止任何可能触发 malloc 的调用(否则递归进 hook,armed
// 标志不防递归——当前 hook 体内无分配调用,保持之)。
extern "C"
void esp_heap_trace_alloc_hook(void *ptr, size_t size, uint32_t caps)
{
(void)caps;
if (!ptr || size == 0 || g_hook_armed == 0) {
return;
}
const uint32_t sz = (uint32_t)size;
/* 近似空闲:只减不增(free 路径不在此修正),health_mon 周期 resync。 */
uint32_t est = g_est_free;
if (est >= sz) {
est -= sz;
} else {
est = 0;
}
g_est_free = est;
/* 环形记下近期「有意义」分配,破新低时可读到发送路径小包之前的大块。 */
if (sz >= kHookInterestingMin) {
const uint32_t w = g_recent_alloc_w % (uint32_t)kRecentAllocCap;
g_recent_allocs[w].size = sz;
g_recent_allocs[w].caller = (uint32_t)(uintptr_t)__builtin_return_address(0);
g_recent_alloc_w = w + 1U;
if (g_recent_alloc_n < (uint32_t)kRecentAllocCap) {
g_recent_alloc_n = g_recent_alloc_n + 1U;
}
}
/* est 创新低 → 记「最后一击」alloc(常为网络 pbuf,任务若可读会是 wifi) */
if (est < g_hook_min_est) {
g_hook_min_est = est;
g_hook_alloc_size = sz;
g_hook_caller_addr = (uint32_t)(uintptr_t)__builtin_return_address(0);
g_hook_free_after = est;
g_hook_fired = 1;
/* 不采任务名(WiFi IRAM 路径禁止 FreeRTOS 调用) */
}
}
// IP 事件 handler:STA 获得 IP(含 DHCP 续约)时计数 +1
void ipEventHandler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
(void)arg; (void)event_base; (void)event_id; (void)event_data;
if (event_base == IP_EVENT && (event_id == IP_EVENT_STA_GOT_IP || event_id == IP_EVENT_ETH_GOT_IP)) {
uint32_t cur = g_ip_event_count;
g_ip_event_count = cur + 1;
}
}
// ============================================================
// 栈水位主动巡检
// ============================================================
// 告警阈值:剩余高水位 < 栈大小 20% 即告警(复用 /system/status 的任务表)。
// 去重:每个任务只在"水位由安全跌入危险"时告警一次,恢复后再跌入才再次告警,
// 避免 5 秒巡检刷屏。health_mon 任务从不被 OTA 暂停,DEBUG_ERR 经 UDP/UART
// 直发,不受 OTA 门控抑制。
static constexpr float STACK_WARN_RATIO = 0.20f; ///< 剩余水位告警阈值(栈大小比例)
static constexpr size_t STACK_TASK_MAX = 12; ///< 受监控任务上限(含 spi_worker 等)
static bool s_stackWarned[STACK_TASK_MAX] = {}; ///< 各任务是否已告警(去重)
// ============================================================
// 单例
@@ -44,21 +225,18 @@ void HealthMonitor::init()
if (m_mutex == NULL) {
DEBUG_ERR("HEALTH", "创建健康状态互斥锁失败");
}
}
/**
* @brief 反初始化:停止并删除温度监控定时器,回收 esp_timer 资源。
* 当前 HealthMonitor 任务为无限循环,无显式删除路径,
* 本方法预留给未来停用场景(如安全关机后停止监控)使用。
*/
void HealthMonitor::deinit()
{
if (m_tempTimer != nullptr) {
esp_timer_stop(m_tempTimer);
esp_timer_delete(m_tempTimer);
m_tempTimer = nullptr;
DEBUG_INFO("HEALTH", "温度监控定时器已停止并删除");
// 注册 IP 事件 handler,统计 STA 获得 IP(含 DHCP 续约)次数。
// 配合 heap_low_log 快照的 ip_event_count 字段,可验证"低谷是不是 DHCP 续约引起"。
// 失败仅告警,不致命(计数器保持 0,快照里 ip_event_count 永远 0)。
esp_err_t ev = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, ipEventHandler, nullptr);
if (ev != ESP_OK) {
DEBUG_WARN("HEALTH", "IP 事件 handler 注册失败: %s", esp_err_to_name(ev));
}
// 武装 heap alloc hook:init() 在 app_main 之后调用。armed=0 时 hook 直接 return。
// hook 本身禁止任何 FreeRTOS/flash 调用(见 esp_heap_trace_alloc_hook 注释)。
g_hook_armed = 1;
}
// ============================================================
@@ -158,7 +336,7 @@ void HealthMonitor::tempMonitorCallback(void *arg)
if (temp >= TEMP_CRITICAL) {
self.setState(SYS_SAFE_SHUTDOWN);
DEBUG_ERR("TEMP", "!!! 危险 !!! 温度 %.1f°C >= %d°C,触发安全关机", temp, TEMP_CRITICAL);
DEBUG_ERR("TEMP", "过热 %.1fC>=%dC 安全关机", temp, TEMP_CRITICAL);
DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN");
} else if (temp >= TEMP_WARNING) {
health_state_t cur = self.getState();
@@ -190,7 +368,6 @@ void HealthMonitor::taskEntry(void *pvParameters)
DEBUG_ERR("MON", "温度传感器初始化失败: %s", esp_err_to_name(err));
} else {
ESP_ERROR_CHECK(temperature_sensor_enable(self.m_tempHandle));
DEBUG_INFO("MON", "温度传感器已启用 (范围 20°C ~ 80°C)");
}
// 定时器,每1秒读取一次温度
@@ -202,7 +379,6 @@ void HealthMonitor::taskEntry(void *pvParameters)
if (esp_timer_create(&timer_args, &self.m_tempTimer) == ESP_OK) {
esp_timer_start_periodic(self.m_tempTimer, 1000000); // 1秒
DEBUG_INFO("MON", "温度监控定时器已启动 (1秒间隔)");
} else {
self.m_tempTimer = nullptr;
DEBUG_ERR("MON", "创建温度监控定时器失败");
@@ -210,16 +386,77 @@ void HealthMonitor::taskEntry(void *pvParameters)
// 周期性巡检
while (1) {
// 栈溢出 canary 触发检测:vApplicationStackOverflowHook 置位后,
// 在 health_mon 完好栈上用 DEBUG_ERR 经 UDP/UART 发详细告警。
// 标志只在置位后告警一次,不自动清除(canary 触发即不可逆,需重启恢复)。
if (g_stackOverflowDetected) {
DEBUG_ERR("MON", "栈溢出(canary),检查任务栈");
// 告警一次后清标志,避免 5 秒刷屏;后续若再次触发会重新置位
g_stackOverflowDetected = false;
}
uint32_t free_heap = esp_get_free_heap_size();
/* 同步 hook 近似空闲:纠正 free 路径未记账造成的向下漂移。
* 必须在 maybeRecord 之前,否则 est 长期偏低会误触发 hook 快照。 */
hookResyncEstFree(free_heap);
// 任务栈指标:本周期只采集一次,堆低水位快照和栈水位巡检共用。
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
// 堆低水位快照:仅在"最低空闲"创新低时记录一条上下文(5s 检测足够)
// 传入已采集的 metrics,避免 maybeRecordHeapLowWater 内部二次遍历任务表。
self.maybeRecordHeapLowWater(metrics, count);
if (free_heap < HEAP_WARNING_MIN) {
uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT);
DEBUG_WARN("MON", "堆内存不足: free=%lu min=%lu largest_blk=%lu",
free_heap, esp_get_minimum_free_heap_size(), largest_block);
/* 堆告警节流:OTA 期间阈值更低、间隔更长 */
static TickType_t s_last_heap_warn = 0;
TickType_t now = xTaskGetTickCount();
const bool ota_busy = !OtaCoordinator::instance().isIdle();
const uint32_t interval_ms = ota_busy ? 15000U
: (free_heap < 8192U ? 10000U : 3000U);
const uint32_t warn_floor = ota_busy ? 4096U : (uint32_t)HEAP_WARNING_MIN;
if (free_heap < warn_floor &&
(now - s_last_heap_warn) >= pdMS_TO_TICKS(interval_ms)) {
s_last_heap_warn = now;
uint32_t largest_block = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT);
DEBUG_WARN("MON", "堆紧%s free=%lu min=%lu big=%lu",
ota_busy ? "(OTA中)" : "",
(unsigned long)free_heap,
(unsigned long)esp_get_minimum_free_heap_size(),
(unsigned long)largest_block);
}
}
health_state_t cur = self.getState();
if (cur >= SYS_CRITICAL) {
DEBUG_ERR("MON", "健康状态危险: %d", cur);
}
// 栈水位主动巡检:复用已采集的任务表,低水位时 DEBUG_ERR 主动告警。
// health_mon 任务不被 OTA 暂停,告警经 UDP/UART 直发,穿透 OTA 门控。
for (size_t i = 0; i < count && i < STACK_TASK_MAX; ++i) {
if (metrics[i].handle == NULL || metrics[i].stack_size_bytes == 0) {
continue;
}
UBaseType_t hw = uxTaskGetStackHighWaterMark(metrics[i].handle);
size_t warn_bytes = (size_t)(metrics[i].stack_size_bytes * STACK_WARN_RATIO);
if (hw < (UBaseType_t)warn_bytes) {
if (!s_stackWarned[i]) {
DEBUG_ERR("MON", "栈低 task=%s left=%lu thr=%lu sz=%lu",
metrics[i].name,
(unsigned long)hw,
(unsigned long)warn_bytes,
(unsigned long)metrics[i].stack_size_bytes);
s_stackWarned[i] = true;
}
} else if (s_stackWarned[i]) {
// 水位恢复,清除告警标志,允许下次再次跌入时告警
s_stackWarned[i] = false;
DEBUG_INFO("MON", "栈水位恢复: 任务=%s 剩余=%luB",
metrics[i].name, (unsigned long)hw);
}
}
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
@@ -262,14 +499,14 @@ health_status_t HealthMonitor::checkBeforeOta()
if (getLastWifiRssi(&last_rssi, &age_ms)) {
if (age_ms <= OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS) {
if (last_rssi < OTA_WIFI_RSSI_CRITICAL) {
DEBUG_WARN("HEALTH", "OTA 前检查: 最近 RSSI %d < %d (样本年龄 %lu ms)",
DEBUG_WARN("HEALTH", "OTA前 RSSI %d<%d age=%lums",
last_rssi, OTA_WIFI_RSSI_CRITICAL, (unsigned long)age_ms);
return HEALTH_MON_CRITICAL;
} else if (last_rssi < WIFI_RSSI_NORMAL) {
if (status < HEALTH_MON_WARNING) {
status = HEALTH_MON_WARNING;
}
DEBUG_WARN("HEALTH", "OTA 前检查: 最近 RSSI %d < %d (样本年龄 %lu ms)",
DEBUG_WARN("HEALTH", "OTA前 RSSI %d<%d age=%lums",
last_rssi, WIFI_RSSI_NORMAL, (unsigned long)age_ms);
}
}
@@ -358,7 +595,7 @@ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
} else { // next == SYS_NORMAL,从 WARNING/CRITICAL 恢复
setState(SYS_NORMAL);
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm (从 %d 降级)", rssi, WIFI_RSSI_NORMAL, (int)cur);
DEBUG_INFO("HEALTH", "RSSI恢复 %d>=%d (lv%d)", rssi, WIFI_RSSI_NORMAL, (int)cur);
}
}
@@ -367,3 +604,281 @@ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
return HEALTH_MON_OK;
}
// ============================================================
// 堆低水位快照("最低空闲"创新低时记录上下文)
// ============================================================
/**
* @brief 检测"内部堆最低空闲"是否创新低,是则覆盖写入唯一快照槽。
*
* 调用方:health_mon 5s 巡检任务。仅在"新低"时写一条,避免每 5s 刷一条无意义记录。
* 上下文字段:OTA 系统状态、通道、RSSI、温度、栈剩余最少的任务名+剩余字节。
* 所有外部读取(OtaCoordinator::getSnapshot / collectTaskStackMetrics / rssi / temp)
* 都先在锁外取,避免持锁期间嵌套调用其它模块的锁;持锁后只做字段拷贝。
*/
void HealthMonitor::maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics, size_t pre_count)
{
// 与 http_handlers.cpp 同口径:内部 DRAM 数据堆
static constexpr uint32_t CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
const uint32_t cur_min = (uint32_t)heap_caps_get_minimum_free_size(CAPS);
// 不创新低则直接返回(不持锁)
if (cur_min >= m_heapLowWaterLastMin) {
return;
}
// ── 锁外收集上下文 ──
const uint32_t free_heap = (uint32_t)esp_get_free_heap_size();
const uint32_t largest_block = (uint32_t)heap_caps_get_largest_free_block(CAPS);
/* hook 共享状态一致快照:禁中断一次性拷出,避免多字段撕裂读。
* 必须在 m_mutex 外取——s_hookMux 与 m_mutex 不可嵌套持锁。 */
const HookSnap hs = captureHookSnapshot();
OtaSnapshot snap = OtaCoordinator::instance().getSnapshot();
const uint8_t sys_state = (uint8_t)snap.sys_state;
const uint8_t ota_channel = (uint8_t)snap.channel;
const uint32_t ota_stored = (uint32_t)snap.meta.stored_size;
const uint32_t ota_total = (uint32_t)snap.meta.total_size;
int8_t rssi = -127;
{
int8_t r;
uint32_t age_ms;
if (getLastWifiRssi(&r, &age_ms)) {
rssi = r;
}
}
const float temperature = getLastTemperature();
/* current_task = 采样者,不是触发者。固定写 health_mon,避免误解为“谁 alloc”。 */
char current_task[16] = "health_mon";
// 连接状态
const uint8_t ble_connected = BleOta::instance().isConnected() ? 1 : 0;
const uint8_t wifi_connected = WifiOta::instance().getStaConnected() ? 1 : 0;
// 嫌疑资源水位:定位"哪个子系统吃堆"
const uint16_t ble_queue_depth = (uint16_t)ble_ota_get_data_queue_depth();
const uint16_t ble_reorder = (uint16_t)ble_ota_get_reorder_cached_count();
const uint16_t spi_pending = (uint16_t)SpiMaster::instance().getPendingJobCount();
// 内存全景:default 含 PSRAM,与 internal 对比可判断"是内部碎片 vs 系统级紧"
const uint32_t default_free = (uint32_t)heap_caps_get_free_size(MALLOC_CAP_DEFAULT);
uint32_t alloc_count = 0;
{
multi_heap_info_t info = {};
heap_caps_get_info(&info, CAPS);
alloc_count = (uint32_t)info.allocated_blocks;
}
// 收集所有受监控任务栈水位,按"剩余升序"取前 8 个
HeapLowTaskStack stacks[8];
uint8_t stack_count = 0;
{
// 优先复用巡检循环已采集的指标,避免二次遍历
TaskStackMetric local_metrics[STACK_TASK_MAX];
const TaskStackMetric *metrics;
size_t count;
if (pre_metrics && pre_count > 0) {
metrics = pre_metrics;
count = pre_count;
} else {
count = collectTaskStackMetrics(local_metrics, STACK_TASK_MAX);
metrics = local_metrics;
}
// 先全部采集
struct Item { char name[16]; uint32_t rem; };
Item items[STACK_TASK_MAX];
size_t item_n = 0;
for (size_t i = 0; i < count && i < STACK_TASK_MAX; ++i) {
if (metrics[i].handle == NULL || metrics[i].stack_size_bytes == 0) {
continue;
}
UBaseType_t hw = uxTaskGetStackHighWaterMark(metrics[i].handle);
Item &it = items[item_n++];
it.rem = (uint32_t)hw * sizeof(StackType_t);
strncpy(it.name, metrics[i].name ? metrics[i].name : "?", sizeof(it.name) - 1);
it.name[sizeof(it.name) - 1] = '\0';
}
// 冒泡排序按 rem 升序(item_n ≤ 9,开销小)
for (size_t i = 0; i + 1 < item_n; ++i) {
for (size_t j = i + 1; j < item_n; ++j) {
if (items[j].rem < items[i].rem) {
Item t = items[i]; items[i] = items[j]; items[j] = t;
}
}
}
// 取前 8
for (size_t i = 0; i < item_n && stack_count < 8; ++i) {
strncpy(stacks[stack_count].name, items[i].name, sizeof(stacks[stack_count].name) - 1);
stacks[stack_count].name[sizeof(stacks[stack_count].name) - 1] = '\0';
stacks[stack_count].remaining = items[i].rem;
++stack_count;
}
}
// ── 持锁覆盖写唯一槽 ──
LockGuard lock(m_mutex);
HeapLowWaterRecord &rec = m_heapLowWater;
rec = {};
rec.ts_ms = (uint64_t)(esp_timer_get_time() / 1000);
rec.free_heap = free_heap;
rec.min_free_heap = cur_min;
rec.largest_free_block = largest_block;
rec.sys_state = sys_state;
rec.ota_channel = ota_channel;
rec.rssi = rssi;
rec.temperature = temperature;
rec.task_stack_count = stack_count;
for (uint8_t i = 0; i < stack_count && i < 8; ++i) {
rec.task_stacks[i] = stacks[i];
}
strncpy(rec.current_task, current_task, sizeof(rec.current_task) - 1);
rec.current_task[sizeof(rec.current_task) - 1] = '\0';
rec.ble_connected = ble_connected;
rec.wifi_connected = wifi_connected;
rec.ota_stored_size = ota_stored;
rec.ota_total_size = ota_total;
rec.ble_data_queue_depth = ble_queue_depth;
rec.ble_reorder_cached = ble_reorder;
rec.spi_pending_jobs = spi_pending;
rec.reserved = 0;
rec.default_free_heap = default_free;
rec.alloc_count = alloc_count;
// heap hook:「最后一击」alloc(无任务名;见文件头注释)
// 字段取自锁外已取的一致快照 hs(captureHookSnapshot),不再裸读 volatile,
// 消除 fired/size/caller/free_after 多字段撕裂读。
{
const uint32_t hook_size = hs.alloc_size;
const uint32_t hook_caller = hs.caller_addr;
const uint32_t hook_after = hs.free_after;
const uint32_t hook_fired = hs.fired;
/* 窗口内更大块(若存在):发送前小 pbuf 压低水位时,主因更可能是此前 bulk */
uint32_t big_size = 0;
uint32_t big_caller = 0;
const uint32_t n = hs.recent_n;
for (uint32_t i = 0; i < n && i < (uint32_t)kRecentAllocCap; ++i) {
if (hs.recent[i].size > big_size) {
big_size = hs.recent[i].size;
big_caller = hs.recent[i].caller;
}
}
rec.hook_alloc_size = hook_size;
rec.hook_caller_addr = hook_caller;
rec.hook_free_after = hook_after;
rec.hook_fired = hook_fired;
/* hook_task 存类别:net? / net?+big / bulk / tiny(非 FreeRTOS 任务名) */
rec.hook_task[0] = '\0';
if (hook_fired) {
const char *cls = "tiny";
if (hook_size >= 2048U) {
cls = "bulk";
} else if (hook_size >= kHookInterestingMin) {
if (big_size > hook_size && big_size >= 2048U) {
cls = "net?+big";
rec.hook_caller_addr = big_caller;
} else {
cls = "net?";
}
}
strncpy(rec.hook_task, cls, sizeof(rec.hook_task) - 1);
rec.hook_task[sizeof(rec.hook_task) - 1] = '\0';
}
/* 消费已记录的 hook 命中:在 s_hookMux 内重置 min_est/fired,避免同一窗口
* 重复记。与 hook 写互斥(禁中断),不持 m_mutex 同时持 s_hookMux。 */
portENTER_CRITICAL(&s_hookMux);
consumeHookFiredInLock();
portEXIT_CRITICAL(&s_hookMux);
}
rec.ip_event_count = g_ip_event_count;
m_heapLowWaterValid = true;
m_heapLowWaterLastMin = cur_min;
}
/**
* @brief 输出设备唯一快照槽:{"count":0|1,"records":[...]}。
* cap 不够装下整条时退回 count=0,避免半截 JSON。
*/
int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
{
LockGuard lock(m_mutex);
if (!m_heapLowWaterValid) {
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
const HeapLowWaterRecord &r = m_heapLowWater;
/* hook_free_after 是 estimated(hook 只减不增近似值),JSON 字段名显式标注 est,
* hook_confidence 告知前端置信度:hook 命中=est_low_watermark,未命中=none。
* 前端应据此区分 estimated 与 measured(free_heap/min_free_heap 为实测)。 */
const char *hook_confidence = r.hook_fired ? "est_low_watermark" : "none";
int pos = http_json_append(buf, cap, offset,
"{\"count\":1,\"records\":[{"
"\"ts_ms\":%llu,\"free_heap\":%lu,\"min_free_heap\":%lu,"
"\"largest_free_block\":%lu,\"sys_state\":%u,\"ota_channel\":%u,"
"\"rssi\":%d,\"temperature\":%.1f,"
"\"current_task\":\"%s\",\"ble_connected\":%u,\"wifi_connected\":%u,"
"\"ota_stored_size\":%lu,\"ota_total_size\":%lu,"
"\"ble_data_queue_depth\":%u,\"ble_reorder_cached\":%u,"
"\"spi_pending_jobs\":%u,\"default_free_heap\":%lu,\"alloc_count\":%lu,"
"\"hook_task\":\"%s\",\"hook_alloc_size\":%lu,\"hook_caller_addr\":%lu,"
"\"hook_est_free_after\":%lu,\"hook_confidence\":\"%s\",\"hook_fired\":%lu,"
"\"ip_event_count\":%lu,"
"\"task_stacks\":[",
(unsigned long long)r.ts_ms,
(unsigned long)r.free_heap,
(unsigned long)r.min_free_heap,
(unsigned long)r.largest_free_block,
(unsigned)r.sys_state,
(unsigned)r.ota_channel,
(int)r.rssi,
(double)r.temperature,
r.current_task,
(unsigned)r.ble_connected,
(unsigned)r.wifi_connected,
(unsigned long)r.ota_stored_size,
(unsigned long)r.ota_total_size,
(unsigned)r.ble_data_queue_depth,
(unsigned)r.ble_reorder_cached,
(unsigned)r.spi_pending_jobs,
(unsigned long)r.default_free_heap,
(unsigned long)r.alloc_count,
r.hook_task,
(unsigned long)r.hook_alloc_size,
(unsigned long)r.hook_caller_addr,
(unsigned long)r.hook_free_after,
hook_confidence,
(unsigned long)r.hook_fired,
(unsigned long)r.ip_event_count);
if (pos == offset) {
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
for (uint8_t k = 0; k < r.task_stack_count && k < 8; ++k) {
int after = http_json_append(buf, cap, pos,
"%s{\"name\":\"%s\",\"remaining\":%lu}",
k == 0 ? "" : ",",
r.task_stacks[k].name,
(unsigned long)r.task_stacks[k].remaining);
if (after == pos) {
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
pos = after;
}
int closed = http_json_append(buf, cap, pos, "]}]}");
if (closed == pos) {
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
return closed;
}
+80 -14
View File
@@ -12,6 +12,7 @@
#include "driver/temperature_sensor.h"
#include "esp_timer.h"
#include "app_config.h"
#include "task_stack.h" // TaskStackMetric(maybeRecordHeapLowWater 可选参数)
// T9.3: 健康状态等级
typedef enum {
@@ -31,7 +32,62 @@ typedef enum {
// WiFi RSSI 阈值(来自 app_config.h)
#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING ///< WiFi 信号正常阈值(别名)
#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL ///< WiFi 信号弱阈值(别名)
// ============================================================
// 堆低水位快照("最低空闲"创新低时记录上下文,供 /system/heap_low_log 查询)
// ============================================================
/** @brief 单条任务栈水位(用于低水位快照内嵌的任务栈表)。 */
struct HeapLowTaskStack {
char name[16]; ///< 任务名(截断)
uint32_t remaining; ///< 剩余栈字节(uxTaskGetStackHighWaterMark × sizeof(StackType_t))
};
/** @brief 单条"堆低水位"快照记录。仅当 internal_min_free 出现新低时写入。 */
struct HeapLowWaterRecord {
uint64_t ts_ms; ///< 启动后毫秒(esp_timer_get_time / 1000)
uint32_t free_heap; ///< 当时的总空闲堆(esp_get_free_heap_size)
uint32_t min_free_heap; ///< 当时记录到的内部堆最低水位
uint32_t largest_free_block; ///< 最大连续空闲块
uint8_t sys_state; ///< OTA 系统状态码(ota_system_state_t)
uint8_t ota_channel; ///< OTA 通道(0=WIFI, 1=BLE)
int8_t rssi; ///< 最近 WiFi RSSI(dBm,-127=未知)
float temperature; ///< 最近芯片温度(°C)
/* 多任务栈水位表:按"剩余升序"取前 8 个,定位"是谁的栈突然涨了"。
* task_stack_count=0 表示未采集到任务信息。 */
HeapLowTaskStack task_stacks[8];
uint8_t task_stack_count;
/* 触发低水位的"当前任务":取当前 TaskHandle 的名字(taskENTER_CRITICAL 后取),
* 用于推断是谁在分配堆。空串表示未知。 */
char current_task[16];
/* 连接/OTA 进度上下文:区分"无连接 vs 中继期 vs 收尾期" */
uint8_t ble_connected; ///< 0/1
uint8_t wifi_connected; ///< 0/1
uint32_t ota_stored_size; ///< OTA 已存储字节
uint32_t ota_total_size; ///< OTA 总字节
/* 嫌疑资源水位:定位"OTA/IDLE 期低谷由哪个子系统吃堆"。
* 非 OTA 期这些应为 0;非 0 时即对应子系统持有堆缓冲。 */
uint16_t ble_data_queue_depth; ///< BLE OTA 数据队列当前条数
uint16_t ble_reorder_cached; ///< BLE reorder 已缓存乱序包数
uint16_t spi_pending_jobs; ///< SPI master 待处理 job 数
uint16_t reserved; ///< 对齐保留
/* 内存全景:区分"内部碎片"vs"系统级紧"。
* default_free 含 PSRAM;internal_free = free_heap(含 SPIRAM 时与 default 区分)。
* alloc_count = 当前活跃 alloc 数,看"是不是小块大量未释放"。 */
uint32_t default_free_heap; ///< MALLOC_CAP_DEFAULT free(含 PSRAM)
uint32_t alloc_count; ///< 内部堆当前 allocated_blocks(heap_caps_get_info)
/* heap hook:「破新低时的最后一击 alloc」。
* 不记任务名(WiFi IRAM 禁止;且发送路径末包常把任务刷成 wifi,误导主因)。
* hook_task 现为类别串:net? / bulk / tiny(非 FreeRTOS 任务名)。
* hook_caller_addr 用 addr2line 解析。 */
char hook_task[16]; ///< 分配类别 net?/bulk/tiny,不是任务名
uint32_t hook_alloc_size; ///< 最后一击 alloc 字节数
uint32_t hook_caller_addr; ///< 调用者返回地址(addr2line)
uint32_t hook_free_after; ///< 该次后的近似 free(**estimated 低水位近似值**,非精确实测;JSON 输出为 hook_est_free_after + hook_confidence,前端应标注 estimated)
uint32_t hook_fired; ///< 0=本窗口 hook 未命中
/* DHCP/IP 事件计数:每次 STA GOT_IP(含续约)+1。
* 对比两次低谷记录的计数差,确认低谷是不是 DHCP 续约引起。 */
uint32_t ip_event_count; ///< 累计 IP 获得/续约次数
};
/**
* @brief 系统健康监控类(单例)
@@ -49,13 +105,6 @@ public:
/** @brief 初始化健康监控模块,创建内部互斥锁与基础状态。 */
void init();
/**
* @brief 反初始化:停止并删除温度监控定时器,回收 esp_timer 资源。
* 在任务删除路径中调用;当前 HealthMonitor 任务为无限循环,
* 无显式删除路径,本方法预留给未来停用场景使用。
*/
void deinit();
/** @brief 健康监控 FreeRTOS 任务入口。 */
static void taskEntry(void *pvParameters);
@@ -81,10 +130,23 @@ public:
/** @brief 获取健康监控任务句柄。 */
TaskHandle_t getTaskHandle() const { return m_taskHandle; }
// ── 堆低水位快照 ──
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。 */
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。
* @param pre_metrics 巡检循环已采集的任务栈指标(避免二次遍历);null 时内部自采 */
void maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics = nullptr, size_t pre_count = 0);
/** @brief 将唯一快照序列化为 JSON:{"count":0|1,"records":[...]}。线程安全。 */
int dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const;
/**
* @brief 由 WiFi 事件回调推送连接状态(解耦 HealthMonitor → WifiOta 依赖)。
* @brief 由 WiFi 事件回调推送连接状态(避免 HealthMonitor → WifiOta 依赖)。
* WiFi 连接/断开时由 wifi_ota 事件处理器主动调用,
* HealthMonitor 不再主动查询 WifiOta::isConnected()。
* HealthMonitor 无需主动查询 WifiOta::isConnected()。
* @param connected true=已连接,false=断开。
*/
void setWifiConnected(bool connected);
@@ -103,8 +165,8 @@ private:
SemaphoreHandle_t m_mutex = NULL;
temperature_sensor_handle_t m_tempHandle = NULL;
// 温度监控 esp_timer 句柄存为成员,便于 deinit() 时停止并删除定时器,
// 避免任务被删除后定时器仍在运行、回调继续访问 instance() 成员。
// 温度监控 esp_timer 句柄存为成员,避免任务被删除后定时器仍在运行、
// 回调继续访问 instance() 成员。
esp_timer_handle_t m_tempTimer = nullptr;
health_state_t m_healthState = SYS_NORMAL;
float m_lastTemperature = 0.0f;
@@ -112,8 +174,12 @@ private:
int64_t m_lastWifiRssiMs = 0;
TaskHandle_t m_taskHandle = NULL;
// m_wifiConnected 由 m_mutex 保护(setWifiConnected 写 / checkBeforeOta 读)。
// volatile 保留为历史兼容,实际同步由 LockGuard 提供。
volatile bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送)
bool m_wifiConnected = false; ///< WiFi 连接状态(由 WiFi 事件推送)
// 堆低水位:设备仅 1 槽(覆盖写)。m_mutex 保护。
HeapLowWaterRecord m_heapLowWater = {};
bool m_heapLowWaterValid = false; ///< 是否有一条有效快照
uint32_t m_heapLowWaterLastMin = 0xFFFFFFFFu; ///< 上次记下的最低值
static void tempMonitorCallback(void *arg);
+577
View File
@@ -0,0 +1,577 @@
/*
* motor_ota_relay.cpp - 电机 OTA relay ESP32 侧实现
*/
#include "monitor/motor_ota_relay.h"
#include "monitor/debug_log.h"
#include "transport/spi_master.h"
#include "storage/gd32_flash_store.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include <string.h>
static const char *TAG = "MOTOR_OTA";
/* SPI 类型常量(与 GD32 app_spi_command_defs.h 对齐) */
static constexpr uint8_t kSpiMotorOtaCmd = 0xD0U;
static constexpr uint8_t kSpiMotorOtaData = 0xD1U;
static constexpr uint8_t kSpiMotorOtaStatus = 0xD2U;
static constexpr uint8_t kRspAck = 0x80U;
static constexpr uint8_t kRspWait = 0x81U;
/* 内部状态 */
static SemaphoreHandle_t s_mutex = nullptr;
static motor_ota_status_t s_status = {};
/* 固件不再整体缓冲在堆里:HTTP handler 流式写入 gd32_ota flash 分区,
* feedTask 经 flashReadChunk 分块读取(见 feedTask)。 */
static uint32_t s_fw_size = 0;
static uint32_t s_fw_offset = 0; /* 已喂给 GD32 的偏移 */
static TaskHandle_t s_feed_task = nullptr;
static volatile bool s_feed_running = false;
static int64_t nowMs() {
return esp_timer_get_time() / 1000;
}
/* ── 共享辅助:终态判断 + QUERY SPI 事务 ── */
/** @brief GD32 侧已进终态(IDLE/FINISHED/ERROR),喂数据应停止。 */
static inline bool isTerminalState(motor_ota_state_t s)
{
return s == MOTOR_OTA_IDLE || s == MOTOR_OTA_FINISHED || s == MOTOR_OTA_ERROR;
}
/**
* @brief 发 SPI 0xD0 QUERY 查询 GD32 真实状态。
* @param out_state 非 null 时写入 GD32 返回的 state
* @param out_err 非 null 时写入 error_code
* @param update_status true 时把结果同步写回 s_status(持锁)
* @return true=QUERY 成功且 ACK 有有效数据;false=SPI 失败或无 ACK
*
* 消除 feedTask 中 4 处重复的 qcmd[1]={2} + transact + parse qp[0..5] 块。
*/
static bool queryMotorOtaStatus(motor_ota_state_t *out_state = nullptr,
uint8_t *out_err = nullptr,
bool update_status = false)
{
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) != ESP_OK
|| qt != kRspAck || ql < 1U) {
return false;
}
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (out_state) *out_state = qst;
if (out_err) *out_err = (ql >= 2U) ? qp[1] : 0U;
if (update_status && s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = (ql >= 2U) ? qp[1] : 0U;
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
if (ql >= 10U) {
s_status.fw_size = static_cast<uint32_t>(qp[6])
| (static_cast<uint32_t>(qp[7]) << 8)
| (static_cast<uint32_t>(qp[8]) << 16)
| (static_cast<uint32_t>(qp[9]) << 24);
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
return true;
}
/* ═══════════════════ 喂数据 task ═══════════════════ */
static void feedTask(void *arg)
{
(void)arg;
uint8_t resp_payload[16];
uint8_t resp_type = 0;
size_t resp_len = 0;
/* 栈上 chunk:[offset:u32 LE][data…],避免整固件堆缓冲 */
uint8_t chunk_buf[MOTOR_OTA_CHUNK_SIZE];
/* 整体超时保护:feedTask 最长存活 120s,超时自动退出。
* 防止 GD32 侧不回 ACK 或持续回 WAIT 时,task 永久卡在循环里,
* 导致 motorOtaIsActive() 一直为 true、阻塞其他 OTA。 */
const int64_t deadline_ms = nowMs() + 120000LL;
/* 启动宽限期:只需覆盖“suppress 生效前在途的陈旧 0xD2 push”这一极小窗口——
* s_status 刚被 motorOtaStart 置为 HANDSHAKE_REQ,下方 suppress 后 ring 不再读,
* 残留 ERROR 只可能在这几十 ms 内覆写。原 1500ms 基于 kBootSettleMs=800ms 时代,
* GD32 v1.5.1 已移除残帧窗口(收 0x103 即进握手,握手后不再发 ERROR push),
* 100ms 足够(用户指定)。 */
const int64_t grace_end_ms = nowMs() + 100LL;
/* 重新启用 suppressRingService:ring service 抢占 SPI 带宽导致吐量从 ~5 chunks/s 降到 ~1.5 chunks/s。
* 进度显示由 feedTask 自己基于 s_fw_offset 更新(近似值),不依赖 GD32 push。 */
SpiMaster::instance().setSuppressRingService(true);
/* suppress 期间 s_status 不被 0xD2 push 更新(worker 不读 ring),
* feedTask 无法靠 motorOtaGetStatus() 检测 GD32 是否已进 ERROR/FINISHED 终态。
* 连续 SPI 失败计数:超过阈值时主动 QUERY GD32 真实状态,避免盲重试空转到 120s。 */
uint32_t consec_fail = 0U;
constexpr uint32_t kQueryAfterFails = 8U; /* 连续 8 次失败(~2s)后 QUERY 一次 */
uint32_t consec_wait = 0U;
constexpr uint32_t kQueryAfterWaits = 150U; /* 连续 150 次 WAIT(~300ms)后 QUERY:
* 正常 buffer-full WAIT <100 次即恢复;
* 超限说明 GD32 可能因 auto-retry 重置了
* s_fw_fed=0,当前 offset 被 gap 拒收。 */
uint32_t ack_n = 0U; /* 成功 ACK 计数,用于降频写 s_status */
bool handshake_confirmed = false; /* GD32 已进 kTransfer/kErase,可标 TRANSFER */
while (s_feed_running && s_fw_offset < s_fw_size) {
if (nowMs() >= deadline_ms) {
ESP_LOGE(TAG, "feedTask 整体超时 (120s),强制退出 offset=%u/%u",
(unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
/* GD32 已进终态:停止喂,避免盲重试空转到 120s 超时。
* 启动宽限期(100ms)内跳过终态检查:避免上一次 OTA 残留的 ERROR 状态推送
* (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 */
const motor_ota_status_t st = motorOtaGetStatus();
if (nowMs() >= grace_end_ms) {
if (isTerminalState(st.state)) {
ESP_LOGW(TAG, "GD32 终态 0x%02X,停止喂数据 offset=%u/%u",
(int)st.state, (unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
}
/* GD32 auto-retry 检测(v1.5.5+):GD32 SPI 断流/电机超时后自动重新握手,
* 重置 s_fw_fed=0。ESP32 侧通过连续 WAIT→QUERY 检测到 state!=kTransfer 后
* 重置 s_fw_offset=0 从头喂(见下方 kRspWait 分支)。
* 注:v1.4.38~v1.5.4 曾移除此逻辑(当时无 auto-retry,握手失败即中止)。 */
const uint32_t remaining = s_fw_size - s_fw_offset;
const uint32_t data_len = (remaining < MOTOR_OTA_CHUNK_DATA)
? remaining : MOTOR_OTA_CHUNK_DATA;
const uint32_t frame_len = MOTOR_OTA_CHUNK_HDR + data_len;
/* [0..3]=绝对 offset LE,[4..]=固件数据。重传同 offset 时 GD32 幂等。 */
chunk_buf[0] = static_cast<uint8_t>(s_fw_offset & 0xFFU);
chunk_buf[1] = static_cast<uint8_t>((s_fw_offset >> 8) & 0xFFU);
chunk_buf[2] = static_cast<uint8_t>((s_fw_offset >> 16) & 0xFFU);
chunk_buf[3] = static_cast<uint8_t>((s_fw_offset >> 24) & 0xFFU);
esp_err_t rd = Gd32FlashStore::instance().flashReadChunk(
s_fw_offset, chunk_buf + MOTOR_OTA_CHUNK_HDR, data_len);
if (rd != ESP_OK) {
ESP_LOGE(TAG, "flash read fail @%u: %s", (unsigned)s_fw_offset, esp_err_to_name(rd));
break;
}
esp_err_t ret = SpiMaster::instance().transact(
kSpiMotorOtaData,
chunk_buf, frame_len,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
200U); /* 200ms: v1.4.59成功时的确切值。poll有~95ms 做多次盲读 */
if (ret != ESP_OK) {
ESP_LOGW(TAG, "SPI data fail: %s offset=%u", esp_err_to_name(ret), (unsigned)s_fw_offset);
vTaskDelay(pdMS_TO_TICKS(10)); /* 快速重试(原 100ms 太慢导致吐量低) */
/* suppress 期间 s_status 不更新,连续失败时主动 QUERY GD32 真实状态,
* 避免 GD32 已进 ERROR 终态但 feedTask 不知道、盲重试空转到 120s 超时。 */
if (++consec_fail >= kQueryAfterFails) {
consec_fail = 0U;
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true) && isTerminalState(qst)) {
ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u",
(int)qst, (unsigned)qerr,
(unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
}
continue; /* 不推进 offset,同 offset 重发 */
}
consec_fail = 0U;
consec_wait = 0U;
if (resp_type == kRspAck) {
s_fw_offset += data_len;
/* 进度:每 8 chunk 写 s_status。
* ★ 不再喂数据一成功就强标 TRANSFER:GD32 feedData() 在握手阶段
* (kPreCancel/kHandshakeReq 等) 也接收预填充数据,若此刻强标
* TRANSFER,无电机的空通道会让网页误显示"数据传输 N/912"而非
* "握手中…→超时"。改为:只在 GD32 真正进 kErase/kTransfer 后
* 才标 TRANSFER;握手未完成时保持握手态显示。
* 握手未确认时每 8 个 ACK 重新 QUERY 一次(GD32 握手+擦除
* 通常 1~3s,8 个 ACK ~16 个 chunk ≈ 3700B <1s,需多轮确认)。 */
++ack_n;
const bool touch_status = ((ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size);
/* 握手未确认时定期 QUERY:确认 GD32 已过握手阶段才标 TRANSFER */
if (!handshake_confirmed && (ack_n & 7U) == 0U) {
motor_ota_state_t qst;
if (queryMotorOtaStatus(&qst, nullptr, false)) {
if (qst == MOTOR_OTA_ERASE || qst == MOTOR_OTA_TRANSFER ||
qst == MOTOR_OTA_FINISH_OTA || qst == MOTOR_OTA_WAIT_APP_RUN) {
handshake_confirmed = true;
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
s_status.state = MOTOR_OTA_TRANSFER;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
}
/* 终态(ERROR/IDLE/FINISHED)交给下方终态检测处理 */
}
}
if (touch_status) {
const uint16_t fed_pkts = static_cast<uint16_t>((s_fw_offset + 47U) / 48U);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
/* 握手未确认时不标 TRANSFER、不更新 packets_acked:
* 此刻 fed_pkts 是喂给 GD32 环缓冲的字节数(预填充),
* 不是电机真实 ACK,握手未成时显示会让用户误以为在传输。 */
if (handshake_confirmed) {
s_status.state = MOTOR_OTA_TRANSFER;
s_status.packets_acked = fed_pkts;
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
}
} else if (resp_type == kRspWait) {
/* 缓冲满 / gap 拒收:不推进 offset,稍候重试。
* WAIT 路径 SPI 层已推进 seq,避免同 seq 重发被 GD32 当重复请求死锁。 */
vTaskDelay(pdMS_TO_TICKS(2));
/* 连续 WAIT 超限:可能 GD32 auto-retry 重置了 s_fw_fed=0,
* 当前 offset 被 gap 拒收(永远不会变 ACK)。QUERY 确认真实状态。 */
if (++consec_wait >= kQueryAfterWaits) {
consec_wait = 0U;
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, false)) {
if (qst != MOTOR_OTA_TRANSFER) {
/* GD32 不在传输态(auto-retry 回了 kPreCancel/握手):
* 重置 offset 从头喂,配合 GD32 重新擦除+握手。 */
ESP_LOGW(TAG, "GD32 retry detected: state=%u err=0x%02X, reset offset %u->0",
(unsigned)qst, (unsigned)qerr, (unsigned)s_fw_offset);
s_fw_offset = 0U;
ack_n = 0U;
handshake_confirmed = false; /* 重新握手,需重新确认 */
} else {
handshake_confirmed = true; /* GD32 在 transfer,确认握手完成 */
}
/* 终态仍 break */
if (isTerminalState(qst)) {
ESP_LOGW(TAG, "QUERY(WAIT) 终态 0x%02X err=0x%02X,停止",
(unsigned)qst, (unsigned)qerr);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qerr;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
break;
}
}
}
} else {
/* 非 ACK/WAIT(含 ERROR/陈旧 type):不 break 整次 OTA。
* 曾因 poll 读到 EVENT/错 type 直接 break → fed 卡首 chunk。 */
ESP_LOGW(TAG, "SPI data unexpected type=0x%02X offset=%u,重试",
resp_type, (unsigned)s_fw_offset);
vTaskDelay(pdMS_TO_TICKS(5));
}
}
/* 恢复 ring service,让后续 GD32 push(FINISHED/ERROR) 正常到达 */
SpiMaster::instance().setSuppressRingService(false);
/* feedTask 字节喂完 ≠ GD32/电机已完成。suppress 期间 0xD2 被丢;
* 末包 CAN ACK / 跳转 APP 还要数百 ms。只 QUERY 一次会把前端卡在 kTransfer。
* 退出后轮询 QUERY,直到 GD32 进 FINISHED/ERROR/IDLE,或最多 ~8s。
* 用户取消(s_feed_running 已被 motorOtaCancel 置 false)时跳过:
* 设备端已进 IDLE、GD32 已 resetToIdle,无终态可等——立即收尾退出,
* 把 cancel task 的等待窗口压到最短(也消除强删窗口)。 */
{
constexpr uint32_t kExitPollMs = 8000U;
constexpr uint32_t kExitGapMs = 150U;
const uint32_t exit_deadline = nowMs() + kExitPollMs;
bool got_terminal = false;
while (s_feed_running && nowMs() < exit_deadline) {
/* 优先吃 ring 推送(已恢复 suppress) */
{
const motor_ota_status_t st = motorOtaGetStatus();
if (isTerminalState(st.state)) {
ESP_LOGI(TAG, "feedTask 退出,ring 终态: state=%u err=%u pkt=%u/%u",
(unsigned)st.state, (unsigned)st.error_code,
(unsigned)st.packets_acked, (unsigned)st.total_packets);
got_terminal = true;
break;
}
}
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true)) {
if (isTerminalState(qst)) {
const motor_ota_status_t snap = motorOtaGetStatus();
ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u",
(unsigned)qst, (unsigned)qerr,
(unsigned)snap.packets_acked, (unsigned)snap.total_packets);
got_terminal = true;
break;
}
}
vTaskDelay(pdMS_TO_TICKS(kExitGapMs));
}
if (!got_terminal) {
ESP_LOGW(TAG, "feedTask 退出轮询超时,前端可能仍显示传输中 offset=%u/%u",
(unsigned)s_fw_offset, (unsigned)s_fw_size);
}
}
s_feed_running = false;
s_feed_task = nullptr;
vTaskDelete(NULL);
}
/* ═══════════════════ 公开接口 ═══════════════════ */
void motorOtaRelayInit(void)
{
if (!s_mutex) {
s_mutex = xSemaphoreCreateMutex();
}
}
void onMotorOtaStatusPush(const uint8_t *payload, size_t len)
{
if (len < 12U || !s_mutex) return;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) != pdTRUE) return;
s_status.state = static_cast<motor_ota_state_t>(payload[0]);
s_status.error_code = payload[1];
s_status.packets_acked = static_cast<uint16_t>(payload[2]) | (static_cast<uint16_t>(payload[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(payload[4]) | (static_cast<uint16_t>(payload[5]) << 8);
s_status.fw_size = static_cast<uint32_t>(payload[6])
| (static_cast<uint32_t>(payload[7]) << 8)
| (static_cast<uint32_t>(payload[8]) << 16)
| (static_cast<uint32_t>(payload[9]) << 24);
s_status.buf_used = static_cast<uint16_t>(payload[10]) | (static_cast<uint16_t>(payload[11]) << 8);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
int motorOtaStart(uint32_t fw_size, uint8_t channel)
{
if (fw_size == 0 || fw_size > MOTOR_OTA_MAX_FW_SIZE || channel > 2) {
return -1;
}
if (s_feed_running) {
return -2;
}
/* 固件已由 HTTP handler 流式写入 gd32_ota 分区;此处不再堆缓冲 */
s_fw_size = fw_size;
s_fw_offset = 0;
/* 发 SPI start 命令(best-effort,不阻塞等 ACK)。
* SPI "延迟一拍"协议下,START 的 ACK 偶发被 send 阶段当作 stale 吃掉、
* poll 反读空 → transact 报 TIMEOUT,但 GD32 实际已收到 START 进握手
* (GD32 日志 "SPI 0xD0 START recv" + "START v150" 每次稳定出现)。
* 重发 START 会被 GD32 拒(已在进行中→0xE5),QUERY 的 ACK 同样受
* delayed-by-one 影响不可靠。改为 fire-and-forget:命令发出即继续,
* 由 feedTask 靠 GD32 push 的状态 + 超时保护自决去留。 */
uint8_t cmd[6];
cmd[0] = 0U; /* cmd=START */
cmd[1] = channel;
cmd[2] = static_cast<uint8_t>(fw_size & 0xFF);
cmd[3] = static_cast<uint8_t>((fw_size >> 8) & 0xFF);
cmd[4] = static_cast<uint8_t>((fw_size >> 16) & 0xFF);
cmd[5] = static_cast<uint8_t>((fw_size >> 24) & 0xFF);
uint8_t resp_type = 0;
uint8_t resp_payload[16];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 6,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
300U); /* 300ms: fire-and-forget,让 worker 有足够时间完成 START。
* 原 500ms 偏保守;GD32 收到 START 立即进 kPreCancel,<50ms 响应。 */
if (ret == ESP_OK && resp_type != kRspAck && resp_len >= 1U &&
resp_payload[0] == 0xE5U) {
/* 0xE5 = GD32 端 relay 会话残留(上次 CANCEL 丢失/未达):不清会直接
* 让本次 OTA 空转失败。补发 CANCEL(GD32 resetToIdle 归 kIdle)后
* 重试一次 START,一次自愈即恢复,无需用户重试或刷新。 */
DEBUG_WARN(TAG, "START 0xE5(GD32 残留会话)→ 补发 CANCEL 后重试 START");
uint8_t ccmd[1] = {1U};
uint8_t crt = 0;
uint8_t crp[4] = {0};
size_t crl = 0;
SpiMaster::instance().resetCredit(); /* 解除背压,保证 CANCEL 发出 */
(void)SpiMaster::instance().transact(
kSpiMotorOtaCmd, ccmd, 1, &crt, crp, sizeof(crp), &crl, 500U);
SpiMaster::instance().resetCredit(); /* GD32 已 resetToIdle,窗口对齐满 */
ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 6,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
300U);
}
if (ret != ESP_OK || resp_type != kRspAck) {
DEBUG_WARN(TAG, "START SPI 未确认 ACK (ret=%s resp=0x%02X)——fire-and-forget 继续",
esp_err_to_name(ret), resp_type);
}
/* 初始化状态 */
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
memset(&s_status, 0, sizeof(s_status));
s_status.state = MOTOR_OTA_HANDSHAKE_REQ;
s_status.fw_size = fw_size;
s_status.total_packets = static_cast<uint16_t>((fw_size + 47U) / 48U);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
/* 启动喂数据 task。prio=7 > spi_worker(6) > httpd(5):
* 传输期优先抢 SPI 队列;实际总线访问仍经 worker 串行。 */
s_feed_running = true;
BaseType_t ok = xTaskCreate(feedTask, "ota_feed", 4096, nullptr, 7, &s_feed_task);
if (ok != pdPASS) {
s_feed_running = false;
return -3;
}
ESP_LOGI(TAG, "Motor OTA started: %lu B, ch=%u", (unsigned long)fw_size, channel);
return 0;
}
void motorOtaCancel(void)
{
DEBUG_WARN(TAG, "cancel: feed_running=%d task=%p offset=%u/%u",
(int)s_feed_running, s_feed_task,
(unsigned)s_fw_offset, (unsigned)s_fw_size);
/* 立即标记 false,让 feedTask 在下次循环检查时退出。
* motorOtaIsActive() 依赖此标志,设 false 后后续 OTA 不再被阻塞。 */
s_feed_running = false;
/* 标记状态为 IDLE,让前端 /motor/ota/status 立即看到空闲 */
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_status.state = MOTOR_OTA_IDLE;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
/* 通知 GD32 取消 relay 状态机 + 等 feedTask 退出是耗时操作
*(SPI transact 最多 1s + feedTask 退出最长 ~8.2s),不能在 httpd 线程
* 同步阻塞,否则 cancel HTTP 请求本身会超时("Failed to fetch"),
* 且 httpd 单线程会饿死其他端点。
* 用独立 task 异步执行,HTTP handler 立即返回。 */
static TaskHandle_t cancel_task = nullptr;
if (cancel_task != nullptr) {
/* 上一次 cancel 还在跑,不重复创建 */
DEBUG_WARN(TAG, "cancel: previous cancel task still running, skip");
return;
}
auto do_cancel = [](void *) {
/* ★ 先复位 credit 再发 CANCEL:取消的典型时机恰是传输卡住(GD32 缓冲满,
* 最后一帧 RESPONSE credit=0 已锁存进 m_remoteCredit)——不先解除背压,
* CANCEL 自己会被 CREDIT 背压 refuse,GD32 端 relay 会话收不到取消而残留,
* 下次 START 被 GD32 0xE5 拒 → "取消后重新开始没法开始"。
* GD32 收到 CANCEL 后 resetToIdle 恢复满窗口,与复位值对齐。 */
SpiMaster::instance().resetCredit();
uint8_t cmd[1] = {1U}; /* cmd=CANCEL */
uint8_t resp_type = 0;
uint8_t resp_payload[4];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 1,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1000U);
if (ret != ESP_OK) {
/* 重试一次:再复位 credit(防首次期间又被失配帧锁 0)后重发 */
vTaskDelay(pdMS_TO_TICKS(50));
SpiMaster::instance().resetCredit();
ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 1,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1000U);
}
DEBUG_WARN(TAG, "cancel: GD32 resp ret=%s type=0x%02X", esp_err_to_name(ret), resp_type);
/* 等待 feedTask 真正退出。feedTask 最坏存活:主循环收尾 ≤0.2s +
* 退出轮询 8s ≈ 8.2s(120s 整体超时路径同样有界),等待上限取 10s
* 覆盖之——正常路径 feedTask 因 s_status 已被置 IDLE 在 ~0.3s 内退出,
* 这里只是兜底;尽量不强删(强删会泄漏 job 槽 + worker 悬垂栈写)。 */
TaskHandle_t t = s_feed_task;
if (t) {
for (int i = 0; i < 1000 && s_feed_task != nullptr; i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (s_feed_task != nullptr) {
DEBUG_ERR(TAG, "cancel: feedTask 未在 10s 内退出,强制删除");
vTaskDelete(s_feed_task);
s_feed_task = nullptr;
/* 强删绕过了 feedTask 收尾,suppress 必须在此恢复,
* 否则 ring service 永久关闭 → GD32 推送全部收不到。 */
SpiMaster::instance().setSuppressRingService(false);
}
}
s_feed_running = false; /* 双保险 */
/* credit 复位:取消的典型时机是传输卡住(GD32 缓冲满)——最后一帧
* RESPONSE 的 credit=0(session 合法)被锁存进 m_remoteCredit。
* GD32 resetToIdle 后转空闲不再发帧,relay 又跑在 APP(不重启、
* 无 session=0 帧)→ 没有任何 resync 入口能把 credit 送回来,
* 后续所有 transact 被 CREDIT 背压拒绝 → 网页所有消息收不到。
* GD32 端 clearOtaSessionQueues 已把接收窗口恢复满,此处对齐复位。 */
SpiMaster::instance().resetCredit();
cancel_task = nullptr;
vTaskDelete(NULL);
};
BaseType_t ok = xTaskCreate(do_cancel, "ota_cancel", 3072, nullptr, 4, &cancel_task);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "cancel: failed to create cancel task, doing sync cancel");
cancel_task = nullptr;
/* 降级:同步执行(极端 OOM 情况) */
SpiMaster::instance().resetCredit(); /* 先解除背压,保证 CANCEL 能发出 */
uint8_t cmd[1] = {1U};
uint8_t resp_type = 0;
uint8_t resp_payload[4];
size_t resp_len = 0;
(void)SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 1,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1000U);
TaskHandle_t t = s_feed_task;
if (t) {
for (int i = 0; i < 200 && s_feed_task != nullptr; i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (s_feed_task != nullptr) {
vTaskDelete(s_feed_task);
s_feed_task = nullptr;
SpiMaster::instance().setSuppressRingService(false);
}
}
s_feed_running = false;
SpiMaster::instance().resetCredit(); /* 同 do_cancel:防 credit=0 锁存死锁 */
}
DEBUG_WARN(TAG, "cancel: dispatched feed_running=%d", (int)s_feed_running);
}
bool motorOtaIsActive(void)
{
return s_feed_running;
}
motor_ota_status_t motorOtaGetStatus(void)
{
motor_ota_status_t out = {};
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
out = s_status;
xSemaphoreGive(s_mutex);
}
return out;
}
+98
View File
@@ -0,0 +1,98 @@
/*
* motor_ota_relay.h - 电机 OTA relay ESP32 侧(固件缓冲 + SPI 喂数据 + 进度缓存)
*
* 两段式流程(与 BLE 刷 GD32 对齐,避免收固件期间与 GD32 按铃争抢单缓冲 MISO):
* 1. 前端 POST /motor/ota 上传固件 + channel → 流式写 gd32_ota flash 分区,写 meta
* 2. 前端 POST /motor/ota/relay?ch=X → motorOtaStart 发 SPI 0xD0(start) + 启动 feedTask
* 3. feedTask 从 flash 分块读,经 SPI 0xD1(data) 喂 GD32
* 4. GD32 通过 0xD2 ring push 报进度,ESP32 缓存
* 5. 前端 GET /motor/ota/status 轮询进度
*/
#ifndef MOTOR_OTA_RELAY_H
#define MOTOR_OTA_RELAY_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define MOTOR_OTA_MAX_FW_SIZE 0xC000U /* 48 KB */
/* SPI payload 上限 236 = 256 - V2_OVERHEAD(20)。前 4B 为绝对 offset LE,数据最多 232B。
* 无 offset 时 SPI 超时重传会被 GD32 重复追加(BLF:前 236B 循环 → 刷坏)。 */
#define MOTOR_OTA_CHUNK_HDR 4U
#define MOTOR_OTA_CHUNK_DATA 232U
#define MOTOR_OTA_CHUNK_SIZE (MOTOR_OTA_CHUNK_HDR + MOTOR_OTA_CHUNK_DATA)
typedef enum {
MOTOR_OTA_IDLE = 0,
MOTOR_OTA_HANDSHAKE_REQ,
MOTOR_OTA_HANDSHAKE_NONCE,
MOTOR_OTA_HANDSHAKE_CONFIRM,
MOTOR_OTA_ERASE,
MOTOR_OTA_TRANSFER,
MOTOR_OTA_FINISH_OTA, /* 收到 0xF0F0F0F0,已发 FINISH_OTA,等跳转 ACK */
MOTOR_OTA_FINISHED,
MOTOR_OTA_ERROR,
MOTOR_OTA_PRE_CANCEL = 9, /* 握手前预取消:发 CANCEL 清理电机残留会话(GD32 侧过渡态) */
MOTOR_OTA_WAIT_APP_RUN = 10 /* 尾包 ACK 后等新 APP 自主发 [0A AC] 运行确认,收到才 FINISHED */
} motor_ota_state_t;
typedef struct {
motor_ota_state_t state;
uint8_t error_code;
uint16_t packets_acked;
uint16_t total_packets;
uint32_t fw_size;
uint16_t buf_used; /* GD32 侧缓冲占用 */
int64_t last_update_ms; /* ESP32 收到状态的时间 */
} motor_ota_status_t;
/**
* @brief 处理 GD32 0xD2 ring push(由 can_feedback_task 分发)。
*/
void onMotorOtaStatusPush(const uint8_t *payload, size_t len);
/**
* @brief 启动电机 OTA:发 SPI start + 启动喂数据 task。
*
* 固件须已由调用方(HTTP handler)流式写入 gd32_ota flash 分区
* (Gd32FlashStore::flashWriteChunk,offset 0 起)。喂数据 task 从
* flash 分块读取并经 SPI 0xD1 喂给 GD32,不在堆上缓冲整个固件
* (48KB 整块 malloc 会超出 ESP32-C3 的 largest_free_block,导致 OOM)。
*
* @param fw_size 固件大小 (≤48KB),须与已写入 flash 的大小一致
* @param channel CAN 通道 (0-2)
* @return 0 成功, -1 参数错误, -2 已在进行中, -3 SPI 失败
*/
int motorOtaStart(uint32_t fw_size, uint8_t channel);
/**
* @brief 电机 OTA 是否正在进行(喂数据 task 存活)。
* 供 OtaCoordinator 在 beginGd32Storage/beginEsp32Ota 中检查:
* 电机喂数期间禁止新的 GD32/ESP32 OTA,避免 gd32_ota 分区被并发覆写。
*/
bool motorOtaIsActive(void);
/**
* @brief 取消电机 OTA。
*/
void motorOtaCancel(void);
/**
* @brief 获取最新状态(线程安全)。
*/
motor_ota_status_t motorOtaGetStatus(void);
/**
* @brief 模块初始化(创建 mutex 等)。
*/
void motorOtaRelayInit(void);
#ifdef __cplusplus
}
#endif
#endif /* MOTOR_OTA_RELAY_H */
+119
View File
@@ -0,0 +1,119 @@
/*
* motor_state.cpp - GD32 多电机状态推送解析与缓存 (SPI TYPE 0xC5)
*/
#include "monitor/motor_state.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_state_table_t s_table = {};
} // namespace
bool onMotorStateEvent(const uint8_t *payload, size_t len)
{
if (payload == nullptr || len < MOTOR_PAGE_HEADER) {
return false;
}
const uint8_t page = payload[0];
const uint8_t total_pages = payload[1];
const uint8_t entry_count = payload[2];
const uint8_t active_cnt = payload[3];
if (page >= MOTOR_TOTAL_PAGES || total_pages == 0 || total_pages > MOTOR_TOTAL_PAGES) {
return false;
}
/* 验证长度足够容纳声明的 entry 数 */
const size_t expected = MOTOR_PAGE_HEADER + static_cast<size_t>(entry_count) * MOTOR_ENTRY_SIZE;
if (len < expected) {
return false;
}
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
/* page 0 时刷新全局字段。
* active 字节 bit7 = boot_active(总线上有电机在 BOOT,0x103 心跳),
* 低 7 位才是真实在线电机数。 */
if (page == 0) {
s_table.active_count = static_cast<uint8_t>(active_cnt & 0x7FU);
s_table.boot_active = (active_cnt & 0x80U) ? 1U : 0U;
}
/* 逐条解析 entry:node×channel 双键槽位,同节点号多通道互不覆盖 */
size_t offset = MOTOR_PAGE_HEADER;
for (uint8_t i = 0; i < entry_count; ++i) {
const uint8_t *e = &payload[offset];
const uint8_t node = e[0];
const uint8_t ch = e[1];
if (node < 1 || node > MOTOR_MAX_NODES || ch >= MOTOR_CHANNELS) {
offset += MOTOR_ENTRY_SIZE;
continue;
}
motor_slot_t &slot = s_table.slots[(node - 1) * MOTOR_CHANNELS + ch];
slot.node_id = node;
slot.channel = ch;
slot.online = e[2];
slot.dlc = e[3];
slot.rx_count = rdU32Le(&e[4]);
slot.seq = rdU32Le(&e[8]);
memcpy(slot.data, &e[12], MOTOR_STATUS_DLC);
/* v0.74: STATUS DLC 48,CONFIG 字段整体后移 16B */
slot.config_rx_count = rdU32Le(&e[60]);
slot.config_seq = rdU32Le(&e[64]);
memcpy(slot.config_data, &e[68], MOTOR_CONFIG_DLC);
offset += MOTOR_ENTRY_SIZE;
}
/* 统计有效槽位 */
uint8_t valid = 0;
for (uint8_t i = 0; i < MOTOR_MAX_SLOTS; ++i) {
if (s_table.slots[i].node_id != 0) valid++;
}
s_table.valid_slots = valid;
s_table.recv_ms = esp_timer_get_time() / 1000;
s_table.have_data = true;
xSemaphoreGive(s_mux);
return true;
}
bool getLatestMotorState(motor_state_table_t *out)
{
if (out == nullptr) return false;
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
if (s_table.have_data) {
*out = s_table;
ok = true;
}
xSemaphoreGive(s_mux);
}
return ok;
}
uint32_t motorStateAgeMs(void)
{
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return UINT32_MAX;
int64_t recv = 0;
bool have = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
have = s_table.have_data;
recv = s_table.recv_ms;
xSemaphoreGive(s_mux);
}
return ringCacheAgeMs(have, recv);
}
+86
View File
@@ -0,0 +1,86 @@
/*
* motor_state.h - 被动接收 GD32 多电机状态推送(SPI TYPE 0xC5)
*
* 与 CAN 0xC1 / ADC 0xC4 同模型:GD32 250ms 主动按铃推送(独立任务位),ESP32 takeEvent 侧解析入库。
* 是否推送由 GD32 CONFIG_MOTOR_STATE_ENABLE 决定;ESP32 无本地开关,有帧即解析。
*
* payload 格式(每页):
* [0] page, [1] total_pages, [2] entry_count, [3] active_count
* [4..] 每个 entry 92B (v0.74: STATUS DLC 48):
* [0] node_id, [1] channel, [2] online, [3] dlc,
* [4..7] rx_count (LE), [8..11] seq (LE),
* [12..59] data[48] (原始 STATUS payload, 字节 32~35 = InitStatus)
* [60..63] config_rx_count (LE), [64..67] config_seq (LE),
* [68..91] config_data[24] (原始 CONFIG payload, 目标值回报)
*
* 注意:STATUS DLC 48 与页大小/页数变更(76→92、9→14 页)需 DCU GD32 与
* ESP32 固件成对升级,旧解析方会因长度校验失败拒收(不误解析)。
*
* 同一节点号可能同时挂在多个 CAN 通道(两台电机同号异线),
* 缓存表与 GD32 端一致按 node×channel 双键存储(27 槽)。
*/
#ifndef MOTOR_STATE_H
#define MOTOR_STATE_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define MOTOR_MAX_NODES 9
#define MOTOR_CHANNELS 3
#define MOTOR_MAX_SLOTS (MOTOR_MAX_NODES * MOTOR_CHANNELS)
#define MOTOR_STATUS_DLC 48
#define MOTOR_CONFIG_DLC 24
#define MOTOR_ENTRY_SIZE 92
#define MOTOR_PAGE_HEADER 4
#define MOTOR_TOTAL_PAGES 14
/** @brief 单电机状态快照。 */
typedef struct {
uint8_t node_id; ///< 1-9, 0=空槽
uint8_t channel; ///< CAN 通道 (0/1/2)
uint8_t online; ///< 1=在线, 0=离线
uint8_t dlc; ///< STATUS 帧 DLC (正常=48)
uint32_t rx_count; ///< 累计收到帧数
uint32_t seq; ///< GD32 侧更新序号
uint8_t data[MOTOR_STATUS_DLC]; ///< 原始 STATUS payload
uint32_t config_rx_count; ///< 累计收到 CONFIG 帧数
uint32_t config_seq; ///< CONFIG 更新序号
uint8_t config_data[MOTOR_CONFIG_DLC]; ///< 原始 CONFIG payload (目标值回报)
} motor_slot_t;
/** @brief 全局电机状态表。 */
typedef struct {
motor_slot_t slots[MOTOR_MAX_SLOTS]; ///< 按 (node_id-1)*MOTOR_CHANNELS+channel 索引
uint8_t active_count; ///< 在线电机数
uint8_t boot_active; ///< 1=总线上有电机在 BOOT(0x103 心跳,来自 0xC5 头 bit7)
uint8_t valid_slots; ///< 有效槽位数(node_id!=0)
int64_t recv_ms; ///< ESP32 最近收到时 esp_timer ms
bool have_data; ///< 至少收到过一帧
} motor_state_table_t;
/**
* @brief 处理一帧 0xC5 EVENT payload(由 can_fb 任务 takeEvent 分发)。
* @return true 表示已接受并更新。
*/
bool onMotorStateEvent(const uint8_t *payload, size_t len);
/**
* @brief 拷贝最新电机状态表(线程安全)。
* @param[out] out 输出缓冲
* @return true 表示至少收到过一帧
*/
bool getLatestMotorState(motor_state_table_t *out);
/** @brief 距离上次收到的毫秒数;无数据返回 UINT32_MAX。 */
uint32_t motorStateAgeMs(void);
#ifdef __cplusplus
}
#endif
#endif /* MOTOR_STATE_H */
+67
View File
@@ -0,0 +1,67 @@
/*
* motor_version.cpp - GD32 多电机版本推送解析与缓存 (SPI TYPE 0xC6)
*
* 镜像 motor_state.cpp 的互斥保护缓存模型。
*/
#include "monitor/motor_version.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_version_table_t s_table = {};
} // namespace
bool onMotorVersionEvent(const uint8_t *payload, size_t len)
{
if (payload == nullptr || len < MOTORVER_HEADER_LEN) {
return false;
}
const uint8_t count = payload[0];
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
size_t offset = MOTORVER_HEADER_LEN;
for (uint8_t i = 0; i < count; ++i) {
if (offset + MOTORVER_ENTRY_SIZE > len) {
break;
}
const uint8_t node = payload[offset];
if (node >= 1 && node <= MOTORVER_MAX_NODES) {
motor_version_slot_t &slot = s_table.slots[node - 1];
slot.node_id = node;
slot.seq = rdU32Le(&payload[offset + 1]);
memcpy(slot.version, &payload[offset + 5], MOTORVER_DLC);
slot.recv_ms = esp_timer_get_time() / 1000;
slot.valid = true;
s_table.have_data = true;
}
offset += MOTORVER_ENTRY_SIZE;
}
xSemaphoreGive(s_mux);
return true;
}
bool getLatestMotorVersion(motor_version_table_t *out)
{
if (out == nullptr) return false;
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
if (s_table.have_data) {
*out = s_table;
ok = true;
}
xSemaphoreGive(s_mux);
}
return ok;
}
+62
View File
@@ -0,0 +1,62 @@
/*
* motor_version.h - 被动接收 GD32 多电机版本推送(SPI TYPE 0xC6)
*
* 与 0xC5 电机状态同模型:GD32 收到电机版本应答(0x00FF)后事件驱动按铃推送,
* ESP32 takeEvent 侧解析入库。版本为按需查询的低频数据,独立于 0xC5 周期推送。
* 是否推送由 GD32 CONFIG_MOTOR_VERSION_ENABLE 决定;ESP32 无本地开关,有帧即解析。
*
* payload 格式(单帧,Header 2B + 每 entry 29B):
* [0] count 本帧节点数
* [1] rsvd 保留
* [2..] 每个 entry 29B:
* [0] node_id, [1..4] seq (LE), [5..28] version[24] (原始版本 payload)
*/
#ifndef MOTOR_VERSION_H
#define MOTOR_VERSION_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define MOTORVER_MAX_NODES 9
#define MOTORVER_DLC 24
#define MOTORVER_ENTRY_SIZE 29 /* node(1) + seq(4) + version(24) */
#define MOTORVER_HEADER_LEN 2
/** @brief 单电机版本快照。 */
typedef struct {
uint8_t node_id; ///< 1-9, 0=空槽
uint32_t seq; ///< GD32 侧更新序号(判新鲜用)
uint8_t version[MOTORVER_DLC]; ///< 原始版本 payload
int64_t recv_ms; ///< ESP32 收到时 esp_timer ms
bool valid; ///< 该槽已收到过版本
} motor_version_slot_t;
/** @brief 全局电机版本表。 */
typedef struct {
motor_version_slot_t slots[MOTORVER_MAX_NODES]; ///< 按 node_id-1 索引
bool have_data; ///< 至少收到过一帧
} motor_version_table_t;
/**
* @brief 处理一帧 0xC6 EVENT payload(由 can_fb 任务 takeEvent 分发)。
* @return true 表示已接受并更新。
*/
bool onMotorVersionEvent(const uint8_t *payload, size_t len);
/**
* @brief 拷贝最新电机版本表(线程安全)。
* @param[out] out 输出缓冲
* @return true 表示至少收到过一帧
*/
bool getLatestMotorVersion(motor_version_table_t *out);
#ifdef __cplusplus
}
#endif
#endif /* MOTOR_VERSION_H */
+62
View File
@@ -0,0 +1,62 @@
/*
* spi_ring_cache.h - SPI ring push 缓存共享辅助
*
* gd32_adc_feedback / motor_state / motor_version 三个模块共用相同的
* 互斥懒创建 + 小端读 + ageMs 计算样板,提取到此处消除三份拷贝。
*/
#ifndef SPI_RING_CACHE_H
#define SPI_RING_CACHE_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── 小端读 ── */
static inline uint16_t rdU16Le(const uint8_t *p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
static inline uint32_t rdU32Le(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
/* ── 互斥懒创建 ── */
static inline void ringCacheEnsureMux(SemaphoreHandle_t *mux)
{
if (*mux == nullptr) {
*mux = xSemaphoreCreateMutex();
}
}
/* ── 通用 ageMs ──
* 传入缓存的 have 标志 + recv_ms 时间戳,返回距 now 的毫秒数。
* 无数据或 recv_ms<=0 返回 UINT32_MAX。
*/
static inline uint32_t ringCacheAgeMs(bool have, int64_t recv_ms)
{
if (!have || recv_ms <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv_ms) return 0U;
const int64_t age = now - recv_ms;
return (age > (int64_t)UINT32_MAX) ? UINT32_MAX : (uint32_t)age;
}
#ifdef __cplusplus
}
#endif
#endif // SPI_RING_CACHE_H
+181
View File
@@ -0,0 +1,181 @@
/**
* @file spi_speed_test.cpp
* @brief 【临时功能 SPI-SPEEDTEST】SPI 链路测速端点 GET /spi/speedtest
*
* 用 0xA1 APP PING(无副作用、GD32 在 onCsRelease ISR 内 µs 级应答)作探针,
* 经 SpiMaster::transact() 走与业务完全相同的 worker 串行路径,
* 测量事件驱动握手(ready 下降沿唤醒)下的真实 RTT 与等效吞吐。
*
* 用法:
* GET /spi/speedtest # 默认 200 帧 × 236B payload
* GET /spi/speedtest?frames=500&payload=0 # 500 帧空载(纯 RTT)
* 返回中文纯文本: 帧数/成功/失败、总耗时、往返延迟 平均/最快/最慢、等效吞吐
*
* ── 删除本临时功能的步骤(共 3 处挂接点,全部以 [SPI-SPEEDTEST] 标记)──
* 1. 删除本文件与 monitor/spi_speed_test.h
* 2. main/CMakeLists.txt 删除 "monitor/spi_speed_test.cpp" 一行
* 3. server/wifi_ota.cpp 删除 include 与 kHttpRoutes 中各一行
* (spi_master.cpp 中 0xA1 零盲等白名单为正式优化,保留不删)
*/
#include "spi_speed_test.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include "esp_timer.h"
#include "spi_master.h"
#include "http_handlers.h" /* http_set_cors_headers */
#include "server/http_util.h"
#include "monitor/debug_log.h"
namespace {
constexpr uint8_t kProbeType = 0xA1U; /* APP PING:无副作用探针 */
constexpr uint32_t kFramesDefault = 200U;
constexpr uint32_t kFramesMax = 2000U; /* ~3s 上限,避免占死 HTTP/worker */
constexpr size_t kPayloadDefault = 236U; /* 满帧:等效 0xD1 电机 OTA 数据节奏 */
constexpr size_t kPayloadMax = 236U; /* APP kTxBufSize=256 - V2_OVERHEAD */
constexpr uint32_t kFrameTimeoutMs = 500U;
} // namespace
esp_err_t spi_speedtest_handler(httpd_req_t *req)
{
uint32_t frames = http_query_u32(req, "frames", kFramesDefault);
if (frames < 1U) { frames = 1U; }
if (frames > kFramesMax) { frames = kFramesMax; }
size_t payload_len = (size_t)http_query_u32(req, "payload", (uint32_t)kPayloadDefault);
if (payload_len > kPayloadMax) { payload_len = kPayloadMax; }
/* 探针 payload:递增填充(CRC32 覆盖,顺带校验数据路径完整性) */
uint8_t probe[kPayloadMax];
for (size_t i = 0; i < payload_len; ++i) {
probe[i] = (uint8_t)i;
}
uint32_t n_ok = 0U;
uint32_t n_fail = 0U;
uint64_t rtt_sum_us = 0U;
uint64_t rtt_min_us = UINT64_MAX;
uint64_t rtt_max_us = 0U;
/* RTT 分布直方图(µs 计时,<2ms 区间细化为 0.5ms 粒度):
* <0.5 / 0.5-1 / 1-1.5 / 1.5-2 / 2-3 / 3-5 / >=5 ms,
* 用于判断抖动形态(<2ms 内的尖峰/双峰 vs 离散 +1ms 碰撞) */
uint32_t histo[7] = {0U, 0U, 0U, 0U, 0U, 0U, 0U};
/* 事务耗时(worker 内执行段,SpiMaster::lastWorkerExecUs)与
* 调度/唤醒开销(RTT − 事务)拆解:事务慢=GD32 应答/SPI 时序;
* 调度慢=ESP32 任务抢占(WiFi/esp_timer 等)。 */
uint64_t w_sum_us = 0U, w_min_us = UINT64_MAX, w_max_us = 0U;
uint64_t s_sum_us = 0U, s_min_us = UINT64_MAX, s_max_us = 0U;
/* 5 段细分(SpiMaster::SpiProfile):提交/调度、发送帧、等应答、读响应、唤醒 */
uint64_t seg_sum[5] = {0U, 0U, 0U, 0U, 0U};
uint64_t seg_max[5] = {0U, 0U, 0U, 0U, 0U};
DEBUG_INFO("SPI", "[SPEEDTEST] start frames=%u payload=%u",
(unsigned)frames, (unsigned)payload_len);
const int64_t t_start = esp_timer_get_time();
for (uint32_t i = 0U; i < frames; ++i) {
uint8_t resp_type = 0U;
uint8_t resp_payload[64];
size_t resp_len = 0U;
const int64_t t0 = esp_timer_get_time();
const esp_err_t ret = SpiMaster::instance().transact(
kProbeType, probe, payload_len,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
kFrameTimeoutMs);
const int64_t t1 = esp_timer_get_time();
if (ret == ESP_OK && resp_type == 0x80U) {
const uint64_t rtt = (uint64_t)(t1 - t0);
++n_ok;
rtt_sum_us += rtt;
if (rtt < rtt_min_us) { rtt_min_us = rtt; }
if (rtt > rtt_max_us) { rtt_max_us = rtt; }
const uint64_t wus = SpiMaster::instance().lastWorkerExecUs();
const uint64_t sus = (rtt > wus) ? (rtt - wus) : 0U;
w_sum_us += wus;
if (wus < w_min_us) { w_min_us = wus; }
if (wus > w_max_us) { w_max_us = wus; }
s_sum_us += sus;
if (sus < s_min_us) { s_min_us = sus; }
if (sus > s_max_us) { s_max_us = sus; }
/* 5 段细分(防回绕钳 0) */
const SpiMaster::SpiProfile &pf = SpiMaster::instance().lastProfile();
const uint64_t seg[5] = {
(pf.t_w0 > pf.t_submit0) ? (pf.t_w0 - pf.t_submit0) : 0U,
(pf.t_send1 > pf.t_send0) ? (pf.t_send1 - pf.t_send0) : 0U,
(pf.t_wait1 > pf.t_wait0) ? (pf.t_wait1 - pf.t_wait0) : 0U,
(pf.t_read1 > pf.t_wait1) ? (pf.t_read1 - pf.t_wait1) : 0U,
(pf.t_submit1 > pf.t_w1) ? (pf.t_submit1 - pf.t_w1) : 0U,
};
for (int k = 0; k < 5; ++k) {
seg_sum[k] += seg[k];
if (seg[k] > seg_max[k]) { seg_max[k] = seg[k]; }
}
if (rtt < 500U) { ++histo[0]; }
else if (rtt < 1000U) { ++histo[1]; }
else if (rtt < 1500U) { ++histo[2]; }
else if (rtt < 2000U) { ++histo[3]; }
else if (rtt < 3000U) { ++histo[4]; }
else if (rtt < 5000U) { ++histo[5]; }
else { ++histo[6]; }
} else {
++n_fail;
}
}
const int64_t elapsed_us = esp_timer_get_time() - t_start;
const uint32_t elapsed_ms = (uint32_t)(elapsed_us / 1000);
const uint64_t rtt_avg_us = (n_ok > 0U) ? (rtt_sum_us / n_ok) : 0U;
/* 吞吐语义:payload 字节 / 完整"发送+应答"周期,等效 0xD1 单缓冲喂速上限 */
double tput_kbps = 0.0;
if (elapsed_us > 0 && n_ok > 0U) {
tput_kbps = ((double)n_ok * (double)payload_len) / ((double)elapsed_us / 1000000.0) / 1024.0;
}
if (rtt_min_us == UINT64_MAX) { rtt_min_us = 0U; }
if (w_min_us == UINT64_MAX) { w_min_us = 0U; }
if (s_min_us == UINT64_MAX) { s_min_us = 0U; }
DEBUG_INFO("SPI", "[SPEEDTEST] done ok=%u fail=%u avg=%uus tput=%.1fKB/s",
(unsigned)n_ok, (unsigned)n_fail, (unsigned)rtt_avg_us, tput_kbps);
char text[640];
const int len = snprintf(text, sizeof(text),
"SPI 测速结果\n"
"帧数: %u 成功: %u 失败: %u%s\n"
"载荷: %u B/帧 总耗时: %u ms\n"
"往返延迟: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"SPI 事务: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"调度开销: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"单帧分段 平均: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"单帧分段 最慢: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"延迟分布: <0.5ms:%u 0.5-1ms:%u 1-1.5ms:%u 1.5-2ms:%u 2-3ms:%u 3-5ms:%u >=5ms:%u\n"
"等效吞吐: %.1f KB/s\n",
(unsigned)frames, (unsigned)n_ok, (unsigned)n_fail,
(n_fail > 0U) ? " <-- 存在失败帧, 链路异常!" : "",
(unsigned)payload_len, (unsigned)elapsed_ms,
(unsigned long long)rtt_avg_us, (unsigned long long)rtt_min_us,
(unsigned long long)rtt_max_us,
(unsigned long long)(w_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)w_min_us, (unsigned long long)w_max_us,
(unsigned long long)(s_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)s_min_us, (unsigned long long)s_max_us,
(unsigned long long)(seg_sum[0] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[1] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[2] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[3] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[4] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)seg_max[0], (unsigned long long)seg_max[1],
(unsigned long long)seg_max[2], (unsigned long long)seg_max[3],
(unsigned long long)seg_max[4],
(unsigned)histo[0], (unsigned)histo[1], (unsigned)histo[2],
(unsigned)histo[3], (unsigned)histo[4],
(unsigned)histo[5], (unsigned)histo[6], tput_kbps);
http_set_cors_headers(req);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
return httpd_resp_send(req, text, (len > 0) ? len : 0);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* @file spi_speed_test.h
* @brief 【临时功能 SPI-SPEEDTEST】SPI 链路测速端点声明。
* 删除方法见 spi_speed_test.cpp 文件头。
*/
#ifndef SPI_SPEED_TEST_H
#define SPI_SPEED_TEST_H
#include "esp_http_server.h"
/** @brief GET /spi/speedtest — 0xA1 探针测 SPI RTT/吞吐(临时功能)。 */
esp_err_t spi_speedtest_handler(httpd_req_t *req);
#endif // SPI_SPEED_TEST_H
+86
View File
@@ -0,0 +1,86 @@
/*
* task_stack.cpp - 任务栈水位收集 + FreeRTOS 栈溢出钩子
*
* 水位表:health_mon 巡检 + /system/status 共用。
* 溢出钩子:CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY 下内核回调;
* 被踩栈上下文禁止 mutex/网络/格式化,仅 UART0 硬写 + abort。
*/
#include "task_stack.h"
#include <cstring>
#include "app_config.h"
#include "health_monitor.h"
#include "cpu_stats.h"
#include "ota_relay.h"
#include "wifi_ota.h"
#include "ble_ota.h"
#include "spi_master.h"
#include "can_feedback_task.h"
#include "driver/uart.h"
#include "esp_system.h"
// ============================================================
// 栈溢出钩子
// ============================================================
volatile bool g_stackOverflowDetected = false;
extern "C" void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
(void)xTask;
g_stackOverflowDetected = true;
static const char prefix[] = "\r\n!!! STACK OVERFLOW in task: ";
uart_write_bytes(UART_NUM_0, prefix, sizeof(prefix) - 1);
uart_write_bytes(UART_NUM_0, pcTaskName, strnlen(pcTaskName, configMAX_TASK_NAME_LEN));
static const char suffix[] = " !!!\r\n";
uart_write_bytes(UART_NUM_0, suffix, sizeof(suffix) - 1);
/* OTA 末尾若 httpd 栈溢出却不 abort,前端会一直停在 100%。 */
esp_system_abort("stack overflow");
}
// ============================================================
// 任务栈水位表
// ============================================================
size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max)
{
if (!out || max == 0) {
return 0;
}
size_t n = 0;
// httpd 无公开句柄;未 httpd_start 时 xTaskGetHandle 为 NULL
if (n < max) {
out[n++] = { "http_server", xTaskGetHandle("httpd"), HTTP_SERVER_STACK_SIZE };
}
if (n < max) {
out[n++] = { "health_mon", HealthMonitor::instance().getTaskHandle(), TASK_STACK_HEALTH_MON };
}
if (n < max) {
out[n++] = { "cpu_stats", CpuStats::instance().getTaskHandle(), TASK_STACK_CPU_STATS };
}
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 };
}
if (n < max) {
out[n++] = { "wifi_reconnect", WifiOta::instance().m_reconnectTask, TASK_STACK_WIFI_RECONNECT };
}
if (n < max) {
out[n++] = { "ble_ota", BleOta::instance().getTaskHandle(), TASK_STACK_BLE_OTA };
}
if (n < max) {
out[n++] = { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), TASK_STACK_BLE_WORKER };
}
if (n < max) {
out[n++] = { "can_fb", getCanFeedbackTaskHandle(), TASK_STACK_CAN_FB };
}
return n;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* task_stack.h - 任务栈:水位收集 + 溢出钩子
*
* 1) collectTaskStackMetrics:供 health_mon 巡检与 /system/status 共用任务表
* 2) vApplicationStackOverflowHook / g_stackOverflowDetected:canary 溢出
*/
#pragma once
#include <stddef.h>
#include <stdbool.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#ifdef __cplusplus
extern "C" {
#endif
/** canary 触发时由 vApplicationStackOverflowHook 置位(随后 abort)。 */
extern volatile bool g_stackOverflowDetected;
#ifdef __cplusplus
}
#endif
#ifdef __cplusplus
/** @brief 单个任务的栈指标描述。 */
struct TaskStackMetric {
const char* name; ///< 任务名(展示用)
TaskHandle_t handle; ///< 句柄(可能为 NULL:任务尚未创建)
size_t stack_size_bytes; ///< 创建时分配的栈大小(字节)
};
/**
* @brief 收集受监控任务的栈指标。
* @return 实际写入条数(<= max)。handle==NULL 的条目由调用方跳过。
*/
size_t collectTaskStackMetrics(TaskStackMetric* out, size_t max);
#endif /* __cplusplus */
+70 -37
View File
@@ -1,9 +1,7 @@
/*
* esp32_ota_core.cpp - ESP32 本机 OTA 核心流程实现(WiFi / BLE 通道共用)
* esp32_ota_core.cpp - ESP32 本机 OTA 核心流程(WiFi / BLE 通道共用)
*
* 设计依据 WiFi 原流程(wifi_ota.cpp 旧实现),修复已知 bug:
* - 旧 wifi_ota.cpp:625 在 esp_ota_end 成功后又调 esp_ota_abort → 用 m_ended 标志避免
* - SHA 统一从分区读回计算,不在传输过程中做增量 SHA
* SHA-256 在写入完成后从 OTA 分区读回计算;finalize 用 m_ended 区分 end 与 abort。
*/
#include <string.h>
#include <ctype.h>
@@ -12,6 +10,8 @@
#include "esp_system.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
#include "esp_task_wdt.h"
#include "esp_timer.h"
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
#include "mbedtls/private/sha256.h"
@@ -19,11 +19,12 @@
#include "esp32_ota_core.h"
#include "app_config.h"
#include "monitor/debug_log.h"
#include "spi_master.h" /* setSpiPaused:OTA 期间暂停 SPI,避免 flash 写与 SPI 读并发 */
#define TAG "OTA_CORE"
// ============================================================
// 分区互斥锁(M1: 内聚自 ota_mutex.h 全局变量)
// 分区互斥锁
// ============================================================
SemaphoreHandle_t Esp32OtaCore::s_mutex = NULL;
volatile bool Esp32OtaCore::s_occupied = false;
@@ -110,6 +111,7 @@ esp_err_t Esp32OtaCore::finalizeCleanupAndReturn(esp_err_t code)
}
m_ended = false;
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 结束(失败也恢复 SPI) */
resetState();
return code;
}
@@ -144,7 +146,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
}
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx",
DEBUG_DBG(TAG, "当前启动分区: %s, size=%u, address=0x%lx",
boot_partition->label, boot_partition->size, boot_partition->address);
// 选择非当前启动的 OTA 分区(OTA_0 → OTA_1 fallback)
@@ -161,7 +163,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
return beginCleanupAndReturn(ESP_FAIL);
}
DEBUG_INFO(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx",
DEBUG_DBG(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx",
m_partition->label, m_partition->size, m_partition->address);
if (total_size > m_partition->size) {
@@ -170,19 +172,30 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
return beginCleanupAndReturn(ESP_FAIL);
}
// BLE OTA 修复:传固件实际大小而非分区全尺寸,避免全分区擦除导致
// 长时间阻塞触发 Task Watchdog(ESP32-C3 单核 5s 超时)。
// esp_ota_begin 按 image_size 擦除;esp_ota_end 仅校验已写数据,
// 未擦除残留扇区不影响校验。size 对齐到 64KB 块边界确保尾部干净。
size_t erase_size = (total_size + 0xFFFF) & ~0xFFFFULL;
if (erase_size > m_partition->size) {
erase_size = m_partition->size;
/* ★ 边写边擦(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();
if (inv != ESP_OK && inv != ESP_ERR_NOT_SUPPORTED) {
DEBUG_WARN(TAG, "invalidate inactive OTA slot: %s(继续)",
esp_err_to_name(inv));
}
}
esp_err_t err = esp_ota_begin(m_partition, erase_size, &m_handle);
#endif
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_begin 失败: %s", esp_err_to_name(err));
DEBUG_ERR(TAG, "ota_begin fail:%s %lldms",
esp_err_to_name(err), (long long)((t1 - t0) / 1000));
return beginCleanupAndReturn(err);
}
DEBUG_DBG(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu",
(long long)((t1 - t0) / 1000),
(unsigned long)esp_get_free_heap_size());
m_received = 0;
m_active = true;
@@ -190,7 +203,13 @@ 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 字节",
/* ESP32 自我 OTA 期间暂停 SPI(GD32 按铃读 + 业务 transact 全部停摆):
* esp_ota_write 写 flash 时 cache 反复失效,SPI worker 的 flash 驻留代码
* 访问会被拖慢甚至触发 Cache error,且此时 GD32 数据无意义。
* finalize()/abort() 恢复。 */
SpiMaster::instance().setSpiPaused(true);
DEBUG_DBG(TAG, "ESP32 OTA begin part=%s size=%u (erase-on-write)",
m_partition->label, (unsigned)total_size);
return ESP_OK;
}
@@ -207,11 +226,14 @@ esp_err_t Esp32OtaCore::write(const uint8_t *data, size_t len)
return ESP_ERR_INVALID_STATE;
}
/* 整分区已预擦:此处为纯 write,不再扇区擦除。 */
(void)esp_task_wdt_reset();
esp_err_t err = esp_ota_write(m_handle, data, len);
if (err == ESP_OK) {
m_received += len;
} else {
DEBUG_ERR(TAG, "esp_ota_write 失败: %s (len=%u)", esp_err_to_name(err), (unsigned)len);
DEBUG_ERR(TAG, "ota_write %s n=%u off=%u",
esp_err_to_name(err), (unsigned)len, (unsigned)m_received);
}
return err;
}
@@ -226,39 +248,45 @@ esp_err_t Esp32OtaCore::computeShaFromPartition()
return ESP_ERR_INVALID_STATE;
}
DEBUG_INFO(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received);
DEBUG_DBG(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received);
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0);
/* static context + 小读缓冲:勿占 httpd 栈,也勿再静态吃 4KB BSS */
static mbedtls_sha256_context s_sha_ctx;
static uint8_t s_sha_read_buf[1024];
// ★ static 缓冲区:OTA 操作由 OtaCoordinator 互斥串行化,无并发风险
static uint8_t s_sha_read_buf[OTA_BLOCK_SIZE];
mbedtls_sha256_init(&s_sha_ctx);
mbedtls_sha256_starts(&s_sha_ctx, 0);
size_t offset = 0;
uint32_t wdt_chunk = 0;
while (offset < m_received) {
size_t to_read = m_received - offset;
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
if (to_read > sizeof(s_sha_read_buf)) to_read = sizeof(s_sha_read_buf);
esp_err_t rd_err = esp_partition_read(m_partition, offset, s_sha_read_buf, to_read);
if (rd_err != ESP_OK) {
DEBUG_ERR(TAG, "SHA 计算: 分区读失败 @offset %u: %s",
DEBUG_ERR(TAG, "SHA读失败 off=%u %s",
(unsigned)offset, esp_err_to_name(rd_err));
mbedtls_sha256_free(&ctx);
mbedtls_sha256_free(&s_sha_ctx);
return rd_err;
}
mbedtls_sha256_update(&ctx, s_sha_read_buf, to_read);
mbedtls_sha256_update(&s_sha_ctx, s_sha_read_buf, to_read);
offset += to_read;
/* 长固件 SHA 可能 > TASK_WDT(5s);在 httpd 上跑时必须喂狗 */
if ((++wdt_chunk & 0x0FU) == 0U) {
(void)esp_task_wdt_reset();
taskYIELD();
}
}
mbedtls_sha256_finish(&ctx, m_sha256);
mbedtls_sha256_free(&ctx);
mbedtls_sha256_finish(&s_sha_ctx, m_sha256);
mbedtls_sha256_free(&s_sha_ctx);
m_shaComputed = true;
char hex[65] = {0};
for (int i = 0; i < 32; ++i) {
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02x", m_sha256[i]);
}
DEBUG_INFO(TAG, "设备端 SHA-256: %s", hex);
DEBUG_DBG(TAG, "设备端 SHA-256: %s", hex);
return ESP_OK;
}
@@ -303,10 +331,10 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
// ── 先读回分区计算 SHA-256,并与前端文件 SHA 比对 ──
if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) {
DEBUG_INFO(TAG, "ESP32 OTA 校验 SHA-256...");
DEBUG_DBG(TAG, "ESP32 OTA 校验 SHA-256...");
esp_err_t sha_err = computeShaFromPartition();
if (sha_err != ESP_OK) {
DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err));
DEBUG_ERR(TAG, "SHA失败:%s 中止", esp_err_to_name(sha_err));
return finalizeCleanupAndReturn(sha_err);
}
@@ -330,10 +358,12 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
}
if (sha_match) {
DEBUG_INFO(TAG, "SHA-256 校验通过");
DEBUG_DBG(TAG, "SHA-256 校验通过");
} else {
DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s",
frontend_sha_hex, device_hex);
/* 与正常刷写失败一致:先报异常,再分 2 行打两个 SHA-256 */
DEBUG_ERR(TAG, "SHA-256 不匹配");
DEBUG_ERR(TAG, "frontend=%s", frontend_sha_hex);
DEBUG_ERR(TAG, "device=%s", device_hex);
return finalizeCleanupAndReturn(ESP_ERR_INVALID_VERSION);
}
} else if (out_device_sha_hex) {
@@ -360,11 +390,13 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 失败也恢复 SPI */
resetState();
return err;
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 收尾完成,恢复 SPI 业务 */
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received);
// 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理
@@ -391,6 +423,7 @@ void Esp32OtaCore::abort()
esp_ota_abort(m_handle);
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 中止,恢复 SPI 业务 */
resetState();
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
}
@@ -418,7 +451,7 @@ bool Esp32OtaCore::forceCleanup()
if (!s_mutex) return false;
// 在互斥保护下检查 s_occupied
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) != pdTRUE) return false;
bool was_occupied = s_occupied;
if (was_occupied) {
+3 -4
View File
@@ -5,7 +5,7 @@
* begin → write*(N) → finalize / abort
*
* 统一以 WiFi 流程为基准:
* - esp_ota_begin 第二参数传 total_size
* - 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
@@ -102,16 +102,15 @@ public:
/** @brief 获取已接收的字节数。 */
size_t getReceived() const { return m_received; }
// ── 分区互斥锁管理(M1: 从 ota_mutex.h 全局变量内聚到此类)──
// ── 分区互斥锁管理 ──
/**
* @brief 初始化 ESP32 OTA 分区互斥锁(在 app_main 中调用)。
* 替代旧的全局 g_esp32OtaMutex / g_esp32OtaOccupied。
*/
static void initMutex();
private:
Esp32OtaCore() = default;
// ── 分区互斥锁(M1: 内聚自 ota_mutex.h)──
// ── 分区互斥锁 ──
static SemaphoreHandle_t s_mutex; ///< ESP32 OTA 分区互斥锁
static volatile bool s_occupied; ///< 分区占用标志
static bool acquireMutex(uint32_t timeout_ms);
+281 -192
View File
@@ -6,10 +6,11 @@
*/
#include "ota_coordinator.h"
#include "monitor/can_feedback_task.h"
#include "monitor/motor_ota_relay.h"
#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"
#include "freertos/FreeRTOS.h"
@@ -18,6 +19,23 @@
#define TAG "OTA_COORD"
/** WiFi OTA 时挂起 BLE;BLE 通道自身 OTA 不挂。调用勿持 m_mutex。 */
static void wifiOtaSuspendBleIfNeeded(ota_channel_t channel)
{
if (channel != OTA_CHANNEL_WIFI) {
return;
}
(void)BleOta::instance().suspendStack();
}
/** 回到可空闲态时恢复 BLE。调用勿持 m_mutex。 */
static void wifiOtaResumeBleIfNeeded(void)
{
if (BleOta::instance().isSuspendedForWifiOta()) {
(void)BleOta::instance().resumeStack();
}
}
// ============================================================
// 单例
// ============================================================
@@ -32,22 +50,19 @@ OtaCoordinator& OtaCoordinator::instance()
// ============================================================
bool OtaCoordinator::lock() const
{
// ★ 修复: 返回 take 结果,由调用方传回 unlock()。
// 旧实现用成员 bool m_locked 记录 take 结果,多任务并发 lock() 时
// 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give,
// mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。
/* 返回 take 是否成功;调用方须把该值传给 unlock() */
if (!m_mutex) return false;
bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE);
if (!ok) {
DEBUG_WARN(TAG, "lock() 超时 (%u ms),本次状态变更被跳过",
(unsigned)pdTICKS_TO_MS(kLockTimeout));
DEBUG_ERR(TAG, "lock() 超时 (%u ms),本次状态变更被跳过",
(unsigned)pdTICKS_TO_MS(kLockTimeout));
}
return ok;
}
void OtaCoordinator::unlock(bool handle) const
{
// ★ 修复: 仅在对应的 lock() 成功 take 时才 give。
/* 仅当对应 lock() 成功时 give */
if (handle && m_mutex) {
xSemaphoreGive(m_mutex);
}
@@ -58,16 +73,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) {
@@ -80,13 +95,27 @@ void OtaCoordinator::watchdogCheckLocked()
return; // 未超时
}
// ★ 超时自愈:强制转 ERROR。
// ERROR 不在任何 beginXxx() 的拒绝列表中,下次请求会自动从 ERROR 恢复。
// 清理由各子系统自行负责(abortCurrent 中会按 ERROR 不做实际清理,仅复位状态)。
DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制自愈 → ERROR",
DEBUG_ERR(TAG, "忙状态 %d 超过 %" PRIu32 " ms,强制中止 → IDLE",
(int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks));
m_sysState = OTA_SYS_ERROR;
m_busySinceTick = 0;
doAbortLocked();
}
esp_err_t OtaCoordinator::beginLockedWithChecks(const char *tag, bool &out_lock)
{
out_lock = lock();
if (!out_lock) return ESP_ERR_INVALID_STATE;
// ★ 卡死自愈检查
watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数期间禁止新的 GD32/ESP32 OTA
if (motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 %s", tag);
unlock(true);
out_lock = false;
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
// ============================================================
@@ -106,7 +135,7 @@ void OtaCoordinator::init()
m_gd32StorageDone = false;
m_busySinceTick = 0;
// ★ H4 解耦:注册协调器为 OtaRelay 的中继事件监听器
// 注册协调器为 OtaRelay 的中继事件监听器
OtaRelay::setRelayListener(this);
// ★ 重启恢复:如果 NVS 里有完整固件元数据,开机时立即进入 STORED 状态,
@@ -127,63 +156,55 @@ void OtaCoordinator::init()
// ============================================================
esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 先做卡死自愈检查:若之前忙状态卡死,自动转 ERROR 继续后续流程
if (h) watchdogCheckLocked();
bool h;
esp_err_t pre = beginLockedWithChecks("GD32 存储", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 GD32 存储仍在进行中(STORING)且来自同一通道,
// 自动中止旧存储并重新开始。覆盖"上传过程中刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
// RELAYING 是中继阶段,不可中断,也拒绝。
if (h && m_sysState == OTA_SYS_GD32_STORING) {
if (m_sysState == OTA_SYS_GD32_STORING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "GD32 存储中(通道=%d),拒绝跨通道请求(通道=%d)",
DEBUG_WARN(TAG, "GD32存中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
DEBUG_WARN(TAG, "GD32 存储仍在进行中(同通道),中止旧存储并重新开始");
Gd32FlashStore::instance().metaClear();
m_sysState = OTA_SYS_IDLE;
m_busySinceTick = 0;
DEBUG_WARN(TAG, "GD32同通道重传:中止旧存储");
doAbortLocked(); // 清 meta + IDLE,与 abort 路径一致
// epoch 将在下方设置 GD32_STORING 后递增
}
// 系统忙检查:仅拒绝真正"在传输"的活跃状态
// STORED / COMPLETE 是"待中继/已中继"的非活跃状态 → 允许新存储覆盖
if (h && (m_sysState == OTA_SYS_ESP32_RECEIVING ||
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING ||
m_sysState == OTA_SYS_GD32_RELAYING)) {
m_sysState == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "系统忙 (state=%d),拒绝 GD32 存储请求", (int)m_sysState);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 如果之前已有存储但未中继(STORED)或刚中继完(COMPLETE),允许新存储覆盖。
// 注意:清理 NVS meta 不在此处做,避免与紧跟的 prepareGd32Storage 内
// clearGd32Meta + saveGd32Meta 重复一次写入,仅做状态注释说明。
if (h && (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE)) {
if (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE) {
DEBUG_INFO(TAG, "覆盖旧 GD32 存储状态,开始新存储 (size=%" PRIu32 ")", total_size);
}
if (h) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_GD32_STORING;
m_gd32StorageDone = false;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
DEBUG_INFO(TAG, "GD32 固件存储开始: %" PRIu32 " 字节, 系统状态 → GD32_STORING (epoch=%u, channel=%d)",
total_size, m_otaEpoch, (int)channel);
}
m_otaEpoch++; // ★ 新会话开始,递增 epoch
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_GD32_STORING;
m_gd32StorageDone = false;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
DEBUG_DBG(TAG, "GD32存开始 %" PRIu32 "B →STORING ep=%u ch=%d",
total_size, m_otaEpoch, (int)channel);
// ★ 预分配 flashWriteChunk 对齐缓冲(OTA 期消除逐块 malloc;非 OTA 期零占用)
Gd32FlashStore::acquireAlignBufForOta();
unlock(h);
return h ? ESP_OK : ESP_ERR_INVALID_STATE;
return ESP_OK;
}
// GD32 Flash 扇区大小(与 http_handlers.cpp 中的常量保持一致,用于擦除时向上对齐)
static constexpr uint32_t kGd32FlashSectorSize = 4096U;
esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t channel)
{
// 1. 校验固件大小(统一 BLE/HTTP 两路径的大小检查)
@@ -193,25 +214,30 @@ esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t
return err;
}
/* WiFi GD32 上传前先挂 BLE,腾堆给 HTTP 收流 */
wifiOtaSuspendBleIfNeeded(channel);
// 2. 进入 GD32_STORING 状态(系统忙则拒绝)
err = beginGd32Storage(total_size, channel);
if (err != ESP_OK) {
DEBUG_WARN(TAG, "prepareGd32Storage: 协调器拒绝存储(系统忙)");
if (channel == OTA_CHANNEL_WIFI) {
wifiOtaResumeBleIfNeeded();
}
return err;
}
// 3. 擦除 GD32 OTA 分区(按扇区大小向上对齐)
// ★ 原 BLE 路径未擦除分区,此处统一补齐,确保两路径行为一致。
const uint32_t erase_size =
((total_size + kGd32FlashSectorSize - 1U) / kGd32FlashSectorSize) * kGd32FlashSectorSize;
DEBUG_INFO(TAG, "prepareGd32Storage: 擦除 gd32_ota 分区 %u bytes (fw=%" PRIu32 ")",
(unsigned)erase_size, total_size);
err = eraseGd32Partition(erase_size);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "prepareGd32Storage: 擦除失败: %s", esp_err_to_name(err));
// 3. 复位擦除水位;不在 prepare 整区预擦。
// 旧方案在 HTTP JSON 头后同步擦 gd32_ota,TCP 堵在开头。
// 现由 flashWriteChunk → ensureErasedForWrite 按需擦扇区,与收流交织。
Gd32FlashStore::instance().beginWriteSession();
if (Gd32FlashStore::instance().getPartition() == nullptr) {
DEBUG_ERR(TAG, "prepareGd32Storage: 找不到 gd32_ota 分区");
abortCurrent();
return err;
return ESP_ERR_NOT_FOUND;
}
DEBUG_INFO(TAG, "prepareGd32Storage: 会话就绪 fw=%" PRIu32 "(按需擦除,无整区预擦)",
total_size);
// 4. 清空旧 meta + 写入初始 meta(total_size + stored_size=0 + last_seq=0)
clearGd32Meta();
@@ -236,18 +262,21 @@ void OtaCoordinator::completeGd32Storage()
m_sysState = OTA_SYS_GD32_STORED;
m_gd32StorageDone = true;
m_busySinceTick = 0; // ★ STORED 是非忙状态
DEBUG_INFO(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED");
DEBUG_DBG(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED");
/* WiFi 路径不在此处恢复 BLE:
* 旧逻辑 HTTP 一收完就 resume,55KB 极快时日志紧贴 raw upload start,
* 像「一刷新就共存恢复」。挂起到中继结束/中止(notifyRelayComplete、abort)。 */
} else if (h) {
DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState);
}
unlock(h);
}
// ============================================================
// GD32 存储操作(传输层统一入口,解决 H3 绕过协调器问题)
// ── 以下 8 个 wrapper 已 inline 化至 ota_coordinator.h,直接转发到
// Gd32FlashStore::instance(),消除跨 TU 调用开销。
// ============================================================
esp_err_t OtaCoordinator::finishGd32StorageAfterReceive()
{
completeGd32Storage();
return finalizeGd32Checksum();
}
// ============================================================
// GD32 中继阶段
@@ -255,43 +284,43 @@ void OtaCoordinator::completeGd32Storage()
esp_err_t OtaCoordinator::triggerGd32Relay()
{
bool h = lock();
if (!h) {
DEBUG_ERR(TAG, "triggerGd32Relay: lock 超时");
return ESP_ERR_INVALID_STATE;
}
// ★ 卡死自愈检查
if (h) watchdogCheckLocked();
watchdogCheckLocked();
// 校验:必须处于"已存储"或"已中继完成"状态
if (h && m_sysState != OTA_SYS_GD32_STORED && m_sysState != OTA_SYS_IDLE) {
if (m_sysState == OTA_SYS_GD32_STORING) {
DEBUG_WARN(TAG, "GD32 正在存储中,拒绝触发中继");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
if (m_sysState == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "GD32 中继已在进行中");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "ESP32 OTA 进行中,拒绝中继触发");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
/* 白名单:仅 STORED / IDLE(NVS 有完整固件)/ COMPLETE(允许再中继)可触发 */
if (m_sysState != OTA_SYS_GD32_STORED &&
m_sysState != OTA_SYS_IDLE &&
m_sysState != OTA_SYS_GD32_COMPLETE) {
DEBUG_WARN(TAG, "拒中继 state=%s(%d)",
stateToString(m_sysState), (int)m_sysState);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 验证固件是否确实已存储(通过 NVS 元数据)
ota_meta_t meta;
if (h && (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK ||
if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK ||
meta.total_size == 0 ||
meta.stored_size < meta.total_size)) {
meta.stored_size < meta.total_size) {
DEBUG_WARN(TAG, "GD32 固件未完整存储,拒绝触发中继");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 检查 OtaRelay 是否空闲
if (h && OtaRelay::getState() != RELAY_IDLE &&
/* ★ 包级防呆:SHA 匹配只证明"传对了",不证明"传的是对的包"。
* 把 BOOT 包中继进 APP 区会擦掉好固件,触发前必须确认镜像身份为 APP。
* 返回 ESP_ERR_INVALID_SIZE 供调用方(WiFi/BLE)映射成具体错误提示。 */
const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity();
if (identity != Gd32ImageIdentity::kApp) {
DEBUG_ERR(TAG, "拒绝中继:GD32 固件身份校验失败 (identity=%d)", (int)identity);
unlock(h);
return ESP_ERR_INVALID_SIZE;
}
if (OtaRelay::getState() != RELAY_IDLE &&
OtaRelay::getState() != RELAY_DONE &&
OtaRelay::getState() != RELAY_ERROR) {
DEBUG_WARN(TAG, "OtaRelay 正忙 (state=%d),拒绝触发", (int)OtaRelay::getState());
@@ -299,47 +328,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(200));
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_DBG(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING");
unlock(h);
return ESP_OK;
}
/** @brief 通知协调器:GD32 中继已开始,更新系统状态为 GD32_RELAYING。 */
/** @brief 中继 task 已真正开始:若 trigger 已标 RELAYING 则只补 pause;否则兜底设状态。 */
void OtaCoordinator::notifyRelayStarted()
{
bool h = lock();
if (h && m_sysState != OTA_SYS_GD32_RELAYING) {
m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
if (h) {
if (m_sysState != OTA_SYS_GD32_RELAYING) {
m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount();
DEBUG_DBG(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING");
}
pauseCanFeedbackTask(true);
DEBUG_INFO(TAG, "中继已开始, 系统状态 → GD32_RELAYING");
}
unlock(h);
}
@@ -363,10 +387,12 @@ void OtaCoordinator::notifyRelayComplete(bool success)
}
}
unlock(h);
/* 中继结束:若 BLE 仍因 WiFi OTA 挂起则恢复 */
wifiOtaResumeBleIfNeeded();
}
// ============================================================
// IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)
// IRelayListener 接口实现
// ============================================================
/** @brief IRelayListener 回调:中继已开始,委托给 notifyRelayStarted()。 */
void OtaCoordinator::onRelayStarted()
@@ -381,7 +407,7 @@ void OtaCoordinator::onRelayComplete(bool success)
}
// ============================================================
// BLE 射频共存管理(L1: 消除 ble_ota → wifi_ota 直接依赖)
// BLE 射频共存管理
// ============================================================
void OtaCoordinator::setBleCoexListener(IBleCoexListener *listener)
{
@@ -407,59 +433,59 @@ void OtaCoordinator::notifyBleCoexEnd()
// ============================================================
esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 卡死自愈检查
if (h) watchdogCheckLocked();
bool h;
esp_err_t pre = beginLockedWithChecks("ESP32 OTA", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
// FINALIZING/REBOOTING 是不可逆阶段,也拒绝。
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "ESP32 OTA 接收中(通道=%d),拒绝跨通道请求(通道=%d)",
DEBUG_WARN(TAG, "ESP32收中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
DEBUG_WARN(TAG, "ESP32 OTA 仍在接收中(同通道),中止旧会话并重新开始");
Esp32OtaCore::instance().abort();
m_sysState = OTA_SYS_IDLE;
m_busySinceTick = 0;
DEBUG_WARN(TAG, "ESP32同通道重传:中止旧会话");
doAbortLocked(); // abort core + IDLE + 恢复 CAN 反馈
// epoch 将在下方 begin 成功后递增,自动使旧 handler 的清理代码失效
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 收尾/重启中,均拒绝。
// ★ 必须显式拒绝 ESP32_FINALIZING/REBOOTING:
// 若放行,本调用会落到 Esp32OtaCore::begin 去抢全局互斥失败而返回 INVALID_STATE,
// 随后 beginEsp32OtaWithRetry 的 forceCleanup 会把另一通道的活跃会话
// (esp_ota_abort + 释放互斥)误中止 → 两通道并发写同一 OTA 分区。
if (h && (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING)) {
DEBUG_WARN(TAG, "系统忙 (state=%d, %s),拒绝 ESP32 OTA",
// ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器
// 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。
// core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
if (m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "FINALIZING残留(core空闲),强制复位");
doAbortLocked();
} else {
DEBUG_WARN(TAG, "系统忙 (ESP32_FINALIZING, core 活跃),拒绝 ESP32 OTA");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 重启中,均拒绝。
if (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "忙 state=%d(%s) 拒ESP32 OTA",
(int)m_sysState, stateToString(m_sysState));
unlock(h);
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = ESP_ERR_INVALID_STATE;
if (h) {
// 委托 Esp32OtaCore 处理分区/互斥逻辑
err = Esp32OtaCore::instance().begin(total_size);
if (err == ESP_OK) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch(使旧 handler 的 abortCurrentIfEpoch 失效)
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_ESP32_RECEIVING;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
DEBUG_INFO(TAG, "ESP32 OTA 开始: %" PRIu32 " 字节, 系统状态 → ESP32_RECEIVING (epoch=%u, channel=%d)",
total_size, m_otaEpoch, (int)channel);
pauseCanFeedbackTask(true);
} else {
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
}
// 委托 Esp32OtaCore 处理分区/互斥逻辑
esp_err_t err = Esp32OtaCore::instance().begin(total_size);
if (err == ESP_OK) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch(使旧 handler 的 abortCurrentIfEpoch 失效)
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_ESP32_RECEIVING;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻
pauseCanFeedbackTask(true);
} else {
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
}
unlock(h);
@@ -468,6 +494,9 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channel_t channel)
{
/* WiFi 路径:在 begin 前挂 BLE,把 BT 堆腾给 OTA 收流(BLE 通道 OTA 不挂)。 */
wifiOtaSuspendBleIfNeeded(channel);
esp_err_t err = beginEsp32Ota(total_size, channel);
if (err == ESP_ERR_INVALID_STATE) {
// ★ 仅当系统确实空闲(无任何活跃 OTA)时才允许 forceCleanup:
@@ -479,29 +508,58 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe
// 避免连续 isIdle() + getSystemState() 各加锁一次(共 2 次锁)。
ota_system_state_t cur = getSystemState();
if (!isIdleState(cur) && cur != OTA_SYS_ERROR) {
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝且系统非空闲 (state=%d),不强制清理,直接失败",
DEBUG_WARN(TAG, "ESP32拒且非空闲 state=%d,不强制清",
(int)cur);
if (channel == OTA_CHANNEL_WIFI) {
wifiOtaResumeBleIfNeeded();
}
return err;
}
if (cur == OTA_SYS_ERROR) {
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但协调器处于 ERROR,清理底层残留句柄后重试");
DEBUG_WARN(TAG, "ESP32拒但ERROR,清残留后重试");
} else {
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但系统空闲,判定为锁残留,强制清理状态机后重试");
DEBUG_WARN(TAG, "ESP32拒但空闲,判锁残留,强制清后重试");
}
/* ★ 若 motor OTA 的 feedTask 残留活跃(s_feed_running 仍 true,
* 常见于 cancel 请求未送达或 feedTask 卡在 SPI transact),
* 必须先 motorOtaCancel() 让 feedTask 退出,否则 forceCleanup
* 只清 ESP32 锁、motorOtaIsActive() 仍 true,重试时又被拒,
* 形成日志里"强制清后重试→电机 OTA 进行中→再拒"的死循环。 */
if (motorOtaIsActive()) {
DEBUG_WARN(TAG, "强制清前先取消残留的电机 OTA feedTask");
motorOtaCancel();
}
Esp32OtaCore::instance().forceCleanup();
err = beginEsp32Ota(total_size, channel);
}
if (err != ESP_OK && channel == OTA_CHANNEL_WIFI) {
wifiOtaResumeBleIfNeeded();
}
return err;
}
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
// 供 notifyEsp32*Complete / notifyEsp32Rebooting 等锁内路径复用,避免重复样板代码。
void OtaCoordinator::setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg)
{
m_sysState = s;
/* 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_DBG(TAG, "%s", dbg_msg);
}
/** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */
void OtaCoordinator::notifyEsp32ReceiveComplete()
{
bool h = lock();
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
m_sysState = OTA_SYS_ESP32_FINALIZING;
DEBUG_INFO(TAG, "ESP32 固件接收完毕, 系统状态 → ESP32_FINALIZING");
setEsp32StateLocked(OTA_SYS_ESP32_FINALIZING,
"ESP32 固件接收完毕, 系统状态 → ESP32_FINALIZING");
}
unlock(h);
}
@@ -511,8 +569,8 @@ void OtaCoordinator::notifyEsp32FinalizeComplete()
{
bool h = lock();
if (h) {
m_sysState = OTA_SYS_ESP32_REBOOTING;
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成, 系统状态 → ESP32_REBOOTING");
setEsp32StateLocked(OTA_SYS_ESP32_REBOOTING,
"ESP32 OTA 收尾完成, 系统状态 → ESP32_REBOOTING");
}
unlock(h);
}
@@ -522,8 +580,8 @@ void OtaCoordinator::notifyEsp32Rebooting()
{
bool h = lock();
if (h) {
m_sysState = OTA_SYS_ESP32_REBOOTING;
DEBUG_INFO(TAG, "ESP32 即将重启, 系统状态 → ESP32_REBOOTING");
setEsp32StateLocked(OTA_SYS_ESP32_REBOOTING,
"ESP32 即将重启, 系统状态 → ESP32_REBOOTING");
}
unlock(h);
}
@@ -543,6 +601,11 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h
r.device_sha_hex);
if (r.err == ESP_OK) {
notifyEsp32FinalizeComplete();
} else {
/* finalize 失败:复位协调器到 IDLE */
DEBUG_ERR(TAG, "ESP32 finalize 失败: %s,复位协调器 → IDLE",
esp_err_to_name(r.err));
abortCurrent();
}
return r;
}
@@ -550,7 +613,7 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h
void OtaCoordinator::restartEsp32()
{
notifyEsp32Rebooting();
vTaskDelay(pdMS_TO_TICKS(500));
vTaskDelay(pdMS_TO_TICKS(100));
esp_restart();
// 不会返回
}
@@ -564,8 +627,11 @@ void OtaCoordinator::doAbortLocked()
if (m_sysState == OTA_SYS_GD32_STORING) {
Gd32FlashStore::instance().metaClear();
Gd32FlashStore::releaseAlignBufForOta(); // 释放 OTA 期对齐缓冲
} else if (m_sysState == OTA_SYS_GD32_RELAYING) {
OtaRelay::instance().reset();
/* 中继中止必须 CANCEL 远端,否则 GD32 停在 WAITING_* 半会话 */
OtaRelay::instance().abortRemoteAndReset(true);
Gd32FlashStore::releaseAlignBufForOta(); // 中继阶段也可能写过 flash
} else if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING) {
Esp32OtaCore::instance().abort();
@@ -576,6 +642,7 @@ void OtaCoordinator::doAbortLocked()
m_gd32StorageDone = false;
m_busySinceTick = 0;
DEBUG_INFO(TAG, "已中止, 系统状态 → IDLE");
/* 释放协调器锁后由调用方 resume BLE;doAbortLocked 可能持锁,标记后外层 resume */
}
void OtaCoordinator::abortCurrent()
@@ -585,6 +652,39 @@ void OtaCoordinator::abortCurrent()
doAbortLocked();
}
unlock(h);
wifiOtaResumeBleIfNeeded();
}
void OtaCoordinator::healOrphanStates()
{
bool need_resume = false;
bool h = lock();
if (h) {
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);
}
if (need_resume) {
wifiOtaResumeBleIfNeeded();
}
}
uint32_t OtaCoordinator::getOtaEpoch() const
@@ -610,6 +710,7 @@ bool OtaCoordinator::abortCurrentIfEpoch(uint32_t epoch)
doAbortLocked();
unlock(h);
wifiOtaResumeBleIfNeeded();
return true;
}
@@ -619,7 +720,7 @@ bool OtaCoordinator::abortCurrentIfEpoch(uint32_t epoch)
ota_system_state_t OtaCoordinator::getSystemState() const
{
// 对于纯读取,短暂加锁保证一致性
// ★ M1/L3: m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
// m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
bool h = lock();
ota_system_state_t s = m_sysState;
unlock(h);
@@ -667,30 +768,19 @@ const char* OtaCoordinator::stateToString(ota_system_state_t s)
}
}
/** @brief 获取 GD32 APP 存活状态(委托 OtaRelay)。 */
bool OtaCoordinator::getGd32AppAlive() const
{
return OtaRelay::getGd32AppAlive();
}
// ============================================================
// OTA 状态快照(PR1: 收口状态读取)
// OTA 状态快照
// ============================================================
OtaSnapshot OtaCoordinator::getSnapshot() const
{
OtaSnapshot snap{};
// ── coordinator 自身状态(一次 mutex,避免二次加锁导致快照不一致)──
// 持锁时一次性取 m_sysState,并调不加锁的 stateToString()
// 就地转字符串。旧实现释放锁后再调 stateToString(getSystemState()) 二次加锁,
// 两次取值之间状态可能变化,导致 snap.sys_state 与 snap.sys_state_str
// 描述不同时刻状态。
/* 持锁一次读取状态并生成字符串,保证快照字段一致 */
bool h = lock();
if (h) {
snap.sys_state = m_sysState;
snap.channel = m_otaChannel;
snap.storage_done = m_gd32StorageDone;
// 就地把状态枚举转字符串,与 sys_state 来自同一时刻
snap.sys_state_str = stateToString(snap.sys_state);
unlock(h);
} else {
@@ -708,30 +798,29 @@ OtaSnapshot OtaCoordinator::getSnapshot() const
}
// ============================================================
// GD32 APP 探测(PR2: 从 /system/status 移出)
// GD32 APP 探测
// ============================================================
// 注:本函数与 OtaRelay::probeAndCacheAppAlive 逻辑相似,但因依赖方向
// (OtaCoordinator → OtaRelay)不应调用 OtaRelay 的内部实现细节,
// 故此处保留独立实现。
esp_err_t OtaCoordinator::probeGd32AppOnce(gd32_version_t *out_version, uint32_t *out_uptime)
{
/* 统一走 OtaRelay 公开探测,避免与 probeAndCacheAppAlive 双份逻辑 */
uint32_t uptime = 0;
gd32_version_t ver = {};
esp_err_t err = SpiMaster::instance().appPing(500, &uptime, &ver);
if (err == ESP_OK) {
OtaRelay::setGd32AppAlive(true);
OtaRelay::setGd32Version(ver);
if (out_version) *out_version = ver;
if (out_uptime) *out_uptime = uptime;
if (OtaRelay::probeAndCacheAppAlive(500, &uptime)) {
if (out_version) {
*out_version = OtaRelay::getSnapshot().version;
}
if (out_uptime) {
*out_uptime = uptime;
}
DEBUG_INFO(TAG, "GD32 APP 探测成功: alive, uptime=%" PRIu32 "s", uptime);
} else {
OtaRelay::setGd32AppAlive(false);
if (out_version) *out_version = ver;
if (out_uptime) *out_uptime = 0;
DEBUG_WARN(TAG, "GD32 APP 探测失败: %s", esp_err_to_name(err));
return ESP_OK;
}
return err;
if (out_version) {
*out_version = gd32_version_t{};
}
if (out_uptime) {
*out_uptime = 0;
}
return ESP_FAIL;
}
// ============================================================
@@ -739,12 +828,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();
}
+52 -37
View File
@@ -21,7 +21,7 @@
#include "esp_err.h"
#include "app_config.h" // OTA_MAX_FIRMWARE_SIZE / GD32_OTA_PACKET_SIZE(validateGd32Size 用)
#include "storage/gd32_flash_store.h"
#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot(H4 解耦)
#include "relay/ota_relay.h" // IRelayListener 接口定义 + RelaySnapshot
// ============================================================
// 系统级 OTA 状态枚举
@@ -47,7 +47,7 @@ typedef enum {
} ota_channel_t;
// ============================================================
// OTA 系统全量只读快照(PR1: 收口状态读取)
// OTA 系统全量只读快照
// ============================================================
/**
* @brief OTA 系统全量只读快照。
@@ -64,11 +64,11 @@ struct OtaSnapshot {
};
// ============================================================
// IBleCoexListener - BLE 射频共存通知接口(解决 L1 横向耦合)
// IBleCoexListener - BLE 射频共存通知接口
// ============================================================
// WifiOta 实现此接口并注册到 OtaCoordinator,
// BLE OTA 开始/结束时通过协调器通知 WiFi 层,
// 消除 ble_ota → wifi_ota 的直接依赖。
// 避免 ble_ota → wifi_ota 的直接依赖。
/**
* @brief BLE 射频共存监听器接口。
* BLE OTA 开始/结束时,协调器通过此接口通知 WiFi 层暂停/恢复射频活动。
@@ -124,10 +124,10 @@ public:
esp_err_t beginGd32Storage(uint32_t total_size, ota_channel_t channel);
/**
* @brief 统一 GD32 OTA 写入前的准备工作:校验大小 + begin + 擦除分区 + 清空 meta + 写入初始 meta。
* 供 BLE seq=0 首包 / HTTP GD32 upload(非 prepared 复用路径)调用,
* 消除两路径对"擦除/meta 初始化"序列的隐式差异(原 BLE 路径未擦除分区)。
* 任意步骤失败时内部调 abortCurrent 清理,调用方无需再清理。
* @brief 统一 GD32 OTA 写入前准备:校验大小 + begin + 复位擦除水位 + 清空 meta + 写入初始 meta。
* 扇区擦除由 flashWriteChunk/ensureErasedForWrite 按需进行(避免 HTTP 头卡整区预擦)。
* 收流时 write 路径只写 Flash(ensureErased 仅作水位兜底)。
* WiFi/BLE 上传入口共用,任意步骤失败时内部 abortCurrent,调用方无需再清理。
* @param total_size 固件总字节数。
* @param channel 发起通道(WiFi/BLE)。
* @return ESP_OK 成功;ESP_ERR_INVALID_SIZE 大小非法;ESP_ERR_INVALID_STATE 系统忙;其他擦除/写入错误。
@@ -140,7 +140,14 @@ public:
*/
void completeGd32Storage();
// ── GD32 存储操作(传输层通过协调器操作 Flash,解决 H3 绕过问题)──
/**
* @brief 收流完成后的统一收尾:completeGd32Storage + finalizeGd32Checksum。
* WiFi/BLE 传输层共用,避免一端只 complete、另一端漏算 SHA 的分叉。
* @return finalizeGd32Checksum 的返回值(complete 本身无错误码)。
*/
esp_err_t finishGd32StorageAfterReceive();
// ── GD32 存储操作(传输层通过协调器操作 Flash)──
/**
* @brief 向 GD32 Flash 分区写入固件数据块。
* 传输层(WiFi/BLE)通过此接口写入,不再直接调用 Gd32FlashStore。
@@ -306,7 +313,9 @@ public:
/**
* @brief ESP32 OTA 收尾(统一封装 BLE/WiFi 两端重复的收尾序列)。
* 步骤:notifyEsp32ReceiveComplete → Esp32OtaCore::finalize(reboot=false)
* → 成功则 notifyEsp32FinalizeComplete。
* → 成功则 notifyEsp32FinalizeComplete;
* → 失败(SHA 不匹配 / end 失败等)则 abortCurrent() 复位到 IDLE,
* 避免协调器卡在 ESP32_FINALIZING 导致后续上传被假拒绝。
* 不重启;调用方负责把 device_sha_hex 上报前端后再调 restartEsp32()。
* @param frontend_sha_hex 前端 64 字符 hex SHA-256,nullptr 表示跳过比对。
* @return Esp32FinalizeResult,err 为 ESP_OK/ESP_ERR_INVALID_VERSION/其他。
@@ -327,6 +336,14 @@ public:
*/
void abortCurrent();
/**
* @brief 自愈孤儿忙态(供 /system/status 等轮询路径调用)。
* - 忙状态 watchdog(超时完整 abort → IDLE;不含 REBOOTING)
* - ESP32_FINALIZING 但 core 已空闲:残留 → IDLE
* - ERROR → IDLE 软清,避免 UI 长期卡错误态
*/
void healOrphanStates();
/**
* @brief 获取当前 OTA 会话的 epoch(代次)。
* 每次 beginEsp32Ota/beginGd32Storage 成功时递增。
@@ -348,7 +365,7 @@ public:
// ── 状态查询 ──
/**
* @brief 一次性读取系统 OTA 全量状态快照(PR1: 收口状态读取)。
* @brief 一次性读取系统 OTA 全量状态快照。
* coordinator mutex + relay mutex 各取一次,
* 供 /system/status 等只读接口消费。
* @return 包含 sys_state、meta、relay 进度的完整快照。
@@ -356,7 +373,7 @@ public:
OtaSnapshot getSnapshot() const;
/**
* @brief 单次 SPI 探测 GD32 APP 是否存活(PR2: 从 status 移出)。
* @brief 单次 SPI 探测 GD32 APP 是否存活。
* 成功时更新 OtaRelay 缓存的 alive/version,
* 失败时清除 alive 标志。
* @param out_version 输出版本信息,可为 nullptr。
@@ -403,19 +420,13 @@ public:
bool isIdle() const;
// ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)──
/**
* @brief 获取 GD32 APP 存活状态。
* @return true 表示 GD32 APP 存活。
*/
bool getGd32AppAlive() const;
// ── 完全复位 ──
/**
* @brief 完全复位协调器和所有子系统状态。
*/
void reset();
// ── IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)──
// ── IRelayListener 接口实现 ──
/**
* @brief IRelayListener 回调:中继已开始。
* 由 OtaRelay task 在 forwardFirmware() 前通过 s_relayListener 调用。
@@ -429,7 +440,7 @@ public:
*/
void onRelayComplete(bool success) override;
// ── BLE 射频共存管理(L1: 替代 ble_ota → wifi_ota 直接调用)──
// ── BLE 射频共存管理 ──
/**
* @brief 注册 BLE 共存监听器(由 main 在初始化时调用)。
* @param listener WifiOta 实现的监听器指针,nullptr 取消注册。
@@ -452,24 +463,27 @@ private:
OtaCoordinator(const OtaCoordinator&) = delete;
OtaCoordinator& operator=(const OtaCoordinator&) = delete;
// 线程安全辅助
// ★ lock() 返回是否成功 take;unlock(handle) 据此决定是否 give。
// 旧实现用共享 bool m_locked 记录 take 结果,多任务并发 lock() 时
// 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give,
// mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。
// 线程安全:lock() 返回 take 是否成功;unlock(handle) 仅在成功时 give
bool lock() const;
void unlock(bool handle) const;
// ★ 卡死自愈检查:若忙状态停留超过 watchdog 超时,强制转为 ERROR。
// 用于在中继 task 因任何原因(崩溃/SPI 死锁/看门狗复位残留)未能回调
// notifyRelayComplete/notifyEsp32*Complete 时,让协调器自动脱离卡死状态。
// 必须在已持锁状态下调用。
// 忙状态超时检查(须已持锁);超时则 doAbortLocked → IDLE
void watchdogCheckLocked();
// ★ 已持锁版本的中止序列:根据当前 m_sysState 调用对应子模块的清理,
// 并把状态机复位为 IDLE。abortCurrent / abortCurrentIfEpoch 共享此实现。
// 已持锁中止:清理子系统并将状态复位为 IDLE
void doAbortLocked();
/** @brief 统一 "lock + watchdog 自愈 + 电机 OTA 互斥拒绝" 前缀检查。
* beginGd32Storage / beginEsp32Ota 共用此逻辑。
* @param tag 日志标签(如 "GD32 存储" / "ESP32 OTA")
* @param out_lock 成功时输出锁句柄(调用方负责 unlock)
* @return ESP_OK=前缀通过可继续;ESP_ERR_INVALID_STATE=被拒绝(已 unlock) */
esp_err_t beginLockedWithChecks(const char *tag, bool &out_lock);
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
// 供 notifyEsp32*Complete / notifyEsp32Rebooting 等锁内路径复用,避免重复样板代码。
void setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg);
// 系统状态
ota_system_state_t m_sysState = OTA_SYS_IDLE;
@@ -488,16 +502,17 @@ private:
// 仅在同类型活跃状态(ESP32_RECEIVING / GD32_STORING)下有意义。
ota_channel_t m_otaChannel = OTA_CHANNEL_WIFI;
// 互斥锁(M1/L3: mutable 允许在 const 方法中加锁,消除 const_cast)
// 互斥锁(mutable 允许在 const 方法中加锁,无需 const_cast)
mutable SemaphoreHandle_t m_mutex = nullptr;
// BLE 共存监听器(L1: 由 WifiOta 实现并注册)
// 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
-16
View File
@@ -1,16 +0,0 @@
/*
* ota_snapshot.h - OTA 状态只读快照(便捷聚合头文件)
*
* 本文件本身不包含结构体定义,仅聚合两个快照来源:
* - RelaySnapshot → 定义在 relay/ota_relay.h(一次锁保护读取 relay 进度)
* - OtaSnapshot → 定义在 ota/ota_coordinator.h(coordinator + meta + relay 快照)
*
* HTTP handler 等消费方只需 #include "ota/ota_snapshot.h" 即可使用两种快照。
*/
#ifndef OTA_SNAPSHOT_H
#define OTA_SNAPSHOT_H
#include "relay/ota_relay.h" // RelaySnapshot
#include "ota/ota_coordinator.h" // OtaSnapshot
#endif // OTA_SNAPSHOT_H
+603 -335
View File
File diff suppressed because it is too large Load Diff
+74 -67
View File
@@ -63,15 +63,15 @@ typedef enum {
} ota_result_t;
// ============================================================
// IRelayListener - 中继事件监听接口(解决 H4 双向耦合)
// IRelayListener - 中继事件监听接口
// ============================================================
// OtaCoordinator 实现此接口并注册到 OtaRelay,
// OtaRelay 通过接口回调通知协调器,不再直接 #include ota_coordinator.h。
// 依赖方向变为单向:OtaCoordinator → OtaRelay(通过接口)。
// 依赖方向为单向:OtaCoordinator → OtaRelay(通过接口)。
/**
* @brief 中继事件监听器接口。
* OtaCoordinator 实现此接口,OtaRelay 通过回调通知状态变更,
* 消除 OtaRelay → OtaCoordinator 的直接依赖(解决 H4 双向耦合)。
* 避免 OtaRelay → OtaCoordinator 的直接依赖。
*/
class IRelayListener {
public:
@@ -118,16 +118,6 @@ public:
*/
ota_result_t forwardFirmware();
// ── 事件驱动接口 ──
/**
* @brief 向状态机投递事件,驱动状态转换.
* @param event 事件类型.
* @param payload 关联数据(响应载荷等),可为nullptr.
* @param len 数据长度.
* @return 当前操作结果码(OTA_RESULT_OK 表示事件已处理且可继续).
*/
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 状态查询(线程安全)──
/**
* @brief 获取当前中继状态.
@@ -145,31 +135,6 @@ public:
* @return 中继进度快照。
*/
static RelaySnapshot getSnapshot();
/**
* @brief 设置中继状态.
* @param s 目标状态。
*/
static void setState(relay_state_t s);
/**
* @brief 设置中继当前序号.
* @param seq 序号值。
*/
static void setRelaySeq(uint32_t seq);
/**
* @brief 设置中继总包数.
* @param total 总包数。
*/
static void setRelayTotalPackets(uint32_t total);
/**
* @brief 设置GD32 APP存活标志位.
* @param alive true 表示存活。
*/
static void setGd32AppAlive(bool alive);
/**
* @brief 设置GD32固件版本.
* @param ver 版本信息结构体引用。
*/
static void setGd32Version(const gd32_version_t &ver);
/**
* @brief 获取 OTA 中继任务句柄。
* @return FreeRTOS 任务句柄。
@@ -177,23 +142,29 @@ public:
static TaskHandle_t getTaskHandle();
/**
* @brief 重置状态机到 IDLE.
* @brief 重置本端中继状态机到 IDLE(不主动通知 GD32)。
* 中继失败/中止时请优先用 abortRemoteAndReset()。
* @param release_ring_suppress true(默认)解除 SPI ring 抑制引用;
* 状态机起点(IDLE→HANDSHAKING)必须传 false——taskEntry 在调度时
* 取的抑制引用要覆盖整个中继,否则中继全程 worker 会偷读 OTA ACK/WAIT。
*/
void reset();
void reset(bool release_ring_suppress = true);
// ── 中继事件监听器(解决 H4 双向耦合)──
/**
* @brief 通知 GD32 取消 OTA 会话(SPI CANCEL),再复位本端到 IDLE。
* 失败路径必须调用,否则 GD32 可能停在 WAITING_RANDOM/CONFIRM 等半会话。
* @param notify_remote true 时尽力发送 CANCEL(默认 true)。
*/
void abortRemoteAndReset(bool notify_remote = true);
// ── 中继事件监听器 ──
/**
* @brief 注册中继事件监听器(由 OtaCoordinator 在 init 时调用)。
* @param listener 监听器指针,传 nullptr 取消注册。
*/
static void setRelayListener(IRelayListener *listener);
/**
* @brief 获取当前注册的监听器。
* @return 监听器指针,未注册时为 nullptr。
*/
static IRelayListener* getRelayListener();
// ── 队列访问器(解决 M3: 封装 public static 成员)──
// ── 队列访问器 ──
/**
* @brief 获取事件分发队列句柄。
* 外部模块(OtaCoordinator / BLE)通过 getter 访问,不再直接引用静态成员。
@@ -207,7 +178,26 @@ public:
*/
static QueueHandle_t getResultQueue();
// ── Flash 操作已迁移至 Gd32FlashStore(storage/gd32_flash_store.h)──
/**
* @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);
/** @brief 统一 "HELLO(role=APP) → PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。 */
static bool helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
uint32_t max_attempts, bool allow_hello_shortcut);
// ── 内部状态写入(仅 ota_relay.cpp 使用,无外部调用)──
static void setState(relay_state_t s);
static void setRelaySeq(uint32_t seq);
static void setRelayTotalPackets(uint32_t total);
static void setGd32AppAlive(bool alive);
static void setGd32Version(const gd32_version_t &ver);
static IRelayListener* getRelayListener();
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 线程安全锁 ──
static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用)
@@ -239,11 +229,11 @@ public:
static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36;
private:
// ── 队列(M3: 由 getter 访问,外部不再直接引用)──
// ── 队列(由 getter 访问)──
static QueueHandle_t dispatchQueue; ///< 事件分发队列
static QueueHandle_t resultQueue; ///< 结果回传队列
// ── 状态成员(全部改 private,统一通过 public setter/getter 访问)──
// ── 状态成员(统一通过 public setter/getter 访问)──
// 外部代码使用 getState()/getRelaySeq()/... 读取,setState()/setRelaySeq()/... 写入。
// 内部代码:若已在 StateLockGuard 块内(reset/getSnapshot/启动探测),可直接访问;
// 否则必须使用 setter/getter,禁止裸读写(消除数据竞争)。
@@ -252,7 +242,7 @@ private:
static uint32_t relayTotalPackets; ///< 本次中继总包数
static volatile bool gd32AppAlive; ///< GD32 APP 存活标志
static gd32_version_t gd32Version; ///< GD32 固件版本信息
static IRelayListener *s_relayListener; ///< 中继事件监听器(解决 H4)
static IRelayListener *s_relayListener; ///< 中继事件监听器
static SemaphoreHandle_t s_stateMutex; ///< 状态成员互斥锁(由 StateLockGuard 使用)
// taskHandle 为 write-once-at-init 字段:仅 taskEntry 启动首行写入一次,
@@ -307,6 +297,11 @@ private:
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_AUTH 认证失败。
*/
ota_result_t doChallengeAndAuth();
/**
* @brief 向 GD32 发送 OTA CANCEL(0x03),请求远端完整 resetToIdle。
* @return ESP_OK 已发出并读到响应(或可忽略的链路错误已记录)。
*/
esp_err_t sendRemoteCancel();
/**
* @brief 发送固件总大小.
* @param size 固件总大小(字节)。
@@ -322,26 +317,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 成功,负值表示错误。
@@ -353,7 +361,7 @@ private:
*/
ota_result_t doSendFinishOta();
/**
* @brief 轮询等待 GD32 APP 存活(30 次重试).
* @brief 轮询等待 GD32 APP 存活(10 次重试).
* @param timeout_ms 单次 appPing 超时(毫秒)。
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_GD32 超时失败。
*/
@@ -363,20 +371,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,
@@ -398,6 +397,14 @@ private:
uint8_t *out_k_session, uint8_t *out_uid);
static bool aes128EcbEncrypt48Bytes(const uint8_t *key, const uint8_t *in, uint8_t *out);
static void calculateCrc32WithKey(const uint8_t *key, const uint8_t *data48, uint8_t *output);
/** @brief 清理会话密钥/上下文/nonce/UID 运行时缓存(reset 与 doFinishAndVerify 共用)。 */
void clearSessionCryptoState();
ota_result_t sendAndWaitAckShape(const char *stage, uint8_t cmd,
const uint8_t *payload, size_t payload_len,
uint32_t transact_timeout_ms,
uint32_t poll_timeout_ms, uint32_t wait_rounds, uint32_t max_extra_polls,
uint8_t resp_payload[], size_t resp_max, size_t *resp_len,
bool (*matcher)(const uint8_t *, size_t));
};
#endif // OTA_RELAY_H
+122 -1654
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -31,6 +31,23 @@ public:
* @return ESP_OK 成功,其他失败。
*/
esp_err_t serverStart();
/**
* @brief WiFi OTA 期间软挂起 BLE(停广播、断连、拒收写;不 deinit 控制器)。
* ESP32-C3 上 hard deinit 会打挂 WiFi 共存,导致上传立刻失联。
* @return ESP_OK 已挂起或本就未运行。
*/
esp_err_t suspendStack();
/**
* @brief WiFi OTA 结束后恢复 BLE 广播(栈仍在则只重新 adv)。
* @return ESP_OK 成功或无需恢复。
*/
esp_err_t resumeStack();
/** @brief 当前是否因 WiFi OTA 挂起 BLE。 */
bool isSuspendedForWifiOta() const { return m_suspendedForWifiOta; }
/**
* @brief 通过 BLE Notify 推送响应数据。
* @param data 待推送的数据指针。
@@ -60,6 +77,16 @@ public:
uint16_t m_esp32LastSeq = 0xFFFFU; ///< 最后一次数据包序号(用于补发 ACK)
TaskHandle_t m_taskHandle = nullptr; ///< BLE OTA 守护任务句柄
TaskHandle_t m_workerTaskHandle = nullptr; ///< BLE OTA 数据工作线程句柄
bool m_stackRunning = false; ///< NimBLE 已 init 且 host 在跑
bool m_suspendedForWifiOta = false; ///< 因 WiFi OTA 主动挂起
};
// ── 堆低水位快照辅助 getter(不在 BleOta 类内,file-static 资源访问) ──
/** @brief BLE OTA 数据队列当前深度(每个 item 持有 malloc 的 data 缓冲)。
* 供 /system/heap_low_log 采样。非 OTA 期返回 0。 */
uint32_t ble_ota_get_data_queue_depth();
/** @brief BLE reorder buffer 已缓存的乱序包数(每个 slot 持有 malloc 的 payload)。
* 供 /system/heap_low_log 采样。非 ESP32 OTA 期返回 0。 */
uint32_t ble_ota_get_reorder_cached_count();
#endif // BLE_OTA_H
File diff suppressed because it is too large Load Diff
+721
View File
@@ -0,0 +1,721 @@
/*
* ble_ota_host.cpp - BLE OTA GATT/GAP/广播/NimBLE Host
*/
#include "ble_ota_priv.h"
uint16_t s_data_out_val_handle = 0;
int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)arg;
if (BleOta::instance().isSuspendedForWifiOta()) {
return BLE_ATT_ERR_WRITE_NOT_PERMITTED;
}
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
uint8_t buf[36] = {0};
int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
if (len < 1) {
DEBUG_WARN(TAG, "CTRL 数据为空");
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
DEBUG_DBG(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
uint16_t session_id = 0;
if (len >= 35) {
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
} else if (len >= 3) {
session_id = (uint16_t)(buf[1] | (buf[2] << 8));
}
if (len < 35) {
DEBUG_WARN(TAG, "CTRL 0xF7 len<35 缺SHA,拒GD32中继");
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
packBleFinishValue(session_id, 1));
return 0;
}
// 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继
ota_meta_t meta = {};
esp_err_t err = OtaCoordinator::instance().loadGd32Meta(&meta);
if (err == ESP_OK &&
meta.total_size > 0 &&
meta.stored_size >= meta.total_size) {
const uint8_t *frontend_sha = &buf[1];
char frontend_sha_hex[65] = {0};
char device_sha_hex[65] = {0};
shaBytesToHexUpper(frontend_sha, frontend_sha_hex);
// ★ 统一走共享 helper:计算设备端 SHA → 比对前端 SHA
uint8_t device_sha[32] = {0};
err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha);
shaBytesToHexUpper(device_sha, device_sha_hex);
sendSha256Notify(device_sha, session_id);
if (err == ESP_ERR_INVALID_CRC) {
DEBUG_ERR(TAG, "GD32 SHA-256 不匹配");
DEBUG_ERR(TAG, "frontend=%s", frontend_sha_hex);
DEBUG_ERR(TAG, "device=%s", device_sha_hex);
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
packBleFinishValue(session_id, 0));
return 0;
} else if (err != ESP_OK) {
DEBUG_ERR(TAG, "GD32 SHA 失败: %s", esp_err_to_name(err));
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 3));
return 0;
}
DEBUG_INFO(TAG, "GD32就绪 SHA匹配 %" PRIu32 "/%" PRIu32 ",触发中继",
meta.stored_size, meta.total_size);
/* ★ 包级防呆:SHA 匹配只证明传输无误。BOOT 包/非 APP 包在此拒绝,
* 不进入 SPI 中继,GD32 APP 区不会被擦除。 */
const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity();
if (identity != Gd32ImageIdentity::kApp) {
DEBUG_ERR(TAG, "GD32 固件身份校验失败 identity=%d,拒中继", (int)identity);
sendFinishEvent(BLE_OTA_FINISH_ERR_FW_IDENTITY,
packBleFinishValue(session_id,
(identity == Gd32ImageIdentity::kBootloader) ? 1 : 2));
return 0;
}
if (OtaRelay::getResultQueue()) {
uint32_t stale = 0;
while (xQueueReceive(OtaRelay::getResultQueue(), &stale, 0) == pdTRUE) {
DEBUG_WARN(TAG, "清理旧 GD32 中继结果: %" PRIu32, stale);
}
}
esp_err_t trig_err = OtaCoordinator::instance().triggerGd32Relay();
if (trig_err == ESP_ERR_INVALID_SIZE) {
/* 兜底:triggerGd32Relay 内部身份校验拒绝(与上方同判据) */
DEBUG_ERR(TAG, "GD32 固件身份校验失败(trigger 兜底),拒中继");
sendFinishEvent(BLE_OTA_FINISH_ERR_FW_IDENTITY,
packBleFinishValue(session_id, 2));
return 0;
}
if (trig_err != ESP_OK) {
DEBUG_ERR(TAG, "GD32 中继触发失败: %s", esp_err_to_name(trig_err));
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 1));
return 0;
}
sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START,
packBleFinishValue(session_id, 0));
DEBUG_INFO(TAG, "GD32中继开始 stage=0x10 sess=%u", session_id);
BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx));
if (!ctx) {
DEBUG_ERR(TAG, "GD32 中继等待任务上下文分配失败");
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 2));
return 0;
}
ctx->session_id = session_id;
BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task,
"ble_gd32_wait",
TASK_STACK_BLE_GD32_WAIT,
ctx,
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 GD32 中继等待任务失败");
free(ctx);
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 2));
}
} else {
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
meta.stored_size, meta.total_size);
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 1));
}
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
uint16_t session_id = 0;
if (len >= 35) {
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
}
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, 1));
return 0;
}
if (BleOta::instance().m_esp32RebootPending) {
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, 1));
return 0;
}
// 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对,
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。
if (len < 33) {
DEBUG_WARN(TAG, "CTRL 0xF8 len<33 缺SHA,拒绝");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG);
return 0;
}
BleEsp32FinishCtx *finish_ctx = (BleEsp32FinishCtx *)malloc(sizeof(BleEsp32FinishCtx));
if (!finish_ctx) {
DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM));
return 0;
}
for (int i = 0; i < 32; ++i) {
snprintf(&finish_ctx->sha_hex[i * 2], 3, "%02x", buf[1 + i]);
}
finish_ctx->sha_hex[64] = '\0';
finish_ctx->session_id = session_id;
DEBUG_DBG(TAG, "feSHA=%s sess=%u", finish_ctx->sha_hex, session_id);
BleOta::instance().m_esp32RebootPending = true;
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
"ble_ota_finish",
TASK_STACK_BLE_FINISH,
finish_ctx, // heap context; task 负责 free
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
free(finish_ctx);
BleOta::instance().m_esp32RebootPending = false;
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM));
} else {
DEBUG_DBG(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
}
} else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {
// 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host
DEBUG_INFO(TAG, "0xFA重启,建restart task(500ms)");
if (BleOta::instance().m_esp32RebootPending) {
DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL");
return 0;
}
BleOta::instance().m_esp32RebootPending = true;
BaseType_t ok = xTaskCreate(ble_esp32_restart_task,
"ble_ota_restart",
TASK_STACK_BLE_RESTART,
NULL,
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 0xFA 重启任务失败");
BleOta::instance().m_esp32RebootPending = false;
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0);
}
} else if (buf[0] == BLE_OTA_CTRL_ABORT_OTA) {
// 0xFB: 中止当前 OTA(保持 BLE 连接)
// - 数据接收阶段(GD32_STORING / ESP32_RECEIVING):立即中止 + 清队列 + 回复 ABORTED
// - 中继/收尾阶段(GD32_RELAYING / ESP32_FINALIZING):拒绝中止(SPI 转发不可中断)
// - IDLE/已完成状态:幂等回复 ABORTED
uint16_t session_id = 0;
if (len >= 3) {
session_id = (uint16_t)(buf[1] | (buf[2] << 8));
}
ota_system_state_t cur_state = OtaCoordinator::instance().getSystemState();
DEBUG_WARN(TAG, "0xFB abort state=%s sess=%u",
OtaCoordinator::stateToString(cur_state), session_id);
if (cur_state == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "GD32 SPI中继中,无法中止,请等待");
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 2));
return 0;
}
if (cur_state == OTA_SYS_ESP32_FINALIZING) {
DEBUG_WARN(TAG, "ESP32 OTA 收尾进行中,无法中止");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, 2));
return 0;
}
// ── 可中止状态:清队列 + abort core + 复位协调器 ──
if (s_otaDataQueue) {
BleOtaDataBatch stale;
size_t dropped = 0;
while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
if (stale.pool_idx >= 0) ble_ota_pool_release(stale.pool_idx);
else free(stale.data);
dropped++;
}
if (dropped > 0) {
DEBUG_INFO(TAG, "中止时清理 OTA 数据队列: %zu 项", dropped);
}
}
// abortCurrent 内部按状态分发:GD32_STORING→metaClear, ESP32_RECEIVING→core.abort, IDLE→空操作
// ble_ota_session_reset() 统一负责 abortCurrent + core.abort 兜底 + 会话状态复位
// (m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending/reorder/s_gd32_ooo_count)
ble_ota_session_reset();
ble_ota_restore_wifi_ps();
DEBUG_INFO(TAG, "OTA 已中止(BLE 保持连接),通知前端 (stage=0x20)");
sendFinishEvent(BLE_OTA_FINISH_ABORTED,
packBleFinishValue(session_id, 0));
}
// 0xF7 = 触发 GD32 中继
// 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对,
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启)
// 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)
// 0xFB = 中止当前 OTA(不断开 BLE):仅数据接收阶段有效,中继/收尾阶段拒绝
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
}
/*
* DATA_IN (0x0002) - Write
* 数据块格式:
* [0] cmd (0x00=数据)
* [1-2] seq uint16 LE - 分块序号
* [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效)
* [7..n] payload 固件数据
* 注: 已移除CRC字段(BLE链路层已有CRC校验)
*/
/**
* @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return NimBLE ATT 错误码,0 表示成功。
*/
int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)arg;
if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) {
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
const char *version = FW_VERSION;
// 版本字符串: "vX.Y.Z YYYY-MM-DD HH:MM:SS"
char ver_buf[64];
int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, fwBuildTime());
if (ver_len < 0 || ver_len >= (int)sizeof(ver_buf)) {
ver_len = strlen(version);
int rc = os_mbuf_append(ctxt->om, version, ver_len);
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
}
int rc = os_mbuf_append(ctxt->om, ver_buf, ver_len);
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
}
/*
* DATA_OUT (0x0003) - Notify only
* 访问回调不做读写处理,通知由 BleOta::instance().notifyResponse() 发送
*/
/**
* @brief GATT DATA_OUT 特征访问回调(Notify-only,占位实现)。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return 0。
*/
int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)ctxt;
(void)arg;
return 0;
}
// ============================================================
// GATT 服务定义
// ============================================================
// BLE_UUID16_DECLARE 在 C++ 中产生 "taking address of rvalue" 编译错误。
// 改用 static ble_uuid16_t 变量,再取 .u 地址。
/** @brief OTA GATT 服务 UUID */
const ble_uuid16_t GATT_SVC_UUID = BLE_UUID16_INIT(BLE_OTA_SERVICE_UUID);
/** @brief CTRL 特征 UUID,写入 0xF7 触发中继 */
const ble_uuid16_t GATT_CTRL_UUID = BLE_UUID16_INIT(BLE_OTA_CTRL_UUID);
/** @brief DATA_IN 特征 UUID,接收固件数据块 */
const ble_uuid16_t GATT_DATA_IN_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_IN_UUID);
/** @brief DATA_OUT 特征 UUID,Notify 推送 GD32 响应 */
const ble_uuid16_t GATT_DATA_OUT_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_OUT_UUID);
/** @brief VERSION 特征 UUID,返回固件版本字符串 */
const ble_uuid16_t GATT_VERSION_UUID = BLE_UUID16_INIT(BLE_OTA_VERSION_UUID);
// NimBLE C 结构体在 C++ designated initializer 不能显式初始化全部字段。
// 用 pragma 局部关闭 -Wmissing-field-initializers 警告。
#ifdef __cplusplus
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
#endif
const struct ble_gatt_chr_def gatt_ota_chrs[] = {
{
.uuid = &GATT_CTRL_UUID.u,
.access_cb = ble_ota_ctrl_access,
.flags = BLE_GATT_CHR_F_WRITE,
},
{
.uuid = &GATT_DATA_IN_UUID.u,
.access_cb = ble_ota_data_in_access,
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
},
{
.uuid = &GATT_DATA_OUT_UUID.u,
.access_cb = ble_ota_data_out_access,
.flags = BLE_GATT_CHR_F_NOTIFY,
.val_handle = &s_data_out_val_handle,
},
{
.uuid = &GATT_VERSION_UUID.u,
.access_cb = ble_ota_version_access,
.flags = BLE_GATT_CHR_F_READ,
},
{} // 终止符
};
const struct ble_gatt_svc_def gatt_ota_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &GATT_SVC_UUID.u,
.characteristics = (struct ble_gatt_chr_def *)gatt_ota_chrs,
},
{} // 终止符
};
#ifdef __cplusplus
#pragma GCC diagnostic pop
#endif
// ============================================================
// GAP 事件回调
// ============================================================
/**
* @brief BLE GAP 事件回调,处理连接和断开事件。
* @param event
* @param arg
* @return 0 成功。
*/
int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
{
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
if (BleOta::instance().isSuspendedForWifiOta()) {
/* WiFi OTA 中拒绝 BLE 连接,立刻踢掉 */
DEBUG_WARN(TAG, "WiFi OTA 中,拒绝 BLE 连接 handle=%d",
event->connect.conn_handle);
(void)ble_gap_terminate(event->connect.conn_handle,
BLE_ERR_REM_USER_CONN_TERM);
BleOta::instance().m_bleConnected = false;
return 0;
}
// 连接成功
BleOta::instance().m_bleConnected = true;
BleOta::instance().m_connHandle = event->connect.conn_handle;
s_conn_fallback_requested = false;
DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", BleOta::instance().m_connHandle);
ble_ota_log_conn_params(BleOta::instance().m_connHandle, "初始");
int rc = ble_ota_request_conn_params(BleOta::instance().m_connHandle, false);
if (rc != 0) {
s_conn_fallback_requested = true;
ble_ota_request_conn_params(BleOta::instance().m_connHandle, true);
}
/* 优先 LE 2M PHY(空中速率约翻倍);对端不支持则保持 1M,失败仅打日志 */
{
const uint8_t phy_mask = BLE_GAP_LE_PHY_1M_MASK | BLE_GAP_LE_PHY_2M_MASK;
int phy_rc = ble_gap_set_prefered_le_phy(
BleOta::instance().m_connHandle, phy_mask, phy_mask,
BLE_GAP_LE_PHY_CODED_ANY);
DEBUG_DBG(TAG, "请求 LE PHY 1M|2M: rc=%d", phy_rc);
}
} else {
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
BleOta::instance().m_bleConnected = false;
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d",
event->disconnect.reason);
// ★ 断开时清空数据队列,丢弃属于旧会话的残留包
if (s_otaDataQueue) {
drainOtaDataQueue();
}
BleOta::instance().m_bleConnected = false;
BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE;
s_conn_fallback_requested = false;
/* WiFi OTA 软挂起期间:主动踢连触发的 disconnect 不得 abort WiFi OTA,也不得再开广播 */
if (BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_INFO(TAG, "WiFi OTA 挂起中:忽略 BLE 断开对 OTA 状态的影响");
return 0;
}
// ★ BLE 断开时恢复 WiFi 射频
ble_ota_restore_wifi_ps();
// ★ 断开时清理"BLE 数据接收阶段"残留的 OTA 会话状态,避免下次刷写被
// 系统 busy 假拒绝。注意区分可中止 / 不可中止状态:
// - GD32_STORING / ESP32_RECEIVING / ESP32_FINALIZING:BLE 是唯一数据源,
// 断开即等价于中止,必须 abortCurrent() + 清理底层 core。
// - GD32_RELAYING / ESP32_REBOOTING:SPI 中继/重启不依赖 BLE,不能打断。
// 这些状态保留,等中继/重启自然结束。
// - IDLE / STORED / COMPLETE / ERROR:abortCurrent 幂等安全,直接调即可。
{
ota_system_state_t cur = OtaCoordinator::instance().getSystemState();
if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "BLE断但%s进行中,保留状态",
OtaCoordinator::stateToString(cur));
} else if (cur == OTA_SYS_ESP32_RECEIVING ||
cur == OTA_SYS_ESP32_FINALIZING ||
cur == OTA_SYS_GD32_STORING) {
/* 仅当当前 OTA 通道是 BLE 时才因断连中止;WiFi 通道的 RECEIVING 不能被误杀 */
ota_channel_t ch = OTA_CHANNEL_WIFI;
{
/* get channel via snapshot if available */
OtaSnapshot snap = OtaCoordinator::instance().getSnapshot();
ch = snap.channel;
}
if (ch == OTA_CHANNEL_BLE) {
DEBUG_WARN(TAG, "BLE断,中止OTA state=%s",
OtaCoordinator::stateToString(cur));
ble_ota_session_reset();
} else {
DEBUG_INFO(TAG, "BLE 断开但当前为 WiFi OTA,不中止会话");
}
} else {
ble_ota_session_reset();
}
}
// 恢复广播以接受新连接
ble_ota_start_advertising();
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
if (BleOta::instance().isSuspendedForWifiOta()) {
return 0; /* WiFi OTA 中不要自动再开广播 */
}
DEBUG_INFO(TAG, "广播周期结束,重新开始");
ble_ota_start_advertising();
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
DEBUG_INFO(TAG, "CCC upd conn=%d attr=%d n=%d i=%d",
event->subscribe.conn_handle,
event->subscribe.attr_handle,
event->subscribe.cur_notify,
event->subscribe.cur_indicate);
if (event->subscribe.cur_notify) {
ble_ota_log_conn_params(event->subscribe.conn_handle, "订阅时");
}
return 0;
case BLE_GAP_EVENT_CONN_UPDATE: {
DEBUG_DBG(TAG, "BLE 连接参数更新完成: status=%d", event->conn_update.status);
uint16_t interval = ble_ota_log_conn_params(event->conn_update.conn_handle, "更新后");
/* 回退阈值:高速分支目标是 itvl ≤ 8(≤10ms)。若协商失败或落在 > 8(>10ms),
* 说明高速区间被对端拒绝/降级,尝试 Windows 兼容回退路径(10..12.5ms)。
* 原阈值 > 16(>20ms)过宽松,收窄后让回退更早触发、避免停在 15ms 这种"慢但不算失败"的区间。 */
if (!s_conn_fallback_requested &&
(event->conn_update.status != 0 || interval == 0 || interval > 8)) {
s_conn_fallback_requested = true;
ble_ota_request_conn_params(event->conn_update.conn_handle, true);
}
return 0;
}
case BLE_GAP_EVENT_MTU:
DEBUG_INFO(TAG, "MTU 协商: conn=%d mtu=%d",
event->mtu.conn_handle, event->mtu.value);
return 0;
#ifdef BLE_GAP_EVENT_PHY_UPDATE_COMPLETE
case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: {
/* NimBLE: event type 名 *_COMPLETE,union 字段 phy_updated(非 phy_update)。
* 2M 生效才能翻倍空中速率;Chrome/Win 常仍停在 1M。 */
const uint8_t rx = event->phy_updated.rx_phy;
const uint8_t tx = event->phy_updated.tx_phy;
const char *rx_phy = (rx == BLE_GAP_LE_PHY_2M) ? "2M" :
(rx == BLE_GAP_LE_PHY_1M) ? "1M" :
(rx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?";
const char *tx_phy = (tx == BLE_GAP_LE_PHY_2M) ? "2M" :
(tx == BLE_GAP_LE_PHY_1M) ? "1M" :
(tx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?";
DEBUG_INFO(TAG, "PHY %d %s/%s",
event->phy_updated.status, rx_phy, tx_phy);
return 0;
}
#endif
default:
return 0;
}
}
// ============================================================
// BLE 广播
// ============================================================
/**
* @brief 启动 BLE 广播,使设备可被发现和连接。
*/
void ble_ota_start_advertising(void) // kept as free function for BLE callback compatibility
{
if (BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_DBG(TAG, "WiFi OTA 挂起中,跳过广播");
return;
}
int rc;
// 自动推断可用地址类型(优先 Public,不可用则生成 Random)
uint8_t own_addr_type;
rc = ble_hs_id_infer_auto(0, &own_addr_type);
if (rc != 0) {
DEBUG_ERR(TAG, "推断地址失败 rc=%d,试随机地址", rc);
// 生成不可解析随机地址作为 fallback
ble_addr_t rnd_addr;
rc = ble_hs_id_gen_rnd(0, &rnd_addr);
if (rc != 0) {
DEBUG_ERR(TAG, "随机地址失败 rc=%d", rc);
return;
}
own_addr_type = rnd_addr.type;
}
DEBUG_DBG(TAG, "BLE addr type: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random");
// --- 构建广播数据 ---
uint8_t adv_data[31] = {0};
int adv_pos = 0;
// AD Flags: LE General Discoverable + BR/EDR Not Supported
adv_data[adv_pos++] = 0x02; // 长度
adv_data[adv_pos++] = 0x01; // 类型 = Flags
adv_data[adv_pos++] = 0x06; // 值
// Complete Local Name
const char *name = BLE_DEVICE_NAME;
size_t name_len = strlen(name);
if (name_len > 29 - adv_pos - 2) {
name_len = 29 - adv_pos - 2; // 截断以适配广播包
}
adv_data[adv_pos++] = (uint8_t)(name_len + 1);
adv_data[adv_pos++] = 0x09; // 类型 = Complete Local Name
memcpy(&adv_data[adv_pos], name, name_len);
adv_pos += name_len;
rc = ble_gap_adv_set_data(adv_data, adv_pos);
if (rc != 0) {
DEBUG_ERR(TAG, "设置广播数据失败: %d", rc);
return;
}
// 扫描响应: 包含 Service UUID 以帮助发现
uint8_t scan_rsp[31] = {0};
int rsp_pos = 0;
scan_rsp[rsp_pos++] = 0x11; // 长度 (2 + 15)
scan_rsp[rsp_pos++] = 0x09; // 类型 = Complete Local Name
name_len = strlen(name);
if (name_len > (size_t)(31 - rsp_pos)) {
name_len = 31 - rsp_pos;
}
memcpy(&scan_rsp[rsp_pos], name, name_len);
rsp_pos += name_len;
rc = ble_gap_adv_rsp_set_data(scan_rsp, rsp_pos);
if (rc != 0) {
DEBUG_WARN(TAG, "设置扫描响应数据失败: %d", rc);
}
// 开始广播
struct ble_gap_adv_params adv_params = {};
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
// own_addr_type 已在上面由 ble_hs_id_infer_auto 推断
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
&adv_params, ble_ota_gap_event, NULL);
if (rc != 0) {
DEBUG_ERR(TAG, "启动广播失败: %d", rc);
} else {
/* 冷启动与 WiFi 共存恢复都会走这里;常态 INFO 不刷,避免上传日志夹杂 BLE */
DEBUG_DBG(TAG, "BLE广播就绪: \"%s\"", name);
}
}
// ============================================================
// NimBLE 同步/重置回调
// ============================================================
/**
* @brief NimBLE 协议栈同步完成回调,初始化广播。
*/
void ble_ota_on_sync(void)
{
DEBUG_INFO(TAG, "NimBLE 协议栈同步完成");
// GATT 服务已在 serverStart() 中注册,不再重复注册
// 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY
DEBUG_INFO(TAG, "DATA_OUT 值句柄: %u", s_data_out_val_handle);
if (s_data_out_val_handle == 0) {
DEBUG_WARN(TAG, "DATA_OUT 值句柄为 0,通知功能不可用");
}
BleOta::instance().m_bleReady = true;
ble_ota_start_advertising();
}
/**
* @brief NimBLE 协议栈重置回调。
* @param reason
*/
void ble_ota_on_reset(int reason)
{
DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason);
BleOta::instance().m_bleReady = false;
}
// ============================================================
// NimBLE Host 任务(独立 FreeRTOS 任务)
// ============================================================
/**
* @brief NimBLE 主机任务入口,处理协议栈事件循环。
* @param param
*/
void ble_ota_nimble_host_task(void *param)
{
(void)param;
DEBUG_INFO(TAG, "NimBLE Host 任务启动");
nimble_port_run();
// 正常情况下不会到达这里
DEBUG_INFO(TAG, "NimBLE Host 任务退出");
}
// ============================================================
// 公开 API
// ============================================================
/**
* @brief 启动 BLE GATT 服务器并注册 OTA 服务。
* @return ESP_OK 成功,其他失败。
*/
+124
View File
@@ -0,0 +1,124 @@
/*
* ble_ota_priv.h - BLE OTA 内部共享声明(仅 server/ble_ota*.cpp 使用)
*
* 拆分:
* ble_ota.cpp — 单例/会话/收尾/公开 API
* ble_ota_data.cpp — 乱序缓冲、数据写路径、worker
* ble_ota_host.cpp — GATT/GAP/广播/Host
*/
#pragma once
#include <string.h>
#include <inttypes.h>
#include <stdlib.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"
#include "esp_err.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/ble_gap.h"
#include "nimble/ble.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "host/ble_att.h"
#include "app_config.h"
#include "version.h"
#include "ble_ota.h"
#include "esp32_ota_core.h"
#include "ota_coordinator.h"
#include "ota_relay.h"
#include "storage/gd32_flash_store.h"
#include "health_monitor.h"
#include "monitor/debug_log.h"
#define TAG "BLE_OTA"
#define BLE_OTA_NOTIFY_ACK 0xA1
#define BLE_OTA_NOTIFY_FINISH 0xB1
#define BLE_OTA_NOTIFY_SHA256 0xC1
#define BLE_OTA_FINISH_DONE 0x02
#define BLE_OTA_FINISH_REBOOT_NOW 0x07
#define BLE_OTA_FINISH_GD32_RELAY_START 0x10
#define BLE_OTA_FINISH_GD32_RELAY_DONE 0x11
#define BLE_OTA_FINISH_ABORTED 0x20
#define BLE_ATT_ERR_OTA_BUSY 0x80
#define BLE_OTA_FINISH_ERR_FINALIZE 0x81
#define BLE_OTA_FINISH_ERR_MUTEX 0x83
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84
#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85
#define BLE_OTA_FINISH_ERR_GD32_RELAY 0x86
#define BLE_OTA_FINISH_ERR_FW_IDENTITY 0x87 ///< 包身份不符(疑似 Bootloader/非 APP 包),拒绝中继
struct BleOtaDataBatch {
uint8_t *data;
size_t len;
int pool_idx; ///< 静态池槽索引(>=0 为池内,<0 为 heap malloc,需 free)
};
struct BleGd32RelayWaitCtx {
uint16_t session_id;
};
struct BleEsp32FinishCtx {
char sha_hex[65];
uint16_t session_id;
};
struct BleFinishCtxDeleter {
BleEsp32FinishCtx *p;
explicit BleFinishCtxDeleter(BleEsp32FinishCtx *ctx) : p(ctx) {}
~BleFinishCtxDeleter() { if (p) { free(p); p = nullptr; } }
BleFinishCtxDeleter(const BleFinishCtxDeleter&) = delete;
BleFinishCtxDeleter& operator=(const BleFinishCtxDeleter&) = delete;
};
extern QueueHandle_t s_otaDataQueue;
extern uint16_t s_data_out_val_handle;
extern bool s_wifi_ps_boosted;
/** GAP 连接参数回退请求标志(定义在 ble_ota_data.cpp,host 连接事件读写) */
extern bool s_conn_fallback_requested;
void drainOtaDataQueue();
/** @brief 归还 DATA_IN 静态池槽(drainOtaDataQueue / 会话清理跨 TU 调用)。 */
void ble_ota_pool_release(int idx);
void ble_ota_start_advertising(void);
void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
void sendFinishEvent(uint8_t stage, uint32_t value);
uint32_t packBleFinishValue(uint16_t session_id, uint16_t code);
void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]);
void sendSha256Notify(const uint8_t sha[32], uint16_t session_id);
void esp32_reorder_free();
void ble_ota_session_reset(void);
void ble_ota_restore_wifi_ps(void);
void ble_ota_boost_wifi_ps(void);
void ble_ota_data_worker_task(void *pvParameters);
void ble_gd32_relay_wait_task(void *pvParameters);
int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len);
void ble_ota_on_sync(void);
void ble_ota_on_reset(int reason);
void ble_ota_nimble_host_task(void *param);
int ble_ota_gap_event(struct ble_gap_event *event, void *arg);
uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase);
int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compatible);
void ble_esp32_restart_task(void *pvParameters);
void ble_esp32_finish_and_reboot_task(void *pvParameters);
int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
extern const struct ble_gatt_svc_def gatt_ota_svcs[];
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -5,6 +5,9 @@
// HTTP CORS and options
void http_set_cors_headers(httpd_req_t *req);
/** @brief 给 GET 轮询接口加 Connection: keep-alive,让浏览器复用 TCP 连接,
* 省 4Hz 轮询的握手开销。OTA/relay 等需 close 的 handler 不要调用。 */
void http_set_keepalive_hdr(httpd_req_t *req);
esp_err_t http_options_handler(httpd_req_t *req);
// OTA handlers
@@ -15,13 +18,31 @@ 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 发送(唯一入口):同通道 1~N 帧,单帧即 frames 长度为 1。 */
esp_err_t can_send_batch_handler(httpd_req_t *req);
/** @brief CAN 跨通道突发发送:多帧经 0xC7 一次/多次 SPI 事务背靠背发出,近同时。 */
esp_err_t can_send_burst_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);
// CAN 1Hz 反馈快照与接收模式
/** @brief GET /system/heap_low_log — 返回设备侧最新 1 条创新低上下文。 */
esp_err_t system_heap_low_log_handler(httpd_req_t *req);
// CAN 反馈快照(被动 0xC1)与接收模式
esp_err_t can_feedback_handler(httpd_req_t *req);
esp_err_t can_mode_handler(httpd_req_t *req);
// CAN 通道收发器 Silent(0xC8,只听不发)
esp_err_t can_silent_handler(httpd_req_t *req);
// 多电机状态(被动 0xC5)
esp_err_t motor_state_handler(httpd_req_t *req);
// 多电机版本(被动 0xC6,事件驱动)
esp_err_t motor_version_handler(httpd_req_t *req);
esp_err_t motor_ota_upload_handler(httpd_req_t *req);
esp_err_t motor_ota_relay_handler(httpd_req_t *req);
esp_err_t motor_ota_status_handler(httpd_req_t *req);
esp_err_t motor_ota_cancel_handler(httpd_req_t *req);
#endif // HTTP_HANDLERS_H
+109
View File
@@ -0,0 +1,109 @@
/*
* http_util.h - HTTP handler 共享工具(JSON 追加、query 解析、hex 编解码)
*
* 消除 http_handlers.cpp / health_monitor.cpp / spi_speed_test.cpp 中的重复样板。
*/
#ifndef HTTP_UTIL_H
#define HTTP_UTIL_H
#include <stddef.h>
#include <stdint.h>
#include <stdarg.h>
#include <stdbool.h>
#include "esp_http_server.h"
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── JSON buffer 追加 ──────────────────────────────────────────
* 统一替代 feedback_append / append_jsonf / appendJsonfSafe 三份拷贝。
* 截断时不越界,返回新 offset;出错返回 -1 或原 offset(视调用方约定)。
* 返回 (int)cap-1 表示已截断填满。 */
static inline int http_json_append(char *buf, size_t cap, int offset, const char *fmt, ...)
{
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
va_end(ap);
if (n < 0) return offset;
if ((size_t)n >= cap - (size_t)offset) return (int)cap - 1; /* 截断 */
return offset + n;
}
/* ── Query 参数解析 ────────────────────────────────────────────
* 统一替代 6 处内联 ?lite=1 / ?bin=1 / ?ch=N 解析块。 */
/** 查询布尔型参数:值为 "1"/"y"/"t"/"T" 时返回 true,缺失或其它返回 false。 */
static inline bool http_query_bool(httpd_req_t *req, const char *key)
{
char query[48];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return false;
char val[8] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return false;
return val[0] == '1' || val[0] == 'y' || val[0] == 'Y' ||
val[0] == 't' || val[0] == 'T';
}
/** 查询无符号整型参数:缺失/非法时返回 defval。 */
static inline uint32_t http_query_u32(httpd_req_t *req, const char *key, uint32_t defval)
{
char query[64];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return defval;
char val[16] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return defval;
return (uint32_t)strtoul(val, NULL, 10);
}
/* ── Hex 编解码 ────────────────────────────────────────────────
* 统一替代内联 strtoul 循环和 snprintf("%02X") 循环。 */
/** hex 字符串 → 字节数组,返回写入字节数。 */
static inline size_t http_hex_to_bytes(const char *hex, uint8_t *out, size_t max_out)
{
size_t hex_len = strlen(hex);
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** hex 字符串(带长度)→ 字节数组,返回写入字节数。
* 供 json_parse.h 的非 NUL 终止字符串值使用。 */
static inline size_t http_hex_to_bytes_n(const char *hex, size_t hex_len, uint8_t *out, size_t max_out)
{
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** 字节数组 → 大写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_upper(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02X", in[i]);
}
out[n * 2] = '\0';
}
/** 字节数组 → 小写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_lower(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02x", in[i]);
}
out[n * 2] = '\0';
}
#ifdef __cplusplus
}
#endif
#endif // HTTP_UTIL_H
+243
View File
@@ -0,0 +1,243 @@
/*
* json_parse.h - 极简 JSON 扁平对象解析器(cJSON 零堆分配替代)
*
* 项目中对 JSON 的使用全部是"解析前端发来的扁平 JSON 对象":
* { "sha256": "...", "verify_only": true, "total_size": 12345, "type": "esp32" }
* 无嵌套对象、无数组(can_send_batch 已自研流式解析)。
* 本解析器直接在调用方提供的可写字符串缓冲上做扫描,
* 不分配堆内存,字符串值通过就地 NUL 终止返回指针。
*
* 用法:
* JsonView root = jsonParse(buf); // buf 须为可写 char[]
* if (!root.valid) return error;
* JsonView sha = jsonGet(&root, "sha256");
* if (sha.type == JSON_STRING) { use(sha.str); }
* JsonView sz = jsonGet(&root, "total_size");
* if (sz.type == JSON_NUMBER) { uint32_t n = (uint32_t)sz.num; }
* // 无需 free —— 指针指向 buf,buf 由调用方管理
*/
#ifndef JSON_PARSE_H
#define JSON_PARSE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
JSON_INVALID = 0,
JSON_NULL,
JSON_BOOL,
JSON_NUMBER,
JSON_STRING,
JSON_OBJECT,
} JsonType;
typedef struct {
JsonType type;
bool valid;
char *str; /* JSON_STRING:指向 buf 内字符串(不一定 NUL 终止!) */
size_t str_len; /* JSON_STRING:字符串长度(配合 str 使用,避免就地写 NUL) */
double num; /* JSON_NUMBER / JSON_BOOL */
char *obj_start; /* JSON_OBJECT:'{' 后的内容区 */
size_t obj_len;
} JsonView;
/**
* @brief 解析 JSON 字符串为根对象视图(就地扫描,可能写入 NUL 终止符)。
* @param json 可写 NUL 终止 JSON 字符串(须为顶层 { ... } 扁平对象)
* @return type=JSON_OBJECT 且 valid=true,或 valid=false 解析失败
*/
static inline JsonView jsonParse(char *json)
{
JsonView v = {};
if (!json) return v;
char *p = json;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '{') return v;
p++;
char *content = p;
int depth = 1;
while (*p) {
if (*p == '"') {
p++;
while (*p && *p != '"') {
if (*p == '\\' && p[1]) p++;
p++;
}
if (*p) p++;
} else if (*p == '{') {
depth++; p++;
} else if (*p == '}') {
depth--;
if (depth == 0) break;
p++;
} else {
p++;
}
}
if (depth != 0) return v;
v.type = JSON_OBJECT;
v.valid = true;
v.obj_start = content;
v.obj_len = (size_t)(p - content);
return v;
}
/**
* @brief 在扁平 JSON 对象中查找指定 key 的值。
* @return valid=true 表示找到;字符串值经就地 NUL 终止后通过 .str 返回
*/
static inline JsonView jsonGet(const JsonView *obj, const char *key)
{
JsonView result = {};
if (!obj || !obj->valid || obj->type != JSON_OBJECT || !key) return result;
char *p = obj->obj_start;
char *end = obj->obj_start + obj->obj_len;
while (p < end) {
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')) p++;
if (p >= end) break;
/* "key" */
if (*p != '"') break;
p++;
char *key_start = p;
while (p < end && *p != '"') p++;
if (p >= end) break;
size_t klen = (size_t)(p - key_start);
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end || *p != ':') break;
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end) break;
bool match = (strlen(key) == klen && strncmp(key_start, key, klen) == 0);
if (*p == '"') {
p++;
char *val_start = p;
while (p < end && *p != '"') {
if (*p == '\\' && p + 1 < end) p++;
p++;
}
if (p >= end) break;
if (match) {
/* 不就地写 NUL(会破坏后续 jsonGet 扫描);用 str_len 返回长度 */
result.type = JSON_STRING;
result.valid = true;
result.str = val_start;
result.str_len = (size_t)(p - val_start);
return result;
}
p++;
} else if (*p == 't' || *p == 'f') {
if (match) {
result.type = JSON_BOOL;
result.valid = true;
result.num = (*p == 't') ? 1.0 : 0.0;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else if (*p == 'n') {
if (match) {
result.type = JSON_NULL;
result.valid = true;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else {
char *val_start = p;
if (*p == '-') p++;
while (p < end && ((*p >= '0' && *p <= '9') || *p == '.' || *p == 'e' || *p == 'E' || *p == '+' || *p == '-')) p++;
if (match) {
/* 不就地写 NUL;strtod 可在非 NUL 终止串上工作(指定长度)。
* 但 strtod 需要终止符——用临时方式:记录起止,用 sscanf 或手动解析。
* 更简方案:buf 本身是 NUL 终止的(jsonParse 要求 NUL 终止输入),
* 而 *p 此处是数字后的分隔符(',' 或 '}' 或空白),不是 NUL。
* 我们临时保存 *p、写 NUL、解析、恢复 *p。 */
char saved = *p;
*p = '\0';
result.type = JSON_NUMBER;
result.valid = true;
result.num = strtod(val_start, nullptr);
*p = saved; /* 恢复,不影响后续 jsonGet 扫描 */
return result;
}
}
}
return result;
}
/* ── 便捷谓词(兼容 cJSON 调用风格)── */
static inline bool jsonIsString(const JsonView *item) {
return item && item->valid && item->type == JSON_STRING;
}
static inline bool jsonIsNumber(const JsonView *item) {
return item && item->valid && item->type == JSON_NUMBER;
}
static inline bool jsonIsBool(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL;
}
static inline bool jsonIsTrue(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL && item->num != 0.0;
}
/**
* @brief 获取字符串值指针和长度(字符串不一定 NUL 终止,须配合 str_len 使用)。
* @param item JSON 值视图
* @param out_len 可选,输出字符串长度
* @return 字符串指针(指向 buf 内部),或 nullptr
*/
static inline const char *jsonGetStringValue(const JsonView *item, size_t *out_len = nullptr) {
if (item && item->valid && item->type == JSON_STRING) {
if (out_len) *out_len = item->str_len;
return item->str;
}
if (out_len) *out_len = 0;
return nullptr;
}
/**
* @brief 比较字符串值是否等于指定 C 字符串。
* 因 jsonGet 不写 NUL 终止符,不能用 strcmp,须用此函数。
*/
static inline bool jsonStringEquals(const JsonView *item, const char *cstr) {
if (!jsonIsString(item) || !cstr) return false;
size_t clen = strlen(cstr);
return clen == item->str_len && strncmp(item->str, cstr, clen) == 0;
}
/**
* @brief 将字符串值拷贝到 NUL 终止的输出缓冲(安全截断)。
* @return 实际拷贝长度(不含 NUL)
*/
static inline size_t jsonGetStringCopy(const JsonView *item, char *out, size_t out_max) {
if (!jsonIsString(item) || !out || out_max == 0) return 0;
size_t n = (item->str_len < out_max - 1) ? item->str_len : out_max - 1;
memcpy(out, item->str, n);
out[n] = '\0';
return n;
}
static inline double jsonGetNumberValue(const JsonView *item) {
return (item && item->valid && item->type == JSON_NUMBER) ? item->num : 0.0;
}
#ifdef __cplusplus
}
#endif
#endif // JSON_PARSE_H
+161 -106
View File
@@ -31,8 +31,31 @@
#include "spi_master.h"
#include "ble_ota.h"
#include "http_handlers.h"
#include "monitor/spi_speed_test.h" /* [SPI-SPEEDTEST] 临时:可整行删除 */
// ----------------------------------------
// 文件作用域 static helper
// ----------------------------------------
/**
* @brief 清除指定 NVS 命名空间下的全部键(erase_all + commit + close)。
* 纯函数,内部不打日志,由调用方负责日志输出。
* @param ns NVS 命名空间字符串(例如 "nvs.net80211"、"phy")。
* @return ESP_OK 成功;否则返回 nvs_open 的错误码。
*/
static esp_err_t clearNvsNamespace(const char *ns)
{
nvs_handle_t h;
esp_err_t err = nvs_open(ns, NVS_READWRITE, &h);
if (err != ESP_OK) {
return err;
}
nvs_erase_all(h);
nvs_commit(h);
nvs_close(h);
return ESP_OK;
}
// ============================================================
/**
@@ -45,10 +68,6 @@ WifiOta& WifiOta::instance()
return inst;
}
/**
* @brief 预先声明:为 STA 模式配置静态 IP、网关和子网掩码。
*/
// ----------------------------------------
// STA 后台重连任务(避免在系统事件回调中调用 esp_wifi_connect 死锁)
// ----------------------------------------
@@ -148,11 +167,8 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base,
inst.setStaRetryCount(0);
inst.setStaRetryExhausted(false); // ★ 连接成功,重置耗尽标志
inst.stateUnlock();
// ★ M2: 推送 WiFi 连接状态给 HealthMonitor(解耦监控层主动查询)
// 推送 WiFi 连接状态给 HealthMonitor
HealthMonitor::instance().setWifiConnected(true);
if (inst.m_staConnectedSem) {
xSemaphoreGive(inst.m_staConnectedSem);
}
break;
case WIFI_EVENT_STA_DISCONNECTED: {
@@ -162,7 +178,7 @@ void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base,
bool exhausted = inst.getStaRetryExhausted();
int retryCount = inst.getStaRetryCount();
inst.stateUnlock();
// ★ M2: 推送 WiFi 断开状态给 HealthMonitor
// 推送 WiFi 断开状态给 HealthMonitor
HealthMonitor::instance().setWifiConnected(false);
if (!exhausted) {
@@ -283,7 +299,10 @@ esp_err_t WifiOta::initSta()
country.policy = WIFI_COUNTRY_POLICY_MANUAL;
wifi_config_t wifi_config = {};
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
/* FAST_SCAN:扫到目标 SSID 即停并连接(多 AP 同名时取先扫到的,可能非最强)。
* 原 ALL_CHANNEL_SCAN 要把 13 信道全扫完才连,OTA 重启后页面恢复要多等 3~8s。
* esp_restart 不清 nvs.net80211 缓存,驱动可复用上次 AP 信息,恢复更快。 */
wifi_config.sta.scan_method = WIFI_FAST_SCAN;
wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
wifi_config.sta.pmf_cfg.capable = true;
@@ -298,15 +317,10 @@ esp_err_t WifiOta::initSta()
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
setStaticIp();
// ★ 冷启动时在 wifi_start 前增加延迟
// 长时间断电后首次上电,WiFi PHY 校准需要额外时间
// esp_wifi_start() 内部会触发 PHY 初始化和校准
// 如果校准未完成就启动射频,可能导致连接失败
/* 冷启动时在 wifi_start 前短延时 */
if (WifiOta::instance().m_coldBoot) {
DEBUG_INFO("WIFI", "冷启动: 等待 PHY 就绪...");
vTaskDelay(pdMS_TO_TICKS(500));
DEBUG_INFO("WIFI", "冷启动: wifi_start 前等待 300ms");
vTaskDelay(pdMS_TO_TICKS(300));
}
ESP_ERROR_CHECK(esp_wifi_start());
@@ -325,8 +339,8 @@ esp_err_t WifiOta::initSta()
*/
esp_err_t WifiOta::waitStaConnected(char *ip_str, size_t ip_len)
{
SemaphoreHandle_t wait_sem = WIFI_STA_USE_STATIC_IP ? WifiOta::instance().m_staConnectedSem : WifiOta::instance().m_staGotIpSem;
const char *wait_name = WIFI_STA_USE_STATIC_IP ? "STA_CONNECTED" : "STA_GOT_IP";
SemaphoreHandle_t wait_sem = WifiOta::instance().m_staGotIpSem;
const char *wait_name = "STA_GOT_IP";
if (wait_sem == NULL) {
DEBUG_ERR("WIFI", "%s 信号量未创建", wait_name);
@@ -368,37 +382,6 @@ esp_err_t WifiOta::waitStaConnected(char *ip_str, size_t ip_len)
return ESP_ERR_TIMEOUT;
}
/**
* @brief 设置 STA 静态 IP
*/
void WifiOta::setStaticIp()
{
if (WIFI_STA_USE_STATIC_IP) {
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_netif) {
DEBUG_INFO("WIFI", "STA 使用静态 IP 模式,停止 DHCP Client");
ESP_ERROR_CHECK(esp_netif_dhcpc_stop(sta_netif));
esp_netif_ip_info_t info;
memset(&info, 0, sizeof(info));
inet_pton(AF_INET, WIFI_STA_STATIC_IP, &info.ip);
inet_pton(AF_INET, WIFI_STA_STATIC_GW, &info.gw);
inet_pton(AF_INET, WIFI_STA_STATIC_NETMASK, &info.netmask);
ESP_ERROR_CHECK(esp_netif_set_ip_info(sta_netif, &info));
DEBUG_INFO("WIFI", "静态 IP 已设置: %s", WIFI_STA_STATIC_IP);
// 设置 DNS
esp_netif_dns_info_t dns_info;
memset(&dns_info, 0, sizeof(dns_info));
inet_pton(AF_INET, WIFI_STA_STATIC_DNS, &dns_info.ip.u_addr.ip4.addr);
ESP_ERROR_CHECK(esp_netif_set_dns_info(sta_netif, ESP_NETIF_DNS_MAIN, &dns_info));
DEBUG_INFO("WIFI", "DNS 已设置: %s", WIFI_STA_STATIC_DNS);
}
} else {
DEBUG_INFO("WIFI", "使用 DHCP 获取 IP");
}
}
// ----------------------------------------
// ESP32 OTA 内部函数
// 注:begin/write/finalize/abort 已抽离到 Esp32OtaCore(main/ota/esp32_ota_core.{h,cpp})
@@ -417,11 +400,48 @@ esp_err_t WifiOta::httpServerStart()
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = HTTP_SERVER_PORT;
config.stack_size = HTTP_SERVER_STACK_SIZE;
config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检
config.max_open_sockets = 4; // 配合 LWIP_MAX_SOCKETS=12: 4(httpd) + 1(UDP) + ~3(LWIP内部) = 8, 余量 4; 空闲时前端 status/logs/上传可能并发,3 个易被 LRU 踢掉导致连接中断
config.lru_purge_enable = true; // socket 满时自动清理最久未用的连接
config.recv_wait_timeout = 30; // GD32 上传前会擦除 Flash,5 秒默认值过紧
config.send_wait_timeout = 30;
/* 路由上限:当前 18 个 main handler + 17 个唯一 path 的 OPTIONS = 35 次注册。
* max_uri_handlers 必须 ≥ 该总数,否则超出部分被 httpd 静默丢弃
*(httpd_register_uri_handler 返回 ESP_ERR_HTTPD_HANDLERS_FULL,下方已检查并报错)。
* 曾因 =32 导致按注册顺序排在末尾的 /motor/ota/cancel(POST) 未注册,
* 取消请求恒返回 404、httpd 其它读取被在途 SPI 事务短暂饿死后才回 404。
* 取 48 留足后续新增端点余量(每槽仅一个指针,内存开销可忽略)。 */
config.max_uri_handlers = 48;
/* Socket 预算(无法“合并”httpd 内部 3 个 fd:listen + ctrl 对):
*
* LWIP 总池 CONFIG_LWIP_MAX_SOCKETS = 12
* ├─ httpd 内部固定 3(listen + ctrl socket 对)
* ├─ max_open_sockets = 业务并发(下面 want)
* └─ 余量:UDP 调试日志 1 + 偶发其它
*
* 约束:max_open_sockets <= LWIP_MAX_SOCKETS - 3 = 9
*
* 业务并发需要 6(前端三链路 motor/feedback/status + OTA 上传 + 操作 + 余量):
* - motor.html 独立 3 条轮询链路(已剥离互不阻塞)
* - OTA 大上传占 1 条长连接
* - 双 tab / 偶发脚本 1 条
* - 余量 1
* 4 太紧会触发 lru_purge 踢连接,表现为「请求挂起 + age 不动」。
* lru_purge:满槽时踢最久空闲连接,避免 EMFILE 卡死。
* 硬编码 7 在 LWIP=8 时会 ESP_ERR_INVALID_ARG → HTTP 起不来。 */
{
const int lwip_cap = CONFIG_LWIP_MAX_SOCKETS - 3; // httpd 硬上限
const int want = 6; // 前端 3 链路 + OTA + 操作 + 余量
config.max_open_sockets = (lwip_cap < want) ? ((lwip_cap > 1) ? lwip_cap : 1) : want;
}
/* 满连接时淘汰最久未用的空闲 socket(相当于“复用/腾挪”槽位) */
config.lru_purge_enable = true;
/* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉。
* 缩到 5s:WiFi 暴断时挂死连接更快释放槽位,避免长时占满 6 槽导致卡顿。 */
config.recv_wait_timeout = 5;
config.send_wait_timeout = 5;
/* TCP keep-alive 探针:5s idle 无数据 → 每 1s 探测 3 次失败立即踢。
* 不依赖 recv_wait_timeout(要等 socket 主动读失败),WiFi 半挂/暴断时
* 探针能更快感知,槽位早释放。原默认 false 会让断连占槽到 5s 超时。 */
config.keep_alive_enable = true;
config.keep_alive_idle = 5;
config.keep_alive_interval = 1;
config.keep_alive_count = 3;
DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ===");
DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port);
@@ -432,13 +452,13 @@ esp_err_t WifiOta::httpServerStart()
return err;
}
// 从 NVS 恢复固件存储状态(重启后保留)
// GD32 固件就绪真相源为 OtaCoordinator / NVS meta(/system/status),
// 不再在 WifiOta 上维护影子 storage_done 标志。
{
ota_meta_t boot_meta;
if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK &&
boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) {
WifiOta::instance().setGd32StorageDone(true);
DEBUG_INFO("WIFI", "从 NVS 恢复: 固件已就绪 (%u bytes)", boot_meta.total_size);
DEBUG_INFO("WIFI", "从 NVS 恢复: GD32 固件已就绪 (%u bytes)", boot_meta.total_size);
}
}
@@ -455,30 +475,62 @@ esp_err_t WifiOta::httpServerStart()
{"/logs", HTTP_GET, logs_handler},
{"/system/status", HTTP_GET, system_status_handler},
{"/system/probe", HTTP_GET, system_probe_handler},
{"/can/send", HTTP_POST, can_send_handler},
{"/system/heap_low_log", HTTP_GET, system_heap_low_log_handler},
{"/can/send_batch", HTTP_POST, can_send_batch_handler},
{"/can/send_burst", HTTP_POST, can_send_burst_handler},
{"/can/feedback", HTTP_GET, can_feedback_handler},
{"/can/mode", HTTP_GET, can_mode_handler},
{"/can/mode", HTTP_POST, can_mode_handler},
{"/can/silent", HTTP_POST, can_silent_handler},
{"/spi/send", HTTP_POST, spi_send_handler},
{"/motor/state", HTTP_GET, motor_state_handler},
{"/motor/version", HTTP_GET, motor_version_handler},
{"/motor/ota", HTTP_POST, motor_ota_upload_handler},
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
{"/motor/ota/status", HTTP_GET, motor_ota_status_handler},
{"/motor/ota/cancel", HTTP_POST, motor_ota_cancel_handler},
{"/spi/speedtest", HTTP_GET, spi_speedtest_handler}, /* [SPI-SPEEDTEST] 临时:可整行删除 */
};
for (const auto &r : kHttpRoutes) {
/* 同一 path 可有多 method(如 GET+POST);OPTIONS 每个 path 只注册一次,
* 否则 httpd 报 “handler ... method 6 already registered”。 */
for (size_t i = 0; i < sizeof(kHttpRoutes) / sizeof(kHttpRoutes[0]); ++i) {
const UriEntry &r = kHttpRoutes[i];
httpd_uri_t main_uri = {};
main_uri.uri = r.uri;
main_uri.method = r.method;
main_uri.handler = r.handler;
main_uri.user_ctx = NULL;
httpd_register_uri_handler(WifiOta::instance().m_httpServer, &main_uri);
esp_err_t reg_err = httpd_register_uri_handler(WifiOta::instance().m_httpServer, &main_uri);
if (reg_err != ESP_OK) {
/* 静默丢弃会导致对应端点 404(如曾经的 /motor/ota/cancel)。
* 明确报错,提示提高 max_uri_handlers。 */
DEBUG_ERR("WIFI", "URI 注册失败 %s method=%d: %s(提高 max_uri_handlers)",
r.uri, (int)r.method, esp_err_to_name(reg_err));
}
httpd_uri_t opts_uri = {};
opts_uri.uri = r.uri;
opts_uri.method = HTTP_OPTIONS;
opts_uri.handler = http_options_handler;
opts_uri.user_ctx = NULL;
httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri);
bool need_opts = true;
for (size_t j = 0; j < i; ++j) {
if (strcmp(kHttpRoutes[j].uri, r.uri) == 0) {
need_opts = false;
break;
}
}
if (need_opts) {
httpd_uri_t opts_uri = {};
opts_uri.uri = r.uri;
opts_uri.method = HTTP_OPTIONS;
opts_uri.handler = http_options_handler;
opts_uri.user_ctx = NULL;
esp_err_t opts_err = httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri);
if (opts_err != ESP_OK) {
DEBUG_ERR("WIFI", "OPTIONS 注册失败 %s: %s(提高 max_uri_handlers)",
r.uri, esp_err_to_name(opts_err));
}
}
}
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", "HTTP端点已注册 (ota/can/spi/logs/status)");
return ESP_OK;
}
@@ -520,7 +572,7 @@ bool WifiOta::isConnected()
}
// ============================================================
// IBleCoexListener 接口实现(L1: 通过协调器接收 BLE 共存通知)
// IBleCoexListener 接口实现
// ============================================================
void WifiOta::onBleOtaStart()
{
@@ -542,12 +594,12 @@ void WifiOta::onBleOtaEnd()
*/
bool WifiOta::earlyInit()
{
DEBUG_INFO("WIFI", "=== WiFi 早期初始化 (调度器启动前) ===");
DEBUG_INFO("WIFI", "=== WiFi 早期初始化 ===");
// ★ 冷启动检测:判断是否为上电复位
// ★ 冷启动检测:判断是否为上电/欠压复位
esp_reset_reason_t reset_reason = esp_reset_reason();
WifiOta::instance().m_coldBoot = (reset_reason == ESP_RST_POWERON || reset_reason == ESP_RST_BROWNOUT);
DEBUG_INFO("WIFI", "复位原因: %d (%s), 冷启动: %s",
reset_reason,
(reset_reason == ESP_RST_POWERON) ? "POWER_ON" :
@@ -560,6 +612,12 @@ bool WifiOta::earlyInit()
"OTHER",
WifiOta::instance().m_coldBoot ? "是" : "否");
/* 冷启动:电源与外设上电完成后再初始化 NVS/WiFi */
if (WifiOta::instance().m_coldBoot) {
DEBUG_INFO("WIFI", "冷启动: 等待 400ms 后初始化 NVS/WiFi");
vTaskDelay(pdMS_TO_TICKS(400));
}
esp_err_t err = nvs_flash_init();
bool nvs_erased = false;
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
@@ -573,32 +631,24 @@ bool WifiOta::earlyInit()
DEBUG_ERR("WIFI", "NVS 初始化失败: %s", esp_err_to_name(err));
} else {
if (nvs_erased) {
DEBUG_WARN("WIFI", "NVS 已擦除重建,WiFi PHY 校准数据已清除,将重新校准");
DEBUG_WARN("WIFI", "NVS重建,PHY校准已清,将重校");
}
DEBUG_INFO("WIFI", "NVS 初始化成功");
}
// ★ 冷启动时强制清除 WiFi NVS 缓存,避免残留脏数据
// 长时间断电后 NVS 中的 WiFi 配置缓存可能不一致
/* 冷启动清理 WiFi 连接缓存;欠压复位后再清 PHY 强制重校准 */
if (WifiOta::instance().m_coldBoot) {
nvs_handle_t nvs_handle;
err = nvs_open("nvs.net80211", NVS_READWRITE, &nvs_handle);
err = clearNvsNamespace("nvs.net80211");
if (err == ESP_OK) {
// 清除 WiFi 驱动内部 NVS 缓存键
nvs_erase_all(nvs_handle);
nvs_commit(nvs_handle);
nvs_close(nvs_handle);
DEBUG_DBG("WIFI", "冷启动: 已清除 WiFi NVS 缓存 (nvs.net80211)");
} else {
DEBUG_INFO("WIFI", "冷启动: 无 WiFi NVS 缓存分区,跳过清除");
}
// 同样清除 PHY 校准数据分区,强制下次全量校准
err = nvs_open("phy", NVS_READWRITE, &nvs_handle);
if (err == ESP_OK) {
nvs_erase_all(nvs_handle);
nvs_commit(nvs_handle);
nvs_close(nvs_handle);
DEBUG_DBG("WIFI", "冷启动: 已清除 PHY 校准缓存,将重新校准");
if (reset_reason == ESP_RST_BROWNOUT) {
err = clearNvsNamespace("phy");
if (err == ESP_OK) {
DEBUG_WARN("WIFI", "欠压复位后: 已清除 PHY 校准数据");
}
}
}
@@ -614,16 +664,15 @@ bool WifiOta::earlyInit()
}
}
WifiOta::instance().m_staConnectedSem = xSemaphoreCreateBinary();
WifiOta::instance().m_staGotIpSem = xSemaphoreCreateBinary();
if (!WifiOta::instance().m_staConnectedSem || !WifiOta::instance().m_staGotIpSem) {
if (!WifiOta::instance().m_staGotIpSem) {
DEBUG_ERR("WIFI", "创建信号量失败");
}
// 创建后台重连任务:避免在系统事件任务中调用 esp_wifi_connect()/vTaskDelay 导致死锁
if (WifiOta::instance().m_reconnectTask == nullptr) {
BaseType_t ret = xTaskCreate(&WifiOta::reconnectTask, "wifi_reconnect",
4096, NULL, 5,
TASK_STACK_WIFI_RECONNECT, NULL, 5,
&WifiOta::instance().m_reconnectTask);
if (ret != pdPASS) {
DEBUG_ERR("WIFI", "创建重连任务失败");
@@ -741,7 +790,7 @@ void WifiOta::taskEntry(void *pvParameters)
ota_meta_t boot_meta;
if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK &&
boot_meta.total_size > 0 && boot_meta.stored_size < boot_meta.total_size) {
DEBUG_WARN("WIFI", "检测到不完整 OTA 缓存 (stored=%u total=%u),清除残留状态",
DEBUG_WARN("WIFI", "不完整OTA缓存 stored=%u total=%u,清残留",
(unsigned)boot_meta.stored_size, (unsigned)boot_meta.total_size);
OtaCoordinator::instance().clearGd32Meta();
OtaCoordinator::instance().abortCurrent();
@@ -762,21 +811,27 @@ void WifiOta::taskEntry(void *pvParameters)
// 等待网络栈稳定(ARP 表建立、路由就绪),避免首批 UDP 包丢失
vTaskDelay(pdMS_TO_TICKS(500));
// 启动摘要,发两遍确保上位机收到(第一包可能触发 ARP 被丢)
// 启动摘要(单次;前面已 delay 等网络/ARP 稳定)
esp_chip_info_t chip;
esp_chip_info(&chip);
for (int i = 0; i < 2; i++) {
DEBUG_INFO("WIFI", "========================================");
DEBUG_INFO("WIFI", "[启动摘要] ESP32-C3 OTA Relay v%s", FW_VERSION);
DEBUG_INFO("WIFI", "[启动摘要] Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip.revision);
DEBUG_INFO("WIFI", "[启动摘要] Free heap: %lu bytes", (unsigned long)esp_get_free_heap_size());
DEBUG_INFO("WIFI", "[启动摘要] WiFi: %s", WifiOta::getMode() == OTA_WIFI_MODE_STA ? "STA" : "AP");
DEBUG_INFO("WIFI", "[启动摘要] NVS + WiFi driver + netif + event handler 初始化完成");
DEBUG_INFO("WIFI", "[启动摘要] HTTP 服务器端口: %d", HTTP_SERVER_PORT);
DEBUG_INFO("WIFI", "========================================");
DEBUG_INFO("WIFI", "=== WiFi OTA 服务器就绪 ===");
if (i == 0) vTaskDelay(pdMS_TO_TICKS(200));
}
DEBUG_INFO("WIFI", "========================================");
DEBUG_INFO("WIFI", "[启动摘要] ESP32-C3 OTA Relay v%s", FW_VERSION);
DEBUG_INFO("WIFI", "[启动摘要] Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip.revision);
DEBUG_INFO("WIFI", "[启动摘要] Free heap: %lu bytes", (unsigned long)esp_get_free_heap_size());
DEBUG_INFO("WIFI", "[启动摘要] WiFi: %s", WifiOta::getMode() == OTA_WIFI_MODE_STA ? "STA" : "AP");
DEBUG_INFO("WIFI", "[启动摘要] NVS + WiFi driver + netif + event handler 初始化完成");
DEBUG_INFO("WIFI", "[启动摘要] HTTP 服务器端口: %d", HTTP_SERVER_PORT);
DEBUG_INFO("WIFI", "========================================");
DEBUG_INFO("WIFI", "=== WiFi OTA 服务器就绪 ===");
/* 启动日志 burst 已过:环 64→16 槽,释放大约 7.5KB 给业务堆 */
DebugLog::instance().shrinkRingAfterBoot();
/* ★ 重启完成判定信号(前端 polling.js 匹配 /OTA Relay .*启动/ + tag=MAIN):
* 必须在本函数(httpd 就绪、shrinkRingAfterBoot 之后)打印——main.cpp 的
* 早期 banner 在环缩容时会被裁掉,前端首轮 /logs 拉不到,只能干等 10s
* 安全阈值才恢复页面。此处是环内最新日志,前端一定能拉到。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v%s 启动", FW_VERSION);
// WiFi 状态监控
// 每秒一次循环:每 10 秒检查 RSSI
+1 -9
View File
@@ -69,8 +69,6 @@ public:
bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); }
/** @brief 设置 STA 重试耗尽标志。 */
void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); }
/** @brief 设置 GD32 固件存储完成标志。 */
void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); }
// ── 状态互斥锁(保护复合读-改-写操作)──
/** @brief 获取状态互斥锁句柄。 */
SemaphoreHandle_t getStateMutex() const { return m_stateMutex; }
@@ -82,7 +80,6 @@ public:
// ── 初始化/运行时状态(仅 OTA 任务访问,无需额外锁)──
ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP; ///< 当前 WiFi 工作模式
bool m_coldBoot = false; ///< 是否为冷启动(首次上电)
SemaphoreHandle_t m_staConnectedSem = NULL; ///< STA 连接成功信号量
SemaphoreHandle_t m_staGotIpSem = NULL; ///< STA 获取 IP 信号量
TaskHandle_t m_reconnectTask = nullptr; ///< STA 后台重连任务句柄
@@ -93,7 +90,7 @@ public:
/** @brief BLE OTA 开始传输时调用,暂停 WiFi RSSI 轮询等射频活动。 */
void setBleOtaActive(bool v) { m_bleOtaActive.store(v); }
// ── IBleCoexListener 接口实现(L1: 通过协调器接收 BLE 共存通知)──
// ── IBleCoexListener 接口实现 ──
/** @brief BLE OTA 开始,暂停 WiFi RSSI 轮询。 */
void onBleOtaStart() override;
/** @brief BLE OTA 结束,恢复 WiFi 射频活动。 */
@@ -107,7 +104,6 @@ private:
std::atomic<bool> m_staConnected{false};
std::atomic<int> m_staRetryCount{0};
std::atomic<bool> m_staRetryExhausted{false};
std::atomic<bool> m_gd32StorageDone{false};
SemaphoreHandle_t m_stateMutex = NULL;
TaskHandle_t m_taskHandle = nullptr;
@@ -120,10 +116,6 @@ private:
* @return ESP_OK 成功,其他失败。
*/
esp_err_t initSta();
/**
* @brief 设置 STA 静态 IP、网关和子网掩码。
*/
void setStaticIp();
/**
* @brief WiFi/IP 事件回调,处理连接、断开和 IP 获取。
* @param arg 回调用户参数。
+267 -29
View File
@@ -7,14 +7,17 @@
#include <stdlib.h>
#include <inttypes.h>
#include "esp_partition.h"
#include "esp_task_wdt.h"
#include "nvs_flash.h"
#include "nvs.h"
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
#include "mbedtls/private/sha256.h"
#include "gd32_flash_store.h"
#include "app_config.h" // OTA_BLOCK_SIZE(flashWriteChunk 静态对齐缓冲容量)
#include "monitor/debug_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
// ============================================================
@@ -73,9 +76,136 @@ const esp_partition_t* Gd32FlashStore::getPartition()
// Flash 分区读写
// ============================================================
void Gd32FlashStore::beginWriteSession(bool full_erase)
{
m_erasedUpTo = 0;
if (!full_erase) {
return; // GD32 OTA:保持原优化,由 flashWriteChunk 按需擦除与收流交织
}
/* ★ motor OTA:强制擦除整个 gd32_ota 分区,而不是只重置水位。
* 之前只重置 m_erasedUpTo=0,依赖 flashWriteChunk 按需擦除覆盖到的扇区,
* 导致两类隐患:
* 1. 上次刷的更大固件在高位扇区残留,本次若擦除位错误会与残留 AND 产生位翻转
* (正是 0xAC20 处 0x19→0x18 单 bit 翻转的根因)。
* 2. ensureErasedForWrite 按水位推进,若某次擦除返回 OK 但物理失败,
* 旧数据残留 + AND 语义写入 = 静默位错误。
* 强制全擦(252KB ~200ms)确保每次上传从全 0xFF 干净状态开始,
* 即使新固件只覆盖前 44KB,剩余高位扇区也回到擦除态,杜绝残留干扰。
* 擦除失败立刻报错,不让坏状态继续。 */
const esp_partition_t *part = getPartition();
if (!part) {
DEBUG_ERR("OTA", "beginWriteSession: 分区未找到,无法预擦除");
return;
}
esp_err_t err = erasePartition(part->size);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "beginWriteSession: 预擦除整个分区失败: %s", esp_err_to_name(err));
/* 不 return(void 函数);后续 flashWriteChunk 的 ensureErasedForWrite
* 仍会尝试擦除目标扇区,给数据写入多一次机会。 */
} else {
m_erasedUpTo = part->size; /* 全擦完成,后续 ensureErasedForWrite 全部跳过 */
DEBUG_INFO("OTA", "beginWriteSession: 已预擦除整个 gd32_ota 分区 (%lu B)",
(unsigned long)part->size);
}
}
/** @brief 确保 [offset, offset+len) 覆盖的扇区已擦除(从 m_erasedUpTo 顺序推进)。 */
esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len)
{
if (len == 0) return ESP_OK;
const esp_partition_t *part = getPartition();
if (!part) return ESP_ERR_NOT_FOUND;
const uint32_t end = offset + (uint32_t)len;
// 目标区间已在水位内:无需再擦(顺序 OTA 不回写旧扇区)
if (end <= m_erasedUpTo) return ESP_OK;
// 水位始终扇区对齐;只从 m_erasedUpTo 向高地址推进,禁止重擦已写扇区。
// 若写入跳空,一并擦掉空隙,保持 [0, m_erasedUpTo) 连续已擦。
uint32_t erase_to = (end + (kFlashSectorSize - 1U)) & ~(kFlashSectorSize - 1U);
if (erase_to > part->size) {
erase_to = part->size;
}
uint32_t erase_from = m_erasedUpTo;
if (erase_from >= erase_to) return ESP_OK;
for (uint32_t addr = erase_from; addr < erase_to; ) {
uint32_t chunk = kFlashSectorSize;
if (addr + chunk > erase_to) {
chunk = erase_to - addr;
}
esp_err_t err = esp_partition_erase_range(part, addr, chunk);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "ensureErased: erase @%" PRIu32 " len=%" PRIu32 " failed: %s",
addr, chunk, esp_err_to_name(err));
return err;
}
addr += chunk;
m_erasedUpTo = addr;
(void)esp_task_wdt_reset();
taskYIELD(); /* 长时间兜底擦时让 WiFi/LWIP 有机会跑 */
}
return ESP_OK;
}
// ============================================================
// OTA 期对齐缓冲池(2026-08 内存优化)
//
// flashWriteChunk 在非 4 字节对齐写入时需要临时缓冲读回 padding。
// 原实现每次 malloc/free,OTA 收流时与非对齐块叠加产生堆抖动。
// 现改为 OTA begin 时一次性 calloc,end/abort 时 free,消除抖动
// 又不增加常驻 BSS(非 OTA 期零占用)。
// ============================================================
static uint8_t *s_align_buf = nullptr;
static constexpr size_t kAlignBufCap = OTA_BLOCK_SIZE + 8;
void Gd32FlashStore::acquireAlignBufForOta()
{
if (!s_align_buf) {
s_align_buf = (uint8_t *)calloc(1, kAlignBufCap);
if (!s_align_buf) {
DEBUG_WARN("OTA", "对齐缓冲分配失败,回退 malloc free=%u",
(unsigned)esp_get_free_heap_size());
}
}
}
void Gd32FlashStore::releaseAlignBufForOta()
{
if (s_align_buf) {
free(s_align_buf);
s_align_buf = nullptr;
}
}
/** @brief 取对齐缓冲。优先用 OTA 期预分配;未分配或太小则临时 malloc。 */
static uint8_t *acquireAlignBuf(size_t needed)
{
if (s_align_buf && needed <= kAlignBufCap) {
return s_align_buf;
}
// OTA 未预分配(begin 失败)或包过大:回退临时 malloc
return (uint8_t *)malloc(needed);
}
/** @brief 归还对齐缓冲。OTA 池的不释放;临时 malloc 的 free。 */
static void releaseAlignBuf(uint8_t *buf)
{
if (buf && buf != s_align_buf) {
free(buf);
}
}
/** @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;
@@ -86,24 +216,33 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
size_t aligned_len = (total + 3) & ~(size_t)3;
if (aligned_offset + aligned_len > part->size) {
DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu aligned_off=%" PRIu32 " aligned_len=%zu part=%" PRIu32,
offset, len, aligned_offset, aligned_len, part->size);
DEBUG_ERR("OTA", "flashWrite越界 off=%" PRIu32 " n=%zu part=%" PRIu32,
offset, len, part->size);
return ESP_ERR_INVALID_SIZE;
}
// 按需擦除目标扇区(与收流交织;prepare 不再整区预擦)
esp_err_t erase_err = ensureErasedForWrite(aligned_offset, aligned_len);
if (erase_err != ESP_OK) {
return erase_err;
}
// 快速路径:offset 和 len 都对齐
if (head_pad == 0 && len % 4 == 0) {
return esp_partition_write(part, offset, data, len);
}
if (head_pad > 0 || len % 4 != 0) {
DEBUG_WARN("OTA", "flashWriteChunk 非对齐: offset=%" PRIu32 " len=%zu head=%zu aligned_len=%zu",
offset, len, head_pad, aligned_len);
DEBUG_WARN("OTA", "flashWrite非对齐 off=%" PRIu32 " n=%zu head=%zu",
offset, len, head_pad);
}
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
/* OTA 期按需对齐缓冲(OTA begin 时 calloc,end/abort 时 free):
* 消除每非对齐块的 malloc/free 抖动,又不增加常驻 BSS。
* 容量 = OTA_BLOCK_SIZE(4KB) + 最多 3 字节 tail pad。 */
uint8_t *aligned_buf = acquireAlignBuf(aligned_len);
if (!aligned_buf) {
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
DEBUG_ERR("OTA", "flashWriteChunk 对齐缓冲不足/未分配: %zu", aligned_len);
return ESP_ERR_NO_MEM;
}
@@ -113,7 +252,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s",
aligned_offset, head_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
@@ -128,21 +267,35 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s",
aligned_offset + total, tail_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len);
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
/** @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);
}
@@ -155,7 +308,27 @@ esp_err_t Gd32FlashStore::erasePartition(uint32_t size)
DEBUG_ERR("OTA", "erasePartition: size=%" PRIu32 " > partition=%" PRIu32, size, part->size);
return ESP_ERR_INVALID_SIZE;
}
return esp_partition_erase_range(part, 0, size);
// 按扇区擦除并喂狗/yield,避免大块 erase 长时间占住调用任务饿死 WiFi
uint32_t done = 0;
while (done < size) {
uint32_t chunk = kFlashSectorSize;
if (done + chunk > size) {
chunk = size - done;
}
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;
if (done > m_erasedUpTo) {
m_erasedUpTo = done;
}
(void)esp_task_wdt_reset();
taskYIELD();
}
return ESP_OK;
}
// ============================================================
@@ -279,7 +452,7 @@ esp_err_t Gd32FlashStore::metaUpdate(uint32_t stored_size, uint32_t last_seq)
/** @brief OTA 完成或中止时清除元数据(全零写入)。 */
esp_err_t Gd32FlashStore::metaClear()
{
DEBUG_INFO("OTA", "清除 OTA 元数据");
DEBUG_DBG("OTA", "清除 OTA 元数据");
bool locked = lockNvs(kNvsLockTimeoutMs);
if (!locked) {
DEBUG_WARN("OTA", "metaClear 加锁超时");
@@ -307,43 +480,49 @@ esp_err_t Gd32FlashStore::computeFlashSha256(uint8_t out_sha256[32])
return ESP_ERR_INVALID_STATE;
}
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
if (mbedtls_sha256_starts(&ctx, 0) != 0) {
mbedtls_sha256_free(&ctx);
/* static context:OTA 串行;勿放 httpd 栈(上传 100% 后 SHA 易栈溢出) */
static mbedtls_sha256_context s_sha_ctx;
mbedtls_sha256_init(&s_sha_ctx);
if (mbedtls_sha256_starts(&s_sha_ctx, 0) != 0) {
mbedtls_sha256_free(&s_sha_ctx);
return ESP_FAIL;
}
static constexpr size_t kChunkSize = 1024;
uint8_t *buf = (uint8_t *)malloc(kChunkSize);
if (!buf) {
mbedtls_sha256_free(&ctx);
mbedtls_sha256_free(&s_sha_ctx);
return ESP_ERR_NO_MEM;
}
uint32_t remaining = meta.total_size;
uint32_t offset = 0;
uint32_t wdt_chunk = 0;
while (remaining > 0) {
size_t to_read = (remaining > kChunkSize) ? kChunkSize : remaining;
err = flashReadChunk(offset, buf, to_read);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "computeSha256: Flash 读取失败 offset=%" PRIu32, offset);
free(buf);
mbedtls_sha256_free(&ctx);
mbedtls_sha256_free(&s_sha_ctx);
return err;
}
if (mbedtls_sha256_update(&ctx, buf, to_read) != 0) {
if (mbedtls_sha256_update(&s_sha_ctx, buf, to_read) != 0) {
free(buf);
mbedtls_sha256_free(&ctx);
mbedtls_sha256_free(&s_sha_ctx);
return ESP_FAIL;
}
offset += to_read;
remaining -= to_read;
if ((++wdt_chunk & 0x0FU) == 0U) {
(void)esp_task_wdt_reset();
taskYIELD();
}
}
free(buf);
int ret = mbedtls_sha256_finish(&ctx, out_sha256);
mbedtls_sha256_free(&ctx);
int ret = mbedtls_sha256_finish(&s_sha_ctx, out_sha256);
mbedtls_sha256_free(&s_sha_ctx);
return (ret == 0) ? ESP_OK : ESP_FAIL;
}
@@ -383,10 +562,10 @@ esp_err_t Gd32FlashStore::metaVerifyChecksum()
// 检查 checksum 是否全零(未计算过)
// 注意:返回 INVALID_STATE 而非 ESP_OK,由调用方决定是否容忍。
// 历史上这里直接返回 OK 跳过校验,但 meta 被意外清零时会掩盖固件损坏。
// 若直接返回 OK 跳过校验,meta 被意外清零时会掩盖固件损坏。
static const uint8_t kZeroSha[32] = {0};
if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) {
DEBUG_WARN("OTA", "metaVerify: checksum 未计算,返回 INVALID_STATE(调用方决定容忍或失败)");
DEBUG_WARN("OTA", "metaVerify: checksum未算→INVALID_STATE");
return ESP_ERR_INVALID_STATE;
}
@@ -418,14 +597,20 @@ esp_err_t Gd32FlashStore::verifyFrontendSha(const uint8_t frontend_sha32[32],
{
if (!frontend_sha32) return ESP_ERR_INVALID_ARG;
// 1) 计算设备端 SHA 并写入 meta.checksum
esp_err_t err = metaFinalizeChecksum();
/* 上传收尾 finishGd32StorageAfterReceive 通常已 metaFinalizeChecksum(并打过 SHA 日志)。
* relay 触发时若 checksum 已有,直接比对,避免整盘重算 + 再打一遍「固件 SHA-256」。 */
ota_meta_t meta;
esp_err_t err = metaLoad(&meta);
if (err != ESP_OK) return err;
// 2) 读回 meta 拿到 checksum
ota_meta_t meta;
err = metaLoad(&meta);
if (err != ESP_OK) return err;
static const uint8_t kZeroSha[32] = {0};
if (memcmp(meta.checksum, kZeroSha, sizeof(meta.checksum)) == 0) {
err = metaFinalizeChecksum();
if (err != ESP_OK) return err;
err = metaLoad(&meta);
if (err != ESP_OK) return err;
}
if (out_device_sha) memcpy(out_device_sha, meta.checksum, 32);
// 3) 比对前端 SHA 与设备端 SHA
@@ -434,3 +619,56 @@ esp_err_t Gd32FlashStore::verifyFrontendSha(const uint8_t frontend_sha32[32],
}
return ESP_OK;
}
// ============================================================
// 包级身份校验(向量表内容判定)
// ============================================================
/* GD32E508 Flash 布局常量,与 GD32E50X_BOOT sys/config.h 对齐:
* BOOT 0x08000000 ~ 0x08011FFF(72KB)
* APP 0x08048000 ~ 0x0807DFFB(APPLATEST_START + APP_MAX_SIZE,216KB-4)
* SRAM 0x20000000 ~ 0x2001FFFF(128KB)
* APP 固件链接基址 0x08048000、BOOT 链接基址 0x08000000,
* 两者向量表第二字(Reset_Handler)必然落在各自区域内,据此区分包类型。
* 注意:BOOT 侧 validateApplication() 使用同一判据,修改需两边同步。 */
static constexpr uint32_t kGd32BootRegionStart = 0x08000000U;
static constexpr uint32_t kGd32BootRegionEnd = 0x08012000U;
static constexpr uint32_t kGd32AppRegionStart = 0x08048000U;
static constexpr uint32_t kGd32AppRegionEnd = 0x08048000U + 0x00035FFCU;
static constexpr uint32_t kGd32SramStart = 0x20000000U;
static constexpr uint32_t kGd32SramEnd = 0x20020000U;
static inline uint32_t rd_le32(const uint8_t *p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
Gd32ImageIdentity Gd32FlashStore::validateStoredImageIdentity()
{
uint8_t vec[8] = {0};
if (flashReadChunk(0, vec, sizeof(vec)) != ESP_OK) {
DEBUG_ERR("OTA", "身份校验:读取向量表失败");
return Gd32ImageIdentity::kReadFailed;
}
const uint32_t msp = rd_le32(&vec[0]);
const uint32_t reset = rd_le32(&vec[4]) & ~1U; // 清除 Thumb 位
const bool msp_valid = (msp >= kGd32SramStart && msp < kGd32SramEnd);
const bool reset_is_app = (reset >= kGd32AppRegionStart && reset < kGd32AppRegionEnd);
const bool reset_is_boot = (reset >= kGd32BootRegionStart && reset < kGd32BootRegionEnd);
if (msp_valid && reset_is_app) {
return Gd32ImageIdentity::kApp;
}
if (reset_is_boot) {
DEBUG_ERR("OTA", "身份校验:检测到 Bootloader 包 (MSP=0x%08lX RH=0x%08lX),禁止刷入 APP 区",
(unsigned long)msp, (unsigned long)reset);
return Gd32ImageIdentity::kBootloader;
}
DEBUG_ERR("OTA", "身份校验:向量表非法 (MSP=0x%08lX RH=0x%08lX),非 GD32 APP 固件",
(unsigned long)msp, (unsigned long)reset);
return Gd32ImageIdentity::kInvalid;
}
+49 -7
View File
@@ -1,9 +1,8 @@
/*
* gd32_flash_store.h - GD32 OTA 固件 Flash 存储抽象层
*
* 从 OtaRelay 中剥离的 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验职责。
* 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储,
* 不再由 OtaRelay 承担存储职责(解决 H1 职责超载)。
* 封装 Flash 分区读写、NVS 元数据持久化和 SHA-256 校验。
* 传输层(WiFi/BLE)和 OtaRelay 均通过本类操作 GD32 固件存储。
*
* 线程模型:
* - 通过 instance() 单例访问,NVS 元数据读写由内部 mutex 保护
@@ -29,6 +28,21 @@ typedef struct {
uint8_t checksum[32]; ///< 固件 SHA-256 校验值
} ota_meta_t;
/**
* @brief gd32_ota 分区中存储镜像的身份分类(包级防呆)。
*
* 判定依据是镜像自身内容(Cortex-M 向量表前两个字),不依赖文件名:
* SHA-256 只能证明"传对了",不能证明"传的是对的包"。
* 把 BOOT 包(或任意非 APP 包)中继进 GD32 APP 区会擦掉好固件,
* 因此触发中继前必须确认镜像身份为 kApp。
*/
enum class Gd32ImageIdentity : uint8_t {
kApp = 0, ///< 复位向量落在 APP 区 → 合法 GD32 APP 固件
kBootloader, ///< 复位向量落在 BOOT 区(0x08000000~0x08011FFF)→ Bootloader 包,禁止刷入 APP
kInvalid, ///< 向量表非法 → 非 GD32 APP 固件(ESP32 镜像 / 文本 / 损坏数据)
kReadFailed, ///< 分区读取失败,无法判定
};
/**
* @brief GD32 OTA 固件 Flash 存储抽象层(纯静态工具类)。
*
@@ -71,6 +85,13 @@ public:
*/
esp_err_t erasePartition(uint32_t size);
/**
* @brief 新一轮 GD32 固件写入会话开始时调用:复位擦除水位。
* @param full_erase true=强制擦除整个分区(motor OTA 用,杜绝旧固件残留导致的位错误);
* false=只重置水位,由 flashWriteChunk 按需擦扇区(GD32 OTA 用,与收流交织)。
*/
void beginWriteSession(bool full_erase = false);
// ── NVS 元数据持久化 ──
/**
@@ -133,12 +154,29 @@ public:
esp_err_t verifyFrontendSha(const uint8_t frontend_sha32[32],
uint8_t out_device_sha[32]);
/**
* @brief 读取 gd32_ota 分区镜像开头的 Cortex-M 向量表并判定包身份。
*
* 检查初始 MSP 是否落在 GD32E508 SRAM、复位向量是否落在 APP 区
* (0x08048000~0x0807DFFB,与 BOOT 侧 validateApplication() 同一判据)。
* 复位向量落在 BOOT 区(0x08000000~0x08011FFF)则判为 Bootloader 包。
* 供中继触发前的包级防呆使用(WiFi /ota/relay 与 BLE 0xF7 共用)。
* @return 镜像身份分类;调用方仅应在 kApp 时允许中继。
*/
Gd32ImageIdentity validateStoredImageIdentity();
/**
* @brief 获取分区指针(懒初始化)。
* @return 分区指针,找不到时返回 nullptr。
*/
const esp_partition_t* getPartition();
// ── OTA 期对齐缓冲生命周期 ──
/** @brief GD32 OTA begin 时调用:预分配对齐缓冲(消除 flashWriteChunk 逐块 malloc)。 */
static void acquireAlignBufForOta();
/** @brief GD32 OTA end/abort 时调用:释放对齐缓冲(非 OTA 期零占用)。 */
static void releaseAlignBufForOta();
private:
Gd32FlashStore();
~Gd32FlashStore() = default;
@@ -147,15 +185,19 @@ private:
const esp_partition_t *m_partition = nullptr; ///< gd32_ota 分区指针(懒初始化)
// ── NVS 互斥锁(保护元数据读-改-写原子性)──
// lockNvs 返回是否成功 take;unlockNvs(handle) 据此决定是否 give,
// 参考 OtaCoordinator::lock/unlock 模式(避免共享 bool 被并发覆盖导致死锁)。
// ── 擦除水位: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);
// NVS 互斥:lockNvs 返回 take 是否成功;unlockNvs(handle) 仅成功时 give
SemaphoreHandle_t m_nvsMutex = nullptr;
bool lockNvs(uint32_t timeout_ms);
void unlockNvs(bool handle);
static constexpr uint32_t kNvsLockTimeoutMs = 100; ///< NVS 加锁超时(ms)
// 已持锁版本:由持锁的公开方法或 metaUpdate 调用,不再自行加锁,避免递归 take 死锁
// 已持锁版本(调用方已持 m_nvsMutex)
esp_err_t metaLoadLocked(ota_meta_t *meta);
esp_err_t metaSaveLocked(const ota_meta_t *meta);
};
+407 -50
View File
@@ -1,19 +1,54 @@
/*
* canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取)
* canfd_bridge.cpp - CANFD 3通道通信桥接实现
*
* SPI 业务类型:
* 0xC0 发 CAN 帧(canFdSend)
* 0xC1 反馈页解析(canFdParseFeedbackPage;由 GD32 主动推送,本文件不轮询)
* 0xC2 设 RX 模式(canFdSetRxMode)
* 0xC8 设通道收发器 Silent(canFdSetTxSilent,只听不发)
*
* 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"
#include "spi_master.h"
#include "monitor/debug_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
namespace {
constexpr uint8_t kSpiCanFrameType = 0xC0U;
constexpr uint8_t kSpiCanFrameType = 0xC0U;
constexpr uint8_t kSpiCanBurstType = 0xC7U;
constexpr uint8_t kSpiCanSetModeType = 0xC2U;
constexpr uint8_t kSpiCanSetSilentType = 0xC8U;
constexpr uint8_t kFeedbackFixedHeader = 28U;
constexpr uint8_t kFeedbackMaxPage = 1U; /* GD32 仅 page 0/1 */
/* 0xC1 收到链路层 LINK_ERROR(0x7F) 的限流标记:见 canFdQueryFeedback。
* 一轮"拒绝风暴"(同一成因:GD32 刚复位、序号/session 对不上)只报第一条 WARN,
* 其后降级 DBG;任一次查询成功即复位,下一轮风暴仍能报出来。 */
static bool s_linkErrLogged = false;
/* 突发 payload 上限 = V2_MAX_PAYLOAD(236);头 4B = count+dlc+suffix(2)。 */
constexpr uint8_t kBurstHeaderLen = 4U;
constexpr uint16_t kBurstPayloadCap = 236U;
/**
* @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,
uint32_t timeout_ms)
uint32_t timeout_ms,
uint8_t *gd32_err_out)
{
uint8_t tx_buf[72];
size_t tx_len = 0;
@@ -23,76 +58,239 @@ 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;
/* 两步法:otaTransfer 发命令(丢弃延迟一拍的过期响应)+ otaPollResponse 读真实响应。
* CAN 帧非幂等,命令只发一次,不重发。 */
esp_err_t ret = SpiMaster::instance().otaSendAndPoll(
kSpiCanFrameType,
tx_buf, tx_len,
&resp_type, recv_buf, recv_max, &resp_len, timeout_ms);
esp_err_t ret = ESP_OK;
/* 0xC0 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
* 喂数(0xD1→WAIT)残留在 SPI 响应管道里的陈旧帧(0xC0 命令本身永不产生 WAIT)。
* 陈旧 WAIT 会被下方误判为 ERROR → resp_len=0 → gd32_err=0xFF → 无声拒绝发送(
* 表现为“失败一次就再也发不出去”)。此处最多重试 4 次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。
* SPI 传输失败/无响应的重发已由 SpiMaster::otaSendAndPoll 统一处理
* (同 TX_SEQ,最多 3 次尝试),此处不再各自重试;GD32 拒绝(ERROR)同样不重试。 */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
ret = SpiMaster::instance().transact(
kSpiCanFrameType,
tx_buf, tx_len,
&resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms);
if (resp_type != SPI_OTA_RSP_WAIT) {
break; /* 已拿到真正响应(ACK/ERROR);SPI 失败(ret!=OK)也退出交上层处理 */
}
vTaskDelay(pdMS_TO_TICKS(3)); /* 让 GD32 更新响应,再读一次排空陈旧 WAIT */
}
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));
return -2; /* SPI 通信失败 */
/* 同类错误节流日志,降低高频失败时的日志开销 */
static uint32_t s_spi_fail_n = 0U;
static int s_last_spi_err = 0;
const int err_i = static_cast<int>(ret);
if (s_spi_fail_n == 0U || err_i != s_last_spi_err || (s_spi_fail_n % 32U) == 0U) {
DEBUG_WARN("CAN", "SEND SPI fail ch=%u id=%08lX %s x%lu",
(unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret),
(unsigned long)(s_spi_fail_n + 1U));
}
s_last_spi_err = err_i;
++s_spi_fail_n;
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) {
return static_cast<int>(resp_len);
}
const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max;
return static_cast<int>(copied_len);
}
/* resp_type == ERROR: 读取 GD32 返回的错误码 */
uint8_t gd32_err = (resp_len > 0 && recv_buf) ? recv_buf[0] : 0xFF;
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 */
/* 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;
}
{
static uint32_t s_rej_n = 0U;
static uint8_t s_last_rej = 0xFFU;
if (s_rej_n == 0U || gd32_err != s_last_rej || (s_rej_n % 32U) == 0U) {
DEBUG_WARN("CAN", "SEND拒 ch=%u id=%08lX r=%02X e=%02X x%lu",
(unsigned)channel, (unsigned long)ext_id, resp_type, gd32_err,
(unsigned long)(s_rej_n + 1U));
}
s_last_rej = gd32_err;
++s_rej_n;
}
return -3;
}
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr uint8_t kSpiCanSetModeType = 0xC2U;
constexpr size_t kFeedbackPayloadLen = 123U; // 与 GD32 协议表对齐 (kTxBufSize=128 - overhead=5)
static inline uint32_t readU32Le(const uint8_t *p) {
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
/** @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)
| (static_cast<uint32_t>(p[3]) << 24);
}
} // namespace
/**
* @brief 发送一帧 CANFD 数据,可选等待并读取响应。
* 通过 SPI 协议帧发送给 GD32 APP,并读取 ACK/ERROR 响应。
* 返回值: >=0 成功(接收数据长度), -1 参数错误, -2 SPI通信失败, -3 GD32拒绝。
*
* 返回值: >=0 成功(接收数据长度), -1 参数错误, -2 SPI通信失败, -3 GD32拒绝。
* gd32_err_out 在返回 -3 时输出 GD32 细分错误码,其它情况清零。
* dlc==0 时允许 data 为 nullptr(仅发 ID 的远程帧/空数据帧)。
*/
int canFdSend(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc,
uint8_t *recv_buf, uint8_t recv_max,
uint32_t timeout_ms)
uint32_t timeout_ms,
uint8_t *gd32_err_out)
{
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
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);
/* 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 给定统一 DLC,返回单次突发最多可容纳的帧数。
* payload = 4B 头 + count*(1+dlc),须 ≤ 236B;并受 GD32 硬上限约束。
*/
uint8_t canBurstCapacity(uint8_t dlc)
{
if (dlc > CAN_MAX_DLC) {
return 0U;
}
const uint16_t entry_len = static_cast<uint16_t>(1U + dlc);
const uint16_t avail = (kBurstPayloadCap > kBurstHeaderLen)
? static_cast<uint16_t>(kBurstPayloadCap - kBurstHeaderLen)
: 0U;
const uint16_t cap = static_cast<uint16_t>(avail / entry_len);
return static_cast<uint8_t>((cap < CAN_BURST_HARD_MAX) ? cap : CAN_BURST_HARD_MAX);
}
/**
* @brief 发送一次 CAN 突发(SPI 0xC7)。
*
* GD32 在 CS 释放 ISR 内对每帧调用非阻塞 transmitFrame(仅装载邮箱),
* 全部装载完成后才回 ACK,因此各通道帧近同时上线;ESP32 这里等待的只是
* 这一个 ACK,并非逐帧等待,不会破坏组内同时性。
*
* 返回值: count 成功, -1 参数错误, -2 SPI 通信失败, -3 GD32 拒绝。
*/
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
const can_burst_entry_t *entries, uint8_t count,
uint8_t *status_out, uint8_t status_max,
uint32_t timeout_ms, uint8_t *gd32_err_out)
{
if (gd32_err_out) {
*gd32_err_out = 0U;
}
if (dlc > CAN_MAX_DLC || entries == nullptr || count == 0U) {
return -1;
}
if (count > canBurstCapacity(dlc)) {
return -1;
}
uint8_t tx_buf[240];
size_t tx_len = 0U;
tx_buf[tx_len++] = count;
tx_buf[tx_len++] = dlc;
tx_buf[tx_len++] = static_cast<uint8_t>((id_suffix >> 8) & 0xFFU);
tx_buf[tx_len++] = static_cast<uint8_t>(id_suffix & 0xFFU);
for (uint8_t i = 0U; i < count; ++i) {
tx_buf[tx_len++] = entries[i].chnode;
if (dlc > 0U) {
if (entries[i].data == nullptr) {
return -1;
}
memcpy(&tx_buf[tx_len], entries[i].data, dlc);
tx_len += dlc;
}
}
uint8_t resp[32];
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = ESP_OK;
/* 与 0xC0 相同:排空可能残留的陈旧 WAIT,直到读到本次真正的 ACK/ERROR */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
ret = SpiMaster::instance().transact(
kSpiCanBurstType, tx_buf, tx_len,
&resp_type, resp, sizeof(resp), &resp_len, timeout_ms);
if (resp_type != SPI_OTA_RSP_WAIT) {
break;
}
vTaskDelay(pdMS_TO_TICKS(3));
}
if (ret != ESP_OK) {
DEBUG_WARN("CAN", "BURST SPI fail n=%u %s",
(unsigned)count, esp_err_to_name(ret));
return -2;
}
if (resp_type == SPI_OTA_RSP_ACK) {
/* resp[0]=count, resp[1..count]=每帧状态(CanTxResult 码) */
const uint8_t n = (resp_len > 0U) ? resp[0] : 0U;
if (status_out != nullptr && status_max > 0U) {
const uint8_t copy_n = (n < status_max) ? n : status_max;
for (uint8_t i = 0U; i < copy_n; ++i) {
status_out[i] = (static_cast<size_t>(1U + i) < resp_len)
? resp[1U + i] : 0xFFU;
}
}
return static_cast<int>(n);
}
const uint8_t gd32_err = (resp_len > 0U) ? resp[0] : 0xFFU;
if (gd32_err_out) {
*gd32_err_out = gd32_err;
}
DEBUG_WARN("CAN", "BURST拒 n=%u r=%02X e=%02X",
(unsigned)count, resp_type, gd32_err);
return -3;
}
/**
* @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)
@@ -101,8 +299,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;
}
@@ -111,10 +308,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]);
@@ -126,13 +328,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]);
@@ -141,30 +351,123 @@ 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;
// 线协议条目实际大小按原始 dlc 计算(CANFD 帧可能 64B),offset 必须按
// 实际条目推进,否则后续条目错位。data 拷贝按 dataLen 截断。
const uint16_t wireEntrySize = static_cast<uint16_t>(8U + e.dlc);
if (offset + wireEntrySize > len) {
break;
}
memcpy(e.data, &payload[offset + 8U], dataLen);
for (uint8_t d = dataLen; d < CANFD_ID_SNAPSHOT_DATA_MAX; ++d) {
e.data[d] = 0U;
}
offset += entrySize;
offset += wireEntrySize;
(*entryIdx)++;
}
/* 声明了条目但一个都没完整解析 → 截断/坏帧 */
if (entryCount > 0U && *entryIdx == idx_before) {
return -1;
}
out->valid = 1;
return 0;
}
/* ── 0xC1 请求/响应查询(2026-08 架构改造:替代被动推送缓存)── */
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx)
{
if (out == nullptr || entryIdx == nullptr) {
return -1;
}
if (channel >= CAN_CHANNEL_MAX || page > 1U) {
return -1;
}
uint8_t req[2] = { channel, page };
uint8_t resp_type = 0;
/* SPI v2 payload 上限 236B(256 帧 - 20 开销),与 GD32 kFeedbackPayloadLen 一致 */
uint8_t resp_buf[SPI_OTA_FRAME_MAX];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
0xC1, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (ret != ESP_OK) {
static TickType_t s_lastFailWarnTick = 0;
const TickType_t now_tick = xTaskGetTickCount();
if (s_lastFailWarnTick == 0 || (now_tick - s_lastFailWarnTick) >= pdMS_TO_TICKS(5000)) {
s_lastFailWarnTick = now_tick;
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u SPI失败: %s (限频5s)",
(unsigned)channel, (unsigned)page, esp_err_to_name(ret));
}
return -1;
}
if (resp_type == SPI_OTA_RSP_ERROR) {
uint8_t gd32_err = (resp_len > 0) ? resp_buf[0] : 0;
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u GD32拒绝 err=0x%02X",
(unsigned)channel, (unsigned)page, (unsigned)gd32_err);
return -2;
}
if (resp_type != SPI_OTA_RSP_ACK || resp_len < 1U) {
/* 0x7F = 链路层 LINK_ERROR(payload = [错误码, 期望序号])。成因几乎只有一种:
* GD32 刚被复位过(APP→BOOT 交接、BOOT→新 APP 跳转、上电),它的接收序号/session
* 从 0 重来,而 ESP32 仍按旧序号发 —— 于是一帧一条。此刻对面根本没有 CAN 桥
* (BOOT 不提供 0xC1),这些查询注定无果,属纯噪音。
* 一轮风暴只报第一条 WARN(含错误码与期望序号,够定位),其余降级 DBG;
* 成功一次即复位标记,下一轮风暴照样能报。避免 1Hz × 3 通道刷屏。 */
if (resp_type == V2_TYPE_LINK_ERROR) {
const uint8_t errc = (resp_len > 0U) ? resp_buf[0] : 0xFFU;
const uint8_t exp = (resp_len > 1U) ? resp_buf[1] : 0xFFU;
if (!s_linkErrLogged) {
s_linkErrLogged = true;
DEBUG_WARN("CAN", "queryFeedback 链路层拒绝(0x7F code=0x%02X exp=%u):"
"GD32 正在复位/重同步(刷写交接或 BOOT↔APP),同类后续静默",
errc, exp);
} else {
DEBUG_DBG("CAN", "queryFeedback 链路层拒绝(0x7F code=0x%02X exp=%u) 已静默",
errc, exp);
}
} else {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 非ACK type=0x%02X len=%zu",
(unsigned)channel, (unsigned)page, (unsigned)resp_type, resp_len);
}
return -1;
}
s_linkErrLogged = false; /* 通了一次:链路已重同步,标记复位 */
/* 解析复用现有 canFdParseFeedbackPage(payload 格式与推送一致) */
int parse_rc = canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx);
if (parse_rc != 0) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 解析失败 rc=%d len=%zu",
(unsigned)channel, (unsigned)page, parse_rc, resp_len);
return -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;
}
@@ -173,9 +476,8 @@ int canFdSetRxMode(can_rx_mode_t mode,
uint8_t resp_type = 0U;
size_t resp_len = 0U;
/* 两步法破解延迟一拍:发命令一次 + poll 真实响应。
* 0xC2 幂等,但两步法避免无效重试。 */
esp_err_t ret = SpiMaster::instance().otaSendAndPoll(
/* 两步法:发命令一次 + poll 真实响应。0xC2 幂等,但避免无效重试。 */
esp_err_t ret = SpiMaster::instance().transact(
kSpiCanSetModeType,
&req, 1U,
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms);
@@ -186,5 +488,60 @@ int canFdSetRxMode(can_rx_mode_t mode,
}
return 0;
}
DEBUG_WARN("CAN", "SET_MODE fail want=%u %s r=0x%02X n=%u",
(unsigned)mode,
esp_err_to_name(ret),
(unsigned)resp_type,
(unsigned)resp_len);
return -1;
}
/**
* @brief 切换指定 CAN 通道收发器 Silent(SPI 0xC8):只听不发。
* @return 0 成功;-1 参数非法或 SPI/应答失败;-2 GD32 返回 ERROR。
* @note 与 canFdSetRxMode 同款单步事务:GD32 在 CS EXTI ISR 内直接回 ACK/ERROR,
* 不会回 WAIT(区别于 0xC0 那条要排空 OTA 残留 WAIT 的路径)。
* ACK payload = [0]channel [1]实际 silent [2]通道使能位。
*/
int canFdSetTxSilent(uint8_t channel,
bool silent,
uint8_t *actual,
uint32_t timeout_ms)
{
if (channel > 2U) {
DEBUG_WARN("CAN", "SET_SILENT 非法 ch=%u", (unsigned)channel);
return -1;
}
uint8_t req[2] = { channel, static_cast<uint8_t>(silent ? 1U : 0U) };
uint8_t resp_buf[3] = {0};
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = SpiMaster::instance().transact(
kSpiCanSetSilentType,
req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms);
if (ret == ESP_OK && resp_type == SPI_OTA_RSP_ACK && resp_len >= 2U) {
if (actual != nullptr) {
*actual = resp_buf[1];
}
return 0;
}
if (ret == ESP_OK && resp_type == SPI_OTA_RSP_ERROR) {
DEBUG_WARN("CAN", "SET_SILENT ch=%u want=%u GD32 err=0x%02X",
(unsigned)channel, (unsigned)(silent ? 1U : 0U),
(resp_len >= 1U) ? (unsigned)resp_buf[0] : 0U);
return -2;
}
DEBUG_WARN("CAN", "SET_SILENT ch=%u want=%u %s r=0x%02X n=%u",
(unsigned)channel, (unsigned)(silent ? 1U : 0U),
esp_err_to_name(ret),
(unsigned)resp_type,
(unsigned)resp_len);
return -1;
}
+99 -22
View File
@@ -1,7 +1,7 @@
/*
* canfd_bridge.h - CANFD 3通道通信桥接(M4: 从 SpiMaster 提取)
* canfd_bridge.h - CANFD 3通道通信桥接
*
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责解耦。
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责分离。
* 通过 SpiMaster 的公共 SPI 接口完成底层传输。
*/
#ifndef CANFD_BRIDGE_H
@@ -30,7 +30,7 @@ typedef struct {
can_channel_t channel; ///< CAN 通道 (0~2)
uint32_t ext_id; ///< 扩展帧 ID (29bit, Big-Endian 存储)
uint8_t data[CAN_MAX_DLC]; ///< CAN 数据负载 (最大 64 字节)
uint8_t dlc; ///< 数据长度 (1~64)
uint8_t dlc; ///< 数据长度 (0~64)
} can_frame_t;
/**
@@ -38,31 +38,75 @@ 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 超时时间(毫秒)。
* @return 成功时返回接收数据长度,失败返回 -1。
* @param gd32_err_out 可选输出,GD32 返回的细分错误码(0xE0/0xE1/0xE2/0xE3/0xE4/0xE5/0xE6=通道静默拒发/0xE7=该通道被电机 OTA 独占);
* 仅在返回 -3 时有效,其它情况清零。可为 nullptr。
* @return 成功时返回接收数据长度,失败返回 -1/-2/-3。
*/
int canFdSend(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc,
uint8_t *recv_buf, uint8_t recv_max,
uint32_t timeout_ms);
uint32_t timeout_ms,
uint8_t *gd32_err_out = nullptr);
// ============================================================
// CAN 跨通道突发发送(SPI 0xC7)
// 一次 SPI 事务携带多帧,GD32 背靠背装载各通道 CAN 邮箱,
// 组内近同时上线(偏差 <1ms),用于多电机同步驱动。
// ============================================================
#define CAN_BURST_HARD_MAX (16) ///< GD32 单次突发硬上限(帧数)
/** @brief 突发条目:chnode=(ch<<4)|node,data 指向 dlc 字节数据。 */
typedef struct {
uint8_t chnode; ///< (channel<<4) | node
const uint8_t *data; ///< dlc 字节数据
} can_burst_entry_t;
/**
* @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。
* 受 236B SPI payload(4B 头 + count*(1+dlc))与 GD32 硬上限约束。
*/
uint8_t canBurstCapacity(uint8_t dlc);
/**
* @brief 发送一次 CAN 突发(SPI 0xC7)。count 必须 <= canBurstCapacity(dlc)。
* GD32 在 ISR 内对每帧调用非阻塞 transmitFrame,各通道邮箱背靠背装载。
* @param id_suffix CAN ID 低 16 位(CMD=0x0200);id=(node<<16)|suffix。
* @param dlc 所有条目统一数据长度(0~64)。
* @param entries count 个条目(chnode + data)。
* @param count 条目数(1~canBurstCapacity(dlc))。
* @param status_out 输出每帧状态(GD32 CanTxResult 码,0x00=成功),可为 nullptr。
* @param status_max status_out 容量。
* @param timeout_ms SPI 事务超时。
* @param gd32_err_out 返回 -3 时的 GD32 细分错误码;可为 nullptr。
* @return 成功返回 count;-1 参数错误;-2 SPI 通信失败;-3 GD32 ERROR。
*/
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
const can_burst_entry_t *entries, uint8_t count,
uint8_t *status_out, uint8_t status_max,
uint32_t timeout_ms,
uint8_t *gd32_err_out = nullptr);
// ============================================================
// 不重复 ID 快照表(SPI 0xC3 协议配套)
// ============================================================
#define CANFD_ID_SNAPSHOT_MAX 16 ///< 每通道最大不重复 ID 条目数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数(CANFD 最大 64B,保持完整不截断)
/**
* @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;
@@ -71,11 +115,9 @@ typedef struct {
} id_snapshot_entry_t;
// ============================================================
// CAN 接收过滤模式 & 1Hz 反馈快照
// CAN 接收过滤模式 & 反馈快照(GD32 约 1Hz 刷新;ESP32 被动收 0xC1)
// 与 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 做标准帧过滤
@@ -83,7 +125,7 @@ typedef enum {
} can_rx_mode_t;
/**
* @brief 单通道 1Hz 反馈统计。
* @brief 单通道 CAN 反馈统计(快照字段;非 ESP32 侧 1Hz 轮询)。
*/
typedef struct {
uint8_t channel;
@@ -95,7 +137,7 @@ typedef struct {
uint32_t error_count;
uint8_t tec; /**< 硬件发送错误计数器 (0~255) */
uint8_t rec; /**< 硬件接收错误计数器 (0~255) */
uint8_t bus_state; /**< 总线状态位图:bit0=init,bit1=sleep,bit2=error_passive,bit3=bus_off,bit4=error_warning */
uint8_t bus_state; /**< 总线状态位图:bit0=init,bit1=控制器sleep,bit2=error_passive,bit3=bus_off,bit4=error_warning,bit5=tx_silent(收发器Silent,只听不发) */
} can_channel_feedback_t;
/**
@@ -112,27 +154,62 @@ 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 0xC1 请求/响应)。
*
* 2026-08 架构改造:ESP32 不再被动缓存 GD32 推送,改为前端请求时按需查询。
* GD32 handleFeedback 接收 [channel, page] 返回 ACK + 页数据(与推送 payload 格式一致)。
* 查询成功后调 canFdParseFeedbackPage 解析,复用现有累积逻辑。
*
* @param channel 目标通道 0/1/2。
* @param page 页号 0 或 1(page 0 重置累积 + 填统计,page 1 追加 entries)。
* @param out 累积结果(跨 page 0/1 累积,调用方按通道维护)。
* @param entryIdx 当前已填充条目数(page 0 时被重置为 0)。
* @return 0 成功;-1 参数/SPI 失败;-2 GD32 返回 ERROR。
*/
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx);
/**
* @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,
uint32_t timeout_ms);
/**
* @brief 切换指定 CAN 通道收发器的 Silent 模式(SPI 0xC8):只听不发。
* GD32 侧把该通道 TCAN1057AV 的 S 脚置高 → 驱动器关断、**接收机照常工作**
* (手册 Table 8-4:RXD 仍镜像总线),所以静默期间接收与 0xC1 上报不受影响。
* 静默期间该通道所有发送(0xC0/0xC7/桥接/relay)回 0xE6=kErrSilent,不计错误。
* @param channel 目标通道 0/1/2。
* @param silent true=进 Silent,false=回 Normal。
* @param actual 可选输出,GD32 实际生效状态(1=Silent,与其发送门控同一个值)。
* @param timeout_ms SPI 事务超时。
* @return 0 成功,<0 失败(-1 参数/SPI 失败,-2 GD32 返回 ERROR)。
*/
int canFdSetTxSilent(uint8_t channel,
bool silent,
uint8_t *actual,
uint32_t timeout_ms);
#endif // CANFD_BRIDGE_H
+81 -32
View File
@@ -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()
@@ -31,26 +30,47 @@ Protocol& Protocol::instance()
}
/**
* @brief 初始化协议层,打印初始化信息并设置初始化标志。
* @brief 抗优化密钥/敏感缓冲擦除。
*
* 普通 memset 可能被编译器当作「无后续读取」而消除;
* 通过 volatile 逐字节写 0,保证运行时真正清零。
*
* @param p 起始地址;nullptr 忽略。
* @param n 字节数。
*/
void Protocol::secureWipe(void *p, size_t n)
{
if (p == nullptr || n == 0U) {
return;
}
volatile uint8_t *v = static_cast<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;
DEBUG_INFO("PROT", "协议层就绪");
}
/**
@@ -58,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], &timestamp, 4);
memcpy(&state[4], &mem_addr, 4);
memcpy(&state[8], &rand_seed, 4);
@@ -73,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);
}
}
@@ -92,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);
}
+60 -27
View File
@@ -1,75 +1,108 @@
/*
* protocol.h - 帧格式、CRC16、AES 加密解密
* T2.2, T1.3
* ESP-IDF 6.0 适配版本 - 使用硬件 AES-128-CBC + 硬件 RNG
* protocol.h - OTA 握手辅助(nonce 扰码 + 会话密钥生命周期)
*
* Protocol 类封装协议层功能。
* 本模块刻意保持轻量(原「帧格式 / CRC16 / AES-CBC」中间件已迁出):
* - 不负责 SPI Link v2 帧 / CRC32
* - 不负责 AES 加解密(OTA 数据包 AES-ECB 在 ota_relay 内)
* - 仅提供:RNG 扰码常数、server nonce 生成、session key 存清
*
* AES 主密钥 MASTER_KEY[] 见 app_config.h。
*/
#ifndef PROTOCOL_H
#define PROTOCOL_H
#include <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:
/**
* @brief 获取 Protocol 单例实例。
* @brief 获取 Protocol 单例实例。
* @return Protocol 单例引用。
*/
static Protocol& instance();
/**
* @brief 初始化协议层。
* @brief 初始化协议层(幂等)。
*
* 使用硬件 RNG 填充 m_scrambleConst,供 scrambleGenerate 混合使用。
* 可重复调用;已初始化则直接返回。
*/
void init();
/**
* @brief 生成基于时间戳、地址和随机种子的扰码 nonce。
* @param timestamp 时间戳。
* @param mem_addr 内存地址。
* @brief 生成基于时间戳、地址和随机种子的 16 字节 server nonce。
* @param timestamp 时间戳(如 FreeRTOS tick)。
* @param mem_addr 内存地址(增加熵,通常取栈/缓冲区地址)。
* @param rand_seed 随机种子。
* @param nonce_out 输出的 16 字节 nonce 缓冲区。
* @param nonce_out 输出缓冲区,至少 16 字节;为 nullptr 时直接返回。
*
* @note 非标准 KDF,仅作 OTA 握手 nonce,勿当通用密钥派生。
* @note 若尚未 init(),内部会自动调用 init()。
*/
void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr,
uint32_t rand_seed, uint8_t *nonce_out);
uint32_t rand_seed, uint8_t *nonce_out);
/**
* @brief 会话密钥初始化,将密钥拷贝到会话上下文。
* @brief 会话密钥初始化,将密钥拷贝到会话上下文。
* @param sess 会话上下文指针。
* @param k_session 会话密钥数据。
* @param k_session 会话密钥数据(至少 SESSION_KEY_LEN 字节)。
* @return 成功返回 0,参数无效返回 -1。
*/
static int sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session);
/**
* @brief 清除会话上下文中的密钥数据。
* @param sess 会话上下文指针。
* @brief 清除会话上下文中的密钥数据(抗优化擦除)。
* @param sess 会话上下文指针;为 nullptr 时忽略。
*/
static void sessionKeyClear(session_ctx_t *sess);
private:
/**
* @brief 私有默认构造函数。
*/
Protocol() = default;
/**
* @brief 私有析构函数。
*/
~Protocol() = default;
/**
* @brief 禁止复制构造。
*/
Protocol(const Protocol&) = delete;
/**
* @brief 禁止复制赋值。
*/
Protocol& operator=(const Protocol&) = delete;
uint8_t m_scrambleConst[SCRAMBLE_CONST_LEN] = {0};
bool m_initialized = false;
/**
* @brief 抗编译器优化的内存擦除(volatile 逐字节写 0)。
* @param p 起始地址;nullptr 忽略。
* @param n 字节数。
*/
static void secureWipe(void *p, size_t n);
uint8_t m_scrambleConst[SCRAMBLE_CONST_LEN] = {0}; ///< per-boot 扰码常数(init 时 RNG 填充)
bool m_initialized = false; ///< 是否已 init
};
#endif // PROTOCOL_H
#endif // PROTOCOL_H
File diff suppressed because it is too large Load Diff
+411 -96
View File
@@ -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
@@ -19,31 +17,24 @@
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#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 同步帧最大字节数
// ESP32 下拉 SPIready 后到拉 CS 之间的微秒延时(零延时起步,按需测算调大)
#define SPI_READY_DRIVE_DELAY_US (0)
// SPI 时钟来自 menuconfig;其余可调项在 app_config.h
#define SPI_CLOCK_HZ CONFIG_DCU_SPI_CLOCK_HZ
// GD32 SPI 同步帧类型
#define SPI_SYNC_SOF0 (0xA5)
#define SPI_SYNC_SOF1 (0x5A)
// GD32 SPI 业务 TYPE(与 SpiDefs / v2 TYPE 字段一致)
#define SPI_OTA_CMD_REQ (0x01)
#define SPI_OTA_CMD_DATA (0x02)
#define SPI_OTA_CMD_CANCEL (0x03) /* 中止 OTA 会话 → GD32 resetToIdle */
#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)
// CANFD 类型已迁移至 transport/canfd_bridge.h(M4 解耦)
// GD32 版本信息(从 APP PING 响应解析)
typedef struct {
uint16_t app_major;
@@ -56,6 +47,26 @@ typedef struct {
uint16_t boot_build;
} gd32_version_t;
/* GD32 APP init_state 编码(身份块 [15] 与 0xA3 payload[9],见 spi_link_v2.h) */
#define GD32_INIT_STATE_OK (0x00) /* 全部模块初始化成功 */
#define GD32_INIT_STATE_DEGRADED (0x01) /* critical 全成,非 critical 有失败/跳过 */
#define GD32_INIT_STATE_CRITICAL_FAIL (0x02) /* 有 critical 模块失败 */
#define GD32_INIT_STATE_UNKNOWN (0xFF) /* 本端尚未收到身份块 */
// GD32 APP 初始化状态详情(SPI 0xA3 INIT_STATUS 响应解析结果)
#define GD32_INIT_MODULE_COUNT 13 /* 模块数(fault_codes 长度,与 DCU_APP kModuleCount 一致) */
typedef struct {
uint32_t module_mask; /* 成功初始化的模块位图(DCU_APP AppInitializer::ModuleBit) */
uint32_t critical_fail_mask; /* 失败的 critical 模块位图(0=无 critical 失败) */
uint8_t reset_cause; /* 复位源位图(bit0 POR/bit1 EPR/bit2 FWDGT/bit3 WWDGT/bit4 SW/bit5 LP) */
uint8_t init_state; /* GD32_INIT_STATE_* */
/* 每模块 1B 故障码(GD32 1.5.36+,响应 25B 时填充;0=成功。
* 编码见 DCU_APP sys/initial.h;canfd = 原因码|(首个失败通道<<4)) */
uint8_t fault_codes[GD32_INIT_MODULE_COUNT];
bool has_fault_codes; /* fault_codes 是否有效(响应 ≥25B) */
bool valid; /* 本结构是否已成功查询填充 */
} gd32_init_status_t;
/**
* @brief SPI Master 驱动类(单例)
*
@@ -76,18 +87,13 @@ public:
// SPI OTA 协议
// ============================================================
/** @brief 按 GD32 SPI OTA 最小协议执行一次请求 + 响应读取。 */
esp_err_t otaTransfer(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);
/** @brief 仅执行 SPI OTA 响应读取事务,不重复发送请求帧。 */
esp_err_t otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
/** @brief ESP32→GD32 发送命令 + 轮询读取真实响应(两步法,原子持锁)。
*
* 解决 GD32 "延迟一拍"协议:otaTransfer 首笔 CS 读到过期响应,
* 解决 GD32 "延迟一拍"协议:transact 首笔 CS 读到过期响应,
* otaPollResponse 读取本笔命令的真实响应。命令只发送一次,不重发。
* 适用于所有 ESP32→GD32 方向的命令(CAN 发送/模式切换/ID 快照等)。
*
@@ -108,47 +114,218 @@ public:
/** @brief 读取对端最新上报的 CREDIT(0 表示对端忙,应 POLL 等待而非重发)。 */
uint8_t remoteCredit() const { return m_remoteCredit; }
/** @brief 读取本端剩余可接收 credit(SPEC §10.1,/system/status 展示用)。 */
uint8_t localCredit() const { return m_localCredit; }
/** @brief credit 窗口复位到初始值(电机 OTA 取消等半会话终止后调用)。
* credit 只随帧头传递:对端转空闲后不再发帧,传输期锁存的 credit=0
* 无法自愈 → 所有 transact 被 CREDIT 背压拒绝(死锁)。
* GD32 端 resetToIdle 已恢复接收窗口(clearOtaSessionQueues 置满),
* 本侧同步复位与之对齐;此后每帧帧头 credit 会覆盖为真实值。 */
void resetCredit() { m_localCredit = 8; m_remoteCredit = 8; }
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
/** @brief 最近一次 worker job 实际执行耗时(µs,worker 内计时)。
* 测速页用 RTT − 本值拆出"提交/调度/唤醒"开销,定位抖动归属。 */
uint32_t lastWorkerExecUs() const { return m_lastWorkerExecUs; }
/**
* @brief 单次 transact 的分段耗时剖析(测速页诊断用)。
* 5 段:提交/调度(t_submit0→t_w0)、发送帧(t_send0→t_send1)、
* 等应答(t_wait0→t_wait1)、读响应(t_wait1→t_read1)、唤醒(t_w1→t_submit1)。
* 写入顺序由 done 信号量保证 happens-before(worker 先写全再 give,调用方 take 后读)。
*/
struct SpiProfile {
uint64_t t_submit0 = 0; ///< 调用方 transact 入口
uint64_t t_w0 = 0; ///< worker executeJob 开始
uint64_t t_send0 = 0; ///< 发送帧开始
uint64_t t_send1 = 0; ///< 发送帧结束
uint64_t t_wait0 = 0; ///< 进入 poll(等 READY)
uint64_t t_wait1 = 0; ///< READY 就绪(wait 段结束)
uint64_t t_read1 = 0; ///< 响应帧读取完成
uint64_t t_w1 = 0; ///< worker executeJob 结束
uint64_t t_submit1 = 0; ///< 调用方 submitJob 返回
};
/** @brief 最近一次 transact 的剖析快照(仅测速/诊断用)。 */
SpiProfile lastProfile() const { return m_lastProfile; }
/** @brief SPI 任务队列当前待处理 job 数(固定 Job pool 槽,无堆分配)。
* 供 /system/heap_low_log 堆低水位快照采样,定位"低谷是不是 SPI 队列堆积引起"。 */
UBaseType_t getPendingJobCount() const { return m_jobQueue ? uxQueueMessagesWaiting(m_jobQueue) : 0; }
/**
* @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。
* 中继开始设 true,结束/复位设 false。
* 实现为引用计数:GD32 中继与电机 OTA feed 可并发持有,任一持有者
* 退出都不会提前解除抑制(避免电机 OTA 收尾把 GD32 中继的抑制误清)。
*/
void setSuppressRingService(bool suppress);
/**
* @brief ESP32 自我 OTA 期间暂停全部 SPI 活动(GD32 按铃读 + 业务 transact)。
* 暂停后 worker 空转不读 SPI,submitJob 快速返回 ESP_ERR_INVALID_STATE
* (业务立即失败,不阻塞排队);恢复时唤醒 worker 恢复正常。幂等。
*/
void setSpiPaused(bool paused);
/**
* @brief 业务层确认当前请求已成功时推进/对齐 m_txSeq。
*/
void confirmTxSeqAccepted();
/**
* @brief 统一请求入口:经 SPI worker 串行执行(send+poll)。
*/
esp_err_t transact(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);
/**
* @brief 取出 GD32 EVENT 帧(worker 已读入事件队列)。
* @param timeout_ms 等待超时(ms)。0 = 非阻塞(原语义)。
* @return true 取到事件;false 队列空且超时未到达。
*/
bool takeEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len,
uint32_t timeout_ms = 0U);
// ============================================================
// SPI Link v2 链路层(阶段1:帧格式 + 状态成员就位)
// ============================================================
/** @brief v2 链路状态统计(SPEC/spi_link_v2.md §12)。 */
struct SpiLinkStats {
uint32_t crc_errors; ///< CRC 校验失败计数
uint32_t duplicate_requests; ///< 重复请求计数(序号回退)
uint32_t seq_errors; ///< 序号跳变计数
uint32_t retries; ///< 重试总数
uint32_t ready_timeouts; ///< DATA_READY 超时计数
uint32_t rx_overruns; ///< RX overrun 计数
uint32_t resyncs; ///< HELLO 重同步次数
uint32_t max_resp_latency_us; ///< 最大响应延迟(微秒)
/* 0.4.3 诊断:GD32 回 0x82 的量化归因(/system/status 直读,不依赖 /logs) */
uint32_t rsp_err_0x82; ///< 收到 GD32 0x82 ERROR 响应总数
uint8_t rsp_err_last_req; ///< 最近一笔 0x82 对应的主动请求 TYPE
uint8_t rsp_err_last_p0; ///< 最近一笔 0x82 的 payload[0](GD32 错误码)
uint8_t rsp_err_last_p1; ///< 0xE8 时=GD32 收到的 rxLen
uint8_t rsp_err_last_p2; ///< 0xE8 时=GD32 rxBuf[0](期望 SOF0=0xA5)
uint8_t rsp_err_last_p3; ///< 0xE8 时=GD32 rxBuf[1](期望 SOF1=0x5A)
uint8_t rsp_err_last_p4; ///< 0xE8 时=GD32 SpiParseResult 解析失败原因枚举
uint8_t rsp_err_last_p5; ///< 0xE8 时=GD32 SOF offset (0=正常, >0=漂移, 255=未找到)
uint32_t ring_steals; ///< serviceRing 偷读迟到 RESPONSE 总数
uint8_t ring_steal_last_type; ///< 最近一笔偷读帧的 TYPE(0.4.5 归因)
uint8_t ring_steal_last_ack; ///< 最近一笔偷读帧 ack_seq 是否匹配 lastSent
uint8_t ring_steal_last_p0; ///< 最近一笔偷读帧 payload[0](0.4.6)
uint8_t ring_steal_last_req; ///< 偷读时刻的 m_lastReqType(0.4.6)
uint8_t ring_steal_last_p1; ///< 0xE8 时=GD32 诊断 rxLen(0.4.8)
uint8_t ring_steal_last_p2; ///< 0xE8 时=GD32 诊断 rxBuf[0](0.4.8)
uint8_t ring_steal_last_p3; ///< 0xE8 时=GD32 诊断 rxBuf[1](0.4.8)
uint8_t ring_steal_last_p4; ///< 0xE8 时=GD32 诊断 parse_res
uint8_t ring_steal_last_p5; ///< 0xE8 时=GD32 诊断 sof_offset
/* 0.4.9 诊断:透明重试会掩盖真实失败率(DBG 被编译裁掉、无计数)。
* tx_total 进入事务;tx_retry 走到重发;tx_fail 耗尽重试仍失败返回。 */
uint32_t tx_total; ///< otaSendAndPoll 事务总数
uint32_t tx_retry; ///< 因 send/poll 超时触发的重发总次数
uint32_t tx_fail; ///< 重试后仍失败(返回非 OK)的事务数
uint8_t tx_fail_last_type; ///< 最近一笔最终失败的命令 TYPE
};
/** @brief v2 完整链路状态(SPEC §10.1,会话/credit + 统计)。 */
struct SpiLinkStatus {
uint8_t version; ///< 协议版本(固定 2)
uint8_t gd32_role; ///< 0=unknown, 1=app, 2=boot
uint32_t session_id; ///< 当前会话 ID
uint8_t local_credit; ///< 本端剩余 credit
uint8_t remote_credit; ///< 对端剩余 credit
SpiLinkStats stats; ///< 8 个统计计数器
};
/** @brief 当前会话 ID(0 = 未建立 session)。 */
uint32_t sessionId() const { return m_sessionId; }
/** @brief v2 session 是否已激活。 */
bool v2Active() const { return m_v2Active; }
/** @brief SPI 链路是否判定在线(连续失败未触发熔断,防 SPI 不通时死循环阻塞与堆栈耗尽)。 */
bool isLinkOnline() const { return m_linkOnline; }
/** @brief 当前连续失败次数。 */
uint32_t consecutiveFailures() const { return m_consecutiveFailures; }
/** @brief 检查当前是否允许发起常规业务 SPI 事务(离线熔断冷却期内快速失败)。 */
bool allowRegularTransact(uint8_t type) const;
/** @brief 最近一次 HELLO 得到的 GD32 角色(0=unknown, 1=app, 2=boot)。 */
uint8_t gd32Role() const { return m_gd32Role; }
/**
* @brief 是否已从 HELLO_ACK/PING 身份块拿到功能位图。
* @note false=尚未建连身份或旧固件。
*/
bool gd32FeatKnown() const { return m_gd32FeatKnown; }
/** @brief GD32 功能位图(V2_FEAT_*);未知时为 0。 */
uint32_t gd32FeatFlags() const { return m_gd32FeatFlags; }
/** @brief 便捷:GD32 是否开启 ADC 0xC4(需 gd32FeatKnown)。 */
bool gd32FeatAdcFeedback() const {
return m_gd32FeatKnown && (m_gd32FeatFlags & V2_FEAT_ADC_FEEDBACK) != 0U;
}
/** @brief 是否已从 HELLO_ACK/PING 拿到 APP/BOOT 版本。 */
bool gd32IdKnown() const { return m_gd32IdKnown; }
/** @brief 最近身份块中的版本(HELLO 或 PING)。 */
gd32_version_t gd32VersionCached() const { return m_gd32Version; }
/** @brief 获取链路统计快照(线程安全,持锁读取)。 */
SpiLinkStats linkStats();
/** @brief 获取完整链路状态(SPEC §10.1,会话/credit + 统计)。 */
SpiLinkStatus linkStatus();
/** @brief 发起 HELLO 握手建立 v2 session(SPEC §5.1)。
* 生成随机非零 SESSION_ID,发送 LINK_HELLO,等待 HELLO_ACK。
* 成功后 m_v2Active=true,序号归零。
* @param timeout_ms 总超时(毫秒),默认 3000。
* @param role_hint 期望的对端角色(0=any, 1=app, 2=boot)。
* @return ESP_OK 成功;ESP_ERR_TIMEOUT 超时;ESP_ERR_INVALID_RESPONSE 响应异常。 */
esp_err_t hello(uint32_t timeout_ms = 3000U, uint8_t role_hint = 0U);
/** @brief 通知 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。 */
esp_err_t appRequestBootloader();
/** @brief 方案A: 通过 PB6 LOW→HIGH 序列等待 Bootloader 就绪。
* Phase1 轮询等待 LOW (APP 复位前拉低 / Bootloader 极早拉低);
* Phase2 轮询等待 HIGH 并确认稳定 10ms (Bootloader init 完成释放)。
* 超时返回 ESP_ERR_TIMEOUT,调用方应退化为固定延时。 */
/** @brief 通过 HELLO 轮询等待 Bootloader 就绪(v2 方式,不再依赖 PB6 序列)。
* App→Boot 切换后,ESP32 每 20ms 发送 HELLO(role_hint=boot),
* 等待 HELLO_ACK 且 role=boot,最长等待 timeout_ms。
* 超时返回 ESP_ERR_TIMEOUT。 */
esp_err_t waitForBootloaderReady(uint32_t timeout_ms);
/** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */
esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr);
// ── CANFD 接口已迁移至 transport/canfd_bridge.h(M4 解耦)──
/** @brief 查询 GD32 APP 初始化状态(SPI 0xA3,12B:模块位图/失败位图/复位源/init_state)。
* 仅 APP 角色支持(BOOT 无此命令);成功时填充 out 并置 valid=true,同时更新本端缓存。 */
esp_err_t appInitStatus(uint32_t timeout_ms, gd32_init_status_t *out);
/** @brief 身份块 [15] 带来的 init_state(HELLO/PING 自动刷新;0xFF=未知)。 */
uint8_t gd32InitState() const { return m_gd32InitState; }
/** @brief 读取缓存的 0xA3 查询结果;valid=false 表示尚未成功查询过。 */
bool gd32InitStatusCached(gd32_init_status_t *out) const;
/**
* @brief 检查 GD32 是否有主动按铃请求,若有则发起读事务读取请求帧。
*
* GD32 下拉 SPIready 表示「我有请求帧要给你读」。此函数检测到该状态后
* 发起一次 SPI 读事务(发 dummy 字节,读回 GD32 准备的请求帧)。
* 读取成功后按帧 TYPE 交给上层处理(如 CAN feedback 轮询、重启请求等)。
*
* @param timeout_ms 等待 GD32 请求的超时(毫秒),0 表示不等待仅检查。
* @return ESP_OK 成功读到请求帧;ESP_ERR_NOT_FOUND 当前无请求;其余为错误码。
*
* @note 应在主循环或 OTA 空闲时周期调用,以响应 GD32 主动上报。
* @brief L0 本机自检(不依赖 GD32):SPI 设备/互斥/worker/队列已就绪。
*/
esp_err_t checkGd32Request(uint32_t timeout_ms = 0U);
bool selfTestL0() const;
/**
* @brief 取出最近一次 GD32 按铃请求帧(线程安全,取出后清空内部缓冲)。
*
* checkGd32Request() 成功读取一帧后将其 type/payload 存入内部缓冲,
* 上层调用本函数取出并消费。取出后缓冲清空,避免重复消费。
*
* @param[out] type 请求帧 TYPE(如 0xC1)。
* @param[out] payload 输出 payload 拷贝缓冲。
* @param[in] max_len payload 缓冲最大长度。
* @param[out] len 实际 payload 长度。
* @return true 表示有数据被取出;false 表示当前无数据。
* @brief L0 对端轻检:短超时 HELLO(role=any),确认 SPI 链路可达 GD32。
* @note GD32 仍在启动延时中时可能失败,属非关键项。
*/
bool takeRingRx(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len);
bool selfTestPeerL0();
private:
/**
@@ -171,32 +348,27 @@ 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 请求帧
WAITING_BOOT_READY, ///< 方案A: 等待 Bootloader 就绪(LOW→HIGH 序列)
IDLE = 0, ///< 空闲,可接受 GD32 按铃
MASTER_SENDING, ///< 主机正在 CS 发送
WAITING_RESPONSE, ///< 主机正在 poll/读响应
SERVING_GD32_RING, ///< 正在读 GD32 EVENT
};
/**
* @brief SPI 链路状态机:封装握手状态 + 完成信号逻辑。
* @brief 本端事务占用标记(v2)。
*
* 线状态语义(对齐 OTA):
* - 线 HIGH = 空闲(双方释放),可发起新事务
* - 线 LOW = 忙碌(某方下拉占用),禁止发起新事务
*
* ESP32 作为主动方(otaTransfer/appPing):enterBusy() 占用线 → transferClocked → exitBusy() 释放线
* ESP32 作为按铃响应方(checkGd32Request):同上,releaseReady 上升沿通知 GD32 完成
* DATA_READY(PB6)由 GD32 独占:低=有待读,高=队列空。
* 本状态机只表示 ESP32 是否正占用 SPI 总线,避免与按铃读并发。
*/
class SpiLinkState {
public:
/** 进入忙碌态(driveReadyLow 前/后调用)。 */
/** 进入忙碌态。 */
void enterBusy(HandshakeState hs) { m_hsState = hs; }
/** 退出忙碌态(releaseReady 后调用),回到 IDLE。 */
/** 退出忙碌态,回到 IDLE。 */
void exitBusy() { m_hsState = HandshakeState::IDLE; }
/** 当前是否空闲(可接受新按铃下降沿)。 */
@@ -213,19 +385,142 @@ private:
};
SpiLinkState m_linkState;
/** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,SPI 任务消费。 */
/** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,仅 SPI worker 消费。 */
volatile bool m_gd32RequestPending = false;
/** SPI 任务句柄(用于 ISR 唤醒)。 */
/** ring service 抑制引用计数(setSuppressRingService 内部使用,临界区保护)。 */
uint32_t m_ringSuppressRefs = 0;
/** OTA 中继时为 true:worker 不 serviceRing,避免偷读 OTA ACK/WAIT。 */
volatile bool m_suppressRingService = false;
/** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */
volatile bool m_spiPaused = false;
/** GD32 重启检测(响应帧 session=0)置位:worker 空闲时 HELLO 重同步。 */
volatile bool m_resyncPending = false;
/** 上次 HELLO 重同步时刻(冷却 5s,防 GD32 不响应时反复 HELLO)。 */
volatile uint32_t m_lastResyncTick = 0;
/** 链路在线状态及熔断状态:防 SPI 不通时死循环阻塞与堆栈耗尽 */
volatile bool m_linkOnline = true;
volatile uint32_t m_consecutiveFailures = 0;
volatile uint32_t m_lastFailureTick = 0;
static constexpr uint32_t kCircuitBreakerFailThreshold = 3U; ///< 连续 3 次失败触发熔断
static constexpr uint32_t kCircuitBreakerCooloffMs = 2000U; ///< 熔断后冷却 2 秒
void recordSpiSuccess();
void recordSpiFailure();
/** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */
volatile uint8_t m_lastReqType = 0xFFU;
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */
TaskHandle_t m_spiTaskHandle = NULL;
/** GD32 按铃请求帧缓冲区。 */
uint8_t m_ringRxBuf[SPI_OTA_FRAME_MAX] = {};
size_t m_ringRxLen = 0;
uint8_t m_ringRxType = 0; ///< GD32 按铃请求帧 TYPE(checkGd32Request 填充)
TaskHandle_t m_workerTask = NULL;
QueueHandle_t m_jobQueue = NULL;
/** 最近一次 job 执行耗时(µs,worker 写入、业务只读;单核 32 位读原子)。 */
uint32_t m_lastWorkerExecUs = 0;
/** 最近一次 transact 分段剖析(worker 与调用方交叉写入,done 信号量同步)。 */
SpiProfile m_lastProfile;
/** 事件计数信号量(worker push / takeEvent pop)。 */
SemaphoreHandle_t m_eventSem = NULL;
// ── 多槽事件队列(worker 写入,业务 takeEvent 读出)──
static constexpr uint8_t kEventSlots = 4U;
struct EventSlot {
uint8_t type;
uint16_t len;
uint8_t data[SPI_OTA_FRAME_MAX];
};
EventSlot m_eventQ[kEventSlots] = {};
uint8_t m_eventHead = 0;
uint8_t m_eventTail = 0;
uint8_t m_eventCount = 0;
// ── 作业提交(跨任务:固定 Job pool + 静态 binary done;worker 串行执行)──
enum class JobKind : uint8_t {
Transact = 0,
Poll,
Hello,
EnterBoot,
Ping,
WaitBoot,
InitStatus,
};
struct Job {
JobKind kind = JobKind::Transact;
uint32_t timeout_ms = 0;
uint8_t type = 0;
uint8_t expected_type = 0xFFU;
uint8_t role_hint = 0;
size_t payload_len = 0;
uint8_t payload[SPI_OTA_FRAME_MAX] = {};
uint8_t *out_type = nullptr;
uint8_t *out_payload = nullptr;
size_t out_max = 0;
size_t *out_len = nullptr;
uint32_t *out_uptime = nullptr;
gd32_version_t *out_version = nullptr;
gd32_init_status_t *out_init_status = nullptr;
esp_err_t result = ESP_FAIL;
SemaphoreHandle_t done = nullptr; ///< 池槽静态 binary,init 创建,永不 delete
volatile bool finished = false; ///< worker 已 execute + give
volatile bool abandoned = false; ///< 提交方已超时返回,worker 负责 release 槽
volatile bool executing = false; ///< worker 已 dequeue(即将/正在写 out_*;超时放弃分型用)
bool in_use = false; ///< 池槽占用(critical 保护)
};
/** 队列深度;池 = 队列 + 1(在途执行槽),覆盖 queued + executing。 */
static constexpr UBaseType_t kJobQueueLen = 6;
static constexpr UBaseType_t kJobPoolSize = kJobQueueLen + 1U;
Job m_jobPool[kJobPoolSize] = {};
bool m_jobPoolReady = false;
bool onWorker() const;
/** @brief 从固定池取空闲 Job;池耗尽返回 nullptr(ESP_ERR_NO_MEM)。 */
Job *acquireJobSlot();
/** @brief 归还池槽(清空业务字段/标志,drain done 信号量)。 */
void releaseJobSlot(Job *job);
esp_err_t submitJob(Job &job);
void executeJob(Job &job);
/** @brief worker 完成池 Job 后的收尾(give done;abandoned 时 release 槽)。 */
void finishPoolJob(Job *job);
/** @brief FreeRTOS 任务入口,转到 workerLoop。 */
static void workerEntry(void *arg);
/** @brief SPI worker 主循环:EVENT 按铃 + Job 队列。 */
void workerLoop();
/** @brief 单次服务 GD32 按铃读帧并入 EVENT 队列。 */
esp_err_t serviceRingOnce();
/** @brief EVENT 入队(队列满丢最旧)。 */
bool pushEvent(uint8_t type, const uint8_t *payload, size_t len);
/** @brief EVENT 出队(非阻塞)。 */
bool popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t *len);
/**
* @brief 解析并缓存 APP 身份 16B(HELLO_ACK 附加块 / PING body 同布局)。
* @param id 身份块
* @param out_version 可选输出版本
* @param out_uptime_sec 可选输出 uptime 秒
*/
void applyAppIdentity16(const uint8_t *id, gd32_version_t *out_version,
uint32_t *out_uptime_sec);
// ── 帧内同步状态机 ──
volatile uint8_t m_localCredit = 8; ///< 本端剩余可接收帧数(信用窗口)
volatile uint8_t m_remoteCredit = 8; ///< 对端上报的剩余信用
// ── SPI Link v2 会话/序号状态(阶段1 就位,阶段2 启用校验)──
volatile uint32_t m_sessionId = 0; ///< 会话 ID(0 = 未建立,HELLO 时由 ESP32 生成)
volatile uint8_t m_txSeq = 0; ///< 本端发送序号(每发一帧递增,重发不变)
volatile uint8_t m_lastSentSeq = 0; ///< 上次发送的序号(用于响应归属匹配)
volatile uint8_t m_rxAckedSeq = 0; ///< 已确认的对端最新序号
volatile uint8_t m_expectedRxSeq = 0; ///< 期望收到的对端序号
volatile uint8_t m_gd32Role = 0; ///< GD32 角色(0=unknown, 1=app, 2=boot)
volatile bool m_gd32FeatKnown = false; ///< HELLO/PING 身份是否带 feat_map
volatile uint32_t m_gd32FeatFlags = 0; ///< V2_FEAT_*
volatile bool m_gd32IdKnown = false; ///< 是否已缓存版本号
gd32_version_t m_gd32Version = {}; ///< HELLO/PING 缓存的版本
volatile uint8_t m_gd32InitState = GD32_INIT_STATE_UNKNOWN; ///< 身份块 [15] init_state
gd32_init_status_t m_gd32InitStatus = {}; ///< 0xA3 查询缓存(valid 标志内置)
volatile bool m_v2Active = false; ///< v2 session 是否激活
/** 最近一次 otaFindFrame 解析到的 RESPONSE 是否与 m_lastSentSeq 归属匹配 */
volatile bool m_lastRxAckMatched = false;
SpiLinkStats m_stats = {}; ///< 链路统计
// RAII 锁守卫
class LockGuard {
public:
@@ -241,25 +536,22 @@ private:
SemaphoreHandle_t m_lock;
};
/** @brief 下拉 SPIready(开漏驱动低)。 */
void driveReadyLow();
/** @brief 释放 SPIready(开漏高阻,靠上拉拉高)。 */
void releaseReady();
/**
* @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);
/** @brief APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type);
// ── 去重辅助 ──
/** @brief 把 otaFindFrame 找到的帧拷贝到调用方输出缓冲(安全截断)。 */
static void copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
uint8_t *resp_payload, size_t resp_max,
const uint8_t *found_payload, size_t found_len,
size_t *resp_len);
/** @brief 见到 APP 独有 TYPE 时纠正粘滞的 boot role(3 处调用合并)。 */
void correctRoleFromType(uint8_t found_type, const char *src);
// 内部 SPI 传输
/**
* @brief 执行底层 SPI 时钟同步传输。
@@ -273,6 +565,28 @@ private:
esp_err_t transferClocked(const uint8_t *tx_buf, size_t tx_len,
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms = 0U);
/**
* @brief 底层传输,支持 SPI 事务 flags(如 SPI_TRANS_CS_KEEP_ACTIVE)。
* 多段同 CS 变长读时:先 spi_device_acquire_bus,段间 KEEP_ACTIVE,末段清 flag 后 release_bus。
*/
esp_err_t transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
uint8_t *rx_buf, size_t rx_len,
uint32_t timeout_ms, uint32_t trans_flags);
/**
* @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 budget_hint 读预算提示(0=按角色默认;>0 时取 min(hint, 角色预算),
* 主动 poll 按最近请求类型收紧,省 0xFF 填充时钟)
*/
esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms, size_t budget_hint = 0U);
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
/**
* @brief 无锁版本的 OTA 响应轮询(调用者须已持有锁)。
@@ -284,7 +598,8 @@ private:
* @return ESP_OK 表示成功,否则返回错误码。
*/
esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
size_t resp_max, size_t *resp_len, uint32_t timeout_ms,
uint8_t expected_type = 0xFFU);
/** @brief 无锁版本的 otaTransfer(调用者须已持有锁)。
* @param expect_stale true 表示调用方预期读到过期/空响应(如 otaSendAndPoll 步骤1),
* 找不到帧时降级为 DBG 日志,不打 WARN。 */
@@ -292,13 +607,13 @@ private:
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms,
bool expect_stale = false);
// OTA 帧工具(静态,无状态)
static uint32_t otaCrc32(const uint8_t *data, size_t len);
static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *out, size_t *out_len);
static bool otaFindFrame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset);
size_t *frame_offset, uint8_t *out_flags = nullptr);
};
#endif // SPI_MASTER_H
+73 -13
View File
@@ -4,26 +4,69 @@ CONFIG_ESP_WIFI_TX_BA_WIN=6
CONFIG_DCU_OTA_POLL_BACKOFF_MS=5
CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000
# DCU SPI clock -- default 4 MHz, can be raised via menuconfig to speed up GD32 OTA
CONFIG_DCU_SPI_CLOCK_HZ=4000000
# Flash mode DIO(锁定):本板 PCB 实测 QIO 不可用。
# 2026-07 尝试过 CONFIG_ESPTOOLPY_FLASHMODE_QIO=y 加速 OTA,结果:
# 开机 boot 日志 SPI Mode : QIO 看似正常,但 WiFi OTA 写 flash 后
# 从分区读回计算 SHA-256 就与上传前 SHA 不一致(每次都不过),
# 属于“能启动但数据不可靠”。推断:当前 flash 芯片/PCB 走线/WP+HOLD 不支持 QIO。
# 不要再开 QIO,除非确认 flash 型号与硬件连接均兼容。
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
# DCU OTA packet interval -- default 1 ms, can be lowered to speed up GD32 OTA
CONFIG_DCU_OTA_PACKET_INTERVAL_MS=1
# DCU SPI clock -- 13.3 MHz(2026-08-02:80MHz/6 整除,比 12M 再省 ~8%;
# 半周期 37.5ns,MISO 输出延迟 + GPIO 矩阵延迟 ~45ns 预算已近上限,需实测;
# ≥16MHz 全双工不可行。写 13333333:若写 13000000 驱动取整 80/7=11.4M 反而更慢。
# 2026-08-09 注:曾怀疑 13.33M 是 relay 重发率高(~4.4%)的根因,改 12M 实测
# 重发率几乎不变 → 时钟不是主因,回到 13.33M,继续找逻辑层瓶颈。)
CONFIG_DCU_SPI_CLOCK_HZ=13333333
# GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压
# (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发)
# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 + UDP debug log + 余量
# 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE)
# 提到 12 留足缓冲,配合 can_feedback_task 退避从根本上避免 socket 饥荒
CONFIG_LWIP_MAX_SOCKETS=12
CONFIG_LWIP_TCP_WND_DEFAULT=11480
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
# C3 堆紧:窗口过大 OTA 峰值易顶;8KB≈5*MSS,在 4KB OTA 块下吞吐更好,仍比 12KB+ 保守。
# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。
# WND 保持 8192:OTA 上传走接收窗口,不动以保上传吞吐。
CONFIG_LWIP_TCP_WND_DEFAULT=8192
# SND_BUF 8192→5760(=4*MSS):发送侧(/logs、status、feedback 响应)每 socket 的
# 瞬时 TX pbuf 上限从 ~8KB 降到 ~5.76KB;max_open_sockets=6 时聚合发送峰值明显下降。
# 我方响应体均 ≤5KB,4*MSS 不损局域网 TX 吞吐;OTA 上传是 RX 侧,不受影响。
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=5760
# 乱序队列 pbuf:4→2,少占堆;HTTP 顺序上传基本不需要大 OOSEQ
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=2
# WiFi 动态缓冲(C3 + NimBLE 常驻):32 过大,OTA 峰值与 TCP 窗口叠加易堆碎。
# 16/16/6 对单连接 OTA 足够,省出 ~10–20KB 峰值给 Flash 写路径。
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16
# ── 固件体积裁剪(P0,2026-07):仅三项,WPA2-PSK / 本地 HTTP / IPv4 OTA 不受影响 ──
# 关闭企业 WiFi(802.1X),家用 WPA2-PSK 不受影响
CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT=n
# 无 HTTPS 出站客户端,本地 HTTP OTA 不需要 WiFi 内嵌 mbedTLS TLS client
CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT=n
# 仅 IPv4;关 IPv6 可去掉 nd6 等路径
CONFIG_LWIP_IPV6=n
# FreeRTOS runtime stats -- /system/status CPU usage
CONFIG_FREERTOS_USE_TRACE_FACILITY=y
CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y
CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y
CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y
# ── BLE / NimBLE 主机栈(set-target 会重生 sdkconfig,必须写在 defaults 里)──
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_CONTROLLER_ENABLED=y
# CONFIG_BT_BLUEDROID_ENABLED is not set
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
# ── NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service)──
# 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB
CONFIG_BT_NIMBLE_PROX_SERVICE=n
@@ -39,16 +82,33 @@ CONFIG_BT_NIMBLE_HR_SERVICE=n
CONFIG_BT_NIMBLE_BAS_SERVICE=n
CONFIG_BT_NIMBLE_DIS_SERVICE=n
# ── NimBLE: 缩减内存池(单连接 OTA 场景,不需要大池子)──
# MSYS pool 1: 256B blocks, 16→8 (省 ~2 KB)
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8
# MSYS pool 2: 320B blocks, 32→16 (省 ~5 KB)
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=16
# ACL transport buffers: 12→6 (省 ~1.5 KB)
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
# ── NimBLE: 缓冲略收(WiFi OTA 优先;BLE OTA 单连接仍够用)──
# 原 24/24 为 BLE 突发优化,但与 WiFi OTA 抢堆导致“卡半 + free~5KB”。
# 12/16:BLE 吞吐略降,WiFi 峰值堆更稳。
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=16
# Event buffers: 30→16 (省 ~1 KB)
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=16
# Prepared write entries: 64→16 (省 ~1 KB)
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=16
# GATT procedures: 4→2 (单连接够用)
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
CONFIG_BT_CTRL_CE_LENGTH_TYPE_ORIG=y
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MIN_CONN_INTERVAL=12
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=16
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200
# Brownout detector threshold (LVL7, ~2.8V)
# 长时间断电后 CHIP_EN RC 充电慢,LVL5 可能停在半复位状态;提高到 LVL7 确保电源稳定后才启动。
CONFIG_ESP_BROWNOUT_DET=y
# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5 is not set
CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7=y
# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_5 is not set
CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_7=y
# ── 控制台/调试 UART 波特率 3M(debug_log.h UART_DEBUG_BAUD_RATE 与此对齐)──
# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig 或删 sdkconfig 重生。
CONFIG_ESP_CONSOLE_UART_BAUDRATE=3000000
CONFIG_CONSOLE_UART_BAUDRATE=3000000
-1889
View File
File diff suppressed because it is too large Load Diff
-1624
View File
File diff suppressed because it is too large Load Diff
-218
View File
@@ -1,218 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<link rel="stylesheet" href="style.css?v=20260701a">
</head>
<body class="can-page">
<div class="container can-container">
<header class="header-bar can-header">
<div class="header-brand">
<span class="dot-mark"></span>
<strong>ESP32 CAN 通信控制台</strong>
</div>
<div class="header-status">
<span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></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>
</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>
</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>
<div class="can-toolbar-block can-toolbar-channel">
<label class="form-label" for="defaultChannel">默认通道</label>
<select class="input" id="defaultChannel">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
</select>
</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">
<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">
<span class="can-node-filter-label" id="nodeFilterLabel">发送节点:全部</span>
<span class="can-node-filter-caret">▾</span>
</button>
<div class="can-node-filter-panel" id="nodeFilterPanel" hidden>
<div class="can-node-filter-head">
<input class="input can-node-filter-search" id="nodeFilterSearch" type="text" placeholder="筛选节点名…">
<div class="can-node-filter-actions">
<button class="btn btn-ghost btn-sm" id="nodeFilterAllBtn" type="button">全选</button>
<button class="btn btn-ghost btn-sm" id="nodeFilterNoneBtn" type="button">清空</button>
</div>
</div>
<div class="can-node-filter-list" id="nodeFilterList">
<div class="status-empty">先导入 DBC。</div>
</div>
</div>
</div>
<label class="checkbox-inline can-batch-all-label" title="全选/取消当前过滤的所有报文">
<input type="checkbox" id="selectAllBox">
<span>全选当前过滤</span>
</label>
<button class="btn btn-outline btn-sm" id="expandAllBtn" type="button">展开全部</button>
<button class="btn btn-outline btn-sm" id="collapseAllBtn" type="button">收起全部</button>
<button class="btn btn-primary btn-sm" id="sendCheckedBtn" type="button">批量发送勾选</button>
</div>
</div>
<div class="card-body can-table-card-body">
<div class="message-list" id="messageList">
<div class="status-empty">等待导入 DBC...</div>
</div>
</div>
</section>
<aside class="can-sidepanel">
<section class="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>
<div class="btn-row can-frame-actions">
<button class="btn btn-outline btn-sm" id="loadPreviewToRawBtn" type="button">载入到原始发送区</button>
<button class="btn btn-primary btn-sm" id="sendSelectedBtn" type="button">发送当前预览帧</button>
</div>
<div class="can-response" id="dbcResp" style="display:none;">
<div class="can-response-label">设备响应</div>
<div id="dbcRespText"></div>
</div>
</div>
</div>
</section>
<section class="card">
<div class="card-head"><span class="ico">✍️</span> 原始帧发送</div>
<div class="card-body">
<div class="row-2 can-raw-grid">
<div class="form-row" style="margin:0;">
<label class="form-label" for="manualChannel">通道</label>
<select class="input" id="manualChannel">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
</select>
</div>
<div class="form-row" style="margin:0;">
<label class="form-label" for="manualId">CAN ID</label>
<input class="input" type="text" id="manualId" value="0x18DA00F1" placeholder="支持 0x 或十进制">
</div>
</div>
<div class="form-row" style="margin-top:10px;">
<label class="form-label" for="manualDlc">DLC</label>
<input class="input" type="number" id="manualDlc" value="8" min="0" max="64">
</div>
<div class="form-row" style="margin-top:10px;">
<label class="form-label" for="manualData">HEX 数据</label>
<input class="input" type="text" id="manualData" value="0102030405060708" placeholder="01020304...">
</div>
<div class="helper-note" id="manualHint">
原始帧模式适合绕过 DBC 直接发 HEX,或发送当前预览帧后二次微调。
</div>
<div class="btn-row">
<button class="btn btn-primary" id="sendManualBtn" type="button">发送原始帧</button>
<button class="btn btn-outline" id="resetManualBtn" type="button">重置</button>
</div>
<div class="can-response" id="manualResp" style="display:none;">
<div class="can-response-label">设备响应</div>
<div id="manualRespText"></div>
</div>
</div>
</section>
</aside>
</div>
</div>
<div class="toast" id="toast">
<span class="toast-ico" id="toastIco">✓</span>
<span id="toastMsg">操作成功</span>
</div>
<script src="can.js?v=20260701a"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More