Files
DCU_DesktopController/scripts/test_ecat_autostart.mjs
T
cbb 514070b36d fix(ui): 「谁可以动总线」收到一处判定;healthCheck 与 OTA 页不再自己 loop_stop
同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了
native 检测层那一条的一半。本 commit 关掉两条由我们自己的前端反复按 loop_stop
的成因——不需要硬件故障就能把健康总线掐停:motor/start 内部先 loop_stop,停手
>1000ms 从站过程数据看门狗就踢出 OP,反复这样会把 SSC 邮箱楔到只能断电。

1) healthCheck 违反自己的契约。文件注释写着「只读…绝不 motor/start、不动总线」,
   代码却是内联的 dc !== 1 || dcs !== 250 → motor/start。这把「回包没有 dc 字段」
   当成「DC 配错」,于是一篇错误回包/一份旧 exe 就打出一记 loop_stop,而这条路径
   只在 probe() 超时后走(那一刻已经有一记 loop_stop 在飞)。
   git 史:断言 383535b 引入即红 → 2304ed6 改走 ecat-bus.repairEcatDc(含「缺字段
   不动总线」守卫)转绿 → 7ea80f0 前端回滚删掉那次调用,又红。
2) OTA 负载页以「读数不新鲜」为理由无条件 motor/start。固件 <1.5.61(段5 恒 0)、
   GD32 没跑 APP、首个 1s 窗口没滚完都会读数不新鲜而循环完全健康——一个跟电机无关
   的页面于是周期性掐停总线。现在先证明再动手;native 完全没起仍算「该拉起」,
   刷写体验不变。

做法:判定与预算只放 ecat-bus.js——
  proveEcatLoop(h)  'up'|'down'|'unknown':三态是重点,证明不了就不许自动动手
  proveEcatDc(h)    同上,把「缺字段=证明不了」收编成唯一实现
  ensureEcatLoopRepair()  in-flight 锁 + 最小间隔 20s(时间戳先写后发,落
  localStorage 才跨标签页/跨 F5)+ 连续 3 次封顶;reason 随 body 走,供代理侧统一上限
repairEcatDc 新增「native 正在全量重连时不动总线」;isEcatBusHealthy 与 proveEcatLoop
共用同一处判定(两个「看起来在跑」迟早漂移)。transport-ecat 的 readHealth 附带原始
回包 raw,证明函数与界面读同一份,不再抄一遍字段名。

测试:test_ecat_autostart.mjs 26→57 项。stub 现在能喂任意 health 形状并按 reason
计数,新增 [2c] 反空断言(DC 确实配错时必须恰又一次且带 reason)、[2d] 三态矩阵、
[2e] OTA 决策、[2f] 预算(间隔/封顶/复位后放行)。另加 scripts/check_fe_guard_mutation.py:
把实现逐条退回旧形状看断言是否真变红(全绿=那条断言是自证的)——M1..M6 实测全部变红,
还原后基线仍 57/0。配套门槛见 tools/ecat/check_guard_regression.py 的 S6/S7。

未做(另计):loop=0 的三重闩锁(transport 的 started 位、transient 正则、device-link
的 e.loop=loadValid||op 假位——native /api/status 的 slave.state 是缓存,循环停了仍报 8)
与 UI 对 loop/guard 的可见性;以及 loop_stop 不清 g_cmd_q 造成的「重启那刻陈旧命令一起灌出去」。
2026-09-22 00:30:33 +08:00

365 lines
19 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 被证明配错 ⇒ 恰又多一次 motor/start(拉回 250µs)",
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 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: period 掉到 4ms ⇒ misconfigured",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 4000, period_ms: 4 }) === "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);