feat(motor): ESP32 侧适配 EtherCAT native 后端, 清理 Python 时代残留
tools/ecat 后端已从 Python(ecat_server.py, 已删除)切到原生守护进程 build_native/ecat_native.exe。本仓(ESP32 电机控制台)的 EtherCAT 传输经 /ecat/api/* 代理打它, 这次把代理实现与全部相关措辞对齐到 native。 代理与自启(esp32_server.py): - ECAT_ENTRY/ECAT_PYW(src/ecat_server.py + venv/pythonw) → ECAT_NATIVE_EXE (build_native/ecat_native.exe); autostart 直接 Popen 单文件 exe, 不再经 venv/pythonw; 清场/--parent-pid/端口漂移三条契约 native 原样镜像, 注释同步 - 文件头与代理段里 ecat_server.py 措辞订正为 native 传输层注释(js/motor/*): - main/transport/transport-ecat/refresh-rate 里"后端是 ecat_server.py / 用 venv 拉起"等 Python 时代说法统一改为 ecat_native.exe - MOT_SLOTS_PER_PDO/MOTOR_SLOT_COUNT 引用改指 ecat_native.c(同名存在); MOTOR_SCAN_CYCLES native 无此常量, 改按真实来源写 ⌈27/7⌉=4 - main.js 环境错误分类正则 /venv|pysoem|Npcap/ → /Npcap|build_native| 守护进程|全被占/: 前两者 native 永不吐给前端(死分支), 后者对齐 autostart 真实文案, 让"exe 没编译"这类错误不被 140 字截断 README/motor.html 同步; 新增 scripts/browser_check_period.mjs(内核级 PDO 周期热调的浏览器实测, 与既有 browser_check_*.mjs 同族)。 验证: py_compile esp32_server.py 通过; 6 个改动 JS + 新脚本 node --check (ESM)全过; 正则对三类 native 错误命中、对废弃 venv/pysoem 不命中; js/ 与 scripts/ 重扫 ecat_server|venv|pysoem 已空。
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 浏览器实测:内核级 PDO 周期热调(250µs=4kHz)+ 持久化 + µs 显示。
|
||||
* 单测(test_motor_transport.mjs 的 i4 段)只验传输层契约;这里验真页面真后端:
|
||||
* - setPdoPeriod 走 /motor/period,绝不碰 motor/start
|
||||
* (后者会停 PDO 循环,停手 >1s 从站掉 OP —— 把板子弄楔死的那条路)
|
||||
* - 状态栏亚毫秒显示 µs,自旋/E 核标注正确
|
||||
* - 页面重载后 ensureStarted 自动带回持久化周期(内核级档位跨服务重启存活)
|
||||
* - 换回 4ms 后自旋标注消失(自动退回内核睡眠,不白烧核)
|
||||
*/
|
||||
const CDP = "http://127.0.0.1:9333";
|
||||
const list = await (await fetch(CDP + "/json/list")).json();
|
||||
const page = list.find((t) => t.type === "page");
|
||||
const sock = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
await new Promise((r, j) => { sock.onopen = r; sock.onerror = j; });
|
||||
sock.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
|
||||
const send = (method, params = {}) => { const myId = ++id; return new Promise((res) => { pending.set(myId, res); sock.send(JSON.stringify({ id: myId, method, params })); }); };
|
||||
async function ev(expr) {
|
||||
const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
|
||||
return r.result && r.result.result && r.result.result.value;
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let pass = 0, fail = 0; const out = [];
|
||||
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点:ECAT 链路,清掉周期/节拍残留偏好(后端保持当前值不动) */
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(2000);
|
||||
await ev(`localStorage.setItem("dcu_motor_transport","ecat");
|
||||
localStorage.removeItem("dcu_motor_ui_v2");
|
||||
localStorage.removeItem("dcu_ecat_period_ms");
|
||||
localStorage.removeItem("dcu_ecat_spin"); "ok"`);
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(4000);
|
||||
|
||||
/* 请求计数探针:区分 /motor/period 与 /motor/start */
|
||||
await ev(`(() => {
|
||||
window.__per = 0; window.__start = 0;
|
||||
if (!window.__origFetch) window.__origFetch = window.fetch;
|
||||
window.fetch = function (u) { const s = String(u);
|
||||
if (s.includes("motor/period")) window.__per++;
|
||||
if (s.includes("motor/start")) window.__start++;
|
||||
return window.__origFetch.apply(this, arguments); };
|
||||
return "ok"; })()`);
|
||||
|
||||
const pre = JSON.parse(await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn: motorDbg.state.connected})`));
|
||||
check("已在 ecat 链路且已连接", pre.tp === "ecat" && pre.conn === true, JSON.stringify(pre));
|
||||
|
||||
out.push("\n[A] setPdoPeriod(0.25):走 /motor/period,不碰 motor/start");
|
||||
const startBefore = await ev(`window.__start`);
|
||||
const got = await ev(`motorDbg.state.tp.setPdoPeriod(0.25)`);
|
||||
await sleep(1200);
|
||||
check("返回新周期 0.25", got === 0.25, String(got));
|
||||
const cnt = JSON.parse(await ev(`JSON.stringify({per: window.__per, start: window.__start})`));
|
||||
check("命中 /motor/period 一次", cnt.per === 1, JSON.stringify(cnt));
|
||||
check("没碰 /motor/start(停循环=看门狗掉OP的楔死路径)",
|
||||
cnt.start === startBefore, JSON.stringify({ cnt, startBefore }));
|
||||
|
||||
let st = JSON.parse(await ev(`JSON.stringify({txt: document.querySelector("#devStatus").textContent, dbg: motorDbg.state.tp.debug()})`));
|
||||
out.push(" 状态栏: " + st.txt);
|
||||
check("状态栏显示 PDO 250µs(亚毫秒用 µs 不用 0.25ms)", /PDO 250µs/.test(st.txt), st.txt);
|
||||
check("亚毫秒周期自动自旋并标注(⚡自旋节拍)", /自旋节拍/.test(st.txt), st.txt);
|
||||
check("debug().periodMs === 0.25", st.dbg.periodMs === 0.25, JSON.stringify(st.dbg));
|
||||
check("周期持久化到 localStorage",
|
||||
(await ev(`localStorage.getItem("dcu_ecat_period_ms")`)) === "0.25");
|
||||
|
||||
out.push("\n[B] 页面重载:ensureStarted 自动带回 0.25ms(跨重启档位不丢)");
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(4000);
|
||||
st = JSON.parse(await ev(`JSON.stringify({txt: document.querySelector("#devStatus").textContent, per: motorDbg.state.tp.debug().periodMs})`));
|
||||
check("重载后仍是 PDO 250µs", /PDO 250µs/.test(st.txt) && st.per === 0.25, JSON.stringify(st));
|
||||
|
||||
out.push("\n[C] 换回 4ms:µs 与自旋标注都消失(自动退回内核睡眠)");
|
||||
await ev(`motorDbg.state.tp.setPdoPeriod(4)`);
|
||||
await sleep(1200);
|
||||
st = JSON.parse(await ev(`JSON.stringify({txt: document.querySelector("#devStatus").textContent})`));
|
||||
out.push(" 状态栏: " + st.txt);
|
||||
check("显示 PDO 4ms", /PDO 4ms/.test(st.txt), st.txt);
|
||||
check("4ms 自动退回内核睡眠,无自旋标注", !/自旋/.test(st.txt), st.txt);
|
||||
check("localStorage 更新为 4", (await ev(`localStorage.getItem("dcu_ecat_period_ms")`)) === "4");
|
||||
|
||||
console.log(out.join("\n"));
|
||||
console.log("\n" + "=".repeat(56));
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
sock.close();
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -6,8 +6,8 @@
|
||||
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
|
||||
* 四级链条的每一级都被正确驱动,且失败原因可读。
|
||||
*
|
||||
* 为什么要 stub 后端:板子当前 SSC 邮箱楔死(config_map 失败,ecat_server 自己的
|
||||
* 注释写明必须复位 MCU),真实链路走不到 OP。但"自动开网卡→自动进 OP→自动起循环"
|
||||
* 为什么要 stub 后端:板子当前 SSC 邮箱楔死(config_map 失败,ecat_native.c 里
|
||||
* 同样注明必须断电复位 MCU),真实链路走不到 OP。但"自动开网卡→自动进 OP→自动起循环"
|
||||
* 这三级的**驱动逻辑**必须能独立验证,否则一块坏板子就让这段代码永远测不到。
|
||||
* 服务自动拉起那一级已在真机实测过(16s 起、带 --parent-pid),见 README。
|
||||
*/
|
||||
@@ -38,7 +38,7 @@ function check(name, cond, detail) {
|
||||
}
|
||||
function section(t) { out.push(`\n${t}`); }
|
||||
|
||||
/* ── stub 后端:模拟 ecat_server 的状态机 ──
|
||||
/* ── stub 后端:模拟 ecat_native 的状态机 ──
|
||||
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
|
||||
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数。 */
|
||||
function makeStub(opts) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* node scripts/test_motor_transport.mjs [--port 8080]
|
||||
*
|
||||
* 测什么:**真的 import 生产代码** js/motor/transport-*.js,用 Node 的
|
||||
* 全局 fetch 打真实的 /ecat/api/* 反向代理 → 真实 ecat_server → 真实从站。
|
||||
* 全局 fetch 打真实的 /ecat/api/* 反向代理 → 真实 ecat_native → 真实从站。
|
||||
* 不 mock 传输实现本身,否则测不到"契约对不对"这唯一值得测的东西。
|
||||
*
|
||||
* 覆盖:
|
||||
@@ -124,8 +124,8 @@ check("WiFi 支持 0xC5 周期同步", wifi.supportsPeriodSync === true);
|
||||
check("EtherCAT 不做周期同步(PDO 周期由后台循环维持)", ecat.supportsPeriodSync === false);
|
||||
check("EtherCAT 不回报设备 IP", ecat.reportedIp({}) === null);
|
||||
|
||||
/* ── 4~9. EtherCAT:打真实代理 + 真实 ecat_server ── */
|
||||
section("[4] EtherCAT 端到端(真实代理 → ecat_server → 从站)");
|
||||
/* ── 4~9. EtherCAT:打真实代理 + 真实 ecat_native ── */
|
||||
section("[4] EtherCAT 端到端(真实代理 → ecat_native → 从站)");
|
||||
let liveOk = false;
|
||||
try {
|
||||
const st = await ecat.fetchStatus();
|
||||
@@ -299,7 +299,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
st.motors.find((x) => x.node === 4).config_data === CFG2);
|
||||
|
||||
/* (h) 关键:seq 停止不动超过阈值 → online 转 false。
|
||||
* ecat_server 的快照**永不删除**见过的槽(电机拔了也还在),
|
||||
* 后端的快照**永不删除**见过的槽(电机拔了也还在),
|
||||
* 只认从站的 online 标志会让界面上永远挂着一台不存在的电机。 */
|
||||
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX };
|
||||
snap = mkSnap([stale]);
|
||||
@@ -430,6 +430,68 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
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");
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user