feat: 添加局域网设备发现功能和链路胶囊优化

- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
cbb
2026-10-13 00:48:34 +08:00
parent 514070b36d
commit eac3717f9a
41 changed files with 4229 additions and 934 deletions
+160 -101
View File
@@ -7,7 +7,7 @@
export function createOtaSys(ctx) {
const {
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
sendBatch, sendOneFrameWhenFree, stopSeq, stopRepeat, saveUiState,
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
} = ctx;
@@ -223,13 +223,14 @@ function resetOtaUi() {
* 后端 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,
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: "空闲", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
idle: "空闲", staging: "灌入从站暂存区…", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输",
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误",
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…",
finishing: "已刷完,正在恢复过程数据通路…", finished: "完成", error: "错误",
};
$("#otaFileBtn").addEventListener("click", async () => {
@@ -289,24 +290,51 @@ $("#otaFileInput").addEventListener("change", async (e) => {
toast(`固件已加载: ${file.name}`, "ok");
});
$("#otaStartBtn").addEventListener("click", startOta);
$("#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() {
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");
/* 每个拒绝启动的出口都往 OTA 日志区写一行原因。toast 只显示 3 秒,
* 而 ota.running 卡死时原先是完全静默的 return —— 表现就是"按钮能点、
* 日志一个字都不加",无从定位。 */
if (ota.running) {
otaLog('<span class="log-err">[未启动]</span> 上一次 OTA 状态未复位(ota.running=true),本次点击已忽略。' +
'点"取消"或刷新页面即可复位;若经常走到这里,看下面 [校验] 行的异常。');
return;
}
if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); 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;
}
@@ -337,10 +365,18 @@ async function startOta() {
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",
`将先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备并校验 SHA-256,` +
`再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` +
`由 GD32 本地完成 AES 握手与 CAN 传输。OTA 期间请勿操作电机。`);
`${verifyText},${bootText},由 GD32 本地完成 AES 握手与 CAN 停等传输。` +
`OTA 期间本链路的过程数据与轮询会暂停(其他电机也收不到运动指令),请勿断电。`);
if (!ok) return;
stopRepeat(true);
@@ -370,91 +406,88 @@ async function startOta() {
* 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("");
/* ── 步骤 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:上传 + SHA 比对,不通过重传(最多 3 次尝试 = 首发 + 重发 2 次)──
* POST /motor/ota 只收固件写 flash(不触发 SPI),ESP32 收尾从 flash 读回算 SHA 随响应返回;
* 前端比对不一致 = flash 写坏 → 重传(每次上传都全盘擦除重写);
* 连续 3 次不过才中断——此时电机仍在 APP 正常运行,重试/中断都不影响电机。 */
/* ── 步骤 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> 上传固件到 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),跳过比对继续`);
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;
}
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 ? ",重传…" : ""}`);
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(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`);
throw new Error(`进料/完整性核对连续 ${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,交握手确认)`);
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)`);
}
} 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(运动帧已被通道锁挡住)`);
}
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 开始握手…`);
/* ── 步骤 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)}`);
@@ -469,10 +502,10 @@ function startOtaPoll() {
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();
/* 进度按链路取: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) {
@@ -497,11 +530,28 @@ function startOtaPoll() {
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 运行确认,核对版本…`);
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 可能未启动)" : "";
otaLog(`<span class="log-err">[错误]</span> 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${errHint}`);
/* 两种病分开报:低字节是电机 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
@@ -509,10 +559,19 @@ function startOtaPoll() {
* 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") verifyNewApp(ota.node);
/* 只有确认过真实传输才去核对版本:否则查到的就是电机现在跑的旧版本,
* 那会让「没刷」看起来像「刷成功了」——自证判据。 */
if (st.state === "finished" && pkTotal > 0 && pkAcked >= pkTotal) verifyNewApp(ota.node);
}
} catch (e) { /* 轮询瞬时失败静默 */ }
}, OTA_POLL_MS);
@@ -599,12 +658,13 @@ async function cancelOta() {
* 导致后续 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)}`);
}
/* 走传输钩子: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 可能残留,建议刷新页面或等待自动超时`);
@@ -637,9 +697,8 @@ function otaDone(sendCancel = true) {
/* 仅在需要时发 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 恢复 */ });
if (sendCancel && state.connected && typeof state.tp.otaCancel === "function") {
state.tp.otaCancel().catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
}
}