diff --git a/README.md b/README.md index 6409aeb..a09455e 100644 --- a/README.md +++ b/README.md @@ -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 数量。 diff --git a/css/motor.css b/css/motor.css index ba4b0ed..d58f05a 100644 --- a/css/motor.css +++ b/css/motor.css @@ -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; } diff --git a/esp32_server.py b/esp32_server.py index 1575347..c7f3051 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -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 / → 本目录静态文件(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() diff --git a/js/motor/main.js b/js/motor/main.js index 80e019c..7a6896a 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -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} */ 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)不可切入。 + *