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:
+33
-12
@@ -41,7 +41,7 @@ import { escapeHtml, flashBusyNow } from "../common/utils.js";
|
||||
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
|
||||
import {
|
||||
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
|
||||
} from "./transport.js?v=20260923i";
|
||||
} from "./transport.js?v=20261011b";
|
||||
import { deviceLink } from "../common/device-link.js";
|
||||
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
|
||||
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
} from "./refresh-rate.js";
|
||||
import { createScopeSys } from "./scope.js?v=20260924f";
|
||||
import { createSeqSys } from "./seq.js?v=20260924f";
|
||||
import { createOtaSys } from "./ota.js?v=20260924g";
|
||||
import { createOtaSys } from "./ota.js?v=20261011c";
|
||||
|
||||
|
||||
/* ─── 控制模式定义 ─── */
|
||||
@@ -780,7 +780,9 @@ function bytesToHexStr(bytes) {
|
||||
const GD32_ERR_DESC = {
|
||||
0xE0: "payload 过短", 0xE1: "通道号非法",
|
||||
0xE2: "BUS_OFF(总线离线)", 0xE3: "TEC 过高",
|
||||
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xEE: "SPI 帧解析失败",
|
||||
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xE6: "只听不发(该通道收发器已 Silent,接收仍正常)",
|
||||
0xE7: "该通道正被电机 OTA 独占(OTA 结束自动恢复;不是总线故障,重试也没用)",
|
||||
0xEE: "SPI 帧解析失败",
|
||||
0xFF: "无有效 SPI 响应(陈旧/空帧,非总线故障)",
|
||||
};
|
||||
/* -3 仅表示“GD32 未回 ACK”;是否真为总线故障看 gd32_err:0xE2/0xE3 才是总线,0xFF 是陈旧 SPI 帧 */
|
||||
@@ -829,6 +831,14 @@ async function sendBatch(frames, opts) {
|
||||
await sleep(backoff);
|
||||
return sendBatch(frames, Object.assign({}, opts, { retried: true }));
|
||||
}
|
||||
if (gerr === 0xE6) {
|
||||
/* 只听不发(收发器 Silent):确定性拒发,不是总线故障。
|
||||
* 不冷却也不重试——冷却治不了,要人把静默切回去;只停周期与序列。 */
|
||||
stopRepeat(true);
|
||||
stopSeq("该通道处于只听不发,命令序列已停止", true);
|
||||
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
|
||||
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
|
||||
}
|
||||
if (gerr === 0xE2 || gerr === 0xE3) {
|
||||
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
|
||||
state.busCooldownUntil = nowMs() + 3000;
|
||||
@@ -906,6 +916,13 @@ async function sendBurst(actions, opts) {
|
||||
await sleep(backoff);
|
||||
return sendBurst(actions, Object.assign({}, opts, { retried: true }));
|
||||
}
|
||||
if (gerr === 0xE6) {
|
||||
/* 只听不发(收发器 Silent):同 sendBatch,确定性拒发,不冷却不重试 */
|
||||
stopRepeat(true);
|
||||
stopSeq("该通道处于只听不发,命令序列已停止", true);
|
||||
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
|
||||
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
|
||||
}
|
||||
if (gerr === 0xE2 || gerr === 0xE3) {
|
||||
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
|
||||
state.busCooldownUntil = nowMs() + 3000;
|
||||
@@ -1117,16 +1134,16 @@ function applyTransportUi() {
|
||||
if (show) ad.value = tp.getAdapter();
|
||||
}
|
||||
|
||||
/* OTA 只有 WiFi 链路有(ESP32 固件上传 + GD32 本地 relay)。
|
||||
* 不支持时禁用页签而不是隐藏:隐藏会让人以为功能没了,
|
||||
* 禁用 + title 能说清"换回 WiFi 链路即可用"。 */
|
||||
/* 电机 OTA 由传输层给整套钩子(WiFi = ESP32 上传 + SPI 中继;EtherCAT = CoE 邮箱
|
||||
* 灌进 GD32 暂存区)。哪条链路缺钩子就禁用页签而不是隐藏:隐藏会让人以为功能没了,
|
||||
* 禁用 + title 能说清缺在哪。 */
|
||||
const otaBtn = document.querySelector('.tab-btn[data-tab="ota"]');
|
||||
if (otaBtn) {
|
||||
const off = !tp.supportsOta;
|
||||
const off = !tp.supportsOta || typeof tp.otaUploadAttempt !== "function";
|
||||
otaBtn.disabled = off;
|
||||
otaBtn.classList.toggle("tab-disabled", off);
|
||||
otaBtn.title = off
|
||||
? "电机 OTA 仅 WiFi 链路支持(需 ESP32 固件上传 + GD32 relay)——切回 WiFi 链路后可用"
|
||||
? "当前链路没有电机 OTA 通路(需要固件进料 + GD32 relay 两套钩子)"
|
||||
: "";
|
||||
/* 当前正停在 OTA 页而链路不支持 → 退回配置页,避免面对一个点不动的界面 */
|
||||
if (off && otaBtn.classList.contains("active")) {
|
||||
@@ -2282,9 +2299,11 @@ function busStateText(bs) {
|
||||
if (bs === 0) return "ACTIVE";
|
||||
const parts = [];
|
||||
if (bs & 1) parts.push("INIT");
|
||||
if (bs & 2) parts.push("SLEEP");
|
||||
if (bs & 2) parts.push("控制器SLEEP");
|
||||
if (bs & 4) parts.push("ERR_PASSIVE");
|
||||
if (bs & 8) parts.push("BUS_OFF");
|
||||
if (bs & 16) parts.push("ERR_WARN");
|
||||
if (bs & 32) parts.push("只听不发");
|
||||
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
|
||||
}
|
||||
|
||||
@@ -2344,9 +2363,11 @@ function onFeedback(data) {
|
||||
busEl.textContent = bsText;
|
||||
const isBusOff = valid && ch.bus_state != null && (ch.bus_state & 8);
|
||||
const isPassive = valid && ch.bus_state != null && (ch.bus_state & 4);
|
||||
busEl.style.color = isBusOff ? "var(--danger)" : isPassive ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
|
||||
const isSilent = valid && ch.bus_state != null && (ch.bus_state & 32);
|
||||
busEl.style.color = isBusOff ? "var(--danger)" : (isPassive || isSilent) ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
|
||||
const sbLed = $("#sbLed");
|
||||
sbLed.className = "led " + (isBusOff ? "led-err" : (valid ? "led-on" : ""));
|
||||
/* 静默=能收不能发,灯不能继续给"全绿":与右侧文字同源判定 */
|
||||
sbLed.className = "led " + (isBusOff ? "led-err" : isSilent ? "led-warn" : (valid ? "led-on" : ""));
|
||||
}
|
||||
|
||||
/* ═══════════════════════ 控制模式联动 ═══════════════════════ */
|
||||
@@ -3069,7 +3090,7 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
* 实现见 js/motor/ota.js(P1 重做:机械外提)。 */
|
||||
const otaSys = createOtaSys({
|
||||
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
|
||||
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
|
||||
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, stopRepeat, saveUiState,
|
||||
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
|
||||
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
|
||||
});
|
||||
|
||||
+160
-101
@@ -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 恢复 */ });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+146
-13
@@ -55,6 +55,31 @@ import {
|
||||
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
|
||||
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
|
||||
const MOT_SLOTS_PER_PDO = 9;
|
||||
/** GD32 `MotorOtaRelay::State` 的数字状态 → WiFi 链路那套字符串(ota.js 的阶段条与
|
||||
* 日志只认字符串,两条链路必须翻成同一个口径,否则 UI 要为两种传输各写一遍判据)。
|
||||
* 序号改了要同步 DCU_APP sys/motor_ota_relay.h 的枚举 —— 那边是唯一真源。 */
|
||||
const RELAY_STATE_NAMES = [
|
||||
"idle", "handshake_req", "handshake_nonce", "handshake_confirm", "erase",
|
||||
"transfer", "finish_ota", "finished", "error", "pre_cancel", "wait_app_run",
|
||||
];
|
||||
|
||||
/** 字节 → 小写 hex(native 的 hex_decode 两种大小写都吃,这里与 WiFi 上传的字节流口径无关)。 */
|
||||
function bytesToHexLower(u8) {
|
||||
let s = "";
|
||||
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 电机 OTA(EtherCAT 灌写)正在占用 exclusive 总线 —— 外壳 Owner 与本页的
|
||||
* motor/start 自愈都读同一个标志,判据只在这一处。
|
||||
* 写成 typeof 守卫是因为 scripts/test_motor_transport.mjs 在 Node 里直接 import
|
||||
* 本模块,那里没有 window,裸读会抛 ReferenceError 把整轮契约自测打断。 */
|
||||
function motorOtaEcatBusy() {
|
||||
return (typeof window !== "undefined") && !!window.motorOtaEcatBusy;
|
||||
}
|
||||
function setMotorOtaEcatBusy(v) {
|
||||
if (typeof window !== "undefined") window.motorOtaEcatBusy = !!v;
|
||||
}
|
||||
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
|
||||
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
|
||||
const MOTOR_SLOT_COUNT = 27;
|
||||
@@ -367,6 +392,13 @@ export function createEcatTransport(ctx) {
|
||||
|
||||
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
||||
async function ensureStarted(reqPeriodMs) {
|
||||
/* 电机 OTA 灌写中:native worker 正独占总线(g_bus_lock)并停着 PDO 循环。
|
||||
* 这时候再打 motor/start 只会在锁上排队,排到时把刚恢复的循环又停一遍,
|
||||
* 表现是刷写结束瞬间链路「假掉线」。判据只在这一处挡,外壳 Owner 那道门
|
||||
* (device-link 的 bootstrapEcatBus)读同一个 window.motorOtaEcatBusy。 */
|
||||
if (motorOtaEcatBusy() && !reqPeriodMs) {
|
||||
return { ok: true, period_ms: periodMs, spin: spinOn, motor_ota_busy: true };
|
||||
}
|
||||
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
|
||||
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
|
||||
if (!reqPeriodMs) {
|
||||
@@ -394,20 +426,19 @@ export function createEcatTransport(ctx) {
|
||||
localStorage.setItem(LS_PERIOD_MIG, "1");
|
||||
}
|
||||
const v = parseFloat(localStorage.getItem(LS_PERIOD));
|
||||
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
|
||||
if (v > 0 && v !== 1) body.period_ms = v;
|
||||
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
|
||||
if (v > 0 && v >= 0.125 && v <= 4) body.period_ms = v;
|
||||
else body.period_ms = 0.25;
|
||||
} catch (_) { body.period_ms = 0.25; }
|
||||
}
|
||||
/* EtherCAT 控制路径必须 DC SYNC0=250µs:
|
||||
* free-run 或 DC=1ms 时,GD32 processOutput 消费输出 PDO 会拖到 ~1s;
|
||||
* DC=250µs 时 motor/send → CAN 出口约 2ms。 */
|
||||
/* PDO 周期与 DC SYNC0 同值:周期可随时热改(0.125–4ms),
|
||||
* 不一致时 GD32 processOutput 消费会拖慢,所以启动时强制对齐。 */
|
||||
{
|
||||
let per = Number(body.period_ms) || 0.25;
|
||||
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
|
||||
if (per < 0.125) per = 0.25;
|
||||
if (per > 4) per = 4;
|
||||
body.period_ms = per;
|
||||
body.dc_cycle_us = Math.round(per * 1000);
|
||||
if (body.dc_cycle_us < 250) body.dc_cycle_us = 250;
|
||||
body.dc_shift_us = 0;
|
||||
}
|
||||
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
||||
@@ -571,8 +602,15 @@ export function createEcatTransport(ctx) {
|
||||
return {
|
||||
id: "ecat",
|
||||
label: "EtherCAT",
|
||||
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
|
||||
supportsOta: false,
|
||||
/** 电机 OTA:走 CoE 邮箱把固件灌进 GD32 暂存区,中继引擎与 WiFi 链路同一个
|
||||
* (见下 ota* 四个钩子与 DCU_APP comm/ecat_motor_ota.cpp)。 */
|
||||
supportsOta: true,
|
||||
/** 进 BOOT 帧(0x000N0000)由 GD32 在**已加锁**的通道上自己发,上位机不要再发一遍:
|
||||
* 这条链路的运动帧和它挤同一批 CAN 邮箱,先解锁让上位机发帧等于把窗口留给运动帧。 */
|
||||
otaHostSendsBootFrame: false,
|
||||
/** 完整性口径(如实告诉 UI,不冒充 SHA-256 那条):
|
||||
* crc32-verify-in-slave = 分块 CRC32 + 整镜像 CRC32 都在从站核,核过才让电机擦除。 */
|
||||
otaIntegrity: "crc32-verify-in-slave",
|
||||
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
|
||||
supportsPeriodSync: false,
|
||||
|
||||
@@ -740,7 +778,7 @@ export function createEcatTransport(ctx) {
|
||||
err: linkOk ? b.drop : null,
|
||||
tec: null, // 过程数据里没有
|
||||
rec: null, // 过程数据里没有
|
||||
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : 0) : null,
|
||||
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : (b.tx_result === 0xE6 ? 32 : 0)) : null,
|
||||
});
|
||||
}
|
||||
return { channels };
|
||||
@@ -891,14 +929,14 @@ export function createEcatTransport(ctx) {
|
||||
},
|
||||
|
||||
/**
|
||||
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
|
||||
* 热换 PDO 周期(毫秒)。合法区间 0.125–4ms(与 UI 档位一致)。
|
||||
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
|
||||
*/
|
||||
async setPdoPeriod(ms) {
|
||||
let v = Number(ms);
|
||||
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
|
||||
if (v > 0.25) v = 0.25; /* 钉死 250µs,杜绝 0.5/1ms */
|
||||
if (v < 0.0625) v = 0.25;
|
||||
if (v < 0.125) v = 0.125;
|
||||
if (v > 4) v = 4;
|
||||
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
|
||||
if (typeof r.period_ms === "number") periodMs = r.period_ms;
|
||||
else periodMs = v;
|
||||
@@ -907,6 +945,101 @@ export function createEcatTransport(ctx) {
|
||||
return periodMs;
|
||||
},
|
||||
|
||||
/* ── 电机 OTA(EtherCAT 邮箱进料)────────────────────────────────
|
||||
* 通路:这里 → /ecat/api/motor/ota(或桌面壳 pywebview 桥)→ ecat_native 的
|
||||
* worker → CoE SDO 0x2511 数据窗 / 0x2510 会话 → GD32 48KB SRAM 暂存
|
||||
* → **从站先核整镜像 CRC32** → 通道锁 + GD32 自己发 0x000N0000 进 BOOT
|
||||
* → 既有 MotorOtaRelay 停等(AES、单发、重发、0AAC 运行确认一个都没换)。
|
||||
* 换的只是「明文怎么进 GD32」:WiFi 那条是 ESP32 经 SPI 0xD1 喂,
|
||||
* 这条是主站经邮箱喂,所以纯网线(ESP32 HTTP 不可达)也能刷电机。
|
||||
*
|
||||
* 刷写期间 native worker 独占总线并停掉 PDO 循环 ⇒ 各通道运动帧一起暂停,
|
||||
* 与 WiFi 模式「OTA 期间请勿操作电机」等效。进度读的是 worker 发布的 volatile
|
||||
* 全局(op=status 不碰总线),所以轮询频率再高也不会干扰灌写。
|
||||
*/
|
||||
|
||||
/** 一次「上传尝试」:把整份镜像推到主站缓冲。
|
||||
* WiFi 那条在这步做 ESP32 flash 回读 SHA-256 比对;这条没有对端可回读,
|
||||
* shaHex 参数用不上(刻意不假装比过),真校验在从站的启动前一步。
|
||||
* @returns {Promise<{ok:boolean, note:string}>} */
|
||||
async otaUploadAttempt(bytes, shaHex, opts) {
|
||||
const ch = (opts && opts.ch) | 0;
|
||||
const node = (opts && opts.node) | 0;
|
||||
const size = bytes.length;
|
||||
unwrap(await api("motor/ota", { op: "open", size, ch, node }, 8000), "开电机灌写会话");
|
||||
/* push 受 native 请求体 65535B 限制:16KB 二进制 = 32768 hex 字符,余量充足 */
|
||||
const STEP = 16384;
|
||||
for (let off = 0; off < size; off += STEP) {
|
||||
const part = bytes.subarray(off, Math.min(off + STEP, size));
|
||||
unwrap(await api("motor/ota", { op: "push", hex: bytesToHexLower(part) }, 15000),
|
||||
"推送镜像分片");
|
||||
}
|
||||
const st = unwrap(await api("motor/ota", { op: "status" }, 5000), "读灌写状态");
|
||||
const recv = Number(st.srv_recv) || 0;
|
||||
return {
|
||||
ok: recv === size,
|
||||
note: recv === size
|
||||
? `主站已持有 ${size} B(分块 CRC32 已逐块核对,整镜像 CRC 由从站在电机进 BOOT 前核)`
|
||||
: `主站只收到 ${recv}/${size} B`,
|
||||
};
|
||||
},
|
||||
|
||||
/** 起会话:worker 后台跑(灌块 → 从站核 CRC → 锁通道 → 进 BOOT → 停等传输)。
|
||||
* 这一步只负责「派活」,成败由 otaStatus 的终态给结论。 */
|
||||
async otaStartRelay() {
|
||||
const r = unwrap(await api("motor/ota", { op: "run" }, 8000), "启动电机灌写");
|
||||
setMotorOtaEcatBusy(true); /* 挡住外壳 Owner 与本页的 motor/start 重拉 */
|
||||
return { ok: !!r.ok };
|
||||
},
|
||||
|
||||
/** 进度:把 native 的 stage + 从站 relay 数字状态翻译成 WiFi 那套字符串,
|
||||
* 好让 ota.js 的阶段条/日志只用一套判据。 */
|
||||
async otaStatus() {
|
||||
const st = unwrap(await api("motor/ota", { op: "status" }, 4000), "读电机刷写进度");
|
||||
const stage = Number(st.stage) || 0;
|
||||
const relay = Number(st.state) || 0;
|
||||
const errWord = Number(st.err) || 0;
|
||||
const sessErr = (errWord >> 8) & 0xFF;
|
||||
const protoErr = errWord & 0xFF;
|
||||
let state;
|
||||
if (stage === 0) state = "idle";
|
||||
else if (stage === 1) state = "staging"; /* 还在灌从站暂存区,引擎尚未启动 */
|
||||
else if (stage === 2 && relay === 7) state = "finishing";
|
||||
/* relay 已 kFinished 但 worker 还没把过程数据循环与 OP 补回来。
|
||||
* 此刻就报 finished 的话,上层的版本查询会打在「主站在发、从站不在 OP」
|
||||
* 的空档里,白丢一次应答(实测会表现成「OTA 完成但查不到版本」)。 */
|
||||
else if (stage === 2) state = RELAY_STATE_NAMES[relay] || "pre_cancel";
|
||||
else state = st.flash_ok ? "finished" : "error";
|
||||
if (stage === 3) setMotorOtaEcatBusy(false);
|
||||
const reason = String(st.reason || "");
|
||||
return {
|
||||
state,
|
||||
error: state === "error" ? (protoErr || sessErr || 0x01) : protoErr,
|
||||
/* 会话码 0x8x 与电机协议码 0x0x/0x3x 是两种病,必须分开摆出来,
|
||||
* 否则「从站把料挡了」会被读成「电机报错」。 */
|
||||
session_error: sessErr,
|
||||
packets_acked: Number(st.acked) || 0,
|
||||
total_packets: Number(st.pk_total) || 0,
|
||||
fw_size: Number(st.claimed) || 0,
|
||||
stage,
|
||||
note: reason,
|
||||
};
|
||||
},
|
||||
|
||||
/** 取消:请 worker 下一拍写 0x2510:01=3,从站发 CAN 取消帧并释放通道锁。
|
||||
* 等 native 报 running=false 才放开 motorOtaEcatBusy,避免外壳 Owner 在
|
||||
* 会话还挂着的时候抢去 motor/start。 */
|
||||
async otaCancel() {
|
||||
const r = await api("motor/ota", { op: "cancel" }, 5000).catch(() => null);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const st = await api("motor/ota", { op: "status" }, 3000).catch(() => null);
|
||||
if (!st || !st.running) break;
|
||||
await new Promise((res) => setTimeout(res, 400));
|
||||
}
|
||||
setMotorOtaEcatBusy(false);
|
||||
return { ok: !!(r && r.ok) };
|
||||
},
|
||||
|
||||
/** 诊断用(控制台 motorDbg.transport)。 */
|
||||
debug() {
|
||||
return {
|
||||
|
||||
@@ -134,8 +134,12 @@ export function createWifiTransport(ctx) {
|
||||
return {
|
||||
id: "wifi",
|
||||
label: "WiFi / HTTP",
|
||||
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay),EtherCAT 侧没有这条链路 */
|
||||
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay) */
|
||||
supportsOta: true,
|
||||
/** 进 BOOT 帧由上位机发(既有顺序:先帧后 relay),锁还没上,通道此刻是空的 */
|
||||
otaHostSendsBootFrame: true,
|
||||
/** 完整性口径:ESP32 从 flash 读回算 SHA-256,与浏览器算的比 */
|
||||
otaIntegrity: "sha256-esp32-flash",
|
||||
/** 0xC5 推送周期可与前端刷新率对齐 */
|
||||
supportsPeriodSync: true,
|
||||
|
||||
@@ -229,6 +233,76 @@ export function createWifiTransport(ctx) {
|
||||
return data.nodes.find((n) => n.node === node) || null;
|
||||
},
|
||||
|
||||
/* ── 电机 OTA(ESP32 上传 + SPI 中继)────────────────────────────
|
||||
* 还是原来那三条 HTTP:POST /motor/ota(写 ESP32 flash 并回读算 SHA-256)、
|
||||
* POST /motor/ota/relay(SPI 0xD0 START + feedTask 喂数)、GET /motor/ota/status。
|
||||
* 收进传输契约只有一个原因:ota.js 原先硬写 getBaseUrl(),等于假设「设备 IP
|
||||
* 一定打得通」——纯 EtherCAT 场合那条前提不成立。行为与外提前完全一致。 */
|
||||
|
||||
/** 一次上传尝试:POST 整份镜像,拿设备回读的 flash SHA-256 与浏览器算的比。
|
||||
* 不抛错——失败是这里的正常出口(ota.js 据此决定重传,最多 3 次)。 */
|
||||
async otaUploadAttempt(bytes, shaHex, opts) {
|
||||
const ch = (opts && opts.ch) | 0;
|
||||
let json = {};
|
||||
try {
|
||||
const resp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: bytes,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
json = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
|
||||
} catch (e) {
|
||||
return { ok: false, note: (e && e.message) || String(e) };
|
||||
}
|
||||
const size = Number(json.size) || bytes.length;
|
||||
if (!json.sha256 || String(json.sha256).length !== 64) {
|
||||
/* 旧 ESP32 固件不回 SHA:没有对端读数可比,如实说明后放行(沿用既有行为),
|
||||
* 后续 relay 若被拒会由那条报错兜住。 */
|
||||
return { ok: true, size, note: "设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对" };
|
||||
}
|
||||
const dev = String(json.sha256).toLowerCase();
|
||||
return {
|
||||
ok: dev === String(shaHex).toLowerCase(),
|
||||
size,
|
||||
note: dev === String(shaHex).toLowerCase()
|
||||
? "flash 回读 SHA-256 一致"
|
||||
: `flash SHA 不一致(设备 ${dev.slice(0, 16)}…)`,
|
||||
};
|
||||
},
|
||||
|
||||
/** 触发中继:GD32 开始握手,ESP32 起 feedTask 喂 SPI。失败抛错(上层按启动失败处理)。 */
|
||||
async otaStartRelay(ch) {
|
||||
const resp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${(ch | 0)}`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const json = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/** 进度:ESP32 已经给字符串状态,直接透传;session_error/note 是 ECAT 才有的字段,
|
||||
* 这里补空值,让上层的显示逻辑不用分链路写两遍。 */
|
||||
async otaStatus() {
|
||||
const st = await getJson("/motor/ota/status", 2000);
|
||||
return Object.assign({ session_error: 0, note: "" }, st);
|
||||
},
|
||||
|
||||
/** 兜底清理:让 ESP32 退出 feedTask + GD32 resetToIdle。 */
|
||||
async otaCancel() {
|
||||
try {
|
||||
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return { ok: r.ok };
|
||||
} catch (e) {
|
||||
return { ok: false, note: (e && e.message) || String(e) };
|
||||
}
|
||||
},
|
||||
|
||||
/** 把前端刷新周期同步给 GD32(0xC5 主动推送周期),失败静默。 */
|
||||
async setPeriod(ms) {
|
||||
const v = Math.max(20, Math.min(65535, Math.round(ms)));
|
||||
|
||||
+15
-2
@@ -17,6 +17,8 @@
|
||||
* reportedIp(data) -> string|null 设备回报 IP(ECAT 恒 null)
|
||||
* fetchMotorState() -> {have_data, age_ms, active_count, boot_active, motors[]}
|
||||
* fetchFeedback() -> {channels:[{ch,enabled,valid,rx,tx,err,tec,rec,bus_state}]}
|
||||
* bus_state 位图:bit0=init bit1=控制器睡眠 bit2=error_passive bit3=bus_off
|
||||
* bit4=error_warning bit5=只听不发(收发器 Silent,本端 S 脚)
|
||||
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
|
||||
* sendBurst(frames, {suffix,dlc}) -> results[]
|
||||
* fetchVersionNode(node) -> {node,seq,version,at?}|null
|
||||
@@ -25,13 +27,24 @@
|
||||
* teardown() -> void 切走时收尾
|
||||
* 可选:takeNodeIdReport(node)、debug()
|
||||
*
|
||||
* 电机 OTA(supportsOta=true 时必须整套实现;流程只在 ota.js 里写一遍):
|
||||
* otaHostSendsBootFrame: bool 进 BOOT 帧由上位机发(WiFi)还是 GD32 在已加锁
|
||||
* 的通道上自己发(EtherCAT)
|
||||
* otaIntegrity: string 完整性口径,UI 文案照它写,不许互相冒充:
|
||||
* 'sha256-esp32-flash' | 'crc32-verify-in-slave'
|
||||
* otaUploadAttempt(bytes, shaHex, {ch, node}) -> {ok, note, size?}
|
||||
* otaStartRelay(ch, node) -> {ok} 抛错=没起来
|
||||
* otaStatus() -> {state, error, session_error, packets_acked, total_packets, note}
|
||||
* state 用 WiFi 那套字符串(ECAT 由传输层翻译)
|
||||
* otaCancel() -> {ok, note?}
|
||||
*
|
||||
* motors[] 每项的字段契约(ECAT 实现负责补平差异):
|
||||
* {node, ch, online, dlc, rx_count, seq, data(64hex), config_data(48hex|"")}
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
import { createWifiTransport } from "./transport-wifi.js";
|
||||
import { createEcatTransport } from "./transport-ecat.js?v=20260923i";
|
||||
import { createWifiTransport } from "./transport-wifi.js?v=20261011b";
|
||||
import { createEcatTransport } from "./transport-ecat.js?v=20261011b";
|
||||
|
||||
/** localStorage key:记住上次选的传输,刷新页面后保持 */
|
||||
export const LS_TRANSPORT = "dcu_motor_transport";
|
||||
|
||||
Reference in New Issue
Block a user