2026-09-04 12:18:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* test_ecat_autostart.mjs — EtherCAT 自动接入链条自测(Node,无浏览器)
|
|
|
|
|
|
*
|
2026-09-07 12:19:26 +08:00
|
|
|
|
* 跑法:node scripts/test_ecat_autostart.mjs [--port 2625]
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
|
|
|
|
|
|
* 四级链条的每一级都被正确驱动,且失败原因可读。
|
|
|
|
|
|
*
|
2026-09-05 03:56:52 +08:00
|
|
|
|
* 为什么要 stub 后端:板子当前 SSC 邮箱楔死(config_map 失败,ecat_native.c 里
|
|
|
|
|
|
* 同样注明必须断电复位 MCU),真实链路走不到 OP。但"自动开网卡→自动进 OP→自动起循环"
|
2026-09-04 12:18:06 +08:00
|
|
|
|
* 这三级的**驱动逻辑**必须能独立验证,否则一块坏板子就让这段代码永远测不到。
|
|
|
|
|
|
* 服务自动拉起那一级已在真机实测过(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");
|
2026-09-22 00:30:33 +08:00
|
|
|
|
/* 总线许可判定与自动重启预算的唯一实现(与浏览器里同一份代码,不是复制品) */
|
|
|
|
|
|
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");
|
2026-09-04 12:18:06 +08:00
|
|
|
|
|
|
|
|
|
|
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}`); }
|
|
|
|
|
|
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/* ── stub 后端:模拟 ecat_native 的状态机 ──
|
2026-09-04 12:18:06 +08:00
|
|
|
|
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
|
2026-09-22 00:30:33 +08:00
|
|
|
|
* /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 置真:用来测"重启没救活"的连续封顶 */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
function makeStub(opts) {
|
2026-09-22 00:30:33 +08:00
|
|
|
|
const o = opts || {};
|
|
|
|
|
|
const st = { opened: false, started: false, calls: [], startReasons: [], openSubstr: null,
|
|
|
|
|
|
failOpen: o.failOpen, failStart: o.failStart, healthFail: o.healthFail };
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const srv = http.createServer((req, res) => {
|
|
|
|
|
|
let body = "";
|
|
|
|
|
|
req.on("data", (c) => (body += c));
|
|
|
|
|
|
req.on("end", () => {
|
|
|
|
|
|
st.calls.push(req.url);
|
2026-09-22 00:30:33 +08:00
|
|
|
|
const send = (obj) => {
|
|
|
|
|
|
const b = Buffer.from(JSON.stringify(obj), "utf8");
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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
|
2026-09-20 22:58:00 +08:00
|
|
|
|
? { 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"] });
|
2026-09-22 00:30:33 +08:00
|
|
|
|
if (!st.opened) return send({ ok: false, log: ["未连接"] });
|
|
|
|
|
|
return send(Object.assign({ ok: true, name: "ecat_native", bus_open: 1,
|
2026-09-20 22:58:00 +08:00
|
|
|
|
op: st.started ? 1 : 0, loop: st.started ? 1 : 0,
|
|
|
|
|
|
slaves: 1, wkc: st.started ? 3 : 0,
|
2026-09-22 00:30:33 +08:00
|
|
|
|
cycles: st.started ? 10 : 0, queue_depth: 0 }, o.health || {}));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 } });
|
|
|
|
|
|
}
|
2026-09-20 22:58:00 +08:00
|
|
|
|
if (req.url === "/ecat/api/close") { st.opened = false; st.started = false; return send({ ok: true, log: [] }); }
|
2026-09-04 12:18:06 +08:00
|
|
|
|
if (req.url === "/ecat/api/motor/start") {
|
|
|
|
|
|
if (st.failStart) return send({ ok: false, log: [st.failStart] });
|
2026-09-22 00:30:33 +08:00
|
|
|
|
st.startReasons.push(p.reason === undefined ? "<无 reason>" : String(p.reason));
|
|
|
|
|
|
if (!o.stayDown) st.started = true;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
return send({ ok: true, log: [], period_ms: p.period_ms || 4, spin: true });
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (req.url === "/ecat/api/motor/state") {
|
|
|
|
|
|
if (!st.opened) return send({ ok: false, log: ["未连接"] });
|
2026-09-20 22:58:00 +08:00
|
|
|
|
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,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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"));
|
2026-09-07 12:19:26 +08:00
|
|
|
|
check("open 用默认网卡子串 I210", st.openSubstr === "I210", String(st.openSubstr));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
check("随后自动调用 /api/motor/start(进 OP + 起后台循环)", st.started === true);
|
|
|
|
|
|
check("open 在 start 之前", st.calls.indexOf("/ecat/api/open") < st.calls.indexOf("/ecat/api/motor/start"));
|
2026-09-21 02:28:26 +08:00
|
|
|
|
check("periodMs 钉在 DC 250µs 档(产品硬约束,非 stub 旧默认 4ms)",
|
|
|
|
|
|
Math.abs(tp.debug().periodMs - 0.25) < 1e-6, String(tp.debug().periodMs));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:58:00 +08:00
|
|
|
|
/* ── 2. 已连接且循环在跑时不要重复 open / motor/start(幂等)── */
|
|
|
|
|
|
section("[2] 已连接则不重复 open / motor/start(幂等,健康位快路径)");
|
2026-09-04 12:18:06 +08:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const startsAfterFirst = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
|
|
|
|
|
|
await tp.probe(); // 第二次探测:健康位应直接复用
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const opensTotal = st.calls.filter((c) => c === "/ecat/api/open").length;
|
2026-09-20 22:58:00 +08:00
|
|
|
|
const startsTotal = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
|
2026-09-04 12:18:06 +08:00
|
|
|
|
un(); srv.close();
|
|
|
|
|
|
check("首次 probe 调了一次 open", opensAfterFirst === 1, String(opensAfterFirst));
|
2026-09-20 22:58:00 +08:00
|
|
|
|
check("首次 probe 调了一次 motor/start", startsAfterFirst === 1, String(startsAfterFirst));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
check("已连接后再 probe 不再 open(避免反复重置总线)",
|
|
|
|
|
|
opensTotal === 1, `共 ${opensTotal} 次`);
|
2026-09-20 22:58:00 +08:00
|
|
|
|
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);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:30:33 +08:00
|
|
|
|
/* ── 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 });
|
2026-10-13 00:48:34 +08:00
|
|
|
|
check("DC 被证明配错(free-run)⇒ 恰又多一次 motor/start(按 period 对齐 DC)",
|
2026-09-22 00:30:33 +08:00
|
|
|
|
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} 次`);
|
2026-10-13 00:48:34 +08:00
|
|
|
|
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} 次`);
|
2026-09-22 00:30:33 +08:00
|
|
|
|
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");
|
2026-10-13 00:48:34 +08:00
|
|
|
|
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");
|
2026-09-22 00:30:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
|
|
|
|
|
|
section("[3] 打不开网卡时的错误语义");
|
|
|
|
|
|
{
|
2026-09-07 12:19:26 +08:00
|
|
|
|
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I210' 的适配器" });
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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);
|