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
+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) => {