feat(motor): 支持 WiFi 与 EtherCAT 双链路传输
- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用 - 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题 - 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询 - 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 浏览器实测:ECAT 刷新速度 1ms(不限速档)。
|
||||
* 单测只验了纯逻辑;这里验真机行为:
|
||||
* - 输入框 min/step 随链路变(否则浏览器把合法的 1ms 标成 :invalid)
|
||||
* - 1ms 真的比 50ms 快,且拿到实测频率而不是理论 1000Hz
|
||||
* - 不限速档**不**亮卡顿黄标(每拍撞上在途请求是预期行为)
|
||||
* - 切回 WiFi 时 1ms 不会被带过去(wifi 下限 50ms)
|
||||
*/
|
||||
const CDP = "http://127.0.0.1:9333";
|
||||
const list = await (await fetch(CDP + "/json/list")).json();
|
||||
const page = list.find((t) => t.type === "page");
|
||||
const sock = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
await new Promise((r, j) => { sock.onopen = r; sock.onerror = j; });
|
||||
sock.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
|
||||
const send = (method, params = {}) => { const myId = ++id; return new Promise((res) => { pending.set(myId, res); sock.send(JSON.stringify({ id: myId, method, params })); }); };
|
||||
async function ev(expr) {
|
||||
const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
|
||||
return r.result && r.result.result && r.result.result.value;
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let pass = 0, fail = 0; const out = [];
|
||||
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点,直接进 ECAT */
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(2000);
|
||||
await ev(`localStorage.setItem("dcu_motor_transport","ecat"); localStorage.removeItem("dcu_motor_ui_v2"); "ok"`);
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(3500);
|
||||
|
||||
/* 请求计数探针 */
|
||||
await ev(`(() => {
|
||||
window.__cnt = 0;
|
||||
if (!window.__origFetch) window.__origFetch = window.fetch;
|
||||
window.fetch = function (u) { if (String(u).includes("motor/state")) window.__cnt++; return window.__origFetch.apply(this, arguments); };
|
||||
return "ok";
|
||||
})()`);
|
||||
async function measure(sec) {
|
||||
await ev(`window.__cnt = 0; "ok"`);
|
||||
await sleep(sec * 1000);
|
||||
return await ev(`window.__cnt`) / sec;
|
||||
}
|
||||
|
||||
const pre = JSON.parse(await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn: motorDbg.state.connected, eff: motorDbg.state.refreshIntervalMs})`));
|
||||
out.push("\n[前置] " + JSON.stringify(pre));
|
||||
check("已在 ecat 链路且已连接", pre.tp === "ecat" && pre.conn === true, JSON.stringify(pre));
|
||||
|
||||
out.push("\n[A] ECAT 下输入框的 min/step 应放开到 1");
|
||||
let dom = JSON.parse(await ev(`JSON.stringify({min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
|
||||
check("min=1(否则浏览器校验会把 1ms 判成非法)", dom.min === "1", JSON.stringify(dom));
|
||||
check("step=1(微调箭头能落到 1ms 档)", dom.step === "1", JSON.stringify(dom));
|
||||
|
||||
out.push("\n[B] 基线:ECAT 默认 50ms");
|
||||
const hz50 = await measure(3);
|
||||
out.push(` 实测 ${hz50.toFixed(1)} Hz`);
|
||||
check("50ms 档约 20Hz", hz50 > 12 && hz50 < 30, `实测 ${hz50.toFixed(1)}`);
|
||||
|
||||
out.push("\n[C] 设成 1ms —— 应被接受且明显更快");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(800);
|
||||
let st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, valid: document.querySelector("#refreshRate").checkValidity(), map: motorDbg.state.refreshByTp})`));
|
||||
check("1ms 被接受(没被钳回 50)", st.eff === 1, JSON.stringify(st));
|
||||
check("输入框显示 1", st.val === "1", JSON.stringify(st));
|
||||
check("通过浏览器原生校验(min 已同步)", st.valid === true, JSON.stringify(st));
|
||||
check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 1 && st.map.wifi === 250, JSON.stringify(st));
|
||||
const hz1 = await measure(4);
|
||||
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,实测约 125Hz 量级)`);
|
||||
check("1ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `1ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
|
||||
check("但达不到 1000Hz(受每拍 HTTP 往返限制,符合预期)", hz1 < 400, `实测 ${hz1.toFixed(1)}`);
|
||||
|
||||
out.push("\n[D] 不限速档不应亮卡顿黄标");
|
||||
st = JSON.parse(await ev(`JSON.stringify({stuck: motorDbg.state.tickStuck, cls: document.querySelector("#chSelect").classList.contains("stuck"), title: document.querySelector("#chSelect").title})`));
|
||||
check("tickStuck 保持 0(每拍撞上在途请求是预期行为,不算卡顿)", st.stuck === 0, JSON.stringify(st));
|
||||
check("通道下拉框没有 .stuck 黄标", st.cls === false, JSON.stringify(st));
|
||||
check("没有误导性的卡顿提示文案", !st.title, JSON.stringify(st));
|
||||
|
||||
out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 1000Hz");
|
||||
const hzText = await ev(`document.querySelector("#statusIdLabel").textContent`);
|
||||
out.push(` 面板显示: ${hzText}`);
|
||||
const m = String(hzText).match(/([\d.]+)\s*Hz/);
|
||||
check("面板有 Hz 读数", !!m || /\u2026/.test(String(hzText)), hzText);
|
||||
if (m) {
|
||||
const shown = parseFloat(m[1]);
|
||||
check("显示的不是谎报的 1000Hz", shown < 400, `显示 ${shown}Hz`);
|
||||
check("显示值与实测频率同量级(±60%)",
|
||||
Math.abs(shown - hz1) / Math.max(hz1, 1) < 0.6, `显示 ${shown} vs 实测 ${hz1.toFixed(1)}`);
|
||||
}
|
||||
|
||||
out.push("\n[F] 切到 WiFi:1ms 不能带过去(wifi 下限 50ms)");
|
||||
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(1500);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
|
||||
check("WiFi 生效值 250(不是 ECAT 的 1)", st.eff === 250, JSON.stringify(st));
|
||||
check("WiFi 下 min 收回 50", st.min === "50", JSON.stringify(st));
|
||||
check("WiFi 下 step 收回 50", st.step === "50", JSON.stringify(st));
|
||||
/* 在 WiFi 下硬填 1ms,应被钳到 50 */
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(500);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
|
||||
check("WiFi 下填 1ms 被钳到 50(无线往返撑不住)", st.eff === 50, JSON.stringify(st));
|
||||
|
||||
out.push("\n[G] 切回 ECAT:仍记着自己的 1ms");
|
||||
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(2500);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min})`));
|
||||
check("ECAT 生效值回到 1ms", st.eff === 1, JSON.stringify(st));
|
||||
check("min 重新放开到 1", st.min === "1", JSON.stringify(st));
|
||||
/* 刷新页面后 1ms 应持久化 */
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(3000);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`));
|
||||
check("刷新页面后 ECAT 仍是 1ms(持久化 + 加载时不被钳高)", st.eff === 1, JSON.stringify(st));
|
||||
check("刷新后 wifi 那一份仍是 50(上面手动改过)", st.map.wifi === 50, JSON.stringify(st));
|
||||
|
||||
console.log(out.join("\n"));
|
||||
console.log("\n" + "=".repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
sock.close();
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 浏览器实测:刷新速度按链路各存一份(DOM + localStorage 往返)。
|
||||
* 单测覆盖不到的部分:输入框回填、切链路自动换、刷新页面后仍各自独立。
|
||||
*/
|
||||
const CDP_BASE = "http://127.0.0.1:9333";
|
||||
|
||||
async function ws() {
|
||||
const list = await (await fetch(CDP_BASE + "/json/list")).json();
|
||||
const page = list.find((t) => t.type === "page");
|
||||
return page.webSocketDebuggerUrl;
|
||||
}
|
||||
const { WebSocket } = await import("node:http").then(() => import("ws")).catch(() => ({}));
|
||||
|
||||
// 没有 ws 依赖时用原生 WebSocket(Node 22+)
|
||||
const WSImpl = globalThis.WebSocket || WebSocket;
|
||||
|
||||
const url = await ws();
|
||||
const sock = new WSImpl(url);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
await new Promise((r, j) => { sock.onopen = r; sock.onerror = j; });
|
||||
sock.onmessage = (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
|
||||
};
|
||||
function send(method, params = {}) {
|
||||
const myId = ++id;
|
||||
return new Promise((res) => { pending.set(myId, res); sock.send(JSON.stringify({ id: myId, method, params })); });
|
||||
}
|
||||
async function evaluate(expr) {
|
||||
const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
|
||||
if (r.result && r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails));
|
||||
return r.result && r.result.result && r.result.result.value;
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const out = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { pass++; out.push(" \u2713 " + name); }
|
||||
else { fail++; out.push(" \u2717 " + name + (detail ? " \u2014 " + detail : "")); }
|
||||
}
|
||||
|
||||
await send("Runtime.enable");
|
||||
await send("Page.enable");
|
||||
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
async function goto(u) {
|
||||
await send("Page.navigate", { url: u });
|
||||
await sleep(2500);
|
||||
}
|
||||
|
||||
/* 干净起点:清掉本页 localStorage 再载入 */
|
||||
await goto(PAGE);
|
||||
await evaluate(`localStorage.removeItem("dcu_motor_ui_v2"); localStorage.removeItem("dcu_motor_transport"); "ok"`);
|
||||
await goto(PAGE);
|
||||
|
||||
out.push("\n[A] 首次进入(无历史数据)");
|
||||
let s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp})`);
|
||||
let o = JSON.parse(s);
|
||||
check("默认链路 wifi", o.tp === "wifi", s);
|
||||
check("输入框显示 250", o.input === "250", s);
|
||||
check("生效值 250", o.eff === 250, s);
|
||||
check("两份都在(wifi250/ecat50)", o.map.wifi === 250 && o.map.ecat === 50, s);
|
||||
|
||||
out.push("\n[B] 切到 EtherCAT → 自动换成 50ms");
|
||||
await evaluate(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(1200);
|
||||
s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs})`);
|
||||
o = JSON.parse(s);
|
||||
check("链路已切到 ecat", o.tp === "ecat", s);
|
||||
check("输入框自动变 50(不是留在 250)", o.input === "50", s);
|
||||
check("生效值 50", o.eff === 50, s);
|
||||
|
||||
out.push("\n[C] 在 ECAT 下改成 120ms,只改这一份");
|
||||
await evaluate(`(() => { const i=document.querySelector("#refreshRate"); i.value="120"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(600);
|
||||
s = await evaluate(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp, ls: JSON.parse(localStorage.getItem("dcu_motor_ui_v2")||"{}").refreshByTp})`);
|
||||
o = JSON.parse(s);
|
||||
check("ecat 生效 120", o.eff === 120, s);
|
||||
check("wifi 那一份仍是 250(没被污染)", o.map.wifi === 250, s);
|
||||
check("已写入 localStorage", o.ls && o.ls.ecat === 120 && o.ls.wifi === 250, s);
|
||||
|
||||
out.push("\n[D] 切回 WiFi → 自动换回 250ms");
|
||||
await evaluate(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(1200);
|
||||
s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp})`);
|
||||
o = JSON.parse(s);
|
||||
check("链路回到 wifi", o.tp === "wifi", s);
|
||||
check("输入框换回 250(没带着 ECAT 的 120)", o.input === "250", s);
|
||||
check("生效值 250", o.eff === 250, s);
|
||||
check("ecat 那一份仍记着 120", o.map.ecat === 120, s);
|
||||
|
||||
out.push("\n[E] 在 WiFi 下改成 800ms,再刷新页面");
|
||||
await evaluate(`(() => { const i=document.querySelector("#refreshRate"); i.value="800"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(600);
|
||||
await goto(PAGE);
|
||||
s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp})`);
|
||||
o = JSON.parse(s);
|
||||
check("刷新后仍是 wifi 链路", o.tp === "wifi", s);
|
||||
check("刷新后输入框恢复 800", o.input === "800", s);
|
||||
check("刷新后生效值 800", o.eff === 800, s);
|
||||
check("刷新后 ecat 那一份仍是 120", o.map.ecat === 120, s);
|
||||
|
||||
out.push("\n[F] 刷新后切到 ECAT,仍取回自己的 120");
|
||||
await evaluate(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(1200);
|
||||
s = await evaluate(`JSON.stringify({input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs})`);
|
||||
o = JSON.parse(s);
|
||||
check("输入框 120(跨刷新持久化 + 切换自动换都成立)", o.input === "120", s);
|
||||
check("生效值 120", o.eff === 120, s);
|
||||
|
||||
out.push("\n[G] 旧数据迁移(模拟老版本只存单个 refreshRate)");
|
||||
await evaluate(`(() => { localStorage.setItem("dcu_motor_ui_v2", JSON.stringify({refreshRate:"400"})); localStorage.setItem("dcu_motor_transport","wifi"); return "ok"; })()`);
|
||||
await goto(PAGE);
|
||||
s = await evaluate(`JSON.stringify({input: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`);
|
||||
o = JSON.parse(s);
|
||||
check("旧单值 400 迁到 wifi 并生效", o.input === "400", s);
|
||||
check("ecat 不继承旧值,仍是默认 50", o.map.ecat === 50, s);
|
||||
|
||||
console.log(out.join("\n"));
|
||||
console.log("\n" + "=".repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
sock.close();
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 浏览器实测(第二轮):切链路后**实际节拍**是否真的跟着变。
|
||||
* 前一轮只验了输入框/state;这里数真实的 fetchMotorState 调用频率 ——
|
||||
* 若 applyTransportRefresh 忘了重排定时器,state 是新值但节拍还是旧的。
|
||||
*/
|
||||
const CDP_BASE = "http://127.0.0.1:9333";
|
||||
const list = await (await fetch(CDP_BASE + "/json/list")).json();
|
||||
const page = list.find((t) => t.type === "page");
|
||||
const sock = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
await new Promise((r, j) => { sock.onopen = r; sock.onerror = j; });
|
||||
sock.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
|
||||
const send = (method, params = {}) => { const myId = ++id; return new Promise((res) => { pending.set(myId, res); sock.send(JSON.stringify({ id: myId, method, params })); }); };
|
||||
async function ev(expr) {
|
||||
const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
|
||||
return r.result && r.result.result && r.result.result.value;
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let pass = 0, fail = 0; const out = [];
|
||||
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点,直接进 ECAT 链路(本机有 ecat 服务,能真连上跑起轮询) */
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(2000);
|
||||
await ev(`localStorage.setItem("dcu_motor_transport","ecat"); localStorage.removeItem("dcu_motor_ui_v2"); "ok"`);
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(3000);
|
||||
|
||||
/* 装一个计数探针:统计 /ecat/api/motor/state 的实际请求次数 */
|
||||
await ev(`(() => {
|
||||
window.__cnt = 0;
|
||||
if (!window.__origFetch) window.__origFetch = window.fetch;
|
||||
window.fetch = function (u, i) {
|
||||
if (String(u).includes("motor/state")) window.__cnt++;
|
||||
return window.__origFetch.apply(this, arguments);
|
||||
};
|
||||
return "ok";
|
||||
})()`);
|
||||
|
||||
const state0 = await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn: motorDbg.state.connected, eff: motorDbg.state.refreshIntervalMs})`);
|
||||
out.push("\n[前置] 当前链路状态: " + state0);
|
||||
const st0 = JSON.parse(state0);
|
||||
check("已在 ecat 链路", st0.tp === "ecat", state0);
|
||||
check("已连接(否则数不到轮询)", st0.conn === true, state0);
|
||||
check("生效间隔 50ms", st0.eff === 50, state0);
|
||||
|
||||
async function measure(seconds) {
|
||||
await ev(`window.__cnt = 0; "ok"`);
|
||||
await sleep(seconds * 1000);
|
||||
return await ev(`window.__cnt`);
|
||||
}
|
||||
|
||||
out.push("\n[A] ECAT 默认 50ms 的实际节拍");
|
||||
let n = await measure(3);
|
||||
let hz = n / 3;
|
||||
out.push(` 3 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 20 Hz)`);
|
||||
check("接近 20Hz(>12Hz,留足浏览器调度余量)", hz > 12, `实测 ${hz.toFixed(1)}Hz`);
|
||||
|
||||
out.push("\n[B] 改成 500ms → 节拍应明显变慢");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="500"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(800);
|
||||
n = await measure(4);
|
||||
hz = n / 4;
|
||||
out.push(` 4 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 2 Hz)`);
|
||||
check("落到 2Hz 附近(0.8~4Hz)", hz > 0.8 && hz < 4, `实测 ${hz.toFixed(1)}Hz`);
|
||||
|
||||
out.push("\n[C] 切到 WiFi 再切回 ECAT:节拍应自动回到 ecat 那一份(500ms)");
|
||||
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(1500);
|
||||
const wifiState = await ev(`JSON.stringify({input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs})`);
|
||||
check("切 WiFi 后生效值变 250(不是带着 ECAT 的 500)", JSON.parse(wifiState).eff === 250, wifiState);
|
||||
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(2500);
|
||||
const backState = await ev(`JSON.stringify({input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, conn: motorDbg.state.connected})`);
|
||||
check("切回 ECAT 生效值回到 500(自己那一份)", JSON.parse(backState).eff === 500, backState);
|
||||
n = await measure(4);
|
||||
hz = n / 4;
|
||||
out.push(` 4 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 2 Hz)`);
|
||||
check("实际节拍也回到 2Hz 附近(不是 20Hz 的旧节拍)", hz > 0.8 && hz < 4, `实测 ${hz.toFixed(1)}Hz`);
|
||||
|
||||
out.push("\n[D] 改回 50ms,节拍应立刻提上去(验证重排真的生效)");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="50"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(600);
|
||||
n = await measure(3);
|
||||
hz = n / 3;
|
||||
out.push(` 3 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 20 Hz)`);
|
||||
check("回到 20Hz 附近(>12Hz)", hz > 12, `实测 ${hz.toFixed(1)}Hz`);
|
||||
|
||||
console.log(out.join("\n"));
|
||||
console.log("\n" + "=".repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
sock.close();
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* test_ecat_autostart.mjs — EtherCAT 自动接入链条自测(Node,无浏览器)
|
||||
*
|
||||
* 跑法:node scripts/test_ecat_autostart.mjs [--port 8080]
|
||||
*
|
||||
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
|
||||
* 四级链条的每一级都被正确驱动,且失败原因可读。
|
||||
*
|
||||
* 为什么要 stub 后端:板子当前 SSC 邮箱楔死(config_map 失败,ecat_server 自己的
|
||||
* 注释写明必须复位 MCU),真实链路走不到 OP。但"自动开网卡→自动进 OP→自动起循环"
|
||||
* 这三级的**驱动逻辑**必须能独立验证,否则一块坏板子就让这段代码永远测不到。
|
||||
* 服务自动拉起那一级已在真机实测过(16s 起、带 --parent-pid),见 README。
|
||||
*/
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import http from "node:http";
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
if (typeof globalThis.performance === "undefined") {
|
||||
globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 };
|
||||
}
|
||||
/* transport-ecat.js 会读 localStorage(网卡子串),Node 里没有,给个内存版 */
|
||||
const _ls = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (_ls.has(k) ? _ls.get(k) : null),
|
||||
setItem: (k, v) => _ls.set(k, String(v)),
|
||||
removeItem: (k) => _ls.delete(k),
|
||||
};
|
||||
|
||||
const { createEcatTransport } = await import("../js/motor/transport-ecat.js");
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const out = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { pass++; out.push(` \u2713 ${name}`); }
|
||||
else { fail++; out.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); }
|
||||
}
|
||||
function section(t) { out.push(`\n${t}`); }
|
||||
|
||||
/* ── stub 后端:模拟 ecat_server 的状态机 ──
|
||||
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
|
||||
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数。 */
|
||||
function makeStub(opts) {
|
||||
const st = { opened: false, started: false, calls: [], openSubstr: null,
|
||||
failOpen: opts && opts.failOpen, failStart: opts && opts.failStart };
|
||||
const srv = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
st.calls.push(req.url);
|
||||
const send = (o) => {
|
||||
const b = Buffer.from(JSON.stringify(o), "utf8");
|
||||
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": b.length });
|
||||
res.end(b);
|
||||
};
|
||||
let p = {};
|
||||
try { p = body ? JSON.parse(body) : {}; } catch (_) {}
|
||||
if (req.url === "/ecat/api/status") {
|
||||
return send(st.opened
|
||||
? { ok: true, slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 8, al_status: 0 } }
|
||||
: { ok: false, log: ["未连接"] });
|
||||
}
|
||||
if (req.url === "/ecat/api/open") {
|
||||
if (st.failOpen) return send({ ok: false, log: [st.failOpen] });
|
||||
st.opened = true; st.openSubstr = p.substr;
|
||||
return send({ ok: true, log: ["已连接 STUB"],
|
||||
slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 1, al_status: 0 } });
|
||||
}
|
||||
if (req.url === "/ecat/api/close") { st.opened = false; return send({ ok: true, log: [] }); }
|
||||
if (req.url === "/ecat/api/motor/start") {
|
||||
if (st.failStart) return send({ ok: false, log: [st.failStart] });
|
||||
st.started = true;
|
||||
return send({ ok: true, log: [], period_ms: p.period_ms || 4 });
|
||||
}
|
||||
if (req.url === "/ecat/api/motor/state") {
|
||||
if (!st.opened) return send({ ok: false, log: ["未连接"] });
|
||||
return send({ ok: true, log: [], ts: Date.now() / 1000, wkc: 3, expected_wkc: 3,
|
||||
cycles: 10, period_ms: 4, sent_seq: null, sent_frames: 0, queue_depth: 0,
|
||||
bridge: { valid: true, ack: 0, rx_count: 0, tx_count: 0, drop: 0, tx_result: 0 },
|
||||
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
|
||||
head: { active: 0, boot_active: false, win_base: 0, win_count: 0, global_seq: 0 },
|
||||
motors: [] });
|
||||
}
|
||||
if (req.url === "/ecat/api/motor/rx") return send({ ok: true, log: [], frames: [], remain: 0 });
|
||||
return send({ ok: false, log: ["接口不存在: " + req.url] });
|
||||
});
|
||||
});
|
||||
return new Promise((r) => srv.listen(0, "127.0.0.1", () => r({ st, srv, port: srv.address().port })));
|
||||
}
|
||||
|
||||
const CTX = { getBaseUrl: () => "", recordSlow: () => {}, isTransient: () => false };
|
||||
/** 把 transport 的相对路径 fetch 指到 stub 上 */
|
||||
function bindFetch(port) {
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (u, i) =>
|
||||
real(typeof u === "string" && u.startsWith("/") ? `http://127.0.0.1:${port}${u}` : u, i);
|
||||
return () => { globalThis.fetch = real; };
|
||||
}
|
||||
|
||||
/* ── 1. 未连接时 probe() 应自动 open 再 start ── */
|
||||
section("[1] probe() 自动开网卡 + 自动起循环(四级链条的 2/3/4 级)");
|
||||
{
|
||||
const { st, srv, port } = await makeStub();
|
||||
const un = bindFetch(port);
|
||||
const tp = createEcatTransport(CTX);
|
||||
await tp.probe();
|
||||
un(); srv.close();
|
||||
check("先探 status 发现未连接", st.calls[0] === "/ecat/api/status", st.calls[0]);
|
||||
check("自动调用 /api/open(代替用户去点连接按钮)", st.calls.includes("/ecat/api/open"));
|
||||
check("open 用默认网卡子串 I226", st.openSubstr === "I226", String(st.openSubstr));
|
||||
check("随后自动调用 /api/motor/start(进 OP + 起后台循环)", st.started === true);
|
||||
check("open 在 start 之前", st.calls.indexOf("/ecat/api/open") < st.calls.indexOf("/ecat/api/motor/start"));
|
||||
check("periodMs 由后端回报写入", tp.debug().periodMs === 4, String(tp.debug().periodMs));
|
||||
}
|
||||
|
||||
/* ── 2. 已连接时不要重复 open(api_open 会 close 旧 master,反复调会重置总线)── */
|
||||
section("[2] 已连接则不重复 open(幂等)");
|
||||
{
|
||||
const { st, srv, port } = await makeStub();
|
||||
const un = bindFetch(port);
|
||||
const tp = createEcatTransport(CTX);
|
||||
await tp.probe();
|
||||
const opensAfterFirst = st.calls.filter((c) => c === "/ecat/api/open").length;
|
||||
await tp.probe(); // 第二次探测
|
||||
const opensTotal = st.calls.filter((c) => c === "/ecat/api/open").length;
|
||||
un(); srv.close();
|
||||
check("首次 probe 调了一次 open", opensAfterFirst === 1, String(opensAfterFirst));
|
||||
check("已连接后再 probe 不再 open(避免反复重置总线)",
|
||||
opensTotal === 1, `共 ${opensTotal} 次`);
|
||||
}
|
||||
|
||||
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
|
||||
section("[3] 打不开网卡时的错误语义");
|
||||
{
|
||||
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I226' 的适配器" });
|
||||
const un = bindFetch(port);
|
||||
const tp = createEcatTransport(CTX);
|
||||
let err = null;
|
||||
try { await tp.probe(); } catch (e) { err = e; }
|
||||
un(); srv.close();
|
||||
check("probe 抛错(不静默成功)", err !== null);
|
||||
check("错误消息带候选网卡线索", err && /适配器/.test(err.message), err && err.message);
|
||||
check("不标 transient(外部条件不满足,不是瞬时抖动)", err && err.transient === false,
|
||||
err && String(err.transient));
|
||||
}
|
||||
|
||||
/* ── 4. 进 OP 失败(板子邮箱楔死那类)→ 原因要原样带上来 ── */
|
||||
section("[4] 进 OP 失败时原因可读");
|
||||
{
|
||||
const { srv, port } = await makeStub({
|
||||
failStart: "config_map 失败: ConfigMapError —— SM0/SM1 配置正常却持续失败=SSC 邮箱地址错位楔死" });
|
||||
const un = bindFetch(port);
|
||||
const tp = createEcatTransport(CTX);
|
||||
let err = null;
|
||||
try { await tp.probe(); } catch (e) { err = e; }
|
||||
un(); srv.close();
|
||||
check("probe 抛错", err !== null);
|
||||
check("错误里保留楔死关键词(供上层归类成'需复位 MCU')",
|
||||
err && /楔死/.test(err.message), err && err.message.slice(0, 80));
|
||||
}
|
||||
|
||||
/* ── 5. 换网卡:立即 close 并用新子串重开 ── */
|
||||
section("[5] 换网卡后用新子串重连");
|
||||
{
|
||||
const { st, srv, port } = await makeStub();
|
||||
const un = bindFetch(port);
|
||||
const tp = createEcatTransport(CTX);
|
||||
await tp.probe();
|
||||
const changed = await tp.setAdapter("X710");
|
||||
check("setAdapter 返回 true(确实变了)", changed === true);
|
||||
check("换网卡会 close 旧连接", st.calls.includes("/ecat/api/close"));
|
||||
await tp.probe();
|
||||
un(); srv.close();
|
||||
check("重新 open 用的是新子串", st.openSubstr === "X710", String(st.openSubstr));
|
||||
check("同值 setAdapter 不重连", (await tp.setAdapter("X710")) === false);
|
||||
check("网卡子串已持久化", _ls.get("dcu_ecat_adapter") === "X710", _ls.get("dcu_ecat_adapter"));
|
||||
check("debug() 暴露当前网卡", tp.debug().adapter === "X710");
|
||||
}
|
||||
|
||||
console.log(out.join("\n"));
|
||||
console.log(`\n${"=".repeat(56)}`);
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* test_motor_refresh.mjs — 刷新速度「每链路一份」的自测(Node,无浏览器、无服务)
|
||||
*
|
||||
* 跑法:node scripts/test_motor_refresh.mjs
|
||||
*
|
||||
* 测什么:**真的 import 生产代码** js/motor/refresh-rate.js。
|
||||
* 这里唯一值得测的是"值会不会串到另一条链路上" —— 那是共用单个变量时
|
||||
* 会出的 bug(ECAT 的 50ms 带到 WiFi 上会让 HTTP 请求排队卡顿),
|
||||
* 也是旧数据迁移最容易搞错的地方。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1 默认值:wifi 250 / ecat 50,且 defaultRefreshMap 每次给新对象
|
||||
* 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 1)
|
||||
* 3 旧数据迁移:单个 refreshRate → 只进 wifi,ecat 保持默认
|
||||
* 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值
|
||||
* 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义)
|
||||
* 6 不限速谓词:1ms 档要关掉卡顿黄标与理论 Hz 显示(1ms ≠ 1000Hz)
|
||||
*/
|
||||
import {
|
||||
REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP,
|
||||
UNTHROTTLED_MAX_MS, minRefreshFor, isUnthrottled,
|
||||
defaultRefreshMap, normalizeRefreshMap, pickRefresh
|
||||
} from "../js/motor/refresh-rate.js";
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const out = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { pass++; out.push(` \u2713 ${name}`); }
|
||||
else { fail++; out.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); }
|
||||
}
|
||||
function section(t) { out.push(`\n${t}`); }
|
||||
|
||||
/* ── 1. 默认值 ── */
|
||||
section("[1] 每链路默认值");
|
||||
check("wifi 默认 250ms(无线 + ESP32 HTTP 往返)", REFRESH_DEFAULTS.wifi === 250,
|
||||
`实际 ${REFRESH_DEFAULTS.wifi}`);
|
||||
check("ecat 默认 50ms(本机快照,实测 p90≈22ms)", REFRESH_DEFAULTS.ecat === 50,
|
||||
`实际 ${REFRESH_DEFAULTS.ecat}`);
|
||||
check("两个默认值不相等(否则这次改动没意义)", REFRESH_DEFAULTS.wifi !== REFRESH_DEFAULTS.ecat);
|
||||
check("上限 5000ms 与 #refreshRate 的 max 一致", REFRESH_MAX_MS === 5000);
|
||||
const m1 = defaultRefreshMap(), m2 = defaultRefreshMap();
|
||||
m1.wifi = 999;
|
||||
check("defaultRefreshMap 每次返回新对象(改一份不污染另一份)", m2.wifi === 250);
|
||||
check("defaultRefreshMap 不污染 REFRESH_DEFAULTS 常量", REFRESH_DEFAULTS.wifi === 250);
|
||||
|
||||
/* ── 1b. 下限也按链路 ── */
|
||||
section("[1b] 每链路最小间隔");
|
||||
check("ecat 下限 1ms(读本机 PDO 快照,无总线代价)", REFRESH_MIN_BY_TP.ecat === 1,
|
||||
`实际 ${REFRESH_MIN_BY_TP.ecat}`);
|
||||
check("wifi 下限仍 50ms(每拍是一次无线 HTTP 往返,再快只会积压)",
|
||||
REFRESH_MIN_BY_TP.wifi === 50, `实际 ${REFRESH_MIN_BY_TP.wifi}`);
|
||||
check("minRefreshFor('ecat') = 1", minRefreshFor("ecat") === 1);
|
||||
check("minRefreshFor('wifi') = 50", minRefreshFor("wifi") === 50);
|
||||
check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope") === REFRESH_MIN_MS);
|
||||
|
||||
/* ── 2. 归一 + 钳制(下限按链路) ── */
|
||||
section("[2] 归一与钳制(下限按链路)");
|
||||
const norm = normalizeRefreshMap({ wifi: 400, ecat: 20 });
|
||||
check("正常值原样保留", norm.wifi === 400);
|
||||
check("ecat 的 20ms 合法(下限 1ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`);
|
||||
check("ecat 可以设到 1ms", normalizeRefreshMap({ ecat: 1 }).ecat === 1);
|
||||
check("ecat 的 0/负数被抬到 1ms", normalizeRefreshMap({ ecat: 0 }).ecat === 1);
|
||||
check("ecat 的 -5 被抬到 1ms", normalizeRefreshMap({ ecat: -5 }).ecat === 1);
|
||||
check("wifi 的 1ms 仍被抬到 50(无线往返撑不住)",
|
||||
normalizeRefreshMap({ wifi: 1 }).wifi === 50, `实际 ${normalizeRefreshMap({ wifi: 1 }).wifi}`);
|
||||
check("wifi 的 20ms 也被抬到 50", normalizeRefreshMap({ wifi: 20 }).wifi === 50);
|
||||
check("高于上限被压到 5000", normalizeRefreshMap({ wifi: 99999 }).wifi === REFRESH_MAX_MS);
|
||||
check("ecat 高于上限同样压到 5000", normalizeRefreshMap({ ecat: 99999 }).ecat === REFRESH_MAX_MS);
|
||||
check("字符串数字可用(localStorage 里存的是字符串)",
|
||||
normalizeRefreshMap({ wifi: "300" }).wifi === 300);
|
||||
check("ecat 字符串 \"1\" 可用", normalizeRefreshMap({ ecat: "1" }).ecat === 1);
|
||||
const dirty = normalizeRefreshMap({ wifi: "abc", ecat: null });
|
||||
check("脏值回落该链路默认(wifi)", dirty.wifi === 250, `实际 ${dirty.wifi}`);
|
||||
check("脏值回落该链路默认(ecat)", dirty.ecat === 50, `实际 ${dirty.ecat}`);
|
||||
check("未知链路 key 被丢弃(不在 localStorage 里攒垃圾)",
|
||||
normalizeRefreshMap({ wifi: 300, bogus: 111 }).bogus === undefined);
|
||||
const empty = normalizeRefreshMap(undefined, undefined);
|
||||
check("完全没有历史数据 → 全用默认", empty.wifi === 250 && empty.ecat === 50);
|
||||
check("传入非对象(数组/字符串)不炸且回落默认",
|
||||
normalizeRefreshMap("garbage").wifi === 250);
|
||||
|
||||
/* ── 3. 旧数据迁移 ──
|
||||
* 旧版只存单个 refreshRate(那时只有 WiFi 链路,值是为无线调的)。
|
||||
* 迁到 wifi 才对;套到 ecat 上等于让用户平白继承一个不合适的值。 */
|
||||
section("[3] 旧版单值迁移");
|
||||
const mig = normalizeRefreshMap(undefined, 500);
|
||||
check("旧单值迁进 wifi", mig.wifi === 500, `实际 ${mig.wifi}`);
|
||||
check("ecat **不**继承旧单值,保持 50ms 默认", mig.ecat === 50, `实际 ${mig.ecat}`);
|
||||
const migClamp = normalizeRefreshMap(undefined, "10");
|
||||
check("旧单值也走 wifi 的下限钳制(10 → 50)", migClamp.wifi === 50);
|
||||
const both = normalizeRefreshMap({ wifi: 300, ecat: 80 }, 500);
|
||||
check("已有新格式时忽略旧字段(不被旧值覆盖)", both.wifi === 300 && both.ecat === 80);
|
||||
|
||||
/* ── 4. 取值 ── */
|
||||
section("[4] pickRefresh 取当前链路的生效值");
|
||||
check("正常取值", pickRefresh({ wifi: 300, ecat: 60 }, "ecat") === 60);
|
||||
check("缺该链路 → 回落该链路默认(不是另一条的值)",
|
||||
pickRefresh({ wifi: 300 }, "ecat") === 50);
|
||||
check("map 为 null 不炸", pickRefresh(null, "wifi") === 250);
|
||||
check("ecat 取出 1ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 1 }, "ecat") === 1);
|
||||
check("wifi 取出 1ms 被抬到 50", pickRefresh({ wifi: 1 }, "wifi") === 50);
|
||||
check("未知链路 id 回落到 wifi 默认(防御,不应出现)",
|
||||
pickRefresh({}, "somethingnew") === 250);
|
||||
|
||||
/* ── 5. 隔离:本次改动的核心 ── */
|
||||
section("[5] 两条链路互不污染");
|
||||
const map = defaultRefreshMap();
|
||||
/* 模拟:用户在 ECAT 下把刷新速度拉到 1ms(applyRefreshRate 只写当前链路那一份) */
|
||||
map.ecat = 1;
|
||||
check("改 ecat 到 1ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250);
|
||||
/* 模拟:切回 WiFi,装载的应是 wifi 那一份 */
|
||||
check("切回 wifi 装载 250 而非 1", pickRefresh(map, "wifi") === 250);
|
||||
/* 模拟:在 WiFi 下改成 1000ms,再切去 ECAT */
|
||||
map.wifi = 1000;
|
||||
check("改 wifi 后 ecat 仍是 1ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 1);
|
||||
check("切到 ecat 装载 1 而非 1000", pickRefresh(map, "ecat") === 1);
|
||||
/* 存盘 → 读回一圈后仍各自独立(saveUiState/loadUiState 的往返) */
|
||||
const roundTrip = normalizeRefreshMap(JSON.parse(JSON.stringify(map)));
|
||||
check("存盘再读回:wifi 保留 1000", roundTrip.wifi === 1000);
|
||||
check("存盘再读回:ecat 保留 1ms(不被 wifi 的下限 50 顺手抬高)",
|
||||
roundTrip.ecat === 1, `实际 ${roundTrip.ecat}`);
|
||||
|
||||
/* ── 6. 不限速谓词 ──
|
||||
* 1ms 档下节拍源必然比请求快,每拍都撞上"上一拍还在飞"。那是预期行为,
|
||||
* 所以要据此关掉卡顿黄标;同时 1ms 硬算成 1000Hz 是谎报(浏览器实测约 125Hz)。 */
|
||||
section("[6] 不限速谓词(关黄标 + 不谎报 Hz)");
|
||||
check("阈值 20ms(50Hz):比浏览器实测每拍耗时(~8ms)宽裕,又远小于常用档",
|
||||
UNTHROTTLED_MAX_MS === 20, `实际 ${UNTHROTTLED_MAX_MS}`);
|
||||
check("1ms 属不限速", isUnthrottled(1) === true);
|
||||
check("10ms 属不限速", isUnthrottled(10) === true);
|
||||
check("19ms 属不限速(边界内)", isUnthrottled(19) === true);
|
||||
check("20ms 不属不限速(边界外)", isUnthrottled(20) === false);
|
||||
check("ECAT 默认 50ms 是正常限速档(黄标与 Hz 显示都保留)",
|
||||
isUnthrottled(REFRESH_DEFAULTS.ecat) === false);
|
||||
check("WiFi 默认 250ms 是正常限速档", isUnthrottled(REFRESH_DEFAULTS.wifi) === false);
|
||||
check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)",
|
||||
isUnthrottled(REFRESH_MIN_BY_TP.wifi) === false);
|
||||
check("ECAT 下限 1ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
|
||||
check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false);
|
||||
|
||||
console.log(out.join("\n"));
|
||||
console.log("\n" + "=".repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* test_motor_transport.mjs — 电机页双传输的端到端自测(Node,无浏览器)
|
||||
*
|
||||
* 跑法(先确保本地页面服务器在跑,EtherCAT 部分还需 tools/ecat 服务在跑):
|
||||
* node scripts/test_motor_transport.mjs [--port 8080]
|
||||
*
|
||||
* 测什么:**真的 import 生产代码** js/motor/transport-*.js,用 Node 的
|
||||
* 全局 fetch 打真实的 /ecat/api/* 反向代理 → 真实 ecat_server → 真实从站。
|
||||
* 不 mock 传输实现本身,否则测不到"契约对不对"这唯一值得测的东西。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1 两个传输都实现了 transport.js 声明的完整接口(漏一个方法 = 运行时崩)
|
||||
* 2 WiFi 的二进制 /motor/state 解析(喂构造好的字节,不需要真设备)
|
||||
* 3 能力位:只有 WiFi 支持 OTA / 周期同步
|
||||
* 4 EtherCAT 快照 → WiFi motors[] 契约(字段名/类型逐个核)
|
||||
* 5 seq 不动 → online 转 false(快照永不删除历史槽,必须靠 seq 判活)
|
||||
* 6 CONFIG(0x0101) 从批量接收区累积并回填到 motors[].config_data
|
||||
* 7 版本(0x00FF) 累积且 seq 递增(上层靠 seq 增长判新应答)
|
||||
* 8 节点 ID 应答取走即清
|
||||
* 9 fetchFeedback:无数据的字段必须是 null 而不是 0(监控 UI 约定)
|
||||
* 10 代理不可用时报错信封可读(不是裸 500/CORS 报错)
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dir, "..");
|
||||
|
||||
const argPort = (() => {
|
||||
const i = process.argv.indexOf("--port");
|
||||
return (i > 0 && process.argv[i + 1]) ? Number(process.argv[i + 1]) : 8080;
|
||||
})();
|
||||
const BASE = `http://127.0.0.1:${argPort}`;
|
||||
|
||||
/* transport-ecat.js 用相对路径 "/ecat/api/..." 调 fetch;Node 里没有
|
||||
* document.baseURI,所以把相对路径补成绝对地址后转给真实 fetch。
|
||||
* 这层只改 URL 前缀,不碰请求体/方法/响应,被测逻辑仍是生产代码。 */
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (url, init) =>
|
||||
realFetch(typeof url === "string" && url.startsWith("/") ? BASE + url : url, init);
|
||||
if (typeof globalThis.performance === "undefined") {
|
||||
globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 };
|
||||
}
|
||||
|
||||
const { createWifiTransport } = await import("../js/motor/transport-wifi.js");
|
||||
const { createEcatTransport } = await import("../js/motor/transport-ecat.js");
|
||||
|
||||
const CTX = {
|
||||
getBaseUrl: () => "http://127.0.0.1:1", /* WiFi 用例不打真设备 */
|
||||
recordSlow: () => {},
|
||||
isTransient: (e) => /timeout|abort/i.test(String(e && e.message)),
|
||||
};
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const results = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { pass++; results.push(` \u2713 ${name}`); }
|
||||
else { fail++; results.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); }
|
||||
}
|
||||
function section(t) { results.push(`\n${t}`); }
|
||||
|
||||
/* ── 1. 接口完整性 ──
|
||||
* transport.js 头部声明的接口,两个实现都必须齐。少一个方法在浏览器里
|
||||
* 是"点某个按钮才崩",很难发现,所以在这里一次性拦住。 */
|
||||
section("[1] 接口完整性");
|
||||
const REQUIRED = [
|
||||
"id", "label", "supportsOta", "supportsPeriodSync", "sendHint",
|
||||
"probe", "fetchStatus", "describeStatus", "reportedIp",
|
||||
"fetchMotorState", "fetchFeedback", "sendFrames", "sendBurst",
|
||||
"fetchVersionNode", "setPeriod", "teardown",
|
||||
];
|
||||
const wifi = createWifiTransport(CTX);
|
||||
const ecat = createEcatTransport(CTX);
|
||||
for (const tp of [wifi, ecat]) {
|
||||
const missing = REQUIRED.filter((k) => tp[k] === undefined);
|
||||
check(`${tp.id} 实现全部 ${REQUIRED.length} 个接口成员`, missing.length === 0,
|
||||
missing.length ? "缺: " + missing.join(",") : "");
|
||||
}
|
||||
|
||||
/* ── 2. WiFi 二进制解析 ── 构造 header 12B + 一条 76B entry */
|
||||
section("[2] WiFi /motor/state?bin=1 二进制解析");
|
||||
{
|
||||
const buf = new ArrayBuffer(12 + 76);
|
||||
const dv = new DataView(buf); const u8 = new Uint8Array(buf);
|
||||
dv.setUint16(0, 0x4D53, true); // 'MS'
|
||||
dv.setUint8(2, 1); // ver
|
||||
dv.setUint8(3, 0x01); // flags: boot_active
|
||||
dv.setUint32(4, 123, true); // age_ms
|
||||
dv.setUint8(8, 2); // active
|
||||
dv.setUint8(9, 1); // count
|
||||
let o = 12;
|
||||
u8[o] = 5; u8[o + 1] = 2; u8[o + 2] = 1; u8[o + 3] = 32;
|
||||
dv.setUint32(o + 4, 77, true); // rx_count
|
||||
dv.setUint32(o + 8, 999, true); // seq
|
||||
u8[o + 12] = 0xAB; // data[0]
|
||||
dv.setUint32(o + 44, 4, true); // config_rx_count
|
||||
dv.setUint32(o + 48, 6, true); // config_seq
|
||||
u8[o + 52] = 0xCD; // config_data[0]
|
||||
|
||||
/* 让传输的 fetchMotorState 拿到这段字节:临时替换 fetch */
|
||||
const saved = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => buf });
|
||||
const st = await wifi.fetchMotorState();
|
||||
globalThis.fetch = saved;
|
||||
|
||||
check("boot_active 由 flags bit0 解出", st.boot_active === true);
|
||||
check("age_ms/active_count 正确", st.age_ms === 123 && st.active_count === 2,
|
||||
`age=${st.age_ms} active=${st.active_count}`);
|
||||
const m = st.motors[0] || {};
|
||||
check("entry 步长 76B,字段齐全",
|
||||
m.node === 5 && m.ch === 2 && m.online === true && m.dlc === 32 &&
|
||||
m.rx_count === 77 && m.seq === 999, JSON.stringify(m).slice(0, 120));
|
||||
check("data 是 64 字符大写 hex", m.data && m.data.length === 64 && m.data.startsWith("AB"));
|
||||
check("config_data 是 48 字符 hex", m.config_data && m.config_data.length === 48 &&
|
||||
m.config_data.startsWith("CD"));
|
||||
}
|
||||
|
||||
/* ── 3. 能力位 ── */
|
||||
section("[3] 链路能力位");
|
||||
check("WiFi 支持 OTA", wifi.supportsOta === true);
|
||||
check("EtherCAT 不支持 OTA(没有固件上传+relay 通路)", ecat.supportsOta === false);
|
||||
check("WiFi 支持 0xC5 周期同步", wifi.supportsPeriodSync === true);
|
||||
check("EtherCAT 不做周期同步(PDO 周期由后台循环维持)", ecat.supportsPeriodSync === false);
|
||||
check("EtherCAT 不回报设备 IP", ecat.reportedIp({}) === null);
|
||||
|
||||
/* ── 4~9. EtherCAT:打真实代理 + 真实 ecat_server ── */
|
||||
section("[4] EtherCAT 端到端(真实代理 → ecat_server → 从站)");
|
||||
let liveOk = false;
|
||||
try {
|
||||
const st = await ecat.fetchStatus();
|
||||
liveOk = true;
|
||||
const parts = ecat.describeStatus(st);
|
||||
check("能读到从站状态并生成 AL 描述", Array.isArray(parts) && parts.length > 0,
|
||||
JSON.stringify(parts));
|
||||
} catch (e) {
|
||||
results.push(` \u26a0 EtherCAT 服务不可用,跳过 4~9: ${e.message}`);
|
||||
}
|
||||
|
||||
if (liveOk) {
|
||||
const st = await ecat.fetchMotorState();
|
||||
check("返回 WiFi 契约的顶层字段",
|
||||
"have_data" in st && "age_ms" in st && "active_count" in st &&
|
||||
"boot_active" in st && Array.isArray(st.motors));
|
||||
if (st.motors.length) {
|
||||
const m = st.motors[0];
|
||||
const shapeOk =
|
||||
typeof m.node === "number" && typeof m.ch === "number" &&
|
||||
typeof m.online === "boolean" && typeof m.dlc === "number" &&
|
||||
typeof m.rx_count === "number" && typeof m.seq === "number" &&
|
||||
typeof m.data === "string" && typeof m.config_data === "string";
|
||||
check("motors[] 每项字段名/类型与 WiFi 一致", shapeOk, JSON.stringify(m).slice(0, 150));
|
||||
check("data 是 64 字符 hex(32B STATUS)", m.data.length === 64, `len=${m.data.length}`);
|
||||
results.push(` \u2139 在线电机: ` + st.motors
|
||||
.map((x) => `N${x.node}@CH${x.ch}${x.online ? "" : "(离线)"}`).join(" "));
|
||||
} else {
|
||||
results.push(" \u2139 总线上无电机槽,跳过 motors[] 字段核对");
|
||||
}
|
||||
|
||||
section("[5] 反馈:缺失字段必须是 null 而不是 0");
|
||||
const fb = await ecat.fetchFeedback();
|
||||
check("channels 长度为 3", fb.channels && fb.channels.length === 3);
|
||||
const c0 = fb.channels[0];
|
||||
check("tec/rec 为 null(过程数据没有这两个计数)",
|
||||
c0.tec === null && c0.rec === null, `tec=${c0.tec} rec=${c0.rec}`);
|
||||
check("valid 反映 wkc==expected_wkc", typeof c0.valid === "boolean");
|
||||
|
||||
section("[6] 版本/节点ID 累积器");
|
||||
check("未收到应答时 fetchVersionNode 返回 null",
|
||||
(await ecat.fetchVersionNode(7)) === null);
|
||||
check("takeNodeIdReport 存在且未收到时返回 null",
|
||||
typeof ecat.takeNodeIdReport === "function" && ecat.takeNodeIdReport(7) === null);
|
||||
const dbg = ecat.debug();
|
||||
check("debug() 暴露 PDO 周期与槽位统计",
|
||||
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 7 &&
|
||||
dbg.slotCount === 27 && dbg.scanCycles === 4, JSON.stringify(dbg && {
|
||||
periodMs: dbg.periodMs, slots: dbg.slots }));
|
||||
}
|
||||
|
||||
/* ── 10. 代理错误信封可读 ── 指向一个没人监听的端口 */
|
||||
section("[7] 代理不可用时的错误信封");
|
||||
{
|
||||
const dead = createEcatTransport(CTX);
|
||||
const saved = globalThis.fetch;
|
||||
/* 打一个必定没人监听的端口,模拟"EtherCAT 服务没启动" */
|
||||
globalThis.fetch = (url, init) =>
|
||||
realFetch(typeof url === "string" && url.startsWith("/")
|
||||
? "http://127.0.0.1:1" + url : url, init);
|
||||
let msg = "";
|
||||
try { await dead.fetchMotorState(); } catch (e) { msg = e.message || ""; }
|
||||
globalThis.fetch = saved;
|
||||
check("服务不可用时抛出可读错误(不是裸 CORS/undefined)", msg.length > 0, msg.slice(0, 90));
|
||||
check("fetchVersionNode 纯本地不打网络", (await dead.fetchVersionNode(1)) === null);
|
||||
}
|
||||
|
||||
/* ── 8. 快照 → WiFi 契约的翻译逻辑 ──
|
||||
* 这几项是本次改动的核心,且**只能**用构造快照来测:
|
||||
* 真实总线此刻可能没有 CAN 流量(global_seq=0 / win_count=0),
|
||||
* 那样 motors[] 永远是空的,seq 判活、CONFIG 回填、版本 seq 递增
|
||||
* 这些恰恰最容易写错的分支就一条都覆盖不到。
|
||||
* 这里仍然跑的是生产代码 transport-ecat.js,只是替换它看到的 HTTP 响应。 */
|
||||
section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
{
|
||||
const tp = createEcatTransport(CTX);
|
||||
const saved = globalThis.fetch;
|
||||
|
||||
/* 每次 fetch 按路由返回预置内容;motor/rx 只在第一次给帧,之后给空。 */
|
||||
let snap = null;
|
||||
let rxFrames = [];
|
||||
const jsonResp = (obj) => ({
|
||||
ok: true, status: 200, text: async () => JSON.stringify(obj),
|
||||
});
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/motor/rx")) {
|
||||
const f = rxFrames; rxFrames = [];
|
||||
return jsonResp({ ok: true, log: [], frames: f, remain: 0 });
|
||||
}
|
||||
if (u.includes("/motor/state")) return jsonResp(Object.assign({ ok: true, log: [] }, snap));
|
||||
if (u.includes("/motor/start")) return jsonResp({ ok: true, log: [], period_ms: 4 });
|
||||
return jsonResp({ ok: true, log: [] });
|
||||
};
|
||||
|
||||
const mkSnap = (motors, extra) => Object.assign({
|
||||
ts: Date.now() / 1000, wkc: 3, expected_wkc: 3, cycles: 100, period_ms: 4,
|
||||
sent_seq: null, sent_frames: 0, queue_depth: 0,
|
||||
bridge: { valid: true, ack: 0, rx_count: 1000, tx_count: 5, drop: 2, tx_result: 0 },
|
||||
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
|
||||
head: { active: motors.length, boot_active: false, win_base: 0, win_count: 7, global_seq: 42 },
|
||||
motors,
|
||||
}, extra || {});
|
||||
|
||||
const STATUS_HEX = "AB".repeat(32); /* 32B STATUS */
|
||||
const CONFIG_HEX = "5A".repeat(24); /* 24B CONFIG(0x0101) */
|
||||
|
||||
/* (a) 首次:seq 在动 → online=true,rx_count 从 1 起累 */
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 500, data: STATUS_HEX }]);
|
||||
let st = await tp.fetchMotorState();
|
||||
let m = st.motors[0];
|
||||
check("(a) 从站 online + seq 在动 → online=true", m.online === true);
|
||||
check("(a) rx_count 本地累计为 1", m.rx_count === 1, `rx=${m.rx_count}`);
|
||||
check("(a) active_count 取自 head.active", st.active_count === 1);
|
||||
|
||||
/* (b) seq 递增 → rx_count 继续累加 */
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 501, data: STATUS_HEX }]);
|
||||
st = await tp.fetchMotorState();
|
||||
check("(b) seq 递增 → rx_count 累加到 2", st.motors[0].rx_count === 2,
|
||||
`rx=${st.motors[0].rx_count}`);
|
||||
|
||||
/* (c) CONFIG 从批量接收区累积 → 回填 motors[].config_data。
|
||||
* 这是 WiFi 有、EtherCAT 没有的字段,回填不了就等于 CONFIG 面板永久空白。 */
|
||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x0101, data: CONFIG_HEX }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 502, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
st = await tp.fetchMotorState();
|
||||
check("(c) CONFIG(0x0101) 回填到 config_data(48 hex)",
|
||||
st.motors[0].config_data === CONFIG_HEX,
|
||||
`len=${st.motors[0].config_data.length}`);
|
||||
|
||||
/* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答" */
|
||||
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
|
||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 503, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
await tp.fetchMotorState();
|
||||
const v1 = await tp.fetchVersionNode(3);
|
||||
check("(d) 版本应答被累积", v1 && v1.node === 3 && v1.version === VER_HEX);
|
||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 504, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
await tp.fetchMotorState();
|
||||
const v2 = await tp.fetchVersionNode(3);
|
||||
check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2.seq === v1.seq + 1,
|
||||
`${v1.seq} → ${v2.seq}`);
|
||||
|
||||
/* (e) 节点 ID 应答:取走即清 */
|
||||
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 505, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
await tp.fetchMotorState();
|
||||
check("(e) 节点 ID 应答可取出", tp.takeNodeIdReport(3) === "0700");
|
||||
check("(e) 取走即清(第二次为 null)", tp.takeNodeIdReport(3) === null);
|
||||
|
||||
/* (f) STATUS 已由状态窗口聚合,批量区里的 0x0100 必须跳过(否则重复解一遍) */
|
||||
rxFrames = [{ ch: 1, dlc: 32, valid: true, id: (3 << 16) | 0x0100, data: "FF".repeat(32) }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
st = await tp.fetchMotorState();
|
||||
check("(f) 批量区的 STATUS(0x0100) 不污染 config_data",
|
||||
st.motors[0].config_data === CONFIG_HEX);
|
||||
check("(f) data 仍来自状态窗口", st.motors[0].data === STATUS_HEX);
|
||||
|
||||
/* (g) xrx 长帧快照也要分派:CONFIG/版本是 DLC24,短快照会截断 */
|
||||
const CFG2 = "77".repeat(24);
|
||||
snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 32, seq: 10, data: STATUS_HEX }],
|
||||
{ xrx: { ch: 1, dlc: 24, id: (4 << 16) | 0x0101, data: CFG2 } });
|
||||
st = await tp.fetchMotorState();
|
||||
check("(g) xrx 长帧快照的 CONFIG 也回填",
|
||||
st.motors.find((x) => x.node === 4).config_data === CFG2);
|
||||
|
||||
/* (h) 关键:seq 停止不动超过阈值 → online 转 false。
|
||||
* ecat_server 的快照**永不删除**见过的槽(电机拔了也还在),
|
||||
* 只认从站的 online 标志会让界面上永远挂着一台不存在的电机。 */
|
||||
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX };
|
||||
snap = mkSnap([stale]);
|
||||
st = await tp.fetchMotorState();
|
||||
check("(h) seq 未变但仍在阈值内 → 暂时仍算在线",
|
||||
st.motors.find((x) => x.node === 3).online === true);
|
||||
/* 把快照 ts 往前推 3 秒不管用(判活用的是本地时间),
|
||||
* 所以真正等过 STALE_OFFLINE_MS(2000ms) —— 这是唯一忠实的验证方式。 */
|
||||
await new Promise((r) => setTimeout(r, 2100));
|
||||
snap = mkSnap([stale]);
|
||||
st = await tp.fetchMotorState();
|
||||
check("(h) seq 停超 2s → online 转 false(快照不删历史槽,必须靠 seq 判活)",
|
||||
st.motors.find((x) => x.node === 3).online === false,
|
||||
`online=${st.motors.find((x) => x.node === 3).online}`);
|
||||
|
||||
/* (i) 通信异常(wkc != expected)→ feedback 全部 null,而不是 0 */
|
||||
snap = mkSnap([stale], { wkc: 0, expected_wkc: 3 });
|
||||
await tp.fetchMotorState();
|
||||
const fb2 = await tp.fetchFeedback();
|
||||
check("(i) wkc 不匹配 → valid=false 且 rx/tx 为 null(不能显示成 0)",
|
||||
fb2.channels[0].valid === false && fb2.channels[0].rx === null,
|
||||
JSON.stringify(fb2.channels[0]));
|
||||
|
||||
/* (i2) 单槽刷新周期:状态栏必须显示"这台电机的数据多久更新一次",
|
||||
* 而不是只显示 PDO 周期。窗口自适应后两者通常相等(在线电机每周期
|
||||
* 都刷),但电机分散在多个窗口时 slot_period_ms 会是 period_ms 的倍数,
|
||||
* 这时只报 PDO 4ms 就是谎报 —— 实际数据是 16ms 才换一次。 */
|
||||
await tp.fetchStatus().catch(() => {}); /* 让 periodMs 就位(start 回报 4) */
|
||||
snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 4 });
|
||||
await tp.fetchMotorState();
|
||||
let parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
||||
check("(i2) 单槽周期 == PDO 周期时只显示一个(不啰嗦)",
|
||||
parts.some((p) => p === "PDO 4ms") &&
|
||||
!parts.some((p) => p.includes("单槽")),
|
||||
JSON.stringify(parts));
|
||||
snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 16 });
|
||||
await tp.fetchMotorState();
|
||||
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
||||
check("(i2) 单槽周期 != PDO 周期时两者都显示(不谎报数据新鲜度)",
|
||||
parts.some((p) => p.includes("PDO 4ms") && p.includes("单槽 16ms")),
|
||||
JSON.stringify(parts));
|
||||
check("(i2) debug() 暴露 slotPeriodMs 与 winBases 便于现场排查",
|
||||
tp.debug().slotPeriodMs === 16,
|
||||
JSON.stringify({ slotPeriodMs: tp.debug().slotPeriodMs }));
|
||||
|
||||
/* (i3) 自旋节拍源:它占满一个 CPU 核,属于用户能感知的代价,
|
||||
* 必须在状态栏可见,且 setSpin 要把选择原样带给后端。 */
|
||||
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true });
|
||||
await tp.fetchMotorState();
|
||||
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
||||
check("(i3) 自旋生效时状态栏标注占核(不能让用户猜 CPU 去哪了)",
|
||||
parts.some((p) => p.includes("自旋") && p.includes("1 核")),
|
||||
JSON.stringify(parts));
|
||||
|
||||
/* (i3b) 钉到 E 核时要写出核号 —— 用户看到"占 1 核"会担心抢了性能核,
|
||||
* 显示实际落在哪些逻辑处理器才能证明它是能效核。掩码是 64 位,
|
||||
* JS 移位只有 32 位,所以高低两段分别取,这里专门覆盖跨 32 位的情况。 */
|
||||
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
||||
ecore_mask: 0xfff0000 });
|
||||
await tp.fetchMotorState();
|
||||
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
||||
check("(i3b) 钉到 E 核时列出核号区间(16-27,不逐个罗列挤掉从站名)",
|
||||
parts.some((p) => p.includes("E 核 16-27")),
|
||||
JSON.stringify(parts));
|
||||
check("(i3b) 已列出 E 核号时不再写笼统的『占 1 核』",
|
||||
!parts.some((p) => p.includes("占 1 核")), JSON.stringify(parts));
|
||||
check("(i3b) debug() 暴露 ecores 列表便于现场核对",
|
||||
tp.debug().ecores.length === 12 && tp.debug().ecores[0] === 16 &&
|
||||
tp.debug().ecores[11] === 27,
|
||||
JSON.stringify(tp.debug().ecores));
|
||||
/* 高 32 位之外的核(>31)不能丢:用 2^40 验证跨段位 */
|
||||
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
||||
ecore_mask: 1 + 2 ** 40 });
|
||||
await tp.fetchMotorState();
|
||||
check("(i3b) 掩码跨 32 位时高段核号不丢(0 与 40)",
|
||||
JSON.stringify(tp.debug().ecores) === JSON.stringify([0, 40]),
|
||||
JSON.stringify(tp.debug().ecores));
|
||||
/* 掩码为 0 = 探测失败/对称 CPU,要退回笼统措辞而不是显示空列表 */
|
||||
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
||||
ecore_mask: 0 });
|
||||
await tp.fetchMotorState();
|
||||
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
||||
check("(i3b) 没钉核时退回『占 1 核』而不是列出空的 E 核",
|
||||
parts.some((p) => p.includes("占 1 核") && !p.includes("E 核")),
|
||||
JSON.stringify(parts));
|
||||
/* 不连续的核(混合架构常见:E 核未必连成一段)要压成多段而不是硬凑一个区间 */
|
||||
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
||||
ecore_mask: (1 << 16) | (1 << 17) | (1 << 20) | (1 << 24) });
|
||||
await tp.fetchMotorState();
|
||||
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
||||
check("(i3b) 不连续核压成 16-17,20,24(不硬凑区间)",
|
||||
parts.some((p) => p.includes("16-17,20,24")), JSON.stringify(parts));
|
||||
|
||||
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: false });
|
||||
await tp.fetchMotorState();
|
||||
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
||||
check("(i3) 未自旋时不标注(避免长期挂着无关提示)",
|
||||
!parts.some((p) => p.includes("自旋")), JSON.stringify(parts));
|
||||
|
||||
let spinBody = null, spinHits = 0, startHits = 0;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/motor/spin")) {
|
||||
spinHits++; spinBody = JSON.parse(init.body);
|
||||
return jsonResp({ ok: true, log: [], spin: spinBody.spin !== false, period_ms: 1 });
|
||||
}
|
||||
if (u.includes("/motor/start")) {
|
||||
startHits++;
|
||||
return jsonResp({ ok: true, log: [], period_ms: 1, spin: true });
|
||||
}
|
||||
return jsonResp({ ok: true, log: [] });
|
||||
};
|
||||
/* 先走一遍 probe,让 ensureStarted 把 periodMs 填上 */
|
||||
await tp.probe();
|
||||
const startBefore = startHits;
|
||||
check("(i3) setSpin(false) 把 spin:false 带给后端并采纳回报值",
|
||||
(await tp.setSpin(false)) === false && spinBody.spin === false,
|
||||
JSON.stringify(spinBody));
|
||||
await tp.setSpin(true);
|
||||
check("(i3) setSpin(true) 透传 spin:true", spinBody.spin === true,
|
||||
JSON.stringify(spinBody));
|
||||
/* 关键:切节拍源绝不能走 motor/start —— 那条路由会先停 PDO 循环,
|
||||
* 停手超过从站 1000ms 看门狗就把从站踢出 OP,重进还可能楔死邮箱。 */
|
||||
check("(i3) setSpin 走 /motor/spin,不重启 PDO 循环(不碰 motor/start)",
|
||||
spinHits >= 2 && startHits === startBefore,
|
||||
JSON.stringify({ spinHits, startHits, startBefore }));
|
||||
await tp.setSpin(null);
|
||||
check("(i3) setSpin(null) 发空请求体,交回后端自动决定",
|
||||
spinBody && !("spin" in spinBody), JSON.stringify(spinBody));
|
||||
|
||||
/* (j) 发送:多帧一次提交(同一 PDO 周期 / 同一 seq 沿)→ 逐帧 results */
|
||||
let sentBody = null;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/motor/send")) {
|
||||
sentBody = JSON.parse(init.body);
|
||||
return jsonResp({ ok: true, log: [], queued: sentBody.frames.length, batch_max_bytes: 332 });
|
||||
}
|
||||
return jsonResp({ ok: true, log: [] });
|
||||
};
|
||||
const res = await tp.sendFrames(
|
||||
[{ id: 0x30200, dlc: 32, data: "11".repeat(32) },
|
||||
{ id: 0x40200, dlc: 32, data: "22".repeat(32) }], { ch: 1 });
|
||||
check("(j) 多帧一次提交(同一 seq 沿)", sentBody && sentBody.frames.length === 2,
|
||||
JSON.stringify(sentBody && sentBody.frames.map((f) => f.id)));
|
||||
check("(j) 每帧带上批级通道", sentBody.frames.every((f) => f.ch === 1));
|
||||
check("(j) 返回逐帧 results(长度与请求一致)",
|
||||
Array.isArray(res) && res.length === 2 && res.every((r) => r.status === "ok"));
|
||||
|
||||
/* (k) 后端 ok:false → 每帧标错,让上层流控能看到 */
|
||||
globalThis.fetch = async () => jsonResp({ ok: false, log: ["未连接"] });
|
||||
const bad = await tp.sendFrames([{ id: 0x30200, dlc: 8, data: "00".repeat(8) }], { ch: 0 });
|
||||
check("(k) 后端 ok:false → results 标 error 且带原因",
|
||||
bad[0].status === "error" && /未连接/.test(bad[0].desc || ""), JSON.stringify(bad[0]));
|
||||
|
||||
globalThis.fetch = saved;
|
||||
}
|
||||
|
||||
console.log(results.join("\n"));
|
||||
console.log(`\n${"=".repeat(56)}`);
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user