feat: 更新固件版本至 0.3.72 并实现构建时间自动生成

在 version.h 中将固件版本由 0.3.71 递增至 0.3.72。
移除 BUILD_TIME 的手动填写项,现由构建系统自动生成。
更新电机 OTA relay 逻辑,缩短状态检查的宽限期。
增强电机 OTA 上传与 relay 流程中的错误处理和日志记录。
为固件上传引入 SHA-256 校验,确保固件完整性。
调整前端命令值提交逻辑,改善用户体验。
更新 HTML 与 JavaScript 文件,以反映 OTA 流程及 UI 的变更。
新增 build_info 文件,用于管理构建时间的自动生成。
This commit is contained in:
小龙虾
2026-07-28 00:23:18 +08:00
parent 33b41fd50a
commit 74eb68b140
12 changed files with 281 additions and 163 deletions
+14
View File
@@ -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")
+12
View File
@@ -0,0 +1,12 @@
/*
* build_info.cpp - 唯一包含自动生成时间头的编译单元。
* build_time_gen.h 每次构建都被 gen_build_time 目标重写 → 仅本文件重编 + 重链接,
* 其余增量编译不受影响;其他模块经 fwBuildTime() 取值。
*/
#include "build_info.h"
#include "build_time_gen.h"
const char *fwBuildTime(void)
{
return FW_BUILD_TIME_AUTO;
}
+8
View File
@@ -0,0 +1,8 @@
# 每次构建执行:生成真实编译时间头文件(由 main/CMakeLists.txt 的 gen_build_time 目标调用)。
# 手工维护的 version.h BUILD_TIME 曾停留在旧值,导致「刷了新固件仍显示旧时间」被误判为回退。
string(TIMESTAMP _build_time "%Y-%m-%d %H:%M:%S")
file(WRITE "${OUT}"
"/* 自动生成(每次构建刷新),勿手改、勿提交。仅 core/build_info.cpp 包含本文件。 */
#pragma once
#define FW_BUILD_TIME_AUTO \"${_build_time}\"
")
+3 -1
View File
@@ -9,6 +9,7 @@
#include <cstdint>
#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 提交哈希(手动维护)
// ============================================================
+15
View File
@@ -0,0 +1,15 @@
/*
* build_info.h - 真实编译时间(构建系统每次自动生成,替代 version.h 手工 BUILD_TIME)
*/
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/** 返回本固件的真实编译时间字符串 "YYYY-MM-DD HH:MM:SS"(每次构建自动刷新)。 */
const char *fwBuildTime(void);
#ifdef __cplusplus
}
#endif
+7 -8
View File
@@ -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"
+8 -9
View File
@@ -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 ||
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -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);
+34 -64
View File
@@ -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":"<hex64>"})。
* 前端在 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),
+171 -73
View File
@@ -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(`<span class="log-info">[进BOOT]</span> 发送 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(`<span class="log-info">[校验]</span> 本地固件 SHA-256: ${fwShaHex}`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 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(`<span class="log-info">[上传]</span> 上传固件到 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(`<span class="log-err">[上传]</span> 第 ${attempt} 次上传失败:${e.message || e}`);
if (attempt < kUploadMaxAttempts) await sleep(300);
continue;
}
otaLog(`<span class="log-ok">[上传]</span> 固件已存储 (${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(`<span class="log-err">[校验]</span> 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`);
uploadOk = true;
break;
}
if (upJson.sha256.toLowerCase() === fwShaHex) {
otaLog(`<span class="log-ok">[校验]</span> flash SHA 比对一致,允许进 BOOT`);
uploadOk = true;
break;
}
otaLog(`<span class="log-err">[校验]</span> 第 ${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(`<span class="log-info">[进BOOT]</span> 发送异常:${e.message || e}(继续,由握手确认是否进 BOOT)`);
}
otaLog(`<span class="log-ok">[进BOOT]</span> 等待电机 jumpToBOOT() 重启进入 Bootloader…`);
/* ── 等待电机跳转 + BOOT 初始化 ──
* jumpToBOOT() 含 can_deinit + 时钟重置 + MSP 跳转,实测 <100ms;
* 给 1000ms 余量覆盖 APP 保存配置 + flash 写 + 重启稳定 + CAN 恢复。
* 原 2000ms 过保守(GD32 kBootSettleMs 已降到 800ms,总链路无需 2s 等待)。 */
await sleep(1000);
otaLog(`<span class="log-info">[上传]</span> 开始上传固件到 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(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
}
} catch (e) {
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${e.message || e}(继续,由握手确认是否进 BOOT)`);
}
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出,立即触发转发…`);
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(`<span class="log-ok">[上传]</span> 固件已存储 (${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(`<span class="log-info">[校验]</span> 固件 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(`<span class="log-ok">[完成]</span> OTA 成功 · ${st.packets_acked} 包 · 电机 APP 已启动`);
toast("电机 OTA 完成", "ok");
otaLog(`<span class="log-ok">[完成]</span> 传输完成 · ${st.packets_acked} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
} else if (st.state === "error") {
otaLog(`<span class="log-err">[错误]</span> 代码 0x${(st.error || 0).toString(16).toUpperCase()}`);
const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
otaLog(`<span class="log-err">[错误]</span> 代码 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(`<span class="log-info">[验证]</span> 查询新 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(`<span class="log-ok">[成功]</span> OTA 成功 · 新 APP v${parsed.soft} 运行中(0AAC 确认 + 版本应答)`);
toast(`电机 OTA 成功 · APP v${parsed.soft}`, "ok");
return;
}
}
}
} catch (e) { /* 单轮异常按未应答处理,进入重试 */ }
if (attempt < kVerifyAttempts) {
otaLog(`<span class="log-info">[验证]</span> 第 ${attempt} 次无应答,重试…`);
}
}
otaLog(`<span class="log-err">[提醒]</span> 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 波动 / 锁相状态:
+6 -6
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>电机控制台 — Motor CAN FD Console</title>
<link rel="stylesheet" href="css/motor.css?v=20260727a">
<link rel="stylesheet" href="css/motor.css?v=20260727b">
</head>
<body>
@@ -199,8 +199,8 @@
<!-- OTA TAB -->
<div class="tab-page" id="tab-ota">
<div class="ota-stages" id="otaStages" aria-label="OTA阶段">
<div class="ota-stage" data-stage="boot"><span class="st-dot">1</span>进入BOOT</div>
<div class="ota-stage" data-stage="upload"><span class="st-dot">2</span>上传固件</div>
<div class="ota-stage" data-stage="upload"><span class="st-dot">1</span>上传固件</div>
<div class="ota-stage" data-stage="boot"><span class="st-dot">2</span>进入BOOT</div>
<div class="ota-stage" data-stage="handshake"><span class="st-dot">3</span>转发握手</div>
<div class="ota-stage" data-stage="erase"><span class="st-dot">4</span>擦除Flash</div>
<div class="ota-stage" data-stage="transfer"><span class="st-dot">5</span>数据传输</div>
@@ -226,8 +226,8 @@
<button id="otaLogClearBtn" title="清空日志">清空</button>
</div>
<div class="ota-log" id="otaLog" aria-live="polite">
<div class="log-info">[就绪] 电机 Bootloader OTA · 先发 0x000N0000 进 BOOT → 上传固件到 ESP32 → 触发转发 → GD32 本地 relay(AES-128 + CAN stop-and-wait)</div>
<div>[信息] 选固件 → 发进BOOT命令 → 上传固件到 ESP32 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限</div>
<div class="log-info">[就绪] 电机 Bootloader OTA · 先上传固件到 ESP32 → 发 0x000N0000 进 BOOT → 触发转发 → GD32 本地 relay(AES-128 + CAN stop-and-wait)</div>
<div>[信息] 选固件 → 上传并比对 flash SHA-256(失败重传≤2次)→ 发进BOOT命令 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限</div>
</div>
</div>
</div>
@@ -343,6 +343,6 @@
</div>
</div>
<script type="module" src="js/motor/main.js?v=20260727a"></script>
<script type="module" src="js/motor/main.js?v=20260727l"></script>
</body>
</html>