Files
DCU_DesktopController/README.md
T
cbb 2d1743ca4f feat(motor): 支持 WiFi 与 EtherCAT 双链路传输
- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用
- 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题
- 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询
- 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
2026-09-04 12:18:06 +08:00

260 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DCU ESP32 调试控制台(Web 前端 + 本地页面服务)
`G:\Robot_v2\DCU\tools\ESP32` —— ESP32-C3(`DCU/ESP32_COMM` 固件)的上位机调试界面。
本目录**不含固件代码,也不直接访问硬件**:HTML/CSS/JS 是纯前端,页面本体由
`esp32_server.py` 以静态文件方式提供(同源相对路径),而所有业务 API 由 ESP32
固件的 80 端口跨域提供。设备 IP 存在浏览器 `localStorage` 的 `dcu_esp_ip`
(默认 `192.168.31.127`,见 `js/common/base-url.js`)。
## 启动方式
| 入口 | 形态 | 说明 |
|------|------|------|
| `esp32_app.vbs` | 桌面窗口 | pywebview + WebView2,无控制台窗口;服务跑在独立子进程 |
| `esp32_web.vbs` | 默认浏览器标签页 | 无控制台窗口 |
| `serve.bat` | 控制台模式 | 端口 8080,窗口内可见服务器日志,用于调试 |
窗口模式为何拆两个进程:`pythonw` + `pythonnet`(pywebview 依赖)环境下 Python
后台线程会被静默杀死,把 HTTP 服务放进独立子进程可规避,与 `tools/ecat/ecat_server.py`
同款结构。pywebview 窗口没有地址栏与刷新按钮,因此服务器会向 HTML 响应注入
`_reload.html`(右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 拦截)。
`build_mem.bat`(在 `scripts/`)是从 Git Bash 调用 ESP-IDF 构建 `DCU/ESP32_COMM` 的辅助脚本
(会先清掉 MSYS 环境变量绕过 IDF 的 MSys 检测)。
## 目录结构
```
tabs.html 标签外壳 = 工具入口(GET / 会 302 到这里)
pages/ 主功能页,各自引用 ../css 与 ../js
lab/ 自包含实验页,零外部依赖,删文件即可移除
css/ js/ 共享样式与 ES module
scripts/ 构建辅助脚本
esp32_server.py 本地页面服务 + 桌面窗口外壳
esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
```
## 页面
`tabs.html` 是单窗口多标签外壳,用 iframe 承载下列功能页,激活标签记忆在
`localStorage` 的 `dcu_tab`(按文件名匹配,与目录层级无关):
| 页面 | 用途 | 主要脚本 |
|------|------|----------|
| `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,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 链路测速,完全自包含,删文件即可移除 | 内联 |
## 目录约定
- `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。
- `js/common/` —— 跨页公用:`base-url`(设备 IP)、`connection`、`polling`、`log`、`toast`、`utils`、`heap-low-history`。
- `js/ota/`、`js/ble/`、`js/can/`、`js/motor/` —— 按功能域拆分的 ES module,跨域引用只走 `js/common/`。
- 子系统配色:ESP32 = 青绿 · GD32 = 琥珀 · BLE = 蓝 · CAN = 绿 · 内存/调试 = 紫。
## 改动须知
- **改 CSS/JS 后必须 bump 引用处的 `?v=` 缓存版本**,否则浏览器会加载旧代码。
- `pages/` 与 `lab/` 下的页面引用共享资源要用 `../css/`、`../js/` 前缀;
**JS 模块内部引用其他资源(如 Worker)须用 `import.meta.url` 解析**,
不能写文档相对路径,否则页面换层级就会 404(见 `js/motor/main.js` 的 `TICK_WORKER_URL`)。
- 新增功能页放 `pages/`,并在 `tabs.html` 的 `TABS` 数组登记;
一次性/实验页放 `lab/` 并保持自包含。
- 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 数量。
- 新增 CAN 工作模式时,需在页面上显式补按钮与说明文字。
- DBC 文件常为 GBK/GB2312:先按 UTF-8(`{fatal: true}`)尝试,失败回退 GB18030,并去 BOM。
## 相关仓库
- `DCU/ESP32_COMM` —— ESP32-C3 固件(OTA、SPI Link v2、CAN FD bridge)。
- `DCU/DCU_APP`、`DCU/GD32E50X_BOOT` —— GD32 侧应用与 bootloader。
- `DCU/tools/ecat` —— LAN9252 EtherCAT 主站工具集(同类结构的姊妹工具)。