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:
@@ -16,8 +16,8 @@
|
||||
| `serve.bat` | 控制台模式 | 端口 8080,窗口内可见服务器日志,用于调试 |
|
||||
|
||||
窗口模式为何拆两个进程:`pythonw` + `pythonnet`(pywebview 依赖)环境下 Python
|
||||
后台线程会被静默杀死,把 HTTP 服务放进独立子进程可规避,与 `tools/ecat/ecat_server.py`
|
||||
同款结构。pywebview 窗口没有地址栏与刷新按钮,因此服务器会向 HTML 响应注入
|
||||
后台线程会被静默杀死,把 HTTP 服务放进独立子进程可规避,与 `tools/ecat` 的
|
||||
窗口模式同款结构。pywebview 窗口没有地址栏与刷新按钮,因此服务器会向 HTML 响应注入
|
||||
`_reload.html`(右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 拦截)。
|
||||
|
||||
`build_mem.bat`(在 `scripts/`)是从 Git Bash 调用 ESP-IDF 构建 `DCU/ESP32_COMM` 的辅助脚本
|
||||
@@ -76,12 +76,12 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
| 链路 | 后端 | 状态 | 发送 |
|
||||
|------|------|------|------|
|
||||
| `wifi` | ESP32 固件 80 端口 | `GET /motor/state?bin=1` | `POST /can/send_batch` / `send_burst` |
|
||||
| `ecat` | `tools/ecat` 的 `ecat_server.py`(经本服务 `/ecat/api/*` 代理) | `POST /api/motor/state` 读后台 PDO 快照 | `POST /api/motor/send`(多帧同一 seq 沿) |
|
||||
| `ecat` | `tools/ecat` 的 `ecat_native.exe`(C/SOEM 守护进程,经本服务 `/ecat/api/*` 代理) | `POST /api/motor/state` 读后台 PDO 快照 | `POST /api/motor/send`(多帧同一 seq 沿) |
|
||||
|
||||
- 接口定义见 `js/motor/transport.js` 头部;两个实现分别在 `transport-wifi.js` / `transport-ecat.js`。
|
||||
**业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控
|
||||
——互斥/退避/冷却/latest-wins)两条链路完全共用**,新增链路只实现接口即可。
|
||||
- **为什么要 `/ecat/api/*` 反向代理**:`ecat_server.py` 只绑 `127.0.0.1` 且没有 CORS
|
||||
- **为什么要 `/ecat/api/*` 反向代理**:EtherCAT 后端只绑 `127.0.0.1` 且没有 CORS
|
||||
(无 `Access-Control-Allow-Origin`,也没有 `do_OPTIONS` —— 预检直接 501),
|
||||
而本页 API 是 POST + JSON 属非简单请求,浏览器必发预检,跨端口直连必失败。
|
||||
改那边要动另一个 git 仓库,把页面搬过去又会让两种链路分裂成两份页面,
|
||||
@@ -182,14 +182,17 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
实测 p50 回到 0.56ms 基线。`perf_counter` 与 QPC 同一时间基(实测差 ≤6µs),
|
||||
所以截止点可以直接用 `perf_counter()` 算。
|
||||
|
||||
**坑 2:自旋要钉到 E 核,别抢 P 核。** 它必然 100% 占住一个逻辑处理器,
|
||||
自由调度会落在 P 核上,而 P 核还要跑 HTTP 线程和解释器主线程。本机
|
||||
i7-14700K(8P/16E)钉到 E 核后:1ms 档 **968 Hz**、HTTP p50 仅 1.49ms。
|
||||
**坑 2:自旋要钉到 P 核(快核组)。** 它必然 100% 占住一个逻辑处理器,自由
|
||||
调度会乱跳。本机 i7-14700K(8P/16E)—— 早期为"别抢 HTTP 的 P 核"钉到 E 核,
|
||||
但 2026-09-05 实测发现超时拍主体是网卡驱动抖动、与钉哪组核无关,且 E 核单核
|
||||
性能弱、解释器段更长,遂按用户要求改回钉 P 核:让节拍跑在单核性能最高的核上,
|
||||
HTTP 线程溢出到组内其余核(16 个 P 线程 ≫ 并发 HTTP 数)。改向后真机 250µs 档
|
||||
5×2000 拍复测 **99.3~99.6%**,与钉 E 核基线(99.4~99.7%)持平,无回退。
|
||||
核号是**实测**出来的(把线程逐个钉到每个逻辑处理器跑同一段纯计算,按
|
||||
**最大间隙**分快慢两组)—— 这台机器上 `GetLogicalProcessorInformationEx`
|
||||
**最大间隙**分快慢两组、取快的那组)—— 这台机器上 `GetLogicalProcessorInformationEx`
|
||||
直接 `ERROR_BAD_LENGTH`、`GetSystemCpuSetInformation` 拿不到 EfficiencyClass、
|
||||
`GetDynamicProcessorCounts` 未导出、处理器组只有一个,没有可用的 API 捷径。
|
||||
用间隙法而非固定倍数(如 1.35×最快),因为被 SMT 兄弟拖累的 P 核也能到
|
||||
用间隙法而非固定倍数(如 0.75×最慢),因为被 SMT 兄弟拖累的 P 核也能到
|
||||
16.7ms,固定倍数会把它误判成 E 核。分不出两类核时返回 0 = 不钉。
|
||||
探测在服务启动时后台做(约 1.6s),**绝不能在 PDO 线程里现场探测** ——
|
||||
那 1.6s 不喂过程数据,从站 1000ms 看门狗会把它踢出 OP。
|
||||
@@ -199,18 +202,99 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
- `motor/start` 接受 `spin`,但**改这一项请走 `/api/motor/spin`** —— 它在
|
||||
`_BUS_EXCLUSIVE_PATHS` 之外,只翻标志;`motor/start` 会先停循环,停手超过
|
||||
看门狗就把好板子弄出楔死了(实测踩过两次);
|
||||
- 前端状态栏显示 `⚡ 自旋节拍 · 钉在 E 核 16-27`,可用
|
||||
- 前端状态栏显示 `⚡ 自旋节拍 · 钉在 P 核 0-15`,可用
|
||||
`motorDbg.state.tp.setSpin(false)` 或 localStorage `dcu_ecat_spin=0` 关掉;
|
||||
环境变量 `ECAT_PIN_ECORES=0` 不钉核、`ECAT_ECORE_MASK=0x0F0000` 手工指定。
|
||||
- 自测第 11~14 项分别守:自旋精度、不持 GIL、E 核分类与线程级隔离、
|
||||
环境变量 `ECAT_PIN_PCORES=0` 不钉核、`ECAT_PCORE_MASK=0xFFFF` 手工指定。
|
||||
- 自测第 11~14 项分别守:自旋精度、不持 GIL、P 核分类与线程级隔离、
|
||||
`api_motor_start` 的 OP 状态核实。
|
||||
- **内核级周期可热调到 250µs(4kHz),随时可改**。`period_ms` 已是 float,
|
||||
`/api/motor/period`(`_BUS_EXCLUSIVE_PATHS` 之外,不停循环、喂狗不断流,
|
||||
理由同 `/api/motor/spin`)接受 `period_ms`(float) 或 `period_us`,运行中随时调、
|
||||
可反复调,下一拍即生效;`motor/start` 也收 float,但只用于冷启动。
|
||||
- 物理墙:真机一送一回 ≈165µs。下限 `MOTOR_PERIOD_MIN_MS`=62.5µs 只是**允许
|
||||
请求**的表达值,比 165µs 更快的档自然自限(实测率 < 目标),不会弄坏循环;
|
||||
保底 4kHz(250µs) 在预算内。亚毫秒自动就是自旋节拍(睡眠粒度睡不出 250µs;
|
||||
强制 `spin:false` 时后端会明确警告)。
|
||||
- 假从站 0.25ms 档实测 **3979Hz**(自测第 15 项;假从站收发是纯内存操作,
|
||||
该项证明"自旋+float+热调"机制本身驱动得到 4kHz 档,不含真机总线时间)。
|
||||
- **真机总线负载下的忠实代理(自测第 16 项)**:给假从站收发注入按真机分段
|
||||
实测拆开的 166µs(send 48 + receive 118)且用 `_spin_until` 释放 GIL(与真机
|
||||
pysoem 在 Npcap 调用期间放 GIL 一致),网格算法在 250µs 档**精确收敛到
|
||||
4000Hz**。这证明真机旧算法 3680Hz(92%) 的缺口来自"每拍迟到累进周期",
|
||||
网格修复消除了它 —— 是真机 4kHz 的强预测(仍非真机终验,断电后跑
|
||||
`rate_probe.py --periods 0.25` 确认)。
|
||||
- 节拍用**绝对网格截止点**(锚点 + n×周期),不是"本拍 t0 + 周期":后者把
|
||||
每拍自旋/重拿 GIL 的迟到 ε 累进周期(250µs 档 ε≈30µs → 只有 89%);
|
||||
超期时**跳过**过期网格拍而不是挪锚点(挪锚点把零头白丢)。配套给自旋态
|
||||
线程挂 TIME_CRITICAL(钉核只选了核,不保证该核上谁先跑;回落 NORMAL
|
||||
在不自旋时)。真机极限(wkc 全程 3/3,最终网格+跳过拍代码,2026-09-04 归因复测,
|
||||
rate_probe 测速 bug 修复后的口径,见下两条):
|
||||
**250µs→3977Hz(99.5%)**、**200µs→4949Hz(99%,即 ~4.9kHz)**、
|
||||
150µs→5672Hz(85%,work p50≈170µs 物理自限) —— 保底 4kHz 达成:250µs 档 99.5%,
|
||||
要硬 ≥4000Hz 跑 200~240µs 档实测 ~4.9kHz。旧算法(本拍 t0+周期)同档只有
|
||||
3680Hz(92%),网格修复把缺口压到 0.5%。
|
||||
- 为什么此前 96~97% 对周期不敏感(2026-09-04 归因结论):**观测代码自身是热循环里
|
||||
最大的流程不稳定源**。`cyc_us` 分位数原先每 8 拍在 PDO 循环内重算一次,每次排序
|
||||
7 段 × 400 样本 ≈400~600µs,250µs 档一次就吃掉整拍还多 —— 固定节拍触发、与周期
|
||||
长短无关,所以 250µs 和 200µs 成功率几乎一样;铁证:迟到拍的相邻间隔恰好落在
|
||||
mod-8 位置(194/250 对)。修复:分位数彻底移出循环,读侧 `_cyc_fresh`(0.25s TTL,
|
||||
在 HTTP 请求线程现算),PDO 循环每拍只做 O(1) 的 5 点时间线 append。GC 洗清
|
||||
(开机以来 gen0 仅 7 次、最大 89µs、零次落在超时窗内)。剩余 ~0.5% 空载损耗按拍内
|
||||
分段拆到底了(15s 完全不碰服务、只末尾单次导出,超时拍 2.11%、真丢吞吐 Σ(k-1)=0.55%,
|
||||
与 rate_probe 的 99.5% 对齐):**57% 主导段是 send_processdata**(基线 45µs,超时拍
|
||||
98~190µs —— Npcap/I226 驱动发送路径抖动),**43% 是 receive 等待**(基线 100µs,
|
||||
尾部到 3ms —— 从站响应 + NIC RX DPC);而 pre(拿锁+重拿 GIL)max 仅 7µs,**证明空载
|
||||
时那 0.5% 根本不是 GIL 争抢**,是 pysoem 两次调用自身的驱动抖动,Python 侧已无税可削。
|
||||
UI 轮询叠加时才另加 ~0.3~1%(cadence_diag 在 hammer 下把部分超时拍归因到 HTTP 时间窗,
|
||||
但空载超时率 2.11% 与 hammer=1 的 2.10% 几乎相同 → 超时拍主体是驱动固有抖动,非 HTTP)。
|
||||
两个测量陷阱:① `/api/motor/dbg` 本身很重(一次 dump 2048 拍元组 + json 序列化长时间
|
||||
持 GIL),连续抓会自污染,归因必须"静默一段后单次导出";② skip_total/Σk 会高估损失
|
||||
(一拍微超就把整个过期网格拍跳过记 k=1,实际只丢超出松弛量的部分),**真丢吞吐 = Σ(k-1)**。
|
||||
归因工具:`/api/motor/dbg`(每拍时间线+网格跳过数+GC/HTTP 事件
|
||||
日志,读内存不碰总线、不进独占路由)与 `tools/ecat/cadence_diag.py`(把迟到拍与
|
||||
GC/HTTP 时间窗求交集做归因,stdlib-only)。
|
||||
- **测速脚本自己也曾是"假负载"**(2026-09-04 二次踩坑):rate_probe 旧版
|
||||
`sample()` 的分子取最后一次轮询的 cycles、分母却在循环退出后才算,退出前那次
|
||||
`sleep(0.1)` 让 dt 比 cycles 窗口多 0.1s —— Hz 被系统性低估 3~5% 且与周期无关,
|
||||
把真实的 99.2% 报成 96%,一度被误读成"HTTP 轮询负载吃掉 3 个点"。修复后读数与
|
||||
cadence_diag 的拍间时间线(不依赖 cycles 轮询)互相印证。教训:**两条独立测量
|
||||
路径对不上时,先怀疑测量本身,再怀疑系统**;带载/空载的区分要用拍间时间线做基准。
|
||||
- 前端:`motorDbg.state.tp.setPdoPeriod(0.25)`(恢复默认 `setPdoPeriod(4)`)。
|
||||
取值存 localStorage `dcu_ecat_period_ms`,后端重启后由
|
||||
`ensureStarted` 自动带回;状态栏 <1ms 显示 µs(`PDO 250µs`,与后端
|
||||
`_fmt_period` 同口径),且每拍从快照同步周期 —— 别的标签页或脚本改的
|
||||
不会显示成过期值。
|
||||
- 极限测速复用脚本:`tools/ecat/rate_probe.py`(只用标准库,对正在跑的
|
||||
服务加压):逐级下发周期档,报实测 Hz/达成率/cyc_us/HTTP p50/wkc,
|
||||
wkc 不达预期自动停止加压,测完自动恢复测前周期。**循环没跑时会自动
|
||||
open+start**,所以断电重启板子后直接敲这一条命令即可,不必先开电机页;
|
||||
自动 start 失败(典型=邮箱楔死)会如实打印原因。`--no-autostart` 可关掉。
|
||||
- **原生 PDO 守护进程(A:把热循环从 Python 换成 C/SOEM;现已是生产后端)**。
|
||||
pysoem 本就是 SOEM(C) 的包装,热循环的 ~5kHz 天花板来自解释器/GIL,不是总线。
|
||||
`tools/ecat/` 下 `ecat_native.c` + `build_native.bat`(MSVC 直编 SOEM 源码,
|
||||
`soem-src/` 从 pysoem sdist 解出),产出 `build_native/ecat_native.exe`:
|
||||
独占 I226、自己 bring-up 进 OP、跑网格+自旋+分段计时的 PDO 线程。**阶段 4
|
||||
(2026-09-04)已完成 big-bang 切换**:native 复刻全部 24 条路由(含 SDO/EEPROM/
|
||||
flash/sync_diag 与 shutdown/focus/parent-pid/端口漂移/陈旧实例清场契约),
|
||||
接管 8090,Python 版已彻底删除(含备份)。
|
||||
迁移全程与逐字段对拍记录见 `tools/ecat/MIGRATION.md`。
|
||||
当年原型阶段的**真机实测(2026-09-05,wkc 3/3)**:work p50 从 Python 的
|
||||
~176µs 降到 **~135µs**(解释器税没了),250µs 档超时拍 2.11%→**0.45%**,
|
||||
天花板从 Python 的 ~5kHz 抬到 **~7kHz**(125µs 档 7050Hz、62.5µs 档 7070Hz,
|
||||
平台期 = 总线一送一回 ~135µs 的物理墙,不再是 Python 墙)。**结论**:换语言
|
||||
只值"5k→7k + 尾抖动干净",那 0.5% 的驱动周期维护停顿(~2.5s 一次)与语言无关、
|
||||
依旧存在;要再快得出网卡栈(换 I210 / Linux PREEMPT_RT)。bring-up 踩坑:SOEM
|
||||
master 的全局 `ecx_context` 初始化器漏了新增的 DCtO/DCl 两字段(MSVC 下 C1311),
|
||||
已在 `soem-src` 打补丁;且状态迁移必须带 ACK 位(SSC 对带错误位且不含 ACK 的
|
||||
请求直接忽略),否则卡 PREOP。
|
||||
- **EtherCAT 链路全自动接入**,切过去即用,不需要先双击 `tools/ecat` 的 vbs。
|
||||
四级按需自愈,每级都幂等(已连接就只复用,不重复动总线):
|
||||
1. **服务没起** → `esp32_server._ecat_autostart()` 用 `tools/ecat\venv\Scripts\pythonw.exe`
|
||||
拉起(实测约 16s 就绪)。两个要点:**必须先探测再拉起**,因为 ecat_server
|
||||
独立启动时会 `_kill_stale_instances()`,那会把用户已开着的 EtherCAT 窗口
|
||||
优雅关掉;拉起时**必须带 `--parent-pid`**,既跳过那段清场,也让它随本服务
|
||||
退出而自我了断,不留孤儿占着 Npcap 适配器和端口。
|
||||
1. **服务没起** → `esp32_server._ecat_autostart()` 直接拉起
|
||||
`tools/ecat\build_native\ecat_native.exe`(阶段 4 起不再经 venv/pythonw,
|
||||
启动即绑口,实测 <1s 就绪)。两个要点不变:**必须先探测再拉起**,因为
|
||||
native 独立启动时会清场(对 8090..8095 逐个优雅 shutdown,那会把用户已
|
||||
开着的实例关掉、从站带回 INIT);拉起时**必须带 `--parent-pid`**,既跳过
|
||||
那段清场,也让它随本服务退出而自我了断,不留孤儿占着 Npcap 适配器和端口。
|
||||
2. **网卡没开** → `ensureOpen()` 调 `/api/open`,代替用户去 `ecat.html` 点"连接"。
|
||||
网卡匹配子串在工具栏「链路」右边的输入框里改(默认 `I226`,
|
||||
存 `localStorage` 的 `dcu_ecat_adapter`)。
|
||||
@@ -222,17 +306,18 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
(`summarizeLinkError`),完整诊断放在两者的 `title` 里。
|
||||
典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成
|
||||
"需复位 DCU 板 MCU 才能恢复" —— 这个故障软件无法自愈
|
||||
(见 `ecat_server.py` 的 `_kill_stale_instances` 注释)。
|
||||
(成因与规避的完整记录在 `tools/ecat/MIGRATION.md` 阶段 1/4 与
|
||||
`tools/ecat/ecat_native.c` 的 `kill_stale_instances()` 注释)。
|
||||
- 自测:
|
||||
- `node scripts/test_motor_transport.mjs --port <页面服务端口>` —— 传输契约
|
||||
(真实 import 生产代码打真实代理;EtherCAT 实机段在板子不可用时会自动跳过,
|
||||
因此项数会随板子状态在 33/36~43 之间浮动,`失败 0` 才是判据)。
|
||||
- `python tools/ecat/src/test_motor_backend.py` —— EtherCAT 后端(假从站,
|
||||
不接硬件,14 项)。第 9/10 项专守窗口自适应:**稀疏总线**(27 槽只有 2 个
|
||||
有主)下单槽命中率要 >90%(旧实现只有 25%),以及热插的电机能被发现并
|
||||
纳入常规窗口(含 slot 21..26 这段尾部)。注意 1~8 项用的假从站把 27 个槽
|
||||
**全填满**,那正是自适应毫无收益的最坏情况,测不出窗口浪费,
|
||||
所以第 9 项换了个 `SparseSlave`。
|
||||
- `python tools/ecat/native_enc_diff.py` —— CAN 编码回归(native enctest 路由
|
||||
输出 vs `native_enc_golden.json` 金标准,4 组用例逐字节,byte0=seq 状态字节
|
||||
除外 + seq 递增校验;不碰网卡,native 在跑即可)。金标准是阶段 2 与 Python
|
||||
`_fill_can_out` 逐字节对拍通过后固化的;改 `fill_can_out`/`pack_tx_batch` 后必跑。
|
||||
原 16 项假从站后端自测(含窗口自适应 SparseSlave 专项)已随 Python 后端删除,
|
||||
其覆盖结论记录在 `tools/ecat/MIGRATION.md`。
|
||||
- `node scripts/test_ecat_autostart.mjs` —— 自动接入链条(stub 后端,
|
||||
不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。
|
||||
- `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(53 项,
|
||||
@@ -246,6 +331,10 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
`node scripts/browser_check_1ms.mjs [port]` 验 ECAT 1ms 档(24 项:`min`/`step` 同步、
|
||||
通过浏览器原生校验、实测 124 Hz、无卡顿黄标、面板 Hz 不谎报、
|
||||
切 WiFi 时 1ms 被钳到 50)。
|
||||
`node scripts/browser_check_period.mjs [port]` 验内核级周期热调(12 项:
|
||||
`setPdoPeriod(0.25)` 走 `/motor/period` 不碰 `/motor/start`、状态栏显示
|
||||
`PDO 250µs`(亚毫秒用 µs)、自动自旋标注、localStorage 持久化、页面重载后
|
||||
`ensureStarted` 自动带回、换回 4ms 自旋标注消失)。
|
||||
- 监控 UI 需区分 `valid`(通信是否成功)与 `count > 0`(是否有数据),
|
||||
以便分辨「正常空闲」和「真实通信错误」。
|
||||
- 响应式 grid 表隐藏列用 `0fr` 宽度实现,不要改变 grid track 数量。
|
||||
|
||||
Reference in New Issue
Block a user