/** * 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, stopSeq, stopRepeat, 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, staging: 0, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2, erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finishing: 5, finished: 5, error: -1, }; const OTA_STATE_ZH = { idle: "空闲", staging: "灌入从站暂存区…", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…", handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输", finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finishing: "已刷完,正在恢复过程数据通路…", 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 是 async:任何逃逸出来的异常都会把 ota.running 永久留在 true, * 之后每次点击都在入口 return —— 完全静默。这里兜最后一道:异常必须落日志, * 并且走 otaDone() 复位状态。 */ Promise.resolve().then(startOta).catch((e) => { otaLog(`[异常] OTA 流程抛出未捕获异常:` + escapeHtml(e && e.message ? e.message : String(e)) + `,状态已复位,可重新点击`); toast("OTA 异常,详见 OTA 日志", "err"); otaDone(); }); }); $("#otaCancelBtn").addEventListener("click", cancelOta); async function startOta() { /* 每个拒绝启动的出口都往 OTA 日志区写一行原因。toast 只显示 3 秒, * 而 ota.running 卡死时原先是完全静默的 return —— 表现就是"按钮能点、 * 日志一个字都不加",无从定位。 */ if (ota.running) { otaLog('[未启动] 上一次 OTA 状态未复位(ota.running=true),本次点击已忽略。' + '点"取消"或刷新页面即可复位;若经常走到这里,看下面 [校验] 行的异常。'); return; } if (!state.connected) { otaLog('[未启动] 设备未连接(state.connected=false),本次点击已忽略。'); toast("设备未连接", "err"); return; } /* 电机 OTA 的进料通路按链路不同,但收尾都是同一个 GD32 relay 引擎: * WiFi → POST ESP32 /motor/ota 上传 + SPI 0xD0/0xD1 喂数 * EtherCAT → CoE 0x2511 邮箱灌进 GD32 暂存区(只插网线也能刷) * 缺钩子的链路在这里硬拦,不要去打一个必然超时的端点。 */ if (!state.tp.supportsOta || typeof state.tp.otaUploadAttempt !== "function") { otaLog('[未启动] 当前链路 ' + escapeHtml(String(state.tp.label || state.tpId)) + ' 没有电机 OTA 通路。'); toast("当前链路(" + state.tp.label + ")没有电机 OTA 通路", "err"); return; } if (!ota.file) { otaLog('[未启动] 未选择固件文件。'); toast("请先选择固件 (.bin)", "warn"); return; } /* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。 * node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */ const node = state.selectedNode; if (node == null || node < 1) { otaLog('[未启动] 未选中目标节点(当前 selectedNode=' + escapeHtml(String(node)) + ')。'); 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; } /* 弹窗文案按链路口径如实写:WiFi 是「ESP32 回读 flash 算 SHA-256 比对」, * EtherCAT 是「从站在电机进 BOOT 之前核整镜像 CRC32」。 * 把另一条的校验说法套过来就是假话,用户会按假话决定要不要断电。 */ const verifyText = state.tp.otaIntegrity === "sha256-esp32-flash" ? `先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到 ESP32,设备从 flash 读回算 SHA-256 与本地比对` : `先把固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 经 EtherCAT 邮箱灌进 GD32 暂存区,逐块 CRC32 + 整镜像 CRC32 由从站核对`; const bootText = state.tp.otaHostSendsBootFrame ? `再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader` : `核对通过后由 GD32 在已独占的通道上发 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader`; const ok = await showModal("开始电机 OTA", `${verifyText},${bootText},由 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(只算一次,重传不变)── * ★ 必须在 try 内。这句原先落在 try/catch 之外,而 ota.running 已在上面置 true: * 一旦抛异常(最常见是非安全上下文里 crypto.subtle 为 undefined),本函数以 * 未处理的 rejection 结束,otaDone() 不跑 → ota.running 永久停在 true, * 之后每次点击都在入口静默 return,界面表现就是"点了没反应"。 */ let fwShaHex = ""; try { if (!window.crypto || !window.crypto.subtle || !window.crypto.subtle.digest) { throw new Error("本浏览器上下文没有 WebCrypto(crypto.subtle 不可用):当前源是 " + location.origin + ",非安全上下文。请用 http://127.0.0.1:2625/tabs.html 打开本页" + "(桌面 APP 的入口),或改用 https 访问设备。"); } const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes); fwShaHex = Array.from(new Uint8Array(fwSha256)) .map(b => b.toString(16).padStart(2, "0")).join(""); } catch (e) { otaLog(`[校验] 本地固件 SHA-256 计算失败:${escapeHtml(e.message || e)},OTA 未启动`); toast("固件校验计算失败,见 OTA 日志", "err"); otaDone(); return; } otaLog(`[校验] 本地固件 SHA-256: ${fwShaHex}`); try { /* ── 步骤 2:进料 + 完整性核对(按链路口径,最多 3 次尝试)── * 通路交给传输钩子,本文件不再假设「一定能打到设备 IP」: * WiFi POST /motor/ota → ESP32 写 flash 后读回算 SHA-256,浏览器比对; * EtherCAT CoE 0x2511 灌进 GD32 暂存区,整镜像 CRC32 由**从站**在电机进 BOOT * 之前核对(这一步只报「主站是否完整持有镜像」,不冒充比过 SHA)。 * 两种失败都发生在电机仍跑 APP 的阶段:重试与中断都不影响电机。 */ const kUploadMaxAttempts = 3; let uploadOk = false; for (let attempt = 1; attempt <= kUploadMaxAttempts && ota.running; attempt++) { otaLog(`[上传] ${escapeHtml(String(state.tp.label || state.tpId))} 通路进料 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`); const up = await state.tp.otaUploadAttempt(ota.bytes, fwShaHex, { ch, node }); if (up && up.ok) { otaLog(`[上传] ${escapeHtml(String(up.note || "固件已就位"))}` + (up.size ? ` (${Number(up.size).toLocaleString()} B)` : "")); uploadOk = true; break; } otaLog(`[上传] 第 ${attempt} 次未通过:` + escapeHtml(String((up && up.note) || "未知原因")) + (attempt < kUploadMaxAttempts ? ",重试…" : "")); if (attempt < kUploadMaxAttempts) await sleep(300); } if (!uploadOk) { throw new Error(`进料/完整性核对连续 ${kUploadMaxAttempts} 次未通过,中断(电机未进 BOOT)`); } setOtaStage(1); /* 进入 BOOT 阶段 */ if (state.tp.otaHostSendsBootFrame) { /* ── 步骤 3(WiFi 链路的既有顺序):上位机发 0x000N0000,再触发中继 ── * APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。 * 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK, * 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败—— * 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。 * 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */ const bootId = canId(node, 0x0000); /* 0x000N0000 */ otaLog(`[进BOOT] 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`); 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)`); } } else { /* EtherCAT:进 BOOT 帧由 GD32 自己在**已加锁**的通道上发。这条链路的周期运动帧 * 与它挤同一批 CAN 邮箱;先解锁让上位机发帧,等于把窗口留给运动帧(单发模式 * 不重传,固件包一旦被 abort 就是白等 100ms 重发,握手期更会被打成 0x05)。 */ otaLog(`[进BOOT] 交 GD32 在已独占的 CH${ch} 上发 CANFD_BOOT 0x000${nodeHex(node)}0000(运动帧已被通道锁挡住)`); } /* ── 步骤 4:触发转发/灌写(纯启动扳机,不带镜像内容)── * 完整性核对已在上一步与从站启动前闭环,这里只下发起止指令。 * 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。 */ await state.tp.otaStartRelay(ch, node); otaLog(`[转发] 已触发:从站核对→锁通道→进 BOOT→起握手…`); 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 { /* 进度按链路取:WiFi = GET /motor/ota/status(ESP32 缓存 GD32 的 0xD2 推送); * EtherCAT = native 发布的会话读数(不碰总线),状态已由传输层翻译成 * 同一套字符串,本文件不需要知道料是怎么进去的。 */ const st = await state.tp.otaStatus(); 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; const pkTotal = Number(st.total_packets) || 0; const pkAcked = Number(st.packets_acked) || 0; /* finished 必须有真实传输撑着:包数 > 0 且全部 ACK。 * 只看 state 会放行「一帧都没上线却回了 finished」这类假成功—— * 2026-10-11 实测就是这样:从站 OOM 拒收,前端把 HTTP 信封的 ok 当成刷写结果, * 0 包也报「OTA 成功」,因为查版本必然答得上(电机根本没被动过)。 */ if (st.state === "finished" && pkTotal > 0 && pkAcked >= pkTotal) { otaLog(`[完成] 传输完成 · ${pkAcked}/${pkTotal} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`); } else if (st.state === "finished") { otaLog(`[可疑] 回执 finished 但没有真实传输(acked=${pkAcked}/total=${pkTotal}),不判成功` + ` · 看 [状态] 行与 native 读数:料没进去或启动被挡` + (st.note ? ` · ${escapeHtml(String(st.note))}` : "")); toast("OTA 未发生真实传输,不能算成功", "err"); } else if (st.state === "error") { const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : ""; /* 两种病分开报:低字节是电机 BOOT 回来的协议码(查电机与 CAN); * session_error 高字节是 DCU 本端把料挡了(0x8x,查链路与参数)。 * 混成一句「OTA 失败 0xXX」历史上会把人支去查一台没接的电机。 */ const sess = Number(st.session_error) || 0; const sessHint = sess ? " · 本端会话码 0x" + sess.toString(16).toUpperCase() + "(料被 DCU 挡下,不是电机报错)" : ""; const note = st.note ? " · " + escapeHtml(String(st.note)) : ""; otaLog(`[错误] 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${sessHint}${errHint}${note}`); 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"); if (st.state === "idle") { /* 会话回到空闲却没给出 finished/error 结论:这条出口原先是静默的, * 表现就是"进度条停住、日志不再动"。至少把「没有结论」这件事说出来, * 别让人去猜是刷完了还是根本没开始。 */ const idleNote = st.note ? " · " + escapeHtml(String(st.note)) : ""; otaLog("[已停] 会话回到空闲且未给出结果(多为启动指令未生效或已被清理),可重新点开始" + idleNote); } /* ★ finished = GD32 已收到电机新 APP 上电自主发的 [0A AC] 运行确认 * (kWaitAppRun → kFinished,无确认 3s 超时报 0x25)——APP 已确认在跑。 * verifyNewApp 只做最后一步:查版本号核对并回填 UI。 */ /* 只有确认过真实传输才去核对版本:否则查到的就是电机现在跑的旧版本, * 那会让「没刷」看起来像「刷成功了」——自证判据。 */ if (st.state === "finished" && pkTotal > 0 && pkAcked >= pkTotal) 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++) { /* 走传输钩子:WiFi 是 POST /motor/ota/cancel,EtherCAT 是请 native worker 下一拍 * 写 0x2510:01=3(从站发 CAN 取消帧并释放通道锁)。单次失败不能静默吞掉, * 否则设备端残留(ESP32 feedTask / GD32 会话与通道锁),后续 OTA 全被拒。 */ let r = null; try { r = await state.tp.otaCancel(); } catch (e) { r = { ok: false, note: e.message || String(e) }; } ok = !!(r && r.ok); if (!ok) otaLog(`[取消] 第 ${i} 次失败:${escapeHtml(String((r && r.note) || "未确认"))}`); 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 && typeof state.tp.otaCancel === "function") { state.tp.otaCancel().catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ }); } } return { ota, otaRestorePersistedFirmware, }; }