Files
DCU_DesktopController/js/motor/ota.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

711 lines
38 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, 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(`<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, 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(`<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 是 async:任何逃逸出来的异常都会把 ota.running 永久留在 true,
* 之后每次点击都在入口 return —— 完全静默。这里兜最后一道:异常必须落日志,
* 并且走 otaDone() 复位状态。 */
Promise.resolve().then(startOta).catch((e) => {
otaLog(`<span class="log-err">[异常]</span> 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('<span class="log-err">[未启动]</span> 上一次 OTA 状态未复位(ota.running=true),本次点击已忽略。' +
'点"取消"或刷新页面即可复位;若经常走到这里,看下面 [校验] 行的异常。');
return;
}
if (!state.connected) {
otaLog('<span class="log-err">[未启动]</span> 设备未连接(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('<span class="log-err">[未启动]</span> 当前链路 ' + escapeHtml(String(state.tp.label || state.tpId)) +
' 没有电机 OTA 通路。');
toast("当前链路(" + state.tp.label + ")没有电机 OTA 通路", "err");
return;
}
if (!ota.file) {
otaLog('<span class="log-err">[未启动]</span> 未选择固件文件。');
toast("请先选择固件 (.bin)", "warn"); return;
}
/* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
* node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
const node = state.selectedNode;
if (node == null || node < 1) {
otaLog('<span class="log-err">[未启动]</span> 未选中目标节点(当前 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(`<span class="log-err">[固件]</span> 重新读取失败:${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(`<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(只算一次,重传不变)──
* ★ 必须在 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(`<span class="log-err">[校验]</span> 本地固件 SHA-256 计算失败:${escapeHtml(e.message || e)},OTA 未启动`);
toast("固件校验计算失败,见 OTA 日志", "err");
otaDone();
return;
}
otaLog(`<span class="log-info">[校验]</span> 本地固件 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(`<span class="log-info">[上传]</span> ${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(`<span class="log-ok">[上传]</span> ${escapeHtml(String(up.note || "固件已就位"))}` +
(up.size ? ` (${Number(up.size).toLocaleString()} B)` : ""));
uploadOk = true;
break;
}
otaLog(`<span class="log-err">[上传]</span> 第 ${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(`<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)`);
}
} else {
/* EtherCAT:进 BOOT 帧由 GD32 自己在**已加锁**的通道上发。这条链路的周期运动帧
* 与它挤同一批 CAN 邮箱;先解锁让上位机发帧,等于把窗口留给运动帧(单发模式
* 不重传,固件包一旦被 abort 就是白等 100ms 重发,握手期更会被打成 0x05)。 */
otaLog(`<span class="log-info">[进BOOT]</span> 交 GD32 在已独占的 CH${ch} 上发 CANFD_BOOT 0x000${nodeHex(node)}0000(运动帧已被通道锁挡住)`);
}
/* ── 步骤 4:触发转发/灌写(纯启动扳机,不带镜像内容)──
* 完整性核对已在上一步与从站启动前闭环,这里只下发起止指令。
* 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。 */
await state.tp.otaStartRelay(ch, node);
otaLog(`<span class="log-ok">[转发]</span> 已触发:从站核对→锁通道→进 BOOT→起握手…`);
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 {
/* 进度按链路取: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(`<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;
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(`<span class="log-ok">[完成]</span> 传输完成 · ${pkAcked}/${pkTotal} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
} else if (st.state === "finished") {
otaLog(`<span class="log-err">[可疑]</span> 回执 finished 但没有真实传输(acked=${pkAcked}/total=${pkTotal}),不判成功` +
`<span class="log-info"> · </span>看 [状态] 行与 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(`<span class="log-err">[错误]</span> 代码 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("<span class='log-err'>[已停]</span> 会话回到空闲且未给出结果(多为启动指令未生效或已被清理),可重新点开始" + 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(`<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++) {
/* 走传输钩子: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(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(String((r && r.note) || "未确认"))}`);
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 && typeof state.tp.otaCancel === "function") {
state.tp.otaCancel().catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
}
}
return {
ota,
otaRestorePersistedFirmware,
};
}