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:
cbb
2026-09-05 03:56:52 +08:00
parent 2d1743ca4f
commit c189a8563e
10 changed files with 394 additions and 116 deletions
+87
View File
@@ -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);
+3 -3
View File
@@ -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) {
+66 -4
View File
@@ -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) => {