From 74eb68b1400f25c7df3950e45133c8984cad1e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Tue, 28 Jul 2026 00:23:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=9B=BA=E4=BB=B6?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=87=B3=200.3.72=20=E5=B9=B6=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=9E=84=E5=BB=BA=E6=97=B6=E9=97=B4=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 version.h 中将固件版本由 0.3.71 递增至 0.3.72。 移除 BUILD_TIME 的手动填写项,现由构建系统自动生成。 更新电机 OTA relay 逻辑,缩短状态检查的宽限期。 增强电机 OTA 上传与 relay 流程中的错误处理和日志记录。 为固件上传引入 SHA-256 校验,确保固件完整性。 调整前端命令值提交逻辑,改善用户体验。 更新 HTML 与 JavaScript 文件,以反映 OTA 流程及 UI 的变更。 新增 build_info 文件,用于管理构建时间的自动生成。 --- main/CMakeLists.txt | 14 ++ main/core/build_info.cpp | 12 ++ main/gen_build_time.cmake | 8 + main/include/app_config.h | 4 +- main/include/build_info.h | 15 ++ main/include/version.h | 15 +- main/monitor/motor_ota_relay.cpp | 17 +-- main/monitor/motor_ota_relay.h | 3 +- main/server/ble_ota_host.cpp | 2 +- main/server/http_handlers.cpp | 98 +++++-------- tools/js/motor/main.js | 244 ++++++++++++++++++++++--------- tools/motor.html | 12 +- 12 files changed, 281 insertions(+), 163 deletions(-) create mode 100644 main/core/build_info.cpp create mode 100644 main/gen_build_time.cmake create mode 100644 main/include/build_info.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 4cef60d..59fffbc 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -3,6 +3,7 @@ idf_component_register( SRCS "core/main.cpp" # 程序入口 (app_main) "core/system_boot.cpp" # 启动模块位图与汇总 + "core/build_info.cpp" # 真实编译时间(每次构建自动刷新,见文末 gen_build_time) "ota/esp32_ota_core.cpp" # ESP32 本机 OTA 核心(分区写入/SHA验证) "ota/ota_coordinator.cpp" # OTA 多通道协调器(状态机/互斥) "relay/ota_relay.cpp" # GD32 SPI OTA 中继状态机 @@ -49,3 +50,16 @@ idf_component_register( bt # NimBLE BLE 协议栈 ) +# ── 真实编译时间:每次构建重新生成 build_time_gen.h ── +# 背景:version.h 的 BUILD_TIME 是手写死值(停在 2026-07-26 20:25),刷新固件后仍显示旧时间, +# 被误判为 OTA 回退;__DATE__/__TIME__ 方案在增量编译下同样会过期。 +# 机制:gen_build_time 无产物依赖 → 每次构建必跑 → 重写头文件 → 仅 core/build_info.cpp 重编+重链。 +set(BUILD_TIME_HDR "${CMAKE_CURRENT_BINARY_DIR}/gen/build_time_gen.h") +add_custom_target(gen_build_time + COMMAND ${CMAKE_COMMAND} -D OUT=${BUILD_TIME_HDR} -P ${CMAKE_CURRENT_LIST_DIR}/gen_build_time.cmake + BYPRODUCTS ${BUILD_TIME_HDR} + COMMENT "Refreshing build_time_gen.h" +) +add_dependencies(${COMPONENT_LIB} gen_build_time) +target_include_directories(${COMPONENT_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/gen") + diff --git a/main/core/build_info.cpp b/main/core/build_info.cpp new file mode 100644 index 0000000..5821882 --- /dev/null +++ b/main/core/build_info.cpp @@ -0,0 +1,12 @@ +/* + * build_info.cpp - 唯一包含自动生成时间头的编译单元。 + * build_time_gen.h 每次构建都被 gen_build_time 目标重写 → 仅本文件重编 + 重链接, + * 其余增量编译不受影响;其他模块经 fwBuildTime() 取值。 + */ +#include "build_info.h" +#include "build_time_gen.h" + +const char *fwBuildTime(void) +{ + return FW_BUILD_TIME_AUTO; +} diff --git a/main/gen_build_time.cmake b/main/gen_build_time.cmake new file mode 100644 index 0000000..e054a6f --- /dev/null +++ b/main/gen_build_time.cmake @@ -0,0 +1,8 @@ +# 每次构建执行:生成真实编译时间头文件(由 main/CMakeLists.txt 的 gen_build_time 目标调用)。 +# 手工维护的 version.h BUILD_TIME 曾停留在旧值,导致「刷了新固件仍显示旧时间」被误判为回退。 +string(TIMESTAMP _build_time "%Y-%m-%d %H:%M:%S") +file(WRITE "${OUT}" +"/* 自动生成(每次构建刷新),勿手改、勿提交。仅 core/build_info.cpp 包含本文件。 */ +#pragma once +#define FW_BUILD_TIME_AUTO \"${_build_time}\" +") diff --git a/main/include/app_config.h b/main/include/app_config.h index b86b264..e4566eb 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -9,6 +9,7 @@ #include #include "freertos/FreeRTOS.h" #include "version.h" +#include "build_info.h" // ============================================================ // 功能开关(ESP32 本机;GD32 侧开关随 HELLO/PING 身份块上报,无需镜像) @@ -20,7 +21,8 @@ inline constexpr uint8_t CONFIG_WIFI_MODE = 1; // 固件版本(在 version.h 中手动维护,此处提供 C++ constexpr 访问) // ============================================================ inline constexpr const char *FW_VERSION = FW_VERSION_STRING; ///< 固件版本字符串(手动维护) -inline constexpr const char *FW_BUILD_TIME = BUILD_TIME; ///< 编译时间字符串(手动维护) +/* FW_BUILD_TIME 已删:手写死值停在旧时间,刷新固件仍显示 20:25 被误判回退。 + * 改用 fwBuildTime()(build_info.h,构建系统每次自动生成真实编译时间)。 */ inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(手动维护) // ============================================================ diff --git a/main/include/build_info.h b/main/include/build_info.h new file mode 100644 index 0000000..c9739d0 --- /dev/null +++ b/main/include/build_info.h @@ -0,0 +1,15 @@ +/* + * build_info.h - 真实编译时间(构建系统每次自动生成,替代 version.h 手工 BUILD_TIME) + */ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +/** 返回本固件的真实编译时间字符串 "YYYY-MM-DD HH:MM:SS"(每次构建自动刷新)。 */ +const char *fwBuildTime(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/include/version.h b/main/include/version.h index a5d66aa..cd532d0 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -1,22 +1,21 @@ /* * version.h - 固件版本信息(手动维护) * - * 注意:本文件不再由构建脚本自动生成。如需修改版本号、编译时间或 - * Git Hash,请直接编辑本文件。 + * 注意:本文件不再由构建脚本自动生成。如需修改版本号或 Git Hash, + * 请直接编辑本文件。 * - FW_VERSION_MAJOR / MINOR / PATCH:发版时手动递增 - * - BUILD_TIME:每次发版编译时手动填写 * - GIT_HASH:手动填写对应提交的 git short hash + * - 编译时间:已改为构建系统自动生成(build_info.h 的 fwBuildTime(), + * 每次构建刷新),手写 BUILD_TIME 已删——旧值停在 2026-07-26 20:25 + * 曾被误判为 OTA 回退。 */ #pragma once // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 71 -#define FW_VERSION_STRING "0.3.71" - -// 编译时间(手动填写) -#define BUILD_TIME "2026-07-26 20:25:00" +#define FW_VERSION_PATCH 72 +#define FW_VERSION_STRING "0.3.72" // Git 信息(手动填写) #define GIT_HASH "770ae14" \ No newline at end of file diff --git a/main/monitor/motor_ota_relay.cpp b/main/monitor/motor_ota_relay.cpp index 70fa8c3..3485a68 100644 --- a/main/monitor/motor_ota_relay.cpp +++ b/main/monitor/motor_ota_relay.cpp @@ -51,12 +51,12 @@ static void feedTask(void *arg) * 导致 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; + /* 启动宽限期:只需覆盖“suppress 生效前在途的陈旧 0xD2 push”这一极小窗口—— + * s_status 刚被 motorOtaStart 置为 HANDSHAKE_REQ,下方 suppress 后 ring 不再读, + * 残留 ERROR 只可能在这几十 ms 内覆写。原 1500ms 基于 kBootSettleMs=800ms 时代, + * GD32 v1.5.1 已移除残帧窗口(收 0x103 即进握手,握手后不再发 ERROR push), + * 100ms 足够(用户指定)。 */ + const int64_t grace_end_ms = nowMs() + 100LL; /* 重新启用 suppressRingService:ring service 抢占 SPI 带宽导致吐量从 ~5 chunks/s 降到 ~1.5 chunks/s。 * 进度显示由 feedTask 自己基于 s_fw_offset 更新(近似值),不依赖 GD32 push。 */ @@ -77,9 +77,8 @@ static void feedTask(void *arg) } /* GD32 已进终态:停止喂,避免盲重试空转到 120s 超时。 - * 启动宽限期(3s)内跳过终态检查:避免上一次 OTA 残留的 ERROR 状态推送 - * (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 - * GD32 需 kPreCancel(500ms 沉淀)+握手(~1s) 才能进入传输态。 */ + * 启动宽限期(100ms)内跳过终态检查:避免上一次 OTA 残留的 ERROR 状态推送 + * (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 */ const motor_ota_status_t st = motorOtaGetStatus(); if (nowMs() >= grace_end_ms) { if (st.state == MOTOR_OTA_IDLE || st.state == MOTOR_OTA_FINISHED || diff --git a/main/monitor/motor_ota_relay.h b/main/monitor/motor_ota_relay.h index 0b05b7c..f6bf6ee 100644 --- a/main/monitor/motor_ota_relay.h +++ b/main/monitor/motor_ota_relay.h @@ -36,7 +36,8 @@ typedef enum { MOTOR_OTA_FINISH_OTA, /* 收到 0xF0F0F0F0,已发 FINISH_OTA,等跳转 ACK */ MOTOR_OTA_FINISHED, MOTOR_OTA_ERROR, - MOTOR_OTA_PRE_CANCEL = 9 /* 握手前预取消:发 CANCEL 清理电机残留会话(GD32 侧过渡态) */ + MOTOR_OTA_PRE_CANCEL = 9, /* 握手前预取消:发 CANCEL 清理电机残留会话(GD32 侧过渡态) */ + MOTOR_OTA_WAIT_APP_RUN = 10 /* 尾包 ACK 后等新 APP 自主发 [0A AC] 运行确认,收到才 FINISHED */ } motor_ota_state_t; typedef struct { diff --git a/main/server/ble_ota_host.cpp b/main/server/ble_ota_host.cpp index f6452e0..bc647a0 100644 --- a/main/server/ble_ota_host.cpp +++ b/main/server/ble_ota_host.cpp @@ -287,7 +287,7 @@ int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle, const char *version = FW_VERSION; // 版本字符串: "vX.Y.Z YYYY-MM-DD HH:MM:SS" char ver_buf[64]; - int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, FW_BUILD_TIME); + int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, fwBuildTime()); if (ver_len < 0 || ver_len >= (int)sizeof(ver_buf)) { ver_len = strlen(version); int rc = os_mbuf_append(ctxt->om, version, ver_len); diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index e4ea6b6..fb09f3b 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -1566,7 +1566,7 @@ esp_err_t system_status_handler(httpd_req_t *req) "\"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"channel\": %d, \"channel_name\": \"%s\", " "\"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, " "\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}, ", - FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(), + FW_VERSION, fwBuildTime(), FW_GIT_HASH, HealthMonitor::instance().getCurrent(), (unsigned long)free_heap, (unsigned long)min_free_heap, (unsigned long)largest_free_block, @@ -2157,12 +2157,23 @@ esp_err_t motor_ota_upload_handler(httpd_req_t *req) return ESP_FAIL; } + /* ★ 把设备端 flash SHA 直接回给前端:前端在发进 BOOT 帧之前就能比对, + * flash 写坏时电机还在 APP 正常运行即可中止,不用等到 relay 阶段 + * (那时电机已被踢进 BOOT 白等)。relay 的三方比对仍保留作兜底。 */ + ota_meta_t sha_meta = {}; + char sha_hex[65] = {0}; + if (Gd32FlashStore::instance().metaLoad(&sha_meta) == ESP_OK) { + for (int i = 0; i < 32; ++i) { + snprintf(&sha_hex[i * 2], 3, "%02x", sha_meta.checksum[i]); + } + } + httpd_resp_set_type(req, "application/json"); - char resp_buf[160]; + char resp_buf[256]; 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); + "\"channel\":%u,\"size\":%d,\"sha256\":\"%s\"}", + (unsigned)channel, (unsigned)channel, received, sha_hex); httpd_resp_sendstr(req, resp_buf); return ESP_OK; } @@ -2182,70 +2193,29 @@ esp_err_t motor_ota_relay_handler(httpd_req_t *req) } } - /* 确认固件已完整存储 */ + /* 读 meta 拿 fw_size(motorOtaStart 参数的唯一来源)。 + * total_size==0 = 无固件可刷(未上传过,或上次 relay 成功后 metaClear 已消费)。 + * 数量完整性由上传流程保证:上传开头 clearGd32Meta,收满 content_len 才 metaSave, + * 中断则 meta 保持空——因此这里无需再查 stored_size。 */ ota_meta_t meta = {}; - if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || - meta.total_size == 0 || meta.stored_size < meta.total_size) { + if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || meta.total_size == 0) { return http_send_json_error(req, "400 Bad Request", - "firmware_not_stored", "firmware not fully stored"); + "firmware_not_stored", "no firmware stored (upload first)"); } - /* ★ 接收前端发送的固件 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); - } + /* relay 不做 SHA 校验(2026-07-27):完整性校验已在上传阶段闭环 + *(ESP32 从 flash 读回算 SHA 随响应返回,前端比对 + 失败重传)。 + * relay 只是启动扳机;body 一律忽略,仅排空以维持 keep-alive 解析。 */ + if (req->content_len > 0) { + char drain[128]; + int remaining = req->content_len; + while (remaining > 0) { + const int chunk = remaining > (int)sizeof(drain) ? (int)sizeof(drain) : remaining; + const int r = httpd_req_recv(req, drain, (size_t)chunk); + if (r <= 0) break; + remaining -= r; } } - 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"); @@ -2282,9 +2252,9 @@ esp_err_t motor_ota_status_handler(httpd_req_t *req) static const char *state_names[] = { "idle", "handshake_req", "handshake_nonce", "handshake_confirm", "erase", "transfer", "finish_ota", "finished", "error", - "pre_cancel" + "pre_cancel", "wait_app_run" }; - const int si = (st.state <= MOTOR_OTA_PRE_CANCEL) ? (int)st.state : 0; + const int si = (st.state <= MOTOR_OTA_WAIT_APP_RUN) ? (int)st.state : 0; char buf[256]; snprintf(buf, sizeof(buf), diff --git a/tools/js/motor/main.js b/tools/js/motor/main.js index c265a72..d531ad1 100644 --- a/tools/js/motor/main.js +++ b/tools/js/motor/main.js @@ -363,18 +363,38 @@ function faultCountOf(d) { * PID_vel_loop_P 136|16@1- → B17-18 * PID_angle_loop_P 152|16@1- → B19-20 */ +/* ── CMD 数值输入提交制 ── + * 周期发送每 80~100ms 读一次输入框:若直接读 .value,键入 "200" 的中间态 + * "2"/"20" 会被当目标值发给电机。改为:输入过程不生效,按回车或点击别处 + * (blur 触发 change)才提交到 cmdCommitted,buildCmdBytes 只读已提交值。 */ +const CMD_VALUE_IDS = ["inVoltage", "inCurrent", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP"]; +const cmdCommitted = {}; +function initCmdCommit() { + for (const id of CMD_VALUE_IDS) { + const el = document.getElementById(id); + if (!el) continue; + cmdCommitted[id] = el.value; /* 初始提交:含 loadUiState 回填值(本函数在其之后调用) */ + el.addEventListener("change", () => { cmdCommitted[id] = el.value; }); /* blur/回车后值变化才触发 */ + el.addEventListener("keydown", (e) => { if (e.key === "Enter") el.blur(); }); /* 回车 = 失焦提交 */ + } +} +/* 读已提交值(未 init 时回退读当前框值,保险) */ +function cmdVal(id) { + return cmdCommitted[id] !== undefined ? cmdCommitted[id] : $("#" + id).value; +} + function buildCmdBytes() { const buf = new ArrayBuffer(32); const dv = new DataView(buf); const mode = parseInt($("#modeSelect").value, 10) || 0; dv.setUint8(0, mode); - dv.setUint16(1, Math.round((parseFloat($("#inVoltage").value) || 0) * 1000) & 0xFFFF, true); - dv.setFloat32(3, parseFloat($("#inCurrent").value) || 0, true); - dv.setFloat32(7, parseFloat($("#inVelocity").value) || 0, true); - dv.setFloat32(11, parseFloat($("#inAngle").value) || 0, true); - dv.setInt16(15, parseInt($("#pidCurP").value, 10) || 0, true); - dv.setInt16(17, parseInt($("#pidVelP").value, 10) || 0, true); - dv.setInt16(19, parseInt($("#pidAngP").value, 10) || 0, true); + dv.setUint16(1, Math.round((parseFloat(cmdVal("inVoltage")) || 0) * 1000) & 0xFFFF, true); + dv.setFloat32(3, parseFloat(cmdVal("inCurrent")) || 0, true); + dv.setFloat32(7, parseFloat(cmdVal("inVelocity")) || 0, true); + dv.setFloat32(11, parseFloat(cmdVal("inAngle")) || 0, true); + dv.setInt16(15, parseInt(cmdVal("pidCurP"), 10) || 0, true); + dv.setInt16(17, parseInt(cmdVal("pidVelP"), 10) || 0, true); + dv.setInt16(19, parseInt(cmdVal("pidAngP"), 10) || 0, true); return new Uint8Array(buf); } function bytesToHexStr(bytes) { @@ -1775,7 +1795,9 @@ document.querySelectorAll(".tab-btn").forEach((btn) => { * 两段式(与 BLE 刷 GD32 对齐): * 步1 POST /motor/ota 收固件写 ESP32 flash(不碰 SPI,避免与 GD32 按铃竞态) * 步2 POST /motor/ota/relay 触发 SPI START + feedTask 喂 GD32 → GD32 本地 AES 握手 + CAN stop-and-wait - * 前端负责:选文件 → 进BOOT → 上传 → 触发转发 → 轮询进度 → 显示结果。 */ + * 前端负责:选文件 → 上传 → 进BOOT → 触发转发 → 轮询进度 → 显示结果。 + * ★ 顺序(2026-07-27 调整):先收完固件再让电机进 BOOT——上传期间电机仍在 APP + * 正常运行,上传失败/固件损坏时电机不会被白白踢进 BOOT 空等。 */ const OTA_MAX_FW = 0xC000; // 48 KB 上限 const OTA_POLL_MS = 500; // 进度轮询间隔 const OTA_TOTAL_TIMEOUT_MS = 120000; // OTA 整体超时(含进 BOOT + 上传 + 传输),超时自动取消 @@ -1975,17 +1997,17 @@ function resetOtaUi() { $("#otaPktNum").textContent = "0"; } -/* 状态名 → 阶段索引映射(6 阶段:进入BOOT → 上传固件 → 转发握手 → 擦除 → 传输 → 完成) - * 后端 motor_ota_state_t 不含 boot/upload 阶段(纯前端驱动),后端状态从 handshake_req 起。 - * 两段式:上传固件(stage1)完成 → 触发转发 → GD32 握手(stage2)/擦除(stage3)/传输(stage4)/完成(stage5)。 */ +/* 状态名 → 阶段索引映射(6 阶段:上传固件 → 进入BOOT → 转发握手 → 擦除 → 传输 → 完成) + * 后端 motor_ota_state_t 不含 upload/boot 阶段(纯前端驱动),后端状态从 handshake_req 起。 + * 顺序:上传固件(stage0) → 进BOOT(stage1) → 触发转发 → GD32 握手(stage2)/擦除(stage3)/传输(stage4)/完成(stage5)。 */ const OTA_STATE_STAGE = { idle: -1, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2, - erase: 3, transfer: 4, finish_ota: 5, finished: 5, error: -1, + erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finished: 5, error: -1, }; const OTA_STATE_ZH = { idle: "空闲", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…", handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输", - finish_ota: "等待跳转…", finished: "完成", error: "错误", + finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误", }; $("#otaFileBtn").addEventListener("click", async () => { @@ -2087,20 +2109,21 @@ async function startOta() { } const ok = await showModal("开始电机 OTA", - `将先发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` + - `再上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备,` + + `将先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备并校验 SHA-256,` + + `再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` + `由 GD32 本地完成 AES 握手与 CAN 传输。OTA 期间请勿操作电机。`); if (!ok) return; stopRepeat(true); ota.running = true; + ota.node = node; /* 记录目标节点:finished 后 verifyNewApp 查版本用 */ ota.startTime = performance.now(); $("#otaStartBtn").disabled = true; $("#otaCancelBtn").disabled = false; resetOtaUi(); - setOtaStage(0); /* 进入 BOOT 阶段 */ + setOtaStage(0); /* 上传固件阶段 */ - /* 整体超时看门狗:进 BOOT + 上传 + 传输 + 跳转 总计不应超过 OTA_TOTAL_TIMEOUT_MS。 + /* 整体超时看门狗:上传 + 进 BOOT + 传输 + 跳转 总计不应超过 OTA_TOTAL_TIMEOUT_MS。 * 超时后自动取消,避免卡死时前端无限轮询 + ESP32 feedTask 残留。 */ if (ota.watchdogTimer) clearTimeout(ota.watchdogTimer); ota.watchdogTimer = setTimeout(() => { @@ -2110,67 +2133,93 @@ async function startOta() { }, OTA_TOTAL_TIMEOUT_MS); /* OTA 期间暂停 motor/feedback/status 三链路轮询: - * 电机已跳 BOOT,motor 状态无意义;三条链各占一个 HTTP socket + httpd 线程时间, - * 叠加 OTA poll(500ms)+ cancel 需要的新连接,6 个 max_open_sockets 会紧张, - * 导致 cancel "Failed to fetch"(连接建立失败/被 lru_purge 踢掉)。 + * 三条链各占一个 HTTP socket + httpd 线程时间,叠加固件上传 + OTA poll(500ms) + * + cancel 需要的新连接,6 个 max_open_sockets 会紧张, + * 导致 cancel "Failed to fetch"(连接建立失败/被 lru_purge 踢掉); + * 且电机随后将跳 BOOT,motor 状态轮询无意义。 * OTA 完成后 otaDone→startPolling 恢复。 */ stopDataPolling(); - /* ── 步骤 1:发 0x000N0000 进 BOOT 帧(DLC=0,fire-and-forget)── - * APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。 - * 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK, - * 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败—— - * 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。 - * 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */ - const bootId = canId(node, 0x0000); /* 0x000N0000 */ - otaLog(`[进BOOT] 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`); + /* ── 步骤 1:前端先算本地固件 SHA-256(只算一次,重传不变)── */ + const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes); + const fwShaHex = Array.from(new Uint8Array(fwSha256)) + .map(b => b.toString(16).padStart(2, "0")).join(""); + otaLog(`[校验] 本地固件 SHA-256: ${fwShaHex}`); + try { - const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true }); - if (r.ok) { - otaLog(`[进BOOT] 命令已发送并确认`); - } else { - otaLog(`[进BOOT] 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`); + /* ── 步骤 2:上传 + SHA 比对,不通过重传(最多 3 次尝试 = 首发 + 重发 2 次)── + * POST /motor/ota 只收固件写 flash(不触发 SPI),ESP32 收尾从 flash 读回算 SHA 随响应返回; + * 前端比对不一致 = flash 写坏 → 重传(每次上传都全盘擦除重写); + * 连续 3 次不过才中断——此时电机仍在 APP 正常运行,重试/中断都不影响电机。 */ + const kUploadMaxAttempts = 3; + let uploadOk = false; + for (let attempt = 1; attempt <= kUploadMaxAttempts && ota.running; attempt++) { + otaLog(`[上传] 上传固件到 ESP32 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`); + let upJson = {}; + try { + const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: ota.bytes, + signal: AbortSignal.timeout(15000), + }); + upJson = await upResp.json().catch(() => ({})); + if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`); + } catch (e) { + otaLog(`[上传] 第 ${attempt} 次上传失败:${e.message || e}`); + if (attempt < kUploadMaxAttempts) await sleep(300); + continue; + } + otaLog(`[上传] 固件已存储 (${upJson.size || ota.bytes.length} B)`); + + /* 比对:本地 SHA vs ESP32 从 flash 读回算的 SHA(upJson.sha256) */ + if (!upJson.sha256 || upJson.sha256.length !== 64) { + /* 旧 ESP32 固件无 sha256 字段:无法本地比对,且旧固件 relay 还要求 body(新前端已不发), + * 需刷新 ESP32 固件;继续尝试,若 relay 被拒会报错中止。 */ + otaLog(`[校验] 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`); + uploadOk = true; + break; + } + if (upJson.sha256.toLowerCase() === fwShaHex) { + otaLog(`[校验] flash SHA 比对一致,允许进 BOOT`); + uploadOk = true; + break; + } + otaLog(`[校验] 第 ${attempt} 次 flash SHA 不一致(device=${upJson.sha256.slice(0, 16)}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`); + if (attempt < kUploadMaxAttempts) await sleep(300); + } + if (!uploadOk) { + throw new Error(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`); } - } catch (e) { - otaLog(`[进BOOT] 发送异常:${e.message || e}(继续,由握手确认是否进 BOOT)`); - } - otaLog(`[进BOOT] 等待电机 jumpToBOOT() 重启进入 Bootloader…`); - /* ── 等待电机跳转 + BOOT 初始化 ── - * jumpToBOOT() 含 can_deinit + 时钟重置 + MSP 跳转,实测 <100ms; - * 给 1000ms 余量覆盖 APP 保存配置 + flash 写 + 重启稳定 + CAN 恢复。 - * 原 2000ms 过保守(GD32 kBootSettleMs 已降到 800ms,总链路无需 2s 等待)。 */ - await sleep(1000); - otaLog(`[上传] 开始上传固件到 ESP32 (CH${ch})…`); - setOtaStage(1); /* 上传固件阶段 */ + /* ── 步骤 3:发 0x000N0000 进 BOOT 帧(DLC=0,fire-and-forget)── + * 固件已收完且 flash SHA 比对通过,此刻才让电机离开 APP。 + * APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。 + * 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK, + * 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败—— + * 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。 + * 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */ + setOtaStage(1); /* 进入 BOOT 阶段 */ + const bootId = canId(node, 0x0000); /* 0x000N0000 */ + otaLog(`[进BOOT] 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`); + try { + const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true }); + if (r.ok) { + otaLog(`[进BOOT] 命令已发送并确认`); + } else { + otaLog(`[进BOOT] 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`); + } + } catch (e) { + otaLog(`[进BOOT] 发送异常:${e.message || e}(继续,由握手确认是否进 BOOT)`); + } + otaLog(`[进BOOT] 命令已发出,立即触发转发…`); - try { - /* ── 步骤 2:POST /motor/ota 只收固件写 flash(不触发 SPI,避免与 GD32 按铃竞态)── */ - const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body: ota.bytes, - signal: AbortSignal.timeout(15000), - }); - const upJson = await upResp.json().catch(() => ({})); - if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`); - otaLog(`[上传] 固件已存储 (${upJson.size || ota.bytes.length} B),触发 GD32 转发…`); - - /* ── 步骤 3:前端算固件 SHA-256,发给 ESP32 与 flash 实际 SHA 比对 ── - * ★ 关键:之前 motor OTA 没有 SHA-256 比对,ESP32 flash 写入位错误无法被发现。 - * 前端用 Web Crypto 算 ota.bytes 的 SHA-256,relay 触发时用 JSON body 发给 ESP32, - * ESP32 调 metaVerifyChecksum 复核 flash 实际 SHA,不匹配则拒绝刷写。 - * 这样从源头确认"前端发的 = ESP32 flash 存的",堵住 flash 存储环节位错误。 */ - const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes); - const fwShaHex = Array.from(new Uint8Array(fwSha256)) - .map(b => b.toString(16).padStart(2, "0")).join(""); - otaLog(`[校验] 固件 SHA-256: ${fwShaHex}`); - - /* ── 步骤 4:POST /motor/ota/relay 触发 SPI START + 喂数据 ── */ + /* ── 步骤 4:POST /motor/ota/relay 触发 SPI START + 喂数据(纯启动扳机,无 body)── + * SHA 校验已在步骤 2 闭环(比对+重传),relay 不再传/比 SHA。 + * 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。 + * SPI 0xC0(进 BOOT) 与 0xD0(START) 经 SPI worker 串行,顺序有保证。 */ const rResp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${ch}`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sha256: fwShaHex }), signal: AbortSignal.timeout(8000), }); const rJson = await rResp.json().catch(() => ({})); @@ -2213,10 +2262,10 @@ function startOtaPoll() { clearInterval(ota.pollTimer); ota.pollTimer = null; if (st.state === "finished") { - otaLog(`[完成] OTA 成功 · ${st.packets_acked} 包 · 电机 APP 已启动`); - toast("电机 OTA 完成", "ok"); + otaLog(`[完成] 传输完成 · ${st.packets_acked} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`); } else if (st.state === "error") { - otaLog(`[错误] 代码 0x${(st.error || 0).toString(16).toUpperCase()}`); + const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : ""; + otaLog(`[错误] 代码 0x${(st.error || 0).toString(16).toUpperCase()}${errHint}`); toast("电机 OTA 失败 (0x" + (st.error || 0).toString(16) + ")", "err"); } /* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel @@ -2224,17 +2273,65 @@ function startOtaPoll() { * CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s → APP 看似卡死。 * 只有 error/idle/异常退出才发 cancel 兜底清理设备端残留状态。 */ otaDone(st.state !== "finished"); + /* ★ finished = GD32 已收到电机新 APP 上电自主发的 [0A AC] 运行确认 + * (kWaitAppRun → kFinished,无确认 3s 超时报 0x25)——APP 已确认在跑。 + * verifyNewApp 只做最后一步:查版本号核对并回填 UI。 */ + if (st.state === "finished") verifyNewApp(ota.node); } } catch (e) { /* 轮询瞬时失败静默 */ } }, OTA_POLL_MS); } + + /* ── OTA 终验:查版本号核对新 APP ── + * 运行确认已由设备端闭环:电机新 APP 上电自主发 [0A AC],GD32 kWaitAppRun + * 收到才报 finished(无确认 3s → error 0x25)——走到这里 APP 必然在跑且 CAN 已就绪。 + * 本函数只补最后一步:发 GET_VERSION(0x000N00FF) 拿新版本号展示/核对, + * 无应答仅提醒(不推翻设备端的运行确认结论)。 */ + async function verifyNewApp(node) { + if (node == null || node < 1) return; + otaLog(`[验证] 查询新 APP 版本号 (${hexId(ID_GET_VERSION_REQ(node))})…`); + await sleep(200); /* APP 已发过 0AAC,CAN 已就绪,留小间隔避开启动期杂帧 */ + const kVerifyAttempts = 2; + for (let attempt = 1; attempt <= kVerifyAttempts; attempt++) { + try { + const before = await fetchMotorVersionNode(node); + const prevSeq = before ? before.seq : null; + const res = await sendOneFrameWhenFree(ID_GET_VERSION_REQ(node), 24, bytesToHexStr(new Uint8Array(24))); + if (res.ok) { + const v = await waitMotorVersion(node, prevSeq, 4000); + if (v && v.version) { + const parsed = parseVersionPayload(v.version); + if (parsed && parsed.soft) { + /* 同步版本卡片与缓存,用户无需再手动点查版本 */ + setVer("verSoft", parsed.soft || "--"); + setVer("verHard", parsed.hard || "--"); + setVer("verBoot", parsed.boot || "--"); + state.versions.set(node, { soft: parsed.soft || null, hard: parsed.hard || null, boot: parsed.boot || null }); + saveUiState(); + otaLog(`[成功] OTA 成功 · 新 APP v${parsed.soft} 运行中(0AAC 确认 + 版本应答)`); + toast(`电机 OTA 成功 · APP v${parsed.soft}`, "ok"); + return; + } + } + } + } catch (e) { /* 单轮异常按未应答处理,进入重试 */ } + if (attempt < kVerifyAttempts) { + otaLog(`[验证] 第 ${attempt} 次无应答,重试…`); + } + } + otaLog(`[提醒] APP 已发 0AAC 确认运行,但版本查询无应答,请手动点「查版本」核对`); + toast("OTA 完成(版本查询无应答,请手动核对)", "warn"); + } function updateOtaUi(st, startTime) { const stage = OTA_STATE_STAGE[st.state] ?? -1; setOtaStage(stage); const acked = st.packets_acked || 0; const total = st.total_packets || 1; - $("#otaPktNum").textContent = String(acked) + "/" + String(total); + /* HTML 模板已是「包 <已确认> / <总数>」,这里各填各的; + * 之前往 otaPktNum 塞整串 "acked/total" 会渲染成「包 696/912 / 912」。 */ + $("#otaPktNum").textContent = String(acked); + $("#otaPktTotal").textContent = String(total); $("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%"; const elapsed = (performance.now() - startTime) / 1000; if (elapsed > 0.5 && acked > 0) { @@ -2614,6 +2711,7 @@ renderNodeList(); renderStatusPanel(); restoreVersionUi(state.selectedNode); /* 回填上次缓存的版本号 */ bindAutoSave(); +initCmdCommit(); /* CMD 输入提交制:必须在 loadUiState 回填之后初始化,否则初始提交值是 HTML 默认值 */ otaRestorePersistedFirmware(); /* 异步恢复上次固件(无阻塞) */ /* 暴露诊断对象到 window,供控制台排查 age 波动 / 锁相状态: diff --git a/tools/motor.html b/tools/motor.html index 84819b5..fdc8f50 100644 --- a/tools/motor.html +++ b/tools/motor.html @@ -4,7 +4,7 @@ 电机控制台 — Motor CAN FD Console - + @@ -199,8 +199,8 @@
-
1进入BOOT
-
2上传固件
+
1上传固件
+
2进入BOOT
3转发握手
4擦除Flash
5数据传输
@@ -226,8 +226,8 @@
-
[就绪] 电机 Bootloader OTA · 先发 0x000N0000 进 BOOT → 上传固件到 ESP32 → 触发转发 → GD32 本地 relay(AES-128 + CAN stop-and-wait)
-
[信息] 选固件 → 发进BOOT命令 → 上传固件到 ESP32 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限
+
[就绪] 电机 Bootloader OTA · 先上传固件到 ESP32 → 发 0x000N0000 进 BOOT → 触发转发 → GD32 本地 relay(AES-128 + CAN stop-and-wait)
+
[信息] 选固件 → 上传并比对 flash SHA-256(失败重传≤2次)→ 发进BOOT命令 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限
@@ -343,6 +343,6 @@ - + \ No newline at end of file