feat(motor): 支持 WiFi 与 EtherCAT 双链路传输

- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用
- 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题
- 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询
- 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
This commit is contained in:
cbb
2026-09-04 12:18:06 +08:00
parent 31b90f68b3
commit 2d1743ca4f
15 changed files with 2889 additions and 211 deletions
+180 -1
View File
@@ -45,7 +45,7 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
| `pages/index.html` | OTA 中继控制台(ESP32 WiFi OTA / GD32 relay / BLE OTA) | `js/app.js`、`js/ota/*`、`js/ble_ota.js`、`js/ble/*` |
| `pages/can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` |
| `pages/can_monitor.html` | CAN 接收监控(独立页;也被 `can.html` 内嵌复用) | `js/can_monitor.js` |
| `pages/motor.html` | 电机控制台(CAN FD) | `js/motor/main.js`、`js/motor/tick.worker.js` |
| `pages/motor.html` | 电机控制台(CAN FD,WiFi / EtherCAT 双链路) | `js/motor/main.js`、`js/motor/transport*.js`、`js/motor/tick.worker.js` |
| `lab/continuity_test.html` | LAN9252 通断测试(自包含,含 WebGL 液态玻璃效果) | 内联 |
| `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI 链路测速,完全自包含,删文件即可移除 | 内联 |
@@ -67,6 +67,185 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
- OTA 流程以设备端为真相来源:WiFi OTA 由设备回读分区算 SHA-256 后自动重启;
BLE OTA 由前端发 `0xF8 + 32B SHA-256`、设备端比较并通过 FINISH 事件报结果
(前端不做 SHA 预比较);GD32 relay 状态以 `/system/status` 为准。
## 电机控制台的双链路(WiFi / EtherCAT)
同一个页面、同一套解码器与界面,只有"数据怎么进出"这一层可换,
由工具栏「链路」下拉框切换(记在 `localStorage` 的 `dcu_motor_transport`,默认 WiFi):
| 链路 | 后端 | 状态 | 发送 |
|------|------|------|------|
| `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 沿) |
- 接口定义见 `js/motor/transport.js` 头部;两个实现分别在 `transport-wifi.js` / `transport-ecat.js`。
**业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控
——互斥/退避/冷却/latest-wins)两条链路完全共用**,新增链路只实现接口即可。
- **为什么要 `/ecat/api/*` 反向代理**:`ecat_server.py` 只绑 `127.0.0.1` 且没有 CORS
(无 `Access-Control-Allow-Origin`,也没有 `do_OPTIONS` —— 预检直接 501),
而本页 API 是 POST + JSON 属非简单请求,浏览器必发预检,跨端口直连必失败。
改那边要动另一个 git 仓库,把页面搬过去又会让两种链路分裂成两份页面,
所以在 `esp32_server.py` 里同源转发(端口在 8090-8095 间自动探测,见 `_ecat_proxy`)。
- EtherCAT 链路的三处已知差异(都在 `transport-ecat.js` 头注释里写明):
1. 每通道 TEC/REC/err 过程数据里没有 → 一律回 `null` 让界面显示 `--`,
不编 0 冒充正常(区分"通信正常但无此数据"与"真的是 0");
2. CONFIG(`0x000N0101`) / 版本(`0x000N00FF`) / 节点 ID 应答不在状态窗口里,
由传输层从批量接收区累积后回填,上层无感;
3. 没有固件上传 + GD32 relay 通路 → `supportsOta=false`,OTA 页签自动置灰。
- EtherCAT 的 PDO 周期由后台循环独立维持(从站 SM2 有 **1000ms 过程数据看门狗**,
停超 1 秒会 OP→SAFEOP 掉线报 AL `0x001B`),与界面刷新率无关;
故切走链路时**不停**后台循环。
- **刷新速度按链路各记一份**(`state.refreshByTp`,纯逻辑在 `js/motor/refresh-rate.js`),
切链路时自动换成该链路自己的值,界面输入框(含 `min`/`step`)同步回填、节拍立即重排:
| 链路 | 默认 | 下限 | 为什么 |
|------|------|------|--------|
| `wifi` | 250 ms | 50 ms | 每拍是一次无线 HTTP 往返,还要与 GD32 `0xC5` 主动推送周期对齐;再快只会让请求排队积压 |
| `ecat` | 50 ms | **1 ms** | 只是读本机后台 PDO 快照,无总线代价;总线周期另由后台循环维持(默认 4 ms) |
两条链路**不能共用一个变量**:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
(`tickStuck` 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
旧数据(早期只存单个 `refreshRate`)只迁进 `wifi` —— 那个值是为无线调的,
不该套到 ECAT 上。
- **ECAT 的 1ms 语义是「不限速」而不是 1000Hz**(`UNTHROTTLED_MAX_MS = 20`)。
单飞语义下上一拍完成才发下一拍,而每拍要过一次 HTTP(代理 → `ecat_server` → 回来),
浏览器里 1ms 档实测约 **125 Hz**。
所以低于 20ms 的档位要**同时**关掉两处显示,否则界面会撒谎:
1. `tickStuck` 卡顿黄标 —— 该档每拍都会撞上"上一拍还在飞",这是设计行为,
再报"连续 N 拍未按时完成"就是误报;
2. 「≈N Hz」理论值 —— 1ms 硬算是 1000Hz。这时只显示 `statusHzText`
按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s,
否则 `1ms × 2 × 10 = 20ms` 的旧数据 cutoff 会把刚到的样本全清空)。
- **代理转发绝不能给 socket 传 timeout**(`_ecat_post`,已踩过)。
给 socket 设 timeout 会让 Python 把它切成非阻塞模式,而 **Windows 上非阻塞
connect 要靠 select 轮询完成,于是撞上约 10ms 的系统调度粒度**。
同一个 `/api/motor/state` 请求实测(本机回环,200 次均值):
| 客户端写法 | 平均耗时 | 慢拍(>8ms) | 背靠背吞吐 |
|------------|----------|-----------|-----------|
| `urllib.urlopen(req, timeout=5)` | 5.38 ms | 70/200 | 186 Hz |
| 裸 socket,连接阶段不设 timeout | **1.43 ms** | **0/200** | **699 Hz** |
| 直连 `ecat_server`(不过代理) | 0.70 ms | 0/200 | 1421 Hz |
`ecat_server` 自己只要 0.7ms,那 10ms 全是代理这一层白交的过路费 ——
排查时很容易误判成"EtherCAT 就这么快"。现在 `_ecat_post` 手写最小 HTTP/1.0
客户端:`create_connection` 不传 timeout(本机回环要么立刻成功要么立刻
`ECONNREFUSED`,不会挂住),连上之后才 `settimeout` 管收发。
这一改让 1ms 档从 62 Hz 提到 125 Hz。
- **别把 EtherCAT 当以太网用:PDO 窗口要自适应**(`ecat_server._cover_bases`,已踩过)。
从站一个 PDO 只带 7 个状态槽,而槽表有 27 个(9 节点 × 3 通道是**容量**,
不是实际接入数)。旧实现每周期把窗口 `+7` 无脑轮扫 4 个窗口,于是
**单台电机**的状态只有 `周期率 / 4` 的更新速度 —— 实测 4ms 周期下总线跑
225 Hz,而每个槽只有 **56 Hz**;现场只接 2 台电机时,4 个窗口里有 3 个
整窗都在传空槽(280B/周期只有 80B 有效)。
关键事实(读固件 `ethercat_can_bridge.cpp` 的 `buildInput` 得到):从站的
`win_base` 可以是**任意槽号**,它只是从 `base` 开始逐槽填到越界即停,
并不要求 `base` 是 7 的倍数。所以让窗口盖住"有主的那几个槽"即可,
稀疏总线上一个窗口就够 —— 在线电机每个 PDO 周期都刷新,快 4 倍,
总线开销一点没多加。两个必需的配套:
1. **开机首圈先走完整轮全表扫描**再切自适应。否则第一个窗口刚看见 1 号电机
就把覆盖集合收缩到那一个窗口,后面的槽再也扫不到(自测第 2 项会炸);
2. **周期性插入发现扫描**(`MOTOR_DISCOVERY_EVERY = 64` 周期)找热插上来的
电机,游标必须走 `MOTOR_DISCOVERY_BASES`。朴素的"每次 +7、越界归零"在
27 槽 / 7 槽一窗时是 `0,7,14` 之后到 21 > 20 直接绕回 0,
于是 **slot 21..26 永远扫不到**,挂在 node8/node9 上的电机再也发现不了
(自测第 10 项专门守这条)。
单槽刷新周期由快照的 `slot_period_ms` 回报,前端状态栏在它与 PDO 周期
不相等时会**两个都显示**(`PDO 4ms · 单槽 16ms`)—— 只报 PDO 周期就是
谎报数据新鲜度。
- **PDO 周期压不进 1ms 不是 EtherCAT 的错,是 Windows 内核睡眠的粒度**
(`ecat_server._motor_wait`)。把一次周期分段计时(真机,period_ms=1):
| 环节 | 实测 |
|---|---|
| `send_processdata` 调用 | 48 µs |
| `receive_processdata` 等从站回帧 | **118 µs** ← 真正的总线时间 |
| 解码 + 建快照 | 5 µs |
| 合计 | **165 µs** → 理论上能跑 ≈6000 Hz |
| 实际周期 | **1360 µs** |
差的 1.2ms 全在"睡到下一拍"。ETG 官方口径是周期 **≤100 µs**、抖动 ≤1 µs、
还有 8 kHz 闭环的示例,所以 165 µs 已经属于正常水平,**慢的是节拍源**:
`Event.wait(1ms)` 实测 p50 1.075 / p90 2.003 ms,稳定超调 0.4~1.0 ms。
也别指望"先睡后自旋"补救 —— 睡过头时截止点已经过了,自旋无事可做
(实测 hybrid 与裸 `wait` 完全一样)。`timeBeginPeriod(1)` 同样没用。
唯一能跑准的是**自旋到绝对截止点**:实测 p50 1.000 / p90 1.001 ms、抖动
0.000 ms。但自旋有两个必须处理的坑:
**坑 1:自旋循环不能持 GIL。** 第一版写成 `while time.perf_counter() < d: pass`,
真机读快照的 HTTP p50 从 0.6ms 直接劣化到 **40ms** —— 页面等于废了。原因是
CPython GIL 切换间隔默认 5ms,纯 Python 循环能连拿好几个 5ms 不放。改成用
ctypes 调 `QueryPerformanceCounter`(ctypes 外部调用默认释放 GIL)后,其它线程
实测 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。
核号是**实测**出来的(把线程逐个钉到每个逻辑处理器跑同一段纯计算,按
**最大间隙**分快慢两组)—— 这台机器上 `GetLogicalProcessorInformationEx`
直接 `ERROR_BAD_LENGTH`、`GetSystemCpuSetInformation` 拿不到 EfficiencyClass、
`GetDynamicProcessorCounts` 未导出、处理器组只有一个,没有可用的 API 捷径。
用间隙法而非固定倍数(如 1.35×最快),因为被 SMT 兄弟拖累的 P 核也能到
16.7ms,固定倍数会把它误判成 E 核。分不出两类核时返回 0 = 不钉。
探测在服务启动时后台做(约 1.6s),**绝不能在 PDO 线程里现场探测** ——
那 1.6s 不喂过程数据,从站 1000ms 看门狗会把它踢出 OP。
开关与可见性:
- 周期 ≤ `MOTOR_SPIN_AUTO_MAX_MS`(2ms) 自动自旋,长周期仍用内核睡眠省 CPU;
- `motor/start` 接受 `spin`,但**改这一项请走 `/api/motor/spin`** —— 它在
`_BUS_EXCLUSIVE_PATHS` 之外,只翻标志;`motor/start` 会先停循环,停手超过
看门狗就把好板子弄出楔死了(实测踩过两次);
- 前端状态栏显示 `⚡ 自旋节拍 · 钉在 E 核 16-27`,可用
`motorDbg.state.tp.setSpin(false)` 或 localStorage `dcu_ecat_spin=0` 关掉;
环境变量 `ECAT_PIN_ECORES=0` 不钉核、`ECAT_ECORE_MASK=0x0F0000` 手工指定。
- 自测第 11~14 项分别守:自旋精度、不持 GIL、E 核分类与线程级隔离、
`api_motor_start` 的 OP 状态核实。
- **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 适配器和端口。
2. **网卡没开** → `ensureOpen()` 调 `/api/open`,代替用户去 `ecat.html` 点"连接"。
网卡匹配子串在工具栏「链路」右边的输入框里改(默认 `I226`,
存 `localStorage` 的 `dcu_ecat_adapter`)。
3. **没进 OP** → `/api/motor/start` 内部自己走 PREOP→SAFEOP→OP(会自动重试一次,
最坏约 24s,所以前端超时给 30s、代理也给 30s)。
4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。
断线后每 2s 自动重来一遍,中途拔网线再插会自己恢复。
- 接入失败时**必须能看到原因**:横幅与 `#devStatus` 显示归类后的可执行提示
(`summarizeLinkError`),完整诊断放在两者的 `title` 里。
典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成
"需复位 DCU 板 MCU 才能恢复" —— 这个故障软件无法自愈
(见 `ecat_server.py` 的 `_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`。
- `node scripts/test_ecat_autostart.mjs` —— 自动接入链条(stub 后端,
不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。
- `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(53 项,
纯逻辑、不依赖板子/浏览器:默认值、**按链路下限**、旧单值迁移、
两链路互不污染、不限速谓词边界)。
- 浏览器实测(需 headless Chrome/Edge 开 `--remote-debugging-port=9333`,
页面服务端口可作第一个参数传入,默认 8080):
`node scripts/browser_check_refresh.mjs [port]` 验 DOM 回填与
跨刷新持久化(22 项);`node scripts/browser_check_tick.mjs [port]` 数真实请求频率,
确认切链路后**实际节拍**跟着变而不只是 state 变了(9 项,实测 20.0/2.0 Hz);
`node scripts/browser_check_1ms.mjs [port]` 验 ECAT 1ms 档(24 项:`min`/`step` 同步、
通过浏览器原生校验、实测 124 Hz、无卡顿黄标、面板 Hz 不谎报、
切 WiFi 时 1ms 被钳到 50)。
- 监控 UI 需区分 `valid`(通信是否成功)与 `count > 0`(是否有数据),
以便分辨「正常空闲」和「真实通信错误」。
- 响应式 grid 表隐藏列用 `0fr` 宽度实现,不要改变 grid track 数量。
+6
View File
@@ -376,6 +376,12 @@ label { display: block; }
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
/* 当前链路不支持的页签(如 EtherCAT 下的电机 OTA):置灰但保留可见,
悬浮 title 说明怎么才能用 —— 直接隐藏会让人以为功能被删了。 */
.tab-btn.tab-disabled {
color: var(--fg-dim, #666); cursor: not-allowed; opacity: .55;
}
.tab-btn.tab-disabled:hover { color: var(--fg-dim, #666); }
.tab-content { flex: 1; overflow-y: auto; min-height: 0; padding: var(--sp-4); }
.tab-page { display: none; }
.tab-page.active { display: block; }
+229 -3
View File
@@ -3,11 +3,20 @@
本目录的 HTML/CSS/JS 是纯前端: 页面本体由本服务器提供(同源相对路径),
而所有业务 API 由 ESP32 固件的 80 端口跨域提供(js/common/base-url.js 里的
设备 IP, 存 localStorage 的 dcu_esp_ip)。因此本脚本不碰任何硬件, 只做静态服务。
设备 IP, 存 localStorage 的 dcu_esp_ip)。因此本脚本不碰任何硬件, 只做静态服务
—— 唯一例外是 /ecat/api/* 反向代理(见下)。
GET / → 302 到 /tabs.html (标签外壳, 工具主页)
GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/)
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_server.py 的 /api/<路径>
为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输,
后者的后端 tools/ecat/src/ecat_server.py 只绑 127.0.0.1 且完全没有 CORS
(既无 Access-Control-Allow-Origin, 也没有 do_OPTIONS —— 预检直接 501)。
我们的 API 是 POST + application/json, 属非简单请求, 浏览器必发预检,
因此页面无法直接跨端口调它。改那边要动另一个 git 仓库, 把页面搬过去又会让
WiFi/ECAT 分裂成两份页面, 所以在本服务做同源转发, 两个仓库都不用改。
目录约定: tabs.html 在根(工具入口), 功能页在 pages/, 自包含实验页在 lab/。
@@ -25,6 +34,7 @@ _reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默
import argparse
import collections
import ctypes
import json
import os
import re
import socket
@@ -104,6 +114,40 @@ g_port = None
g_win = None
g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程
# ── EtherCAT 后端反向代理 ──────────────────────────────────────────────
# 电机页的 EtherCAT 传输要打 tools/ecat/src/ecat_server.py 的 /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 —— 那是硬白名单, 且会让
# WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求;
# 3) 本服务代理 —— 页面永远同源(:8080), 两个仓库都不用动。
# 端口自动探测: ecat_server 的 _bind_server 会在 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。
# 本服务在第一次代理请求时按需拉起 tools/ecat 的服务进程。
#
# 三条必须小心的地方(都在 ecat_server.py 里读出来的, 不是猜的):
# 1) **必须先探测再拉起**。ecat_server 的 _run_browser 在没有 --parent-pid 时
# 会调 _kill_stale_instances(), 那会把用户已经开着的 EtherCAT 窗口
# 优雅关掉(还会把从站带回 INIT)。所以已有实例时只复用, 绝不重复拉起。
# 2) 拉起时**必须带 --parent-pid**: 一是跳过上面那个清场逻辑, 二是让它在
# 本服务退出后自我了断, 不留孤儿进程占着 Npcap 适配器和端口。
# 3) 用 pythonw.exe + venv: pysoem/pywebview 都装在 tools/ecat\venv 里,
# 用系统 python 会 ImportError; 用 python.exe 会弹黑框。
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")
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
class _GuiApi:
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
@@ -141,6 +185,173 @@ def _focus_existing(port):
return False
def _ecat_find_port():
"""返回正在监听的 ecat 服务端口, 没有则 None。"""
ports = ([g_ecat_port] if g_ecat_port else []) + \
[p for p in ECAT_PORTS if p != g_ecat_port]
for p in ports:
if _port_taken(p):
return p
return None
def _ecat_autostart():
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
port 为 None 表示拉不起来(缺 venv / 缺文件 / 起了但没监听)。
已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释:
ecat_server 独立启动会 _kill_stale_instances(), 把用户开着的窗口关掉。
"""
global g_ecat_child, g_ecat_port
with g_ecat_lock:
port = _ecat_find_port()
if port: # 已经有人在跑(用户手开的, 或我们上次拉起的)
g_ecat_port = port
return port, []
logs = []
if not os.path.isfile(ECAT_ENTRY):
return None, [f"找不到 EtherCAT 服务入口: {ECAT_ENTRY}"]
if not os.path.isfile(ECAT_PYW):
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",
]
# 复用已被本进程拉起但已退出的槽位
if g_ecat_child is not None and g_ecat_child.poll() is not None:
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 解析。
g_ecat_child = subprocess.Popen(
[ECAT_PYW, ECAT_ENTRY, "--no-browser",
"--parent-pid", str(os.getpid())],
cwd=os.path.join(ECAT_DIR, "src"),
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})")
except OSError as e:
return None, [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
# 等它把端口监听起来。pysoem 首次导入 + find_adapters 要一两秒,
# 给到 20s: 慢机器上 Npcap 枚举适配器偶发很久, 提前放弃会让用户
# 看到"启动失败"而其实几秒后就好了。
deadline = time.time() + 20
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; "
"可双击 tools/ecat/ecat_web.bat 看具体报错"]
port = _ecat_find_port()
if port:
g_ecat_port = port
logs.append(f"EtherCAT 服务已就绪(端口 {port})")
return port, logs
time.sleep(0.3)
return None, logs + ["EtherCAT 服务启动超时(20s 内未监听端口)"]
def _ecat_post(port, path, body, timeout):
"""向 ecat_server 转发一次 POST, 返回 (status, body_bytes)。
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
给 socket 设 timeout 会让 Python 把它切成非阻塞模式, 而 Windows 上非阻塞
connect 要靠 select 轮询完成, 于是撞上约 10ms 的系统调度粒度。实测
(本机回环, /api/motor/state):
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 全是这一层白交的过路费 ——
电机页 EtherCAT 链路的刷新上限直接被它按在 ~76Hz。
所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接
要么立刻成功要么立刻 ECONNREFUSED, 不会挂住), 连上之后才设读写超时。
"""
payload = (
f"POST {path} HTTP/1.0\r\n"
f"Host: 127.0.0.1:{port}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(body or b'')}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("ascii") + (body or b"")
sock = socket.create_connection(("127.0.0.1", port)) # 注意: 不传 timeout
try:
sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响 connect
sock.sendall(payload)
chunks = []
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
finally:
sock.close()
raw = b"".join(chunks)
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 体里带
# "服务在改动之后没重启" 的排查提示, 吞掉它会让人白排查半天。
return int(first[1]), data
def _ecat_proxy(path, body, timeout=6.0):
"""把 /ecat/api/xxx 转发到 ecat_server 的 /api/xxx。
返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
ecat_server 的 _bind_server 会在 8090..8095 之间漂移(端口被占就挪),
写死 8090 会在它挪了之后表现为"页面点了没反应"。
连不上时返回 503 + 与 ecat_server 同构的 {"ok":false,"log":[...]} 信封,
这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。
"""
global g_ecat_port
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
target = "/api/" + path[len(ECAT_PROXY_PREFIX):]
ports = ([g_ecat_port] if g_ecat_port else []) + \
[p for p in ECAT_PORTS if p != g_ecat_port]
last = None
for port in ports:
try:
status, data = _ecat_post(port, target, body, timeout)
g_ecat_port = port
return status, data
except Exception as e: # 连不上/超时 → 试下一个端口
last = e
if g_ecat_port == port:
g_ecat_port = None # 缓存端口失效, 下轮重新全量探测
# 一个端口都不通 → 服务没在跑, 按需自动拉起后重试一次。
# 放在这里而不是每次请求前探测: 正常情况(服务在跑)一次都不会走到,
# 不给高频的 /motor/state 轮询增加任何开销。
port, start_logs = _ecat_autostart()
if port:
try:
status, data = _ecat_post(port, target, body, timeout)
g_ecat_port = port
return status, data
except Exception as e:
last = e
msg = (f"EtherCAT 服务不可用: {type(last).__name__ if last else 'unknown'}")
body = json.dumps({"ok": False, "log": start_logs + [msg]},
ensure_ascii=False).encode("utf-8")
return 503, body
class Handler(SimpleHTTPRequestHandler):
server_version = "Esp32Tools/1.0"
@@ -199,10 +410,25 @@ class Handler(SimpleHTTPRequestHandler):
self._post_json(body)
threading.Thread(target=_shutdown_self, daemon=True).start()
return
if path.startswith(ECAT_PROXY_PREFIX):
# 电机页的 EtherCAT 传输: 同源转发到 ecat_server(见 _ecat_proxy 注释)。
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
# 实测最坏 ~24s, 用默认 6s 会在正常进 OP 的过程中假失败。
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
raw = self.rfile.read(length) if length > 0 else b"{}"
timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
"/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0
status, body = _ecat_proxy(path, raw, timeout)
self._post_json(body, status)
return
self.send_error(404)
def _post_json(self, body):
self.send_response(200)
def _post_json(self, body, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
+409 -203
View File
@@ -4,12 +4,27 @@
* 原脚本已是 "use strict",转 module 行为中性;唯一对外暴露 window.phaseDebug。
*/
/* ═══════════════════════════════════════════════════════════════
电机控制台 — 共用 OTA 主页设备 IP(localStorage dcu_esp_ip),本页无连接配置
数据源:
GET /motor/state refreshIntervalMs 驱动(默认 250ms,50~5000 可调)→ 节点列表 + STATUS 解码
POST /can/send_batch 唯一发送入口(CMD / CONFIG / VERSION / OTA)
GET /can/feedback 1Hz → 状态栏 RX/TX/ERR/TEC/REC/BUS
GET /system/status 进入页自动探测 + 5s 健康检查 / 断线 2s 探测
电机控制台 — 双传输:WiFi(HTTP) / EtherCAT(过程数据),业务逻辑完全共用
传输由工具栏「链路」下拉框切换(记在 localStorage dcu_motor_transport)。
两种传输的差异全部收敛在 js/motor/transport-*.js 里,本文件只经
state.tp.* 调用抽象接口 —— 解码器、节点列表、STATUS 面板、波形、
命令序列、以及全部流控策略(互斥/退避/冷却/latest-wins)两边共用。
WiFi 传输(原有,共用 OTA 主页设备 IP localStorage dcu_esp_ip):
GET /motor/state?bin=1 refreshIntervalMs 驱动(WiFi 默认 250ms,50~5000 可调)
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):
POST /api/motor/state 读后台 PDO 循环发布的快照(不碰总线,默认 50ms 读一次)
POST /api/motor/send 多帧塞进同一 PDO 周期的批量区(同一 seq 沿发出)
POST /api/motor/rx 消费批量接收区(CONFIG/版本/节点ID 应答走这里)
刷新速度按链路各存一份(state.refreshByTp / REFRESH_DEFAULTS):两条链路的
往返代价差一个数量级,切链路时自动换成该链路自己的值,互不污染。
两者的差异(每通道 TEC/REC 缺失、CONFIG 需前端累积、无 OTA 链路)
见 transport-ecat.js 头部注释。
CAN ID(29-bit 扩展,物理 ID,不含 0x80000000 标志):
STATUS 0x000N0100 · CMD 0x000N0200(DLC32) · CMD_CONFIG 0x000N0301(DLC24)
GET_VERSION 请求/回报共用 0x000N00FF(同 ID, DLC24)
@@ -22,6 +37,17 @@ import {
DEFAULT_ESP_IP, getDeviceIp, saveDeviceIp
} from "../common/base-url.js";
import { escapeHtml } from "../common/utils.js";
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
import {
createTransport, loadTransportId, saveTransportId
} from "./transport.js";
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
import {
REFRESH_DEFAULTS, REFRESH_MAX_MS,
defaultRefreshMap, normalizeRefreshMap, pickRefresh, minRefreshFor,
isUnthrottled
} from "./refresh-rate.js";
/* ─── 控制模式定义 ─── */
const MODES = {
@@ -57,6 +83,14 @@ const state = {
connected: false, // 连续成功探测后视为在线
connecting: false,
ip: DEFAULT_ESP_IP,
/* 当前传输实例(wifi / ecat)。所有链路调用都经它走,
* 见文件头注释与 js/motor/transport.js 的接口定义。 */
tp: null,
tpId: "wifi",
/* 最近一次探测失败原因(EtherCAT 自动接入四级里任一级的报错)。
* null = 没失败过。显示到横幅 + #devStatus,别让页面只写"探测中…"。 */
probeErr: null,
probeErrFull: null, // 未压缩的完整诊断(放 title,供复制排查)
ch: 0,
chEnabled: [true, true, true], // 各通道启用状态(由 onFeedback 更新)
chOnline: [0, 0, 0], // 各通道在线电机数(由 onMotorState 按 m.ch 统计,驱动下拉框信号标记)
@@ -65,8 +99,11 @@ const state = {
decoded: new Map(), // node -> 解码后的 STATUS 对象
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。 */
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。
* 这是**当前链路**的生效值,等于 refreshByTp[tpId];全页仍只读这一个字段。 */
refreshIntervalMs: 250,
/* 每条链路各存一份刷新速度,切链路时自动换(见 refresh-rate.js)。 */
refreshByTp: defaultRefreshMap(),
lastSentMode: new Map(), // node -> 最近发送的 Control_Mode
lastSentTargets: new Map(), // node -> {mode, angle, velocity, current, voltage} 最近发送的 CMD 目标值
sendInFlight: false, // send_batch 互斥
@@ -97,6 +134,10 @@ window.motorDbg = {
state, // 完整 state 引用,方便深挖
get tickWorker() { return tickWorker; }, // Worker 节拍源对象(null=未创建)
get tickUseFallback() { return tickUseFallback; }, // true=已回退到 setTimeout 兜底
get transport() { return state.tp && state.tp.id; }, // 当前链路 id(wifi/ecat)
/* EtherCAT 传输内部:PDO 周期 / 累积的 config·版本数 / 最近一次原始快照。
* 排"界面没数据"时先看这里的 snapshot.wkc 是否等于 expected_wkc。 */
get transportDebug() { return state.tp && state.tp.debug ? state.tp.debug() : null; },
};
/**
* 连续「硬失败」多少次才判离线。
@@ -133,10 +174,12 @@ function getSharedDeviceIp() {
* 保存 strategy:用户改输入项时,把所有需保存项打包成 JSON 写 localStorage。
* 恢复 strategy:页面加载时读出,按字段类型回填到对应 DOM + state。
* 版本号按节点缓存(versions[node] = {soft,hard,boot}),切节点时回填。 */
/* 注意:#refreshRate **不在**这里 —— 它按链路各存一份(refreshByTp),
* 走通用 DOM 回填会把上一条链路的值套到当前链路上。见 applyTransportRefresh。 */
const PERSIST_FIELDS = [
"modeSelect", "inVoltage", "inVelocity", "inAngle",
"pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect",
"nodeIdInput", "refreshRate",
"nodeIdInput",
];
function saveUiState() {
@@ -147,6 +190,8 @@ function saveUiState() {
if (el) o[id] = el.value;
}
if (state.selectedNode != null) o.selectedNode = state.selectedNode;
/* 刷新速度:按链路分别保存(旧版是单个 refreshRate 字段,见 loadUiState 的迁移) */
o.refreshByTp = Object.assign({}, state.refreshByTp);
/* 命令序列:步骤 + 循环次数(有内容才写入,保持旧数据兼容) */
if (seq.steps.length > 0 || seq.loops !== 0) {
o.seq = { loops: seq.loops, steps: seq.steps };
@@ -189,16 +234,11 @@ function loadUiState() {
if (Number.isFinite(node) && v) state.versions.set(node, { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null });
}
}
/* 恢复刷新速度到 state(loadUiState 在 initPage 阶段调用,
* 此时 polling 尚未启动,直接写 state 即可,无需重排定时器) */
const rr = parseInt(o.refreshRate, 10);
if (Number.isFinite(rr)) {
/* 与 #refreshRate min/max 一致:最快 50ms(20Hz),最慢 5s */
const clamped = clampNum(rr, 50, 5000);
state.refreshIntervalMs = clamped;
const el = document.getElementById("refreshRate");
if (el) el.value = clamped;
}
/* 恢复刷新速度:按链路各一份,归一与旧数据迁移都在 refresh-rate.js 里。
* loadUiState 在 initPage 阶段调用(polling 未启动),写 state 即可,无需重排定时器;
* 输入框由随后的 applyTransportRefresh() 按当前链路回填 —— 这里**不要**直接写 DOM,
* 否则会把另一条链路的值显示出来。 */
state.refreshByTp = normalizeRefreshMap(o.refreshByTp, o.refreshRate);
} catch (_) {}
}
@@ -500,28 +540,10 @@ async function sendBatch(frames, opts) {
const n = frames.length;
/* 通道:默认用顶部选中通道;调用方可经 opts.ch 指定(命令序列每步独立通道) */
const txCh = (opts && Number.isInteger(opts.ch)) ? opts.ch : state.ch;
/* 单帧 3s:ESP32 handler 内部单帧超时 1200ms + HTTP 排队余量;
* 旧值 8s 会在 SPI 卡顿时长时间霸占互斥锁,阻塞后续周期 CMD */
const timeoutMs = (n <= 1) ? 3000 : Math.max(15000, n * 500);
try {
const resp = await fetch(getBaseUrl() + "/can/send_batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ch: txCh, frames: frames }),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) { throw new Error("响应非合法 JSON"); }
if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (json.message || text || "发送失败"));
if (!json.results || !Array.isArray(json.results)) throw new Error("响应缺少 results");
let results = json.results.slice();
const need = n - results.length;
if (need > 0) {
const pad = { status: "error", code: json.abort_code != null ? json.abort_code : -2, gd32_err: json.abort_gd32_err || 0 };
for (let p = 0; p < need; p++) results.push(pad);
}
/* 传输层只负责"把帧送出去 + 把响应补齐成 results[]";
* 下面的流控(退避/冷却/latest-wins)与传输无关,两种链路共用。 */
let results = await state.tp.sendFrames(frames, { ch: txCh });
/* 流控:检查首帧 gd32_err */
const first = results[0] || {};
@@ -595,24 +617,10 @@ async function sendBurst(actions, opts) {
const frames = actions.map((a) => ({
ch: a.ch, node: a.node, data: bytesToHexStr(buildCmdBytes(a)),
}));
const timeoutMs = Math.max(3000, frames.length * 250);
try {
const resp = await fetch(getBaseUrl() + "/can/send_burst", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suffix: 0x0200, dlc: 32, frames: frames }),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) { throw new Error("响应非合法 JSON"); }
if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (json.message || text || "发送失败"));
if (!json.results || !Array.isArray(json.results)) throw new Error("响应缺少 results");
const results = json.results.slice();
while (results.length < frames.length) {
results.push({ status: "error", code: -2, gd32_err: 0 });
}
/* WiFi:ESP32 转 0xC7 突发;EtherCAT:批量发送区天然同一个 seq 沿。
* 两边都保证"组内近同时",语义一致,故上层不必区分。 */
const results = await state.tp.sendBurst(frames, { suffix: 0x0200, dlc: 32 });
/* 流控:取第一个错误帧的状态(同 sendBatch 逻辑) */
const firstErr = results.find((r) => r.status !== "ok") || {};
@@ -681,7 +689,10 @@ function isTransientError(e) {
}
/**
* 统一 GET JSON。失败抛 Error,附带 status / transient。
* 统一 GET JSON(设备直连)。失败抛 Error,附带 status / transient。
*
* 常规数据链(状态/反馈/发送/版本)已全部走 state.tp 传输层,
* 这里保留给 OTA 那条 WiFi 专属链路复用同一套错误语义。
* @returns {Promise<object>}
*/
async function fetchDeviceJson(path, timeoutMs) {
@@ -729,6 +740,139 @@ function recordSlowReq(path, durMs, errMsg) {
/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */
function noteHardSuccess() { state.failCount = 0; }
/* ═══════════════════════ 传输层装配 ═══════════════════════
* 传输实例需要 recordSlowReq / isTransientError / getBaseUrl,
* 这些都定义在本文件里,因此用注入而非反向 import —— 否则
* transport-*.js 与 main.js 会互相 import 形成循环。 */
const TRANSPORT_CTX = {
getBaseUrl: () => getBaseUrl(),
recordSlow: (path, ms, err) => recordSlowReq(path, ms, err),
isTransient: (e) => isTransientError(e),
};
/**
* 把**当前链路**的刷新速度装载为生效值:写 state.refreshIntervalMs、回填输入框、
* 更新 Hz 标签,必要时重排节拍。
*
* 为什么刷新速度不能两条链路共用一个值:见 refresh-rate.js 的头注释。
* 这里是"链路 → 刷新速度"的唯一入口,applyRefreshRate 则是反方向
* (用户改输入框 → 存回当前链路那一份),两者共同维持 refreshByTp 与
* refreshIntervalMs 一致。
*/
function applyTransportRefresh() {
const v = pickRefresh(state.refreshByTp, state.tpId);
const changed = state.refreshIntervalMs !== v;
state.refreshByTp[state.tpId] = v;
state.refreshIntervalMs = v;
/* 输入框无条件回填:初始化时 state 恰好与 HTML 默认值相同也要写,
* 否则输入框会停在 HTML 里的 250 而实际链路用的是别的值。
* min/step 也随链路走:ECAT 允许到 1ms(读本机快照,见 refresh-rate.js),
* WiFi 仍是 50ms 起。不同步 min 的话,浏览器自带的数字校验会把
* 合法的 1ms 标成 :invalid,微调箭头也会停在错误的档位上。 */
const el = $("#refreshRate");
if (el) {
const lo = minRefreshFor(state.tpId);
el.min = String(lo);
el.step = lo <= 1 ? "1" : "50"; /* 1ms 档用 1 步进,否则 50 步进太粗 */
el.value = v;
}
if (!changed) return v; /* 值没变就别白重排节拍 */
updateNodeLabels(); /* STATUS 面板右上角 Hz 跟随 */
/* 兜底模式下清掉按旧间隔排的 setTimeout,避免双链 */
if (state.poll.tick) {
clearTimeout(state.poll.tick);
state.poll.tick = null;
}
if (state.connected && !state.probing) {
tickNextAt = 0;
schedulePollLoop(state.pollGen, 0);
}
return v;
}
/** 建立(或切换到)指定传输。返回新实例。 */
function useTransport(id) {
state.tpId = id;
state.tp = createTransport(id, TRANSPORT_CTX);
return state.tp;
}
/**
* 切换传输:停链路 → 旧传输收尾 → 建新传输 → 重新探测连接。
* OTA 页签在不支持的传输下禁用(EtherCAT 没有固件上传 + relay 链路)。
* 刷新速度按链路各存一份,由 applyTransportUi → applyTransportRefresh 自动换。
*/
async function switchTransport(id) {
if (id === state.tpId) return;
const old = state.tp;
stopRepeat(true);
stopSeq("切换链路,命令序列已停止", true);
stopDataPolling();
stopProbe();
state.connected = false;
state.connecting = false;
state.failCount = 0;
if (old) { try { await old.teardown(); } catch (_) {} }
useTransport(id);
saveTransportId(id);
applyTransportUi();
/* 刷新速度换了要说一声,否则用户会以为是自己之前改的值丢了 */
toast("已切换链路: " + state.tp.label + " · 刷新 " + state.refreshIntervalMs + " ms", "ok");
connectShared();
}
/** 按当前传输能力刷新 UI:OTA 页签可用性 + 链路下拉框 + 发送提示。 */
function applyTransportUi() {
const tp = state.tp;
const sel = $("#transportSelect");
if (sel && sel.value !== state.tpId) sel.value = state.tpId;
/* 网卡匹配子串只对 EtherCAT 有意义,WiFi 下隐藏(而不是禁用):
* 它不是"暂时不能用",而是这条链路根本没有网卡的概念。 */
const ad = $("#ecatAdapter");
if (ad) {
const show = !!tp.getAdapter;
ad.style.display = show ? "" : "none";
if (show) ad.value = tp.getAdapter();
}
/* OTA 只有 WiFi 链路有(ESP32 固件上传 + GD32 本地 relay)。
* 不支持时禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清"换回 WiFi 链路即可用"。 */
const otaBtn = document.querySelector('.tab-btn[data-tab="ota"]');
if (otaBtn) {
const off = !tp.supportsOta;
otaBtn.disabled = off;
otaBtn.classList.toggle("tab-disabled", off);
otaBtn.title = off
? "电机 OTA 仅 WiFi 链路支持(需 ESP32 固件上传 + GD32 relay)——切回 WiFi 链路后可用"
: "";
/* 当前正停在 OTA 页而链路不支持 → 退回配置页,避免面对一个点不动的界面 */
if (off && otaBtn.classList.contains("active")) {
const cfg = document.querySelector('.tab-btn[data-tab="config"]');
if (cfg) cfg.click();
}
}
/* 刷新速度:先装载本链路那一份(回填输入框 + 重排节拍),再更新提示文案。
* EtherCAT 的 PDO 周期由后台循环独立维持(看门狗要求),与界面刷新率无关,
* 这点必须说清楚否则会被当成"改了没生效"。 */
applyTransportRefresh();
const rr = document.getElementById("refreshRate");
if (rr) {
const other = state.tpId === "ecat" ? "wifi" : "ecat";
const otherName = other === "wifi" ? "WiFi" : "EtherCAT";
const shared = "两条链路各记一份刷新速度,切链路自动换(" + otherName +
" 当前记着 " + (state.refreshByTp[other] || REFRESH_DEFAULTS[other]) + " ms)。";
rr.parentElement.title = tp.supportsPeriodSync
? "电机状态刷新间隔(ms," + minRefreshFor(state.tpId) + "~" + REFRESH_MAX_MS +
")。同时驱动前端 HTTP 轮询与 GD32 0xC5 SPI 主动推送周期,端到端节拍一致。" + shared
: "界面读取快照的间隔(ms," + minRefreshFor(state.tpId) + "~" + REFRESH_MAX_MS +
")。EtherCAT 的 PDO 总线周期由后台循环独立维持(1000ms 过程数据看门狗要求持续交换),不受此值影响。" +
"填 1 表示不限速(实测约 125Hz,受每拍一次 HTTP 往返限制),真实频率看 STATUS 面板右上角。" + shared;
}
updateConnUi();
}
function noteHardFailure(e, source) {
if (e && e.transient) return; /* 500/503/超时等瞬时错误不计入 */
state.failCount++;
@@ -777,62 +921,113 @@ function scheduleProbe(delayMs) {
}, delayMs == null ? PROBE_INTERVAL_MS : delayMs);
}
/**
* 把冗长的链路错误压成一句人能看的话。
*
* 为什么需要:ecat_server 会把整串诊断塞进 log[],SSC 邮箱楔死那条会带
* **64 个一模一样的 MailboxError** 加一段 SM 寄存器现场,原样显示等于没显示。
* 这里做两件事:把重复项折叠成"×N",并对已知故障给出**该怎么办**
* —— 楔死是软件恢复不了的(ecat_server 自己的注释写着必须复位 MCU)。
*/
function summarizeLinkError(msg) {
let s = String(msg || "未知错误");
/* 折叠重复的 MailboxError(…),只留计数 */
const mb = s.match(/MailboxError\([^)]*\)/g);
if (mb && mb.length > 2) {
s = s.replace(/\[?MailboxError\([^)]*\)(,\s*MailboxError\([^)]*\))*\]?/,
`MailboxError ×${mb.length}`);
}
/* 已知故障 → 给可执行的处置建议 */
if (/邮箱地址错位楔死|ConfigMapError/.test(s)) {
return "SSC 邮箱楔死(config_map 失败)—— 需复位 DCU 板 MCU 才能恢复(软件无法自愈)";
}
if (/没有找到描述包含/.test(s)) {
return "找不到匹配的网卡 —— 请在工具栏「链路」旁的输入框改网卡匹配子串";
}
if (/没有发现从站/.test(s)) {
return "总线上没有从站 —— 检查 DCU 板供电与网线是否接在该网口";
}
if (/venv|pysoem|Npcap/.test(s)) return s; /* 环境类错误原样给,它本身就是指引 */
/* 兜底:截断,完整内容留在 title 里 */
return s.length > 140 ? s.slice(0, 140) + "…" : s;
}
async function probeOnce() {
if (!state.probing) return;
state.ip = getSharedDeviceIp();
const label = $("#ipLabel");
if (label) label.textContent = "IP " + state.ip;
if (label) label.textContent = transportTargetLabel();
try {
const data = await fetchDeviceJson("/system/status", 2500);
const data = await state.tp.probe();
const offlineMs = state.offlineSince ? (nowMs() - state.offlineSince) : 0;
stopProbe();
state.connected = true;
state.connecting = false;
state.failCount = 0;
state.offlineSince = 0;
state.failCount = 0;
state.probeErr = null;
state.probeErrFull = null;
$("#netBanner").classList.remove("show");
applySystemStatus(data);
updateConnUi();
/* 短离线恢复不弹 toast,避免毛刺「闪一下」 */
if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) {
connToast("连接已恢复 — " + state.ip, "ok");
connToast("连接已恢复 — " + transportTargetLabel(), "ok");
}
startPolling();
} catch (e) {
/* 把失败原因显示出来。
* 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP),
* 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作**
* (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / venv 缺 pysoem),
* 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */
const raw = (e && e.message) ? String(e.message) : "未知错误";
state.probeErrFull = raw;
state.probeErr = summarizeLinkError(raw);
const bt = $("#netBannerText");
if (bt) {
bt.textContent = (state.tpId === "ecat" ? "EtherCAT 接入失败: " : "连接失败: ")
+ state.probeErr + "(每 2 秒自动重试)";
bt.title = raw; /* 完整诊断留在悬浮提示里 */
}
$("#netBanner").classList.add("show");
updateConnUi(); /* 同步 #devStatus,别让它一直写"探测中…" */
if (state.probing) scheduleProbe(PROBE_INTERVAL_MS);
}
}
/** 连接目标的显示文本:WiFi 是设备 IP,EtherCAT 是本地服务 + 从站。 */
function transportTargetLabel() {
return (state.tpId === "ecat") ? "EtherCAT 从站" : ("IP " + state.ip);
}
/**
* 使用主页共享 IP 自动连接(本页无连接按钮)。
* 失败不阻断页面,进入探测直到 /system/status 可用。
* 自动连接当前链路(本页无连接按钮)。
* WiFi 用主页共享 IP,EtherCAT 打本地 EtherCAT 服务。
* 失败不阻断页面,进入探测直到链路可用。
*/
function connectShared() {
state.ip = getSharedDeviceIp();
state.connecting = true;
state.probing = false;
state.failCount = 0;
state.probeErr = null; /* 新一轮连接:清掉上次的失败原因 */
state.probeErrFull = null;
state.offlineSince = nowMs();
state.ch = parseInt($("#chSelect").value, 10) || 0;
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent =
"自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP";
/* HTTP 探测驱动连接:/system/status 可用 → startPolling */
$("#netBannerText").textContent = (state.tpId === "ecat")
? "正在自动接入 EtherCAT … 按需拉起本地服务 → 打开网卡 → 进 OP → 启动 PDO 循环"
: ("自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP");
startProbe();
}
/** 传输回报的状态 → #devStatus。字段差异由各传输的 describeStatus 消化。 */
function applySystemStatus(data) {
const wifi = (data && data.wifi) || {};
if (wifi.ip) setSharedDeviceIp(wifi.ip);
let parts = [];
if (data && data.gd32_features) {
const f = data.gd32_features;
if (f.known) parts.push("feat 0x" + (f.flags >>> 0).toString(16).toUpperCase());
}
if (data && data.app_version) parts.push("APP " + data.app_version);
if (data && data.boot_version) parts.push("BOOT " + data.boot_version);
if (wifi.mode) parts.unshift(wifi.mode + (wifi.connected ? " 在线" : " 离线"));
const ip = state.tp.reportedIp(data);
if (ip) setSharedDeviceIp(ip);
const parts = state.tp.describeStatus(data) || [];
const el = $("#devStatus");
if (el) el.textContent = parts.length ? parts.join(" · ") : "在线";
}
@@ -840,13 +1035,26 @@ function applySystemStatus(data) {
function updateConnUi() {
const led = $("#connLed");
const ipLabel = $("#ipLabel");
if (ipLabel) ipLabel.textContent = "IP " + state.ip;
if (ipLabel) ipLabel.textContent = transportTargetLabel();
/* 连接灯只反映稳定状态:在线绿 / 探测黄 / 离线红,中间失败不闪黄 */
if (state.connecting || state.probing) {
if (led) led.className = "led led-warn led-pulse";
if (!state.connected) {
const ds = $("#devStatus");
if (ds) ds.textContent = "探测中…";
/* 有失败原因就显示原因摘要,别一直"探测中…"(那等于什么都没说)。
* 摘要截断:SSC 邮箱楔死那类错误会带 64 个 MailboxError + SM 现场,
* 全塞进工具栏会把布局挤爆,完整内容留在横幅和 title 里。 */
if (ds) {
if (state.probeErr) {
const brief = state.probeErr.length > 44
? state.probeErr.slice(0, 44) + "…" : state.probeErr;
ds.textContent = brief;
ds.title = state.probeErrFull || state.probeErr;
} else {
ds.textContent = "探测中…";
ds.title = "";
}
}
}
} else if (state.connected) {
if (led) led.className = "led led-on";
@@ -856,7 +1064,7 @@ function updateConnUi() {
if (ds) ds.textContent = "未连接";
}
const sendHint = $("#sendHint");
if (sendHint) sendHint.textContent = "POST /can/send_batch \u00b7 CH" + state.ch;
if (sendHint) sendHint.textContent = state.tp.sendHint(state.ch);
const sbCh = $("#sbCh");
if (sbCh) sbCh.textContent = "CH" + state.ch;
}
@@ -980,9 +1188,15 @@ let tickUseFallback = false; /* Worker 创建/加载失败 → 回退到 setTim
function tickHandler() {
if (!state.connected || state.probing) return;
if (state.tickInFlight) {
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过并标记卡顿 */
state.tickStuck = (state.tickStuck || 0) + 1;
updateTickStuckUi();
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
* 「不限速」档(如 ECAT 的 1ms)下这是**预期行为**:设定间隔本就小于
* 单次往返耗时,节拍源必然比请求快,每拍都会撞上。此时不计卡顿,
* 否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",而用户要的
* 恰恰就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
if (!isUnthrottled(state.refreshIntervalMs)) {
state.tickStuck = (state.tickStuck || 0) + 1;
updateTickStuckUi();
}
return;
}
pollLoop(state.pollGen);
@@ -1083,12 +1297,11 @@ function schedulePollLoop(gen, delayMs) {
async function fetchSystemStatus(gen, wantFull) {
if (gen !== state.pollGen || !state.connected || state.probing) return;
try {
/* wantFull=false(默认)走轻量路径 ?lite=1:~150B,
* 不调 ESP32 端 heap_caps_get_info / 任务栈遍历 / OTA snapshot,
* 避免 httpd 单线程被重 handler 阻塞几十~上百 ms。
* wantFull=true 走完整路径,5s 一次,含 OTA/heap/SPI。 */
const path = wantFull ? "/system/status" : "/system/status?lite=1";
const data = await fetchDeviceJson(path, wantFull ? 3500 : 1500);
/* WiFi:wantFull=false 走 ?lite=1(~150B),不调 ESP32 端
* heap_caps_get_info / 任务栈遍历 / OTA snapshot,避免 httpd 单线程被
* 重 handler 阻塞几十~上百 ms;wantFull=true 5s 一次,含 OTA/heap/SPI。
* EtherCAT:读从站身份/AL 状态,无 lite/full 之分。 */
const data = await state.tp.fetchStatus(wantFull);
if (gen !== state.pollGen) return;
applySystemStatus(data);
noteHardSuccess();
@@ -1102,9 +1315,10 @@ async function fetchMotorState(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
try {
/* 二进制模式:体积 ~408B vs JSON ~1200B,省 66%。
* ESP32 端 memcpy 拼包,无 snprintf,CPU 开销降一个数量级。 */
const data = await fetchMotorStateBinary();
/* WiFi 走二进制 /motor/state?bin=1(~408B vs JSON ~1200B,省 66%);
* EtherCAT 读后台 PDO 循环的快照。两者都归一成同一份 motors[] 契约,
* 所以 onMotorState 及其下游全部不用区分链路。 */
const data = await state.tp.fetchMotorState();
if (gen != null && gen !== state.pollGen) return false;
onMotorState(data);
noteHardSuccess();
@@ -1116,100 +1330,14 @@ async function fetchMotorState(gen) {
}
}
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]},
* 让 onMotorState 不用改。data 字段用 32 字节 hex 字符串保持兼容(前端
* hexToBytes 解析 STATUS 帧)。 */
async function fetchMotorStateBinary() {
const t0 = performance.now();
let resp;
try {
resp = await fetch(getBaseUrl() + "/motor/state?bin=1", {
signal: AbortSignal.timeout(3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransientError(err);
recordSlowReq("/motor/state?bin=1", performance.now() - t0, err.message);
throw err;
}
recordSlowReq("/motor/state?bin=1", performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
const ab = await resp.arrayBuffer();
return parseMotorStateBinary(ab);
}
/* 解析 ESP32 二进制 /motor/state?bin=1 响应。
* Header 12B: magic u16 LE=0x4D53, ver u8, flags u8, age_ms u32 LE,
* active u8, count u8, rsvd u16
* Entry 44B: node u8, ch u8, online u8, dlc u8,
* rx_count u32 LE, seq u32 LE, data[32]
* 无效响应(magic 不匹配/长度不足)回退到 have_data:false。 */
function parseMotorStateBinary(ab) {
const out = { have_data: false, active_count: 0, age_ms: null, boot_active: false, motors: [] };
if (!ab || ab.byteLength < 12) return out;
const dv = new DataView(ab);
const magic = dv.getUint16(0, true);
if (magic !== 0x4D53) return out; /* 'MS' LE */
const ver = dv.getUint8(2);
if (ver !== 1) return out;
const flags = dv.getUint8(3); /* flags bit0 = boot_active */
const age = dv.getUint32(4, true);
const active = dv.getUint8(8);
const count = dv.getUint8(9);
/* rsvd u16 @10..11 */
/* age == 0xFFFFFFFF 表示 ESP32 端无数据 */
const have = (age !== 0xFFFFFFFF);
out.have_data = have;
out.active_count = active;
out.age_ms = have ? age : null;
out.boot_active = (flags & 0x01) !== 0; /* 总线有电机在 BOOT(0x103 心跳) */
const u8 = new Uint8Array(ab);
let off = 12;
for (let i = 0; i < count && off + 76 <= ab.byteLength; i++) {
const node = u8[off];
const ch = u8[off + 1];
const online = u8[off + 2] !== 0;
const dlc = u8[off + 3];
const rx_count = dv.getUint32(off + 4, true);
const seq = dv.getUint32(off + 8, true);
/* data[32] @ off+12..off+43 转 hex 字符串(兼容 onMotorState 的 hexToBytes) */
let hex = "";
for (let b = 0; b < 32; b++) {
hex += u8[off + 12 + b].toString(16).padStart(2, "0").toUpperCase();
}
/* config_data[24] @ off+52..off+67 转 hex */
const config_rx_count = dv.getUint32(off + 44, true);
const config_seq = dv.getUint32(off + 48, true);
let config_hex = "";
for (let b = 0; b < 24; b++) {
config_hex += u8[off + 52 + b].toString(16).padStart(2, "0").toUpperCase();
}
out.motors.push({
node, ch, online, dlc, rx_count, seq, data: hex,
config_rx_count, config_seq, config_data: config_hex,
});
off += 76;
}
return out;
}
async function fetchFeedback(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
try {
/* ?lite=1:跳过 entries 数组(~3KB → ~200B)。motor.html 只用 ch/stats 字段,
* 不需要 CAN 帧快照(那是 index.html/can.html 监控页用的)。
* ESP32 端 lite 路径不走 chunked + httpJsonWorkMutex,handler <1ms。 */
const data = await fetchDeviceJson("/can/feedback?lite=1", 1500);
/* WiFi:/can/feedback?lite=1 —— 跳过 entries 数组(~3KB → ~200B),
* 本页只用 ch/stats 字段,不需要 CAN 帧快照(那是监控页用的)。
* EtherCAT:从最近一次过程数据快照的 bridge 段取,不额外打一次后端。 */
const data = await state.tp.fetchFeedback();
if (gen != null && gen !== state.pollGen) return false;
onFeedback(data);
noteHardSuccess();
@@ -1471,8 +1599,12 @@ const RECV_HZ_WINDOW = 10;
* 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */
function statusHzText() {
const ts = state.recvTs;
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃) */
const maxAge = (state.refreshIntervalMs || TICK_INTERVAL_MS) * 2 * RECV_HZ_WINDOW;
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
* 「不限速」档要按实测往返而非设定值算这个窗口:1ms × 2 × 10 = 20ms,
* 会把刚到的样本全判成"旧数据"清空,Hz 就永远退回理论值(谎报 1000Hz)。
* 这里给不限速档一个固定的 2s 窗口,足够攒满 10 个样本算出真实频率。 */
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
const maxAge = isUnthrottled(iv) ? 2000 : iv * 2 * RECV_HZ_WINDOW;
const cutoff = Date.now() - maxAge;
while (ts.length > 0 && ts[0] < cutoff) ts.shift();
if (ts.length >= 2) {
@@ -1483,8 +1615,10 @@ function statusHzText() {
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
}
/* 窗口数据不足时回退到理论值,不让面板空着 */
const hz = 1000 / (state.refreshIntervalMs || TICK_INTERVAL_MS);
/* 窗口数据不足时回退到理论值,不让面板空着。
* 不限速档没有有意义的理论值(1ms≠1000Hz),显示 "…" 等实测数据到齐。 */
if (isUnthrottled(iv)) return "\u2026 Hz";
const hz = 1000 / iv;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
function updateNodeLabels() {
@@ -2270,9 +2404,10 @@ function restoreVersionUi(node) {
* 按 seq 增长判定是否为本次查询的新应答(不再从 /can/feedback 取原始帧)。 ── */
async function fetchMotorVersionNode(node) {
try {
const data = await fetchDeviceJson("/motor/version", 2000);
if (!data || !data.have_data || !Array.isArray(data.nodes)) return null;
return data.nodes.find((n) => n.node === node) || null;
/* WiFi:读 ESP32 的 /motor/version 缓存(GD32 解析后经 0xC6 同步)。
* EtherCAT:版本应答从批量接收区回来,由传输层自行累积并维护 seq,
* 所以下面"按 seq 增长判新应答"的逻辑对两种链路都成立。 */
return await state.tp.fetchVersionNode(node);
} catch (e) {
return null; /* 瞬时 busy 忽略 */
}
@@ -2422,12 +2557,33 @@ $("#getNodeIdBtn").addEventListener("click", async () => {
$("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] cur=${cur} … ${time}`;
if (res.ok) {
state.txCount++;
let reported = null;
const r = res.results && res.results[0];
let reported = cur;
if (r && r.recv) {
/* WiFi:SPI 请求-应答,回执里直接带 recv */
const rb = hexToBytes(r.recv);
if (rb.length >= 2) reported = rb[0] | (rb[1] << 8);
else if (rb.length >= 1) reported = rb[0];
} else if (state.tp.takeNodeIdReport) {
/* EtherCAT:应答是异步经批量接收区回来的,等几拍再取。
* 不能像 WiFi 那样直接读回执 —— 那里没有 recv 字段,
* 早期版本会把"没收到应答"显示成"回报=当前节点号",等于假装成功。 */
const deadline = nowMs() + 1500;
while (nowMs() < deadline) {
await sleep(120);
const hex = state.tp.takeNodeIdReport(cur);
if (hex) {
const rb = hexToBytes(hex);
if (rb.length >= 2) reported = rb[0] | (rb[1] << 8);
else if (rb.length >= 1) reported = rb[0];
break;
}
}
}
if (reported == null) {
$("#nodeIdResult").textContent = `节点 ${cur} 未回报 ID(已发出请求 ${hexId(id)})`;
toast(`GET_NODE_ID → Node ${cur} 无应答`, "warn");
return;
}
$("#nodeIdResult").textContent =
`节点 ${cur} 回报 ID: ${reported} · ${hexId(id)}`;
@@ -2441,6 +2597,9 @@ $("#getNodeIdBtn").addEventListener("click", async () => {
/* ═══════════════════════ Tab 切换 ═══════════════════════ */
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
/* 当前链路不支持的页签(EtherCAT 下的电机 OTA)不可切入。
* <button disabled> 本身不会派发 click,这里是防止将来改成 <div> 后失效。 */
if (btn.disabled || btn.classList.contains("tab-disabled")) return;
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".tab-page").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
@@ -2730,6 +2889,13 @@ $("#otaCancelBtn").addEventListener("click", cancelOta);
async function startOta() {
if (ota.running) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
/* 电机 OTA 依赖 ESP32 固件的 /motor/ota* 上传 + GD32 本地 relay,
* EtherCAT 链路上没有这条通路(过程数据只跑 CAN 帧)。这里必须硬拦:
* 否则会去打设备 IP 的 HTTP,在只连了网线的场合表现为莫名超时。 */
if (!state.tp.supportsOta) {
toast("当前链路(" + state.tp.label + ")不支持电机 OTA —— 请切回 WiFi 链路", "err");
return;
}
if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
/* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
* node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
@@ -3083,29 +3249,33 @@ $("#chSelect").addEventListener("change", () => {
});
/* ═══════════════════════ 刷新速度(motor 轮询间隔) ═══════════════════════ */
/* 用户改 #refreshRate 后:clamp、回填、写 state,并按「N×GD32周期」立即重排。
* 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。 */
/* 用户改 #refreshRate 后:clamp、回填、写**当前链路那一份**并立即重排节拍。
* 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。
* 两条链路各记一份(state.refreshByTp,见 refresh-rate.js):这里是
* "用户输入 → 存回当前链路",反方向的"切链路 → 装载该链路的值"在
* applyTransportRefresh。 */
const refreshRateInput = $("#refreshRate");
/* 下发 GD32 0xC5 主动推送周期:复用通用 SPI 透传口 /spi/send(不新增 HTTP 路由)。
* 命令码 0xC5 + 子操作码 0xFE,data 十六进制 = FE <ms_lo> <ms_hi>(毫秒 LE)。
* GD32 端钳制 [50,5000]ms 并回 ACK。失败静默(不影响本地轮询)。 */
async function sendMotorPeriod(ms) {
const lo = ms & 0xFF, hi = (ms >> 8) & 0xFF;
const toHex = (b) => b.toString(16).padStart(2, "0").toUpperCase();
/* WiFi:经 /spi/send 下发 0xC5 子命令 0xFE,让 GD32 主动推送周期与前端一致。
* EtherCAT:PDO 周期由后台循环独立维持(1000ms 过程数据看门狗要求持续交换),
* 与界面刷新率无关,传输层实现为空操作。 */
if (!state.tp.supportsPeriodSync) return;
try {
await fetch(getBaseUrl() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "C5", data: "FE" + toHex(lo) + toHex(hi), timeout_ms: 2000 }),
signal: AbortSignal.timeout(3000),
});
await state.tp.setPeriod(ms);
} catch (e) { /* 周期下发失败静默:GD32 保持旧周期,本地轮询不受影响 */ }
}
function applyRefreshRate() {
const v = clampNum(parseInt(refreshRateInput.value, 10) || 250, 50, 5000);
const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS;
const lo = minRefreshFor(state.tpId); /* 下限按链路:ECAT 到 1ms,WiFi 仍 50ms */
const v = clampNum(parseInt(refreshRateInput.value, 10) || dflt, lo, REFRESH_MAX_MS);
refreshRateInput.value = v;
state.refreshIntervalMs = v;
state.refreshByTp[state.tpId] = v; /* 只改当前链路那一份,另一条保持原值 */
saveUiState(); /* #refreshRate 不在 PERSIST_FIELDS 里,得自己存 */
updateNodeLabels(); /* STATUS 面板右上角 Hz 跟随刷新速度 */
/* 后台节流兜底模式下清掉旧 setTimeout 节拍,避免双链 */
if (state.poll.tick) {
@@ -3117,14 +3287,20 @@ function applyRefreshRate() {
schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
}
/* 极小间隔别报"≈1000 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
* (ECAT 每拍一次 HTTP,浏览器里实测约 125Hz)。这时只说"不限速",
* 实际到达频率看 STATUS 面板右上角那个按数据到达时间算出来的 Hz。 */
const hz = 1000 / v;
toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
toast(isUnthrottled(v)
? `${state.tp.label} 刷新速度 ${v} ms(不限速,实际频率见 STATUS 面板)`
: `${state.tp.label} 刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
}
refreshRateInput.addEventListener("change", applyRefreshRate);
refreshRateInput.addEventListener("input", () => {
/* 输入过程中只 clamp 下限显示,松手/change 再应用节拍,避免每个键都 toast */
const raw = parseInt(refreshRateInput.value, 10);
if (Number.isFinite(raw) && raw < 50) refreshRateInput.value = 50;
const lo = minRefreshFor(state.tpId);
if (Number.isFinite(raw) && raw < lo) refreshRateInput.value = lo;
});
/* ═══════════════════════ 示波器系统 ═══════════════════════ */
@@ -3389,11 +3565,16 @@ const scopeSys = (() => {
})();
/* ═══════════════════════ 初始化 ═══════════════════════ */
/* 本页不提供连接配置:读主页 localStorage dcu_esp_ip,进入后自动探测 */
/* 传输必须最先建:updateConnUi / renderStatusPanel 等都会读 state.tp。 */
useTransport(loadTransportId());
/* 本页不提供连接配置:WiFi 链路读主页 localStorage dcu_esp_ip,进入后自动探测 */
state.ip = getSharedDeviceIp();
/* 先恢复用户上次输入(modeSelect / 目标值 / PID / 通道 / 选中节点),
* 再 applyModeVisibility 才能让保存的 mode 决定字段可见性。 */
loadUiState();
/* 装载当前链路那一份刷新速度(回填输入框 + 写 refreshIntervalMs)。
* 必须在 updateNodeLabels / renderStatusPanel 之前:它们要按刷新率算显示 Hz。 */
applyTransportRefresh();
/* 固定节点 1–9:恢复后若无有效选中,默认 N1,保证离线也能直接刷写 */
if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) {
state.selectedNode = 1;
@@ -3410,6 +3591,31 @@ bindAutoSave();
initCmdCommit(); /* CMD 输入提交制:必须在 loadUiState 回填之后初始化,否则初始提交值是 HTML 默认值 */
otaRestorePersistedFirmware(); /* 异步恢复上次固件(无阻塞) */
/* 链路切换下拉框:切 WiFi/EtherCAT。放在最后绑,此时 state.tp 已就绪。 */
const transportSelect = $("#transportSelect");
if (transportSelect) {
transportSelect.value = state.tpId;
transportSelect.addEventListener("change", () => {
switchTransport(transportSelect.value);
});
}
/* EtherCAT 网卡匹配子串:改完立即重连该网口(换网卡时用)。 */
const ecatAdapterInput = $("#ecatAdapter");
if (ecatAdapterInput) {
ecatAdapterInput.addEventListener("change", async () => {
const tp = state.tp;
if (!tp.setAdapter) return;
const changed = await tp.setAdapter(ecatAdapterInput.value);
if (!changed) { ecatAdapterInput.value = tp.getAdapter(); return; }
toast("网卡已切换到 " + tp.getAdapter() + ",正在重连…", "ok");
/* 重新走一遍探测:ensureOpen 会用新子串 open,再自动进 OP + 起循环 */
stopDataPolling();
state.connected = false;
connectShared();
});
}
applyTransportUi(); /* 按当前链路能力刷新 OTA 页签可用性 + 提示文案 */
/* 暴露诊断对象到 window,供控制台排查慢请求:
* window.phaseDebug.lastSlowReq // 上次慢请求(>200ms)path+耗时
* window.phaseDebug.slowStats // 累计慢请求统计
+102
View File
@@ -0,0 +1,102 @@
/**
* motor/refresh-rate.js — 刷新速度的「每链路一份」纯逻辑
*
* 为什么单独一个文件:这里是唯一带分支的部分(默认值、钳制、旧数据迁移),
* 而 main.js 里剩下的都是 DOM 副作用。抽出来后自测能直接跑生产代码
* (见 scripts/test_motor_refresh.mjs),不用为了测三个 if 去搭 DOM 桩。
*
* 为什么两条链路不能共用一个值:往返代价差一个数量级。
* wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐;
* ecat 50ms(下限 1ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
* PDO 总线周期由后台循环独立维持(默认 4ms,喂 1000ms 看门狗),
* 界面读得快只是显示更跟手,不给总线加负担。
* 1ms 的语义是「不限速」而非 1000Hz,见 UNTHROTTLED_MAX_MS。
* 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
*/
"use strict";
/** 每条链路的默认刷新间隔(ms)。新链路在这里加一行即可。 */
export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 };
/**
* 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同:
* wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求
* 排队积压(tickStuck 亮黄),毫无收益。
* ecat 1ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
* 注意 1ms **不等于** 1000Hz:单飞语义下上一拍完成才发下一拍,
* 而每拍要过一次 HTTP(代理 → ecat_server → 回来),实测均值
* ≈ 1.4ms,浏览器里 1ms 档实测约 125Hz。填 1 的语义是
* "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示,
* 见 UNTHROTTLED_MAX_MS。
*/
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 1 };
/** 兜底最小值:未知链路 id 时用(防御,正常不该出现)。 */
export const REFRESH_MIN_MS = 50;
export const REFRESH_MAX_MS = 5000;
/**
* 低于此间隔视为「不限速」模式:设定值已经小于单次往返耗时,
* 节拍源会比请求完成得更快,于是每拍都撞上"上一拍还在飞"。
*
* 这在 1ms 档是**设计行为而不是故障**,所以调用方要据此关掉两处显示:
* 1. tickStuck 卡顿黄标 —— 否则通道下拉框会常亮黄并报"连续 N 拍未按时完成",
* 而用户要的就是"能多快就多快";
* 2. 「≈N Hz」的理论值 —— 1ms 硬算是 1000Hz,浏览器里实测约 125Hz,那是谎报。
* 这时只显示按数据到达时间实测出来的频率。
* 取 20ms(50Hz):浏览器里每拍实际约 8ms(fetch + 代理 + ecat_server 全链),
* 20ms 留了足够余量不误判;又远小于常用档位(50/250ms),不会误伤正常设置。
*/
export const UNTHROTTLED_MAX_MS = 20;
/** 该间隔是否处于「不限速」区间(见 UNTHROTTLED_MAX_MS)。 */
export function isUnthrottled(ms) {
return Number.isFinite(ms) && ms < UNTHROTTLED_MAX_MS;
}
/** 某条链路的最小间隔。 */
export function minRefreshFor(tpId) {
const v = REFRESH_MIN_BY_TP[tpId];
return v === undefined ? REFRESH_MIN_MS : v;
}
/** 按**该链路自己的**下限钳制(这是本文件里唯一需要区分链路的地方)。 */
function clampFor(tpId, v) {
return Math.max(minRefreshFor(tpId), Math.min(REFRESH_MAX_MS, v));
}
/** 全新的一份默认表(调用方可随意改,不会污染 REFRESH_DEFAULTS)。 */
export function defaultRefreshMap() {
return Object.assign({}, REFRESH_DEFAULTS);
}
/**
* 从 localStorage 读出的内容归一成完整的「链路 → 间隔」表。
*
* @param raw 持久化的 refreshByTp(可能是 undefined / 脏数据)
* @param legacySingle 旧版单个 refreshRate 字段(两条链路曾共用一个值)
*
* 迁移取向:旧的单值是**为 WiFi 调的**(那时只有 WiFi 链路),所以只迁到
* wifi,ecat 留给默认 50ms —— 把一个为无线调出来的 250ms 套到本地快照上,
* 等于让用户平白无故继承一个不合适的值。
*/
export function normalizeRefreshMap(raw, legacySingle) {
const out = defaultRefreshMap();
if (raw && typeof raw === "object") {
for (const [id, v] of Object.entries(raw)) {
const n = parseInt(v, 10);
/* 只认已知链路:脏 key 直接丢,避免 localStorage 里攒出永不使用的条目 */
if (Number.isFinite(n) && out[id] !== undefined) out[id] = clampFor(id, n);
}
return out;
}
const legacy = parseInt(legacySingle, 10);
if (Number.isFinite(legacy)) out.wifi = clampFor("wifi", legacy);
return out;
}
/** 取某条链路的生效值:脏值/缺失回落到该链路默认,再按该链路下限钳制。 */
export function pickRefresh(map, tpId) {
const raw = map ? map[tpId] : undefined;
const n = parseInt(raw, 10);
if (Number.isFinite(n)) return clampFor(tpId, n);
return REFRESH_DEFAULTS[tpId] || REFRESH_DEFAULTS.wifi;
}
+522
View File
@@ -0,0 +1,522 @@
/**
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
*
* 后端是 tools/ecat/src/ecat_server.py(另一个 git 仓库),经本地页面服务器
* 的 /ecat/api/* 反向代理同源访问 —— 那个服务只绑 127.0.0.1 且没有 CORS,
* 浏览器直连跨端口会卡在预检上,详见 esp32_server.py 的 _ecat_proxy 注释。
*
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
* 1. 服务没起 → 代理侧 _ecat_autostart() 用 tools/ecat\venv 拉起(已有实例只复用)
* 2. 网卡没开 → ensureOpen() 调 /api/open(网卡子串存 localStorage dcu_ecat_adapter)
* 3. 没进 OP → /api/motor/start 内部自己走 PREOP→SAFEOP→OP
* 4. 循环没跑 → /api/motor/start 起后台 PDO 循环(喂 1000ms 过程数据看门狗)
* 断线后 main.js 的探测器每 2s 重来一遍,所以中途拔网线再插也会自己恢复。
*
* 本模块的**唯一职责**是把 EtherCAT 过程数据的形状翻译成 WiFi 传输那套契约,
* 好让 main.js 的解码器 / 节点列表 / STATUS 面板 / 波形 / 命令序列全部原样复用。
* 三处形状差异必须在这里补平,否则上层就得为两种传输各写一遍:
*
* 1. **config_data**:WiFi 的 /motor/state 每个电机自带 24B CONFIG 回报;
* EtherCAT 状态窗口只有 32B STATUS,CONFIG(0x000N0101) 是当普通帧从
* 批量接收区回来的。故这里累积 configs[node],回填到 motors[].config_data。
* 2. **版本**:WiFi 由 GD32 解析后经 0xC6 缓存到 /motor/version;EtherCAT
* 没有这个缓存,版本应答(0x000N00FF)同样从批量接收区来,这里自建缓存并
* 维护 seq,让 main.js 那套"按 seq 增长判新应答"的逻辑不用改。
* 3. **节点 ID 应答**:WiFi 走 SPI 请求-应答,帧结果里直接带 recv;
* EtherCAT 是异步的,应答稍后才到,这里存起来供上层取。
*
* 另外两处**不能假装有**的东西,宁可报 null 让 UI 显示 "--":
* - 每通道 TEC/REC/err:过程数据里根本没有(bridge 只有全局计数),
* 编 0 会让"总线正常"和"没这个数据"看起来一样,违背监控 UI 的约定。
* - OTA:EtherCAT 侧没有固件上传 + GD32 relay 链路,supportsOta=false。
*/
"use strict";
/** 单个 PDO 周期回传的状态槽数(与 ecat_server.MOT_SLOTS_PER_PDO 一致) */
const MOT_SLOTS_PER_PDO = 7;
/** 状态槽总数 = 9 节点 × 3 通道(ecat_server.MOTOR_SLOT_COUNT) */
const MOTOR_SLOT_COUNT = 27;
/** 扫完全表所需周期数(ecat_server.MOTOR_SCAN_CYCLES)。
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 ——
* 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) {
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) */
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) {
if (!list.length) return "";
const parts = [];
let s = list[0], p = list[0];
for (let i = 1; i <= list.length; i++) {
if (i < list.length && list[i] === p + 1) { p = list[i]; continue; }
parts.push(s === p ? String(s) : `${s}-${p}`);
if (i < list.length) { s = p = list[i]; }
}
return parts.join(",");
}
/** @description 创建 EtherCAT 传输实例。 */
export function createEcatTransport(ctx) {
const { recordSlow, isTransient } = ctx;
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I226);
* 存 localStorage 以便换网卡后不用每次重设。 */
const LS_ADAPTER = "dcu_ecat_adapter";
let adapterSubstr = "I226";
try {
const saved = localStorage.getItem(LS_ADAPTER);
if (saved && saved.trim()) adapterSubstr = saved.trim();
} catch (_) {}
/* ── 传输内部累积状态 ──
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx} 判活 + 计更新次数
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
* 窗口轮到它才更新。ecat_server 的窗口自适应会把窗口收缩到只盖有主的槽,
* 稳态下两者相等;但开机首圈、或多台电机分散落在多个窗口时,
* slotPeriodMs 会是 periodMs 的整数倍。状态栏显示这个才不谎报"数据多新"。 */
let slotPeriodMs = 0;
/* 节拍源是否为自旋(来自 motor/start 回报与快照)。
* 必须显示出来:自旋能把 PDO 周期跑准(实测抖动 0.000ms),代价是**吃满一个
* CPU 核**。用户把刷新调到 1ms 档时默认就会自旋,不写清楚的话 CPU 占用
* 看起来像凭空冒出来的。可用 dcu_ecat_spin 显式覆盖("0"=强制内核睡眠)。 */
let spinOn = false;
/* 自旋线程被钉到的逻辑处理器位掩码(快照 ecore_mask)。0 = 没钉(对称 CPU
* 或探测失败)。混合架构下钉到 E 核才有意义,所以要把落在哪些核显示出来。 */
let ecoreMask = 0;
const LS_SPIN = "dcu_ecat_spin";
let started = false; // 后台循环是否已启动
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
/** POST 到本地代理。失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。 */
async function api(path, body, timeoutMs) {
const url = "/ecat/api/" + path.replace(/^\/+/, "");
const t0 = performance.now();
let resp;
try {
resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs || 4000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransient(err);
recordSlow(url, performance.now() - t0, err.message);
throw err;
}
recordSlow(url, performance.now() - t0);
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) {
throw new Error("EtherCAT 服务响应非合法 JSON");
}
if (!resp.ok) {
/* 代理连不上后端会给 503 + 同构信封;ecat_server 的 404 体里带
* "服务改过但没重启"的排查提示,两者都在 log[] 里,原样冒泡给用户。 */
const err = new Error(
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
err.status = resp.status;
err.transient = (resp.status === 503);
throw err;
}
return json;
}
/** ok:false 是 ecat_server 的常规业务错误信道(HTTP 仍是 200)。 */
function unwrap(json, what) {
if (!json || json.ok !== true) {
const msg = (json && json.log && json.log.length) ? json.log.join(" / ") : (what + "失败");
const err = new Error(msg);
/* "后台 PDO 循环未启动" 是可自愈的:下一拍 ensureStarted 会重开,
* 归为瞬时错误,避免刚切过来的一瞬间就把页面判成断线。 */
err.transient = /未启动|周期异常|排队|busy/i.test(msg);
throw err;
}
return json;
}
/**
* 确保网卡已打开(从站已被发现)。
*
* 服务刚被自动拉起时是"没连接"状态(g_master=None),此时任何
* /api/motor/* 都会回 {ok:false, log:["未连接"]}。这里代替用户去点
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
* 用户还是得开另一个页面点两下才能用。
*
* 幂等:ecat_server 的 api_open 会先 close 掉旧 master 再重开,
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
*/
async function ensureOpen() {
const st = await api("status", {}, 6000);
if (st && st.ok === true) return st; // 已连接
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I226);
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
const r = await api("open", { substr: adapterSubstr }, 20000);
if (!r || r.ok !== true) {
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败";
const err = new Error(msg);
/* 网线没插/板子没供电属"外部条件不满足",不是瞬时抖动:
* 标成 transient 会让页面一直转圈却不给红灯提示。
* 但"适配器没找到"往往是网卡名不同,值得把候选列表原样冒泡上去。 */
err.transient = false;
throw err;
}
return r;
}
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
async function ensureStarted(reqPeriodMs) {
const body = {};
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。 */
if (reqPeriodMs) body.period_ms = reqPeriodMs;
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
let spinPref = null;
try {
const v = localStorage.getItem(LS_SPIN);
if (v === "0" || v === "1") spinPref = (v === "1");
} catch (_) { /* 隐私模式下 localStorage 会抛,忽略即可 */ }
if (spinPref !== null) body.spin = spinPref;
/* motor/start 内部会在未进 OP 时自己跑 PREOP→SAFEOP→OP(还会自动重试一次),
* 最坏约 24s,所以超时给 30s —— 给小了会在正常进 OP 的过程中假失败。 */
const r = unwrap(await api("motor/start", body, 30000), "启动 PDO 循环");
periodMs = r.period_ms || periodMs;
if (typeof r.spin === "boolean") spinOn = r.spin;
started = true;
return r;
}
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。 */
function dispatchFrame(id, dlc, dataHex) {
if (!dataHex) return;
const node = (id >> 16) & 0xF;
const sub = id & 0xFFFF;
if (sub === 0x0101) {
configs.set(node, dataHex); // CONFIG 目标值回报
} else if (sub === 0x00FF) {
const prev = versions.get(node);
versions.set(node, {
node,
seq: (prev ? prev.seq : 0) + 1, // 自建 seq:让上层"seq 增长=新应答"逻辑通用
version: dataHex,
});
} else if (sub === 0x0400 || sub === 0x0401) {
nodeIdReports.set(node, dataHex);
}
}
/** 消费批量接收区(取走即移除,语义是消费不是查看)。 */
async function drainRx() {
const r = await api("motor/rx", {}, 3000);
if (!r || !r.frames) return;
for (const f of r.frames) {
if (!f.valid) continue;
/* STATUS 已由状态窗口按槽聚合,重复解一遍纯属浪费 */
if ((f.id & 0xFFFF) === 0x0100) continue;
dispatchFrame(f.id, f.dlc, f.data);
}
}
/**
* 快照 → WiFi 的 /motor/state 契约。
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活(见 STALE_OFFLINE_MS)。
*/
function snapshotToMotorState(snap) {
const now = Date.now();
const motors = [];
for (const m of (snap.motors || [])) {
const key = m.ch + ":" + m.node;
const prev = seqSeen.get(key);
let rx = prev ? prev.rx : 0;
let at = prev ? prev.at : now;
if (!prev || prev.seq !== m.seq) {
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
at = now;
}
seqSeen.set(key, { seq: m.seq, at, rx });
const fresh = (now - at) <= STALE_OFFLINE_MS;
motors.push({
node: m.node,
ch: m.ch,
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽 */
online: !!m.online && fresh,
dlc: m.dlc,
rx_count: rx,
seq: m.seq,
data: m.data,
/* CONFIG 从批量接收区累积而来(WiFi 侧是每电机自带的) */
config_data: configs.get(m.node) || "",
config_seq: 0,
config_rx_count: 0,
});
}
const head = snap.head || {};
return {
have_data: motors.length > 0,
active_count: head.active || 0,
/* 快照 ts 是服务端 time.time()(秒);与本地时钟比会受偏差影响,
* 只用它算个量级即可,UI 也只把 age 当"有没有数据"的粗指标。 */
age_ms: snap.ts ? Math.max(0, Math.round(now - snap.ts * 1000)) : null,
boot_active: !!head.boot_active,
motors,
};
}
return {
id: "ecat",
label: "EtherCAT",
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
supportsOta: false,
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
supportsPeriodSync: false,
sendHint(ch) {
return "POST /api/motor/send \u00b7 CH" + ch +
(periodMs ? " \u00b7 PDO " + periodMs + "ms" : "");
},
/**
* 连接探测 —— 一路自动到能用:
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
* 网卡没开 → ensureOpen() 代替用户点"连接"
* 没进 OP → ensureStarted() 里 motor/start 自己走 PREOP→SAFEOP→OP
* 循环没跑 → ensureStarted() 起后台 PDO 循环(喂 1000ms 看门狗)
* 四步都自动,用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
*
* 只有 /api/status 通不算连上 —— 循环没跑就读不到任何电机状态,
* 那样 LED 会绿着但界面全是 "--",比直接报未连接更难排查。
*/
async probe() {
const st = await ensureOpen();
await ensureStarted();
return st;
},
/** 健康检查:EtherCAT 没有 /system/status,用从站身份/AL 状态代替。 */
async fetchStatus() {
const st = unwrap(await api("status", {}, 6000), "读从站状态");
if (!started) await ensureStarted();
return st;
},
describeStatus(data) {
const s = (data && data.slave) || {};
const AL = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
const parts = [];
parts.push("AL " + (AL[s.state] || ("0x" + Number(s.state || 0).toString(16))));
if (s.al_status) parts.push("code 0x" + Number(s.al_status).toString(16).toUpperCase().padStart(4, "0"));
if (s.name) parts.push(s.name);
/* PDO 周期 = 总线速度;单槽周期 = 某个电机的状态多久更新一次。
* 两者不等时(电机分散在多个窗口)必须都显示,否则用户会拿 PDO 周期
* 当成数据新鲜度 —— 那正是"看着 4ms 实际 16ms"的谎报。 */
if (periodMs) {
parts.push(slotPeriodMs && slotPeriodMs !== periodMs
? "PDO " + periodMs + "ms \u00b7 单槽 " + slotPeriodMs + "ms"
: "PDO " + periodMs + "ms");
}
/* 自旋会占满一个核,属于用户能感知的代价,必须在状态栏可见而不是只写在文档里。
* 钉到 E 核时把核号一起写出来,用户才知道占的是能效核不是性能核。 */
if (periodMs && spinOn) {
const rng = ecoreRanges(ecoreList(ecoreMask));
parts.push(rng ? `⚡ 自旋节拍 · 钉在 E 核 ${rng}` : "⚡ 自旋节拍(占 1 核)");
}
return parts;
},
/** EtherCAT 不经 IP 通信(走网卡二层),不回报设备 IP。 */
reportedIp() { return null; },
async fetchMotorState() {
const snap = unwrap(await api("motor/state", {}, 4000), "读电机快照");
lastSnapshot = snap;
/* 单槽刷新周期随在线电机分布变化(窗口自适应),每拍跟着更新 */
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
if (typeof snap.spin === "boolean") spinOn = snap.spin;
if (typeof snap.ecore_mask === "number") ecoreMask = snap.ecore_mask;
/* 有帧待取才去消费,省一次往返 */
const rxb = snap.rx_batch;
if (rxb && (rxb.count > 0 || rxb.pending > 0)) {
try { await drainRx(); } catch (e) { /* 瞬时失败下拍再取 */ }
}
/* 长帧快照(CONFIG/版本这类 DLC24 帧在 8B 短快照里会被截断)也要分派 */
if (snap.xrx && snap.xrx.data) {
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data);
}
return snapshotToMotorState(snap);
},
/**
* CAN 反馈(状态栏)。直接复用上一次快照里的 bridge,不再打一次后端:
* 那本来就是同一份过程数据,多打一次只会拿到同样的值。
*
* 逐字段说明为什么很多是 null:过程数据的 bridge 段是**桥全局**计数,
* 没有按通道拆分,也完全没有 TEC/REC。按监控 UI 的约定,
* "通信正常但无此数据" 必须显示 "--",不能编 0 冒充正常。
*/
async fetchFeedback() {
const snap = lastSnapshot;
const b = (snap && snap.bridge) || null;
const linkOk = !!(snap && b && b.valid && snap.wkc === snap.expected_wkc);
const channels = [];
for (let ch = 0; ch < 3; ch++) {
channels.push({
ch,
enabled: true, // 过程数据不回报通道启用状态,不能据此禁止发送
valid: linkOk,
rx: linkOk ? b.rx_count : null,
tx: linkOk ? b.tx_count : null,
err: linkOk ? b.drop : null,
tec: null, // 过程数据里没有
rec: null, // 过程数据里没有
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : 0) : null,
});
}
return { channels };
},
/**
* 发一批帧:一次提交的多帧被后端塞进同一个 PDO 周期的批量发送区,
* 在**同一个 seq 沿**由从站一次性发出 —— 这是多轴同步下发的前提,
* 逐帧调用会退化成一帧一个周期。
* 返回值补成 WiFi 的 results[] 形状(后端只回 queued/backlog)。
*/
async sendFrames(frames, opts) {
if (!started) await ensureStarted();
const payload = {
frames: frames.map((f) => ({
ch: opts.ch, id: f.id, dlc: f.dlc, data: f.data,
})),
};
const r = await api("motor/send", payload, 6000);
if (!r || r.ok !== true) {
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
return frames.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
}
/* 后端不做逐帧回执(帧是同一个 seq 沿一次性发出的),
* 统一回 ok;真正的总线级错误在下一拍 bridge.tx_result 里体现。 */
return frames.map(() => ({ status: "ok" }));
},
/** 突发发送:EtherCAT 的批量区天然就是"同一 seq 沿",直接复用 sendFrames。 */
async sendBurst(frames, opts) {
const built = frames.map((f) => ({
id: ((f.node & 0xF) << 16) | (opts.suffix & 0xFFFF),
dlc: opts.dlc,
data: f.data,
ch: f.ch,
}));
if (!started) await ensureStarted();
const r = await api("motor/send", { frames: built }, 6000);
if (!r || r.ok !== true) {
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
return built.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
}
return built.map(() => ({ status: "ok" }));
},
/** 版本:由批量接收区累积(见模块头注释第 2 点)。 */
async fetchVersionNode(node) {
return versions.get(node) || null;
},
/** 节点 ID 应答:异步到达,取走即清(供 main.js 在发完 GET 后短轮询)。 */
takeNodeIdReport(node) {
const hex = nodeIdReports.get(node);
if (hex) nodeIdReports.delete(node);
return hex || null;
},
/** PDO 周期由后台循环维持,与界面刷新率无关,故不实现。 */
async setPeriod() { /* no-op:见 supportsPeriodSync */ },
/**
* 切走本传输:**故意不停后台 PDO 循环**。
* 从站 SM2 有 1000ms 过程数据看门狗,停超 1 秒会 OP→SAFEOP 掉线报 AL 0x001B;
* 用户只是切到 WiFi 看看,不该把 EtherCAT 链路打断。
*/
async teardown() { started = false; lastSnapshot = null; },
/** 当前网卡匹配子串(工具栏输入框回填用)。 */
getAdapter() { return adapterSubstr; },
/**
* 换网卡:立即重开并重进 OP。
* 换网卡意味着当前 master 指向错的网口,留着旧连接毫无意义,
* 所以这里主动 close 让下一次 ensureOpen 走全新的 open。
*/
async setAdapter(substr) {
const v = String(substr || "").trim();
if (!v || v === adapterSubstr) return false;
adapterSubstr = v;
try { localStorage.setItem(LS_ADAPTER, v); } catch (_) {}
started = false;
lastSnapshot = null;
try { await api("close", {}, 8000); } catch (_) { /* 没连接时无所谓 */ }
return true;
},
/**
* 设节拍源:true=自旋(周期跑得准,占满一个核),false=内核睡眠(省 CPU,
* 周期超调约 0.5ms),null=交回后端按周期自动决定。
*
* 必须走 /api/motor/spin 而不是 /api/motor/start:后者在
* _BUS_EXCLUSIVE_PATHS 里,dispatcher 会先停掉 PDO 循环再执行,而停手超过
* 从站那 1000ms 过程数据看门狗就会把它踢出 OP,重进时还可能撞上 SSC 邮箱
* 楔死(实测把一块好板子弄死过两次)。自旋只是循环里读的一个标志,热改即可。
*/
async setSpin(v) {
try {
if (v === null || v === undefined) localStorage.removeItem(LS_SPIN);
else localStorage.setItem(LS_SPIN, v ? "1" : "0");
} catch (_) {}
const body = (v === null || v === undefined) ? {} : { spin: !!v };
const r = unwrap(await api("motor/spin", body, 8000), "切换节拍源");
if (typeof r.spin === "boolean") spinOn = r.spin;
if (r.period_ms) periodMs = r.period_ms;
return spinOn;
},
/** 诊断用(控制台 motorDbg.transport)。 */
debug() {
return {
periodMs, slotPeriodMs, spinOn, ecoreMask, ecores: ecoreList(ecoreMask),
started, adapter: adapterSubstr,
configs: configs.size, versions: versions.size,
slots: seqSeen.size, snapshot: lastSnapshot,
slotsPerPdo: MOT_SLOTS_PER_PDO,
slotCount: MOTOR_SLOT_COUNT,
scanCycles: MOTOR_SCAN_CYCLES,
winBases: (lastSnapshot && lastSnapshot.win_bases) || null,
};
},
};
}
+231
View File
@@ -0,0 +1,231 @@
/**
* transport-wifi.js — 电机控制台的 WiFi / HTTP 传输实现
*
* 从 motor/main.js 里原地抽出的**原有**链路,行为未改:
* GET /system/status 探测 + 健康检查(lite/full 交替)
* GET /motor/state?bin=1 电机状态(二进制,~408B)
* GET /can/feedback?lite=1 CAN 计数/总线状态
* POST /can/send_batch 发送(唯一入口)
* POST /can/send_burst 突发同时发送(多轴)
* GET /motor/version 版本缓存(GD32 经 0xC6 同步给 ESP32)
* POST /spi/send 0xC5 推送周期对齐
*
* 本模块只做"发请求 + 校验响应形状",**不含任何流控**:
* 互斥、0xE4 退避重试、0xE2/0xE3 冷却、latest-wins 排空全部留在 main.js,
* 因为那些是与传输无关的业务策略,两种传输必须共用同一套,否则 ECAT
* 传输会悄悄失去这些保护(这正是"所有组件复用"的要点)。
*
* ctx 依赖(由 main.js 注入,避免与 main.js 形成循环 import):
* getBaseUrl() → "http://<设备IP>"
* recordSlow(path,ms,err?) 慢请求统计
* isTransient(err) → bool,500/503/超时归为瞬时
*/
"use strict";
/** @description 创建 WiFi/HTTP 传输实例。 */
export function createWifiTransport(ctx) {
const { getBaseUrl, recordSlow, isTransient } = ctx;
/** 统一 GET JSON。失败抛 Error,附 status / transient(与原 fetchDeviceJson 一致)。 */
async function getJson(path, timeoutMs) {
let resp;
const t0 = performance.now();
try {
resp = await fetch(getBaseUrl() + path, {
signal: AbortSignal.timeout(timeoutMs || 3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransient(err);
recordSlow(path, performance.now() - t0, err.message);
throw err;
}
recordSlow(path, performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return resp.json();
}
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]}。
* data / config_data 保持 hex 字符串,让上层 hexToBytes 直接解析。 */
function parseMotorStateBinary(ab) {
const out = { have_data: false, active_count: 0, age_ms: null, boot_active: false, motors: [] };
if (!ab || ab.byteLength < 12) return out;
const dv = new DataView(ab);
if (dv.getUint16(0, true) !== 0x4D53) return out; /* 'MS' */
if (dv.getUint8(2) !== 1) return out; /* ver */
const flags = dv.getUint8(3);
const age = dv.getUint32(4, true);
const active = dv.getUint8(8);
const count = dv.getUint8(9);
const have = (age !== 0xFFFFFFFF);
out.have_data = have;
out.active_count = active;
out.age_ms = have ? age : null;
out.boot_active = (flags & 0x01) !== 0;
const u8 = new Uint8Array(ab);
let off = 12;
const hexOf = (start, len) => {
let s = "";
for (let i = 0; i < len; i++) s += u8[start + i].toString(16).padStart(2, "0").toUpperCase();
return s;
};
for (let i = 0; i < count && off + 76 <= ab.byteLength; i++) {
out.motors.push({
node: u8[off],
ch: u8[off + 1],
online: u8[off + 2] !== 0,
dlc: u8[off + 3],
rx_count: dv.getUint32(off + 4, true),
seq: dv.getUint32(off + 8, true),
data: hexOf(off + 12, 32),
config_rx_count: dv.getUint32(off + 44, true),
config_seq: dv.getUint32(off + 48, true),
config_data: hexOf(off + 52, 24),
});
off += 76;
}
return out;
}
/** POST JSON,返回解析后的 json;HTTP 非 2xx / 响应缺 results 时抛错。 */
async function postFrames(path, payload, timeoutMs, frameCount) {
const resp = await fetch(getBaseUrl() + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) { throw new Error("响应非合法 JSON"); }
if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (json.message || text || "发送失败"));
if (!json.results || !Array.isArray(json.results)) throw new Error("响应缺少 results");
/* 响应短于请求帧数:用 abort_code 补齐,让上层按帧一一对应地报错 */
const results = json.results.slice();
const need = frameCount - results.length;
if (need > 0) {
const pad = {
status: "error",
code: json.abort_code != null ? json.abort_code : -2,
gd32_err: json.abort_gd32_err || 0,
};
for (let p = 0; p < need; p++) results.push(pad);
}
return results;
}
return {
id: "wifi",
label: "WiFi / HTTP",
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay),EtherCAT 侧没有这条链路 */
supportsOta: true,
/** 0xC5 推送周期可与前端刷新率对齐 */
supportsPeriodSync: true,
sendHint(ch) { return "POST /can/send_batch \u00b7 CH" + ch; },
/** 连接探测:/system/status 能通即视为在线(与原 probeOnce 完全一致)。 */
async probe() { return getJson("/system/status", 2500); },
/** 健康检查:lite ~150B / full ~3KB(5s 一次)。 */
async fetchStatus(wantFull) {
return getJson(wantFull ? "/system/status" : "/system/status?lite=1",
wantFull ? 3500 : 1500);
},
/** 设备状态摘要 → #devStatus 文本片段数组。 */
describeStatus(data) {
const wifi = (data && data.wifi) || {};
const parts = [];
if (data && data.gd32_features && data.gd32_features.known) {
parts.push("feat 0x" + (data.gd32_features.flags >>> 0).toString(16).toUpperCase());
}
if (data && data.app_version) parts.push("APP " + data.app_version);
if (data && data.boot_version) parts.push("BOOT " + data.boot_version);
if (wifi.mode) parts.unshift(wifi.mode + (wifi.connected ? " 在线" : " 离线"));
return parts;
},
/** 设备回报的 IP(用于写回共享 localStorage);ECAT 传输无此概念。 */
reportedIp(data) { return (data && data.wifi && data.wifi.ip) || null; },
async fetchMotorState() {
const t0 = performance.now();
let resp;
try {
resp = await fetch(getBaseUrl() + "/motor/state?bin=1", {
signal: AbortSignal.timeout(3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransient(err);
recordSlow("/motor/state?bin=1", performance.now() - t0, err.message);
throw err;
}
recordSlow("/motor/state?bin=1", performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return parseMotorStateBinary(await resp.arrayBuffer());
},
async fetchFeedback() { return getJson("/can/feedback?lite=1", 1500); },
/**
* 发一批帧。frames = [{id,dlc,data}],opts.ch = 批级通道。
* @returns {Promise<object[]>} 已按帧数补齐的 results[]
*/
async sendFrames(frames, opts) {
const n = frames.length;
/* 单帧 3s:ESP32 handler 内部单帧超时 1200ms + HTTP 排队余量 */
const timeoutMs = (n <= 1) ? 3000 : Math.max(15000, n * 500);
return postFrames("/can/send_batch", { ch: opts.ch, frames }, timeoutMs, n);
},
/**
* 突发(近同时)发送:ESP32 转 0xC7,GD32 背靠背装载各通道邮箱,组内偏差 µs 级。
* frames = [{ch,node,data}],共用 suffix + dlc。
*/
async sendBurst(frames, opts) {
const timeoutMs = Math.max(3000, frames.length * 250);
return postFrames("/can/send_burst",
{ suffix: opts.suffix, dlc: opts.dlc, frames },
timeoutMs, frames.length);
},
/** 版本缓存查询(GD32 解析后经 0xC6 同步,ESP32 提供缓存)。 */
async fetchVersionNode(node) {
const data = await getJson("/motor/version", 2000);
if (!data || !data.have_data || !Array.isArray(data.nodes)) return null;
return data.nodes.find((n) => n.node === node) || null;
},
/** 把前端刷新周期同步给 GD32(0xC5 主动推送周期),失败静默。 */
async setPeriod(ms) {
const v = Math.max(20, Math.min(65535, Math.round(ms)));
const lo = (v & 0xFF).toString(16).padStart(2, "0").toUpperCase();
const hi = ((v >> 8) & 0xFF).toString(16).padStart(2, "0").toUpperCase();
await fetch(getBaseUrl() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "C5", data: "FE" + lo + hi, timeout_ms: 2000 }),
signal: AbortSignal.timeout(3000),
});
},
/** 切走本传输时的收尾:HTTP 无状态,无需处理。 */
async teardown() { /* no-op */ },
};
}
+57
View File
@@ -0,0 +1,57 @@
/**
* transport.js — 电机控制台的传输选择层
*
* 电机页支持两条链路,业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、
* 流控)**完全共用**,只有"数据怎么进出"这一层不同:
*
* wifi ESP32 固件 80 端口的 HTTP API(原有实现,行为未变)
* ecat EtherCAT 过程数据,经本地页面服务器 /ecat/api/* 代理到
* tools/ecat/src/ecat_server.py
*
* 传输实例必须实现的接口(详见两个实现文件的注释):
* id / label / supportsOta / supportsPeriodSync
* sendHint(ch) -> string
* probe() -> statusData 连接探测,抛错=没连上
* fetchStatus(wantFull) -> statusData 健康检查
* describeStatus(data) -> string[] #devStatus 文本片段
* reportedIp(data) -> string|null 设备回报 IP(ECAT 恒 null)
* fetchMotorState() -> {have_data, age_ms, active_count, boot_active, motors[]}
* fetchFeedback() -> {channels:[{ch,enabled,valid,rx,tx,err,tec,rec,bus_state}]}
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
* sendBurst(frames, {suffix,dlc}) -> results[]
* fetchVersionNode(node) -> {node,seq,version}|null
* setPeriod(ms) -> void 不支持则空实现
* teardown() -> void 切走时收尾
* 可选:takeNodeIdReport(node)、debug()
*
* motors[] 每项的字段契约(ECAT 实现负责补平差异):
* {node, ch, online, dlc, rx_count, seq, data(64hex), config_data(48hex|"")}
*/
"use strict";
import { createWifiTransport } from "./transport-wifi.js";
import { createEcatTransport } from "./transport-ecat.js";
/** localStorage key:记住上次选的传输,刷新页面后保持 */
export const LS_TRANSPORT = "dcu_motor_transport";
export const TRANSPORT_IDS = ["wifi", "ecat"];
/** @description 按 id 创建传输实例。未知 id 回退到 wifi。 */
export function createTransport(id, ctx) {
return (id === "ecat") ? createEcatTransport(ctx) : createWifiTransport(ctx);
}
/** @description 读上次选择的传输 id(默认 wifi —— 不改变老用户的既有行为)。 */
export function loadTransportId() {
try {
const v = localStorage.getItem(LS_TRANSPORT);
if (v && TRANSPORT_IDS.includes(v)) return v;
} catch (_) {}
return "wifi";
}
/** @description 保存传输选择。 */
export function saveTransportId(id) {
try { localStorage.setItem(LS_TRANSPORT, id); } catch (_) {}
}
+15 -4
View File
@@ -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=20260816a">
<link rel="stylesheet" href="../css/motor.css?v=20260904i">
</head>
<body>
@@ -29,6 +29,17 @@
<span style="font-size:.72rem;color:var(--fg-dim);font-family:var(--font-mono)" id="devStatus">探测中…</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="控制链路:WiFi 走 ESP32 固件 HTTP API;EtherCAT 走过程数据(自动拉起本地服务、打开网卡并进 OP,无需手工准备)。两种链路的界面与解码完全相同,仅数据通路不同">
<label for="transportSelect" style="font-size:.72rem;color:var(--fg-muted);font-family:var(--font-mono);letter-spacing:.06em">链路</label>
<select class="ch-select" id="transportSelect">
<option value="wifi">WiFi</option>
<option value="ecat">EtherCAT</option>
</select>
<input type="text" id="ecatAdapter" class="refresh-input" style="width:74px;display:none"
value="I226" aria-label="EtherCAT 网卡匹配子串"
title="EtherCAT 网卡描述匹配子串(默认 I226)。改这里会立即重连该网口,设置存本地。" />
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<label for="chSelect" style="font-size:.72rem;color:var(--fg-muted);font-family:var(--font-mono);letter-spacing:.06em">通道</label>
<select class="ch-select" id="chSelect">
@@ -38,9 +49,9 @@
</select>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="电机状态刷新间隔(ms,50~5000)。同时驱动前端 HTTP 轮询与 GD32 0xC5 SPI 主动推送周期,端到端节拍一致">
<div class="conn-group" title="电机状态刷新间隔(ms)。WiFi 与 EtherCAT 各记一份,切链路自动换(默认 WiFi 250ms / EtherCAT 50ms)。下限也按链路:WiFi 50ms,EtherCAT 可到 1ms(不限速,实际频率见 STATUS 面板)">
<label for="refreshRate" class="refresh-hint">刷新速度</label>
<input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒)" />
<input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒,按链路分别记忆)" />
<span class="refresh-hint">ms</span>
</div>
<div class="toolbar-right">
@@ -388,6 +399,6 @@
</div>
</div>
<script type="module" src="../js/motor/main.js?v=20260816c"></script>
<script type="module" src="../js/motor/main.js?v=20260904i"></script>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
/**
* 浏览器实测:ECAT 刷新速度 1ms(不限速档)。
* 单测只验了纯逻辑;这里验真机行为:
* - 输入框 min/step 随链路变(否则浏览器把合法的 1ms 标成 :invalid)
* - 1ms 真的比 50ms 快,且拿到实测频率而不是理论 1000Hz
* - 不限速档**不**亮卡顿黄标(每拍撞上在途请求是预期行为)
* - 切回 WiFi 时 1ms 不会被带过去(wifi 下限 50ms)
*/
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");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
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"); "ok"`);
await send("Page.navigate", { url: PAGE }); await sleep(3500);
/* 请求计数探针 */
await ev(`(() => {
window.__cnt = 0;
if (!window.__origFetch) window.__origFetch = window.fetch;
window.fetch = function (u) { if (String(u).includes("motor/state")) window.__cnt++; return window.__origFetch.apply(this, arguments); };
return "ok";
})()`);
async function measure(sec) {
await ev(`window.__cnt = 0; "ok"`);
await sleep(sec * 1000);
return await ev(`window.__cnt`) / sec;
}
const pre = JSON.parse(await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn: motorDbg.state.connected, eff: motorDbg.state.refreshIntervalMs})`));
out.push("\n[前置] " + JSON.stringify(pre));
check("已在 ecat 链路且已连接", pre.tp === "ecat" && pre.conn === true, JSON.stringify(pre));
out.push("\n[A] ECAT 下输入框的 min/step 应放开到 1");
let dom = JSON.parse(await ev(`JSON.stringify({min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
check("min=1(否则浏览器校验会把 1ms 判成非法)", dom.min === "1", JSON.stringify(dom));
check("step=1(微调箭头能落到 1ms 档)", dom.step === "1", JSON.stringify(dom));
out.push("\n[B] 基线:ECAT 默认 50ms");
const hz50 = await measure(3);
out.push(` 实测 ${hz50.toFixed(1)} Hz`);
check("50ms 档约 20Hz", hz50 > 12 && hz50 < 30, `实测 ${hz50.toFixed(1)}`);
out.push("\n[C] 设成 1ms —— 应被接受且明显更快");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(800);
let st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, valid: document.querySelector("#refreshRate").checkValidity(), map: motorDbg.state.refreshByTp})`));
check("1ms 被接受(没被钳回 50)", st.eff === 1, JSON.stringify(st));
check("输入框显示 1", st.val === "1", JSON.stringify(st));
check("通过浏览器原生校验(min 已同步)", st.valid === true, JSON.stringify(st));
check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 1 && st.map.wifi === 250, JSON.stringify(st));
const hz1 = await measure(4);
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,实测约 125Hz 量级)`);
check("1ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `1ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
check("但达不到 1000Hz(受每拍 HTTP 往返限制,符合预期)", hz1 < 400, `实测 ${hz1.toFixed(1)}`);
out.push("\n[D] 不限速档不应亮卡顿黄标");
st = JSON.parse(await ev(`JSON.stringify({stuck: motorDbg.state.tickStuck, cls: document.querySelector("#chSelect").classList.contains("stuck"), title: document.querySelector("#chSelect").title})`));
check("tickStuck 保持 0(每拍撞上在途请求是预期行为,不算卡顿)", st.stuck === 0, JSON.stringify(st));
check("通道下拉框没有 .stuck 黄标", st.cls === false, JSON.stringify(st));
check("没有误导性的卡顿提示文案", !st.title, JSON.stringify(st));
out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 1000Hz");
const hzText = await ev(`document.querySelector("#statusIdLabel").textContent`);
out.push(` 面板显示: ${hzText}`);
const m = String(hzText).match(/([\d.]+)\s*Hz/);
check("面板有 Hz 读数", !!m || /\u2026/.test(String(hzText)), hzText);
if (m) {
const shown = parseFloat(m[1]);
check("显示的不是谎报的 1000Hz", shown < 400, `显示 ${shown}Hz`);
check("显示值与实测频率同量级(±60%)",
Math.abs(shown - hz1) / Math.max(hz1, 1) < 0.6, `显示 ${shown} vs 实测 ${hz1.toFixed(1)}`);
}
out.push("\n[F] 切到 WiFi:1ms 不能带过去(wifi 下限 50ms)");
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(1500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
check("WiFi 生效值 250(不是 ECAT 的 1)", st.eff === 250, JSON.stringify(st));
check("WiFi 下 min 收回 50", st.min === "50", JSON.stringify(st));
check("WiFi 下 step 收回 50", st.step === "50", JSON.stringify(st));
/* 在 WiFi 下硬填 1ms,应被钳到 50 */
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
check("WiFi 下填 1ms 被钳到 50(无线往返撑不住)", st.eff === 50, JSON.stringify(st));
out.push("\n[G] 切回 ECAT:仍记着自己的 1ms");
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(2500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min})`));
check("ECAT 生效值回到 1ms", st.eff === 1, JSON.stringify(st));
check("min 重新放开到 1", st.min === "1", JSON.stringify(st));
/* 刷新页面后 1ms 应持久化 */
await send("Page.navigate", { url: PAGE }); await sleep(3000);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`));
check("刷新页面后 ECAT 仍是 1ms(持久化 + 加载时不被钳高)", st.eff === 1, JSON.stringify(st));
check("刷新后 wifi 那一份仍是 50(上面手动改过)", st.map.wifi === 50, JSON.stringify(st));
console.log(out.join("\n"));
console.log("\n" + "=".repeat(56));
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
sock.close();
process.exit(fail === 0 ? 0 : 1);
+128
View File
@@ -0,0 +1,128 @@
/**
* 浏览器实测:刷新速度按链路各存一份(DOM + localStorage 往返)。
* 单测覆盖不到的部分:输入框回填、切链路自动换、刷新页面后仍各自独立。
*/
const CDP_BASE = "http://127.0.0.1:9333";
async function ws() {
const list = await (await fetch(CDP_BASE + "/json/list")).json();
const page = list.find((t) => t.type === "page");
return page.webSocketDebuggerUrl;
}
const { WebSocket } = await import("node:http").then(() => import("ws")).catch(() => ({}));
// 没有 ws 依赖时用原生 WebSocket(Node 22+)
const WSImpl = globalThis.WebSocket || WebSocket;
const url = await ws();
const sock = new WSImpl(url);
let id = 0;
const pending = new Map();
await new Promise((r, j) => { sock.onopen = r; sock.onerror = j; });
sock.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
};
function send(method, params = {}) {
const myId = ++id;
return new Promise((res) => { pending.set(myId, res); sock.send(JSON.stringify({ id: myId, method, params })); });
}
async function evaluate(expr) {
const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
if (r.result && r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails));
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 = [];
function check(name, cond, detail) {
if (cond) { pass++; out.push(" \u2713 " + name); }
else { fail++; out.push(" \u2717 " + name + (detail ? " \u2014 " + detail : "")); }
}
await send("Runtime.enable");
await send("Page.enable");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
const PORT = Number(process.argv[2] || 8080);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
async function goto(u) {
await send("Page.navigate", { url: u });
await sleep(2500);
}
/* 干净起点:清掉本页 localStorage 再载入 */
await goto(PAGE);
await evaluate(`localStorage.removeItem("dcu_motor_ui_v2"); localStorage.removeItem("dcu_motor_transport"); "ok"`);
await goto(PAGE);
out.push("\n[A] 首次进入(无历史数据)");
let s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp})`);
let o = JSON.parse(s);
check("默认链路 wifi", o.tp === "wifi", s);
check("输入框显示 250", o.input === "250", s);
check("生效值 250", o.eff === 250, s);
check("两份都在(wifi250/ecat50)", o.map.wifi === 250 && o.map.ecat === 50, s);
out.push("\n[B] 切到 EtherCAT → 自动换成 50ms");
await evaluate(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(1200);
s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs})`);
o = JSON.parse(s);
check("链路已切到 ecat", o.tp === "ecat", s);
check("输入框自动变 50(不是留在 250)", o.input === "50", s);
check("生效值 50", o.eff === 50, s);
out.push("\n[C] 在 ECAT 下改成 120ms,只改这一份");
await evaluate(`(() => { const i=document.querySelector("#refreshRate"); i.value="120"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(600);
s = await evaluate(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp, ls: JSON.parse(localStorage.getItem("dcu_motor_ui_v2")||"{}").refreshByTp})`);
o = JSON.parse(s);
check("ecat 生效 120", o.eff === 120, s);
check("wifi 那一份仍是 250(没被污染)", o.map.wifi === 250, s);
check("已写入 localStorage", o.ls && o.ls.ecat === 120 && o.ls.wifi === 250, s);
out.push("\n[D] 切回 WiFi → 自动换回 250ms");
await evaluate(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(1200);
s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp})`);
o = JSON.parse(s);
check("链路回到 wifi", o.tp === "wifi", s);
check("输入框换回 250(没带着 ECAT 的 120)", o.input === "250", s);
check("生效值 250", o.eff === 250, s);
check("ecat 那一份仍记着 120", o.map.ecat === 120, s);
out.push("\n[E] 在 WiFi 下改成 800ms,再刷新页面");
await evaluate(`(() => { const i=document.querySelector("#refreshRate"); i.value="800"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(600);
await goto(PAGE);
s = await evaluate(`JSON.stringify({tp: motorDbg.state.tpId, input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, map: motorDbg.state.refreshByTp})`);
o = JSON.parse(s);
check("刷新后仍是 wifi 链路", o.tp === "wifi", s);
check("刷新后输入框恢复 800", o.input === "800", s);
check("刷新后生效值 800", o.eff === 800, s);
check("刷新后 ecat 那一份仍是 120", o.map.ecat === 120, s);
out.push("\n[F] 刷新后切到 ECAT,仍取回自己的 120");
await evaluate(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(1200);
s = await evaluate(`JSON.stringify({input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs})`);
o = JSON.parse(s);
check("输入框 120(跨刷新持久化 + 切换自动换都成立)", o.input === "120", s);
check("生效值 120", o.eff === 120, s);
out.push("\n[G] 旧数据迁移(模拟老版本只存单个 refreshRate)");
await evaluate(`(() => { localStorage.setItem("dcu_motor_ui_v2", JSON.stringify({refreshRate:"400"})); localStorage.setItem("dcu_motor_transport","wifi"); return "ok"; })()`);
await goto(PAGE);
s = await evaluate(`JSON.stringify({input: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`);
o = JSON.parse(s);
check("旧单值 400 迁到 wifi 并生效", o.input === "400", s);
check("ecat 不继承旧值,仍是默认 50", o.map.ecat === 50, s);
console.log(out.join("\n"));
console.log("\n" + "=".repeat(56));
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
sock.close();
process.exit(fail === 0 ? 0 : 1);
+96
View File
@@ -0,0 +1,96 @@
/**
* 浏览器实测(第二轮):切链路后**实际节拍**是否真的跟着变。
* 前一轮只验了输入框/state;这里数真实的 fetchMotorState 调用频率 ——
* 若 applyTransportRefresh 忘了重排定时器,state 是新值但节拍还是旧的。
*/
const CDP_BASE = "http://127.0.0.1:9333";
const list = await (await fetch(CDP_BASE + "/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 = (ev) => { const m = JSON.parse(ev.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");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
const PORT = Number(process.argv[2] || 8080);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
/* 干净起点,直接进 ECAT 链路(本机有 ecat 服务,能真连上跑起轮询) */
await send("Page.navigate", { url: PAGE }); await sleep(2000);
await ev(`localStorage.setItem("dcu_motor_transport","ecat"); localStorage.removeItem("dcu_motor_ui_v2"); "ok"`);
await send("Page.navigate", { url: PAGE }); await sleep(3000);
/* 装一个计数探针:统计 /ecat/api/motor/state 的实际请求次数 */
await ev(`(() => {
window.__cnt = 0;
if (!window.__origFetch) window.__origFetch = window.fetch;
window.fetch = function (u, i) {
if (String(u).includes("motor/state")) window.__cnt++;
return window.__origFetch.apply(this, arguments);
};
return "ok";
})()`);
const state0 = await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn: motorDbg.state.connected, eff: motorDbg.state.refreshIntervalMs})`);
out.push("\n[前置] 当前链路状态: " + state0);
const st0 = JSON.parse(state0);
check("已在 ecat 链路", st0.tp === "ecat", state0);
check("已连接(否则数不到轮询)", st0.conn === true, state0);
check("生效间隔 50ms", st0.eff === 50, state0);
async function measure(seconds) {
await ev(`window.__cnt = 0; "ok"`);
await sleep(seconds * 1000);
return await ev(`window.__cnt`);
}
out.push("\n[A] ECAT 默认 50ms 的实际节拍");
let n = await measure(3);
let hz = n / 3;
out.push(` 3 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 20 Hz)`);
check("接近 20Hz(>12Hz,留足浏览器调度余量)", hz > 12, `实测 ${hz.toFixed(1)}Hz`);
out.push("\n[B] 改成 500ms → 节拍应明显变慢");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="500"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(800);
n = await measure(4);
hz = n / 4;
out.push(` 4 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 2 Hz)`);
check("落到 2Hz 附近(0.8~4Hz)", hz > 0.8 && hz < 4, `实测 ${hz.toFixed(1)}Hz`);
out.push("\n[C] 切到 WiFi 再切回 ECAT:节拍应自动回到 ecat 那一份(500ms)");
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(1500);
const wifiState = await ev(`JSON.stringify({input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs})`);
check("切 WiFi 后生效值变 250(不是带着 ECAT 的 500)", JSON.parse(wifiState).eff === 250, wifiState);
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(2500);
const backState = await ev(`JSON.stringify({input: document.querySelector("#refreshRate").value, eff: motorDbg.state.refreshIntervalMs, conn: motorDbg.state.connected})`);
check("切回 ECAT 生效值回到 500(自己那一份)", JSON.parse(backState).eff === 500, backState);
n = await measure(4);
hz = n / 4;
out.push(` 4 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 2 Hz)`);
check("实际节拍也回到 2Hz 附近(不是 20Hz 的旧节拍)", hz > 0.8 && hz < 4, `实测 ${hz.toFixed(1)}Hz`);
out.push("\n[D] 改回 50ms,节拍应立刻提上去(验证重排真的生效)");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="50"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(600);
n = await measure(3);
hz = n / 3;
out.push(` 3 秒内 ${n} 次 ≈ ${hz.toFixed(1)} Hz(理论 20 Hz)`);
check("回到 20Hz 附近(>12Hz)", hz > 12, `实测 ${hz.toFixed(1)}Hz`);
console.log(out.join("\n"));
console.log("\n" + "=".repeat(56));
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
sock.close();
process.exit(fail === 0 ? 0 : 1);
+184
View File
@@ -0,0 +1,184 @@
/**
* test_ecat_autostart.mjs — EtherCAT 自动接入链条自测(Node,无浏览器)
*
* 跑法:node scripts/test_ecat_autostart.mjs [--port 8080]
*
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
* 四级链条的每一级都被正确驱动,且失败原因可读。
*
* 为什么要 stub 后端:板子当前 SSC 邮箱楔死(config_map 失败,ecat_server 自己的
* 注释写明必须复位 MCU),真实链路走不到 OP。但"自动开网卡→自动进 OP→自动起循环"
* 这三级的**驱动逻辑**必须能独立验证,否则一块坏板子就让这段代码永远测不到。
* 服务自动拉起那一级已在真机实测过(16s 起、带 --parent-pid),见 README。
*/
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import http from "node:http";
const __dir = dirname(fileURLToPath(import.meta.url));
if (typeof globalThis.performance === "undefined") {
globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 };
}
/* transport-ecat.js 会读 localStorage(网卡子串),Node 里没有,给个内存版 */
const _ls = new Map();
globalThis.localStorage = {
getItem: (k) => (_ls.has(k) ? _ls.get(k) : null),
setItem: (k, v) => _ls.set(k, String(v)),
removeItem: (k) => _ls.delete(k),
};
const { createEcatTransport } = await import("../js/motor/transport-ecat.js");
let pass = 0, fail = 0;
const out = [];
function check(name, cond, detail) {
if (cond) { pass++; out.push(` \u2713 ${name}`); }
else { fail++; out.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); }
}
function section(t) { out.push(`\n${t}`); }
/* ── stub 后端:模拟 ecat_server 的状态机 ──
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数。 */
function makeStub(opts) {
const st = { opened: false, started: false, calls: [], openSubstr: null,
failOpen: opts && opts.failOpen, failStart: opts && opts.failStart };
const srv = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
st.calls.push(req.url);
const send = (o) => {
const b = Buffer.from(JSON.stringify(o), "utf8");
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": b.length });
res.end(b);
};
let p = {};
try { p = body ? JSON.parse(body) : {}; } catch (_) {}
if (req.url === "/ecat/api/status") {
return send(st.opened
? { ok: true, slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 8, al_status: 0 } }
: { ok: false, log: ["未连接"] });
}
if (req.url === "/ecat/api/open") {
if (st.failOpen) return send({ ok: false, log: [st.failOpen] });
st.opened = true; st.openSubstr = p.substr;
return send({ ok: true, log: ["已连接 STUB"],
slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 1, al_status: 0 } });
}
if (req.url === "/ecat/api/close") { st.opened = false; return send({ ok: true, log: [] }); }
if (req.url === "/ecat/api/motor/start") {
if (st.failStart) return send({ ok: false, log: [st.failStart] });
st.started = true;
return send({ ok: true, log: [], period_ms: p.period_ms || 4 });
}
if (req.url === "/ecat/api/motor/state") {
if (!st.opened) return send({ ok: false, log: ["未连接"] });
return send({ ok: true, log: [], ts: Date.now() / 1000, wkc: 3, expected_wkc: 3,
cycles: 10, period_ms: 4, sent_seq: null, sent_frames: 0, queue_depth: 0,
bridge: { valid: true, ack: 0, rx_count: 0, tx_count: 0, drop: 0, tx_result: 0 },
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
head: { active: 0, boot_active: false, win_base: 0, win_count: 0, global_seq: 0 },
motors: [] });
}
if (req.url === "/ecat/api/motor/rx") return send({ ok: true, log: [], frames: [], remain: 0 });
return send({ ok: false, log: ["接口不存在: " + req.url] });
});
});
return new Promise((r) => srv.listen(0, "127.0.0.1", () => r({ st, srv, port: srv.address().port })));
}
const CTX = { getBaseUrl: () => "", recordSlow: () => {}, isTransient: () => false };
/** 把 transport 的相对路径 fetch 指到 stub 上 */
function bindFetch(port) {
const real = globalThis.fetch;
globalThis.fetch = (u, i) =>
real(typeof u === "string" && u.startsWith("/") ? `http://127.0.0.1:${port}${u}` : u, i);
return () => { globalThis.fetch = real; };
}
/* ── 1. 未连接时 probe() 应自动 open 再 start ── */
section("[1] probe() 自动开网卡 + 自动起循环(四级链条的 2/3/4 级)");
{
const { st, srv, port } = await makeStub();
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
await tp.probe();
un(); srv.close();
check("先探 status 发现未连接", st.calls[0] === "/ecat/api/status", st.calls[0]);
check("自动调用 /api/open(代替用户去点连接按钮)", st.calls.includes("/ecat/api/open"));
check("open 用默认网卡子串 I226", st.openSubstr === "I226", String(st.openSubstr));
check("随后自动调用 /api/motor/start(进 OP + 起后台循环)", st.started === true);
check("open 在 start 之前", st.calls.indexOf("/ecat/api/open") < st.calls.indexOf("/ecat/api/motor/start"));
check("periodMs 由后端回报写入", tp.debug().periodMs === 4, String(tp.debug().periodMs));
}
/* ── 2. 已连接时不要重复 open(api_open 会 close 旧 master,反复调会重置总线)── */
section("[2] 已连接则不重复 open(幂等)");
{
const { st, srv, port } = await makeStub();
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
await tp.probe();
const opensAfterFirst = st.calls.filter((c) => c === "/ecat/api/open").length;
await tp.probe(); // 第二次探测
const opensTotal = st.calls.filter((c) => c === "/ecat/api/open").length;
un(); srv.close();
check("首次 probe 调了一次 open", opensAfterFirst === 1, String(opensAfterFirst));
check("已连接后再 probe 不再 open(避免反复重置总线)",
opensTotal === 1, `共 ${opensTotal} 次`);
}
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
section("[3] 打不开网卡时的错误语义");
{
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I226' 的适配器" });
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
let err = null;
try { await tp.probe(); } catch (e) { err = e; }
un(); srv.close();
check("probe 抛错(不静默成功)", err !== null);
check("错误消息带候选网卡线索", err && /适配器/.test(err.message), err && err.message);
check("不标 transient(外部条件不满足,不是瞬时抖动)", err && err.transient === false,
err && String(err.transient));
}
/* ── 4. 进 OP 失败(板子邮箱楔死那类)→ 原因要原样带上来 ── */
section("[4] 进 OP 失败时原因可读");
{
const { srv, port } = await makeStub({
failStart: "config_map 失败: ConfigMapError —— SM0/SM1 配置正常却持续失败=SSC 邮箱地址错位楔死" });
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
let err = null;
try { await tp.probe(); } catch (e) { err = e; }
un(); srv.close();
check("probe 抛错", err !== null);
check("错误里保留楔死关键词(供上层归类成'需复位 MCU')",
err && /楔死/.test(err.message), err && err.message.slice(0, 80));
}
/* ── 5. 换网卡:立即 close 并用新子串重开 ── */
section("[5] 换网卡后用新子串重连");
{
const { st, srv, port } = await makeStub();
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
await tp.probe();
const changed = await tp.setAdapter("X710");
check("setAdapter 返回 true(确实变了)", changed === true);
check("换网卡会 close 旧连接", st.calls.includes("/ecat/api/close"));
await tp.probe();
un(); srv.close();
check("重新 open 用的是新子串", st.openSubstr === "X710", String(st.openSubstr));
check("同值 setAdapter 不重连", (await tp.setAdapter("X710")) === false);
check("网卡子串已持久化", _ls.get("dcu_ecat_adapter") === "X710", _ls.get("dcu_ecat_adapter"));
check("debug() 暴露当前网卡", tp.debug().adapter === "X710");
}
console.log(out.join("\n"));
console.log(`\n${"=".repeat(56)}`);
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
+144
View File
@@ -0,0 +1,144 @@
/**
* test_motor_refresh.mjs — 刷新速度「每链路一份」的自测(Node,无浏览器、无服务)
*
* 跑法:node scripts/test_motor_refresh.mjs
*
* 测什么:**真的 import 生产代码** js/motor/refresh-rate.js。
* 这里唯一值得测的是"值会不会串到另一条链路上" —— 那是共用单个变量时
* 会出的 bug(ECAT 的 50ms 带到 WiFi 上会让 HTTP 请求排队卡顿),
* 也是旧数据迁移最容易搞错的地方。
*
* 覆盖:
* 1 默认值:wifi 250 / ecat 50,且 defaultRefreshMap 每次给新对象
* 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 1)
* 3 旧数据迁移:单个 refreshRate → 只进 wifi,ecat 保持默认
* 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值
* 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义)
* 6 不限速谓词:1ms 档要关掉卡顿黄标与理论 Hz 显示(1ms ≠ 1000Hz)
*/
import {
REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP,
UNTHROTTLED_MAX_MS, minRefreshFor, isUnthrottled,
defaultRefreshMap, normalizeRefreshMap, pickRefresh
} from "../js/motor/refresh-rate.js";
let pass = 0, fail = 0;
const out = [];
function check(name, cond, detail) {
if (cond) { pass++; out.push(` \u2713 ${name}`); }
else { fail++; out.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); }
}
function section(t) { out.push(`\n${t}`); }
/* ── 1. 默认值 ── */
section("[1] 每链路默认值");
check("wifi 默认 250ms(无线 + ESP32 HTTP 往返)", REFRESH_DEFAULTS.wifi === 250,
`实际 ${REFRESH_DEFAULTS.wifi}`);
check("ecat 默认 50ms(本机快照,实测 p90≈22ms)", REFRESH_DEFAULTS.ecat === 50,
`实际 ${REFRESH_DEFAULTS.ecat}`);
check("两个默认值不相等(否则这次改动没意义)", REFRESH_DEFAULTS.wifi !== REFRESH_DEFAULTS.ecat);
check("上限 5000ms 与 #refreshRate 的 max 一致", REFRESH_MAX_MS === 5000);
const m1 = defaultRefreshMap(), m2 = defaultRefreshMap();
m1.wifi = 999;
check("defaultRefreshMap 每次返回新对象(改一份不污染另一份)", m2.wifi === 250);
check("defaultRefreshMap 不污染 REFRESH_DEFAULTS 常量", REFRESH_DEFAULTS.wifi === 250);
/* ── 1b. 下限也按链路 ── */
section("[1b] 每链路最小间隔");
check("ecat 下限 1ms(读本机 PDO 快照,无总线代价)", REFRESH_MIN_BY_TP.ecat === 1,
`实际 ${REFRESH_MIN_BY_TP.ecat}`);
check("wifi 下限仍 50ms(每拍是一次无线 HTTP 往返,再快只会积压)",
REFRESH_MIN_BY_TP.wifi === 50, `实际 ${REFRESH_MIN_BY_TP.wifi}`);
check("minRefreshFor('ecat') = 1", minRefreshFor("ecat") === 1);
check("minRefreshFor('wifi') = 50", minRefreshFor("wifi") === 50);
check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope") === REFRESH_MIN_MS);
/* ── 2. 归一 + 钳制(下限按链路) ── */
section("[2] 归一与钳制(下限按链路)");
const norm = normalizeRefreshMap({ wifi: 400, ecat: 20 });
check("正常值原样保留", norm.wifi === 400);
check("ecat 的 20ms 合法(下限 1ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`);
check("ecat 可以设到 1ms", normalizeRefreshMap({ ecat: 1 }).ecat === 1);
check("ecat 的 0/负数被抬到 1ms", normalizeRefreshMap({ ecat: 0 }).ecat === 1);
check("ecat 的 -5 被抬到 1ms", normalizeRefreshMap({ ecat: -5 }).ecat === 1);
check("wifi 的 1ms 仍被抬到 50(无线往返撑不住)",
normalizeRefreshMap({ wifi: 1 }).wifi === 50, `实际 ${normalizeRefreshMap({ wifi: 1 }).wifi}`);
check("wifi 的 20ms 也被抬到 50", normalizeRefreshMap({ wifi: 20 }).wifi === 50);
check("高于上限被压到 5000", normalizeRefreshMap({ wifi: 99999 }).wifi === REFRESH_MAX_MS);
check("ecat 高于上限同样压到 5000", normalizeRefreshMap({ ecat: 99999 }).ecat === REFRESH_MAX_MS);
check("字符串数字可用(localStorage 里存的是字符串)",
normalizeRefreshMap({ wifi: "300" }).wifi === 300);
check("ecat 字符串 \"1\" 可用", normalizeRefreshMap({ ecat: "1" }).ecat === 1);
const dirty = normalizeRefreshMap({ wifi: "abc", ecat: null });
check("脏值回落该链路默认(wifi)", dirty.wifi === 250, `实际 ${dirty.wifi}`);
check("脏值回落该链路默认(ecat)", dirty.ecat === 50, `实际 ${dirty.ecat}`);
check("未知链路 key 被丢弃(不在 localStorage 里攒垃圾)",
normalizeRefreshMap({ wifi: 300, bogus: 111 }).bogus === undefined);
const empty = normalizeRefreshMap(undefined, undefined);
check("完全没有历史数据 → 全用默认", empty.wifi === 250 && empty.ecat === 50);
check("传入非对象(数组/字符串)不炸且回落默认",
normalizeRefreshMap("garbage").wifi === 250);
/* ── 3. 旧数据迁移 ──
* 旧版只存单个 refreshRate(那时只有 WiFi 链路,值是为无线调的)。
* 迁到 wifi 才对;套到 ecat 上等于让用户平白继承一个不合适的值。 */
section("[3] 旧版单值迁移");
const mig = normalizeRefreshMap(undefined, 500);
check("旧单值迁进 wifi", mig.wifi === 500, `实际 ${mig.wifi}`);
check("ecat **不**继承旧单值,保持 50ms 默认", mig.ecat === 50, `实际 ${mig.ecat}`);
const migClamp = normalizeRefreshMap(undefined, "10");
check("旧单值也走 wifi 的下限钳制(10 → 50)", migClamp.wifi === 50);
const both = normalizeRefreshMap({ wifi: 300, ecat: 80 }, 500);
check("已有新格式时忽略旧字段(不被旧值覆盖)", both.wifi === 300 && both.ecat === 80);
/* ── 4. 取值 ── */
section("[4] pickRefresh 取当前链路的生效值");
check("正常取值", pickRefresh({ wifi: 300, ecat: 60 }, "ecat") === 60);
check("缺该链路 → 回落该链路默认(不是另一条的值)",
pickRefresh({ wifi: 300 }, "ecat") === 50);
check("map 为 null 不炸", pickRefresh(null, "wifi") === 250);
check("ecat 取出 1ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 1 }, "ecat") === 1);
check("wifi 取出 1ms 被抬到 50", pickRefresh({ wifi: 1 }, "wifi") === 50);
check("未知链路 id 回落到 wifi 默认(防御,不应出现)",
pickRefresh({}, "somethingnew") === 250);
/* ── 5. 隔离:本次改动的核心 ── */
section("[5] 两条链路互不污染");
const map = defaultRefreshMap();
/* 模拟:用户在 ECAT 下把刷新速度拉到 1ms(applyRefreshRate 只写当前链路那一份) */
map.ecat = 1;
check("改 ecat 到 1ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250);
/* 模拟:切回 WiFi,装载的应是 wifi 那一份 */
check("切回 wifi 装载 250 而非 1", pickRefresh(map, "wifi") === 250);
/* 模拟:在 WiFi 下改成 1000ms,再切去 ECAT */
map.wifi = 1000;
check("改 wifi 后 ecat 仍是 1ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 1);
check("切到 ecat 装载 1 而非 1000", pickRefresh(map, "ecat") === 1);
/* 存盘 → 读回一圈后仍各自独立(saveUiState/loadUiState 的往返) */
const roundTrip = normalizeRefreshMap(JSON.parse(JSON.stringify(map)));
check("存盘再读回:wifi 保留 1000", roundTrip.wifi === 1000);
check("存盘再读回:ecat 保留 1ms(不被 wifi 的下限 50 顺手抬高)",
roundTrip.ecat === 1, `实际 ${roundTrip.ecat}`);
/* ── 6. 不限速谓词 ──
* 1ms 档下节拍源必然比请求快,每拍都撞上"上一拍还在飞"。那是预期行为,
* 所以要据此关掉卡顿黄标;同时 1ms 硬算成 1000Hz 是谎报(浏览器实测约 125Hz)。 */
section("[6] 不限速谓词(关黄标 + 不谎报 Hz)");
check("阈值 20ms(50Hz):比浏览器实测每拍耗时(~8ms)宽裕,又远小于常用档",
UNTHROTTLED_MAX_MS === 20, `实际 ${UNTHROTTLED_MAX_MS}`);
check("1ms 属不限速", isUnthrottled(1) === true);
check("10ms 属不限速", isUnthrottled(10) === true);
check("19ms 属不限速(边界内)", isUnthrottled(19) === true);
check("20ms 不属不限速(边界外)", isUnthrottled(20) === false);
check("ECAT 默认 50ms 是正常限速档(黄标与 Hz 显示都保留)",
isUnthrottled(REFRESH_DEFAULTS.ecat) === false);
check("WiFi 默认 250ms 是正常限速档", isUnthrottled(REFRESH_DEFAULTS.wifi) === false);
check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)",
isUnthrottled(REFRESH_MIN_BY_TP.wifi) === false);
check("ECAT 下限 1ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false);
console.log(out.join("\n"));
console.log("\n" + "=".repeat(56));
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);
+464
View File
@@ -0,0 +1,464 @@
/**
* test_motor_transport.mjs — 电机页双传输的端到端自测(Node,无浏览器)
*
* 跑法(先确保本地页面服务器在跑,EtherCAT 部分还需 tools/ecat 服务在跑):
* node scripts/test_motor_transport.mjs [--port 8080]
*
* 测什么:**真的 import 生产代码** js/motor/transport-*.js,用 Node 的
* 全局 fetch 打真实的 /ecat/api/* 反向代理 → 真实 ecat_server → 真实从站。
* 不 mock 传输实现本身,否则测不到"契约对不对"这唯一值得测的东西。
*
* 覆盖:
* 1 两个传输都实现了 transport.js 声明的完整接口(漏一个方法 = 运行时崩)
* 2 WiFi 的二进制 /motor/state 解析(喂构造好的字节,不需要真设备)
* 3 能力位:只有 WiFi 支持 OTA / 周期同步
* 4 EtherCAT 快照 → WiFi motors[] 契约(字段名/类型逐个核)
* 5 seq 不动 → online 转 false(快照永不删除历史槽,必须靠 seq 判活)
* 6 CONFIG(0x0101) 从批量接收区累积并回填到 motors[].config_data
* 7 版本(0x00FF) 累积且 seq 递增(上层靠 seq 增长判新应答)
* 8 节点 ID 应答取走即清
* 9 fetchFeedback:无数据的字段必须是 null 而不是 0(监控 UI 约定)
* 10 代理不可用时报错信封可读(不是裸 500/CORS 报错)
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dir, "..");
const argPort = (() => {
const i = process.argv.indexOf("--port");
return (i > 0 && process.argv[i + 1]) ? Number(process.argv[i + 1]) : 8080;
})();
const BASE = `http://127.0.0.1:${argPort}`;
/* transport-ecat.js 用相对路径 "/ecat/api/..." 调 fetch;Node 里没有
* document.baseURI,所以把相对路径补成绝对地址后转给真实 fetch。
* 这层只改 URL 前缀,不碰请求体/方法/响应,被测逻辑仍是生产代码。 */
const realFetch = globalThis.fetch;
globalThis.fetch = (url, init) =>
realFetch(typeof url === "string" && url.startsWith("/") ? BASE + url : url, init);
if (typeof globalThis.performance === "undefined") {
globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 };
}
const { createWifiTransport } = await import("../js/motor/transport-wifi.js");
const { createEcatTransport } = await import("../js/motor/transport-ecat.js");
const CTX = {
getBaseUrl: () => "http://127.0.0.1:1", /* WiFi 用例不打真设备 */
recordSlow: () => {},
isTransient: (e) => /timeout|abort/i.test(String(e && e.message)),
};
let pass = 0, fail = 0;
const results = [];
function check(name, cond, detail) {
if (cond) { pass++; results.push(` \u2713 ${name}`); }
else { fail++; results.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); }
}
function section(t) { results.push(`\n${t}`); }
/* ── 1. 接口完整性 ──
* transport.js 头部声明的接口,两个实现都必须齐。少一个方法在浏览器里
* 是"点某个按钮才崩",很难发现,所以在这里一次性拦住。 */
section("[1] 接口完整性");
const REQUIRED = [
"id", "label", "supportsOta", "supportsPeriodSync", "sendHint",
"probe", "fetchStatus", "describeStatus", "reportedIp",
"fetchMotorState", "fetchFeedback", "sendFrames", "sendBurst",
"fetchVersionNode", "setPeriod", "teardown",
];
const wifi = createWifiTransport(CTX);
const ecat = createEcatTransport(CTX);
for (const tp of [wifi, ecat]) {
const missing = REQUIRED.filter((k) => tp[k] === undefined);
check(`${tp.id} 实现全部 ${REQUIRED.length} 个接口成员`, missing.length === 0,
missing.length ? "缺: " + missing.join(",") : "");
}
/* ── 2. WiFi 二进制解析 ── 构造 header 12B + 一条 76B entry */
section("[2] WiFi /motor/state?bin=1 二进制解析");
{
const buf = new ArrayBuffer(12 + 76);
const dv = new DataView(buf); const u8 = new Uint8Array(buf);
dv.setUint16(0, 0x4D53, true); // 'MS'
dv.setUint8(2, 1); // ver
dv.setUint8(3, 0x01); // flags: boot_active
dv.setUint32(4, 123, true); // age_ms
dv.setUint8(8, 2); // active
dv.setUint8(9, 1); // count
let o = 12;
u8[o] = 5; u8[o + 1] = 2; u8[o + 2] = 1; u8[o + 3] = 32;
dv.setUint32(o + 4, 77, true); // rx_count
dv.setUint32(o + 8, 999, true); // seq
u8[o + 12] = 0xAB; // data[0]
dv.setUint32(o + 44, 4, true); // config_rx_count
dv.setUint32(o + 48, 6, true); // config_seq
u8[o + 52] = 0xCD; // config_data[0]
/* 让传输的 fetchMotorState 拿到这段字节:临时替换 fetch */
const saved = globalThis.fetch;
globalThis.fetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => buf });
const st = await wifi.fetchMotorState();
globalThis.fetch = saved;
check("boot_active 由 flags bit0 解出", st.boot_active === true);
check("age_ms/active_count 正确", st.age_ms === 123 && st.active_count === 2,
`age=${st.age_ms} active=${st.active_count}`);
const m = st.motors[0] || {};
check("entry 步长 76B,字段齐全",
m.node === 5 && m.ch === 2 && m.online === true && m.dlc === 32 &&
m.rx_count === 77 && m.seq === 999, JSON.stringify(m).slice(0, 120));
check("data 是 64 字符大写 hex", m.data && m.data.length === 64 && m.data.startsWith("AB"));
check("config_data 是 48 字符 hex", m.config_data && m.config_data.length === 48 &&
m.config_data.startsWith("CD"));
}
/* ── 3. 能力位 ── */
section("[3] 链路能力位");
check("WiFi 支持 OTA", wifi.supportsOta === true);
check("EtherCAT 不支持 OTA(没有固件上传+relay 通路)", ecat.supportsOta === false);
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 → 从站)");
let liveOk = false;
try {
const st = await ecat.fetchStatus();
liveOk = true;
const parts = ecat.describeStatus(st);
check("能读到从站状态并生成 AL 描述", Array.isArray(parts) && parts.length > 0,
JSON.stringify(parts));
} catch (e) {
results.push(` \u26a0 EtherCAT 服务不可用,跳过 4~9: ${e.message}`);
}
if (liveOk) {
const st = await ecat.fetchMotorState();
check("返回 WiFi 契约的顶层字段",
"have_data" in st && "age_ms" in st && "active_count" in st &&
"boot_active" in st && Array.isArray(st.motors));
if (st.motors.length) {
const m = st.motors[0];
const shapeOk =
typeof m.node === "number" && typeof m.ch === "number" &&
typeof m.online === "boolean" && typeof m.dlc === "number" &&
typeof m.rx_count === "number" && typeof m.seq === "number" &&
typeof m.data === "string" && typeof m.config_data === "string";
check("motors[] 每项字段名/类型与 WiFi 一致", shapeOk, JSON.stringify(m).slice(0, 150));
check("data 是 64 字符 hex(32B STATUS)", m.data.length === 64, `len=${m.data.length}`);
results.push(` \u2139 在线电机: ` + st.motors
.map((x) => `N${x.node}@CH${x.ch}${x.online ? "" : "(离线)"}`).join(" "));
} else {
results.push(" \u2139 总线上无电机槽,跳过 motors[] 字段核对");
}
section("[5] 反馈:缺失字段必须是 null 而不是 0");
const fb = await ecat.fetchFeedback();
check("channels 长度为 3", fb.channels && fb.channels.length === 3);
const c0 = fb.channels[0];
check("tec/rec 为 null(过程数据没有这两个计数)",
c0.tec === null && c0.rec === null, `tec=${c0.tec} rec=${c0.rec}`);
check("valid 反映 wkc==expected_wkc", typeof c0.valid === "boolean");
section("[6] 版本/节点ID 累积器");
check("未收到应答时 fetchVersionNode 返回 null",
(await ecat.fetchVersionNode(7)) === null);
check("takeNodeIdReport 存在且未收到时返回 null",
typeof ecat.takeNodeIdReport === "function" && ecat.takeNodeIdReport(7) === null);
const dbg = ecat.debug();
check("debug() 暴露 PDO 周期与槽位统计",
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 7 &&
dbg.slotCount === 27 && dbg.scanCycles === 4, JSON.stringify(dbg && {
periodMs: dbg.periodMs, slots: dbg.slots }));
}
/* ── 10. 代理错误信封可读 ── 指向一个没人监听的端口 */
section("[7] 代理不可用时的错误信封");
{
const dead = createEcatTransport(CTX);
const saved = globalThis.fetch;
/* 打一个必定没人监听的端口,模拟"EtherCAT 服务没启动" */
globalThis.fetch = (url, init) =>
realFetch(typeof url === "string" && url.startsWith("/")
? "http://127.0.0.1:1" + url : url, init);
let msg = "";
try { await dead.fetchMotorState(); } catch (e) { msg = e.message || ""; }
globalThis.fetch = saved;
check("服务不可用时抛出可读错误(不是裸 CORS/undefined)", msg.length > 0, msg.slice(0, 90));
check("fetchVersionNode 纯本地不打网络", (await dead.fetchVersionNode(1)) === null);
}
/* ── 8. 快照 → WiFi 契约的翻译逻辑 ──
* 这几项是本次改动的核心,且**只能**用构造快照来测:
* 真实总线此刻可能没有 CAN 流量(global_seq=0 / win_count=0),
* 那样 motors[] 永远是空的,seq 判活、CONFIG 回填、版本 seq 递增
* 这些恰恰最容易写错的分支就一条都覆盖不到。
* 这里仍然跑的是生产代码 transport-ecat.js,只是替换它看到的 HTTP 响应。 */
section("[8] 快照→契约翻译(构造快照,跑生产代码)");
{
const tp = createEcatTransport(CTX);
const saved = globalThis.fetch;
/* 每次 fetch 按路由返回预置内容;motor/rx 只在第一次给帧,之后给空。 */
let snap = null;
let rxFrames = [];
const jsonResp = (obj) => ({
ok: true, status: 200, text: async () => JSON.stringify(obj),
});
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/motor/rx")) {
const f = rxFrames; rxFrames = [];
return jsonResp({ ok: true, log: [], frames: f, remain: 0 });
}
if (u.includes("/motor/state")) return jsonResp(Object.assign({ ok: true, log: [] }, snap));
if (u.includes("/motor/start")) return jsonResp({ ok: true, log: [], period_ms: 4 });
return jsonResp({ ok: true, log: [] });
};
const mkSnap = (motors, extra) => Object.assign({
ts: Date.now() / 1000, wkc: 3, expected_wkc: 3, cycles: 100, period_ms: 4,
sent_seq: null, sent_frames: 0, queue_depth: 0,
bridge: { valid: true, ack: 0, rx_count: 1000, tx_count: 5, drop: 2, tx_result: 0 },
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
head: { active: motors.length, boot_active: false, win_base: 0, win_count: 7, global_seq: 42 },
motors,
}, extra || {});
const STATUS_HEX = "AB".repeat(32); /* 32B STATUS */
const CONFIG_HEX = "5A".repeat(24); /* 24B CONFIG(0x0101) */
/* (a) 首次:seq 在动 → online=true,rx_count 从 1 起累 */
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 500, data: STATUS_HEX }]);
let st = await tp.fetchMotorState();
let m = st.motors[0];
check("(a) 从站 online + seq 在动 → online=true", m.online === true);
check("(a) rx_count 本地累计为 1", m.rx_count === 1, `rx=${m.rx_count}`);
check("(a) active_count 取自 head.active", st.active_count === 1);
/* (b) seq 递增 → rx_count 继续累加 */
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 501, data: STATUS_HEX }]);
st = await tp.fetchMotorState();
check("(b) seq 递增 → rx_count 累加到 2", st.motors[0].rx_count === 2,
`rx=${st.motors[0].rx_count}`);
/* (c) CONFIG 从批量接收区累积 → 回填 motors[].config_data。
* 这是 WiFi 有、EtherCAT 没有的字段,回填不了就等于 CONFIG 面板永久空白。 */
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x0101, data: CONFIG_HEX }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 502, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
st = await tp.fetchMotorState();
check("(c) CONFIG(0x0101) 回填到 config_data(48 hex)",
st.motors[0].config_data === CONFIG_HEX,
`len=${st.motors[0].config_data.length}`);
/* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答" */
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 503, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
await tp.fetchMotorState();
const v1 = await tp.fetchVersionNode(3);
check("(d) 版本应答被累积", v1 && v1.node === 3 && v1.version === VER_HEX);
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 504, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
await tp.fetchMotorState();
const v2 = await tp.fetchVersionNode(3);
check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2.seq === v1.seq + 1,
`${v1.seq} → ${v2.seq}`);
/* (e) 节点 ID 应答:取走即清 */
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 505, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
await tp.fetchMotorState();
check("(e) 节点 ID 应答可取出", tp.takeNodeIdReport(3) === "0700");
check("(e) 取走即清(第二次为 null)", tp.takeNodeIdReport(3) === null);
/* (f) STATUS 已由状态窗口聚合,批量区里的 0x0100 必须跳过(否则重复解一遍) */
rxFrames = [{ ch: 1, dlc: 32, valid: true, id: (3 << 16) | 0x0100, data: "FF".repeat(32) }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
st = await tp.fetchMotorState();
check("(f) 批量区的 STATUS(0x0100) 不污染 config_data",
st.motors[0].config_data === CONFIG_HEX);
check("(f) data 仍来自状态窗口", st.motors[0].data === STATUS_HEX);
/* (g) xrx 长帧快照也要分派:CONFIG/版本是 DLC24,短快照会截断 */
const CFG2 = "77".repeat(24);
snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 32, seq: 10, data: STATUS_HEX }],
{ xrx: { ch: 1, dlc: 24, id: (4 << 16) | 0x0101, data: CFG2 } });
st = await tp.fetchMotorState();
check("(g) xrx 长帧快照的 CONFIG 也回填",
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]);
st = await tp.fetchMotorState();
check("(h) seq 未变但仍在阈值内 → 暂时仍算在线",
st.motors.find((x) => x.node === 3).online === true);
/* 把快照 ts 往前推 3 秒不管用(判活用的是本地时间),
* 所以真正等过 STALE_OFFLINE_MS(2000ms) —— 这是唯一忠实的验证方式。 */
await new Promise((r) => setTimeout(r, 2100));
snap = mkSnap([stale]);
st = await tp.fetchMotorState();
check("(h) seq 停超 2s → online 转 false(快照不删历史槽,必须靠 seq 判活)",
st.motors.find((x) => x.node === 3).online === false,
`online=${st.motors.find((x) => x.node === 3).online}`);
/* (i) 通信异常(wkc != expected)→ feedback 全部 null,而不是 0 */
snap = mkSnap([stale], { wkc: 0, expected_wkc: 3 });
await tp.fetchMotorState();
const fb2 = await tp.fetchFeedback();
check("(i) wkc 不匹配 → valid=false 且 rx/tx 为 null(不能显示成 0)",
fb2.channels[0].valid === false && fb2.channels[0].rx === null,
JSON.stringify(fb2.channels[0]));
/* (i2) 单槽刷新周期:状态栏必须显示"这台电机的数据多久更新一次",
* 而不是只显示 PDO 周期。窗口自适应后两者通常相等(在线电机每周期
* 都刷),但电机分散在多个窗口时 slot_period_ms 会是 period_ms 的倍数,
* 这时只报 PDO 4ms 就是谎报 —— 实际数据是 16ms 才换一次。 */
await tp.fetchStatus().catch(() => {}); /* 让 periodMs 就位(start 回报 4) */
snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 4 });
await tp.fetchMotorState();
let parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i2) 单槽周期 == PDO 周期时只显示一个(不啰嗦)",
parts.some((p) => p === "PDO 4ms") &&
!parts.some((p) => p.includes("单槽")),
JSON.stringify(parts));
snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 16 });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i2) 单槽周期 != PDO 周期时两者都显示(不谎报数据新鲜度)",
parts.some((p) => p.includes("PDO 4ms") && p.includes("单槽 16ms")),
JSON.stringify(parts));
check("(i2) debug() 暴露 slotPeriodMs 与 winBases 便于现场排查",
tp.debug().slotPeriodMs === 16,
JSON.stringify({ slotPeriodMs: tp.debug().slotPeriodMs }));
/* (i3) 自旋节拍源:它占满一个 CPU 核,属于用户能感知的代价,
* 必须在状态栏可见,且 setSpin 要把选择原样带给后端。 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i3) 自旋生效时状态栏标注占核(不能让用户猜 CPU 去哪了)",
parts.some((p) => p.includes("自旋") && p.includes("1 核")),
JSON.stringify(parts));
/* (i3b) 钉到 E 核时要写出核号 —— 用户看到"占 1 核"会担心抢了性能核,
* 显示实际落在哪些逻辑处理器才能证明它是能效核。掩码是 64 位,
* JS 移位只有 32 位,所以高低两段分别取,这里专门覆盖跨 32 位的情况。 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
ecore_mask: 0xfff0000 });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i3b) 钉到 E 核时列出核号区间(16-27,不逐个罗列挤掉从站名)",
parts.some((p) => p.includes("E 核 16-27")),
JSON.stringify(parts));
check("(i3b) 已列出 E 核号时不再写笼统的『占 1 核』",
!parts.some((p) => p.includes("占 1 核")), JSON.stringify(parts));
check("(i3b) debug() 暴露 ecores 列表便于现场核对",
tp.debug().ecores.length === 12 && tp.debug().ecores[0] === 16 &&
tp.debug().ecores[11] === 27,
JSON.stringify(tp.debug().ecores));
/* 高 32 位之外的核(>31)不能丢:用 2^40 验证跨段位 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
ecore_mask: 1 + 2 ** 40 });
await tp.fetchMotorState();
check("(i3b) 掩码跨 32 位时高段核号不丢(0 与 40)",
JSON.stringify(tp.debug().ecores) === JSON.stringify([0, 40]),
JSON.stringify(tp.debug().ecores));
/* 掩码为 0 = 探测失败/对称 CPU,要退回笼统措辞而不是显示空列表 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
ecore_mask: 0 });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i3b) 没钉核时退回『占 1 核』而不是列出空的 E 核",
parts.some((p) => p.includes("占 1 核") && !p.includes("E 核")),
JSON.stringify(parts));
/* 不连续的核(混合架构常见:E 核未必连成一段)要压成多段而不是硬凑一个区间 */
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true,
ecore_mask: (1 << 16) | (1 << 17) | (1 << 20) | (1 << 24) });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i3b) 不连续核压成 16-17,20,24(不硬凑区间)",
parts.some((p) => p.includes("16-17,20,24")), JSON.stringify(parts));
snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: false });
await tp.fetchMotorState();
parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } });
check("(i3) 未自旋时不标注(避免长期挂着无关提示)",
!parts.some((p) => p.includes("自旋")), JSON.stringify(parts));
let spinBody = null, spinHits = 0, startHits = 0;
globalThis.fetch = async (url, init) => {
const u = String(url);
if (u.includes("/motor/spin")) {
spinHits++; spinBody = JSON.parse(init.body);
return jsonResp({ ok: true, log: [], spin: spinBody.spin !== false, period_ms: 1 });
}
if (u.includes("/motor/start")) {
startHits++;
return jsonResp({ ok: true, log: [], period_ms: 1, spin: true });
}
return jsonResp({ ok: true, log: [] });
};
/* 先走一遍 probe,让 ensureStarted 把 periodMs 填上 */
await tp.probe();
const startBefore = startHits;
check("(i3) setSpin(false) 把 spin:false 带给后端并采纳回报值",
(await tp.setSpin(false)) === false && spinBody.spin === false,
JSON.stringify(spinBody));
await tp.setSpin(true);
check("(i3) setSpin(true) 透传 spin:true", spinBody.spin === true,
JSON.stringify(spinBody));
/* 关键:切节拍源绝不能走 motor/start —— 那条路由会先停 PDO 循环,
* 停手超过从站 1000ms 看门狗就把从站踢出 OP,重进还可能楔死邮箱。 */
check("(i3) setSpin 走 /motor/spin,不重启 PDO 循环(不碰 motor/start)",
spinHits >= 2 && startHits === startBefore,
JSON.stringify({ spinHits, startHits, startBefore }));
await tp.setSpin(null);
check("(i3) setSpin(null) 发空请求体,交回后端自动决定",
spinBody && !("spin" in spinBody), JSON.stringify(spinBody));
/* (j) 发送:多帧一次提交(同一 PDO 周期 / 同一 seq 沿)→ 逐帧 results */
let sentBody = null;
globalThis.fetch = async (url, init) => {
const u = String(url);
if (u.includes("/motor/send")) {
sentBody = JSON.parse(init.body);
return jsonResp({ ok: true, log: [], queued: sentBody.frames.length, batch_max_bytes: 332 });
}
return jsonResp({ ok: true, log: [] });
};
const res = await tp.sendFrames(
[{ id: 0x30200, dlc: 32, data: "11".repeat(32) },
{ id: 0x40200, dlc: 32, data: "22".repeat(32) }], { ch: 1 });
check("(j) 多帧一次提交(同一 seq 沿)", sentBody && sentBody.frames.length === 2,
JSON.stringify(sentBody && sentBody.frames.map((f) => f.id)));
check("(j) 每帧带上批级通道", sentBody.frames.every((f) => f.ch === 1));
check("(j) 返回逐帧 results(长度与请求一致)",
Array.isArray(res) && res.length === 2 && res.every((r) => r.status === "ok"));
/* (k) 后端 ok:false → 每帧标错,让上层流控能看到 */
globalThis.fetch = async () => jsonResp({ ok: false, log: ["未连接"] });
const bad = await tp.sendFrames([{ id: 0x30200, dlc: 8, data: "00".repeat(8) }], { ch: 0 });
check("(k) 后端 ok:false → results 标 error 且带原因",
bad[0].status === "error" && /未连接/.test(bad[0].desc || ""), JSON.stringify(bad[0]));
globalThis.fetch = saved;
}
console.log(results.join("\n"));
console.log(`\n${"=".repeat(56)}`);
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);