Files
DCU_DesktopController/README.md
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

484 lines
40 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` 的
窗口模式同款结构。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/ 正式自测/测量脚本(清单见下;一次性探针不入库)
docs/compose/spec/ compose 功能规格
docs/reports/ 历史调研报告(原根目录 ECAT_*/REPORT_*)
esp32_server.py 本地页面服务 + 桌面窗口外壳
esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
```
## scripts/ 正式清单(仅保留这些)
| 脚本 | 用途 |
|------|------|
| `build_mem.bat` | Git Bash 下构建 ESP32_COMM |
| `test_motor_transport.mjs` | 双链路传输契约 |
| `test_motor_refresh.mjs` | 刷新速度每链路一份(纯逻辑) |
| `test_ecat_autostart.mjs` | 自动接入链条(stub) |
| `test_app_lease.py` / `test_app_lease_e2e.mjs` | 关窗停总线 |
| `test_esf_pack.py` | PDO 快发 ESF1 打包 |
| `check_esf_latency.py` / `check_latency_breakdown.py` / `check_e2e_click_motion.py` | 快发与点击延迟测量 |
| `check_web_send_latency.py` / `check_recover_after_reset.py` / `check_click_to_motion.py` | 网页全链路自测(原 `e2e_web_send_latency.py`)、GD32 复位后恢复并验收、点击→STATUS 动作 |
| `check_guard_idle_silence.py` / `check_guard_fires_hw.py` / `guard_probe.py` | tx 冻结守卫真机门槛:健康总线不许被重连 / 制造冻结证明守卫真会触发;共用前置与通路(改 native 后跑,见 `tools/ecat/MIGRATION.md` 加固段) |
| `browser_check_{refresh,tick,floor,period}.mjs` | 浏览器侧 DOM/节拍实测 |
| `probe_ecat_slot_atomicity.py` / `probe_ecat_vs_wifi.py` | 可复用采样器(gitignore 白名单) |
现场 `_tmp_*` / `skip_*` / `anal_*` 等一次性脚本**不入库**;历史报告在 `docs/reports/`。
## 页面
`tabs.html` 是单窗口多标签外壳,用**多 iframe 常驻保活**承载下列功能页:每个标签一个懒创建 iframe,切换只改可见性、不销毁文档(否则页内 WebSocket/轮询会随导航全灭)。激活标签记忆在
`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/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI/ECAT 通信测速(标签名「SPI/ECAT 通信测速」;含 EtherCAT 接收看板,完全自包含,删文件即可移除) | 内联 |
## 设备发现与选择(局域网)
顶栏 `🔍 设备`(`tabs.html`)与 OTA 页 IP 行的 `🔍 发现`(`pages/index.html`)打开同一个面板:
扫出局域网里哪几台是这块 ESP32 中继板,点一行就把当前设备端点改成它,六个标签一起跟着切。
- **探测原语在服务侧且无状态**:`POST /api/lan/probe {"ips":[≤64],"ports":[80],"timeout_ms":750,"local":true}`
→ 逐 IP 一条判决 + `scan` 计数 + `local`(本机默认路由地址与派生 /24)。实现 `lan_probe.py`,
不建 job 表、不常驻线程、不落盘;分批/进度/取消/缓存全在 `js/common/lan-picker.js`。
- **判据只有一处**(`lan_probe.classify`),页面不复算。探测固定打 `/system/status?lite=1`
(固件里这条路径 256 B 栈缓冲、不抢 JSON 工作锁、不发 SPI 事务)。三态判决:
`board`(形状对得上,STA 需自报 IP 与探测地址一致)/ `rejected`(**有反证**:非 200、非 JSON、
无 `wifi` 对象、STA 自称在别处)/ `unknown`(连不上、读超时、或形状合但落不进前两类)。
超时**不算**"不是板子",也不算"没找到",单独列出来。
- **两档扫描**:`快扫` 先打种子地址(当前端点、192.168.31.127、192.168.31.1、上次发现到的,≤8 个,实测 ~0.1–1s),
**种子没命中就自动扩到整段**再扫一次——设备换了 DHCP 地址正是快扫最常见的失效场景;扩段后仍没命中才说"本轮没扫到"。
`全段` 直接打默认路由派生的 /24(实测 253 个地址:服务端各批耗时之和 6.0s,页面双路并发墙钟约 3.5s)。
耗时由"不存在的地址等满 750ms 截止"主导,服务侧批内并发 48(跨不同地址,一块板子全程只被占 1 个 socket)。
面板上报的"用时"是页面墙钟,不是各批累加。CIDR 可手填;
非默认路由的网段(比如 EtherCAT 那块 I210)永远不自动扫。
- **不打扰正在跑的东西**:当前已选端点不进候选表(它的活体判决就是 `device-link` 那条 1Hz
`/system/status` 轮询,不再另开 socket——设备 httpd 只有 6 个 socket 且开了 `lru_purge`,
多开会踢掉网页自己的 keep-alive);`flashBusyNow()`(localStorage `dcu_flash_busy_until`,跨 iframe)
为真时既不能发起扫描也不能换端点;离线自动补扫额外要求 ECAT 环没在跑且距上次 ≥60s,
且**只列候选、绝不自动换设备**。
- **端点只有一个写手**:`deviceLink.setIp / selectDevice`,IPv4 校验在 `js/common/base-url.js:saveDeviceIp`
单点做,非法值**拒绝且不改写现值**,各页输入框只是视图。选中时记一份设备指纹(版本 / git_hash),
Owner 之后每次拉 status 都核 `snap.endpointCheck = match|mismatch|unverified`——设备换了、
或这地址被另一块板占了,会被标出来而不是悄悄当成同一台。
- **已知限制(面板里也这么写)**:固件 `/system/status` 不上报 mac/chip_id,两块同款板只能靠 IP 与
`uptime_sec` 区分(认领办法:按板上复位键后快扫,新上电那台 uptime < 30s)。STA 失败回退 AP 的板子
自称 `192.168.31.1`,PC 还连着路由器 WLAN 时它在三层上不可达,扫不到是正确行为,要真人连
`ESP32_OTA_RELAY`。mDNS(README 旧版曾写 `esp32-ota-relay.local`)**没有**编进固件。
- 自测:`python scripts/test_lan_probe.py`(离线,假板绑本机网卡地址跑真 socket,含"起/关服务判决必须翻转"
的灵敏度对照);`python scripts/check_lan_discovery.py --expect 1`(真网段门槛,
用 RFC 5737 `192.0.2.0/28` 做反向对照,前置不满足报 BLOCKED/exit 2 而不算通过)。
## 链路胶囊(WiFi / ECAT / BLE)的灯色与文案
`connection.js:updateLinkPills()` 是**唯一**写这三颗 pill 的 class 与文字的地方,判据全部取自
`device-link` 快照。历史上 ECAT 那颗有三个写手(快照一条、`app.js` 的 0x1008 probe 一条、
重连边沿检测一条),节奏和判据都不同,实测出现过"绿点 + 文字离线"的自相矛盾;现在
`app.js:refreshEcatPillName()` 只把读到的名字公布到 `window.__ecatProbeName`,不再碰 DOM。
ECAT 分级:`!busOpen` → 离线(后端/网卡未开);`busOpen && loop` → 在线(绿,显示从站名);
`busOpen && !loop` → 琥珀并标 AL 档位(1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP,见快照
`links.ecat.slaveState`)。注意 `links.ecat.online` 仍是老判据(status 有回且带 slave 对象即真),
只表示"总线侧有应答",不代表能跑——`updateConnectionUi()` 的整体在线判定还在用它,
要收紧得单独定(会影响 OTA 页那张大在线卡)。
## 目录约定
- `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。
**所有权**:`.btn` 共享 chrome 在 `layout.css`;`components.css` 管跨页 `.toast`
(居中单条);`motor.css` 的 `.btn` / `.toast-container` 是电机页主题覆盖与
堆叠 Toast,**不要**再往 layout/components 抄一份电机专用规则。
- `js/common/` —— 跨页公用:`base-url`(设备 IP + IPv4 校验单点)、`connection`、`polling`、`log`、
`toast`(居中)、`toast-stack`(容器堆叠,电机页)、`ecat-bus`(ECAT 接入策略单点)、
`device-link`(端点唯一写手 + 全链路 Owner)、`lan-picker`(设备发现面板)、`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_native.exe`(C/SOEM 守护进程,经本服务 `/ecat/api/*` 代理) | `POST /api/motor/state` 读后台 PDO 快照 | **桌面 APP:只走 ESF1 二进制快发**(`ecat_send_raw` → TCP `fast_send_port`,**失败不回落 HTTP**);浏览器标签:`POST /ecat/api/motor/send` |
- 接口定义见 `js/motor/transport.js` 头部;两个实现分别在 `transport-wifi.js` / `transport-ecat.js`。
**业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控
——互斥/退避/冷却/latest-wins)两条链路完全共用**,新增链路只实现接口即可。
- **PDO 快发(pdo-fast-send)**:桌面壳(`esp32_app`)下 ECAT 全部 motor 发送
**强制** `pywebview.api.ecat_send_raw` → GUI ESF1 → native 持久 TCP 入队。
**已删除** Python 桥内的 HTTP send_raw / JSON motor/send 回落,前端在有桥时
也不再回落 JSON——快发失败直接报错,防止误走慢路径。浏览器无壳仍用 HTTP。
真机对照:TCP FSOK p50≈0.08ms;HTTP 常见 8–12ms 尾部。规格:
`docs/compose/spec/pdo-fast-send.md`。测量:`python scripts/check_esf_latency.py`
与 `scripts/check_latency_breakdown.py`。
- **为什么要 `/ecat/api/*` 反向代理**:EtherCAT 后端只绑 `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. 电机 OTA 的**进料口**不同:WiFi 走 `POST /motor/ota`(ESP32 存 flash,回读算
SHA-256 比对)再经 SPI 0xD1 喂;EtherCAT 走 CoE 邮箱 `0x2511/0x2510`
把明文灌进 GD32 的 48KB 暂存区,**从站在电机进 BOOT 之前**核分块 CRC32 与
整镜像 CRC32(`DCU_APP/comm/ecat_motor_ota.cpp`)。AES 握手、CAN 单发停等、
重发、`0AAC` 运行确认这套引擎两条链路完全同一个,`ota.js` 的流程也只写一遍。
刷写期间 native worker 独占总线并停 PDO 循环(各通道运动帧一起暂停,与 WiFi
模式刷写等效),结束后自动补一次 PREOP→SAFEOP→OP——从站的 SM2 过程数据看门狗
会在这十几秒里以 AL `0x001B` 把 OP 拖回 SAFEOP,不补就是"刷完假在线"。
- 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 | **6 ms** | 只是读本机后台 PDO 快照,无总线代价;总线周期另由后台循环维持(默认 4 ms)。下限取 6 ms 是因为绘制上限由显示器刷新率封顶(本机 160 Hz),再快的轮询没有消费者 |
两条链路**不能共用一个变量**:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
(`tickStuck` 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
旧数据(早期只存单个 `refreshRate`)只迁进 `wifi` —— 那个值是为无线调的,
不该套到 ECAT 上。
- **ECAT 的 6ms 语义是「不限速」而不是 166Hz**(`UNTHROTTLED_MAX_MS = 20`)。
单飞语义下上一拍完成才发下一拍,而每拍要过一次 HTTP(代理 → `ecat_native` → 回来),
实测均值 ≈1.8ms → 6ms 档约 **160 Hz**,正好贴着本机 160Hz 屏的绘制上限。
所以低于 20ms 的档位要**同时**关掉两处显示,否则界面会撒谎:
1. `tickStuck` 卡顿黄标 —— 该档贴着往返耗时,尾部抖动就会撞上"上一拍还在飞",
这是设计行为,再报"连续 N 拍未按时完成"就是误报;
2. 「≈N Hz」理论值 —— 6ms 硬算是 166Hz。这时只显示 `statusHzText`
按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s,
否则 `6ms × 2 × 10 = 120ms` 的旧数据 cutoff 会把刚到的样本全清空)。
- **STATUS 面板的 Hz 显示的是网页实际刷新率(数据到达口径),不是电机的
CAN 回报率**(`recvTs` 滑窗实测)。电机 CAN 回报率可变且可能高于链路承载
(WiFi 慢于电机时界面拿不到中间帧),这时按电机回报率显示就是谎报 ——
面板要如实反映"这份数据每秒真到几次",快链路自动跑出高值,慢链路如实
反映卡顿。按槽 seq 实测电机回报率的方案实现过、被明确否掉(2026-09-12:
用户要的恰是到达口径),seq 现在只用于下面的判活。
- **ECAT 判活阈值随实测回报周期自适应**(`staleLimitMs` = clamp(3×EWMA, 2s, 30s))。
固定 2s 的问题:电机回报周期调到秒级后,两次**正常**回报之间就超过 2s,
节点会反复判离线(LED 闪烁、波形画虚线、面板切离线覆盖层)。现在阈值随该槽
实测 seq 间隔放大到 3 倍,下限 2s 仍兜底快周期,上限 30s 防一次长停顿把
EWMA 拉飞后迟迟不判离线;突发降速收敛前最多闪一次离线(无法预知,固有延迟)。
WiFi 链路的 online 仍以固件判定为准(固件仓库另有阈值,前端不越权改写)。
- **轮询不要快过绘制上限**:1ms 档实测能跑到 **~550 Hz**(`ecat_native` 侧
写字节 ÷ 快照 1.6KB 反推),但画面最多只能显示 160 Hz —— 多出来的 390 次/s
在 renderer / 网络服务 / browser 进程 / Python 代理上各点一次,实测这条链
≈1.0 核(renderer 0.35 + 网络服务 0.26 + browser 0.27 + 代理 0.15),
降到 160 Hz 约省 0.7 核。所以下限抬到 6ms;存量 localStorage 里的 1ms
会在读取时钳到 6ms。
- **代理转发绝不能给 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(该档现已按绘制上限抬到 6ms 下限)。
- **别把 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:自旋要钉到 P 核(快核组)。** 它必然 100% 占住一个逻辑处理器,自由
调度会乱跳。本机 i7-14700K(8P/16E)—— 早期为"别抢 HTTP 的 P 核"钉到 E 核,
但 2026-09-05 实测发现超时拍主体是网卡驱动抖动、与钉哪组核无关,且 E 核单核
性能弱、解释器段更长,遂按用户要求改回钉 P 核:让节拍跑在单核性能最高的核上,
HTTP 线程溢出到组内其余核(16 个 P 线程 ≫ 并发 HTTP 数)。改向后真机 250µs 档
5×2000 拍复测 **99.3~99.6%**,与钉 E 核基线(99.4~99.7%)持平,无回退。
核号是**实测**出来的(把线程逐个钉到每个逻辑处理器跑同一段纯计算,按
**最大间隙**分快慢两组、取快的那组)—— 这台机器上 `GetLogicalProcessorInformationEx`
直接 `ERROR_BAD_LENGTH`、`GetSystemCpuSetInformation` 拿不到 EfficiencyClass、
`GetDynamicProcessorCounts` 未导出、处理器组只有一个,没有可用的 API 捷径。
用间隙法而非固定倍数(如 0.75×最慢),因为被 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` 会先停循环,停手超过
看门狗就把好板子弄出楔死了(实测踩过两次);
- 前端状态栏显示 `⚡ 自旋节拍 · 钉在 P 核 0-15`,可用
`motorDbg.state.tp.setSpin(false)` 或 localStorage `dcu_ecat_spin=0` 关掉;
环境变量 `ECAT_PIN_PCORES=0` 不钉核、`ECAT_PCORE_MASK=0xFFFF` 手工指定。
- 自测第 11~14 项分别守:自旋精度、不持 GIL、P 核分类与线程级隔离、
`api_motor_start` 的 OP 状态核实。
- **内核级周期可热调到 250µs(4kHz),随时可改**。`period_ms` 已是 float,
`/api/motor/period`(`_BUS_EXCLUSIVE_PATHS` 之外,不停循环、喂狗不断流,
理由同 `/api/motor/spin`)接受 `period_ms`(float) 或 `period_us`,运行中随时调、
可反复调,下一拍即生效;`motor/start` 也收 float,但只用于冷启动。
- 物理墙:真机一送一回 ≈165µs。下限 `MOTOR_PERIOD_MIN_MS`=62.5µs 只是**允许
请求**的表达值,比 165µs 更快的档自然自限(实测率 < 目标),不会弄坏循环;
保底 4kHz(250µs) 在预算内。亚毫秒自动就是自旋节拍(睡眠粒度睡不出 250µs;
强制 `spin:false` 时后端会明确警告)。
- 假从站 0.25ms 档实测 **3979Hz**(自测第 15 项;假从站收发是纯内存操作,
该项证明"自旋+float+热调"机制本身驱动得到 4kHz 档,不含真机总线时间)。
- **真机总线负载下的忠实代理(自测第 16 项)**:给假从站收发注入按真机分段
实测拆开的 166µs(send 48 + receive 118)且用 `_spin_until` 释放 GIL(与真机
pysoem 在 Npcap 调用期间放 GIL 一致),网格算法在 250µs 档**精确收敛到
4000Hz**。这证明真机旧算法 3680Hz(92%) 的缺口来自"每拍迟到累进周期",
网格修复消除了它 —— 是真机 4kHz 的强预测(仍非真机终验,断电后跑
`rate_probe.py --periods 0.25` 确认)。
- 节拍用**绝对网格截止点**(锚点 + n×周期),不是"本拍 t0 + 周期":后者把
每拍自旋/重拿 GIL 的迟到 ε 累进周期(250µs 档 ε≈30µs → 只有 89%);
超期时**跳过**过期网格拍而不是挪锚点(挪锚点把零头白丢)。配套给自旋态
线程挂 TIME_CRITICAL(钉核只选了核,不保证该核上谁先跑;回落 NORMAL
在不自旋时)。真机极限(wkc 全程 3/3,最终网格+跳过拍代码,2026-09-04 归因复测,
rate_probe 测速 bug 修复后的口径,见下两条):
**250µs→3977Hz(99.5%)**、**200µs→4949Hz(99%,即 ~4.9kHz)**、
150µs→5672Hz(85%,work p50≈170µs 物理自限) —— 保底 4kHz 达成:250µs 档 99.5%,
要硬 ≥4000Hz 跑 200~240µs 档实测 ~4.9kHz。旧算法(本拍 t0+周期)同档只有
3680Hz(92%),网格修复把缺口压到 0.5%。
- 为什么此前 96~97% 对周期不敏感(2026-09-04 归因结论):**观测代码自身是热循环里
最大的流程不稳定源**。`cyc_us` 分位数原先每 8 拍在 PDO 循环内重算一次,每次排序
7 段 × 400 样本 ≈400~600µs,250µs 档一次就吃掉整拍还多 —— 固定节拍触发、与周期
长短无关,所以 250µs 和 200µs 成功率几乎一样;铁证:迟到拍的相邻间隔恰好落在
mod-8 位置(194/250 对)。修复:分位数彻底移出循环,读侧 `_cyc_fresh`(0.25s TTL,
在 HTTP 请求线程现算),PDO 循环每拍只做 O(1) 的 5 点时间线 append。GC 洗清
(开机以来 gen0 仅 7 次、最大 89µs、零次落在超时窗内)。剩余 ~0.5% 空载损耗按拍内
分段拆到底了(15s 完全不碰服务、只末尾单次导出,超时拍 2.11%、真丢吞吐 Σ(k-1)=0.55%,
与 rate_probe 的 99.5% 对齐):**57% 主导段是 send_processdata**(基线 45µs,超时拍
98~190µs —— Npcap/I226 驱动发送路径抖动),**43% 是 receive 等待**(基线 100µs,
尾部到 3ms —— 从站响应 + NIC RX DPC);而 pre(拿锁+重拿 GIL)max 仅 7µs,**证明空载
时那 0.5% 根本不是 GIL 争抢**,是 pysoem 两次调用自身的驱动抖动,Python 侧已无税可削。
UI 轮询叠加时才另加 ~0.3~1%(cadence_diag 在 hammer 下把部分超时拍归因到 HTTP 时间窗,
但空载超时率 2.11% 与 hammer=1 的 2.10% 几乎相同 → 超时拍主体是驱动固有抖动,非 HTTP)。
两个测量陷阱:① `/api/motor/dbg` 本身很重(一次 dump 2048 拍元组 + json 序列化长时间
持 GIL),连续抓会自污染,归因必须"静默一段后单次导出";② skip_total/Σk 会高估损失
(一拍微超就把整个过期网格拍跳过记 k=1,实际只丢超出松弛量的部分),**真丢吞吐 = Σ(k-1)**。
归因工具:`/api/motor/dbg`(每拍时间线+网格跳过数+GC/HTTP 事件
日志,读内存不碰总线、不进独占路由)与 `tools/ecat/cadence_diag.py`(把迟到拍与
GC/HTTP 时间窗求交集做归因,stdlib-only)。
- **测速脚本自己也曾是"假负载"**(2026-09-04 二次踩坑):rate_probe 旧版
`sample()` 的分子取最后一次轮询的 cycles、分母却在循环退出后才算,退出前那次
`sleep(0.1)` 让 dt 比 cycles 窗口多 0.1s —— Hz 被系统性低估 3~5% 且与周期无关,
把真实的 99.2% 报成 96%,一度被误读成"HTTP 轮询负载吃掉 3 个点"。修复后读数与
cadence_diag 的拍间时间线(不依赖 cycles 轮询)互相印证。教训:**两条独立测量
路径对不上时,先怀疑测量本身,再怀疑系统**;带载/空载的区分要用拍间时间线做基准。
- 前端:`motorDbg.state.tp.setPdoPeriod(0.5)`(合法档 0.125–4ms,DC 与 period 同值;
默认 `setPdoPeriod(0.25)`)。取值存 localStorage `dcu_ecat_period_ms`,后端重启后由
`ensureStarted` 自动带回;状态栏 <1ms 显示 µs(`PDO 250µs`,与后端
`_fmt_period` 同口径),且每拍从快照同步周期 —— 别的标签页或脚本改的
不会显示成过期值。
- 极限测速复用脚本:`tools/ecat/rate_probe.py`(只用标准库,对正在跑的
服务加压):逐级下发周期档,报实测 Hz/达成率/cyc_us/HTTP p50/wkc,
wkc 不达预期自动停止加压,测完自动恢复测前周期。**循环没跑时会自动
open+start**,所以断电重启板子后直接敲这一条命令即可,不必先开电机页;
自动 start 失败(典型=邮箱楔死)会如实打印原因。`--no-autostart` 可关掉。
- **原生 PDO 守护进程(A:把热循环从 Python 换成 C/SOEM;现已是生产后端)**。
pysoem 本就是 SOEM(C) 的包装,热循环的 ~5kHz 天花板来自解释器/GIL,不是总线。
`tools/ecat/` 下 `ecat_native.c` + `build_native.bat`(MSVC 直编 SOEM 源码,
`soem-src/` 从 pysoem sdist 解出),产出 `build_native/ecat_native.exe`:
独占 I226、自己 bring-up 进 OP、跑网格+自旋+分段计时的 PDO 线程。**阶段 4
(2026-09-04)已完成 big-bang 切换**:native 复刻全部 24 条路由(含 SDO/EEPROM/
flash/sync_diag 与 shutdown/focus/parent-pid/端口漂移/陈旧实例清场契约),
接管 8090,Python 版已彻底删除(含备份)。
迁移全程与逐字段对拍记录见 `tools/ecat/MIGRATION.md`。
当年原型阶段的**真机实测(2026-09-05,wkc 3/3)**:work p50 从 Python 的
~176µs 降到 **~135µs**(解释器税没了),250µs 档超时拍 2.11%→**0.45%**,
天花板从 Python 的 ~5kHz 抬到 **~7kHz**(125µs 档 7050Hz、62.5µs 档 7070Hz,
平台期 = 总线一送一回 ~135µs 的物理墙,不再是 Python 墙)。**结论**:换语言
只值"5k→7k + 尾抖动干净",那 0.5% 的驱动周期维护停顿(~2.5s 一次)与语言无关、
依旧存在;要再快得出网卡栈(换 I210 / Linux PREEMPT_RT)。bring-up 踩坑:SOEM
master 的全局 `ecx_context` 初始化器漏了新增的 DCtO/DCl 两字段(MSVC 下 C1311),
已在 `soem-src` 打补丁;且状态迁移必须带 ACK 位(SSC 对带错误位且不含 ACK 的
请求直接忽略),否则卡 PREOP。
- **EtherCAT 链路全自动接入**,切过去即用,不需要先双击 `tools/ecat` 的 vbs。
四级按需自愈,每级都幂等(已连接就只复用,不重复动总线):
1. **服务没起** → `esp32_server._ecat_autostart()` 直接拉起
`tools/ecat\build_native\ecat_native.exe`(阶段 4 起不再经 venv/pythonw,
启动即绑口,实测 <1s 就绪)。两个要点不变:**必须先探测再拉起**,因为
native 独立启动时会清场(对 8090..8095 逐个优雅 shutdown,那会把用户已
开着的实例关掉、从站带回 INIT);拉起时**必须带 `--parent-pid`**,既跳过
那段清场,也让它随本服务退出而自我了断,不留孤儿占着 Npcap 适配器和端口。
2. **网卡没开** → `ensureOpen()` 调 `/api/open`,代替用户去 `ecat.html` 点"连接"。
网卡匹配子串在工具栏「链路」右边的输入框里改(默认 `I210`,
存 `localStorage` 的 `dcu_ecat_adapter`;与 `device-link.js` /
`esp32_server.py` autostart / 页面输入框一致)。
3. **没进 OP** → `/api/motor/start` 内部自己走 PREOP→SAFEOP→OP(会自动重试一次,
最坏约 24s,所以前端超时给 30s、代理也给 30s)。
4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。
断线后每 2s 自动重来一遍,中途拔网线再插会自己恢复。
- **关掉 app 必停从站**(上面"自动接入"的反向操作,`esp32_server.py` 的
`_app_lease_*` 一节):每个 app 页面与页面服务器保持一条 WebSocket
(`js/common/app-lease.js` → `GET /ws/app-lease`),**最后一条**租约连接断开
(所有 app 窗口关闭)后,服务端调后端 `/api/op/stop` 停 PDO 循环并把从站退回
INIT。为什么必须有它:循环在喂从站 1000ms 过程数据看门狗,没人停它就永远
卡在 OP、自旋节拍白占一个核;而 native 的 `--parent-pid` 看门狗只覆盖
"本服务拉起的实例",浏览器模式关窗后服务还活着、复用现成实例时 parent
又是别人,两种常见离开方式都够不着。要点:
- 断开感知靠 TCP FIN / Close 帧回显,**不是心跳计时器** —— 后台标签页的
定时器节流不会把"挂着后台的 app"误判成已关闭(服务端必须回 Close 回显,
否则浏览器拆不掉 TCP,Edge 实测踩过);
- 断连后隔 3s GRACE 才停:F5 刷新只断几百毫秒,不该被当成关窗;
- 外壳内切标签不停(`tabs.html` 自己持有租约),与 `transport-ecat.teardown`
"切到 WiFi 看看不该打断 EtherCAT" 的既有设计一致;
- 只停"本服务经代理确认跑过"的循环(`g_ecat_motor_live` 代理记账),
直接从 ecat 控制台(:8090)起的循环不动;
- 服务进程自己的退出路径(`/api/shutdown`、Ctrl+C)同步补停一次 ——
租约定时器随进程死亡,等不到 GRACE 到点。
代价:每次重开 app 都要重走 PREOP→SAFEOP→OP,有概率撞 SSC 邮箱楔死
(见下一段)—— 这是"关闭必须停"这个要求本身的代价,不是机制缺陷。
自测:`python scripts/test_app_lease.py`(服务端逻辑,裸 TCP 全路径)+
`node scripts/test_app_lease_e2e.mjs`(真 Edge headless + 真服务器,
假后端钉在 8091,绝不碰真总线)。
- 接入失败时**必须能看到原因**:横幅与 `#devStatus` 显示归类后的可执行提示
(`summarizeLinkError`),完整诊断放在两者的 `title` 里。
典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成
"需复位 DCU 板 MCU 才能恢复" —— 这个故障软件无法自愈
(成因与规避的完整记录在 `tools/ecat/MIGRATION.md` 阶段 1/4 与
`tools/ecat/ecat_native.c` 的 `kill_stale_instances()` 注释)。
- 自测:
- `node scripts/test_motor_transport.mjs --port <页面服务端口>` —— 传输契约
(真实 import 生产代码打真实代理;EtherCAT 实机段在板子不可用时会自动跳过,
因此项数会随板子状态在 33/36~43 之间浮动,`失败 0` 才是判据)。
- `python tools/ecat/native_enc_diff.py` —— CAN 编码回归(native enctest 路由
输出 vs `native_enc_golden.json` 金标准,4 组用例逐字节,byte0=seq 状态字节
除外 + seq 递增校验;不碰网卡,native 在跑即可)。金标准是阶段 2 与 Python
`_fill_can_out` 逐字节对拍通过后固化的;改 `fill_can_out`/`pack_tx_batch` 后必跑。
原 16 项假从站后端自测(含窗口自适应 SparseSlave 专项)已随 Python 后端删除,
其覆盖结论记录在 `tools/ecat/MIGRATION.md`。
- `G:\Robot_v2\DCU\tools\ecat\check_guard_regression.bat` —— **tx 冻结守卫**回归门槛。
现场症状:点一次「发送 CMD」后 GD32 不消费输出 PDO,health 全绿而
`bridge.tx_count/ack` 冻死,看着像网页慢,实为 EtherCAT 楔死。默认三段
(纯逻辑 38 项 / 源码契约 / 构建指纹)不碰网卡、十几秒出结论;`--live` 加稳定性
套件与 90s 空闲静默;`--live --wedge` 加真机触发(要按一次 GD32 RESET,电机停一下)。
SKIP 不折算 PASS:有段没跑就退 2。改 `wedge_worker`/`guard_tx.h`/发送路由后必跑;
30 秒自检与判据契约见 `tools/ecat/MIGRATION.md` 的「加固 2026-09-21」段。
- `node scripts/test_ecat_autostart.mjs` —— 自动接入链条(stub 后端,
不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。
- `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(55 项,
纯逻辑、不依赖板子/浏览器:默认值、**按链路下限**、旧单值迁移、
存量 1ms 钳到 6ms、两链路互不污染、不限速谓词边界)。
- 浏览器实测(需 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_floor.mjs [port]` 验 ECAT 下限档 6ms(26 项:
`min`/`step` 同步、通过浏览器原生校验、实测 ≈160 Hz、无卡顿黄标、
面板 Hz 不谎报、硬填旧的 1ms 被抬到 6ms、切 WiFi 时 6ms 被钳到 50)。
`node scripts/browser_check_period.mjs [port]` 验内核级周期热调(12 项:
`setPdoPeriod(0.25)` 走 `/motor/period` 不碰 `/motor/start`、状态栏显示
`PDO 250µs`(亚毫秒用 µs)、自动自旋标注、localStorage 持久化、页面重载后
`ensureStarted` 自动带回、`setPdoPeriod(4)` 可热改到 4ms 档)。
- 监控 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 主站工具集(同类结构的姊妹工具)。