Files
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

582 lines
32 KiB
JavaScript
Raw Permalink 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_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_native → 真实从站。
* 不 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 报错)
* 11 判活阈值随实测回报周期自适应(电机可变 CAN 刷新率,慢速不误判离线)
*/
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",
/* 电机 OTA 整套:少一个就是"点开始才崩",流程只在 ota.js 写一遍,两条链路都得给 */
"otaHostSendsBootFrame", "otaIntegrity",
"otaUploadAttempt", "otaStartRelay", "otaStatus", "otaCancel",
];
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);
/* EtherCAT 的料走 CoE 邮箱进 GD32 暂存区(DCU_APP comm/ecat_motor_ota.cpp),
* 中继引擎与 WiFi 同一个 ⇒ 能力位必须为真。曾经这里是 false,
* 因为那条链路只有 ESP32 上传这一条进料路(纯网线打不通设备 IP)。 */
check("EtherCAT 支持 OTA(邮箱进料 + 同一个 GD32 relay)", ecat.supportsOta === true);
/* 两处真实差异必须被钉住,别被"看着一样"糊过去:
* - 进 BOOT 帧谁发(EtherCAT 必须 GD32 在已加锁通道上发,否则运动帧抢邮箱)
* - 完整性口径(WiFi 回读 SHA-256,EtherCAT 从站核 CRC32,不许互相冒充) */
check("WiFi 由上位机先进 BOOT", wifi.otaHostSendsBootFrame === true);
check("EtherCAT 由 GD32 在已加锁通道上先进 BOOT", ecat.otaHostSendsBootFrame === false);
check("WiFi 完整性口径 = ESP32 flash 回读 SHA-256", wifi.otaIntegrity === "sha256-esp32-flash");
check("EtherCAT 完整性口径 = 从站核 CRC32(进 BOOT 前)", ecat.otaIntegrity === "crc32-verify-in-slave");
check("WiFi 支持 0xC5 周期同步", wifi.supportsPeriodSync === true);
check("EtherCAT 不做周期同步(PDO 周期由后台循环维持)", ecat.supportsPeriodSync === false);
check("EtherCAT 不回报设备 IP", ecat.reportedIp({}) === null);
/* ── 4~9. EtherCAT:打真实代理 + 真实 ecat_native ── */
section("[4] EtherCAT 端到端(真实代理 → ecat_native → 从站)");
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 是 96 字符 hex(48B STATUS,v0.74 含 InitStatus)", m.data.length === 96,
`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 === 9 &&
dbg.slotCount === 27 && dbg.scanCycles === 1, JSON.stringify(dbg && {
periodMs: dbg.periodMs, slots: dbg.slots, slotsPerPdo: dbg.slotsPerPdo,
scanCycles: dbg.scanCycles }));
}
/* ── 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: 5, global_seq: 42 },
motors,
}, extra || {});
const STATUS_HEX = "AB".repeat(48); /* 48B STATUS(v0.74) */
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: 48, 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: 48, 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: 48, 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 增长判"这是本次查询的新应答"。
* fetchMotorState 对 rx drain 有 DRAIN_MIN_MS 节流;构造用例必须等过节流窗,
* 否则测的是节流而不是版本累积器。 */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
await sleep(25);
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: 48, 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);
await sleep(25);
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: 48, 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 && v1 && v2.seq === v1.seq + 1,
`${v1 && v1.seq} → ${v2 && v2.seq}`);
/* (e) 节点 ID 应答:取走即清(同样要等过 DRAIN_MIN_MS) */
await sleep(25);
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, 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: 48, 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: 48, 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。
* 后端的快照**永不删除**见过的槽(电机拔了也还在),
* 只认从站的 online 标志会让界面上永远挂着一台不存在的电机。 */
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 48, 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(快周期兜底下限,快照不删历史槽)",
st.motors.find((x) => x.node === 3).online === false,
`online=${st.motors.find((x) => x.node === 3).online}`);
/* (h2) 判活阈值随实测回报周期自适应(电机可变 CAN 刷新率)。
* 用另一台"慢速电机"(N8@CH0,回报周期 ~1.2s)验证:固定 2s 阈值会在
* 两次正常回报之间把它判离线(LED 闪/波形虚线/面板切离线层);
* 新阈值 = clamp(3×EWMA, 2s, 30s) ≈ 3.6s —— 2.5s 静默必须仍算在线,
* 静默超 ~3.6s 才判离线。间隔用真睡(与 (h) 同理,判活用本地时间)。 */
const slow = (seq) => [{ slot: 21, node: 8, ch: 0, online: true, dlc: 48, seq, data: STATUS_HEX }];
snap = mkSnap(slow(900));
await tp.fetchMotorState(); /* 首见(不计间隔) */
await new Promise((r) => setTimeout(r, 1200));
snap = mkSnap(slow(901));
await tp.fetchMotorState(); /* 间隔#1 ≈1.2s */
await new Promise((r) => setTimeout(r, 1200));
snap = mkSnap(slow(902));
await tp.fetchMotorState(); /* 间隔#2 ≈1.2s → EWMA≈1.2s,阈值≈3.6s */
snap = mkSnap(slow(902)); /* seq 停住:两次回报之间的静默 */
await new Promise((r) => setTimeout(r, 2500));
st = await tp.fetchMotorState();
check("(h2) 慢速电机(1.2s/帧)静默 2.5s 仍在线(固定 2s 阈值会误判)",
st.motors.find((x) => x.node === 8).online === true,
`online=${st.motors.find((x) => x.node === 8).online}`);
await new Promise((r) => setTimeout(r, 1400)); /* 累计静默 ≥3.9s > 阈值 ≈3.6s */
st = await tp.fetchMotorState();
check("(h2) 静默超自适应阈值(≈3.6s)后才判离线(拔线仍能查出)",
st.motors.find((x) => x.node === 8).online === false,
`online=${st.motors.find((x) => x.node === 8).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) 钉到 P 核时要写出核号 —— 自旋吃满一个核是用户能感知的代价,
* 列出实际落在哪些逻辑处理器,用户才知道占的是不是性能核。掩码是 64 位,
* JS 移位只有 32 位,所以高低两段分别取,这里专门覆盖跨 32 位的情况。 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
pcore_mask: 0xfff0000 });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i3b) 钉到 P 核时列出核号区间(16-27,不逐个罗列挤掉从站名)",
parts.some((p) => p.includes("P 核 16-27")),
JSON.stringify(parts));
check("(i3b) 已列出 P 核号时不再写笼统的『占 1 核』",
!parts.some((p) => p.includes("占 1 核")), JSON.stringify(parts));
check("(i3b) debug() 暴露 pcores 列表便于现场核对",
tp.debug().pcores.length === 12 && tp.debug().pcores[0] === 16 &&
tp.debug().pcores[11] === 27,
JSON.stringify(tp.debug().pcores));
/* 高 32 位之外的核(>31)不能丢:用 2^40 验证跨段位 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
pcore_mask: 1 + 2 ** 40 });
await tp.fetchMotorState();
check("(i3b) 掩码跨 32 位时高段核号不丢(0 与 40)",
JSON.stringify(tp.debug().pcores) === JSON.stringify([0, 40]),
JSON.stringify(tp.debug().pcores));
/* 掩码为 0 = 探测失败/对称 CPU,要退回笼统措辞而不是显示空列表 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
pcore_mask: 0 });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i3b) 没钉核时退回『占 1 核』而不是列出空的 P 核",
parts.some((p) => p.includes("占 1 核") && !p.includes("P 核")),
JSON.stringify(parts));
/* 不连续的核(混合架构常见:P 核未必连成一段)要压成多段而不是硬凑一个区间 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
pcore_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));
/* (i4) 内核级周期热调(250µs = 4kHz):必须随时可改、走 /motor/period。
* 走 motor/start 是当年把板子弄楔死的路 —— 它先停循环,
* 停手 >1000ms 从站 SM2 看门狗直接踢出 OP。 */
globalThis.localStorage = (() => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
};
})();
let periodBody = null, periodHits = 0, startBody2 = null, startHits2 = 0;
globalThis.fetch = async (url, init) => {
const u = String(url);
if (u.includes("/motor/rx")) return jsonResp({ ok: true, log: [], frames: [], remain: 0 });
if (u.includes("/motor/state")) return jsonResp(Object.assign({ ok: true, log: [] }, snap));
if (u.includes("/motor/period")) {
periodHits++; periodBody = JSON.parse(init.body);
return jsonResp({ ok: true, log: [], period_ms: periodBody.period_ms, spin: true });
}
if (u.includes("/motor/start")) {
startHits2++; startBody2 = JSON.parse(init.body);
return jsonResp({ ok: true, log: [], period_ms: startBody2.period_ms || 4, spin: true });
}
return jsonResp({ ok: true, log: [] });
};
check("(i4) setPdoPeriod(0.25) 带 period_ms:0.25 走 /motor/period 并返回新值",
(await tp.setPdoPeriod(0.25)) === 0.25 && periodBody.period_ms === 0.25,
JSON.stringify(periodBody));
check("(i4) 热调周期不碰 motor/start(停循环 = 看门狗掉 OP 的楔死路径)",
periodHits === 1 && startHits2 === 0,
JSON.stringify({ periodHits, startHits2 }));
check("(i4) 周期值持久化到 localStorage(服务重启后不用重敲)",
globalThis.localStorage.getItem("dcu_ecat_period_ms") === "0.25");
/* 放开热改:0.5ms 档必须原样下发,不得被前端钳回 0.25 */
const got05 = await tp.setPdoPeriod(0.5);
check("(i4) setPdoPeriod(0.5) 不钉死 0.25:原样下发并持久化",
got05 === 0.5 && periodBody.period_ms === 0.5
&& globalThis.localStorage.getItem("dcu_ecat_period_ms") === "0.5",
JSON.stringify({ got05, periodBody }));
await tp.setPdoPeriod(0.25); /* 回落到默认档,后续 ensureStarted 用例口径不变 */
await tp.probe();
check("(i4) 循环重启(ensureStarted)自动带上持久化的周期",
startHits2 === 1 && startBody2.period_ms === 0.25, JSON.stringify(startBody2));
let threwPeriod = false;
try { await tp.setPdoPeriod(0); } catch (_) { threwPeriod = true; }
try { await tp.setPdoPeriod("abc"); } catch (_) { threwPeriod = true; }
check("(i4) ≤0 / 非法周期在本地就拒绝,不发给后端", threwPeriod);
/* 状态栏显示口径:<1ms 必须写成 µs。"PDO 0.25ms" 这种写法没人读,
* 而且和后端 _fmt_period 的口径要一致。 */
snap = mkSnap([stale], { period_ms: 0.25, slot_period_ms: 0.25, spin: true });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i4) 亚毫秒周期显示 µs(PDO 250µs,不是 PDO 0.25ms)",
parts.some((p) => p.includes("PDO 250µs")), JSON.stringify(parts));
snap = mkSnap([stale], { period_ms: 0.5, slot_period_ms: 1, spin: true });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i4) 单槽≠周期时 µs/ms 各自按自己的档位显示",
parts.some((p) => p.includes("PDO 500µs") && p.includes("单槽 1ms")),
JSON.stringify(parts));
snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 4, spin: false });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i4) 别处(rate_probe/另一标签页)改的周期每拍从快照同步",
parts.some((p) => p.includes("PDO 4ms")) && tp.debug().periodMs === 4,
JSON.stringify(parts));
/* (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);