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
+7 -5
View File
@@ -16,7 +16,7 @@
POST /can/send_batch 发送入口(CMD / CONFIG / VERSION / OTA)
GET /can/feedback 1Hz → 状态栏 RX/TX/ERR/TEC/REC/BUS
GET /system/status 进入页自动探测 + 5s 健康检查 / 断线 2s 探测
EtherCAT 传输(经本地页面服务器 /ecat/api/* 代理到 tools/ecat 的 ecat_server.py):
EtherCAT 传输(经本地页面服务器 /ecat/api/* 代理到 tools/ecat 的 ecat_native.exe):
POST /api/motor/state 读后台 PDO 循环发布的快照(不碰总线,默认 50ms 读一次)
POST /api/motor/send 多帧塞进同一 PDO 周期的批量区(同一 seq 沿发出)
POST /api/motor/rx 消费批量接收区(CONFIG/版本/节点ID 应答走这里)
@@ -924,10 +924,10 @@ function scheduleProbe(delayMs) {
/**
* 把冗长的链路错误压成一句人能看的话。
*
* 为什么需要:ecat_server 会把整串诊断塞进 log[],SSC 邮箱楔死那条会带
* 为什么需要:EtherCAT 后端会把整串诊断塞进 log[],SSC 邮箱楔死那条会带
* **64 个一模一样的 MailboxError** 加一段 SM 寄存器现场,原样显示等于没显示。
* 这里做两件事:把重复项折叠成"×N",并对已知故障给出**该怎么办**
* —— 楔死是软件恢复不了的(ecat_server 自己的注释写着必须复位 MCU)。
* —— 楔死是软件恢复不了的(ecat_native.c 里同样注明要断电复位 MCU)。
*/
function summarizeLinkError(msg) {
let s = String(msg || "未知错误");
@@ -947,7 +947,9 @@ function summarizeLinkError(msg) {
if (/没有发现从站/.test(s)) {
return "总线上没有从站 —— 检查 DCU 板供电与网线是否接在该网口";
}
if (/venv|pysoem|Npcap/.test(s)) return s; /* 环境类错误原样给,它本身就是指引 */
/* 环境类错误原样给,它本身就是指引。token 对齐 native 代理 autostart 的实际文案:
* "找不到...守护进程" / "请先运行 build_native.bat" / "Npcap 未安装或 8090..8095 全被占"。 */
if (/Npcap|build_native|守护进程|全被占/.test(s)) return s;
/* 兜底:截断,完整内容留在 title 里 */
return s.length > 140 ? s.slice(0, 140) + "…" : s;
}
@@ -979,7 +981,7 @@ async function probeOnce() {
/* 把失败原因显示出来。
* 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP),
* 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作**
* (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / venv 缺 pysoem),
* (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / exe 没编译或 Npcap 未装),
* 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */
const raw = (e && e.message) ? String(e.message) : "未知错误";
state.probeErrFull = raw;
+2 -2
View File
@@ -24,7 +24,7 @@ export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 };
* 排队积压(tickStuck 亮黄),毫无收益。
* ecat 1ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
* 注意 1ms **不等于** 1000Hz:单飞语义下上一拍完成才发下一拍,
* 而每拍要过一次 HTTP(代理 → ecat_server → 回来),实测均值
* 而每拍要过一次 HTTP(代理 → ecat_native → 回来),实测均值
* ≈ 1.4ms,浏览器里 1ms 档实测约 125Hz。填 1 的语义是
* "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示,
* 见 UNTHROTTLED_MAX_MS。
@@ -42,7 +42,7 @@ export const REFRESH_MAX_MS = 5000;
* 而用户要的就是"能多快就多快";
* 2. 「≈N Hz」的理论值 —— 1ms 硬算是 1000Hz,浏览器里实测约 125Hz,那是谎报。
* 这时只显示按数据到达时间实测出来的频率。
* 取 20ms(50Hz):浏览器里每拍实际约 8ms(fetch + 代理 + ecat_server 全链),
* 取 20ms(50Hz):浏览器里每拍实际约 8ms(fetch + 代理 + ecat_native 全链),
* 20ms 留了足够余量不误判;又远小于常用档位(50/250ms),不会误伤正常设置。
*/
export const UNTHROTTLED_MAX_MS = 20;
+71 -28
View File
@@ -1,12 +1,12 @@
/**
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
*
* 后端是 tools/ecat/src/ecat_server.py(另一个 git 仓库),经本地页面服务器
* 后端是 tools/ecat 的 ecat_native.exe(C/SOEM 守护进程,另一个 git 仓库),经本地页面服务器
* 的 /ecat/api/* 反向代理同源访问 —— 那个服务只绑 127.0.0.1 且没有 CORS,
* 浏览器直连跨端口会卡在预检上,详见 esp32_server.py 的 _ecat_proxy 注释。
*
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
* 1. 服务没起 → 代理侧 _ecat_autostart() 用 tools/ecat\venv 拉起(已有实例只复用)
* 1. 服务没起 → 代理侧 _ecat_autostart() 拉起 tools/ecat\build_native\ecat_native.exe(已有实例只复用)
* 2. 网卡没开 → ensureOpen() 调 /api/open(网卡子串存 localStorage dcu_ecat_adapter)
* 3. 没进 OP → /api/motor/start 内部自己走 PREOP→SAFEOP→OP
* 4. 循环没跑 → /api/motor/start 起后台 PDO 循环(喂 1000ms 过程数据看门狗)
@@ -32,38 +32,38 @@
*/
"use strict";
/** 单个 PDO 周期回传的状态槽数(与 ecat_server.MOT_SLOTS_PER_PDO 一致) */
/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致) */
const MOT_SLOTS_PER_PDO = 7;
/** 状态槽总数 = 9 节点 × 3 通道(ecat_server.MOTOR_SLOT_COUNT) */
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT) */
const MOTOR_SLOT_COUNT = 27;
/** 扫完全表所需周期数(ecat_server.MOTOR_SCAN_CYCLES)。
/** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/7⌉ = 4)。
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 ——
* ecat_server 会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机
* 后端会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机
* 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */
const MOTOR_SCAN_CYCLES = 4;
/**
* 槽 seq 停止增长多久算离线(ms)。
* 为什么需要它:ecat_server 的快照把见过的槽**永久累积**,电机拔了也不会消失,
* 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则
* 慢周期下会把正常电机误判离线:period 10ms × 4 周期 = 40ms,取 2s 有充足余量。
*/
const STALE_OFFLINE_MS = 2000;
/** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */
function ecoreList(mask) {
function pcoreList(mask) {
const out = [];
let m = mask >>> 0;
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
/* 高 32 位(>31 的核,本机 E 核就在 16..27,但大核数机器会越过 31) */
/* 高 32 位(>31 的核,本机 P 核在 0..15,但大核数机器会越过 31) */
let hi = Math.floor(mask / 4294967296);
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (hi & 1) out.push(b + 32);
return out.sort((a, b) => a - b);
}
/** 连续核号压成区间:[16..27] → "16-27"。状态栏只有一行,
* 12 个核号全列出来会把从站名挤掉,而"16-27"信息量一样。 */
function ecoreRanges(list) {
/** 连续核号压成区间:[0..15] → "0-15"。状态栏只有一行,
* 核号全列出来会把从站名挤掉,而区间信息量一样。 */
function pcoreRanges(list) {
if (!list.length) return "";
const parts = [];
let s = list[0], p = list[0];
@@ -75,6 +75,13 @@ function ecoreRanges(list) {
return parts.join(",");
}
/** 周期显示:<1ms 用 µs("250µs"),否则 ms("0.25ms" 这种写法没人读)。
* 与后端 _fmt_period 保持一致,两边显示口径不能分叉。 */
function fmtPeriod(ms) {
if (!(ms > 0)) return "";
return ms < 1 ? `${Math.round(ms * 1000)}µs` : `${ms}ms`;
}
/** @description 创建 EtherCAT 传输实例。 */
export function createEcatTransport(ctx) {
const { recordSlow, isTransient } = ctx;
@@ -97,7 +104,7 @@ export function createEcatTransport(ctx) {
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
* 窗口轮到它才更新。ecat_server 的窗口自适应会把窗口收缩到只盖有主的槽,
* 窗口轮到它才更新。后端的窗口自适应会把窗口收缩到只盖有主的槽,
* 稳态下两者相等;但开机首圈、或多台电机分散落在多个窗口时,
* slotPeriodMs 会是 periodMs 的整数倍。状态栏显示这个才不谎报"数据多新"。 */
let slotPeriodMs = 0;
@@ -106,10 +113,14 @@ export function createEcatTransport(ctx) {
* CPU 核**。用户把刷新调到 1ms 档时默认就会自旋,不写清楚的话 CPU 占用
* 看起来像凭空冒出来的。可用 dcu_ecat_spin 显式覆盖("0"=强制内核睡眠)。 */
let spinOn = false;
/* 自旋线程被钉到的逻辑处理器位掩码(快照 ecore_mask)。0 = 没钉(对称 CPU
* 或探测失败)。混合架构下钉到 E 核才有意义,所以要把落在哪些核显示出来。 */
let ecoreMask = 0;
/* 自旋线程被钉到的逻辑处理器位掩码(快照 pcore_mask)。0 = 没钉(对称 CPU
* 或探测失败)。把落在哪些核显示出来,用户才知道自旋占的是哪些核。 */
let pcoreMask = 0;
const LS_SPIN = "dcu_ecat_spin";
/* 保存的 PDO 周期(ms,允许亚毫秒 float,如 0.25=4kHz)。用户把周期调到
* 内核级档位后存这儿,后端重启、循环重开时原样带回 —— 用户要的
* "后续随时可用"不该每次手敲一遍。恢复默认 = setPdoPeriod(4)。 */
const LS_PERIOD = "dcu_ecat_period_ms";
let started = false; // 后台循环是否已启动
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
@@ -139,7 +150,7 @@ export function createEcatTransport(ctx) {
throw new Error("EtherCAT 服务响应非合法 JSON");
}
if (!resp.ok) {
/* 代理连不上后端会给 503 + 同构信封;ecat_server 的 404 体里带
/* 代理连不上后端会给 503 + 同构信封;后端的 404 体里带
* "服务改过但没重启"的排查提示,两者都在 log[] 里,原样冒泡给用户。 */
const err = new Error(
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
@@ -150,7 +161,7 @@ export function createEcatTransport(ctx) {
return json;
}
/** ok:false 是 ecat_server 的常规业务错误信道(HTTP 仍是 200)。 */
/** ok:false 是后端的常规业务错误信道(HTTP 仍是 200)。 */
function unwrap(json, what) {
if (!json || json.ok !== true) {
const msg = (json && json.log && json.log.length) ? json.log.join(" / ") : (what + "失败");
@@ -171,7 +182,7 @@ export function createEcatTransport(ctx) {
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
* 用户还是得开另一个页面点两下才能用。
*
* 幂等:ecat_server 的 api_open 会先 close 掉旧 master 再重开,
* 幂等:后端的 open 路由会先 close 掉旧 master 再重开,
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
*/
async function ensureOpen() {
@@ -196,8 +207,15 @@ export function createEcatTransport(ctx) {
async function ensureStarted(reqPeriodMs) {
const body = {};
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。 */
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。
* 没显式给时带上 localStorage 里的上次取值(内核级 250us 档位能跨重启存活)。 */
if (reqPeriodMs) body.period_ms = reqPeriodMs;
else {
try {
const v = parseFloat(localStorage.getItem(LS_PERIOD));
if (v > 0) body.period_ms = v;
} catch (_) { /* 隐私模式忽略 */ }
}
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
let spinPref = null;
@@ -301,7 +319,7 @@ export function createEcatTransport(ctx) {
sendHint(ch) {
return "POST /api/motor/send \u00b7 CH" + ch +
(periodMs ? " \u00b7 PDO " + periodMs + "ms" : "");
(periodMs ? " \u00b7 PDO " + fmtPeriod(periodMs) : "");
},
/**
@@ -340,14 +358,14 @@ export function createEcatTransport(ctx) {
* 当成数据新鲜度 —— 那正是"看着 4ms 实际 16ms"的谎报。 */
if (periodMs) {
parts.push(slotPeriodMs && slotPeriodMs !== periodMs
? "PDO " + periodMs + "ms \u00b7 单槽 " + slotPeriodMs + "ms"
: "PDO " + periodMs + "ms");
? "PDO " + fmtPeriod(periodMs) + " \u00b7 单槽 " + fmtPeriod(slotPeriodMs)
: "PDO " + fmtPeriod(periodMs));
}
/* 自旋会占满一个核,属于用户能感知的代价,必须在状态栏可见而不是只写在文档里。
* 钉到 E 核时把核号一起写出来,用户才知道占的是能效核不是性能核。 */
* 钉到 P 核时把核号一起写出来,用户才知道占的是性能核。 */
if (periodMs && spinOn) {
const rng = ecoreRanges(ecoreList(ecoreMask));
parts.push(rng ? `⚡ 自旋节拍 · 钉在 E 核 ${rng}` : "⚡ 自旋节拍(占 1 核)");
const rng = pcoreRanges(pcoreList(pcoreMask));
parts.push(rng ? `⚡ 自旋节拍 · 钉在 P 核 ${rng}` : "⚡ 自旋节拍(占 1 核)");
}
return parts;
},
@@ -360,8 +378,11 @@ export function createEcatTransport(ctx) {
lastSnapshot = snap;
/* 单槽刷新周期随在线电机分布变化(窗口自适应),每拍跟着更新 */
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
/* PDO 周期也可能被别的入口热改过(rate_probe 脚本、另一个标签页),
* 每拍从快照同步,不然状态栏会一直显示本页面记忆里的旧周期。 */
if (snap.period_ms > 0) periodMs = snap.period_ms;
if (typeof snap.spin === "boolean") spinOn = snap.spin;
if (typeof snap.ecore_mask === "number") ecoreMask = snap.ecore_mask;
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
/* 有帧待取才去消费,省一次往返 */
const rxb = snap.rx_batch;
if (rxb && (rxb.count > 0 || rxb.pending > 0)) {
@@ -505,10 +526,32 @@ export function createEcatTransport(ctx) {
return spinOn;
},
/**
* 热换 PDO 周期(毫秒,允许亚毫秒:0.25 = 4kHz)。真机总线一送一回约
* 165µs,比这更快会被循环自然自限,报多少都不会弄坏东西。
*
* 走 /api/motor/period 而不是 /api/motor/start,理由与 setSpin 相同:
* motor/start 在总线互斥表里会先停循环,停手 >1s 从站掉 OP、重进可能楔死;
* 周期只是循环每拍重读的变量,热改全程喂狗不断流,随时可调可反复调。
* 亚毫秒周期自动就是自旋节拍(内核睡眠粒度 ~0.5ms 跑不出 250µs)。
*
* 取值存 localStorage:后端重启后 ensureStarted 会原样带回。
* 恢复默认:tp.setPdoPeriod(4)。
*/
async setPdoPeriod(ms) {
const v = Number(ms);
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
if (typeof r.period_ms === "number") periodMs = r.period_ms;
if (typeof r.spin === "boolean") spinOn = r.spin;
try { localStorage.setItem(LS_PERIOD, String(periodMs)); } catch (_) {}
return periodMs;
},
/** 诊断用(控制台 motorDbg.transport)。 */
debug() {
return {
periodMs, slotPeriodMs, spinOn, ecoreMask, ecores: ecoreList(ecoreMask),
periodMs, slotPeriodMs, spinOn, pcoreMask, pcores: pcoreList(pcoreMask),
started, adapter: adapterSubstr,
configs: configs.size, versions: versions.size,
slots: seqSeen.size, snapshot: lastSnapshot,
+1 -1
View File
@@ -6,7 +6,7 @@
*
* wifi ESP32 固件 80 端口的 HTTP API(原有实现,行为未变)
* ecat EtherCAT 过程数据,经本地页面服务器 /ecat/api/* 代理到
* tools/ecat/src/ecat_server.py
* tools/ecat 的 ecat_native.exe(C/SOEM 守护进程)
*
* 传输实例必须实现的接口(详见两个实现文件的注释):
* id / label / supportsOta / supportsPeriodSync