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 @@ - +