From c189a8563eadfcda0267905df5d2a57dc6d19125 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Sat, 5 Sep 2026 03:56:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(motor):=20ESP32=20=E4=BE=A7=E9=80=82?= =?UTF-8?q?=E9=85=8D=20EtherCAT=20native=20=E5=90=8E=E7=AB=AF,=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20Python=20=E6=97=B6=E4=BB=A3=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 已空。 --- README.md | 137 +++++++++++++++++++++++++------ esp32_server.py | 89 ++++++++++---------- js/motor/main.js | 12 +-- js/motor/refresh-rate.js | 4 +- js/motor/transport-ecat.js | 99 +++++++++++++++------- js/motor/transport.js | 2 +- pages/motor.html | 4 +- scripts/browser_check_period.mjs | 87 ++++++++++++++++++++ scripts/test_ecat_autostart.mjs | 6 +- scripts/test_motor_transport.mjs | 70 +++++++++++++++- 10 files changed, 394 insertions(+), 116 deletions(-) create mode 100644 scripts/browser_check_period.mjs diff --git a/README.md b/README.md index a09455e..76e0421 100644 --- a/README.md +++ b/README.md @@ -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 数量。 diff --git a/esp32_server.py b/esp32_server.py index c7f3051..5c88b30 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -9,10 +9,10 @@ GET / → 302 到 /tabs.html (标签外壳, 工具主页) GET / → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/) POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出) - POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_server.py 的 /api/<路径> + POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径> 为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输, -后者的后端 tools/ecat/src/ecat_server.py 只绑 127.0.0.1 且完全没有 CORS +后者的后端 tools/ecat 的 EtherCAT 服务只绑 127.0.0.1 且完全没有 CORS (既无 Access-Control-Allow-Origin, 也没有 do_OPTIONS —— 预检直接 501)。 我们的 API 是 POST + application/json, 属非简单请求, 浏览器必发预检, 因此页面无法直接跨端口调它。改那边要动另一个 git 仓库, 把页面搬过去又会让 @@ -26,7 +26,7 @@ WiFi/ECAT 分裂成两份页面, 所以在本服务做同源转发, 两个仓库 窗口模式为何要分两个进程: 实测 pythonw + pythonnet(pywebview 依赖) 环境下, Python 后台线程会被静默杀死(py-spy 可见 serve 线程凭空消失, 页面随即失联)。 -把 HTTP 服务放进独立子进程可彻底规避, 见 tools/ecat/ecat_server.py 同款结构。 +把 HTTP 服务放进独立子进程可彻底规避, 见 tools/ecat 窗口模式同款结构。 pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTML 响应注入 _reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。 @@ -115,36 +115,37 @@ g_win = None g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程 # ── EtherCAT 后端反向代理 ────────────────────────────────────────────── -# 电机页的 EtherCAT 传输要打 tools/ecat/src/ecat_server.py 的 /api/motor/*, +# 电机页的 EtherCAT 传输要打 tools/ecat 的 EtherCAT 服务(native)的 /api/motor/*, # 但那个服务**只绑 127.0.0.1 且完全没有 CORS**(无 Access-Control-Allow-Origin, # 也没有 do_OPTIONS —— 预检直接 501)。而我们的 API 是 POST + JSON, # 属非简单请求, 浏览器必发预检 → 从本页面(:8080)直接 fetch 它(:8090)必然失败。 # # 三种解法里选反向代理: -# 1) 改 ecat_server.py 加 CORS —— 它是**独立 git 仓库**, 不该为本页面改; -# 2) 把电机页搬进 ecat_server 的 _STATIC_MAP —— 那是硬白名单, 且会让 +# 1) 改 EtherCAT 后端加 CORS —— 那是另一个仓库, 不该为本页面改; +# 2) 把电机页搬进 EtherCAT 后端的静态白名单 —— 且会让 # WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求; # 3) 本服务代理 —— 页面永远同源(:8080), 两个仓库都不用动。 -# 端口自动探测: ecat_server 的 _bind_server 会在 8090..8095 间漂移。 +# 端口自动探测: EtherCAT 服务(含 native)会在 8090..8095 间漂移。 ECAT_PROXY_PREFIX = "/ecat/api/" ECAT_PORTS = (8090, 8091, 8092, 8093, 8094, 8095) g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 8090 试起 # ── EtherCAT 服务自动启动 ───────────────────────────────────────────── -# 电机页切到 EtherCAT 链路时, 不该要求用户先去双击 ecat_app.vbs。 +# 电机页切到 EtherCAT 链路时, 不该要求用户先手动起 EtherCAT 后端。 # 本服务在第一次代理请求时按需拉起 tools/ecat 的服务进程。 # -# 三条必须小心的地方(都在 ecat_server.py 里读出来的, 不是猜的): -# 1) **必须先探测再拉起**。ecat_server 的 _run_browser 在没有 --parent-pid 时 -# 会调 _kill_stale_instances(), 那会把用户已经开着的 EtherCAT 窗口 -# 优雅关掉(还会把从站带回 INIT)。所以已有实例时只复用, 绝不重复拉起。 +# 阶段 4 切换(2026-09-04): 后端从 Python(ecat_server.py, 已彻底删除含备份) +# 换成原生守护进程 build_native\ +# ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python +# 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约): +# 1) **必须先探测再拉起**。native 独立启动(无 --parent-pid)会清场: 对 +# 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。 +# 所以已有实例时只复用, 绝不重复拉起。 # 2) 拉起时**必须带 --parent-pid**: 一是跳过上面那个清场逻辑, 二是让它在 # 本服务退出后自我了断, 不留孤儿进程占着 Npcap 适配器和端口。 -# 3) 用 pythonw.exe + venv: pysoem/pywebview 都装在 tools/ecat\venv 里, -# 用系统 python 会 ImportError; 用 python.exe 会弹黑框。 +# 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。 ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat")) -ECAT_ENTRY = os.path.join(ECAT_DIR, "src", "ecat_server.py") -ECAT_PYW = os.path.join(ECAT_DIR, "venv", "Scripts", "pythonw.exe") +ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe") g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None) g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程 @@ -198,9 +199,9 @@ def _ecat_find_port(): def _ecat_autostart(): """按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。 - port 为 None 表示拉不起来(缺 venv / 缺文件 / 起了但没监听)。 + port 为 None 表示拉不起来(缺 exe / 起了但没监听)。 已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释: - ecat_server 独立启动会 _kill_stale_instances(), 把用户开着的窗口关掉。 + native 独立启动会清场(优雅关掉旧实例), 不能重复拉起。 """ global g_ecat_child, g_ecat_port with g_ecat_lock: @@ -210,13 +211,10 @@ def _ecat_autostart(): return port, [] logs = [] - if not os.path.isfile(ECAT_ENTRY): - return None, [f"找不到 EtherCAT 服务入口: {ECAT_ENTRY}"] - if not os.path.isfile(ECAT_PYW): + if not os.path.isfile(ECAT_NATIVE_EXE): return None, [ - f"找不到 EtherCAT 服务的 venv: {ECAT_PYW}", - "请在 tools/ecat 下重建: py -3.12 -m venv venv 后 " - "venv\\Scripts\\python.exe -m pip install -r requirements.txt", + f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}", + "请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。", ] # 复用已被本进程拉起但已退出的槽位 @@ -224,32 +222,29 @@ def _ecat_autostart(): g_ecat_child = None if g_ecat_child is None: try: - # --parent-pid: 跳过 ecat 侧的 _kill_stale_instances 清场, - # 并让它在本服务退出后自我了断(不留孤儿占 Npcap/端口)。 - # --no-browser: 我们只要后台服务, 不要它再弹一个浏览器标签。 - # cwd=src: ecat_server.py 与同目录 py 之间靠相对 import 解析。 + # --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后 + # 自我了断(不留孤儿占 Npcap/端口)。 + # cwd=tools/ecat: --web 的相对路径(src/web)按此解析。 g_ecat_child = subprocess.Popen( - [ECAT_PYW, ECAT_ENTRY, "--no-browser", + [ECAT_NATIVE_EXE, "--substr", "I226", "--web", "src/web", "--parent-pid", str(os.getpid())], - cwd=os.path.join(ECAT_DIR, "src"), + cwd=ECAT_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - env=dict(os.environ, PYTHONUTF8="1"), ) - logs.append(f"已自动启动 EtherCAT 服务(pid {g_ecat_child.pid})") + logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})") except OSError as e: return None, [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"] - # 等它把端口监听起来。pysoem 首次导入 + find_adapters 要一两秒, - # 给到 20s: 慢机器上 Npcap 枚举适配器偶发很久, 提前放弃会让用户 - # 看到"启动失败"而其实几秒后就好了。 - deadline = time.time() + 20 + # 等它把端口监听起来。native 启动即绑口(ec_init 是懒的, 在 open 路由里), + # 给 10s 足够; 老 Python 版要等 pysoem 导入 + Npcap 枚举, 才需要 20s。 + deadline = time.time() + 10 while time.time() < deadline: if g_ecat_child.poll() is not None: return None, logs + [ - f"EtherCAT 服务启动后立刻退出(exit={g_ecat_child.returncode}) —— " - "多为 venv 缺 pysoem 或未安装 Npcap; " + f"EtherCAT 守护进程启动后立刻退出(exit={g_ecat_child.returncode}) —— " + "多为 Npcap 未安装或 8090..8095 全被占; " "可双击 tools/ecat/ecat_web.bat 看具体报错"] port = _ecat_find_port() if port: @@ -257,11 +252,11 @@ def _ecat_autostart(): logs.append(f"EtherCAT 服务已就绪(端口 {port})") return port, logs time.sleep(0.3) - return None, logs + ["EtherCAT 服务启动超时(20s 内未监听端口)"] + return None, logs + ["EtherCAT 服务启动超时(10s 内未监听端口)"] def _ecat_post(port, path, body, timeout): - """向 ecat_server 转发一次 POST, 返回 (status, body_bytes)。 + """向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。 **不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms: 给 socket 设 timeout 会让 Python 把它切成非阻塞模式, 而 Windows 上非阻塞 @@ -270,7 +265,7 @@ def _ecat_post(port, path, body, timeout): urllib.urlopen(req, timeout=5) p50 11.0 ms http.client + timeout p50 10.7 ms 裸 socket 连接阶段不设 timeout p50 0.58 ms - ecat_server 自己只用 0.58ms, 那 10ms 全是这一层白交的过路费 —— + 后端自己只用 0.58ms, 那 10ms 全是这一层白交的过路费 —— 电机页 EtherCAT 链路的刷新上限直接被它按在 ~76Hz。 所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接 @@ -302,20 +297,20 @@ def _ecat_post(port, path, body, timeout): head, _, data = raw.partition(b"\r\n\r\n") first = head.split(b"\r\n", 1)[0].split(b" ", 2) if len(first) < 2 or not first[1].isdigit(): - raise OSError(f"ecat_server 回了无法解析的响应头: {head[:120]!r}") - # 404 等业务错误要原样透传: ecat_server 的 404 体里带 + raise OSError(f"EtherCAT 后端回了无法解析的响应头: {head[:120]!r}") + # 404 等业务错误要原样透传: 后端的 404 体里带 # "服务在改动之后没重启" 的排查提示, 吞掉它会让人白排查半天。 return int(first[1]), data def _ecat_proxy(path, body, timeout=6.0): - """把 /ecat/api/xxx 转发到 ecat_server 的 /api/xxx。 + """把 /ecat/api/xxx 转发到 EtherCAT 后端的 /api/xxx。 返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port: - ecat_server 的 _bind_server 会在 8090..8095 之间漂移(端口被占就挪), + EtherCAT 后端会在 8090..8095 之间漂移(端口被占就挪), 写死 8090 会在它挪了之后表现为"页面点了没反应"。 - 连不上时返回 503 + 与 ecat_server 同构的 {"ok":false,"log":[...]} 信封, + 连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封, 这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。 """ global g_ecat_port @@ -411,7 +406,7 @@ class Handler(SimpleHTTPRequestHandler): threading.Thread(target=_shutdown_self, daemon=True).start() return if path.startswith(ECAT_PROXY_PREFIX): - # 电机页的 EtherCAT 传输: 同源转发到 ecat_server(见 _ecat_proxy 注释)。 + # 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 _ecat_proxy 注释)。 # 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次, # 实测最坏 ~24s, 用默认 6s 会在正常进 OP 的过程中假失败。 try: diff --git a/js/motor/main.js b/js/motor/main.js index 7a6896a..69e73a7 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -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; diff --git a/js/motor/refresh-rate.js b/js/motor/refresh-rate.js index 56a4360..1fb41d4 100644 --- a/js/motor/refresh-rate.js +++ b/js/motor/refresh-rate.js @@ -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; diff --git a/js/motor/transport-ecat.js b/js/motor/transport-ecat.js index 8a6066c..2f69ba6 100644 --- a/js/motor/transport-ecat.js +++ b/js/motor/transport-ecat.js @@ -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, diff --git a/js/motor/transport.js b/js/motor/transport.js index 51abd34..5f67bf0 100644 --- a/js/motor/transport.js +++ b/js/motor/transport.js @@ -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 diff --git a/pages/motor.html b/pages/motor.html index c5bcf8b..16038ad 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -6,7 +6,7 @@ 电机控制台 — Motor CAN FD Console - + @@ -399,6 +399,6 @@ - + \ No newline at end of file diff --git a/scripts/browser_check_period.mjs b/scripts/browser_check_period.mjs new file mode 100644 index 0000000..e9d35b6 --- /dev/null +++ b/scripts/browser_check_period.mjs @@ -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); diff --git a/scripts/test_ecat_autostart.mjs b/scripts/test_ecat_autostart.mjs index 6b2c85f..c45ee2f 100644 --- a/scripts/test_ecat_autostart.mjs +++ b/scripts/test_ecat_autostart.mjs @@ -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) { diff --git a/scripts/test_motor_transport.mjs b/scripts/test_motor_transport.mjs index b5a0056..046445f 100644 --- a/scripts/test_motor_transport.mjs +++ b/scripts/test_motor_transport.mjs @@ -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) => {