Files
DCU_DesktopController/js/motor/ota.js
T
cbb 9925009a92 refactor(motor): 电机 OTA 外提 ota.js(P1 重做第 3 步)
createOtaSys 注入 \$ 与轮询/版本依赖;STATUS offlineOverlay 契约保留。
连接/轮询/STATUS 渲染仍留在 main.js,本轮不再动连接状态机。
UI 验收:绿灯 + 节点数据 + 遮罩 display:none + 控制台无报错。
2026-09-21 03:13:04 +08:00

652 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(`<span class="log-info">[固件]</span> ${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(`<span class="log-info">[固件]</span> 上次固件「${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(`<span class="log-info">[固件]</span> 上次固件「${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(`<span class="log-ok">[固件]</span> 已恢复刷新前的固件: ${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(`<span class="log-err">[固件]</span> 恢复失败: ${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 = '<div class="log-info">[已清空] 日志已清除</div>';
});
$("#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(`<span class="log-info">[固件]</span> ${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(`<span class="log-info">[固件]</span> ${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(`<span class="log-err">[固件]</span> 重新读取失败:${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(`<span class="log-err">[超时]</span> 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(`<span class="log-info">[校验]</span> 本地固件 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(`<span class="log-info">[上传]</span> 上传固件到 ESP32 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`);
let upJson = {};
try {
const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: ota.bytes,
signal: AbortSignal.timeout(15000),
});
upJson = await upResp.json().catch(() => ({}));
if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`);
} catch (e) {
otaLog(`<span class="log-err">[上传]</span> 第 ${attempt} 次上传失败:${escapeHtml(e.message || e)}`);
if (attempt < kUploadMaxAttempts) await sleep(300);
continue;
}
otaLog(`<span class="log-ok">[上传]</span> 固件已存储 (${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(`<span class="log-err">[校验]</span> 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`);
uploadOk = true;
break;
}
if (upJson.sha256.toLowerCase() === fwShaHex) {
otaLog(`<span class="log-ok">[校验]</span> flash SHA 比对一致,允许进 BOOT`);
uploadOk = true;
break;
}
otaLog(`<span class="log-err">[校验]</span> 第 ${attempt} 次 flash SHA 不一致(device=${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(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
}
} catch (e) {
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
}
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出,立即触发转发…`);
/* ── 步骤 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(`<span class="log-ok">[转发]</span> GD32 开始握手…`);
startOtaPoll();
} catch (e) {
otaLog(`<span class="log-err">[失败]</span> ${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(`<span class="log-info">[状态]</span> ${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(`<span class="log-info">[传输]</span> ${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(`<span class="log-ok">[完成]</span> 传输完成 · ${Number(st.packets_acked) || 0} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
} else if (st.state === "error") {
const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
otaLog(`<span class="log-err">[错误]</span> 代码 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(`<span class="log-info">[验证]</span> 查询新 APP 版本号 (${hexId(ID_GET_VERSION_REQ(node))})…`);
await sleep(200); /* APP 已发过 0AAC,CAN 已就绪,留小间隔避开启动期杂帧 */
const kVerifyAttempts = 2;
for (let attempt = 1; attempt <= kVerifyAttempts; attempt++) {
try {
const before = await fetchMotorVersionNode(node);
const prevSeq = before ? before.seq : null;
const res = await sendOneFrameWhenFree(ID_GET_VERSION_REQ(node), 24, bytesToHexStr(new Uint8Array(24)));
if (res.ok) {
const v = await 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(`<span class="log-ok">[成功]</span> OTA 成功 · 新 APP v${parsed.soft} 运行中(0AAC 确认 + 版本应答)`);
toast(`电机 OTA 成功 · APP v${parsed.soft}`, "ok");
return;
}
}
}
} catch (e) { /* 单轮异常按未应答处理,进入重试 */ }
if (attempt < kVerifyAttempts) {
otaLog(`<span class="log-info">[验证]</span> 第 ${attempt} 次无应答,重试…`);
}
}
otaLog(`<span class="log-err">[提醒]</span> APP 已发 0AAC 确认运行,但版本查询无应答,请手动点「查版本」核对`);
toast("OTA 完成(版本查询无应答,请手动核对)", "warn");
}
function updateOtaUi(st, startTime) {
const stage = OTA_STATE_STAGE[st.state] ?? -1;
setOtaStage(stage);
const acked = 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(`<span class="log-err">[取消]</span> 正在取消…`);
/* 先停 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(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(e.message || e)}`);
}
if (!ok && i < 3) await sleep(500);
}
if (!ok) otaLog(`<span class="log-err">[取消]</span> 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,
};
}