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 数量。
|
||||
|
||||
+42
-47
@@ -9,10 +9,10 @@
|
||||
GET / → 302 到 /tabs.html (标签外壳, 工具主页)
|
||||
GET /<file> → 本目录静态文件(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:
|
||||
|
||||
+7
-5
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
<title>电机控制台 — Motor CAN FD Console</title>
|
||||
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="../css/motor.css?v=20260904i">
|
||||
<link rel="stylesheet" href="../css/motor.css?v=20260904j">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -399,6 +399,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="../js/motor/main.js?v=20260904i"></script>
|
||||
<script type="module" src="../js/motor/main.js?v=20260905a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user