diff --git a/.vscode/settings.json b/.vscode/settings.json index 839f82b..953ffdd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" }, diff --git a/.zcode/plans/plan-sess_be551324-a74a-4d44-b63e-8e00c515c2d5.md b/.zcode/plans/plan-sess_be551324-a74a-4d44-b63e-8e00c515c2d5.md new file mode 100644 index 0000000..1198208 --- /dev/null +++ b/.zcode/plans/plan-sess_be551324-a74a-4d44-b63e-8e00c515c2d5.md @@ -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 终态判定(之前加的)继续生效 \ No newline at end of file diff --git a/README.md b/README.md index 474b03b..2d66724 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,11 @@ ESP32_COMM/ ├── 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) +│ │ ├── version.h # 手动维护的版本信息 (v0.3.56) │ │ └── ota_mutex.h # BLE/WiFi OTA 全局互斥 │ ├── core/main.cpp # app_main 入口,4 个 FreeRTOS 任务 │ ├── relay/ota_relay.{h,cpp} # GD32 OTA 中继事件驱动状态机 @@ -626,8 +624,7 @@ CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 ## 固件版本管理 -位于 `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`,仅在内容变化时写入以避免不必要的重编译 +版本信息位于 `main/include/version.h`,**手动维护**,不再由构建脚本自动生成: +- **版本号**: `0.3.56`(发版时手动递增 `FW_VERSION_MAJOR/MINOR/PATCH` 及 `FW_VERSION_STRING`) +- **构建时间**: `BUILD_TIME`,每次发版编译时手动填写 +- **Git Hash**: `GIT_HASH`,手动填写对应提交的 `git rev-parse --short HEAD` 结果 diff --git a/cmake/gen_version_script.cmake b/cmake/gen_version_script.cmake deleted file mode 100644 index df2e2ff..0000000 --- a/cmake/gen_version_script.cmake +++ /dev/null @@ -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() diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 947958e..4cef60d 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -16,6 +16,9 @@ idf_component_register( "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" # 任务栈水位收集 + 溢出钩子 INCLUDE_DIRS @@ -46,12 +49,3 @@ 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 -) \ No newline at end of file diff --git a/main/core/main.cpp b/main/core/main.cpp index 74a492e..ac4bd25 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -34,6 +34,7 @@ #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, @@ -125,6 +126,8 @@ extern "C" void app_main(void) SystemBoot::mark(BOOT_CAN_FB_TASK); } + motorOtaRelayInit(); + if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) { SystemBoot::mark(BOOT_OTA_VALID); } diff --git a/main/include/app_config.h b/main/include/app_config.h index 0909e8f..b86b264 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -3,7 +3,7 @@ * ESP32-C3 OTA 中继项目 * * 使用 constexpr 代替 #define,提供类型安全 - * 版本号和编译时间由 version.h 自动生成 + * 版本号和编译时间在 version.h 中手动维护 */ #pragma once #include @@ -17,11 +17,11 @@ inline constexpr uint8_t CONFIG_WIFI_MODE = 1; // ============================================================ -// 固件版本(由 version.h 自动生成,此处提供 C++ constexpr 访问) +// 固件版本(在 version.h 中手动维护,此处提供 C++ constexpr 访问) // ============================================================ -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 提交哈希(自动生成) +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 STA 模式配置(连接路由器) diff --git a/main/include/version.h b/main/include/version.h index 1aee816..a5d66aa 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -1,14 +1,22 @@ -// Auto-generated version info - DO NOT EDIT +/* + * version.h - 固件版本信息(手动维护) + * + * 注意:本文件不再由构建脚本自动生成。如需修改版本号、编译时间或 + * Git Hash,请直接编辑本文件。 + * - FW_VERSION_MAJOR / MINOR / PATCH:发版时手动递增 + * - BUILD_TIME:每次发版编译时手动填写 + * - GIT_HASH:手动填写对应提交的 git short hash + */ #pragma once // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 53 -#define FW_VERSION_STRING "0.3.53" +#define FW_VERSION_PATCH 71 +#define FW_VERSION_STRING "0.3.71" -// 编译时间 -#define BUILD_TIME "2026-07-21 00:23:44" +// 编译时间(手动填写) +#define BUILD_TIME "2026-07-26 20:25:00" -// Git 信息 -#define GIT_HASH "00b716c" +// Git 信息(手动填写) +#define GIT_HASH "770ae14" \ No newline at end of file diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index e92322e..4e946e3 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -22,6 +22,9 @@ #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 "app_config.h" #include "esp_system.h" @@ -33,6 +36,9 @@ constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超 constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔 constexpr uint8_t kSpiCanFeedbackType = 0xC1U; 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 kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断 constexpr const char *kTag = "CAN_FB"; @@ -102,6 +108,24 @@ static void taskLoop(void *) continue; } + /* 0xC5 多电机状态推送:GD32 250ms 分页推送,ESP32 解析入缓存 */ + if (ring_type == kSpiMotorStateType) { + (void)onMotorStateEvent(ring_payload, ring_len); + continue; + } + + /* 0xC6 多电机版本推送:GD32 收到电机版本应答(0x00FF)后事件驱动推送 */ + if (ring_type == kSpiMotorVersionType) { + (void)onMotorVersionEvent(ring_payload, ring_len); + continue; + } + + /* 0xD2 电机 OTA relay 进度推送 */ + if (ring_type == kSpiMotorOtaStatusType) { + onMotorOtaStatusPush(ring_payload, ring_len); + continue; + } + if (ring_type != kSpiCanFeedbackType || ring_len < 28U) { continue; } diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index f37c222..721a96b 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -6,7 +6,7 @@ * 2. UDP 单播到指定上位机(独立 UDP 调试接收端可选) * 3. 内部环形缓冲区(供 HTTP /logs 拉取,WebUI 实际使用这一条) * - * UART0: TX=GPIO21, RX=GPIO20, 115200 波特率 + * UART0: TX=GPIO21, RX=GPIO20, 3000000 波特率 * UDP: : * * 时序说明: @@ -68,17 +68,6 @@ bool DebugLog::isKeepAliveTag(const char *tag) return false; } -char *DebugLog::allocEmptyJson(int *out_len) -{ - /* 返回 nullptr 让 caller 走"[]"字面量分支,避免空环时每秒 malloc(3)/free(3)。 - * ringDrainJson 的所有"返空"路径都调本函数,caller logs_handler 见 nullptr - * 后直接发 "[]" 字面量,零分配。 */ - if (out_len) { - *out_len = 0; - } - return nullptr; -} - /** * JSON 字符串内容转义写入 buf[pos..];返回新 pos。 * 控制字符除 \n\r\t 外写成 \u00XX;空间不足时截断并尽量写 "..."。 @@ -384,97 +373,62 @@ void DebugLog::ringPush(const char *data, int len) } /** - * 单次持锁:peek 元数据 →(可选)按堆缩小条数 → pop+转义拼 JSON。 - * capacity 按真实 raw 长度估算,避免 48×320 虚高导致 OTA 期返空。 + * 流式导出:peek 快照条数 → 逐条 pop(仅 pop 期间持锁)转义拼 JSON → + * 攒满 512B 栈缓冲即经 sink 发一个 chunk。替代原 ~4KB 一次性 malloc, + * 把 /logs 发送路径的应用层瞬时峰值从几 KB 降到栈缓冲级别。 */ -char *DebugLog::ringDrainJson(int *out_len) +int DebugLog::ringDrainJsonStream(LogJsonSink sink, void *ctx) { - if (!out_len) { - return nullptr; + if (!sink) { + return 0; } - *out_len = 0; const uint32_t free_now = esp_get_free_heap_size(); if (free_now < kDrainMinFree || !m_logRingMutex) { - return allocEmptyJson(out_len); + return 0; /* 低堆/未就绪:caller 一次性发 "[]" */ } + /* 先 peek 快照条数:无内容则让 caller 走 "[]" 一次性响应,不进 chunked 编码。 */ xSemaphoreTake(m_logRingMutex, portMAX_DELAY); - int total_raw = 0; int drain_n = ringPeekDrainMetaLocked(LOG_DRAIN_MAX, &total_raw); - if (drain_n <= 0) { - xSemaphoreGive(m_logRingMutex); - return allocEmptyJson(out_len); - } - - /* 转义最坏 ~2x + 每条 "" 与逗号 + 数组括号 */ - auto calc_capacity = [](int n, int raw) -> int { - int cap = raw * 2 + n * 4 + 16; - if (cap < 16) { - cap = 16; - } - return cap; - }; - - int capacity = calc_capacity(drain_n, total_raw); - const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - - /* 堆不够则减条数重估(仍持锁,peek 只读;减 n 时从最旧开始仍一致) */ - while (drain_n > 0 && largest < (size_t)capacity) { - drain_n = drain_n / 2; - if (drain_n == 0) { - break; - } - total_raw = 0; - drain_n = ringPeekDrainMetaLocked(drain_n, &total_raw); - capacity = calc_capacity(drain_n, total_raw); - } - - if (drain_n <= 0 || largest < (size_t)capacity) { - xSemaphoreGive(m_logRingMutex); - return allocEmptyJson(out_len); - } - - /* 持锁期间尽量短:只 pop 到栈上临时区会爆栈;改为直接写入 malloc 缓冲。 - * 先释放锁再 malloc,再二次持锁 pop——中间可能插入新日志,只 drain 最旧 N 条即可。 */ xSemaphoreGive(m_logRingMutex); - - char *buf = (char *)malloc((size_t)capacity); - if (!buf) { - return allocEmptyJson(out_len); + if (drain_n <= 0) { + return 0; } - xSemaphoreTake(m_logRingMutex, portMAX_DELAY); - - /* 重新核对:ring 可能变少 */ - if (m_logRingCount <= 0) { - xSemaphoreGive(m_logRingMutex); - free(buf); - return allocEmptyJson(out_len); - } - if (drain_n > m_logRingCount) { - drain_n = m_logRingCount; - } - if (drain_n > LOG_DRAIN_MAX) { - drain_n = LOG_DRAIN_MAX; - } + /* 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]; for (int k = 0; k < drain_n; ++k) { - /* 先估空间再 pop,避免 pop 后写不下导致丢条 */ - const int need_min = first ? 4 : 5; /* ,"x" 或 "x" 量级 + 闭合余量 */ - if (pos + need_min >= capacity - 2) { - break; + /* 逐条 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) */ } - int entry_len = 0; - if (!ringPopOldestLocked(entry, sizeof(entry), &entry_len) || entry_len <= 0) { - break; + /* 预留单条转义最坏占用再决定是否 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) { @@ -482,27 +436,26 @@ char *DebugLog::ringDrainJson(int *out_len) } first = false; buf[pos++] = '"'; - pos = jsonEscapeAppend(buf, pos, capacity, entry, entry_len); - if (pos < capacity - 1) { + pos = jsonEscapeAppend(buf, pos, STREAM_BUF_CAP, entry, entry_len); + if (pos < STREAM_BUF_CAP - 1) { buf[pos++] = '"'; - } else if (pos < capacity) { - buf[pos - 1] = '"'; /* 极端:覆盖末字节闭合 */ + } else { + buf[pos - 1] = '"'; /* 极端兜底:覆盖末字节闭合 */ } } - if (pos + 1 < capacity) { - buf[pos++] = ']'; - buf[pos] = '\0'; - } else if (capacity >= 3) { - buf[capacity - 2] = ']'; - buf[capacity - 1] = '\0'; - pos = capacity - 1; + /* 收尾 ']':缓冲放不下先 flush,再单独发闭合。 */ + if (pos + 1 > STREAM_BUF_CAP) { + if (!sink(ctx, buf, pos)) { + return -1; + } + pos = 0; } - - xSemaphoreGive(m_logRingMutex); - - *out_len = pos; - return buf; + buf[pos++] = ']'; + if (!sink(ctx, buf, pos)) { + return -1; + } + return 1; } // ============================================================ diff --git a/main/monitor/debug_log.h b/main/monitor/debug_log.h index 92f375f..dc68ed5 100644 --- a/main/monitor/debug_log.h +++ b/main/monitor/debug_log.h @@ -9,7 +9,7 @@ * 注意:当前 tools/WebUI 日志面板走的是 HTTP /logs, * 不是直接监听 UDP 6666。 * - * UART0: TX=GPIO21, RX=GPIO20, 115200 波特率 + * UART0: TX=GPIO21, RX=GPIO20, 3000000 波特率 * * 线程模型:DEBUG_* / log() / send() 仅允许在任务上下文调用 * (内部使用 portMAX_DELAY 互斥锁,禁止 ISR)。 @@ -31,7 +31,7 @@ // 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 { @@ -41,6 +41,12 @@ typedef enum { 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 调试日志类(单例) * @@ -79,11 +85,15 @@ public: bool ringPopOldest(char *out, size_t out_size, int *out_len); /** - * @brief 将环形缓冲区日志导出为 JSON 数组字符串。 - * @param out_len 输出 JSON 字符串长度。 - * @return 动态分配的 JSON 字符串,调用者须 free();失败返回 NULL。 + * @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); void setLogLevel(debug_log_level_t level) { m_logLevel = level; } debug_log_level_t getLogLevel() const { return m_logLevel; } @@ -129,7 +139,7 @@ private: * free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO * free < kDropWarnFree → 全局丢 DBG * free < kSkipUdpFree → 不 sendto(仍 UART+ring) - * free < kDrainMinFree → /logs 返 "[]" 避免大块 malloc + * 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; @@ -151,7 +161,6 @@ private: int ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const; static bool isKeepAliveTag(const char *tag); - static char *allocEmptyJson(int *out_len); static int jsonEscapeAppend(char *buf, int pos, int capacity, const char *src, int src_len); }; diff --git a/main/monitor/motor_ota_relay.cpp b/main/monitor/motor_ota_relay.cpp new file mode 100644 index 0000000..70fa8c3 --- /dev/null +++ b/main/monitor/motor_ota_relay.cpp @@ -0,0 +1,458 @@ +/* + * 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 + +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; +} + +/* ═══════════════════ 喂数据 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; + + /* 启动宽限期:GD32 需要时间完成 kPreCancel(800ms 残帧窗口) + 握手(~1s)。 + * 在这个窗口内不检查终态,避免上一次 OTA 残留的 ERROR 状态推送 + * (0xD2 ring) 抢先覆写 s_status 导致 feedTask 误判退出。 + * 原 3s 过保守:GD32 进握手后不再发 ERROR push,1500ms 足够覆盖 + * kPreCancel + 开始握手(kBootSettleMs 已降到 800ms)。 */ + const int64_t grace_end_ms = nowMs() + 1500LL; + + /* 重新启用 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 ack_n = 0U; /* 成功 ACK 计数,用于降频写 s_status */ + + 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 超时。 + * 启动宽限期(3s)内跳过终态检查:避免上一次 OTA 残留的 ERROR 状态推送 + * (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 + * GD32 需 kPreCancel(500ms 沉淀)+握手(~1s) 才能进入传输态。 */ + const motor_ota_status_t st = motorOtaGetStatus(); + if (nowMs() >= grace_end_ms) { + if (st.state == MOTOR_OTA_IDLE || st.state == MOTOR_OTA_FINISHED || + st.state == MOTOR_OTA_ERROR) { + ESP_LOGW(TAG, "GD32 终态 0x%02X,停止喂数据 offset=%u/%u", + (int)st.state, (unsigned)s_fw_offset, (unsigned)s_fw_size); + break; + } + } + /* 注:旧版有 "GD32 回到握手阶段→重置 offset" 的逻辑, + * 但 v1.4.38+ 已移除握手重试(0x05 直接中止不重握手), + * 且 v1.4.58 feedData 在握手期间就接受数据(预填充缓冲)。 + * 若在此重置 offset,会导致缓冲中数据重复。已移除。 */ + +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(s_fw_offset & 0xFFU); + chunk_buf[1] = static_cast((s_fw_offset >> 8) & 0xFFU); + chunk_buf[2] = static_cast((s_fw_offset >> 16) & 0xFFU); + chunk_buf[3] = static_cast((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; + 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) { + motor_ota_state_t qst = static_cast(qp[0]); + if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED || + qst == MOTOR_OTA_ERROR) { + ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u", + (int)qst, (unsigned)qp[1], + (unsigned)s_fw_offset, (unsigned)s_fw_size); + if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + s_status.state = qst; + s_status.error_code = qp[1]; + if (ql >= 6U) { + s_status.packets_acked = static_cast(qp[2]) + | (static_cast(qp[3]) << 8); + s_status.total_packets = static_cast(qp[4]) + | (static_cast(qp[5]) << 8); + } + s_status.last_update_ms = nowMs(); + xSemaphoreGive(s_mutex); + } + break; + } + } + } + continue; /* 不推进 offset,同 offset 重发 */ + } + consec_fail = 0U; + + if (resp_type == kRspAck) { + s_fw_offset += data_len; + /* 进度:每 8 chunk 写 s_status;state 在 suppress 下不会收 0xD2, + * 喂数一开始就标 TRANSFER,避免网页卡在「开始握手」。 */ + const bool touch_status = ((++ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size); + if (touch_status || ack_n == 1U) { + const uint16_t fed_pkts = static_cast((s_fw_offset + 47U) / 48U); + if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) { + if (s_status.state < MOTOR_OTA_TRANSFER || + s_status.state == MOTOR_OTA_PRE_CANCEL || + s_status.state == MOTOR_OTA_HANDSHAKE_REQ || + s_status.state == MOTOR_OTA_HANDSHAKE_NONCE || + s_status.state == MOTOR_OTA_HANDSHAKE_CONFIRM || + s_status.state == MOTOR_OTA_ERASE) { + 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) { + /* 缓冲满:不推进 offset,稍候用新 TX_SEQ 再发同一 offset。 + * WAIT 路径 SPI 层已推进 seq(见 otaPollResponseUnlocked), + * 避免同 seq 重发被 GD32 当重复请求死锁。 */ + vTaskDelay(pdMS_TO_TICKS(2)); + } 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。 */ + { + constexpr uint32_t kExitPollMs = 8000U; + constexpr uint32_t kExitGapMs = 150U; + const uint32_t exit_deadline = nowMs() + kExitPollMs; + bool got_terminal = false; + while (nowMs() < exit_deadline) { + /* 优先吃 ring 推送(已恢复 suppress) */ + { + const motor_ota_status_t st = motorOtaGetStatus(); + if (st.state == MOTOR_OTA_FINISHED || st.state == MOTOR_OTA_ERROR || + st.state == MOTOR_OTA_IDLE) { + 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; + } + } + uint8_t qcmd[1] = {2U}; /* cmd=QUERY */ + uint8_t qresp_type = 0; + uint8_t qresp[16] = {0}; + size_t qresp_len = 0; + esp_err_t qr = SpiMaster::instance().transact( + kSpiMotorOtaCmd, qcmd, 1, + &qresp_type, qresp, sizeof(qresp), &qresp_len, 300U); + if (qr == ESP_OK && qresp_type == kRspAck && qresp_len >= 10U) { + const motor_ota_state_t qst = static_cast(qresp[0]); + if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + s_status.state = qst; + s_status.error_code = qresp[1]; + s_status.packets_acked = static_cast(qresp[2]) + | (static_cast(qresp[3]) << 8); + s_status.total_packets = static_cast(qresp[4]) + | (static_cast(qresp[5]) << 8); + s_status.fw_size = static_cast(qresp[6]) + | (static_cast(qresp[7]) << 8) + | (static_cast(qresp[8]) << 16) + | (static_cast(qresp[9]) << 24); + s_status.last_update_ms = nowMs(); + xSemaphoreGive(s_mutex); + } + if (qst == MOTOR_OTA_FINISHED || qst == MOTOR_OTA_ERROR || + qst == MOTOR_OTA_IDLE) { + ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u", + (unsigned)qst, (unsigned)qresp[1], + (unsigned)(static_cast(qresp[2]) + | (static_cast(qresp[3]) << 8)), + (unsigned)(static_cast(qresp[4]) + | (static_cast(qresp[5]) << 8))); + 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(payload[0]); + s_status.error_code = payload[1]; + s_status.packets_acked = static_cast(payload[2]) | (static_cast(payload[3]) << 8); + s_status.total_packets = static_cast(payload[4]) | (static_cast(payload[5]) << 8); + s_status.fw_size = static_cast(payload[6]) + | (static_cast(payload[7]) << 8) + | (static_cast(payload[8]) << 16) + | (static_cast(payload[9]) << 24); + s_status.buf_used = static_cast(payload[10]) | (static_cast(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(fw_size & 0xFF); + cmd[3] = static_cast((fw_size >> 8) & 0xFF); + cmd[4] = static_cast((fw_size >> 16) & 0xFF); + cmd[5] = static_cast((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) { + 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((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 退出最多 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 *) { + /* 通知 GD32 取消 relay 状态机 */ + 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); + DEBUG_WARN(TAG, "cancel: GD32 resp ret=%s type=0x%02X", esp_err_to_name(ret), resp_type); + + /* 等待 feedTask 真正退出(最多 2s),避免 s_feed_running 已清但 task + * 仍卡在 SpiMaster::transact 阻塞中,导致 motorOtaIsActive() 瞬时仍 true。 */ + 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) { + DEBUG_ERR(TAG, "cancel: feedTask 未在 2s 内退出,强制删除"); + vTaskDelete(s_feed_task); + s_feed_task = nullptr; + } + } + s_feed_running = false; /* 双保险 */ + 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 情况) */ + 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; + } + } + s_feed_running = false; + } + 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; +} diff --git a/main/monitor/motor_ota_relay.h b/main/monitor/motor_ota_relay.h new file mode 100644 index 0000000..0b05b7c --- /dev/null +++ b/main/monitor/motor_ota_relay.h @@ -0,0 +1,97 @@ +/* + * 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 +#include +#include + +#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_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 */ diff --git a/main/monitor/motor_state.cpp b/main/monitor/motor_state.cpp new file mode 100644 index 0000000..70c31d7 --- /dev/null +++ b/main/monitor/motor_state.cpp @@ -0,0 +1,137 @@ +/* + * motor_state.cpp - GD32 多电机状态推送解析与缓存 (SPI TYPE 0xC5) + */ +#include "monitor/motor_state.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include + +namespace { + +static SemaphoreHandle_t s_mux = nullptr; +static motor_state_table_t s_table = {}; + +static uint32_t rdU32Le(const uint8_t *p) +{ + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +static void ensureMux() +{ + if (s_mux == nullptr) { + s_mux = xSemaphoreCreateMutex(); + } +} + +} // 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(entry_count) * MOTOR_ENTRY_SIZE; + if (len < expected) { + return false; + } + + ensureMux(); + 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(active_cnt & 0x7FU); + s_table.boot_active = (active_cnt & 0x80U) ? 1U : 0U; + } + + /* 逐条解析 entry */ + 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]; + if (node < 1 || node > MOTOR_MAX_NODES) { + offset += MOTOR_ENTRY_SIZE; + continue; + } + + motor_slot_t &slot = s_table.slots[node - 1]; + slot.node_id = node; + slot.channel = e[1]; + 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); + + offset += MOTOR_ENTRY_SIZE; + } + + /* 统计有效槽位 */ + uint8_t valid = 0; + for (uint8_t i = 0; i < MOTOR_MAX_NODES; ++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; + ensureMux(); + 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) +{ + ensureMux(); + 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); + } + if (!have || recv <= 0) return UINT32_MAX; + + const int64_t now = esp_timer_get_time() / 1000; + if (now < recv) return 0U; + const int64_t age = now - recv; + return (age > static_cast(UINT32_MAX)) ? UINT32_MAX + : static_cast(age); +} diff --git a/main/monitor/motor_state.h b/main/monitor/motor_state.h new file mode 100644 index 0000000..0e3a272 --- /dev/null +++ b/main/monitor/motor_state.h @@ -0,0 +1,72 @@ +/* + * 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 44B: + * [0] node_id, [1] channel, [2] online, [3] dlc, + * [4..7] rx_count (LE), [8..11] seq (LE), + * [12..43] data[32] (原始 STATUS payload) + */ +#ifndef MOTOR_STATE_H +#define MOTOR_STATE_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MOTOR_MAX_NODES 9 +#define MOTOR_STATUS_DLC 32 +#define MOTOR_ENTRY_SIZE 44 +#define MOTOR_PAGE_HEADER 4 +#define MOTOR_TOTAL_PAGES 2 + +/** @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 (正常=32) + uint32_t rx_count; ///< 累计收到帧数 + uint32_t seq; ///< GD32 侧更新序号 + uint8_t data[MOTOR_STATUS_DLC]; ///< 原始 STATUS payload +} motor_slot_t; + +/** @brief 全局电机状态表。 */ +typedef struct { + motor_slot_t slots[MOTOR_MAX_NODES]; ///< 按 node_id-1 索引 + 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 */ diff --git a/main/monitor/motor_version.cpp b/main/monitor/motor_version.cpp new file mode 100644 index 0000000..527b54a --- /dev/null +++ b/main/monitor/motor_version.cpp @@ -0,0 +1,84 @@ +/* + * motor_version.cpp - GD32 多电机版本推送解析与缓存 (SPI TYPE 0xC6) + * + * 镜像 motor_state.cpp 的互斥保护缓存模型。 + */ +#include "monitor/motor_version.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include + +namespace { + +static SemaphoreHandle_t s_mux = nullptr; +static motor_version_table_t s_table = {}; + +static uint32_t rdU32Le(const uint8_t *p) +{ + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +static void ensureMux() +{ + if (s_mux == nullptr) { + s_mux = xSemaphoreCreateMutex(); + } +} + +} // 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]; + + ensureMux(); + 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; + ensureMux(); + 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; +} diff --git a/main/monitor/motor_version.h b/main/monitor/motor_version.h new file mode 100644 index 0000000..00e6842 --- /dev/null +++ b/main/monitor/motor_version.h @@ -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 +#include +#include + +#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 */ diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 881daf7..a120652 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -6,6 +6,7 @@ */ #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" @@ -142,6 +143,14 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch // ★ 先做卡死自愈检查:若之前忙状态卡死,自动转 ERROR 继续后续流程 if (h) watchdogCheckLocked(); + // ★ 电机 OTA 互斥:电机喂数任务与 GD32 固件共用 gd32_ota 分区, + // 电机喂数期间禁止新的 GD32 存储,避免分区被并发覆写。 + if (h && motorOtaIsActive()) { + DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 GD32 存储"); + unlock(h); + return ESP_ERR_INVALID_STATE; + } + // ★ 同类型重启:若上一次 GD32 存储仍在进行中(STORING)且来自同一通道, // 自动中止旧存储并重新开始。覆盖"上传过程中刷新网页再上传"场景。 // 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。 @@ -412,6 +421,14 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann // ★ 卡死自愈检查 if (h) watchdogCheckLocked(); + // ★ 电机 OTA 互斥:ESP32 自身 OTA 会重启设备,中断正在进行的电机喂数, + // 电机 OTA 期间拒绝 ESP32 OTA。 + if (h && motorOtaIsActive()) { + DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 ESP32 OTA"); + unlock(h); + return ESP_ERR_INVALID_STATE; + } + // ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道, // 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。 // 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。 @@ -499,6 +516,15 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe } else { 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); } @@ -597,7 +623,8 @@ void OtaCoordinator::doAbortLocked() if (m_sysState == OTA_SYS_GD32_STORING) { Gd32FlashStore::instance().metaClear(); } else if (m_sysState == OTA_SYS_GD32_RELAYING) { - OtaRelay::instance().reset(); + /* 中继中止必须 CANCEL 远端,否则 GD32 停在 WAITING_* 半会话 */ + OtaRelay::instance().abortRemoteAndReset(true); } else if (m_sysState == OTA_SYS_ESP32_RECEIVING || m_sysState == OTA_SYS_ESP32_FINALIZING) { Esp32OtaCore::instance().abort(); diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index f947386..a37de68 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -458,7 +458,7 @@ ota_result_t OtaRelay::forwardFirmware() if (meta.total_size == 0U || meta.stored_size < meta.total_size) { DEBUG_ERR("OTA", "固件未完整存储 stored=%" PRIu32 " total=%" PRIu32, meta.stored_size, meta.total_size); - reset(); + abortRemoteAndReset(true); /* 顺带清上次远端半会话 */ return OTA_RESULT_ERR_STORAGE; } { @@ -468,7 +468,7 @@ ota_result_t OtaRelay::forwardFirmware() esp_err_t verify_err = Gd32FlashStore::instance().metaVerifyChecksum(); if (verify_err != ESP_OK) { DEBUG_ERR("OTA", "固件完整性校验失败,中止转发"); - reset(); + abortRemoteAndReset(true); return OTA_RESULT_ERR_CRC; } } else { @@ -498,7 +498,8 @@ ota_result_t OtaRelay::forwardFirmware() if (res != OTA_RESULT_OK) { DEBUG_ERR("OTA", "中继失败: result=%d, state=%d", (int)res, (int)getState()); - reset(); + /* 必须通知 GD32 CANCEL,否则远端停在 WAITING_* 半会话 */ + abortRemoteAndReset(true); } else { /* 成功路径:解除按铃抑制(reset 也会做,成功时状态机可能停在 DONE) */ SpiMaster::instance().setSuppressRingService(false); @@ -547,6 +548,39 @@ void OtaRelay::reset() DEBUG_INFO("OTA", "状态机已复位 → IDLE"); } +/** + * @brief 向 GD32 发送 OTA CANCEL,驱动远端 resetToIdle(含 CREDIT/缓冲清理)。 + * + * 失败路径必须调用:本端 reset() 只清 ESP32 状态机,GD32 仍可能停在 + * WAITING_RANDOM / WAITING_CONFIRM 等半会话,导致下次握手/进度错乱。 + */ +esp_err_t OtaRelay::sendRemoteCancel() +{ + uint8_t resp_type = 0; + uint8_t resp_payload[8] = {0}; + size_t resp_len = 0; + /* payload 可空;GD32 CANCEL 分支无条件 resetToIdle 并 ACK 0x03 */ + esp_err_t err = SpiMaster::instance().transact( + SPI_OTA_CMD_CANCEL, nullptr, 0, + &resp_type, resp_payload, sizeof(resp_payload), &resp_len, + 1500U); + if (err != ESP_OK) { + DEBUG_WARN("OTA", "远端 CANCEL 发送失败: %s(本端仍复位)", esp_err_to_name(err)); + return err; + } + DEBUG_INFO("OTA", "远端 CANCEL 已发送, resp t=0x%02X n=%zu", resp_type, resp_len); + return ESP_OK; +} + +void OtaRelay::abortRemoteAndReset(bool notify_remote) +{ + if (notify_remote) { + /* 中继失败时 suppress 可能仍为 true,CANCEL 需能走 SPI 事务 */ + (void)sendRemoteCancel(); + } + reset(); +} + // ============================================================ // 内部工具函数(不变) // ============================================================ @@ -965,8 +999,12 @@ ota_result_t OtaRelay::sendAndWaitAckShape(const char *stage, uint8_t cmd, esp_err_t err = SpiMaster::instance().transact(cmd, payload, payload_len, &resp_type, resp_payload, resp_max, resp_len, transact_timeout_ms); logRespPayload(stage, resp_type, resp_payload, *resp_len); - err = pollUntilExpectedAckShape(stage, &resp_type, resp_payload, resp_max, resp_len, - poll_timeout_ms, wait_rounds, max_extra_polls, matcher); + /* transact 已超时/失败时勿再 pollUntil:否则 resp_type 仍为 0,会刷 + * "unexpected type=0x00" 并二次空等,掩盖真实超时。 */ + if (err == ESP_OK) { + err = pollUntilExpectedAckShape(stage, &resp_type, resp_payload, resp_max, resp_len, + poll_timeout_ms, wait_rounds, max_extra_polls, matcher); + } if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !matcher(resp_payload, *resp_len)) { DEBUG_ERR("OTA", "%s fail err=%d t=0x%02X n=%zu", stage, err, resp_type, *resp_len); @@ -1473,7 +1511,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms) if (!getGd32AppAlive()) { DEBUG_ERR("OTA", "GD32 APP 启动确认超时,OTA 失败"); - reset(); + /* APP 未起来时 CANCEL 可能无效,仍尽力清理 BOOT 半会话 */ + abortRemoteAndReset(true); return OTA_RESULT_ERR_GD32; } @@ -1581,16 +1620,19 @@ void OtaRelay::taskEntry(void *pvParameters) xQueueSend(resultQueue, &result_msg, pdMS_TO_TICKS(100)); if (result == OTA_RESULT_OK) { - // 成功日志已在 doWaitForAppAlive 输出,此处不再重复 - vTaskDelay(pdMS_TO_TICKS(3000)); + // 成功日志已在 doWaitForAppAlive 输出,立即通知协调器置完成态 + instance().reset(); /* 本端清会话;GD32 已跳 APP */ } else if (result == OTA_RESULT_ERR_TIMEOUT) { DEBUG_ERR("OTA", "OTA超时中止 TIMEOUT=%d", (int)result); + /* forwardFirmware 失败路径已 abortRemoteAndReset;再调一次无害(二次 CANCEL) */ + instance().abortRemoteAndReset(true); } else if (result == OTA_RESULT_ERR_USER_ABORT) { DEBUG_ERR("OTA", "OTA 用户中止 (USER_ABORT=%d)", (int)result); + instance().abortRemoteAndReset(true); } else { DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", (int)result); + instance().abortRemoteAndReset(true); } - instance().reset(); // 通过 IRelayListener 接口回调通知协调器中继结束 listener = getRelayListener(); if (listener) listener->onRelayComplete(result == OTA_RESULT_OK); diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 02c90d2..6f6d330 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -177,10 +177,18 @@ public: static TaskHandle_t getTaskHandle(); /** - * @brief 重置状态机到 IDLE. + * @brief 重置本端中继状态机到 IDLE(不主动通知 GD32)。 + * 中继失败/中止时请优先用 abortRemoteAndReset()。 */ void reset(); + /** + * @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 时调用)。 @@ -313,6 +321,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 固件总大小(字节)。 diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index bb236a9..e4ea6b6 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -35,6 +35,9 @@ #include "canfd_bridge.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 "monitor/task_stack.h" #include "ble_ota.h" #include "system_boot.h" @@ -96,6 +99,19 @@ void http_set_cors_headers(httpd_req_t *req) httpd_resp_set_hdr(req, "Access-Control-Max-Age", "600"); } +/* 显式声明 keep-alive,让浏览器明确复用 TCP 连接。 + * ESP-IDF httpd 默认 HTTP/1.1 持久连接,但不发此头时部分客户端/代理 + * 按 HTTP/1.0 语义关闭。4Hz motor 轮询每次新建 TCP 握手 ~10~30ms + * 纯开销;keep-alive 后复用同一 TCP,省全部握手时间。 + * + * 不放在 http_set_cors_headers 里:OTA 上传/relay handler 需要 Connection: close + * (设备即将重启),它们仍调 cors_headers 但不应加 keep-alive,否则会与 + * httpd_resp_set_hdr("Connection","close") 产生重复头。 */ +void http_set_keepalive_hdr(httpd_req_t *req) +{ + httpd_resp_set_hdr(req, "Connection", "keep-alive"); +} + esp_err_t http_options_handler(httpd_req_t *req) { http_set_cors_headers(req); @@ -407,7 +423,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req) } if (OtaRelay::getState() != RELAY_IDLE) { DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState()); - OtaRelay::instance().reset(); + /* 上次失败可能残留本端状态;同时尽力 CANCEL 远端半会话 */ + OtaRelay::instance().abortRemoteAndReset(true); } // 与 BLE 共用 prepareGd32Storage(内含 validateGd32Size + begin + erase + meta) @@ -614,22 +631,32 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req) return ESP_OK; } +/* /logs 分块流式 sink:每个 chunk 经 httpd_resp_send_chunk 发出。 */ +static bool logs_chunk_sink(void *ctx, const char *data, int len) +{ + httpd_req_t *req = static_cast(ctx); + return httpd_resp_send_chunk(req, data, len) == ESP_OK; +} + esp_err_t logs_handler(httpd_req_t *req) { http_set_cors_headers(req); + http_set_keepalive_hdr(req); + httpd_resp_set_type(req, "application/json"); - int json_len = 0; - char *json = DebugLog::instance().ringDrainJson(&json_len); - - if (json && json_len > 0) { - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, json, json_len); - free(json); - } else { - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, "[]", 2); + /* 流式发送:消除原 ~4KB 一次性 malloc,改为 512B 栈缓冲 + 分块。 + * lwip TX pbuf 峰值与应用堆峰值同时下降。 */ + int r = DebugLog::instance().ringDrainJsonStream(logs_chunk_sink, req); + if (r == 1) { + /* 各数据 chunk 已发完,补发终止空 chunk 结束 chunked 响应。 */ + return httpd_resp_send_chunk(req, NULL, 0); } - return ESP_OK; + if (r == 0) { + /* 无日志:一次性小响应(不进 chunked 编码)。 */ + return httpd_resp_send(req, "[]", 2); + } + /* r < 0:中途 send 失败,连接多半已断。 */ + return ESP_FAIL; } @@ -660,8 +687,12 @@ static SemaphoreHandle_t httpJsonWorkMutex(void) // 分块发送:复用 s_http_json_work,一次 HTTP 内按通道 send_chunk; // 前端仍一次 fetch 收完整 JSON。 // ============================================================ -/** 监控预览:data hex 最多 8 字节(16 字符),避免 FD 64B 撑爆分块 */ -static constexpr uint8_t FEEDBACK_DATA_HEX_MAX = 8; +/** + * 监控/业务共用:entry data hex 上限。 + * 原 8B 只够 STATUS 预览,会截掉 GET_VERSION(0x00FF) 的 hard/boot(需 ≥22B)。 + * 提到 64 与 CANFD_ID_SNAPSHOT_DATA_MAX 对齐,完整透出 FD 帧。 + */ +static constexpr uint8_t FEEDBACK_DATA_HEX_MAX = 64; static int feedback_append(char *buf, size_t cap, int off, const char *fmt, ...) { @@ -684,6 +715,22 @@ static int feedback_append(char *buf, size_t cap, int off, const char *fmt, ...) esp_err_t can_feedback_handler(httpd_req_t *req) { http_set_cors_headers(req); + http_set_keepalive_hdr(req); + + /* ?lite=1:轻量路径,跳过 entries 数组构造(~3KB → ~200B)。 + * motor.html 的 onFeedback 只用 ch/enabled/valid/rx/tx/err/tec/rec/bus_state, + * 不需要 entries(CAN 帧快照,index.html/can.html 才用)。 + * 跳过 entries 省掉 N×snprintf+hex 编码 + chunked 发送,handler 从 ~10ms 降到 <1ms。 */ + bool want_lite = false; + { + char query[32]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { + char val[4] = {}; + if (httpd_query_key_value(query, "lite", val, sizeof(val)) == ESP_OK) { + want_lite = (val[0] == '1' || val[0] == 'y' || val[0] == 't'); + } + } + } can_feedback_result_t results[CAN_CHANNEL_MAX] = {}; bool valid[CAN_CHANNEL_MAX] = {}; @@ -702,6 +749,80 @@ esp_err_t can_feedback_handler(httpd_req_t *req) ? (unsigned)((feat & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) : 0x7U; + /* lite 路径:一次性小响应,不走 chunked,不需要 httpJsonWorkMutex(用栈缓冲即可)。 + * 体积上限:3 通道 × ~80B + header ~60B ≈ 300B,远小于 1024 栈预算。 */ + if (want_lite) { + char resp[512]; + int off = snprintf(resp, sizeof(resp), + "{\"feat_known\":%s,\"can_ch_mask\":%u,\"can_brs\":%s,\"channels\":[", + feat_known ? "true" : "false", + ch_mask, + (feat_known && (feat & V2_FEAT_CAN_BRS)) ? "true" : "false"); + if (off < 0 || (size_t)off >= sizeof(resp)) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite hdr"); + return ESP_FAIL; + } + for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) { + const can_feedback_result_t &r = results[i]; + const char *stats_fmt; + if (valid[i] && ch_en[i]) { + stats_fmt = ",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu," + "\"drop\":%lu,\"err\":%lu,\"tec\":%u,\"rec\":%u," + "\"bus_state\":%u,\"count\":%u"; + } else { + stats_fmt = ""; + } + /* entries 省略,count 仍报真实 total,前端可据此判断有无快照 */ + int n = snprintf(resp + off, sizeof(resp) - (size_t)off, + "%s{\"ch\":%u,\"enabled\":%s,\"valid\":%s", + i == 0 ? "" : ",", + (unsigned)i, + ch_en[i] ? "true" : "false", + valid[i] ? "true" : "false"); + if (n < 0 || off + n >= (int)sizeof(resp)) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite ch"); + return ESP_FAIL; + } + off += n; + if (valid[i] && ch_en[i]) { + n = snprintf(resp + off, sizeof(resp) - (size_t)off, + stats_fmt, + (unsigned)r.stats.mode, + (unsigned long)r.stats.snapshot_seq, + (unsigned long)r.stats.rx_count, + (unsigned long)r.stats.tx_count, + (unsigned long)r.stats.rx_drop_count, + (unsigned long)r.stats.error_count, + (unsigned)r.stats.tec, + (unsigned)r.stats.rec, + (unsigned)r.stats.bus_state, + (unsigned)r.total_count); + if (n < 0 || off + n >= (int)sizeof(resp)) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite stats"); + return ESP_FAIL; + } + off += n; + } + /* 闭合 channel 对象 */ + n = snprintf(resp + off, sizeof(resp) - (size_t)off, "}"); + if (n < 0 || off + n >= (int)sizeof(resp)) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite close"); + return ESP_FAIL; + } + off += n; + } + int n = snprintf(resp + off, sizeof(resp) - (size_t)off, "]}"); + if (n < 0 || off + n >= (int)sizeof(resp)) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite end"); + return ESP_FAIL; + } + off += n; + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, resp, off); + } + + /* 完整路径(含 entries):保留给 index.html / can.html 的 CAN 监控页 */ + SemaphoreHandle_t mux = httpJsonWorkMutex(); if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb busy"); @@ -837,6 +958,7 @@ esp_err_t can_mode_handler(httpd_req_t *req) http_set_cors_headers(req); if (req->method == HTTP_GET) { + http_set_keepalive_hdr(req); /* GET 轮询复用 TCP;POST 设置模式不复用 */ can_feedback_result_t fb{}; if (getLatestCanFeedback(0, &fb)) { char resp[64]; @@ -913,7 +1035,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) constexpr int kMaxBody = 49152; constexpr int kMaxFrames = 128; constexpr uint32_t kPerFrameTimeoutMs = 80U; - constexpr uint32_t kSingleFrameTimeoutMs = 2000U; + constexpr uint32_t kSingleFrameTimeoutMs = 1200U; /* 正常 <20ms;httpd 单线程,此值决定其它端点最坏阻塞 */ constexpr uint32_t kInterFrameDelayMs = 5U; int content_len = req->content_len; @@ -1239,6 +1361,55 @@ static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUs esp_err_t system_status_handler(httpd_req_t *req) { http_set_cors_headers(req); + http_set_keepalive_hdr(req); + + /* ?lite=1:轻量路径,只返回 motor.html 高频轮询需要的 4 个字段 + * (wifi.ip/mode/connected + gd32_features)。不调 heap_caps_get_info / + * collectTaskStackMetrics / OtaCoordinator::getSnapshot / SystemBoot::appendStatusJson + * 等重活,避免在 httpd 单线程里阻塞几十~上百 ms 拖慢 motor/feedback 请求。 + * 完整 status 由前端 5s 拉一次。 */ + bool want_lite = false; + { + char query[32]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { + char val[4] = {}; + if (httpd_query_key_value(query, "lite", val, sizeof(val)) == ESP_OK) { + want_lite = (val[0] == '1' || val[0] == 'y' || val[0] == 't'); + } + } + } + if (want_lite) { + char ip_str[16] = "0.0.0.0"; + const char *mode_str = "AP"; + bool connected = false; + 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)); + } + mode_str = "STA"; + connected = true; + } else if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP) { + connected = true; + } + const bool feat_known = SpiMaster::instance().gd32FeatKnown(); + const uint32_t fl = SpiMaster::instance().gd32FeatFlags(); + char resp[256]; + int n = snprintf(resp, sizeof(resp), + "{\"wifi\":{\"mode\":\"%s\",\"ip\":\"%s\",\"connected\":%s}," + "\"gd32_features\":{\"known\":%s,\"flags\":%lu," + "\"can_ch0\":%s,\"can_ch1\":%s,\"can_ch2\":%s,\"can_ch_mask\":%u}}", + mode_str, ip_str, connected ? "true" : "false", + feat_known ? "true" : "false", + (unsigned long)fl, + (feat_known && (fl & V2_FEAT_CAN_CH0)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_CAN_CH1)) ? "true" : "false", + (feat_known && (fl & V2_FEAT_CAN_CH2)) ? "true" : "false", + feat_known ? (unsigned)((fl & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) : 7U); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, resp, n > 0 ? n : 0); + } /* layout=1:附带链接期静态布局(IRAM/.data/.bss/堆容量等),前端缓存后轮询不再要。 * 默认只下发 memory 动态字段,减 status JSON 体积。 */ @@ -1618,6 +1789,7 @@ esp_err_t system_status_handler(httpd_req_t *req) esp_err_t system_probe_handler(httpd_req_t *req) { http_set_cors_headers(req); + http_set_keepalive_hdr(req); gd32_version_t ver = {}; uint32_t uptime = 0; @@ -1673,4 +1845,470 @@ esp_err_t system_heap_low_log_handler(httpd_req_t *req) return err; } +/* ──────────────────────────────────────────────────────────── + * GET /motor/state — 多电机状态表(来自 GD32 0xC5 推送) + * ──────────────────────────────────────────────────────────── */ +esp_err_t motor_state_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + http_set_keepalive_hdr(req); + motor_state_table_t tbl = {}; + const bool have = getLatestMotorState(&tbl); + const uint32_t age = motorStateAgeMs(); + + /* 二进制模式:?bin=1(前端默认走此路径,体积比 JSON 省 66%)。 + * 协议(LE,Header 12B + 每节点 Entry 44B): + * Header: magic u16 = 0x4D53 ('MS') + * ver u8 = 1 + * flags u8 = 0 + * age_ms u32 (0xFFFFFFFF = 无数据) + * active u8 (在线电机数) + * count u8 (有效 entry 数) + * rsvd u16 = 0 + * Entry: node u8, ch u8, online u8, dlc u8, + * rx_count u32, seq u32, data[32] + * 9 节点最大 12 + 9*44 = 408B。 + * 无 have_data 时仍发 header(count=0),让前端区分「无数据」与「空总线」。 */ + bool want_bin = false; + { + char query[32]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { + char val[4] = {}; + if (httpd_query_key_value(query, "bin", val, sizeof(val)) == ESP_OK) { + want_bin = (val[0] == '1' || val[0] == 'y' || val[0] == 't'); + } + } + } + if (want_bin) { + httpd_resp_set_type(req, "application/octet-stream"); + /* 静态栈缓冲:12 + 9*44 = 408B,远小于常见任务栈 */ + uint8_t buf[12 + MOTOR_MAX_NODES * MOTOR_ENTRY_SIZE]; + size_t off = 0; + /* magic 'MS' (LE: 0x53, 0x4D) */ + buf[off++] = 0x53U; buf[off++] = 0x4DU; + buf[off++] = 1U; /* version */ + /* flags bit0 = boot_active(总线有电机在 BOOT,0x103 心跳) */ + buf[off++] = (have && tbl.boot_active) ? 0x01U : 0x00U; + /* age_ms LE u32 */ + const uint32_t age_send = (age == UINT32_MAX) ? 0xFFFFFFFFU : age; + buf[off++] = (uint8_t)(age_send & 0xFF); + buf[off++] = (uint8_t)((age_send >> 8) & 0xFF); + buf[off++] = (uint8_t)((age_send >> 16) & 0xFF); + buf[off++] = (uint8_t)((age_send >> 24) & 0xFF); + buf[off++] = have ? tbl.active_count : 0U; /* active */ + const size_t count_pos = off; /* count,稍后回填 */ + off += 3U; /* count u8 + rsvd u16 */ + /* off 现在应该是 12 */ + assert(off == 12); + + uint8_t count = 0; + if (have) { + for (uint8_t i = 0; i < MOTOR_MAX_NODES; ++i) { + const motor_slot_t &s = tbl.slots[i]; + if (s.node_id == 0) continue; + buf[off++] = s.node_id; + buf[off++] = s.channel; + buf[off++] = s.online; + buf[off++] = s.dlc; + /* rx_count LE */ + uint32_t r = s.rx_count; + buf[off++] = (uint8_t)(r & 0xFF); + buf[off++] = (uint8_t)((r >> 8) & 0xFF); + buf[off++] = (uint8_t)((r >> 16) & 0xFF); + buf[off++] = (uint8_t)((r >> 24) & 0xFF); + /* seq LE */ + uint32_t sq = s.seq; + buf[off++] = (uint8_t)(sq & 0xFF); + buf[off++] = (uint8_t)((sq >> 8) & 0xFF); + buf[off++] = (uint8_t)((sq >> 16) & 0xFF); + buf[off++] = (uint8_t)((sq >> 24) & 0xFF); + memcpy(&buf[off], s.data, MOTOR_STATUS_DLC); + off += MOTOR_STATUS_DLC; + count++; + } + } + buf[count_pos] = count; + + return httpd_resp_send(req, (const char *)buf, off); + } + + /* JSON 兼容路径(保留旧消费者,?bin 缺省时走此分支) */ + httpd_resp_set_type(req, "application/json"); + + if (!have) { + httpd_resp_sendstr(req, "{\"have_data\":false,\"active_count\":0,\"motors\":[]}"); + return ESP_OK; + } + + SemaphoreHandle_t mux = httpJsonWorkMutex(); + if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "motor busy"); + return ESP_FAIL; + } + + char *w = s_http_json_work; + int off = snprintf(w, HTTP_JSON_WORK_CAP, + "{\"have_data\":true,\"active_count\":%u,\"boot_active\":%s,\"age_ms\":%lu,\"motors\":[", + (unsigned)tbl.active_count, tbl.boot_active ? "true" : "false", (unsigned long)age); + + bool first = true; + for (uint8_t i = 0; i < MOTOR_MAX_NODES && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) { + const motor_slot_t &s = tbl.slots[i]; + if (s.node_id == 0) continue; + + /* data hex */ + char hex[MOTOR_STATUS_DLC * 2 + 1]; + for (uint8_t b = 0; b < MOTOR_STATUS_DLC; ++b) { + snprintf(&hex[b * 2], 3, "%02X", s.data[b]); + } + + off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, + "%s{\"node\":%u,\"ch\":%u,\"online\":%s,\"dlc\":%u," + "\"rx_count\":%lu,\"seq\":%lu,\"data\":\"%s\"}", + first ? "" : ",", + (unsigned)s.node_id, (unsigned)s.channel, + s.online ? "true" : "false", + (unsigned)s.dlc, + (unsigned long)s.rx_count, + (unsigned long)s.seq, + hex); + first = false; + } + + if (off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 2) { + off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, "]}"); + } + + esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0); + if (mux) { + xSemaphoreGive(mux); + } + return ret; +} + +/** + * @brief /motor/version — 返回 GD32 缓存的电机版本(0xC6 事件推送)。 + * + * 版本为按需查询的低频数据:前端发 0x00FF 请求触发电机应答,GD32 缓存后经 + * 0xC6 推给 ESP32,本端点从缓存读取。前端按 seq 判断是否为本次查询的新应答。 + * + * JSON: {"have_data":bool,"nodes":[{"node":N,"seq":N,"age_ms":N,"version":"HEX(24B)"}]} + */ +esp_err_t motor_version_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + http_set_keepalive_hdr(req); + + motor_version_table_t tbl = {}; + const bool have = getLatestMotorVersion(&tbl); + + httpd_resp_set_type(req, "application/json"); + + if (!have) { + httpd_resp_sendstr(req, "{\"have_data\":false,\"nodes\":[]}"); + return ESP_OK; + } + + SemaphoreHandle_t mux = httpJsonWorkMutex(); + if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "motor version busy"); + return ESP_FAIL; + } + + const int64_t now_ms = esp_timer_get_time() / 1000; + char *w = s_http_json_work; + int off = snprintf(w, HTTP_JSON_WORK_CAP, "{\"have_data\":true,\"nodes\":["); + + bool first = true; + for (uint8_t i = 0; i < MOTORVER_MAX_NODES && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) { + const motor_version_slot_t &s = tbl.slots[i]; + if (!s.valid || s.node_id == 0) continue; + + char hex[MOTORVER_DLC * 2 + 1]; + for (uint8_t b = 0; b < MOTORVER_DLC; ++b) { + snprintf(&hex[b * 2], 3, "%02X", s.version[b]); + } + const long age = (s.recv_ms > 0 && now_ms >= s.recv_ms) + ? static_cast(now_ms - s.recv_ms) : 0L; + + off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, + "%s{\"node\":%u,\"seq\":%lu,\"age_ms\":%ld,\"version\":\"%s\"}", + first ? "" : ",", + (unsigned)s.node_id, + (unsigned long)s.seq, + age, + hex); + first = false; + } + + if (off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 2) { + off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, "]}"); + } + + esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0); + if (mux) { + xSemaphoreGive(mux); + } + return ret; +} + +/* ═══════════════════ 电机 OTA relay ═══════════════════ */ + +esp_err_t motor_ota_upload_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + char query[16] = {}; + uint8_t channel = 0; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { + char val[4] = {}; + if (httpd_query_key_value(query, "ch", val, sizeof(val)) == ESP_OK) { + channel = static_cast(atoi(val)); + } + } + + const int content_len = req->content_len; + if (content_len <= 0 || content_len > (int)MOTOR_OTA_MAX_FW_SIZE) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"error\":\"firmware too large or empty\"}"); + return ESP_FAIL; + } + + /* ── 协调:电机固件与 GD32E508 固件共用 gd32_ota 分区,先互斥 + 安全清理 ── */ + OtaCoordinator &coord = OtaCoordinator::instance(); + /* 有活跃 OTA(GD32 STORING/RELAYING、ESP32_* 等)时拒绝 */ + if (!coord.isIdleState(coord.getSystemState())) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"error\":\"system busy with another OTA\"}"); + return ESP_FAIL; + } + if (motorOtaIsActive()) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"error\":\"motor OTA already in progress\"}"); + return ESP_FAIL; + } + /* 即将覆写 gd32_ota 分区:先失效已暂存的 GD32 固件并复位协调器到 IDLE, + 避免 /ota/relay 误把电机固件当成 GD32 固件中继刷进主控 */ + coord.abortCurrent(); + (void)coord.clearGd32Meta(); + + /* ── 流式收固件写 flash(无整固件堆缓冲;复用 OTA 静态收块)── + * ★ full_erase=true:强制擦除整个 gd32_ota 分区(252KB ~200ms), + * 杜绝上次固件残留 + 擦除位错误导致的单 bit 翻转(0xAC20 处 0x19→0x18 根因)。 */ + Gd32FlashStore::instance().beginWriteSession(true); + + int received = 0; + while (received < content_len) { + size_t to_read = (size_t)content_len - received; + if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + + otaUploadPaceIfHeapTight(); + + int r = httpd_req_recv(req, reinterpret_cast(s_ota_upload_recv), to_read); + if (r <= 0) { + DEBUG_ERR("MOTOR_OTA", "upload interrupted: received=%d expected=%d", received, content_len); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "recv failed"); + return ESP_FAIL; + } + + esp_err_t werr = Gd32FlashStore::instance().flashWriteChunk( + (uint32_t)received, s_ota_upload_recv, (size_t)r); + if (werr != ESP_OK) { + DEBUG_ERR("MOTOR_OTA", "flash write fail @%d: %s", received, esp_err_to_name(werr)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "flash write failed"); + return ESP_FAIL; + } + + received += r; + (void)esp_task_wdt_reset(); + } + + /* ── 收完固件写 meta(供 /motor/ota/relay 确认固件完整)── + * 不在此处触发 SPI START + 喂数据:拆分两段式,与 BLE 刷 GD32 的 + * "prepare+收流 → /ota/relay" 对齐。收固件阶段纯写 flash,不碰 SPI, + * 避免与 GD32 按铃推送争抢单缓冲 MISO 产生的 "SPI start failed" 竞态。 */ + ota_meta_t meta = {}; + meta.total_size = static_cast(content_len); + meta.stored_size = static_cast(received); + meta.last_seq = 0; + esp_err_t merr = Gd32FlashStore::instance().metaSave(&meta); + if (merr != ESP_OK) { + DEBUG_ERR("MOTOR_OTA", "meta save fail after upload: %s", esp_err_to_name(merr)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"error\":\"meta save failed\"}"); + return ESP_FAIL; + } + + /* ★ 上传收尾计算 flash SHA-256 并存入 meta.checksum。 + * 电机 OTA 之前完全没有完整性校验:HTTP 流式写 flash → metaSave → relay 直接刷。 + * 一旦 flash 写入错位/损坏,ESP32 会把"一致的垃圾"喂给 GD32(CAN CRC 全过但 APP 跑飞)。 + * 这里在收流结束立即算 SHA-256,relay 触发时 metaVerifyChecksum 复核,从源头堵住坏数据。 */ + esp_err_t sha_err = Gd32FlashStore::instance().metaFinalizeChecksum(); + if (sha_err != ESP_OK) { + DEBUG_ERR("MOTOR_OTA", "SHA-256 finalize fail after upload: %s", esp_err_to_name(sha_err)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"error\":\"sha256 compute failed\"}"); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/json"); + char resp_buf[160]; + snprintf(resp_buf, sizeof(resp_buf), + "{\"status\":\"stored\",\"message\":\"call POST /motor/ota/relay?ch=%u to trigger\"," + "\"channel\":%u,\"size\":%d}", + (unsigned)channel, (unsigned)channel, received); + httpd_resp_sendstr(req, resp_buf); + return ESP_OK; +} + +esp_err_t motor_ota_relay_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + http_set_keepalive_hdr(req); + DEBUG_INFO("MOTOR_OTA", "=== HTTP: POST /motor/ota/relay ==="); + + char query[16] = {}; + uint8_t channel = 0; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { + char val[4] = {}; + if (httpd_query_key_value(query, "ch", val, sizeof(val)) == ESP_OK) { + channel = static_cast(atoi(val)); + } + } + + /* 确认固件已完整存储 */ + ota_meta_t meta = {}; + if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || + meta.total_size == 0 || meta.stored_size < meta.total_size) { + return http_send_json_error(req, "400 Bad Request", + "firmware_not_stored", "firmware not fully stored"); + } + + /* ★ 接收前端发送的固件 SHA-256(JSON body: {"sha256":""})。 + * 前端在 relay 触发前用 Web Crypto 算固件 bytes 的 SHA-256, + * ESP32 与自己算的 flash SHA 比对,从源头确认"前端发的 = ESP32 flash 存的"。 + * 之前 motor OTA 没有这一层比对,flash 写入位错误无法被发现, + * 直到刷进电机 APP 跑飞才暴露。 */ + uint8_t frontend_sha[32] = {0}; + bool has_frontend_sha = false; + if (req->content_len > 0 && req->content_len < 512) { + char body_buf[512] = {0}; + int rret = httpd_req_recv(req, body_buf, req->content_len); + if (rret > 0) { + body_buf[rret] = '\0'; + cJSON *root = cJSON_Parse(body_buf); + if (root) { + cJSON *sha_item = cJSON_GetObjectItem(root, "sha256"); + if (sha_item && cJSON_IsString(sha_item) && + strlen(cJSON_GetStringValue(sha_item)) == 64) { + const char *hex = cJSON_GetStringValue(sha_item); + for (int i = 0; i < 32; ++i) { + char hb[3] = { hex[i*2], hex[i*2+1], 0 }; + frontend_sha[i] = (uint8_t)strtoul(hb, nullptr, 16); + } + has_frontend_sha = true; + } + cJSON_Delete(root); + } + } + } + if (!has_frontend_sha) { + return http_send_json_error(req, "400 Bad Request", + "missing_sha256", "missing sha256 in body (frontend SHA-256 required)"); + } + + /* 三方比对:前端 SHA vs flash 实际 SHA vs meta.checksum。 + * verifyFrontendSha 内部重算 flash SHA,与 frontend_sha 比对, + * 同时也验证与 meta.checksum(上传收尾存的)一致。 + * 任何一项不符都拒绝刷写,从源头堵住 flash 存储环节位错误。 */ + uint8_t device_sha[32] = {0}; + esp_err_t verify_err = Gd32FlashStore::instance().verifyFrontendSha(frontend_sha, device_sha); + if (verify_err != ESP_OK) { + char dev_hex[65] = {0}, front_hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&dev_hex[i*2], 3, "%02X", device_sha[i]); + snprintf(&front_hex[i*2], 3, "%02X", frontend_sha[i]); + } + DEBUG_ERR("MOTOR_OTA", "relay 前 SHA 比对失败: front=%s device=%s err=%s", + front_hex, dev_hex, esp_err_to_name(verify_err)); + (void)Gd32FlashStore::instance().metaClear(); + char err_msg[224]; /* 固定文字 ~55 + 2×64 hex = 需 >183,给 224 余量 */ + snprintf(err_msg, sizeof(err_msg), + "frontend SHA != flash SHA (front=%s device=%s), re-upload required", + front_hex, dev_hex); + return http_send_json_error(req, "400 Bad Request", + "firmware_checksum_mismatch", err_msg); + } + DEBUG_INFO("MOTOR_OTA", "relay 前三方 SHA 比对通过 (%lu B)", (unsigned long)meta.total_size); + + if (motorOtaIsActive()) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"error\":\"motor OTA already in progress\"}"); + return ESP_FAIL; + } + + /* 触发 SPI START + 启动 feedTask(从 flash 分块读取喂 GD32)*/ + const int result = motorOtaStart(meta.total_size, channel); + + httpd_resp_set_type(req, "application/json"); + if (result == 0) { + /* 成功触发后清 meta:固件已交给 feedTask,避免残留导致下次误判 */ + (void)Gd32FlashStore::instance().metaClear(); + httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"OTA started\"}"); + } else if (result == -2) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_sendstr(req, "{\"error\":\"OTA already in progress\"}"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\":\"SPI start failed\"}"); + } + return ESP_OK; +} + +esp_err_t motor_ota_status_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + http_set_keepalive_hdr(req); + + const motor_ota_status_t st = motorOtaGetStatus(); + + static const char *state_names[] = { + "idle", "handshake_req", "handshake_nonce", "handshake_confirm", + "erase", "transfer", "finish_ota", "finished", "error", + "pre_cancel" + }; + const int si = (st.state <= MOTOR_OTA_PRE_CANCEL) ? (int)st.state : 0; + + char buf[256]; + snprintf(buf, sizeof(buf), + "{\"state\":\"%s\",\"error\":%u,\"packets_acked\":%u,\"total_packets\":%u," + "\"fw_size\":%lu,\"buf_used\":%u}", + state_names[si], st.error_code, + st.packets_acked, st.total_packets, + (unsigned long)st.fw_size, st.buf_used); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, buf); + return ESP_OK; +} + +esp_err_t motor_ota_cancel_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + DEBUG_WARN("MOTOR_OTA", "cancel HTTP request received, calling motorOtaCancel()"); + motorOtaCancel(); + /* 两阶段都可取消:触发前未跑 feedTask 时 motorOtaCancel 是空操作, + * 但仍清 GD32 侧状态 + 清 flash meta,避免残留固件占分区/被误判为已存。 + * 触发后取消也清 meta 无害(feedTask 已停)。 */ + (void)Gd32FlashStore::instance().metaClear(); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"cancelled\"}"); + return ESP_OK; +} diff --git a/main/server/http_handlers.h b/main/server/http_handlers.h index aeb3b88..f1548b1 100644 --- a/main/server/http_handlers.h +++ b/main/server/http_handlers.h @@ -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 @@ -28,4 +31,14 @@ esp_err_t system_heap_low_log_handler(httpd_req_t *req); esp_err_t can_feedback_handler(httpd_req_t *req); esp_err_t can_mode_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 diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index db870c8..ca84f13 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -396,31 +396,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 预检 - /* Socket 预算(无法“合并” httpd 内部 3 个 fd:listen + ctrl 对): + /* 路由上限:当前 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 - * ├─ httpd 内部固定 3 + * LWIP 总池 CONFIG_LWIP_MAX_SOCKETS = 12 + * ├─ httpd 内部固定 3(listen + ctrl socket 对) * ├─ max_open_sockets = 业务并发(下面 want) * └─ 余量:UDP 调试日志 1 + 偶发其它 * - * 约束:max_open_sockets <= LWIP_MAX_SOCKETS - 3 + * 约束:max_open_sockets <= LWIP_MAX_SOCKETS - 3 = 9 * - * 业务并发压到 4(tools 单浏览器已足够): - * 1× /system/status 轮询 + 1× /logs 轮询 + 1× 操作(OTA/CAN) + 1× 余量 - * OTA 大上传期间前端 pausePolling,只占 1 条长连接,不与轮询抢槽。 + * 业务并发需要 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 = 4; // 合并并发需求后的业务槽位(原 7 偏松) + 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 检测踢掉 */ - config.recv_wait_timeout = 10; - config.send_wait_timeout = 10; + /* 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); @@ -460,6 +477,12 @@ esp_err_t WifiOta::httpServerStart() {"/can/mode", HTTP_GET, can_mode_handler}, {"/can/mode", HTTP_POST, can_mode_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}, }; /* 同一 path 可有多 method(如 GET+POST);OPTIONS 每个 path 只注册一次, @@ -471,7 +494,13 @@ esp_err_t WifiOta::httpServerStart() 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)); + } bool need_opts = true; for (size_t j = 0; j < i; ++j) { @@ -486,7 +515,11 @@ esp_err_t WifiOta::httpServerStart() 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); + 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)); + } } } diff --git a/main/storage/gd32_flash_store.cpp b/main/storage/gd32_flash_store.cpp index cb8f460..12e21fa 100644 --- a/main/storage/gd32_flash_store.cpp +++ b/main/storage/gd32_flash_store.cpp @@ -75,9 +75,37 @@ const esp_partition_t* Gd32FlashStore::getPartition() // Flash 分区读写 // ============================================================ -void Gd32FlashStore::beginWriteSession() +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 顺序推进)。 */ diff --git a/main/storage/gd32_flash_store.h b/main/storage/gd32_flash_store.h index 2762d92..2646347 100644 --- a/main/storage/gd32_flash_store.h +++ b/main/storage/gd32_flash_store.h @@ -72,9 +72,10 @@ public: /** * @brief 新一轮 GD32 固件写入会话开始时调用:复位擦除水位。 - * 后续 flashWriteChunk 经 ensureErasedForWrite 按需擦扇区。 + * @param full_erase true=强制擦除整个分区(motor OTA 用,杜绝旧固件残留导致的位错误); + * false=只重置水位,由 flashWriteChunk 按需擦扇区(GD32 OTA 用,与收流交织)。 */ - void beginWriteSession(); + void beginWriteSession(bool full_erase = false); // ── NVS 元数据持久化 ── diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 105435f..62f8a32 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -14,6 +14,8 @@ #include "canfd_bridge.h" #include "spi_master.h" #include "monitor/debug_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" namespace { @@ -58,10 +60,24 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, uint8_t resp_type = 0U; size_t resp_len = 0U; - esp_err_t ret = SpiMaster::instance().transact( - kSpiCanFrameType, - tx_buf, tx_len, - &resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms); + esp_err_t ret = ESP_OK; + /* CAN 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA + * 喂数(0xD1→WAIT)残留在 SPI 响应管道里的陈旧帧(0xC0 命令本身永不产生 WAIT)。 + * 陈旧 WAIT 会被下方误判为 ERROR → resp_len=0 → gd32_err=0xFF → 无声拒绝发送( + * 表现为“失败一次就再也发不出去”)。此处最多重试几次排空陈旧帧, + * 直到读到本命令真正的 ACK/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 (ret != ESP_OK || resp_type != SPI_OTA_RSP_WAIT) { + break; /* SPI 失败或已拿到真正响应(ACK/ERROR) */ + } + vTaskDelay(pdMS_TO_TICKS(3)); /* 让 GD32 更新响应,再读一次排空陈旧 WAIT */ + } if (ret != ESP_OK) { /* 同类错误节流日志,降低高频失败时的日志开销 */ diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 136302e..10f5928 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -54,9 +54,9 @@ static size_t spiXferBytes(size_t frame_len) /** * @brief 读响应时单 CS 时钟预算(按最近 HELLO 的 GD32 role 分支)。 - * - BOOT:TX DMA 满缓冲 128 → 读 128,避免过长时钟 underrun - * - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断 CRC - * unknown 偏 APP:宁长勿短(BOOT 短帧在 256 时钟下尾部 0xFF,仍可 parse)。 + * - BOOT:TX DMA 满缓冲 128 → 读 128(与 Git 稳定路径一致,避免 BOOT underrun) + * - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断 + * role 粘滞 boot 时靠 isAppOnlySpiType 纠正为 APP 后再走 256。 */ static size_t spiRespReadBudget(void) { @@ -67,6 +67,24 @@ static size_t spiRespReadBudget(void) return SPI_APP_TX_BUF_SIZE; } +/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */ +static bool isAppOnlySpiType(uint8_t type) +{ + switch (type) { + case 0xC1U: /* CAN feedback */ + case 0xC3U: /* ID snapshot */ + case 0xC4U: /* ADC */ + case 0xC5U: /* motor state */ + case 0xC6U: /* motor version */ + case 0xD0U: /* motor OTA cmd(APP relay) */ + case 0xD1U: + case 0xD2U: + return true; + default: + return false; + } +} + /** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。 * 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。 */ @@ -252,6 +270,12 @@ esp_err_t SpiMaster::serviceRingOnce() } { LockGuard lock(m_mutex); + /* APP 独有事件 → 纠正粘滞的 boot role(OTA 后 HELLO 未刷新时常见) */ + if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) { + DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X)", + (unsigned)m_gd32Role, found_type); + m_gd32Role = V2_ROLE_APP; + } (void)pushEvent(found_type, found_payload, found_len); } return ESP_OK; @@ -787,7 +811,7 @@ bool SpiMaster::otaBuildFrame(uint8_t type, const uint8_t *payload, size_t paylo */ bool SpiMaster::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) { if (!buf) return false; @@ -807,10 +831,13 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, if (hdr.session_id != self.m_sessionId) { self.m_stats.seq_errors++; const bool is_event = (hdr.flags & V2_FLAG_EVENT) != 0U; - /* EVENT 按铃帧在 GD32 session 未激活时 session_id=0(见 requestEsp32Read)。 - * 若此处直接拒绝:按铃被读走却解析失败 → 狂刷 WARN,CAN 反馈全丢。 - * 策略:EVENT 一律放行;REQUEST/RESPONSE 仍拒绝错 session。 */ - if (!is_event) { + /* session_id==0:GD32 重启后 g_v2Active=false,响应帧 session 填 0 + * (见 GD32 spi_build_frame)。典型场景:刷完 GD32 固件、ESP32 未重启。 + * 若拒收:GD32 明明在回 ACK,ESP32 全丢 → OTA/命令全超时死循环 + * (日志特征:v2 SES mismatch got=00000000 刷屏 + fed 卡首 chunk)。 + * 放行并按 stale 语义交给上层(ack_seq 归属照常判定)。 */ + const bool is_sess0 = (hdr.session_id == 0U); + if (!is_event && !is_sess0) { static uint32_t s_sess_mm_logs = 0U; if ((++s_sess_mm_logs % 32U) == 1U) { DEBUG_WARN("SPI", "v2 SES mismatch got=%08lX exp=%08lX t=%02X #%lu", @@ -819,8 +846,15 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, } return false; } + if (is_sess0 && !is_event) { + static uint32_t s_sess0_logs = 0U; + if ((++s_sess0_logs % 64U) == 1U) { + DEBUG_WARN("SPI", "GD32 session=0 (对端已重启?) 放行 RESPONSE t=0x%02X #%lu", + hdr.type, (unsigned long)s_sess0_logs); + } + } static uint32_t s_evt_sess0_logs = 0U; - if ((++s_evt_sess0_logs % 64U) == 1U) { + if (is_event && (++s_evt_sess0_logs % 64U) == 1U) { DEBUG_DBG("SPI", "EVENT session mismatch accepted: got=0x%08lX exp=0x%08lX type=0x%02X", (unsigned long)hdr.session_id, (unsigned long)self.m_sessionId, hdr.type); } @@ -858,6 +892,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, if (payload) *payload = pload; if (payload_len) *payload_len = hdr.payload_len; if (frame_offset) *frame_offset = hdr.frame_offset; + if (out_flags) *out_flags = hdr.flags; return true; } @@ -1080,11 +1115,17 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s (void)otaFindFrame(drain, drain_len, nullptr, nullptr, nullptr, nullptr); } } else { - vTaskDelay(pdMS_TO_TICKS(5)); + /* PB6 高 = GD32 无待发数据 = 响应缓冲已释放、可接收新命令。 + * 此时 remoteCredit 仍为 0 是陈旧值:credit 只随帧头传递,GD32 转空闲后 + * 没有帧能把 credit=1 送回主机(典型为电机 OTA 取消后:一次瞬时 credit=0 + * 被锁存,GD32 转空闲再不发帧)→ 死锁,所有发送被拒。 + * PB6 高即证明对端可收,直接放行打破死锁;真忙时 PB6 会低、走上面 drain。 */ + m_remoteCredit = 1U; + break; } } if (m_remoteCredit == 0U) { - DEBUG_WARN("SPI", "remoteCredit=0, refuse TX type=0x%02X", type); + DEBUG_WARN("SPI", "remoteCredit=0 且 PB6 持续低, refuse TX type=0x%02X", type); return ESP_ERR_INVALID_STATE; } } @@ -1118,6 +1159,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s const uint8_t *found_payload = nullptr; size_t found_len = 0; size_t frame_offset = 0; + /* 与 Git 稳定路径一致:step1 陈旧帧原样解析即可(otaSendAndPoll 传 nullptr 丢弃)。 + * 勿在此处过滤 LINK/EVENT,否则改变 delayed-by-one 语义。 */ if (otaFindFrame(rx_raw, xfer_len, &found_type, &found_payload, &found_len, &frame_offset)) { if (resp_type) *resp_type = found_type; if (resp_payload && resp_max > 0) { @@ -1127,6 +1170,9 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s } } if (resp_len) *resp_len = found_len; + if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) { + m_gd32Role = V2_ROLE_APP; + } /* expect_stale(两步法 step1)读到的是延迟一拍陈旧帧,序号由 poll 成功时推进。 * 此处若自增会导致 job 间隙/超时后 resend 使用错误 TX_SEQ。 */ if (!expect_stale) { @@ -1256,14 +1302,29 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p const uint8_t *found_payload = nullptr; size_t found_len = 0; size_t frame_offset = 0; + uint8_t found_flags = 0U; - if (otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, &found_len, &frame_offset)) { + /* 恢复 Git 稳定 poll 语义(GD32 BOOT OTA 以此为准)。 + * 仅额外:EVENT 帧跳过(电机 OTA 喂数时勿把 0xC1/0xC5 当 ACK); + * APP 独有 TYPE 纠正 role 粘滞。不做 LINK/RESPONSE 额外过滤。 */ + if (otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, &found_len, &frame_offset, &found_flags)) { if (found_type == V2_TYPE_CREDIT_UPDATE) { DEBUG_DBG("SPI", "poll got CREDIT_UPDATE credit=%u", (unsigned)m_remoteCredit); vTaskDelay(pdMS_TO_TICKS(1)); continue; } + if ((found_flags & V2_FLAG_EVENT) != 0U) { + if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) { + DEBUG_INFO("SPI", "role %u→APP (poll saw type=0x%02X)", + (unsigned)m_gd32Role, found_type); + m_gd32Role = V2_ROLE_APP; + } + DEBUG_DBG("SPI", "poll skip EVENT type=0x%02X", found_type); + vTaskDelay(pdMS_TO_TICKS(1)); + continue; + } + if (expected_type != 0xFFU && found_type != expected_type && found_type != SPI_OTA_RSP_WAIT) { DEBUG_DBG("SPI", "poll type mismatch: got=0x%02X want=0x%02X", @@ -1283,16 +1344,16 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p } if (resp_len) *resp_len = found_len; - /* WAIT 立刻返回,由 OTA 紧循环再 poll(勿在此空转) */ - if (found_type == SPI_OTA_RSP_WAIT) { - m_linkState.exitBusy(); - return ESP_OK; - } - if (m_lastRxAckMatched) { - m_txSeq = (uint8_t)(m_lastSentSeq + 1U); - } - m_linkState.exitBusy(); - return ESP_OK; +/* WAIT/ACK/ERROR:只要 ack_seq 匹配,GD32 已消费本 TX_SEQ,必须推进。 + * ★ 电机 OTA:feedData 缓冲满回 WAIT 时若不清 seq,ESP32 同 seq 重发 + * → GD32 命中“重复请求”只回缓存 WAIT,feedData 永不再调用 + * → fed 卡在首 chunk(日志 fed=232)、CAN 抽空后 0x01 超时。 + * BOOT OTA 见 WAIT 后只 poll 不重发 DATA,推进 seq 同样正确。 */ + if (m_lastRxAckMatched) { + m_txSeq = (uint8_t)(m_lastSentSeq + 1U); + } + m_linkState.exitBusy(); + return ESP_OK; } vTaskDelay(pdMS_TO_TICKS(1)); @@ -1426,8 +1487,12 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t return send_ret; } - /* CS 释放后短延时,再 poll 应答 */ - vTaskDelay(pdMS_TO_TICKS(5)); + /* CS 释放后短延时,再 poll 应答。 + * GD32 主控 OTA / HELLO 等:5ms(BOOT AES 路径需要)。 + * 电机 OTA 数据 0xD1:1ms。每 chunk 固定 5ms 会把 SPI 喂速压到 ~47KB/s 边缘, + * CAN 5ms/包消费时环缓经常被掏空(日志「缓冲空,等ESP32喂」+ ACK 超时重发)。 */ + const uint32_t post_cs_ms = (type == 0xD1U) ? 1U : 5U; + vTaskDelay(pdMS_TO_TICKS(post_cs_ms)); /* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */ return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout, @@ -1961,6 +2026,10 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint) hdr.payload_len >= V2_HELLO_ACK_APP_LEN) { applyAppIdentity16(&pload[V2_HELLO_ACK_BASE_LEN], nullptr, nullptr); } + /* HELLO 成功时打印 role,便于对照 /system/status 粘滞 boot 问题 */ + DEBUG_INFO("SPI", "HELLO ok role=%u credit=%u sess=0x%08lX", + (unsigned)m_gd32Role, (unsigned)m_remoteCredit, + (unsigned long)m_sessionId); m_v2Active = true; m_stats.resyncs++; return ESP_OK; diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 85c12ff..5b02bd3 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -28,6 +28,7 @@ // 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) #define SPI_OTA_RSP_ACK (0x80) @@ -463,7 +464,7 @@ private: 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 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index b33d28d..26dcf78 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -24,8 +24,12 @@ CONFIG_DCU_SPI_CLOCK_HZ=4000000 CONFIG_LWIP_MAX_SOCKETS=12 # C3 堆紧:窗口过大 OTA 峰值易顶;8KB≈5*MSS,在 4KB OTA 块下吞吐更好,仍比 12KB+ 保守。 # 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。 +# WND 保持 8192:OTA 上传走接收窗口,不动以保上传吞吐。 CONFIG_LWIP_TCP_WND_DEFAULT=8192 -CONFIG_LWIP_TCP_SND_BUF_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 @@ -95,3 +99,8 @@ CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200 # Brownout detector threshold (LVL5) CONFIG_ESP_BROWNOUT_DET=y CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5=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 diff --git a/tools/bump_asset_versions.py b/tools/bump_asset_versions.py deleted file mode 100644 index 65dd558..0000000 --- a/tools/bump_asset_versions.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -bump_asset_versions.py — 按本地静态资源内容 hash 自动更新 HTML 里的 ?v= 版本号。 - -动机 ----- -ESP32_COMM/tools 下多个 HTML(can.html / can_monitor.html / index.html)手工维护 -/ + \ No newline at end of file diff --git a/tools/css/base.css b/tools/css/base.css index d8d2856..28c07c5 100644 --- a/tools/css/base.css +++ b/tools/css/base.css @@ -36,6 +36,9 @@ --can: #43d97b; /* 绿:CAN 总线 / 健康 */ --can-hi: #7deda6; --can-dim: rgba(67,217,123,.14); + --motor: #22d3ee; /* 青:电机控制台 */ + --motor-hi: #67e8f9; + --motor-dim: rgba(34,211,238,.14); --mem: #a88bfa; /* 紫:内存 / 调试 / 日志 */ --mem-hi: #c7b3ff; --mem-dim: rgba(168,139,250,.14); @@ -57,13 +60,12 @@ --radius-lg: 16px; /* ---- 字体 ---- - display:Chakra Petch(棱角科技感,用于标题/大数字/按钮; - 无中文字形,中文自动回退苹方/雅黑) - mono:JetBrains Mono(SHA / HEX / 日志 / 数值) - body:系统中文字体优先,保证正文可读性 */ - --font-display: "Chakra Petch","PingFang SC","Microsoft YaHei","Segoe UI",sans-serif; + 沿用原始系统字体方案:中文优先苹方/雅黑,西文 Segoe UI; + mono 用于 SHA / HEX / 日志 / 数值(SF Mono → Fira Code → Cascadia → Consolas)。 + --font-display 保留变量名以兼容既有引用,指向同一系统栈。 */ + --font-display: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif; --font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif; - --font-mono: "JetBrains Mono","SF Mono","Cascadia Code","Consolas",monospace; + --font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace; /* ---- 间距 / 阴影 ---- */ --gap: 18px; diff --git a/tools/css/can.css b/tools/css/can.css index 0fdd363..3484997 100644 --- a/tools/css/can.css +++ b/tools/css/can.css @@ -5,9 +5,9 @@ } .can-launch-card { background: - radial-gradient(circle at top right, rgba(34,211,238,.18), transparent 28%), + radial-gradient(circle at top right, rgba(67,217,123,.16), transparent 28%), linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%); - border: 1px solid rgba(34,211,238,.14); + border: 1px solid rgba(67,217,123,.16); border-radius: 14px; padding: 20px; box-shadow: @@ -21,7 +21,7 @@ position: absolute; inset: 0 auto 0 0; width: 4px; - background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18)); + background: linear-gradient(180deg, rgba(67,217,123,.9), rgba(67,217,123,.18)); } .can-launch-copy { position: relative; @@ -46,7 +46,7 @@ max-width: 700px; } .can-launch-kicker { - color: #88ecff; + color: #8ef0b4; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; @@ -57,9 +57,9 @@ align-items: center; padding: 7px 12px; border-radius: 999px; - background: rgba(34,211,238,.08); - border: 1px solid rgba(34,211,238,.18); - color: #a9f4ff; + background: rgba(67,217,123,.09); + border: 1px solid rgba(67,217,123,.22); + color: #a9f5c8; font-size: 11px; font-weight: 700; letter-spacing: .12em; @@ -88,8 +88,8 @@ width: 6px; height: 6px; border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 10px rgba(34,211,238,.35); + background: var(--can); + box-shadow: 0 0 10px rgba(67,217,123,.35); } .can-launch-actions { display: grid; @@ -104,7 +104,9 @@ min-height: 42px; } .can-launch-primary { - box-shadow: 0 10px 24px rgba(34,211,238,.18); + background: linear-gradient(135deg, var(--can) 0%, #22b45f 100%); + color: #04240f; + box-shadow: 0 10px 24px rgba(67,217,123,.2); } .can-launch-secondary { color: #d5e4f4; @@ -113,7 +115,7 @@ } .can-launch-secondary:hover:not(:disabled) { background: rgba(255,255,255,.05); - border-color: rgba(34,211,238,.18); + border-color: rgba(67,217,123,.25); } .can-launch-foot { display: flex; @@ -128,6 +130,35 @@ position: relative; z-index: 1; } +/* 电机入口:复用 can-launch 布局,换青色强调 */ +.motor-launch-card { + background: + radial-gradient(circle at top right, rgba(34,211,238,.16), transparent 28%), + linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%); + border-color: rgba(34,211,238,.16); +} +.motor-launch-card::before { + background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18)); +} +.motor-launch-card .can-launch-kicker { color: #67e8f9; } +.motor-launch-card .can-launch-badge { + background: rgba(34,211,238,.09); + border-color: rgba(34,211,238,.22); + color: #a5f3fc; +} +.motor-launch-card .can-launch-point::before { + background: var(--motor, #22d3ee); + box-shadow: 0 0 10px rgba(34,211,238,.35); +} +.motor-launch-primary { + background: linear-gradient(135deg, var(--motor, #22d3ee) 0%, #0891b2 100%) !important; + color: #042f2e !important; + box-shadow: 0 10px 24px rgba(34,211,238,.2); +} +.motor-launch-card .can-launch-secondary:hover:not(:disabled) { + border-color: rgba(34,211,238,.25); +} + .can-launch-foot-label { flex: none; display: inline-flex; @@ -160,7 +191,6 @@ body.can-page { .can-container { max-width: min(1700px, 100%); width: 100%; - overflow-x: hidden; box-sizing: border-box; } .can-header { @@ -170,6 +200,9 @@ body.can-page { backdrop-filter: blur(14px); background: rgba(15,22,31,.86); } +/* sticky+backdrop-filter 合成层冲突致 fadeInUp 卡首帧(translateY(10px)+opacity:0), + * 禁用入场动画。选择器需 .header-bar.can-header 以胜过 responsive.css 的 .header-bar 规则 */ +.header-bar.can-header { animation: none; } .can-header-actions { margin-left: auto; } @@ -942,9 +975,9 @@ body.can-page { grid-template-columns: auto 56px - minmax(0, 0.9fr) - minmax(0, 1.4fr) - minmax(0, 0.8fr); + minmax(0, 0.7fr) + minmax(0, 0.8fr) + minmax(0, 1.6fr); align-items: center; gap: 8px 10px; min-width: 0; @@ -1008,6 +1041,24 @@ body.can-page { min-width: 0; text-align: right; } +/* Click-to-Copy 样式 */ +.history-item-row .copyable { + cursor: pointer; + border-radius: 4px; + padding: 1px 4px; + margin: -1px -4px; + transition: background .15s, color .15s; +} +.history-item-row .copyable:hover { + background: rgba(255,255,255,.08); +} +.history-item-row .copyable:active { + background: rgba(255,255,255,.14); +} +.history-item-row .copyable.copied { + background: rgba(34,197,94,.18); + color: #86efac; +} /* === CAN 1Hz FEEDBACK === */ .can-feedback-card { margin-top: 18px; } @@ -1095,7 +1146,10 @@ body.can-page { } .feedback-row:last-child { border-bottom: none; } .fb-label { color: var(--muted, #8892a4); } -.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; } +.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; cursor: pointer; border-radius: 4px; padding: 1px 4px; margin: -1px -4px; transition: background .15s; } +.fb-val:hover { background: rgba(255,255,255,.08); } +.fb-val:active { background: rgba(255,255,255,.14); } +.fb-val.copied { background: rgba(34,197,94,.18); color: #86efac; } .fb-val.mono { font-family: monospace; } .fb-val.ok { color: #34d399; } .fb-val.warn { color: #f59e0b; } diff --git a/tools/css/components.css b/tools/css/components.css index 7ac8837..80331aa 100644 --- a/tools/css/components.css +++ b/tools/css/components.css @@ -136,14 +136,15 @@ box-shadow: 0 8px 20px rgba(3,7,16,.4); } .metric .k { - font-family: var(--font-display); - font-size: 10px; color: var(--text-label); - text-transform: uppercase; letter-spacing: .13em; margin-bottom: 5px; + font-family: var(--font); + font-size: 11px; font-weight: 500; color: var(--text-label); + letter-spacing: .04em; margin-bottom: 6px; } .metric .v { - font-family: var(--font-display); - font-size: 21px; font-weight: 700; word-break: break-word; - font-variant-numeric: tabular-nums; letter-spacing: .01em; + font-family: var(--font); + font-size: 20px; font-weight: 700; line-height: 1.2; + word-break: break-word; + font-variant-numeric: tabular-nums; } .metric .v.highlight, .metric .v.accent { color: var(--esp32-hi); } .metric .v.ok { color: var(--green); } diff --git a/tools/css/layout.css b/tools/css/layout.css index 6f6ceab..5fe2922 100644 --- a/tools/css/layout.css +++ b/tools/css/layout.css @@ -5,14 +5,16 @@ /* ================= HEADER — 命令栏 ================= */ .header-bar { position: relative; + z-index: 20; /* 高于下方卡片,保证 pill tooltip 不被遮挡 */ display: flex; align-items: center; gap: 16px; padding: 16px 22px; margin-bottom: 26px; - background: linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%); + background: + radial-gradient(620px 240px at 88% -40%, rgba(47,216,193,.12), transparent 60%), + linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); flex-wrap: wrap; - overflow: hidden; } /* 顶部三色霓虹描边:ESP32 青绿 → BLE 蓝 → GD32 琥珀,一条细线点出子系统谱系 */ .header-bar::before { @@ -21,15 +23,7 @@ background: linear-gradient(90deg, var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%); opacity: .85; -} -/* 右上角极淡的芯片电路装饰 */ -.header-bar::after { - content: ""; - position: absolute; right: -40px; top: -40px; - width: 220px; height: 220px; pointer-events: none; - background: - radial-gradient(circle at 30% 30%, rgba(47,216,193,.10), transparent 55%); - border-radius: 50%; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; } .header-brand { @@ -139,6 +133,7 @@ .card--gd32 { --card-accent: var(--gd32); --card-accent-dim: var(--gd32-dim); } .card--ble { --card-accent: var(--ble); --card-accent-dim: var(--ble-dim); } .card--can { --card-accent: var(--can); --card-accent-dim: var(--can-dim); } +.card--motor { --card-accent: var(--motor); --card-accent-dim: var(--motor-dim); } .card--mem { --card-accent: var(--mem); --card-accent-dim: var(--mem-dim); } .card--status { --card-accent: var(--green); --card-accent-dim: var(--can-dim); } diff --git a/tools/css/responsive.css b/tools/css/responsive.css index b34876e..054cbc4 100644 --- a/tools/css/responsive.css +++ b/tools/css/responsive.css @@ -1,24 +1,19 @@ -/* === responsive.css — 响应式断点 + 增强动画 === */ -/* === RESPONSIVE === */ +/* ============================================================ + responsive.css — 响应式断点 + 动效(对齐新设计令牌) + ============================================================ */ + +/* ================= 断点 ================= */ @media (max-width: 1100px) { .main-layout { grid-template-columns: 1fr; } .sys-drawer { --sys-drawer-panel-w: min(340px, calc(100vw - 40px)); --sys-drawer-peek: 32px; - top: 64px; - height: calc(100vh - 72px); - max-height: calc(100vh - 72px); - } - .can-monitor-drawer { - --sys-drawer-panel-w: min(400px, calc(100vw - 40px)); + top: 64px; height: calc(100vh - 72px); max-height: calc(100vh - 72px); } + .can-monitor-drawer { --sys-drawer-panel-w: min(400px, calc(100vw - 40px)); } .badge-grid { grid-template-columns: repeat(2, 1fr); } - .can-toolbar-grid, - .can-toolbar-foot, - .can-editor-layout { grid-template-columns: 1fr; } - .message-list { - max-height: 420px; - } + .can-toolbar-grid, .can-toolbar-foot, .can-editor-layout { grid-template-columns: 1fr; } + .message-list { max-height: 420px; } .can-meta-grid { grid-template-columns: 1fr 1fr; } } @media (max-width: 640px) { @@ -26,254 +21,226 @@ .sys-drawer { --sys-drawer-panel-w: calc(100vw - 36px); --sys-drawer-peek: 28px; - top: 56px; - height: calc(100vh - 60px); - max-height: calc(100vh - 60px); + top: 56px; height: calc(100vh - 60px); max-height: calc(100vh - 60px); } .sys-drawer-rail-text { font-size: 11px; } .header-bar { flex-direction: column; align-items: flex-start; } + .header-sub { margin-left: 0; } .badge-grid, .metric-grid { grid-template-columns: 1fr; } .status-kv-grid, .status-task-meta { grid-template-columns: 1fr; } .can-grid { grid-template-columns: 1fr 1fr; } .btn-row { flex-direction: column; } .can-meta-grid { grid-template-columns: 1fr; } - .can-launch-top, - .can-launch-actions, - .can-launch-foot { - grid-template-columns: 1fr; - flex-direction: column; - align-items: flex-start; - } - .can-selection-bar, - .can-selection-actions { - flex-direction: column; - align-items: flex-start; - } - .can-msg-toolbar { - flex-wrap: wrap; + .can-launch-top, .can-launch-actions, .can-launch-foot { + grid-template-columns: 1fr; flex-direction: column; align-items: flex-start; } + .can-selection-bar, .can-selection-actions { flex-direction: column; align-items: flex-start; } + .can-msg-toolbar { flex-wrap: wrap; } .history-item-row { grid-template-columns: auto 56px minmax(0, 1fr); grid-template-rows: auto auto; } - .history-item-hex, - .history-item-foot { - grid-column: 1 / -1; - text-align: left; - } - .can-title { - font-size: 22px; - } - .can-frame-hex { - font-size: 15px; - } - /* 禁止再强制 980px 最小宽,避免报文/信号表横向滑动 */ - .message-table, - .signal-table { - min-width: 0; - max-width: 100%; - } + .history-item-hex, .history-item-foot { grid-column: 1 / -1; text-align: left; } + .can-title { font-size: 22px; } + .can-frame-hex { font-size: 15px; } + .message-table, .signal-table { min-width: 0; max-width: 100%; } } - - -/* ===== ENHANCED ANIMATIONS (v20260625b) ===== */ - -/* Global keyframes */ +/* ================= 动画关键帧 ================= + * 性能约定:常驻(infinite)动画只动 transform/opacity(合成器直通, + * 不走主线程重绘);box-shadow/background-position 关键帧仅限短暂、 + * 临时态(如 dragover / OTA 阶段活跃)。 */ @keyframes fadeInUp { - from { opacity: 0; transform: translateY(8px); } + from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes popIn { 0% { opacity: 0; transform: scale(.85); } 60% { opacity: 1; transform: scale(1.04); } 100% { opacity: 1; transform: scale(1); } } +/* 光泽扫过:由 background-position 改为伪元素自身平移(合成器友好), + * 扫动节奏与原版一致:扫过 + 停预周期。 */ @keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } } @keyframes ringPulse { - 0% { box-shadow: 0 0 0 0 rgba(34,211,238,.55); } - 70% { box-shadow: 0 0 0 10px rgba(34,211,238,0); } - 100% { box-shadow: 0 0 0 0 rgba(34,211,238,0); } + 0% { box-shadow: 0 0 0 0 rgba(47,216,193,.5); } + 70% { box-shadow: 0 0 0 10px rgba(47,216,193,0); } + 100% { box-shadow: 0 0 0 0 rgba(47,216,193,0); } +} +/* 常驻波纹/呼吸辉光的合成器友好版:叠加层只动 transform/opacity。 */ +@keyframes ringRipple { + 0% { transform: scale(1); opacity: .5; } + 70%,100% { transform: scale(3); opacity: 0; } +} +@keyframes glowBreathFade { + 0%,100% { opacity: 0; } + 50% { opacity: 1; } } @keyframes shake { - 0%, 100% { transform: translateX(0); } - 20% { transform: translateX(-4px); } - 40% { transform: translateX(4px); } - 60% { transform: translateX(-3px); } - 80% { transform: translateX(3px); } + 0%,100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } 80% { transform: translateX(3px); } } -@keyframes spin { - to { transform: rotate(360deg); } -} -@keyframes floatY { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-4px); } -} -@keyframes glowBreath { - 0%, 100% { box-shadow: 0 0 8px var(--accent-dim); } - 50% { box-shadow: 0 0 18px rgba(34,211,238,.45); } -} -@keyframes flowDash { - to { background-position: 200% 0; } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes floatY { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } } +@keyframes flowDash { to { background-position: 200% 0; } } +@keyframes logoPulse { + 0%,100% { opacity: 0; } + 50% { opacity: 1; } } -/* Page entrance — header + cards staggered */ -.header-bar { animation: fadeInUp .45s ease both; } -.main-layout > div > .card { animation: fadeInUp .5s ease both; } -.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .05s; } -.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .12s; } -.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .19s; } -.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .26s; } -.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .08s; } -.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .15s; } -.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .22s; } -.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .29s; } +/* ================= 入场:头部 + 卡片错落浮现 ================= */ +.header-bar { animation: fadeInUp .5s ease both; } +.main-layout > div > .card { animation: fadeInUp .55s ease both; } +.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .06s; } +.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .13s; } +.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .20s; } +.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .27s; } +.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .09s; } +.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .16s; } +.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .23s; } +.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .30s; } +.main-layout > div:nth-child(2) > .card:nth-child(5) { animation-delay: .37s; } -/* Card hover — lift + glow */ -.card { - transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease; -} +/* ================= 卡片悬停:抬升 + 子系统色描边 ================= */ +.card { transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease; } .card:hover { transform: translateY(-2px); - border-color: rgba(34,211,238,.28); - box-shadow: 0 10px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(34,211,238,.08); + border-color: color-mix(in srgb, var(--card-accent, var(--esp32)) 32%, var(--border)); + box-shadow: 0 14px 34px rgba(2,5,12,.5), + 0 0 0 1px color-mix(in srgb, var(--card-accent, var(--esp32)) 12%, transparent); } -/* Brand dot — gentle ring pulse */ -.header-brand .dot-mark { - animation: ringPulse 2.2s ease-out infinite, glowBreath 3.6s ease-in-out infinite; +/* ================= 品牌 Logo:呼吸辉光 ================= + * box-shadow 关键帧每帧走主线程重绘;改为叠加静态辉光的伪元素 + * 只动 opacity(合成器直通),基础辉光由 layout.css 静态 box-shadow 提供, + * 呼吸节奏与原 logoPulse/ringPulse/glowBreath 一致。 */ +.brand-logo { position: relative; } +.brand-logo::before { + content: ""; + position: absolute; inset: -1px; border-radius: inherit; + box-shadow: 0 0 26px rgba(47,216,193,.4), inset 0 0 16px rgba(47,216,193,.14); + opacity: 0; pointer-events: none; + animation: logoPulse 3.2s ease-in-out infinite; + will-change: opacity; +} +.header-brand .dot-mark { position: relative; } +.header-brand .dot-mark::after { + content: ""; + position: absolute; inset: 0; border-radius: 50%; + background: rgba(47,216,193,.5); pointer-events: none; + animation: ringRipple 2.4s ease-out infinite; + will-change: transform, opacity; +} +.header-brand .dot-mark::before { + content: ""; + position: absolute; inset: 0; border-radius: 50%; + box-shadow: 0 0 20px rgba(47,216,193,.45); + opacity: 0; pointer-events: none; + animation: glowBreathFade 3.6s ease-in-out infinite; + will-change: opacity; } -/* Pills — subtle hover */ -.pill { transition: transform .2s ease, border-color .2s ease, background .2s ease; } -.pill:hover { transform: translateY(-1px); border-color: rgba(34,211,238,.3); } +/* ================= 胶囊 / 按钮微交互 ================= */ +.pill:hover { transform: translateY(-1px); border-color: var(--border-strong); } -/* Button — press + sheen on hover for primary */ .btn { position: relative; overflow: hidden; } -.btn:active:not(:disabled) { transform: translateY(0) scale(.97); transition: transform .08s ease; } -.btn-primary::before, -.btn-success::before, -.btn-danger::before { +.btn-primary::before, .btn-success::before, .btn-danger::before { content: ""; position: absolute; top: 0; left: -120%; width: 60%; height: 100%; - background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent); + background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent); transform: skewX(-20deg); transition: left .55s ease; pointer-events: none; } .btn-primary:hover:not(:disabled)::before, .btn-success:hover:not(:disabled)::before, -.btn-danger:hover:not(:disabled)::before { - left: 130%; -} +.btn-danger:hover:not(:disabled)::before { left: 130%; } -/* Upload zone — drag pulse + icon float */ -.upload-zone { transition: border-color .2s, background .2s, transform .2s; } -.upload-zone:hover { transform: translateY(-1px); } -.upload-zone .upload-ico { - display: inline-block; - transition: transform .25s ease; -} +/* ================= 上传区:拖拽脉冲 + 图标浮动 ================= */ .upload-zone:hover .upload-ico { animation: floatY 1.6s ease-in-out infinite; } .upload-zone.dragover { animation: ringPulse 1.2s ease-out infinite; } -/* File info appearance */ +/* ================= 文件信息浮现 ================= */ .file-info.show { animation: fadeInUp .3s ease both; } -/* Progress bar — striped flow on active */ +/* ================= 进度条:流动斜纹 ================= */ .progress-fill { background-image: - linear-gradient(90deg, #22d3ee, #06b6d4), - repeating-linear-gradient(45deg, - rgba(255,255,255,.18) 0 10px, - rgba(255,255,255,0) 10px 20px); + linear-gradient(90deg, var(--esp32), #14b8a6), + repeating-linear-gradient(45deg, rgba(255,255,255,.2) 0 10px, rgba(255,255,255,0) 10px 20px); background-size: 100% 100%, 200% 100%; background-blend-mode: normal, overlay; animation: flowDash 1.4s linear infinite; } .progress-fill.done { animation: none; } -/* Phase step capsule — pop when becoming active/done */ -.pp-step.active, -.pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } +/* ================= 阶段胶囊:激活弹入 ================= */ +.pp-step.active, .pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } -/* Stage flow — connector grow + dot pop */ +/* ================= 阶段流:连接线生长 + 圆点动效 ================= */ .stage-node:not(:last-child)::after { - background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 50%, var(--border) 50%, var(--border) 100%); - background-size: 200% 100%; - background-position: 100% 0; + background: linear-gradient(90deg, var(--esp32) 0%, var(--esp32) 50%, var(--border) 50%, var(--border) 100%); + background-size: 200% 100%; background-position: 100% 0; transition: background-position .5s ease; } .stage-node.done:not(:last-child)::after, -.stage-node.live:not(:last-child)::after { - background-position: 0% 0; -} +.stage-node.live:not(:last-child)::after { background-position: 0% 0; } .stage-dot { transition: transform .3s ease, background .3s, border-color .3s, color .3s, box-shadow .3s; } -.stage-node.live .stage-dot { - animation: ringPulse 1.6s ease-out infinite; -} +.stage-node.live .stage-dot { animation: ringPulse 1.6s ease-out infinite; } .stage-node.done .stage-dot { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } .stage-node.err .stage-dot { animation: shake .4s ease both; } -/* Stage status bar — slide in when state changes */ -.stage-status-bar.active, -.stage-status-bar.ok, -.stage-status-bar.err { animation: fadeInUp .3s ease both; } - -/* Stage result panel */ +/* ================= 状态条切换浮现 ================= */ +.stage-status-bar.active, .stage-status-bar.ok, .stage-status-bar.err { animation: fadeInUp .3s ease both; } .stage-result { animation: popIn .4s cubic-bezier(.4,1.4,.5,1) both; } -/* Metric / badge values — number tick highlight */ -.metric .v, -.badge-stat .v { - transition: color .3s ease, transform .3s ease; -} -.metric:hover, -.badge-stat:hover { - border-color: rgba(34,211,238,.25); - transition: border-color .25s ease; -} +/* ================= 指标数值过渡 ================= */ +.metric .v, .badge-stat .v { transition: color .3s ease, transform .3s ease; } -/* Conn indicator — light ring pulse when warn */ +/* ================= 连接指示 ================= */ .conn-indicator { transition: background .25s ease, border-color .25s ease; } .conn-indicator.warn .light { animation: ringPulse 1.4s ease-out infinite; } -.conn-indicator.ok .light { animation: glowBreath 3s ease-in-out infinite; } +/* 在线呼吸辉光:常驻动画改伪元素 opacity(合成器直通),节奏同原 glowBreath */ +.conn-indicator.ok .light { position: relative; } +.conn-indicator.ok .light::after { + content: ""; + position: absolute; inset: 0; border-radius: 50%; + box-shadow: 0 0 20px rgba(47,216,193,.45); + opacity: 0; pointer-events: none; + animation: glowBreathFade 3s ease-in-out infinite; + will-change: opacity; +} -/* Heap / task bars — sheen */ -.heap-bar-track, -.status-task-bar { position: relative; overflow: hidden; } -.heap-bar-track::after, -.status-task-bar::after { +/* ================= 堆 / 任务条:光泽扫过 ================= + * shimmer 改动 transform(见上方关键帧注释):十几根条常驻扫光 + * 不再每帧触发主线程重绘,视觉效果不变。 */ +.heap-bar-track, .status-task-bar { position: relative; overflow: hidden; } +.heap-bar-track::after, .status-task-bar::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.12) 50%, transparent 100%); transform: translateX(-100%); - animation: shimmer 2.6s linear infinite; - background-size: 200% 100%; + animation: shimmer 2.8s linear infinite; + will-change: transform; pointer-events: none; } -.heap-bar-fill, -.status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); } +.heap-bar-fill, .status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); } -/* Log lines — fresh entry fade-in */ +/* ================= 日志新行淡入 ================= */ .log-line { animation: fadeIn .25s ease both; } -/* Toast — pop and float */ +/* ================= Toast 弹出 ================= */ .toast.show { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } -/* Reduced motion — respect user preference */ +/* ================= 降低动态偏好 ================= */ @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { + *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; diff --git a/tools/index.html b/tools/index.html index 38a40c8..d004be0 100644 --- a/tools/index.html +++ b/tools/index.html @@ -1,7 +1,8 @@ @@ -9,20 +10,29 @@ ESP32 OTA 中继控制台 - - - - - + + + + +
- +
- - ESP32 OTA 中继控制台 + +
+ ESP32 OTA 中继控制台 + DCU · Robot_v2 · Relay +
未连接 @@ -44,7 +54,7 @@
-
+
🔗 设备连接
-
+
🎯 升级目标、固件文件与执行操作
@@ -172,7 +182,7 @@
-
+
⚡ 刷写进度与流程阶段
@@ -237,7 +247,7 @@
-
+
📡 设备状态
@@ -273,7 +283,7 @@
-
+
📊 运行指标
@@ -287,7 +297,7 @@
-