fix(ui): 电机前端回滚到 P1 拆分前,恢复 STATUS 离线层契约
根因:拆分后 renderStatusPanel 操作 #statusOffline/#statusPanel, HTML 实际 id 是 offlineOverlay/statusLive,遮罩永不摘掉。 CSS/HTML 结构本就未重写;本提交恢复完整 main.js 与 dual-link 传输层。
This commit is contained in:
+93
-175
@@ -42,33 +42,10 @@
|
||||
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
|
||||
import { staleLimitMs } from "./refresh-rate.js";
|
||||
import { deviceLink } from "../common/device-link.js";
|
||||
import {
|
||||
openEcatAdapter, startEcatMotorLoop, repairEcatDc,
|
||||
resolveEcatStartBody, readStoredEcatPeriod,
|
||||
ECAT_DEFAULT_ADAPTER,
|
||||
} from "../common/ecat-bus.js";
|
||||
import {
|
||||
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
|
||||
desktopEcatSendRaw, hasDesktopFastSend,
|
||||
} from "../common/desktop-bridge.js?v=20260924a";
|
||||
|
||||
function bytesToHexStr(bytes) {
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
s += bytes[i].toString(16).padStart(2, "0").toUpperCase();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function hexToBytes(hex) {
|
||||
const clean = String(hex || "").replace(/[^0-9a-fA-F]/g, "");
|
||||
const out = new Uint8Array(Math.floor(clean.length / 2));
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16);
|
||||
return out;
|
||||
}
|
||||
function u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); }
|
||||
function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); }
|
||||
const ID_GET_NODE_ID = (n) => canId(n, 0x0401);
|
||||
const ID_SET_NODE_ID = (n) => canId(n, 0x0400);
|
||||
} from "../common/desktop-bridge.js?v=20260923i";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数。
|
||||
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
|
||||
@@ -315,22 +292,39 @@ export function createEcatTransport(ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保网卡已打开 —— R6:策略实现在 js/common/ecat-bus.js 单点。
|
||||
* 服务刚被自动拉起时 g_master=None,任何 /api/motor/* 会回「未连接」。
|
||||
* 确保网卡已打开(从站已被发现)。
|
||||
*
|
||||
* 服务刚被自动拉起时是"没连接"状态(g_master=None),此时任何
|
||||
* /api/motor/* 都会回 {ok:false, log:["未连接"]}。这里代替用户去点
|
||||
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
|
||||
* 用户还是得开另一个页面点两下才能用。
|
||||
*
|
||||
* 幂等:后端的 open 路由会先 close 掉旧 master 再重开,
|
||||
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
|
||||
*/
|
||||
async function ensureOpen() {
|
||||
try {
|
||||
return await openEcatAdapter(api, adapterSubstr);
|
||||
} catch (e) {
|
||||
/* open 失败(网线/网卡名)保持非瞬时,让探测横幅给出可操作原因 */
|
||||
if (e && e.transient === undefined) e.transient = false;
|
||||
throw e;
|
||||
const st = await api("status", {}, 6000);
|
||||
if (st && st.ok === true) return st; // 已连接
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I210);
|
||||
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
|
||||
const r = await api("open", { substr: adapterSubstr }, 20000);
|
||||
if (!r || r.ok !== true) {
|
||||
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败";
|
||||
const err = new Error(msg);
|
||||
/* 网线没插/板子没供电属"外部条件不满足",不是瞬时抖动:
|
||||
* 标成 transient 会让页面一直转圈却不给红灯提示。
|
||||
* 但"适配器没找到"往往是网卡名不同,值得把候选列表原样冒泡上去。 */
|
||||
err.transient = false;
|
||||
throw err;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量健康:native/health 只读缓存(不抢 g_bus_lock、不 loop_stop)。
|
||||
* R6:是否健康的判定与 ecat-bus.isEcatBusHealthy 同源。
|
||||
* bus_open + loop=1 即后台 PDO 循环在跑,从站看门狗正被喂着 —— 这时候
|
||||
* 再打 motor/start 反而有害:它会先 loop_stop 再核实/重进 OP,热路径上
|
||||
* 既慢又可能撞前端 AbortSignal 假超时,文档也写明「已连接只复用」。
|
||||
*/
|
||||
async function readHealth() {
|
||||
const h = await api("native/health", {}, 2500);
|
||||
@@ -343,6 +337,16 @@ export function createEcatTransport(ctx) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 */
|
||||
async function isBusHealthy() {
|
||||
try {
|
||||
const h = await readHealth();
|
||||
return !!(h && h.busOpen && h.loop);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 motor/state 快照同步周期/自旋/钉核,供状态栏在跳过 start 后仍显示正确。 */
|
||||
function syncFromSnapshot(snap) {
|
||||
if (!snap) return;
|
||||
@@ -353,19 +357,12 @@ export function createEcatTransport(ctx) {
|
||||
lastSnapshot = snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保已进 OP 且后台 PDO 循环在跑。
|
||||
* R6:health 复用 + DC 250µs + motor/start 的策略在 ecat-bus.startEcatMotorLoop。
|
||||
*/
|
||||
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
||||
async function ensureStarted(reqPeriodMs) {
|
||||
/* 日常接入:循环还在跑就别动总线(start 会先 loop_stop) */
|
||||
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
|
||||
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
|
||||
if (!reqPeriodMs) {
|
||||
const startedR = await startEcatMotorLoop(api, {
|
||||
force: false,
|
||||
fromLocalStorage: true,
|
||||
period_ms: readStoredEcatPeriod(),
|
||||
});
|
||||
if (startedR.reused) {
|
||||
if (await isBusHealthy()) {
|
||||
started = true;
|
||||
try {
|
||||
const snap = unwrap(await api("motor/state", {}, 2500), "读电机快照");
|
||||
@@ -374,24 +371,52 @@ export function createEcatTransport(ctx) {
|
||||
} catch (_) { /* 健康位说在跑但快照暂不可读:仍按已启动处理 */ }
|
||||
return { ok: true, period_ms: periodMs, spin: spinOn };
|
||||
}
|
||||
const body = startedR.body || resolveEcatStartBody({
|
||||
fromLocalStorage: true, period_ms: readStoredEcatPeriod(),
|
||||
});
|
||||
periodMs = (startedR.result && startedR.result.period_ms) || body.period_ms || periodMs;
|
||||
if (startedR.result && typeof startedR.result.spin === "boolean") spinOn = startedR.result.spin;
|
||||
started = true;
|
||||
return startedR.result || {};
|
||||
}
|
||||
/* 显式周期覆盖(setPdoPeriod 等):强制 start,仍走 ecat-bus 的 DC 钳制 */
|
||||
const startedR = await startEcatMotorLoop(api, {
|
||||
force: true,
|
||||
period_ms: reqPeriodMs,
|
||||
});
|
||||
const body = startedR.body || resolveEcatStartBody({ period_ms: reqPeriodMs });
|
||||
periodMs = (startedR.result && startedR.result.period_ms) || body.period_ms || periodMs;
|
||||
if (startedR.result && typeof startedR.result.spin === "boolean") spinOn = startedR.result.spin;
|
||||
const body = {};
|
||||
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
|
||||
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。
|
||||
* 没显式给时带上 localStorage 里的上次取值(内核级 250us 档位能跨重启存活)。 */
|
||||
if (reqPeriodMs) body.period_ms = reqPeriodMs;
|
||||
else {
|
||||
try {
|
||||
if (!localStorage.getItem(LS_PERIOD_MIG)) {
|
||||
if (parseFloat(localStorage.getItem(LS_PERIOD)) === 4) {
|
||||
localStorage.removeItem(LS_PERIOD);
|
||||
}
|
||||
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;
|
||||
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。 */
|
||||
{
|
||||
let per = Number(body.period_ms) || 0.25;
|
||||
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
|
||||
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;
|
||||
}
|
||||
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
||||
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
|
||||
let spinPref = null;
|
||||
try {
|
||||
const v = localStorage.getItem(LS_SPIN);
|
||||
if (v === "0" || v === "1") spinPref = (v === "1");
|
||||
} catch (_) { /* 隐私模式下 localStorage 会抛,忽略即可 */ }
|
||||
if (spinPref !== null) body.spin = spinPref;
|
||||
/* motor/start 内部会在未进 OP 时自己跑 PREOP→SAFEOP→OP(还会自动重试一次),
|
||||
* 最坏约 24s,所以超时给 30s —— 给小了会在正常进 OP 的过程中假失败。 */
|
||||
const r = unwrap(await api("motor/start", body, 30000), "启动 PDO 循环");
|
||||
periodMs = r.period_ms || periodMs;
|
||||
if (typeof r.spin === "boolean") spinOn = r.spin;
|
||||
started = true;
|
||||
return startedR.result || {};
|
||||
return r;
|
||||
}
|
||||
|
||||
function xrxChangeKey(x) {
|
||||
@@ -589,9 +614,15 @@ export function createEcatTransport(ctx) {
|
||||
const h = await readHealth();
|
||||
if (!h || !h.busOpen) throw new Error("EtherCAT 服务未打开网卡");
|
||||
if (!h.loop) throw new Error("PDO 循环未运行");
|
||||
/* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc(250µs) */
|
||||
/* 自愈:DC/周期被配错(free-run 或 ≥1ms)时发送出口会到秒级 —— 拉回 250µs */
|
||||
try {
|
||||
await repairEcatDc(api);
|
||||
const raw = await api("native/health", {}, 2000);
|
||||
const dc = raw && raw.dc;
|
||||
const dcs = raw && raw.dc_cycle_us;
|
||||
const per = raw && raw.period_ms;
|
||||
if (dc !== 1 || dcs !== 250 || (per != null && Math.abs(per - 0.25) > 0.001)) {
|
||||
await api("motor/start", { period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0 }, 30000);
|
||||
}
|
||||
} catch (_) { /* 自愈失败仍按 health 结果返回 */ }
|
||||
const st = await api("status", {}, 2500);
|
||||
if (!st || st.ok !== true) throw new Error("从站状态不可读");
|
||||
@@ -757,94 +788,6 @@ export function createEcatTransport(ctx) {
|
||||
return versions.get(node) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* R5:ECAT 版本等待 —— drain + 采样循环(不绑主 tick)。
|
||||
* 旧 main.waitVersionEcat 逻辑迁到此处,行为不变。
|
||||
*/
|
||||
async waitVersion(node, opts) {
|
||||
const prevSeq = opts && opts.prevSeq;
|
||||
const sentAt = (opts && opts.sentAt) || 0;
|
||||
const timeoutMs = (opts && opts.timeoutMs) || 2500;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const sampleOnce = async () => {
|
||||
try { await drainRx(); } catch (_) {}
|
||||
const mapV = versions.get(node) || null;
|
||||
if (mapV && mapV.version &&
|
||||
(prevSeq == null || mapV.seq > prevSeq || (mapV.at && mapV.at >= sentAt))) {
|
||||
return mapV;
|
||||
}
|
||||
try {
|
||||
const x = await this.peekVersionXrx(node);
|
||||
if (x && x.version) return x;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
};
|
||||
while (Date.now() < deadline) {
|
||||
const v = await sampleOnce();
|
||||
if (v && v.version) return v;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* R5:节点参数 GET —— 发送走注入 sendFrame,回执走 motor/rx(takeNodeIdReport)。
|
||||
*/
|
||||
async queryNodeIdParam(node, selector) {
|
||||
const sendFrame = ctx.sendFrame;
|
||||
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
|
||||
const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]);
|
||||
const id = ID_GET_NODE_ID(node);
|
||||
const res = await sendFrame(id, 2, bytesToHexStr(bytes));
|
||||
if (!res.ok) {
|
||||
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
|
||||
}
|
||||
let rb = null;
|
||||
const r = res.results && res.results[0];
|
||||
if (r && r.recv && r.recv_len >= 2) {
|
||||
const b = hexToBytes(r.recv);
|
||||
if (b.length === 2 || b.length === 4) rb = b;
|
||||
}
|
||||
if (!rb) {
|
||||
const deadline = Date.now() + 1500;
|
||||
while (Date.now() < deadline && !rb) {
|
||||
await new Promise((r2) => setTimeout(r2, 120));
|
||||
const hex = nodeIdReports.get(node);
|
||||
if (hex) { nodeIdReports.delete(node); rb = hexToBytes(hex); }
|
||||
else { try { await drainRx(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
return parseParamReplyEc(rb, selector);
|
||||
},
|
||||
|
||||
/** R5:节点参数 SET —— 回执走 motor/rx(5B ACK / 旧格式)。 */
|
||||
async setNodeIdParam(node, selector, value) {
|
||||
const sendFrame = ctx.sendFrame;
|
||||
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
|
||||
const bytes = new Uint8Array([
|
||||
selector & 0xFF, (selector >> 8) & 0xFF,
|
||||
value & 0xFF, (value >> 8) & 0xFF,
|
||||
]);
|
||||
const id = ID_SET_NODE_ID(node);
|
||||
const res = await sendFrame(id, 4, bytesToHexStr(bytes));
|
||||
if (!res.ok) {
|
||||
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
|
||||
}
|
||||
let rb = null;
|
||||
const r = res.results && res.results[0];
|
||||
if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv);
|
||||
if (!rb) {
|
||||
const deadline = Date.now() + 1500;
|
||||
while (Date.now() < deadline && !rb) {
|
||||
await new Promise((r2) => setTimeout(r2, 120));
|
||||
const hex = nodeIdReports.get(node);
|
||||
if (hex) { nodeIdReports.delete(node); rb = hexToBytes(hex); }
|
||||
else { try { await drainRx(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
return parseSetReplyEc(rb, selector);
|
||||
},
|
||||
|
||||
/** 版本查询等待期强制消费 motor/rx(不依赖 rx_batch.count)。 */
|
||||
drainPendingRx() {
|
||||
return drainRx();
|
||||
@@ -973,28 +916,3 @@ export function createEcatTransport(ctx) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseParamReplyEc(rb, selector) {
|
||||
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
|
||||
if (rb.length >= 4) {
|
||||
const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2);
|
||||
if (sel !== selector) {
|
||||
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
|
||||
}
|
||||
return { ok: true, val };
|
||||
}
|
||||
if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) };
|
||||
return { ok: false, desc: "应答格式异常" };
|
||||
}
|
||||
|
||||
function parseSetReplyEc(rb, selector) {
|
||||
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
|
||||
if (rb.length >= 5) {
|
||||
const st = rb[0], sel = u16LeAt(rb, 1);
|
||||
if (sel !== selector) {
|
||||
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
|
||||
}
|
||||
return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" };
|
||||
}
|
||||
return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user