diff --git a/README.md b/README.md index df7ef57..2e5d4d0 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,565 @@ # ESP32-C3 OTA Relay -ESP32-C3FH4 OTA 中继固件,通过 WiFi 接收上位机 OTA 固件包,通过 SPI 转发给下位机(GD32)。 +**固件版本: v0.3.12** | 构建时间: 2026-06-14 | 基于 ESP-IDF v6.0 + +## 项目概述 + +ESP32-C3FH4 OTA 中继固件,作为 **GD32E508VET6** 的 OTA 升级桥梁。通过 **WiFi (HTTP)** 或 **BLE (GATT)** 从上位机(手机/PC)接收固件,存储在本地 SPI Flash 的 `gd32_ota` 分区,再通过 **SPI 总线** 经四阶段安全握手协议转发给 GD32。 + +``` +[手机/PC 上位机] --WiFi/BLE--> [ESP32-C3FH4] --SPI--> [GD32E508VET6] + | + +-- gd32_ota 分区 (252KB) + +-- 健康自检 (温度 + WiFi RSSI) + +-- 本机 OTA (ESP32 自升级) +``` ## 硬件平台 -- **芯片:** ESP32-C3FH4 (RISC-V, 单核) -- **Flash:** 4MB -- **编译工具链:** ESP-IDF v6.0, riscv32-esp-elf +| 项目 | 规格 | +|------|------| +| **芯片** | 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 | + +### 分区表 (`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 | 文件系统 | ## 功能模块 -| 模块 | 描述 | -|------|------| -| `health_monitor` | 温度 + WiFi RSSI 监控 | -| `ota_relay` | OTA 数据包接收与转发 | -| `wifi_ota` | WiFi OTA HTTP 服务器 + CAN 帧转发 | -| `spi_master` | SPI 主站,与 GD32 通信 | -| `protocol` | CRC16 校验 + AES-128-CBC 解密(硬件加速) | +| 模块 | 文件 | 描述 | +|------|------|------| +| **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_relay** | `relay/ota_relay.{h,cpp}` | GD32 OTA 中继核心状态机(四阶段 15 状态) | +| **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 会话密钥管理) | ## 目录结构 ``` ESP32_COMM/ -├── CMakeLists.txt +├── 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 +│ ├── CMakeLists.txt # 注册 11 个源文件 + 依赖 │ ├── include/ -│ │ └── app_config.h # WiFi / OTA 参数配置 -│ ├── core/main.cpp # 任务创建入口 -│ ├── relay/ota_relay.cpp # OTA 中继逻辑 +│ │ ├── 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 核心 │ ├── transport/ -│ │ ├── spi_master.cpp # SPI 主站 + CANFD 3通道发送 -│ │ └── protocol.cpp # 协议层 (CRC16 / AES-128-CBC) +│ │ ├── spi_master.{h,cpp} # SPI 主站 + CANFD 3 通道 +│ │ └── protocol.{h,cpp} # 协议层 (CRC16 / AES / nonce) │ ├── server/ -│ │ └── wifi_ota.cpp # WiFi OTA HTTP 服务器 +│ │ ├── wifi_ota.{h,cpp} # WiFi OTA HTTP 服务器 +│ │ └── ble_ota.{h,cpp} # BLE GATT OTA 服务器 │ └── monitor/ -│ ├── health_monitor.cpp # 健康监控 -│ └── debug_log.cpp # UDP 调试日志 -└── tools/ - └── ota_host_web.html # 上位机 WebUI(CAN 发送 + OTA 上传) +│ ├── 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) ``` -## WiFi 工作模式 +## FreeRTOS 任务 -支持 AP / STA 两种模式,通过 `app_config.h` 配置: +4 个主任务全部固定运行在 ESP32-C3 单核(核 0),通过 FreeRTOS 队列通信: -```c -// AP 模式(ESP32 当热点) -CONFIG_WIFI_MODE_IS_STA = 0 -WIFI_AP_SSID = "ESP32_OTA_RELAY" // 默认热点名称 -WIFI_AP_PASSWORD = "12345678" // 默认密码 -// 上位机连接热点后访问: http://192.168.31.1 +| 任务名 | 函数 | 优先级 | 栈大小 | 职责 | +|--------|------|--------|--------|------| +| `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 | 等待调度信号 → 四阶段状态机 → SPI 转发 | -// STA 模式(连接路由器) -CONFIG_WIFI_MODE_IS_STA = 1 -WIFI_STA_SSID = "004舰桥" // 路由器名称 -WIFI_STA_PASSWORD = "q13362875622" // 路由器密码 -// 上位机和 ESP32 在同一局域网,通过 DHCP 分配的 IP 访问 +额外后台任务: + +| 任务名 | 优先级 | 栈大小 | 职责 | +|--------|--------|--------|------| +| `wifi_reconnect` | 5 | 4096 | STA 断线重连(退避算法,独立任务避免死锁)| +| `ble_ota_finish` | 5 | 4096 | BLE ESP32 OTA 异步收尾(SHA 校验 + end + reboot)| +| `nimble_host` | NimBLE 内部 | NimBLE 内部 | BLE 协议栈事件循环 | + +### 跨任务通信 + +- **OTA 调度**: WiFi/BLE 任务通过 `OtaRelay::dispatchQueue` (FreeRTOS 队列, 深度 4) 发送调度信号给 `ota_relay` 任务;结果通过 `resultQueue` 回传 +- **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 中继(四阶段 + APP 验证) + +上位机上传 GD32 固件到 ESP32 本地 `gd32_ota` 分区 → 收到触发命令后启动状态机: + +``` +阶段1 ─ 安全握手 (5 状态) + RELAY_HANDSHAKE_SENT → RELAY_CHALLENGE_SENT → RELAY_CHALLENGE_RESPOND + → RELAY_SESSION_KEY_SENT → RELAY_AUTH_COMPLETE + +阶段2 ─ 固件大小协商 (2 状态) + RELAY_SIZE_SENT → RELAY_SIZE_ACK + +阶段3 ─ 数据包转发 (4 状态) + RELAY_RELAY_ACTIVE → RELAY_PACKET_SENT → RELAY_PACKET_ACK_OK + [重试] RELAY_PACKET_ACK_RETRY + +阶段4 ─ 完成与跳转 (3 状态) + RELAY_RECEIVE_DONE → RELAY_FINISH_SENT → RELAY_DONE + +阶段5 ─ APP 存活验证 + RELAY_APP_VERIFY (PING GD32 APP, 最多 30 次 × 500ms) ``` -## CANFD 3通道 SPI 协议 +#### 安全握手协议 -GD32 通信帧格式:`[通道编号: 1B] [扩展帧ID: 4B BE] [数据: 1~64B]` +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 加密通道 + +#### 数据包转发 + +- 每包 48 字节固件载荷,使用 `K_session` AES-ECB 加密 +- 包结构: `session(4B) + seq(4B) + encrypted48(48B) + if_end(4B) + crc32(4B)` = 64B +- 每 32 包执行一次 OTA 健康检查(温度/堆) +- 支持重试(单包最多 10 次),结构化 ACK 含 seq 校验,残留 ACK 自动过滤 +- 全局超时: 动态计算 5~10 分钟 + +### 二、ESP32 本机 OTA(WiFi + BLE 共用) + +核心逻辑封装在 `Esp32OtaCore` 单例中,WiFi 和 BLE 通道共用: + +| 步骤 | 方法 | 说明 | +|------|------|------| +| 1 | `begin(total_size)` | 选择非当前 OTA 分区,获取全局互斥,`esp_ota_begin` | +| 2 | `write(data, len)` | 循环调用 `esp_ota_write`(WiFi 通过 HTTP 流,BLE 通过 GATT 块) | +| 3 | `finalize(sha_hex, reboot)` | `esp_ota_end` → 从分区读回计算 SHA-256 → 比对前端 SHA → `esp_ota_set_boot_partition` → 可选重启 | +| - | `abort()` | 中止并清理(释放 mutex + 复位状态) | + +**SHA-256 校验**: 固件写入完成后从 OTA 分区读回计算,不做增量 SHA。前端与设备端比对一致才 set_boot,否则返回 `ESP_ERR_INVALID_VERSION`。 + +**回滚保护**: `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 | +| 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 | 备用通道 1 | -| CH2 | 备用通道 2 | +| 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 | -## 上位机 WebUI +## WiFi 工作模式 -`tools/ota_host_web.html` — 浏览器打开即可使用,无需安装。 +支持 AP/STA 双模式,启动时优先 STA,超时自动回退 AP。 -### HTTP API +### AP 模式(设备当热点) -| 方法 | 路径 | 功能 | -|------|------|------| -| POST | `/ota/upload` | 上传固件 (.bin) | -| POST | `/ota/relay` | 触发 OTA 中继 | -| GET | `/ota/status` | 查询 OTA 状态 | -| POST | `/can/send` | 发送 CAN 帧到 GD32 | -| GET | `/can/status` | CAN 通道状态 | -| GET | `/health` | ESP32 健康状态 | -| GET | `/wifi/status` | WiFi 连接状态 | +| 参数 | 值 | +|------|------| +| SSID | `ESP32_OTA_RELAY` | +| 密码 | `12345678` | +| IP | 192.168.31.1 | +| 信道 | 6 | +| 最大客户端 | 4 | -### CAN 帧发送示例 +### 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, `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 | 固件版本字符串 | + +### BLE 数据传输 + +- **ACK 窗口**: 每 32 包等待一次 DATA_OUT ACK(带累计字节数验证) +- **窗口重传**: ACK 超时或字节数不足时重发整个窗口(最多 3 次) +- **ESP32 收尾**: BLE 传输完成后由前端发 `0xF8 + 32B 文件 SHA-256`,设备端创建异步任务执行 finalize(含 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 版本等 | +| 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` 字段自动区分目标: + +```json +// ESP32 OTA +{"type": "esp32", "total_size": 524288} + + +// GD32 OTA +{"type": "gd32", "total_size": 131072} + +``` + +- **ESP32 模式**: 直接写入 OTA 分区,进度上报,完成后调用 `/ota/update` 收尾 +- **GD32 模式**: 写入 `gd32_ota` 分区 → 擦除 → 计算 SHA-256 → 自动触发 SPI 中继 + +### curl 示例 ```bash +# 查询系统状态 +curl http://192.168.31.1/system/status | jq + +# 发送 CAN 帧 curl -X POST http://192.168.31.1/can/send \ -H "Content-Type: application/json" \ - -d '{"ch": 0, "id": 12345678, "dlc": 8, "data": "0102030405060708"}' + -d '{"ch":0,"id":0x18DA00F1,"dlc":8,"data":"0102030405060708"}' + +# SPI 直接发送 +curl -X POST http://192.168.31.1/spi/send \ + -H "Content-Type: application/json" \ + -d '{"type":"01","data":"00AC","timeout_ms":1000}' + +# 获取日志 +curl http://192.168.31.1/logs ``` -## 任务配置 (单核 ESP32-C3) +## WebUI 工具 -所有任务固定运行在 CPU 核 0: +### WiFi OTA 控制台 (`tools/index.html`) -| 任务 | 优先级 | 栈大小 | 说明 | -|------|--------|--------|------| -| health_mon | configMAX_PRIORITIES - 4 | 4096 | 健康监控 | -| ota_relay | configMAX_PRIORITIES - 2 | 8192 | OTA 中继 | -| wifi_ota | configMAX_PRIORITIES - 3 | 8192 | WiFi OTA | +- ESP32 固件上传与 OTA 升级(含 SHA-256 前端校验) +- GD32 固件上传(支持 .bin/.hex)与 SPI 中继触发 +- 实时 OTA 流程阶段显示(6 阶段进度追踪) +- 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 次) -1. **业务通信:HTTP** - - WebUI 通过 HTTP 接口与 ESP32 通信 - - 典型接口:`/system/status`、`/logs`、`/can/send`、`/ota/*` - - 其中日志面板实际通过 **轮询 `GET /logs`** 获取日志 +## 健康监控 -2. **调试镜像:UART0 + UDP** - - 所有 `DEBUG_*` 日志会同时写到 UART0、UDP、以及内部环形缓冲区 - - UDP 目标端口为 `6666`,适合独立调试接收器使用 - - 但 **当前 tools WebUI 不直接监听 UDP 6666** +### 健康状态等级 -因此,当前 WebUI 场景下: -- `/system/status` 用于在线状态/健康状态展示 -- `/logs` 用于日志展示 -- 不再需要额外的 `{"t":"hb"}` 假日志心跳包 +| 等级 | 名称 | 触发条件 | 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 禁止 | -## 编译 +### 监控指标 -```bash -idf.py set-target esp32c3 -idf.py fullclean -idf.py build -idf.py flash monitor +- **温度**: 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" | 热点名称 | +| 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 包健康检查 | +| 阈值 | `TEMP_WARNING` | 80°C | 温度警告阈值 | +| 阈值 | `TEMP_CRITICAL` | 100°C | 温度危险阈值 | +| 阈值 | `WIFI_RSSI_CRITICAL` | -85 dBm | RSSI 危险阈值 | +| 调试 | `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 单包载荷 | + +## 安全配置 + +| 参数 | 位置 | 说明 | 状态 | +|------|------|------|------| +| `MASTER_KEY[16]` | app_config.h:110 | AES-128 主密钥 | ⚠️ 占位值,生产环境需更换 | +| `SCRAMBLE_CONST[16]` | app_config.h:115 | Nonce 搅伴常量 | ⚠️ 占位值,生产环境需更换 | +| `GD32_CHIP_UID[12]` | app_config.h:125 | 运行时从握手包获取 | 仅保留默认占位 | +| `K_session` | 运行时派生 | `AES(MasterKey, nonce_server)` | 每次 OTA 重新生成 | +| SHA-256 | 最终校验 | 从分区读回计算,前端比对 | WiFi/BLE 均已实现 | + +## 构建与部署 + +### 一、本地编译(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 ``` -## 待确认参数 +编译命令: -- AES-128 主密钥 (MASTER_KEY_HERE) -- 搅伴算法种子 (SCRAMBLE_CONST_HERE) -- 芯片唯一码 (CHIP_UID_HERE) -- OTA Service UUID (需与 APP 侧协商) \ No newline at end of file +```powershell +# ── 进入项目目录 ── +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 +``` + +### 二、烧录方式 + +#### 方式 A:串口烧录(首次 / 恢复) + +连接 ESP32-C3 到串口(默认 COM15),直接在 PowerShell 中烧录: + +```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 + +# 烧录 + 监控 +& "F:\.espressif\v6.0\esp-idf\tools\idf.py" -p COM15 flash monitor +``` + +#### 方式 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) + +**步骤 2** — 上传完成后,系统自动计算 SHA-256 并触发 SPI 中继转发 + +**步骤 3** — ESP32 通过四阶段安全握手协议将固件逐包转发给 GD32 Bootloader + +**步骤 4** — 转发完成后 GD32 APP 启动,ESP32 轮询 PING 确认 APP 存活(最多 15 秒) + +> 也可手动点击 **"触发 GD32 OTA 中继"** 按钮重新触发转发。 + +### 四、Python 辅助工具 + +```powershell +# 串口日志采集(COM15, 5 分钟) +python serial_monitor.py + +# OTA 上传测试(HTTP) +python ota_upload_test.py +``` + +### 五、开发容器 + +`.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:140` 在任务创建完成后执行此标记 + +## 固件版本管理 + +位于 `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` 获取 +- 输出到 `main/include/version.h`,仅在内容变化时写入以避免不必要的重编译 diff --git a/main/include/version.h b/main/include/version.h index c1fa8fc..ccf027f 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -8,7 +8,7 @@ #define FW_VERSION_STRING "0.3.12" // 编译时间 -#define BUILD_TIME "2026-06-14 22:46:58" +#define BUILD_TIME "2026-06-14 23:55:14" // Git 信息 -#define GIT_HASH "2caf10b" +#define GIT_HASH "unknown" diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 3d3a443..876aab2 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -201,9 +201,6 @@ bool OtaRelay::isFinishPlainAck(const uint8_t *payload, size_t len) return false; } -bool OtaRelay::isStructuredAckWithPayload(const uint8_t *payload, size_t len) -{ return payload && len >= 6U && payload[0] == 0xBAU && payload[1] == 0xC0U; } - esp_err_t OtaRelay::pollUntilExpectedAckShape(const char *stage, uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, @@ -306,7 +303,6 @@ esp_err_t OtaRelay::metaLoad(ota_meta_t *meta) if (nvs_get_u32(handle, "total_size", &val) == ESP_OK) meta->total_size = val; if (nvs_get_u32(handle, "stored_size", &val) == ESP_OK) meta->stored_size = val; if (nvs_get_u32(handle, "last_seq", &val) == ESP_OK) meta->last_seq = val; - if (nvs_get_u32(handle, "chunk_count", &val) == ESP_OK) meta->chunk_count = val; size_t sha_len = sizeof(meta->checksum); if (nvs_get_blob(handle, "checksum", meta->checksum, &sha_len) == ESP_OK && sha_len == sizeof(meta->checksum)) { @@ -338,7 +334,6 @@ esp_err_t OtaRelay::metaSave(const ota_meta_t *meta) nvs_set_u32(handle, "total_size", meta->total_size); nvs_set_u32(handle, "stored_size", meta->stored_size); nvs_set_u32(handle, "last_seq", meta->last_seq); - nvs_set_u32(handle, "chunk_count", meta->chunk_count); nvs_set_blob(handle, "checksum", meta->checksum, sizeof(meta->checksum)); err = nvs_commit(handle); nvs_close(handle); @@ -618,27 +613,6 @@ esp_err_t OtaRelay::requestGd32AppEnterBootloader() } -ota_result_t OtaRelay::probeBootHandshake() -{ - /* 0xB0 timeout: probe if GD32 is already in BOOT. - * Send one lightweight HANDSHAKE_REQ, accept only echo ACK 0x00 0xAC - * as "already in BOOT" signal; otherwise fail immediately. */ - DEBUG_INFO("OTA", "0xB0 timeout, probing if GD32 already in BOOT..."); - uint8_t payload[] = {0x00, 0xAC}; - uint8_t resp_type = 0U; - uint8_t resp_payload[64] = {0}; - size_t resp_len = 0U; - - esp_err_t err = SpiMaster::instance().otaTransfer(SPI_OTA_CMD_REQ, payload, sizeof(payload), - &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 500); - if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK && isHandshakeEchoAck(resp_payload, resp_len)) { - DEBUG_INFO("OTA", "BOOT probe success: GD32 already in BOOT, continue OTA"); - return OTA_RESULT_OK; - } - DEBUG_ERR("OTA", "BOOT probe failed: GD32 not in BOOT, abort (err=%d resp_type=0x%02X)", err, resp_type); - return OTA_RESULT_ERR_TIMEOUT; -} - ota_result_t OtaRelay::sendHandshakeReq() { DEBUG_INFO("OTA", "=== 阶段1: 握手开始 ==="); @@ -1157,51 +1131,6 @@ ota_result_t OtaRelay::finishOta() return OTA_RESULT_OK; } -/** - * @brief 启动完整 OTA 流程的主入口 - * @return 操作结果码。 - */ -ota_result_t OtaRelay::startHandshake() -{ - ota_result_t res; - - esp_err_t boot_err = requestGd32AppEnterBootloader(); - if (boot_err != ESP_OK) { - res = probeBootHandshake(); - if (res != OTA_RESULT_OK) { - reset(); - return res; - } - } - - res = sendHandshakeReq(); - if (res != OTA_RESULT_OK) { reset(); return res; } - - res = sendNonceAndAuthenticate(); - if (res != OTA_RESULT_OK) { reset(); return res; } - - ota_meta_t meta; - OtaRelay::metaLoad(&meta); - - // 固件完整性校验 - esp_err_t verify_err = metaVerifyChecksum(); - if (verify_err != ESP_OK) { - DEBUG_ERR("OTA", "固件完整性校验失败,中止转发"); - reset(); - return OTA_RESULT_ERR_CRC; - } - - res = sendFirmwareSize(meta.total_size); - if (res != OTA_RESULT_OK) { reset(); return res; } - - res = relayPacketsFromFlash(0); - if (res != OTA_RESULT_OK) { reset(); return res; } - - res = finishOta(); - - return res; -} - /** * @brief 从 Flash 向 GD32 转发固件 * @return 操作结果码。 @@ -1213,8 +1142,8 @@ ota_result_t OtaRelay::forwardFirmware() return OTA_RESULT_ERR_STORAGE; } - DEBUG_INFO("OTA", "开始固件转发: %u 字节, %u 块", - meta.total_size, meta.chunk_count); + DEBUG_INFO("OTA", "开始固件转发: %u 字节", + meta.total_size); // 固件完整性校验 { diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index be8f1d3..83c6343 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -16,19 +16,16 @@ #include "esp_partition.h" #define OTA_META_NVS_NAMESPACE "ota_meta" -#define OTA_META_NVS_KEY "meta" typedef struct { uint32_t total_size; uint32_t stored_size; uint32_t last_seq; - uint32_t chunk_count; uint8_t checksum[32]; } ota_meta_t; typedef enum { RELAY_IDLE = 0, - RELAY_HANDSHAKE_IDLE, RELAY_HANDSHAKE_SENT, RELAY_CHALLENGE_SENT, RELAY_CHALLENGE_RESPOND, @@ -36,13 +33,11 @@ typedef enum { RELAY_AUTH_COMPLETE, RELAY_SIZE_SENT, RELAY_SIZE_ACK, - RELAY_RECEIVING, RELAY_RELAY_ACTIVE, RELAY_PACKET_SENT, RELAY_PACKET_ACK_OK, RELAY_PACKET_ACK_RETRY, RELAY_RECEIVE_DONE, - RELAY_TRANSFER_DONE, RELAY_FINISH_SENT, RELAY_DONE, RELAY_APP_VERIFY, @@ -65,7 +60,6 @@ public: static OtaRelay& instance(); static void taskEntry(void *pvParameters); - ota_result_t startHandshake(); ota_result_t forwardFirmware(); /* ---- 线程安全的状态访问接口 ---- @@ -136,7 +130,6 @@ public: static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36; esp_err_t requestGd32AppEnterBootloader(); - ota_result_t probeBootHandshake(); ota_result_t sendHandshakeReq(); ota_result_t sendNonceAndAuthenticate(); ota_result_t sendFirmwareSize(uint32_t size); @@ -157,8 +150,6 @@ public: static bool isConfirmPlainAck(const uint8_t *payload, size_t len); static bool isSizeSeq0Ack(const uint8_t *payload, size_t len); static bool isFinishPlainAck(const uint8_t *payload, size_t len); - static bool isTransferDoneAck(const uint8_t *payload, size_t len); - static bool isStructuredAckWithPayload(const uint8_t *payload, size_t len); static esp_err_t pollUntilExpectedAckShape(const char *stage, uint8_t *resp_type, diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index 79f0536..36647a8 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -133,14 +133,18 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) // 通知前端:开始 finalize sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); + // 关键:reboot=false,让 finalize 真正返回、拿到 device_sha_hex, + // 再依次推送 SHA 通知 + FINISH_DONE_OTA_END + FINISH_REBOOT_NOW, + // 最后延迟重启。若 reboot=true,core 内部直接 esp_restart(), + // 下面所有 notifyResponse / sendFinishEvent 都不会执行, + // 前端也就拿不到设备端 SHA 与"收尾完成/即将重启"事件。 char device_sha_hex[65] = {0}; esp_err_t err = core.finalize(frontend_sha_hex, - /*reboot=*/true, + /*reboot=*/false, device_sha_hex); - // reboot=true 时 finalize 正常不会返回(core 内部 esp_restart) if (err == ESP_OK) { - // 极端情况:reboot 未执行(理论上不会发生),仍上报并标记 + // 1) 把设备端 SHA 通过 SHA256 通知推给前端 if (device_sha_hex[0] != '\0') { uint8_t sha_pkt[33] = {0}; sha_pkt[0] = BLE_OTA_NOTIFY_SHA256; @@ -151,8 +155,18 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) } BleOta::instance().notifyResponse(sha_pkt, sizeof(sha_pkt)); } + // 2) 等待 SHA 通知从 NimBLE host 队列送出 + vTaskDelay(pdMS_TO_TICKS(150)); + + // 3) 推送"OTA 收尾完成"与"即将重启"事件 sendFinishEvent(BLE_OTA_FINISH_DONE_OTA_END, 0); sendFinishEvent(BLE_OTA_FINISH_REBOOT_NOW, 0); + + // 4) 通知全部发出后延迟 500ms 重启 + DEBUG_INFO(TAG, "[FINISH] 通知已发送,500ms 后重启..."); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + // 不会返回 } else if (err == ESP_ERR_INVALID_VERSION) { DEBUG_ERR(TAG, "[FINISH] SHA-256 不匹配,OTA 已中止"); sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH, 0); @@ -536,7 +550,6 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, meta.total_size = total_size; meta.stored_size = 0; meta.last_seq = 0; - meta.chunk_count = (total_size + expected_payload - 1U) / expected_payload; esp_err_t err = OtaRelay::metaSave(&meta); if (err != ESP_OK) { @@ -544,8 +557,8 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, return BLE_ATT_ERR_UNLIKELY; } - DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32 ", payload=%zu", - meta.total_size, meta.chunk_count, data_len); + DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 ", payload=%zu", + meta.total_size, data_len); } ota_meta_t meta = {}; diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index a24c1e1..73c5277 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -21,10 +21,6 @@ #include "esp_event.h" #include "esp_mac.h" -// SHA-256 哈希计算 -#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS -#include "mbedtls/private/sha256.h" - #include "app_config.h" #include "ota_mutex.h" #include "wifi_ota.h" @@ -501,19 +497,31 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req) return ESP_OK; } - // ── 完整模式:finalize(end → 校验 → set_boot)后由 core 内部重启 ── + // ── 完整模式:finalize 不重启,先把含设备端 SHA 的结果返回给前端 ── + // 关键:reboot=false,让 core.finalize 真正返回,才能拿到 device_sha_hex + // 并把响应发回前端;待响应发完后再延迟重启。 + // 若 reboot=true,core 内部直接 esp_restart(),HTTP 响应永远发不出去, + // 前端会得到 "Failed to fetch" 而错过核对结果。 DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived()); esp_err_t err = core.finalize(has_frontend_sha ? frontend_sha_hex : nullptr, - /*reboot=*/true, + /*reboot=*/false, device_sha_hex); if (err == ESP_OK) { - // 正常不会走到这里(reboot=true 时 core 会 esp_restart) - char resp[256] = {0}; + // 1) 把包含设备端 SHA 的响应发回前端 + char resp[320] = {0}; snprintf(resp, sizeof(resp), - "{\"status\": \"ok\", \"message\": \"OTA finished\"}"); + "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", " + "\"sha256\": \"%s\"}", + device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); + + // 2) 响应发完后延迟 500ms 重启,给 TCP 缓冲区时间 flush 到客户端 + DEBUG_INFO("OTA", "响应已发送,500ms 后重启..."); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + // 不会返回 return ESP_OK; } @@ -552,27 +560,7 @@ static esp_err_t restart_handler(httpd_req_t *req) return ESP_OK; } -/** - * @brief HTTP GET /ota/update - 查询 ESP32 OTA 状态 - * @param req - * @return - */ -static esp_err_t esp32_ota_status_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - char resp[128]; - if (Esp32OtaCore::instance().isActive()) { - snprintf(resp, sizeof(resp), - "{\"active\": true, \"received\": %u}", - (unsigned int)Esp32OtaCore::instance().getReceived()); - } else { - snprintf(resp, sizeof(resp), "{\"active\": false}"); - } - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; -} - +// ---------------------------------------- // 从 HTTP 流中读取完整的 JSON 头(以 } 结尾) /** * @brief 返回 JSON 字符串长度,失败返回 0 @@ -795,7 +783,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) .total_size = expected_fw_size_raw, .stored_size = 0, .last_seq = 0, - .chunk_count = (expected_fw_size_raw + OTA_BLOCK_SIZE - 1U) / OTA_BLOCK_SIZE, .checksum = {0} }; OtaRelay::metaSave(&meta_raw); @@ -935,72 +922,6 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) return ESP_OK; } -/** - * @brief HTTP GET /ota/status 处理器 - * @param req - * @return - */ -static esp_err_t ota_status_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - DEBUG_INFO("WIFI", "=== HTTP: GET /ota/status ==="); - - ota_meta_t meta; - OtaRelay::metaLoad(&meta); - - char resp[256]; - int len = snprintf(resp, sizeof(resp), - "{\"total_size\": %lu, \"stored_size\": %lu, \"last_seq\": %lu, " - "\"storage_done\": %s, \"relay_state\": %d, \"gd32_app_alive\": %s}", - (unsigned long)meta.total_size, (unsigned long)meta.stored_size, - (unsigned long)meta.last_seq, - WifiOta::instance().getGd32StorageDone() ? "true" : "false", - OtaRelay::getState(), - OtaRelay::getGd32AppAlive() ? "true" : "false"); - - gd32_version_t snapVer = {}; - OtaRelay::getGd32Version(&snapVer); - DEBUG_INFO("WIFI", "OTA 状态: total=%lu, stored=%lu, done=%s, app_alive=%s", - (unsigned long)meta.total_size, (unsigned long)meta.stored_size, - WifiOta::instance().getGd32StorageDone() ? "true" : "false", - OtaRelay::getGd32AppAlive() ? "true" : "false", - snapVer.app_major, snapVer.app_minor, - snapVer.app_patch, snapVer.app_build, - snapVer.boot_major, snapVer.boot_minor, - snapVer.boot_patch, snapVer.boot_build); - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, len); - - return ESP_OK; -} - -/** - * @brief HTTP GET /health 处理器 - * @param req - * @return - */ -static esp_err_t health_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - DEBUG_INFO("WIFI", "=== HTTP: GET /health ==="); - - health_state_t state = HealthMonitor::instance().getCurrent(); - uint32_t free_heap = esp_get_free_heap_size(); - - char resp[128]; - int len = snprintf(resp, sizeof(resp), - "{\"health\": %d, \"free_heap\": %lu}", - state, (unsigned long)free_heap); - - DEBUG_INFO("WIFI", "健康状态: %d, 空闲堆: %lu", state, (unsigned long)free_heap); - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, len); - - return ESP_OK; -} - /** * @brief HTTP GET /logs - 拉取并清空日志缓冲区 * @param req @@ -1198,79 +1119,6 @@ static esp_err_t spi_send_handler(httpd_req_t *req) return ESP_OK; } -/** - * @brief HTTP GET /can/status 处理器 - * @param req - * @return - */ -static esp_err_t can_status_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - char resp[128]; - snprintf(resp, sizeof(resp), - "{\"channels\": 3, \"max_dlc\": %d, \"spi_init\": %s}", - CAN_MAX_DLC, - SpiMaster::instance().deviceHandle() != NULL ? "true" : "false"); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - return ESP_OK; -} - -/** - * @brief HTTP GET /wifi/status 处理器 - * @param req - * @return - */ -static esp_err_t wifi_status_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - char resp[256]; - char ip_str[16] = "0.0.0.0"; - int8_t rssi = -127; - - if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) { - esp_netif_ip_info_t ip_info; - esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) { - snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); - } - wifi_ap_record_t ap_info; - if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { - rssi = ap_info.rssi; - } - } - - int len = snprintf(resp, sizeof(resp), - "{\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}", - WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP ? "AP" : "STA", - ip_str, rssi, - (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA) ? (WifiOta::instance().getStaConnected() ? "true" : "false") : "N/A"); - - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, len); - - return ESP_OK; -} - -/** - * @brief HTTP GET /api/version 处理器 - 返回固件版本、编译时间、Git hash - */ -static esp_err_t version_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - gd32_version_t gv = {}; OtaRelay::getGd32Version(&gv); - char resp[512]; - int len = snprintf(resp, sizeof(resp), - "{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", " - "\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\", \"gd32_alive\": %s}", - FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, - gv.app_major, gv.app_minor, gv.app_patch, gv.app_build, - gv.boot_major, gv.boot_minor, gv.boot_patch, gv.boot_build, - OtaRelay::getGd32AppAlive() ? "true" : "false"); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, len); - return ESP_OK; -} /** * @brief HTTP GET /system/status 处理器 * @param req @@ -1419,21 +1267,6 @@ esp_err_t WifiOta::httpServerStart() }; httpd_register_uri_handler(WifiOta::instance().m_httpServer, &relay_options_uri); - httpd_uri_t status_uri = { - .uri = "/ota/status", - .method = HTTP_GET, - .handler = ota_status_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &status_uri); - httpd_uri_t status_options_uri = { - .uri = "/ota/status", - .method = HTTP_OPTIONS, - .handler = http_options_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &status_options_uri); - httpd_uri_t esp32_update_uri = { .uri = "/ota/update", .method = HTTP_POST, @@ -1464,36 +1297,6 @@ esp_err_t WifiOta::httpServerStart() }; httpd_register_uri_handler(WifiOta::instance().m_httpServer, &restart_options_uri); - httpd_uri_t esp32_status_uri = { - .uri = "/ota/esp32_status", - .method = HTTP_GET, - .handler = esp32_ota_status_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &esp32_status_uri); - httpd_uri_t esp32_status_options_uri = { - .uri = "/ota/esp32_status", - .method = HTTP_OPTIONS, - .handler = http_options_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &esp32_status_options_uri); - - httpd_uri_t health_uri = { - .uri = "/health", - .method = HTTP_GET, - .handler = health_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &health_uri); - httpd_uri_t health_options_uri = { - .uri = "/health", - .method = HTTP_OPTIONS, - .handler = http_options_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &health_options_uri); - httpd_uri_t logs_uri = { .uri = "/logs", .method = HTTP_GET, @@ -1509,21 +1312,6 @@ esp_err_t WifiOta::httpServerStart() }; httpd_register_uri_handler(WifiOta::instance().m_httpServer, &logs_options_uri); - httpd_uri_t wifi_status_uri = { - .uri = "/wifi/status", - .method = HTTP_GET, - .handler = wifi_status_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &wifi_status_uri); - httpd_uri_t wifi_status_options_uri = { - .uri = "/wifi/status", - .method = HTTP_OPTIONS, - .handler = http_options_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &wifi_status_options_uri); - httpd_uri_t system_status_uri = { .uri = "/system/status", .method = HTTP_GET, @@ -1555,22 +1343,6 @@ esp_err_t WifiOta::httpServerStart() }; httpd_register_uri_handler(WifiOta::instance().m_httpServer, &can_send_options_uri); - httpd_uri_t can_status_uri = { - .uri = "/can/status", - .method = HTTP_GET, - .handler = can_status_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &can_status_uri); - httpd_uri_t can_status_options_uri = { - .uri = "/can/status", - .method = HTTP_OPTIONS, - .handler = http_options_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &can_status_options_uri); - - DEBUG_INFO("WIFI", "HTTP 服务器已启动"); // SPI 直接发送 httpd_uri_t spi_send_uri = { .uri = "/spi/send", @@ -1586,23 +1358,8 @@ esp_err_t WifiOta::httpServerStart() .user_ctx = NULL }; httpd_register_uri_handler(WifiOta::instance().m_httpServer, &spi_send_options_uri); - // 版本信息端点 - httpd_uri_t version_uri = { - .uri = "/api/version", - .method = HTTP_GET, - .handler = version_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &version_uri); - httpd_uri_t version_options_uri = { - .uri = "/api/version", - .method = HTTP_OPTIONS, - .handler = http_options_handler, - .user_ctx = NULL - }; - httpd_register_uri_handler(WifiOta::instance().m_httpServer, &version_options_uri); - DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /ota/status, /ota/esp32_status, /health, /wifi/status, /system/status, /can/send, /can/status, /spi/send, /api/version"); + DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /restart, /logs, /system/status, /can/send, /spi/send"); return ESP_OK; } diff --git a/main/transport/protocol.cpp b/main/transport/protocol.cpp index ab48570..85b9079 100644 --- a/main/transport/protocol.cpp +++ b/main/transport/protocol.cpp @@ -5,7 +5,9 @@ * * 加密实现使用 ESP32-C3 硬件 AES 加速器(通过 mbedtls port) * - * 所有功能封装为 Protocol 类的静态方法。 + * 当前保留的接口:init、scrambleGenerate(nonce 生成)、 + * sessionKeyInit / sessionKeyClear(会话密钥生命周期管理)。 + * 帧构建、AES-CBC 加解密、CRC16 等中间件代码已移除。 */ #include #include @@ -29,225 +31,12 @@ void Protocol::init() if (m_initialized) return; DEBUG_INFO("PROT", "=== 初始化协议层 ==="); - DEBUG_INFO("PROT", "协议层功能: CRC16 Modbus + 硬件 AES-128-CBC"); + DEBUG_INFO("PROT", "协议层功能: nonce 扰码 + 会话密钥管理"); m_initialized = true; DEBUG_INFO("PROT", "协议层就绪"); } -/** - * @brief CRC16 Modbus(LE,低字节在前) - */ -uint16_t Protocol::crc16Modbus(const uint8_t *data, size_t len) -{ - uint16_t crc = 0xFFFF; - for (size_t i = 0; i < len; i++) { - crc ^= data[i]; - for (int j = 0; j < 8; j++) { - if (crc & 0x0001) { - crc = (crc >> 1) ^ 0xA001; - } else { - crc >>= 1; - } - } - } - return (crc >> 8) | (crc << 8); -} - -/** - * @brief 构建 SPI 帧: SOF + CMD + LEN(2LE) + PAYLOAD + CRC16(2LE) - */ -int Protocol::buildFrame(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_frame) -{ - if (len > FRAME_MAX_PAYLOAD) { - DEBUG_ERR("PROT", "Payload 太大: %zu > %d", len, FRAME_MAX_PAYLOAD); - return -1; - } - - size_t idx = 0; - out_frame[idx++] = FRAME_SOF; - out_frame[idx++] = cmd; - out_frame[idx++] = (uint8_t)(len & 0xFF); - out_frame[idx++] = (uint8_t)((len >> 8) & 0xFF); - - if (payload && len > 0) { - memcpy(&out_frame[idx], payload, len); - idx += len; - } - - uint16_t crc = crc16Modbus(out_frame, idx); - out_frame[idx++] = (uint8_t)(crc & 0xFF); - out_frame[idx++] = (uint8_t)((crc >> 8) & 0xFF); - - DEBUG_DBG("PROT", "帧构建: cmd=0x%02X, len=%zu, crc=0x%04X", cmd, len, crc); - - return idx; -} - -/** - * @brief 解析并校验一帧 SPI 协议数据。 - */ -int Protocol::parseFrame(const uint8_t *frame, size_t frame_len, - uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len) -{ - if (frame_len < 6) { - DEBUG_WARN("PROT", "帧太短: %zu < 6", frame_len); - return -1; - } - - size_t idx = 0; - - if (frame[idx++] != FRAME_SOF) { - DEBUG_WARN("PROT", "无效 SOF: 0x%02X (期望 0x%02X)", frame[0], FRAME_SOF); - return -1; - } - - uint8_t cmd = frame[idx++]; - - size_t payload_len = frame[idx] | (frame[idx + 1] << 8); - idx += 2; - - if (payload_len > FRAME_MAX_PAYLOAD) { - DEBUG_ERR("PROT", "Payload 长度太大: %zu > %d", payload_len, FRAME_MAX_PAYLOAD); - return -1; - } - - size_t expected_len = 1 + 1 + 2 + payload_len + 2; - if (frame_len < expected_len) { - DEBUG_WARN("PROT", "帧太短: %zu < %zu (期望)", frame_len, expected_len); - return -1; - } - - uint16_t received_crc = frame[idx + payload_len] | (frame[idx + payload_len + 1] << 8); - uint16_t calc_crc = crc16Modbus(frame, idx + payload_len); - - if (received_crc != calc_crc) { - DEBUG_WARN("PROT", "CRC 不匹配: 收到=0x%04X, 计算=0x%04X", received_crc, calc_crc); - return -1; - } - - DEBUG_DBG("PROT", "帧解析: cmd=0x%02X, len=%zu, crc=0x%04X OK", cmd, payload_len, calc_crc); - - if (out_payload && out_len) { - memcpy(out_payload, &frame[idx], payload_len); - *out_len = payload_len; - } - if (out_cmd) { - *out_cmd = cmd; - } - - return 0; -} - -/** - * @brief 使用 AES-128 CBC 对明文进行加密(PKCS#7 填充)。 - */ -int Protocol::aesEncrypt(const uint8_t *plaintext, size_t len, - const uint8_t *key, const uint8_t *iv, - uint8_t *ciphertext_out) -{ - if (!plaintext || !key || !iv || !ciphertext_out) { - DEBUG_ERR("PROT", "AES 加密参数无效"); - return -1; - } - - // PKCS#7 填充 - size_t padded_len = ((len / 16) + 1) * 16; - uint8_t padded[FRAME_MAX_PAYLOAD + 16]; - if (padded_len > sizeof(padded)) { - DEBUG_ERR("PROT", "填充后长度超出缓冲区: %zu", padded_len); - return -1; - } - - memcpy(padded, plaintext, len); - uint8_t pad_val = (uint8_t)(padded_len - len); - memset(&padded[len], pad_val, pad_val); - - DEBUG_DBG("PROT", "AES 加密: 明文=%zu 字节, 填充后=%zu 字节", len, padded_len); - - // 硬件 AES-CBC 加密(使用 ESP32-C3 硬件 AES 加速器) - esp_aes_context aes_ctx; - esp_aes_init(&aes_ctx); - - int ret = esp_aes_setkey(&aes_ctx, key, 128); - if (ret != 0) { - DEBUG_ERR("PROT", "esp_aes_setkey 失败: %d", ret); - esp_aes_free(&aes_ctx); - return -1; - } - - uint8_t iv_copy[16]; - memcpy(iv_copy, iv, 16); - - ret = esp_aes_crypt_cbc(&aes_ctx, ESP_AES_ENCRYPT, padded_len, iv_copy, padded, ciphertext_out); - - esp_aes_free(&aes_ctx); - - if (ret != 0) { - DEBUG_ERR("PROT", "esp_aes_crypt_cbc 加密失败: %d", ret); - return -1; - } - - DEBUG_DBG("PROT", "AES 加密成功: %zu -> %zu 字节", len, padded_len); - - return padded_len; -} - -/** - * @brief 使用 AES-128 CBC 对密文进行解密(去除 PKCS#7 填充)。 - */ -int Protocol::aesDecrypt(const uint8_t *ciphertext, size_t len, - const uint8_t *key, const uint8_t *iv, - uint8_t *plaintext_out) -{ - if (!ciphertext || !key || !iv || !plaintext_out) { - DEBUG_ERR("PROT", "AES 解密参数无效"); - return -1; - } - - if (len % 16 != 0 || len == 0) { - DEBUG_ERR("PROT", "Ciphertext 长度无效: %zu (必须为 16 的倍数)", len); - return -1; - } - - DEBUG_DBG("PROT", "AES 解密: 密文=%zu 字节", len); - - // 硬件 AES-CBC 解密(使用 ESP32-C3 硬件 AES 加速器) - esp_aes_context aes_ctx; - esp_aes_init(&aes_ctx); - - int ret = esp_aes_setkey(&aes_ctx, key, 128); - if (ret != 0) { - DEBUG_ERR("PROT", "esp_aes_setkey 失败: %d", ret); - esp_aes_free(&aes_ctx); - return -1; - } - - uint8_t iv_copy[16]; - memcpy(iv_copy, iv, 16); - - ret = esp_aes_crypt_cbc(&aes_ctx, ESP_AES_DECRYPT, len, iv_copy, ciphertext, plaintext_out); - - esp_aes_free(&aes_ctx); - - if (ret != 0) { - DEBUG_ERR("PROT", "esp_aes_crypt_cbc 解密失败: %d", ret); - return -1; - } - - // 移除 PKCS#7 填充 - uint8_t pad = plaintext_out[len - 1]; - if (pad > 16) { - DEBUG_ERR("PROT", "无效填充: %d", pad); - return -1; - } - - size_t orig_len = len - pad; - DEBUG_DBG("PROT", "AES 解密成功: %zu -> %zu 字节 (填充=%d)", len, orig_len, pad); - - return orig_len; -} - /** * @brief 根据时间戳、地址和随机种子生成 16 字节 nonce。 */ @@ -303,50 +92,4 @@ void Protocol::sessionKeyClear(session_ctx_t *sess) memset(sess->iv, 0, 16); sess->used = 0; } -} - -/** - * @brief 使用会话上下文加密数据。 - */ -int Protocol::sessionEncrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out) -{ - if (!sess || !in || !out) return -1; - - uint8_t iv_copy[16]; - memcpy(iv_copy, sess->iv, 16); - - int ret = aesEncrypt(in, len, sess->key, iv_copy, out); - - if (ret > 0) { - memcpy(sess->iv, &out[ret - 16], 16); - } - - return ret; -} - -/** - * @brief 使用会话上下文解密数据。 - */ -int Protocol::sessionDecrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out) -{ - if (!sess || !in || !out) return -1; - - uint8_t iv_copy[16]; - memcpy(iv_copy, sess->iv, 16); - - int ret = aesDecrypt(in, len, sess->key, iv_copy, out); - - if (ret > 0) { - memcpy(sess->iv, &in[len - 16], 16); - } - - return ret; -} - -/** - * @brief 使用 ESP-IDF 硬件 RNG 生成随机数 - */ -void Protocol::generateRandomNonce(uint8_t *nonce, size_t len) -{ - esp_fill_random(nonce, len); } \ No newline at end of file diff --git a/main/transport/protocol.h b/main/transport/protocol.h index 90e6b64..0bbc6d2 100644 --- a/main/transport/protocol.h +++ b/main/transport/protocol.h @@ -17,28 +17,6 @@ #define FRAME_MAX_PAYLOAD (64) // 安全最大 payload #define FRAME_OVERHEAD (6) // SOF + CMD + LEN(2) + CRC16(2) -// 命令码(对应 GD32 OTA 协议) -#define CMD_HANDSHAKE_REQ (0xF0) // 握手请求 -#define CMD_CHALLENGE (0xF1) // 挑战/随机数 -#define CMD_SESSION_KEY (0xF2) // 会话密钥 -#define CMD_SEND_SIZE (0xF3) // 发送固件大小 -#define CMD_DATA_PACKET (0xF4) // 数据包 -#define CMD_DATA_END (0xF5) // 数据结束 -#define CMD_TRANSFER_DONE (0xF6) // 传输完成 -#define CMD_FINISH_OTA (0xF7) // 结束 OTA -#define CMD_ACK (0x80) // 通用 ACK -#define CMD_ERROR (0x81) // 通用错误 - -// ACK 状态码 -#define ACK_OK (0x00) // 成功 -#define ACK_UNSUPPORTED (0x01) // 命令不支持 -#define ACK_AUTH_FAILED (0x02) // 认证失败 -#define ACK_SIZE_OOR (0x03) // 大小超出范围 -#define ACK_CRC_FAILED (0x04) // CRC 校验失败 -#define ACK_RETRY_EXCEED (0x05) // 重试次数超限 -#define ACK_SESSION_INVALID (0x06) // 会话密钥无效 -#define ACK_FLASH_FAILED (0x07) // Flash 写入失败 - // 安全常量(定义在 app_config.h 中) inline constexpr size_t MASTER_KEY_HERE_LEN = 16; inline constexpr size_t SCRAMBLE_CONST_HERE_LEN = 16; @@ -63,23 +41,10 @@ public: void init(); - static uint16_t crc16Modbus(const uint8_t *data, size_t len); - static int buildFrame(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_frame); - static int parseFrame(const uint8_t *frame, size_t frame_len, - uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len); - static int aesEncrypt(const uint8_t *plaintext, size_t len, - const uint8_t *key, const uint8_t *iv, - uint8_t *ciphertext_out); - static int aesDecrypt(const uint8_t *ciphertext, size_t len, - const uint8_t *key, const uint8_t *iv, - uint8_t *plaintext_out); static void scrambleGenerate(uint32_t timestamp, uint32_t mem_addr, uint32_t rand_seed, uint8_t *nonce_out); static int sessionKeyInit(session_ctx_t *sess, const uint8_t *k_session); static void sessionKeyClear(session_ctx_t *sess); - static int sessionEncrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out); - static int sessionDecrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t *out); - static void generateRandomNonce(uint8_t *nonce, size_t len); private: Protocol() = default; diff --git a/tools/ble_ota.html b/tools/ble_ota.html index cee6a0f..5afa9ae 100644 --- a/tools/ble_ota.html +++ b/tools/ble_ota.html @@ -1114,22 +1114,19 @@ log('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W'); } - // 计算文件的 SHA-256 哈希值用于完整性验证 - setBleStage(4, '计算SHA-256...', null); - setBleStageText('正在计算文件哈希...'); + // SHA-256 已在文件选择时(updateFileInfo)计算并存入 state.fileSha256。 + // 此处不再重复打日志(避免与 [FILE] SHA-256 重复),仅保证 fileSha256 变量有值。 var fileSha256; if (state.fileSha256) { - // 复用已计算的 SHA-256 fileSha256 = state.fileSha256.toLowerCase(); - log('OTA', `复用已计算的 SHA-256: ${fileSha256.toUpperCase()}`); } else { + // 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置) log('OTA', '正在计算文件 SHA-256 哈希...'); const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); state.fileSha256 = fileSha256.toUpperCase(); } - log('OTA', `文件 SHA-256: ${fileSha256.toUpperCase()}`); // Update SHA display in file info var shaElem = document.getElementById('fileSha256'); if (shaElem) shaElem.textContent = fileSha256.toUpperCase(); @@ -1296,12 +1293,22 @@ } async function autoFlash() { - // 每次刷写前尝试重新从本地读取文件 + // 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择) if (state.selectedFileHandle) { try { const freshFile = await state.selectedFileHandle.getFile(); - updateFileInfo(freshFile); - log('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')'); + // 仅在文件变化时才刷新元数据并重算 SHA-256 + // (name/size/lastModified 任一不同即视为变化) + // 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志 + const cached = state.selectedFile; + const changed = !cached || + cached.name !== freshFile.name || + cached.size !== freshFile.size || + cached.lastModified !== freshFile.lastModified; + if (changed) { + updateFileInfo(freshFile); + log('FILE', '已重新读取本地文件(已变更): ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')'); + } } catch (e) { log('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); }