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

374 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* test_ecat_autostart.mjs — EtherCAT 自动接入链条自测(Node,无浏览器)
*
* 跑法:node scripts/test_ecat_autostart.mjs [--port 2625]
*
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
* 四级链条的每一级都被正确驱动,且失败原因可读。
*
* 为什么要 stub 后端:板子当前 SSC 邮箱楔死(config_map 失败,ecat_native.c 里
* 同样注明必须断电复位 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");
/* 总线许可判定与自动重启预算的唯一实现(与浏览器里同一份代码,不是复制品) */
const ebus = await import("../js/common/ecat-bus.js");
/* OTA 负载页在浏览器里会摸 window/localStorage;Node 里给最小壳,只为 import 不炸 */
globalThis.window = globalThis.window || { addLog: () => {} };
const { decidePdoCycle } = await import("../js/ota/ecat_load.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_native 的状态机 ──
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数与 body.reason。
* /api/native/health:bus_open/loop 跟 started 联动,供「已健康则跳过 start」快路径。
* opts.health = 额外/覆盖的 health 字段(证明类判据要能喂各种形状,
* 包括"没有 dc 字段的旧 exe"和 recovering=1)
* opts.stayDown= motor/start 不把 started 置真:用来测"重启没救活"的连续封顶 */
function makeStub(opts) {
const o = opts || {};
const st = { opened: false, started: false, calls: [], startReasons: [], openSubstr: null,
failOpen: o.failOpen, failStart: o.failStart, healthFail: o.healthFail };
const srv = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
st.calls.push(req.url);
const send = (obj) => {
const b = Buffer.from(JSON.stringify(obj), "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: st.started ? 8 : 1, al_status: 0 } }
: { ok: false, log: ["未连接"] });
}
if (req.url === "/ecat/api/native/health") {
if (st.healthFail) return send({ ok: false, log: ["health down"] });
if (!st.opened) return send({ ok: false, log: ["未连接"] });
return send(Object.assign({ ok: true, name: "ecat_native", bus_open: 1,
op: st.started ? 1 : 0, loop: st.started ? 1 : 0,
slaves: 1, wkc: st.started ? 3 : 0,
cycles: st.started ? 10 : 0, queue_depth: 0 }, o.health || {}));
}
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; st.started = false; return send({ ok: true, log: [] }); }
if (req.url === "/ecat/api/motor/start") {
if (st.failStart) return send({ ok: false, log: [st.failStart] });
st.startReasons.push(p.reason === undefined ? "<无 reason>" : String(p.reason));
if (!o.stayDown) st.started = true;
return send({ ok: true, log: [], period_ms: p.period_ms || 4, spin: true });
}
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: st.started ? 3 : 0, expected_wkc: 3, cycles: st.started ? 10 : 0,
period_ms: st.started ? 4 : 0, spin: true, pcore_mask: 0,
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 用默认网卡子串 I210", st.openSubstr === "I210", 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 钉在 DC 250µs 档(产品硬约束,非 stub 旧默认 4ms)",
Math.abs(tp.debug().periodMs - 0.25) < 1e-6, String(tp.debug().periodMs));
}
/* ── 2. 已连接且循环在跑时不要重复 open / motor/start(幂等)── */
section("[2] 已连接则不重复 open / motor/start(幂等,健康位快路径)");
{
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;
const startsAfterFirst = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
await tp.probe(); // 第二次探测:健康位应直接复用
const opensTotal = st.calls.filter((c) => c === "/ecat/api/open").length;
const startsTotal = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
un(); srv.close();
check("首次 probe 调了一次 open", opensAfterFirst === 1, String(opensAfterFirst));
check("首次 probe 调了一次 motor/start", startsAfterFirst === 1, String(startsAfterFirst));
check("已连接后再 probe 不再 open(避免反复重置总线)",
opensTotal === 1, `共 ${opensTotal} 次`);
check("循环已在跑时不再打 motor/start(那条路由会先 loop_stop,假超时/楔死源)",
startsTotal === 1, `共 ${startsTotal} 次`);
check("第二次 probe 仍成功返回 status(健康位路径)", true);
}
/* ── 2b. healthCheck:probe 超时后的轻量复核 ── */
section("[2b] healthCheck:总线已健康时可确认连上,且不碰 motor/start");
{
const { st, srv, port } = await makeStub();
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
await tp.probe();
const startsBefore = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
const data = await tp.healthCheck();
const startsAfter = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
check("healthCheck 返回 status(slave 在)", !!(data && data.slave));
check("healthCheck 不调用 motor/start",
startsAfter === startsBefore, `${startsBefore} → ${startsAfter}`);
check("healthCheck 后 debug().started 为 true", tp.debug().started === true);
/* 循环没跑 → healthCheck 必须抛错,不能把假连接报成在线 */
st.started = false;
let err = null;
try { await tp.healthCheck(); } catch (e) { err = e; }
un(); srv.close();
check("循环未运行时 healthCheck 抛错(不谎报在线)",
err !== null, err && err.message);
}
/* ── 2c. DC 自愈的三条反空断言 ──
* 少了这三条,上面"healthCheck 不调用 motor/start"可能只是因为 stub 从不给 dc 字段、
* 判据永远为假 —— 那它就是空断言(本项目反复踩过的坑)。 */
section("[2c] DC 自愈:证明配错才动总线,证明不了/正在恢复都不动");
{
const run = async (healthOverride) => {
const { st, srv, port } = await makeStub({ health: healthOverride });
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
await tp.probe();
const before = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
await tp.healthCheck();
const after = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
un(); srv.close();
return { delta: after - before, reasons: st.startReasons };
};
const bad = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4 });
check("DC 被证明配错(free-run)⇒ 恰又多一次 motor/start(按 period 对齐 DC)",
bad.delta === 1, `多 ${bad.delta} 次`);
check("DC 自愈带 reason=auto_dc_repair(代理侧据此统一节流)",
bad.reasons.some((r) => r === "auto_dc_repair"), bad.reasons.join(","));
const good = await run({ dc: 1, dc_cycle_us: 250, period_ms: 0.25 });
check("DC 正确 ⇒ 零次 motor/start", good.delta === 0, `多 ${good.delta} 次`);
const goodHot = await run({ dc: 1, dc_cycle_us: 500, period_ms: 0.5 });
check("热改档 0.5ms/500us 同值 ⇒ 零次 motor/start(不再被当成配错)",
goodHot.delta === 0, `多 ${goodHot.delta} 次`);
const busy = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4, recovering: 1 });
check("native 正在全量重连 ⇒ 即使 DC 配错也不插第二刀", busy.delta === 0, `多 ${busy.delta} 次`);
const blind = await run({});
check("回包没有 dc 字段(旧 exe)⇒ 证明不了,零次(这条就是 7ea80f0 丢掉的守卫)",
blind.delta === 0, `多 ${blind.delta} 次`);
}
/* ── 2d. 证明函数本体(纯函数矩阵,不碰网络)── */
section("[2d] proveEcatLoop / proveEcatDc 三态矩阵");
{
const cases = [
["读不到 ⇒ unknown(自动化禁止动手)", null, "unknown"],
["ok:false ⇒ unknown", { ok: false }, "unknown"],
["缺 loop 字段 ⇒ unknown", { ok: true, bus_open: 1 }, "unknown"],
["缺 bus_open 字段 ⇒ unknown", { ok: true, loop: 1 }, "unknown"],
["网卡没开 ⇒ down", { ok: true, bus_open: 0, loop: 0 }, "down"],
["开着但循环停了 ⇒ down", { ok: true, bus_open: 1, loop: 0 }, "down"],
["在跑 ⇒ up", { ok: true, bus_open: 1, loop: 1 }, "up"],
["正在重拉 ⇒ unknown(绿灯此刻不作数)",
{ ok: true, bus_open: 1, loop: 1, recovering: 1 }, "unknown"],
];
for (const [name, h, want] of cases) {
check(name, ebus.proveEcatLoop(h) === want, `实际 ${ebus.proveEcatLoop(h)}`);
}
check("连不上对「负责拉起总线」的页面算 down(treatOfflineAsDown)",
ebus.proveEcatLoop(null, { treatOfflineAsDown: true }) === "down");
check("同一份回包对电机页仍算 unknown(不许它据此重启)",
ebus.proveEcatLoop(null) === "unknown");
check("proveEcatDc: 缺字段 ⇒ unknown",
ebus.proveEcatDc({ ok: true }) === "unknown");
check("proveEcatDc: dc=1/250/0.25 ⇒ ok",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 250, period_ms: 0.25 }) === "ok");
check("proveEcatDc: 热改档 dc=1/500/0.5 ⇒ ok(period 与 DC 同值即合法)",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 500, period_ms: 0.5 }) === "ok");
check("proveEcatDc: period=4ms 且 DC=4000us ⇒ ok(UI 档位内)",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 4000, period_ms: 4 }) === "ok");
check("proveEcatDc: period=4ms 但 DC 仍 250us ⇒ misconfigured(不同值)",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 250, period_ms: 4 }) === "misconfigured");
check("proveEcatDc: free-run ⇒ misconfigured",
ebus.proveEcatDc({ ok: true, dc: 0, dc_cycle_us: 0, period_ms: 0.25 }) === "misconfigured");
}
/* ── 2e. OTA 负载页的决策:读数不新鲜 ≠ 循环停了 ── */
section("[2e] OTA 页只在证明循环停了才重启(固件 <1.5.61 不再周期性掐总线)");
{
const cases = [
["loop=1 但 valid 永远 false(固件段5 不支持)⇒ 绝不动总线",
{ ok: true, bus_open: 1, loop: 1 }, "idle-up"],
["loop=1 且 health 正常,age_ms 过期 ⇒ 仍不动总线(由 loop 位判,不看读数)",
{ ok: true, bus_open: 1, loop: 1 }, "idle-up"],
["loop=0 ⇒ 该重启", { ok: true, bus_open: 1, loop: 0 }, "repair"],
["native 完全没起 ⇒ 该拉起(保住「不必先开电机页」的刷写体验)", null, "repair"],
["正在重拉 ⇒ 不动", { ok: true, bus_open: 1, loop: 1, recovering: 1 }, "unproven"],
];
for (const [name, h, want] of cases) {
check(name, decidePdoCycle(h) === want, `实际 ${decidePdoCycle(h)}`);
}
}
/* ── 2f. 自动重启的预算:证明 + 间隔 + 连续封顶 + 复位 ── */
section("[2f] ensureEcatLoopRepair 的预算(少一条就会变成重连风暴)");
{
const post = (path, body, ms) => fetch(`/ecat/api/${path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}), signal: AbortSignal.timeout(ms || 5000),
}).then((r) => r.json());
const { st, srv, port } = await makeStub({ stayDown: true });
const un = bindFetch(port);
const age = () => _ls.set("dcu_ecat_auto_start_at", String(Date.now() - 30000));
let r0 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
check("网卡没开且没给 treatOfflineAsDown ⇒ 证明不了,不动总线",
r0.acted === false && r0.why === "unproven", r0.why);
await post("open", { substr: "I210" }); /* 现在 health: bus_open=1, loop=0 */
let r1 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair",
{ treatOfflineAsDown: false });
check("证明 loop=0 ⇒ 恰一次 motor/start", r1.acted === true, JSON.stringify(r1));
check("自动重启带 reason=auto_loop_repair",
st.startReasons[st.startReasons.length - 1] === "auto_loop_repair",
st.startReasons.join(","));
let r2 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
check("间隔内第二次被拒(时间戳先写后发,并发才不会叠加)",
r2.acted === false && r2.why === "interval", JSON.stringify(r2));
let capped = [];
/* 先把预算复位到 0,否则上面那一次 act 会被算进连续计数里,
* 断言就变成了"总共最多 3 次"而不是"连续 3 次没救活就封顶"。 */
st.started = true;
await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
st.started = false;
age();
for (let i = 0; i < 4; i++) { capped.push(await ebus.ensureEcatLoopRepair(post, "auto_loop_repair")); age(); }
check("连续 3 次没救活 ⇒ 第 4 次封顶不再动总线(深楔死要的是断电)",
capped[2].acted === true && capped[3].acted === false && capped[3].why === "capped",
capped.map((c) => (c.acted ? "act" : c.why)).join(","));
st.started = true; /* 总线自己活过来了 */
let rUp = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
check("看到 loop=1 ⇒ 不重启并复位预算", rUp.acted === false && rUp.why === "not_down",
JSON.stringify(rUp));
age();
st.started = false;
let r3 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
check("复位后重新故障 ⇒ 还能再试一次(封顶不是一刀切死)", r3.acted === true,
JSON.stringify(r3));
check("预算状态可被界面读到(横幅要说 n/3)",
typeof ebus.autoLoopState().streak === "number"
&& ebus.autoLoopState().inFlight === false, JSON.stringify(ebus.autoLoopState()));
un(); srv.close();
}
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
section("[3] 打不开网卡时的错误语义");
{
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I210' 的适配器" });
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);