2026-09-04 12:18:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* test_motor_transport.mjs — 电机页双传输的端到端自测(Node,无浏览器)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 跑法(先确保本地页面服务器在跑,EtherCAT 部分还需 tools/ecat 服务在跑):
|
|
|
|
|
|
* node scripts/test_motor_transport.mjs [--port 8080]
|
|
|
|
|
|
*
|
|
|
|
|
|
* 测什么:**真的 import 生产代码** js/motor/transport-*.js,用 Node 的
|
2026-09-05 03:56:52 +08:00
|
|
|
|
* 全局 fetch 打真实的 /ecat/api/* 反向代理 → 真实 ecat_native → 真实从站。
|
2026-09-04 12:18:06 +08:00
|
|
|
|
* 不 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 报错)
|
2026-09-12 20:10:47 +08:00
|
|
|
|
* 11 判活阈值随实测回报周期自适应(电机可变 CAN 刷新率,慢速不误判离线)
|
2026-09-04 12:18:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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",
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 电机 OTA 整套:少一个就是"点开始才崩",流程只在 ota.js 写一遍,两条链路都得给 */
|
|
|
|
|
|
"otaHostSendsBootFrame", "otaIntegrity",
|
|
|
|
|
|
"otaUploadAttempt", "otaStartRelay", "otaStatus", "otaCancel",
|
2026-09-04 12:18:06 +08:00
|
|
|
|
];
|
|
|
|
|
|
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);
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 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");
|
2026-09-04 12:18:06 +08:00
|
|
|
|
check("WiFi 支持 0xC5 周期同步", wifi.supportsPeriodSync === true);
|
|
|
|
|
|
check("EtherCAT 不做周期同步(PDO 周期由后台循环维持)", ecat.supportsPeriodSync === false);
|
|
|
|
|
|
check("EtherCAT 不回报设备 IP", ecat.reportedIp({}) === null);
|
|
|
|
|
|
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/* ── 4~9. EtherCAT:打真实代理 + 真实 ecat_native ── */
|
|
|
|
|
|
section("[4] EtherCAT 端到端(真实代理 → ecat_native → 从站)");
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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));
|
2026-09-07 12:19:26 +08:00
|
|
|
|
check("data 是 96 字符 hex(48B STATUS,v0.74 含 InitStatus)", m.data.length === 96,
|
|
|
|
|
|
`len=${m.data.length}`);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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 周期与槽位统计",
|
2026-09-21 02:11:49 +08:00
|
|
|
|
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 }));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── 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: [] },
|
2026-09-07 12:19:26 +08:00
|
|
|
|
head: { active: motors.length, boot_active: false, win_base: 0, win_count: 5, global_seq: 42 },
|
2026-09-04 12:18:06 +08:00
|
|
|
|
motors,
|
|
|
|
|
|
}, extra || {});
|
|
|
|
|
|
|
2026-09-07 12:19:26 +08:00
|
|
|
|
const STATUS_HEX = "AB".repeat(48); /* 48B STATUS(v0.74) */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const CONFIG_HEX = "5A".repeat(24); /* 24B CONFIG(0x0101) */
|
|
|
|
|
|
|
|
|
|
|
|
/* (a) 首次:seq 在动 → online=true,rx_count 从 1 起累 */
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 500, data: STATUS_HEX }]);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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 继续累加 */
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 501, data: STATUS_HEX }]);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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 }];
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 502, data: STATUS_HEX }],
|
2026-09-04 12:18:06 +08:00
|
|
|
|
{ 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}`);
|
|
|
|
|
|
|
2026-09-21 02:11:49 +08:00
|
|
|
|
/* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答"。
|
|
|
|
|
|
* fetchMotorState 对 rx drain 有 DRAIN_MIN_MS 节流;构造用例必须等过节流窗,
|
|
|
|
|
|
* 否则测的是节流而不是版本累积器。 */
|
|
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
|
2026-09-21 02:11:49 +08:00
|
|
|
|
await sleep(25);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 503, data: STATUS_HEX }],
|
2026-09-04 12:18:06 +08:00
|
|
|
|
{ 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);
|
2026-09-21 02:11:49 +08:00
|
|
|
|
await sleep(25);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 504, data: STATUS_HEX }],
|
2026-09-04 12:18:06 +08:00
|
|
|
|
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
|
|
|
|
|
await tp.fetchMotorState();
|
|
|
|
|
|
const v2 = await tp.fetchVersionNode(3);
|
2026-09-21 02:11:49 +08:00
|
|
|
|
check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2 && v1 && v2.seq === v1.seq + 1,
|
|
|
|
|
|
`${v1 && v1.seq} → ${v2 && v2.seq}`);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
|
2026-09-21 02:11:49 +08:00
|
|
|
|
/* (e) 节点 ID 应答:取走即清(同样要等过 DRAIN_MIN_MS) */
|
|
|
|
|
|
await sleep(25);
|
2026-09-04 12:18:06 +08:00
|
|
|
|
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 505, data: STATUS_HEX }],
|
2026-09-04 12:18:06 +08:00
|
|
|
|
{ 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) }];
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 506, data: STATUS_HEX }],
|
2026-09-04 12:18:06 +08:00
|
|
|
|
{ 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);
|
2026-09-07 12:19:26 +08:00
|
|
|
|
snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 48, seq: 10, data: STATUS_HEX }],
|
2026-09-04 12:18:06 +08:00
|
|
|
|
{ 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。
|
2026-09-05 03:56:52 +08:00
|
|
|
|
* 后端的快照**永不删除**见过的槽(电机拔了也还在),
|
2026-09-04 12:18:06 +08:00
|
|
|
|
* 只认从站的 online 标志会让界面上永远挂着一台不存在的电机。 */
|
2026-09-07 12:19:26 +08:00
|
|
|
|
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 506, data: STATUS_HEX };
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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();
|
2026-09-12 20:10:47 +08:00
|
|
|
|
check("(h) seq 停超 2s → online 转 false(快周期兜底下限,快照不删历史槽)",
|
2026-09-04 12:18:06 +08:00
|
|
|
|
st.motors.find((x) => x.node === 3).online === false,
|
|
|
|
|
|
`online=${st.motors.find((x) => x.node === 3).online}`);
|
|
|
|
|
|
|
2026-09-12 20:10:47 +08:00
|
|
|
|
/* (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}`);
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/* (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));
|
|
|
|
|
|
|
2026-09-05 19:58:45 +08:00
|
|
|
|
/* (i3b) 钉到 P 核时要写出核号 —— 自旋吃满一个核是用户能感知的代价,
|
|
|
|
|
|
* 列出实际落在哪些逻辑处理器,用户才知道占的是不是性能核。掩码是 64 位,
|
2026-09-04 12:18:06 +08:00
|
|
|
|
* JS 移位只有 32 位,所以高低两段分别取,这里专门覆盖跨 32 位的情况。 */
|
|
|
|
|
|
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
2026-09-05 19:58:45 +08:00
|
|
|
|
pcore_mask: 0xfff0000 });
|
2026-09-04 12:18:06 +08:00
|
|
|
|
await tp.fetchMotorState();
|
|
|
|
|
|
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
2026-09-05 19:58:45 +08:00
|
|
|
|
check("(i3b) 钉到 P 核时列出核号区间(16-27,不逐个罗列挤掉从站名)",
|
|
|
|
|
|
parts.some((p) => p.includes("P 核 16-27")),
|
2026-09-04 12:18:06 +08:00
|
|
|
|
JSON.stringify(parts));
|
2026-09-05 19:58:45 +08:00
|
|
|
|
check("(i3b) 已列出 P 核号时不再写笼统的『占 1 核』",
|
2026-09-04 12:18:06 +08:00
|
|
|
|
!parts.some((p) => p.includes("占 1 核")), JSON.stringify(parts));
|
2026-09-05 19:58:45 +08:00
|
|
|
|
check("(i3b) debug() 暴露 pcores 列表便于现场核对",
|
|
|
|
|
|
tp.debug().pcores.length === 12 && tp.debug().pcores[0] === 16 &&
|
|
|
|
|
|
tp.debug().pcores[11] === 27,
|
|
|
|
|
|
JSON.stringify(tp.debug().pcores));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/* 高 32 位之外的核(>31)不能丢:用 2^40 验证跨段位 */
|
|
|
|
|
|
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
2026-09-05 19:58:45 +08:00
|
|
|
|
pcore_mask: 1 + 2 ** 40 });
|
2026-09-04 12:18:06 +08:00
|
|
|
|
await tp.fetchMotorState();
|
|
|
|
|
|
check("(i3b) 掩码跨 32 位时高段核号不丢(0 与 40)",
|
2026-09-05 19:58:45 +08:00
|
|
|
|
JSON.stringify(tp.debug().pcores) === JSON.stringify([0, 40]),
|
|
|
|
|
|
JSON.stringify(tp.debug().pcores));
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/* 掩码为 0 = 探测失败/对称 CPU,要退回笼统措辞而不是显示空列表 */
|
|
|
|
|
|
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
2026-09-05 19:58:45 +08:00
|
|
|
|
pcore_mask: 0 });
|
2026-09-04 12:18:06 +08:00
|
|
|
|
await tp.fetchMotorState();
|
|
|
|
|
|
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
|
2026-09-05 19:58:45 +08:00
|
|
|
|
check("(i3b) 没钉核时退回『占 1 核』而不是列出空的 P 核",
|
|
|
|
|
|
parts.some((p) => p.includes("占 1 核") && !p.includes("P 核")),
|
2026-09-04 12:18:06 +08:00
|
|
|
|
JSON.stringify(parts));
|
2026-09-05 19:58:45 +08:00
|
|
|
|
/* 不连续的核(混合架构常见:P 核未必连成一段)要压成多段而不是硬凑一个区间 */
|
2026-09-04 12:18:06 +08:00
|
|
|
|
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
|
2026-09-05 19:58:45 +08:00
|
|
|
|
pcore_mask: (1 << 16) | (1 << 17) | (1 << 20) | (1 << 24) });
|
2026-09-04 12:18:06 +08:00
|
|
|
|
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));
|
|
|
|
|
|
|
2026-09-05 03:56:52 +08:00
|
|
|
|
/* (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");
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 放开热改: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 用例口径不变 */
|
2026-09-05 03:56:52 +08:00
|
|
|
|
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));
|
|
|
|
|
|
|
2026-09-04 12:18:06 +08:00
|
|
|
|
/* (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);
|