From 9925009a9267033eecff2a392997e4e726d216df Mon Sep 17 00:00:00 2001
From: cbb <2625274797@qq.com>
Date: Mon, 21 Sep 2026 03:13:04 +0800
Subject: [PATCH] =?UTF-8?q?refactor(motor):=20=E7=94=B5=E6=9C=BA=20OTA=20?=
=?UTF-8?q?=E5=A4=96=E6=8F=90=20ota.js=EF=BC=88P1=20=E9=87=8D=E5=81=9A?=
=?UTF-8?q?=E7=AC=AC=203=20=E6=AD=A5=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
createOtaSys 注入 \$ 与轮询/版本依赖;STATUS offlineOverlay 契约保留。
连接/轮询/STATUS 渲染仍留在 main.js,本轮不再动连接状态机。
UI 验收:绿灯 + 节点数据 + 遮罩 display:none + 控制台无报错。
---
js/motor/main.js | 642 +---------------------------------------------
js/motor/ota.js | 651 +++++++++++++++++++++++++++++++++++++++++++++++
pages/motor.html | 2 +-
3 files changed, 664 insertions(+), 631 deletions(-)
create mode 100644 js/motor/ota.js
diff --git a/js/motor/main.js b/js/motor/main.js
index 5cd221d..b6f835d 100644
--- a/js/motor/main.js
+++ b/js/motor/main.js
@@ -50,8 +50,10 @@ import {
defaultRefreshMap, normalizeRefreshMap, pickRefresh, minRefreshFor,
isUnthrottled
} from "./refresh-rate.js";
-import { createScopeSys } from "./scope.js?v=20260924d";
-import { createSeqSys } from "./seq.js?v=20260924d";
+import { createScopeSys } from "./scope.js?v=20260924f";
+import { createSeqSys } from "./seq.js?v=20260924f";
+import { createOtaSys } from "./ota.js?v=20260924g";
+
/* ─── 控制模式定义 ─── */
const MODES = {
@@ -3063,635 +3065,15 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
});
});
-/* ═══════════════════════ 电机 OTA(relay 模式)═══════════════════════
- * 两段式(与 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 → 触发转发 → 轮询进度 → 显示结果。
- * ★ 顺序(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 + 上传 + 传输),超时自动取消
-const ota = {
- running: false,
- file: null,
- bytes: null,
- pollTimer: null,
- watchdogTimer: null, /* 整体超时看门狗 */
- startTime: 0, /* OTA 开始时间戳 */
- fileHandle: null, /* FileSystemFileHandle(FSAPI),用于刷新后恢复固件 */
- pendingHandle: null, /* 待重新授权的 handle(权限丢失时暂存,点上传区再授权) */
-};
-
-/* ─── 固件文件持久化(仿主页面 file-handle.js 模式)───
- * localStorage 存文件元数据(名/大小),IndexedDB 存 FileSystemFileHandle。
- * 刷新后:权限仍 granted → 静默恢复 bytes;否则展示文件名,点"选择固件"区重新授权。
- * File 对象本身不可序列化,bytes 不入 localStorage;IndexedDB 仅存 handle(可结构化克隆)。 */
-const LS_OTA_FW_META = "dcu_motor_ota_fw_meta";
-const IDB_OTA_NAME = "dcu_motor_ota_fw_v1";
-const IDB_OTA_STORE = "file_handle";
-let _otaIdbDb = null;
-
-function otaIdbOpen() {
- if (_otaIdbDb) return Promise.resolve(_otaIdbDb);
- if (!window.indexedDB) return Promise.reject(new Error("no IDB"));
- return new Promise((resolve, reject) => {
- const req = indexedDB.open(IDB_OTA_NAME, 1);
- req.onupgradeneeded = () => {
- if (!req.result.objectStoreNames.contains(IDB_OTA_STORE)) req.result.createObjectStore(IDB_OTA_STORE);
- };
- req.onsuccess = () => { _otaIdbDb = req.result; resolve(_otaIdbDb); };
- req.onerror = () => { _otaIdbDb = null; reject(req.error); };
- });
-}
-function otaIdbPutHandle(handle) {
- return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
- const tx = db.transaction(IDB_OTA_STORE, "readwrite");
- if (handle) tx.objectStore(IDB_OTA_STORE).put(handle, "motor");
- else tx.objectStore(IDB_OTA_STORE).delete("motor");
- tx.oncomplete = () => resolve();
- tx.onerror = () => reject(tx.error);
- })).catch(() => {});
-}
-function otaIdbGetHandle() {
- return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
- const tx = db.transaction(IDB_OTA_STORE, "readonly");
- const r = tx.objectStore(IDB_OTA_STORE).get("motor");
- r.onsuccess = () => resolve(r.result || null);
- r.onerror = () => reject(r.error);
- })).catch(() => null);
-}
-
-function otaSaveFwMeta(file) {
- try {
- if (file && file.name) {
- localStorage.setItem(LS_OTA_FW_META, JSON.stringify({ name: file.name, size: file.size || 0, lastModified: file.lastModified || 0 }));
- } else {
- localStorage.removeItem(LS_OTA_FW_META);
- }
- } catch (_) {}
-}
-function otaLoadFwMeta() {
- try {
- const raw = localStorage.getItem(LS_OTA_FW_META);
- return raw ? JSON.parse(raw) : null;
- } catch (_) { return null; }
-}
-
-/** 查询 handle 权限(不 request,刷新无手势时 request 会失败) */
-async function otaQueryPermission(handle) {
- if (!handle || typeof handle.getFile !== "function") return "denied";
- try {
- if (typeof handle.queryPermission === "function") return await handle.queryPermission({ mode: "read" });
- await handle.getFile();
- return "granted";
- } catch (_) { return "denied"; }
-}
-
-/** 把恢复的 File 应用到 ota 对象 + UI */
-async function otaApplyRestoredFile(handle, file, opts) {
- opts = opts || {};
- ota.fileHandle = handle || null;
- ota.file = file;
- ota.bytes = new Uint8Array(await file.arrayBuffer());
- $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
- $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
- otaSaveFwMeta(file);
- if (!opts.silent) otaLog(`[固件] ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
-}
-
-/** 无权限时展示文件名,提示点上传区重新授权 */
-function otaShowPendingReauth(handle, meta) {
- ota.fileHandle = handle || null;
- ota.pendingHandle = handle || null;
- const name = (meta && meta.name) || (handle && handle.name) || "上次固件";
- $("#otaFileName").textContent = `${name} — 点击「选择固件」重新授权恢复`;
- if (meta && meta.size) $("#otaPktTotal").textContent = String(Math.ceil(meta.size / 48));
- otaLog(`[固件] 上次固件「${escapeHtml(name)}」需点击「选择固件」重新授权(浏览器安全限制)`);
-}
-
-/** 页面加载后恢复上次固件(权限 granted → 静默恢复;否则提示重新授权) */
-async function otaRestorePersistedFirmware() {
- let handle;
- try { handle = await otaIdbGetHandle(); } catch (_) { handle = null; }
- const meta = otaLoadFwMeta();
- if (!handle) {
- /* 无 handle(非 FSAPI 选择的旧文件或首次):仅展示元数据提示 */
- if (meta && meta.name) {
- $("#otaFileName").textContent = `${meta.name} — ${meta.size ? meta.size.toLocaleString() + " B · " : ""}请重新选择`;
- otaLog(`[固件] 上次固件「${escapeHtml(meta.name)}」需重新选择(无 FSAPI handle)`);
- }
- return;
- }
- const perm = await otaQueryPermission(handle);
- if (perm === "granted") {
- try {
- const file = await handle.getFile();
- await otaApplyRestoredFile(handle, file, { silent: true });
- otaLog(`[固件] 已恢复刷新前的固件: ${escapeHtml(file.name)}`);
- } catch (e) {
- otaShowPendingReauth(handle, meta);
- }
- } else {
- otaShowPendingReauth(handle, meta);
- }
-}
-
-/** 用户手势中:对 pending handle 重新授权并恢复(点"选择固件"时先尝试此路径) */
-async function otaResumeWithGesture() {
- const handle = ota.pendingHandle;
- if (!handle || typeof handle.getFile !== "function") return false;
- try {
- let perm = "granted";
- if (typeof handle.queryPermission === "function") perm = await handle.queryPermission({ mode: "read" });
- if (perm !== "granted" && typeof handle.requestPermission === "function") perm = await handle.requestPermission({ mode: "read" });
- if (perm !== "granted") { toast("未授予读取权限,请在弹窗中允许或重新选择", "warn"); return false; }
- const file = await handle.getFile();
- if (file.size > OTA_MAX_FW) { toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err"); return false; }
- await otaApplyRestoredFile(handle, file, {});
- ota.pendingHandle = null;
- toast(`已恢复上次固件: ${file.name}`, "ok");
- return true;
- } catch (e) {
- otaLog(`[固件] 恢复失败: ${escapeHtml(e.message || e)}`);
- return false;
- }
-}
-
-function otaLog(html) {
- const el = $("#otaLog");
- const div = document.createElement("div");
- div.innerHTML = html;
- el.appendChild(div);
- el.scrollTop = el.scrollHeight;
-}
-
-/* OTA 日志工具栏:清空 / 复制 */
-$("#otaLogClearBtn").addEventListener("click", function () {
- const el = $("#otaLog");
- el.innerHTML = '
[已清空] 日志已清除
';
+/* ═══════════════════════ 电机 OTA(relay 模式)═══════════════
+ * 实现见 js/motor/ota.js(P1 重做:机械外提)。 */
+const otaSys = createOtaSys({
+ state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
+ sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
+ ID_GET_VERSION_REQ, parseVersionPayload, setVer,
+ stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
});
-$("#otaLogCopyBtn").addEventListener("click", function () {
- const el = $("#otaLog");
- const text = el.innerText.trim();
- if (!text) { toast("日志为空", "warn"); return; }
- const btn = this;
- function onDone() {
- btn.classList.add("copied");
- setTimeout(function () { btn.classList.remove("copied"); }, 1200);
- toast("已复制日志", "ok");
- }
- function fallback() {
- const ta = document.createElement("textarea");
- ta.value = text;
- ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
- document.body.appendChild(ta);
- ta.select();
- try { document.execCommand("copy"); onDone(); } catch (_) {}
- document.body.removeChild(ta);
- }
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(onDone).catch(fallback);
- } else {
- fallback();
- }
-});
-function setOtaStage(stageIdx) {
- document.querySelectorAll(".ota-stage").forEach((el, i) => {
- el.classList.toggle("done", i < stageIdx);
- el.classList.toggle("current", i === stageIdx);
- });
-}
-function resetOtaUi() {
- setOtaStage(-1);
- $("#otaProgressFill").style.width = "0%";
- $("#otaPktNum").textContent = "0";
-}
-
-/* 状态名 → 阶段索引映射(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, 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: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误",
-};
-
-$("#otaFileBtn").addEventListener("click", async () => {
- /* 优先尝试恢复待授权的 handle(FSAPI),成功则不再弹文件选择框 */
- if (ota.pendingHandle && await otaResumeWithGesture()) return;
- /* FSAPI:showOpenFilePicker 可拿到 handle 用于刷新后恢复 */
- if (window.showOpenFilePicker) {
- try {
- const [handle] = await window.showOpenFilePicker({
- types: [{ description: "固件 (.bin)", accept: { "application/octet-stream": [".bin"] } }],
- multiple: false,
- });
- const file = await handle.getFile();
- if (file.size > OTA_MAX_FW) {
- toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
- return;
- }
- ota.fileHandle = handle;
- ota.pendingHandle = null;
- ota.file = file;
- ota.bytes = new Uint8Array(await file.arrayBuffer());
- $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
- $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
- $("#otaCrc").textContent = "—";
- otaSaveFwMeta(file);
- otaIdbPutHandle(handle);
- otaLog(`[固件] ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
- toast(`固件已加载: ${file.name}`, "ok");
- return;
- } catch (e) {
- if (e && e.name === "AbortError") return; /* 用户取消,静默 */
- /* FSAPI 失败则回退到 input[type=file] */
- }
- }
- $("#otaFileInput").click();
-});
-$("#otaFileInput").addEventListener("change", async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- if (file.size > OTA_MAX_FW) {
- toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
- e.target.value = "";
- return;
- }
- const buf = await file.arrayBuffer();
- ota.file = file;
- ota.bytes = new Uint8Array(buf);
- ota.fileHandle = null; /* input 选择无 handle,刷新后只能展示文件名 */
- ota.pendingHandle = null;
- $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
- $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
- $("#otaCrc").textContent = "—";
- otaSaveFwMeta(file);
- /* input 选择无 FSAPI handle,清掉旧 handle 避免恢复到旧文件 */
- otaIdbPutHandle(null);
- otaLog(`[固件] ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
- toast(`固件已加载: ${file.name}`, "ok");
-});
-
-$("#otaStartBtn").addEventListener("click", startOta);
-$("#otaCancelBtn").addEventListener("click", cancelOta);
-
-async function startOta() {
- if (ota.running) return;
- if (!state.connected) { toast("设备未连接", "err"); return; }
- /* 电机 OTA 依赖 ESP32 固件的 /motor/ota* 上传 + GD32 本地 relay,
- * EtherCAT 链路上没有这条通路(过程数据只跑 CAN 帧)。这里必须硬拦:
- * 否则会去打设备 IP 的 HTTP,在只连了网线的场合表现为莫名超时。 */
- if (!state.tp.supportsOta) {
- toast("当前链路(" + state.tp.label + ")不支持电机 OTA —— 请切回 WiFi 链路", "err");
- return;
- }
- if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
- /* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
- * node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
- const node = state.selectedNode;
- if (node == null || node < 1) {
- toast("请先在左侧节点列表选中目标电机(node ≥ 1)", "warn");
- return;
- }
- const ch = state.ch || 0;
-
- /* ★ 每次刷写都重新从磁盘读取固件,确保用最新文件内容,不依赖内存缓存。
- * 之前只读一次存 ota.bytes,之后多次刷写复用缓存——若磁盘文件被重新编译
- * 覆盖(改代码 rebuild),前端仍发旧数据,导致刷进电机的不是最新固件。
- * 有 FSAPI handle 时从 handle 重新读(handle 总是指向同一文件路径);
- * 无 handle(input 选择)时从 ota.file 重新读 File 对象。
- * 读取失败提示用户重新选择固件,绝不带着旧/坏缓存刷写。 */
- try {
- let file = ota.file;
- if (ota.fileHandle && typeof ota.fileHandle.getFile === "function") {
- file = await ota.fileHandle.getFile();
- }
- if (file.size > OTA_MAX_FW) {
- toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
- return;
- }
- ota.file = file;
- ota.bytes = new Uint8Array(await file.arrayBuffer());
- $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
- $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
- } catch (e) {
- otaLog(`[固件] 重新读取失败:${escapeHtml(e.message || e)},请重新选择固件`);
- toast("固件读取失败,请重新选择", "err");
- return;
- }
-
- const ok = await showModal("开始电机 OTA",
- `将先上传固件 ${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);
- stopSeq("OTA 开始,命令序列已停止", true);
- ota.running = true;
- ota.node = node; /* 记录目标节点:finished 后 verifyNewApp 查版本用 */
- ota.startTime = performance.now();
- $("#otaStartBtn").disabled = true;
- $("#otaCancelBtn").disabled = false;
- resetOtaUi();
- setOtaStage(0); /* 上传固件阶段 */
-
- /* 整体超时看门狗:上传 + 进 BOOT + 传输 + 跳转 总计不应超过 OTA_TOTAL_TIMEOUT_MS。
- * 超时后自动取消,避免卡死时前端无限轮询 + ESP32 feedTask 残留。 */
- if (ota.watchdogTimer) clearTimeout(ota.watchdogTimer);
- ota.watchdogTimer = setTimeout(() => {
- if (!ota.running) return;
- otaLog(`[超时] OTA 整体超时 (${OTA_TOTAL_TIMEOUT_MS / 1000}s),自动取消…`);
- cancelOta();
- }, OTA_TOTAL_TIMEOUT_MS);
-
- /* OTA 期间暂停 motor/feedback/status 三链路轮询:
- * 三条链各占一个 HTTP socket + httpd 线程时间,叠加固件上传 + OTA poll(500ms)
- * + cancel 需要的新连接,6 个 max_open_sockets 会紧张,
- * 导致 cancel "Failed to fetch"(连接建立失败/被 lru_purge 踢掉);
- * 且电机随后将跳 BOOT,motor 状态轮询无意义。
- * OTA 完成后 otaDone→startPolling 恢复。 */
- stopDataPolling();
-
- /* ── 步骤 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 {
- /* ── 步骤 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} 次上传失败:${escapeHtml(e.message || e)}`);
- if (attempt < kUploadMaxAttempts) await sleep(300);
- continue;
- }
- otaLog(`[上传] 固件已存储 (${escapeHtml(String(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=${escapeHtml(String(upJson.sha256.slice(0, 16)))}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`);
- if (attempt < kUploadMaxAttempts) await sleep(300);
- }
- if (!uploadOk) {
- throw new Error(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`);
- }
-
- /* ── 步骤 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] 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
- }
- otaLog(`[进BOOT] 命令已发出,立即触发转发…`);
-
- /* ── 步骤 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",
- signal: AbortSignal.timeout(8000),
- });
- const rJson = await rResp.json().catch(() => ({}));
- if (!rResp.ok) throw new Error(rJson.error || `HTTP ${rResp.status}`);
- otaLog(`[转发] GD32 开始握手…`);
- startOtaPoll();
- } catch (e) {
- otaLog(`[失败] ${escapeHtml(e.message || e)}`);
- toast("OTA 启动失败:" + (e.message || e), "err");
- otaDone();
- }
-}
-
-function startOtaPoll() {
- const startTime = performance.now();
- let lastState = "";
- let lastLoggedPkt = -1;
- ota.pollTimer = setInterval(async () => {
- try {
- const resp = await fetch(getBaseUrl() + "/motor/ota/status", {
- signal: AbortSignal.timeout(2000), cache: "no-store",
- });
- const st = await resp.json();
- updateOtaUi(st, startTime);
- /* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
- if (st.state && st.state !== lastState) {
- const zh = OTA_STATE_ZH[st.state] || escapeHtml(String(st.state));
- /* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
- * 不显示 x/y 进度——
- * 握手未成时 packets_acked 是 ESP32 预填充字节数,不是电机真实 ACK,
- * 显示 "0/912" 无意义且让用户误以为在传输。transfer 起才显示进度。 */
- const showPkt = (st.state === "transfer" || st.state === "wait_app_run" ||
- st.state === "finish_ota" || st.state === "finished");
- otaLog(`[状态] ${zh}` +
- (showPkt && st.total_packets ? ` · ${Number(st.packets_acked) || 0}/${Number(st.total_packets) || 0}` : ""));
- lastState = st.state;
- } else if (st.state === "transfer" && st.total_packets > 0) {
- const acked = Number(st.packets_acked) || 0;
- /* 传输期每约 64 包补一条进度,避免只有握手日志 */
- if (acked - lastLoggedPkt >= 64 || acked >= st.total_packets) {
- otaLog(`[传输] ${acked}/${Number(st.total_packets) || 0}`);
- lastLoggedPkt = acked;
- }
- }
- if (st.state === "finished" || st.state === "error" || st.state === "idle") {
- clearInterval(ota.pollTimer);
- ota.pollTimer = null;
- if (st.state === "finished") {
- otaLog(`[完成] 传输完成 · ${Number(st.packets_acked) || 0} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
- } else if (st.state === "error") {
- const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
- otaLog(`[错误] 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${errHint}`);
- toast("电机 OTA 失败 (0x" + Number(st.error || 0).toString(16) + ")", "err");
- }
- /* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel
- * 会让 GD32 发 0x00000000(kOtaCancelId),被刚启动的电机 APP 当作
- * 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 waitMotorVersionOnTick(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 = Number(st.packets_acked) || 0;
- const total = st.total_packets || 1;
- /* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
- * 不更新进度条和包数——
- * 此时 packets_acked 是 ESP32 预填充字节数不是电机真实 ACK,
- * 显示 0/912 会让人误以为在传输。transfer 起才显示进度。 */
- const inTransfer = (st.state === "transfer" || st.state === "wait_app_run" ||
- st.state === "finish_ota" || st.state === "finished");
- if (inTransfer) {
- $("#otaPktNum").textContent = String(acked);
- $("#otaPktTotal").textContent = String(total);
- $("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
- } else {
- /* 握手阶段进度条归零,避免残留上次进度 */
- $("#otaPktNum").textContent = "0";
- $("#otaProgressFill").style.width = "0%";
- }
- const elapsed = (performance.now() - startTime) / 1000;
- if (elapsed > 0.5 && acked > 0 && inTransfer) {
- $("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
- }
- $("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
- }
-
-async function cancelOta() {
- if (!ota.running) return;
- otaLog(`[取消] 正在取消…`);
- /* 先停 OTA 进度轮询,腾出 HTTP socket 给 cancel 请求本身。
- * motor/feedback/status 三链路在 startOta 时已 stopDataPolling,
- * 这里只剩 OTA poll(500ms)可能正占用一个 socket。
- * 先停 poll 再发 cancel,避免 cancel 被挤导致 "Failed to fetch"。 */
- if (ota.pollTimer) { clearInterval(ota.pollTimer); ota.pollTimer = null; }
- /* 取消请求重试 3 次:单次失败(网络抖动/ESP32 忙)时不能静默吞掉,
- * 否则 ESP32 侧 feedTask 不会退出,motorOtaIsActive() 残留为 true,
- * 导致后续 ESP32/GD32 OTA 全被"电机 OTA 进行中"拒绝。 */
- let ok = false;
- for (let i = 1; i <= 3 && !ok; i++) {
- try {
- const r = await fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(5000) });
- ok = r.ok;
- } catch (e) {
- otaLog(`[取消] 第 ${i} 次失败:${escapeHtml(e.message || e)}`);
- }
- if (!ok && i < 3) await sleep(500);
- }
- if (!ok) otaLog(`[取消] 3 次均失败,设备端 feedTask 可能残留,建议刷新页面或等待自动超时`);
- /* 成功时不再经 otaDone 兜底重发 cancel:设备端已在取消流程中,
- * 双发 CANCEL 会再次触发 motorOtaCancel(GD32 重复 resetToIdle),
- * 徒占 SPI 且延长 cancel task 存活窗口。仅 3 次全失败时保留兜底。 */
- otaDone(!ok);
-}
-
-/* otaDone(sendCancel): 清理前端 OTA 状态。
- * sendCancel=true 时发 POST /motor/ota/cancel 兜底清理设备端残留状态。
- * sendCancel=false(正常 finished)时不发 cancel——电机 APP 已跳转启动,
- * 此时发 cancel 会让 GD32 发 0x00000000(kOtaCancelId),被电机 APP 当作
- * CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s,APP 看似卡死。
- * 调用方:
- * - startOtaPoll 正常完成 → otaDone(false)
- * - startOtaPoll error/idle → otaDone(true)
- * - cancelOta(用户主动取消)→ otaDone(true)
- * - startOta 启动失败 → otaDone(true) */
-function otaDone(sendCancel = true) {
- ota.running = false;
- $("#otaStartBtn").disabled = false;
- $("#otaCancelBtn").disabled = true;
- if (ota.watchdogTimer) { clearTimeout(ota.watchdogTimer); ota.watchdogTimer = null; }
- /* OTA 期间暂停了 motor/feedback/status 三链路轮询,完成后恢复。
- * startPolling 内部先 stopDataPolling 再重建,安全可重复调用。 */
- if (state.connected && !state.probing) {
- startPolling();
- }
- /* 仅在需要时发 cancel 兜底清理设备端 motorOtaIsActive。
- * 正常完成(finished)时 feedTask 已自行退出 + GD32 已进 kFinished,
- * 不需要 cancel;发了反而干扰刚跳转的电机 APP(见上注释)。 */
- if (sendCancel && state.connected) {
- fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(3000) })
- .catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
- }
-}
+const { ota, otaRestorePersistedFirmware } = otaSys;
/* ═══════════════════════ 通道切换(无连接按钮) ═══════════════════════ */
$("#chSelect").addEventListener("change", () => {
diff --git a/js/motor/ota.js b/js/motor/ota.js
new file mode 100644
index 0000000..6681a29
--- /dev/null
+++ b/js/motor/ota.js
@@ -0,0 +1,651 @@
+/**
+ * ota.js — 电机 OTA(ESP32 上传 + GD32 relay)与固件句柄持久化
+ * 从 main.js 机械外提;行为不变。不在此文件改 STATUS 离线层。
+ */
+"use strict";
+
+export function createOtaSys(ctx) {
+ const {
+ state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
+ sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
+ ID_GET_VERSION_REQ, parseVersionPayload, setVer,
+ stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
+ } = ctx;
+
+
+/* ═══════════════════════ 电机 OTA(relay 模式)═══════════════════════
+ * 两段式(与 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 → 触发转发 → 轮询进度 → 显示结果。
+ * ★ 顺序(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 + 上传 + 传输),超时自动取消
+const ota = {
+ running: false,
+ file: null,
+ bytes: null,
+ pollTimer: null,
+ watchdogTimer: null, /* 整体超时看门狗 */
+ startTime: 0, /* OTA 开始时间戳 */
+ fileHandle: null, /* FileSystemFileHandle(FSAPI),用于刷新后恢复固件 */
+ pendingHandle: null, /* 待重新授权的 handle(权限丢失时暂存,点上传区再授权) */
+};
+
+/* ─── 固件文件持久化(仿主页面 file-handle.js 模式)───
+ * localStorage 存文件元数据(名/大小),IndexedDB 存 FileSystemFileHandle。
+ * 刷新后:权限仍 granted → 静默恢复 bytes;否则展示文件名,点"选择固件"区重新授权。
+ * File 对象本身不可序列化,bytes 不入 localStorage;IndexedDB 仅存 handle(可结构化克隆)。 */
+const LS_OTA_FW_META = "dcu_motor_ota_fw_meta";
+const IDB_OTA_NAME = "dcu_motor_ota_fw_v1";
+const IDB_OTA_STORE = "file_handle";
+let _otaIdbDb = null;
+
+function otaIdbOpen() {
+ if (_otaIdbDb) return Promise.resolve(_otaIdbDb);
+ if (!window.indexedDB) return Promise.reject(new Error("no IDB"));
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(IDB_OTA_NAME, 1);
+ req.onupgradeneeded = () => {
+ if (!req.result.objectStoreNames.contains(IDB_OTA_STORE)) req.result.createObjectStore(IDB_OTA_STORE);
+ };
+ req.onsuccess = () => { _otaIdbDb = req.result; resolve(_otaIdbDb); };
+ req.onerror = () => { _otaIdbDb = null; reject(req.error); };
+ });
+}
+function otaIdbPutHandle(handle) {
+ return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(IDB_OTA_STORE, "readwrite");
+ if (handle) tx.objectStore(IDB_OTA_STORE).put(handle, "motor");
+ else tx.objectStore(IDB_OTA_STORE).delete("motor");
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ })).catch(() => {});
+}
+function otaIdbGetHandle() {
+ return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(IDB_OTA_STORE, "readonly");
+ const r = tx.objectStore(IDB_OTA_STORE).get("motor");
+ r.onsuccess = () => resolve(r.result || null);
+ r.onerror = () => reject(r.error);
+ })).catch(() => null);
+}
+
+function otaSaveFwMeta(file) {
+ try {
+ if (file && file.name) {
+ localStorage.setItem(LS_OTA_FW_META, JSON.stringify({ name: file.name, size: file.size || 0, lastModified: file.lastModified || 0 }));
+ } else {
+ localStorage.removeItem(LS_OTA_FW_META);
+ }
+ } catch (_) {}
+}
+function otaLoadFwMeta() {
+ try {
+ const raw = localStorage.getItem(LS_OTA_FW_META);
+ return raw ? JSON.parse(raw) : null;
+ } catch (_) { return null; }
+}
+
+/** 查询 handle 权限(不 request,刷新无手势时 request 会失败) */
+async function otaQueryPermission(handle) {
+ if (!handle || typeof handle.getFile !== "function") return "denied";
+ try {
+ if (typeof handle.queryPermission === "function") return await handle.queryPermission({ mode: "read" });
+ await handle.getFile();
+ return "granted";
+ } catch (_) { return "denied"; }
+}
+
+/** 把恢复的 File 应用到 ota 对象 + UI */
+async function otaApplyRestoredFile(handle, file, opts) {
+ opts = opts || {};
+ ota.fileHandle = handle || null;
+ ota.file = file;
+ ota.bytes = new Uint8Array(await file.arrayBuffer());
+ $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
+ $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
+ otaSaveFwMeta(file);
+ if (!opts.silent) otaLog(`[固件] ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
+}
+
+/** 无权限时展示文件名,提示点上传区重新授权 */
+function otaShowPendingReauth(handle, meta) {
+ ota.fileHandle = handle || null;
+ ota.pendingHandle = handle || null;
+ const name = (meta && meta.name) || (handle && handle.name) || "上次固件";
+ $("#otaFileName").textContent = `${name} — 点击「选择固件」重新授权恢复`;
+ if (meta && meta.size) $("#otaPktTotal").textContent = String(Math.ceil(meta.size / 48));
+ otaLog(`[固件] 上次固件「${escapeHtml(name)}」需点击「选择固件」重新授权(浏览器安全限制)`);
+}
+
+/** 页面加载后恢复上次固件(权限 granted → 静默恢复;否则提示重新授权) */
+async function otaRestorePersistedFirmware() {
+ let handle;
+ try { handle = await otaIdbGetHandle(); } catch (_) { handle = null; }
+ const meta = otaLoadFwMeta();
+ if (!handle) {
+ /* 无 handle(非 FSAPI 选择的旧文件或首次):仅展示元数据提示 */
+ if (meta && meta.name) {
+ $("#otaFileName").textContent = `${meta.name} — ${meta.size ? meta.size.toLocaleString() + " B · " : ""}请重新选择`;
+ otaLog(`[固件] 上次固件「${escapeHtml(meta.name)}」需重新选择(无 FSAPI handle)`);
+ }
+ return;
+ }
+ const perm = await otaQueryPermission(handle);
+ if (perm === "granted") {
+ try {
+ const file = await handle.getFile();
+ await otaApplyRestoredFile(handle, file, { silent: true });
+ otaLog(`[固件] 已恢复刷新前的固件: ${escapeHtml(file.name)}`);
+ } catch (e) {
+ otaShowPendingReauth(handle, meta);
+ }
+ } else {
+ otaShowPendingReauth(handle, meta);
+ }
+}
+
+/** 用户手势中:对 pending handle 重新授权并恢复(点"选择固件"时先尝试此路径) */
+async function otaResumeWithGesture() {
+ const handle = ota.pendingHandle;
+ if (!handle || typeof handle.getFile !== "function") return false;
+ try {
+ let perm = "granted";
+ if (typeof handle.queryPermission === "function") perm = await handle.queryPermission({ mode: "read" });
+ if (perm !== "granted" && typeof handle.requestPermission === "function") perm = await handle.requestPermission({ mode: "read" });
+ if (perm !== "granted") { toast("未授予读取权限,请在弹窗中允许或重新选择", "warn"); return false; }
+ const file = await handle.getFile();
+ if (file.size > OTA_MAX_FW) { toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err"); return false; }
+ await otaApplyRestoredFile(handle, file, {});
+ ota.pendingHandle = null;
+ toast(`已恢复上次固件: ${file.name}`, "ok");
+ return true;
+ } catch (e) {
+ otaLog(`[固件] 恢复失败: ${escapeHtml(e.message || e)}`);
+ return false;
+ }
+}
+
+function otaLog(html) {
+ const el = $("#otaLog");
+ const div = document.createElement("div");
+ div.innerHTML = html;
+ el.appendChild(div);
+ el.scrollTop = el.scrollHeight;
+}
+
+/* OTA 日志工具栏:清空 / 复制 */
+$("#otaLogClearBtn").addEventListener("click", function () {
+ const el = $("#otaLog");
+ el.innerHTML = '[已清空] 日志已清除
';
+});
+$("#otaLogCopyBtn").addEventListener("click", function () {
+ const el = $("#otaLog");
+ const text = el.innerText.trim();
+ if (!text) { toast("日志为空", "warn"); return; }
+ const btn = this;
+ function onDone() {
+ btn.classList.add("copied");
+ setTimeout(function () { btn.classList.remove("copied"); }, 1200);
+ toast("已复制日志", "ok");
+ }
+ function fallback() {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
+ document.body.appendChild(ta);
+ ta.select();
+ try { document.execCommand("copy"); onDone(); } catch (_) {}
+ document.body.removeChild(ta);
+ }
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(onDone).catch(fallback);
+ } else {
+ fallback();
+ }
+});
+function setOtaStage(stageIdx) {
+ document.querySelectorAll(".ota-stage").forEach((el, i) => {
+ el.classList.toggle("done", i < stageIdx);
+ el.classList.toggle("current", i === stageIdx);
+ });
+}
+function resetOtaUi() {
+ setOtaStage(-1);
+ $("#otaProgressFill").style.width = "0%";
+ $("#otaPktNum").textContent = "0";
+}
+
+/* 状态名 → 阶段索引映射(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, 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: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误",
+};
+
+$("#otaFileBtn").addEventListener("click", async () => {
+ /* 优先尝试恢复待授权的 handle(FSAPI),成功则不再弹文件选择框 */
+ if (ota.pendingHandle && await otaResumeWithGesture()) return;
+ /* FSAPI:showOpenFilePicker 可拿到 handle 用于刷新后恢复 */
+ if (window.showOpenFilePicker) {
+ try {
+ const [handle] = await window.showOpenFilePicker({
+ types: [{ description: "固件 (.bin)", accept: { "application/octet-stream": [".bin"] } }],
+ multiple: false,
+ });
+ const file = await handle.getFile();
+ if (file.size > OTA_MAX_FW) {
+ toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
+ return;
+ }
+ ota.fileHandle = handle;
+ ota.pendingHandle = null;
+ ota.file = file;
+ ota.bytes = new Uint8Array(await file.arrayBuffer());
+ $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
+ $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
+ $("#otaCrc").textContent = "—";
+ otaSaveFwMeta(file);
+ otaIdbPutHandle(handle);
+ otaLog(`[固件] ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
+ toast(`固件已加载: ${file.name}`, "ok");
+ return;
+ } catch (e) {
+ if (e && e.name === "AbortError") return; /* 用户取消,静默 */
+ /* FSAPI 失败则回退到 input[type=file] */
+ }
+ }
+ $("#otaFileInput").click();
+});
+$("#otaFileInput").addEventListener("change", async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ if (file.size > OTA_MAX_FW) {
+ toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
+ e.target.value = "";
+ return;
+ }
+ const buf = await file.arrayBuffer();
+ ota.file = file;
+ ota.bytes = new Uint8Array(buf);
+ ota.fileHandle = null; /* input 选择无 handle,刷新后只能展示文件名 */
+ ota.pendingHandle = null;
+ $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
+ $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
+ $("#otaCrc").textContent = "—";
+ otaSaveFwMeta(file);
+ /* input 选择无 FSAPI handle,清掉旧 handle 避免恢复到旧文件 */
+ otaIdbPutHandle(null);
+ otaLog(`[固件] ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
+ toast(`固件已加载: ${file.name}`, "ok");
+});
+
+$("#otaStartBtn").addEventListener("click", startOta);
+$("#otaCancelBtn").addEventListener("click", cancelOta);
+
+async function startOta() {
+ if (ota.running) return;
+ if (!state.connected) { toast("设备未连接", "err"); return; }
+ /* 电机 OTA 依赖 ESP32 固件的 /motor/ota* 上传 + GD32 本地 relay,
+ * EtherCAT 链路上没有这条通路(过程数据只跑 CAN 帧)。这里必须硬拦:
+ * 否则会去打设备 IP 的 HTTP,在只连了网线的场合表现为莫名超时。 */
+ if (!state.tp.supportsOta) {
+ toast("当前链路(" + state.tp.label + ")不支持电机 OTA —— 请切回 WiFi 链路", "err");
+ return;
+ }
+ if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
+ /* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
+ * node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
+ const node = state.selectedNode;
+ if (node == null || node < 1) {
+ toast("请先在左侧节点列表选中目标电机(node ≥ 1)", "warn");
+ return;
+ }
+ const ch = state.ch || 0;
+
+ /* ★ 每次刷写都重新从磁盘读取固件,确保用最新文件内容,不依赖内存缓存。
+ * 之前只读一次存 ota.bytes,之后多次刷写复用缓存——若磁盘文件被重新编译
+ * 覆盖(改代码 rebuild),前端仍发旧数据,导致刷进电机的不是最新固件。
+ * 有 FSAPI handle 时从 handle 重新读(handle 总是指向同一文件路径);
+ * 无 handle(input 选择)时从 ota.file 重新读 File 对象。
+ * 读取失败提示用户重新选择固件,绝不带着旧/坏缓存刷写。 */
+ try {
+ let file = ota.file;
+ if (ota.fileHandle && typeof ota.fileHandle.getFile === "function") {
+ file = await ota.fileHandle.getFile();
+ }
+ if (file.size > OTA_MAX_FW) {
+ toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
+ return;
+ }
+ ota.file = file;
+ ota.bytes = new Uint8Array(await file.arrayBuffer());
+ $("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
+ $("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
+ } catch (e) {
+ otaLog(`[固件] 重新读取失败:${escapeHtml(e.message || e)},请重新选择固件`);
+ toast("固件读取失败,请重新选择", "err");
+ return;
+ }
+
+ const ok = await showModal("开始电机 OTA",
+ `将先上传固件 ${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);
+ stopSeq("OTA 开始,命令序列已停止", true);
+ ota.running = true;
+ ota.node = node; /* 记录目标节点:finished 后 verifyNewApp 查版本用 */
+ ota.startTime = performance.now();
+ $("#otaStartBtn").disabled = true;
+ $("#otaCancelBtn").disabled = false;
+ resetOtaUi();
+ setOtaStage(0); /* 上传固件阶段 */
+
+ /* 整体超时看门狗:上传 + 进 BOOT + 传输 + 跳转 总计不应超过 OTA_TOTAL_TIMEOUT_MS。
+ * 超时后自动取消,避免卡死时前端无限轮询 + ESP32 feedTask 残留。 */
+ if (ota.watchdogTimer) clearTimeout(ota.watchdogTimer);
+ ota.watchdogTimer = setTimeout(() => {
+ if (!ota.running) return;
+ otaLog(`[超时] OTA 整体超时 (${OTA_TOTAL_TIMEOUT_MS / 1000}s),自动取消…`);
+ cancelOta();
+ }, OTA_TOTAL_TIMEOUT_MS);
+
+ /* OTA 期间暂停 motor/feedback/status 三链路轮询:
+ * 三条链各占一个 HTTP socket + httpd 线程时间,叠加固件上传 + OTA poll(500ms)
+ * + cancel 需要的新连接,6 个 max_open_sockets 会紧张,
+ * 导致 cancel "Failed to fetch"(连接建立失败/被 lru_purge 踢掉);
+ * 且电机随后将跳 BOOT,motor 状态轮询无意义。
+ * OTA 完成后 otaDone→startPolling 恢复。 */
+ stopDataPolling();
+
+ /* ── 步骤 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 {
+ /* ── 步骤 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} 次上传失败:${escapeHtml(e.message || e)}`);
+ if (attempt < kUploadMaxAttempts) await sleep(300);
+ continue;
+ }
+ otaLog(`[上传] 固件已存储 (${escapeHtml(String(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=${escapeHtml(String(upJson.sha256.slice(0, 16)))}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`);
+ if (attempt < kUploadMaxAttempts) await sleep(300);
+ }
+ if (!uploadOk) {
+ throw new Error(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`);
+ }
+
+ /* ── 步骤 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] 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
+ }
+ otaLog(`[进BOOT] 命令已发出,立即触发转发…`);
+
+ /* ── 步骤 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",
+ signal: AbortSignal.timeout(8000),
+ });
+ const rJson = await rResp.json().catch(() => ({}));
+ if (!rResp.ok) throw new Error(rJson.error || `HTTP ${rResp.status}`);
+ otaLog(`[转发] GD32 开始握手…`);
+ startOtaPoll();
+ } catch (e) {
+ otaLog(`[失败] ${escapeHtml(e.message || e)}`);
+ toast("OTA 启动失败:" + (e.message || e), "err");
+ otaDone();
+ }
+}
+
+function startOtaPoll() {
+ const startTime = performance.now();
+ let lastState = "";
+ let lastLoggedPkt = -1;
+ ota.pollTimer = setInterval(async () => {
+ try {
+ const resp = await fetch(getBaseUrl() + "/motor/ota/status", {
+ signal: AbortSignal.timeout(2000), cache: "no-store",
+ });
+ const st = await resp.json();
+ updateOtaUi(st, startTime);
+ /* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
+ if (st.state && st.state !== lastState) {
+ const zh = OTA_STATE_ZH[st.state] || escapeHtml(String(st.state));
+ /* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
+ * 不显示 x/y 进度——
+ * 握手未成时 packets_acked 是 ESP32 预填充字节数,不是电机真实 ACK,
+ * 显示 "0/912" 无意义且让用户误以为在传输。transfer 起才显示进度。 */
+ const showPkt = (st.state === "transfer" || st.state === "wait_app_run" ||
+ st.state === "finish_ota" || st.state === "finished");
+ otaLog(`[状态] ${zh}` +
+ (showPkt && st.total_packets ? ` · ${Number(st.packets_acked) || 0}/${Number(st.total_packets) || 0}` : ""));
+ lastState = st.state;
+ } else if (st.state === "transfer" && st.total_packets > 0) {
+ const acked = Number(st.packets_acked) || 0;
+ /* 传输期每约 64 包补一条进度,避免只有握手日志 */
+ if (acked - lastLoggedPkt >= 64 || acked >= st.total_packets) {
+ otaLog(`[传输] ${acked}/${Number(st.total_packets) || 0}`);
+ lastLoggedPkt = acked;
+ }
+ }
+ if (st.state === "finished" || st.state === "error" || st.state === "idle") {
+ clearInterval(ota.pollTimer);
+ ota.pollTimer = null;
+ if (st.state === "finished") {
+ otaLog(`[完成] 传输完成 · ${Number(st.packets_acked) || 0} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
+ } else if (st.state === "error") {
+ const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
+ otaLog(`[错误] 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${errHint}`);
+ toast("电机 OTA 失败 (0x" + Number(st.error || 0).toString(16) + ")", "err");
+ }
+ /* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel
+ * 会让 GD32 发 0x00000000(kOtaCancelId),被刚启动的电机 APP 当作
+ * 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 waitMotorVersionOnTick(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 = Number(st.packets_acked) || 0;
+ const total = st.total_packets || 1;
+ /* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
+ * 不更新进度条和包数——
+ * 此时 packets_acked 是 ESP32 预填充字节数不是电机真实 ACK,
+ * 显示 0/912 会让人误以为在传输。transfer 起才显示进度。 */
+ const inTransfer = (st.state === "transfer" || st.state === "wait_app_run" ||
+ st.state === "finish_ota" || st.state === "finished");
+ if (inTransfer) {
+ $("#otaPktNum").textContent = String(acked);
+ $("#otaPktTotal").textContent = String(total);
+ $("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
+ } else {
+ /* 握手阶段进度条归零,避免残留上次进度 */
+ $("#otaPktNum").textContent = "0";
+ $("#otaProgressFill").style.width = "0%";
+ }
+ const elapsed = (performance.now() - startTime) / 1000;
+ if (elapsed > 0.5 && acked > 0 && inTransfer) {
+ $("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
+ }
+ $("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
+ }
+
+async function cancelOta() {
+ if (!ota.running) return;
+ otaLog(`[取消] 正在取消…`);
+ /* 先停 OTA 进度轮询,腾出 HTTP socket 给 cancel 请求本身。
+ * motor/feedback/status 三链路在 startOta 时已 stopDataPolling,
+ * 这里只剩 OTA poll(500ms)可能正占用一个 socket。
+ * 先停 poll 再发 cancel,避免 cancel 被挤导致 "Failed to fetch"。 */
+ if (ota.pollTimer) { clearInterval(ota.pollTimer); ota.pollTimer = null; }
+ /* 取消请求重试 3 次:单次失败(网络抖动/ESP32 忙)时不能静默吞掉,
+ * 否则 ESP32 侧 feedTask 不会退出,motorOtaIsActive() 残留为 true,
+ * 导致后续 ESP32/GD32 OTA 全被"电机 OTA 进行中"拒绝。 */
+ let ok = false;
+ for (let i = 1; i <= 3 && !ok; i++) {
+ try {
+ const r = await fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(5000) });
+ ok = r.ok;
+ } catch (e) {
+ otaLog(`[取消] 第 ${i} 次失败:${escapeHtml(e.message || e)}`);
+ }
+ if (!ok && i < 3) await sleep(500);
+ }
+ if (!ok) otaLog(`[取消] 3 次均失败,设备端 feedTask 可能残留,建议刷新页面或等待自动超时`);
+ /* 成功时不再经 otaDone 兜底重发 cancel:设备端已在取消流程中,
+ * 双发 CANCEL 会再次触发 motorOtaCancel(GD32 重复 resetToIdle),
+ * 徒占 SPI 且延长 cancel task 存活窗口。仅 3 次全失败时保留兜底。 */
+ otaDone(!ok);
+}
+
+/* otaDone(sendCancel): 清理前端 OTA 状态。
+ * sendCancel=true 时发 POST /motor/ota/cancel 兜底清理设备端残留状态。
+ * sendCancel=false(正常 finished)时不发 cancel——电机 APP 已跳转启动,
+ * 此时发 cancel 会让 GD32 发 0x00000000(kOtaCancelId),被电机 APP 当作
+ * CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s,APP 看似卡死。
+ * 调用方:
+ * - startOtaPoll 正常完成 → otaDone(false)
+ * - startOtaPoll error/idle → otaDone(true)
+ * - cancelOta(用户主动取消)→ otaDone(true)
+ * - startOta 启动失败 → otaDone(true) */
+function otaDone(sendCancel = true) {
+ ota.running = false;
+ $("#otaStartBtn").disabled = false;
+ $("#otaCancelBtn").disabled = true;
+ if (ota.watchdogTimer) { clearTimeout(ota.watchdogTimer); ota.watchdogTimer = null; }
+ /* OTA 期间暂停了 motor/feedback/status 三链路轮询,完成后恢复。
+ * startPolling 内部先 stopDataPolling 再重建,安全可重复调用。 */
+ if (state.connected && !state.probing) {
+ startPolling();
+ }
+ /* 仅在需要时发 cancel 兜底清理设备端 motorOtaIsActive。
+ * 正常完成(finished)时 feedTask 已自行退出 + GD32 已进 kFinished,
+ * 不需要 cancel;发了反而干扰刚跳转的电机 APP(见上注释)。 */
+ if (sendCancel && state.connected) {
+ fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(3000) })
+ .catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
+ }
+}
+
+
+ return {
+ ota,
+ otaRestorePersistedFirmware,
+ };
+}
diff --git a/pages/motor.html b/pages/motor.html
index c91647d..acafa72 100644
--- a/pages/motor.html
+++ b/pages/motor.html
@@ -430,7 +430,7 @@
-
+