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/ 构建辅助脚本
esp32_server.py 本地页面服务 + 桌面窗口外壳
esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
页面
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 接收看板,完全自包含,删文件即可移除) | 内联 |
目录约定
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_native.exe(C/SOEM 守护进程,经本服务 /ecat/api/* 代理) |
POST /api/motor/state 读后台 PDO 快照 |
POST /api/motor/send(多帧同一 seq 沿) |
-
接口定义见
js/motor/transport.js头部;两个实现分别在transport-wifi.js/transport-ecat.js。 业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控 ——互斥/退避/冷却/latest-wins)两条链路完全共用,新增链路只实现接口即可。 -
为什么要
/ecat/api/*反向代理: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头注释里写明):- 每通道 TEC/REC/err 过程数据里没有 → 一律回
null让界面显示--, 不编 0 冒充正常(区分"通信正常但无此数据"与"真的是 0"); - CONFIG(
0x000N0101) / 版本(0x000N00FF) / 节点 ID 应答不在状态窗口里, 由传输层从批量接收区累积后回填,上层无感; - 没有固件上传 + GD32 relay 通路 →
supportsOta=false,OTA 页签自动置灰。
- 每通道 TEC/REC/err 过程数据里没有 → 一律回
-
EtherCAT 的 PDO 周期由后台循环独立维持(从站 SM2 有 1000ms 过程数据看门狗, 停超 1 秒会 OP→SAFEOP 掉线报 AL
0x001B),与界面刷新率无关; 故切走链路时不停后台循环。 -
刷新速度按链路各记一份(
state.refreshByTp,纯逻辑在js/motor/refresh-rate.js), 切链路时自动换成该链路自己的值,界面输入框(含min/step)同步回填、节拍立即重排:链路 默认 下限 为什么 wifi250 ms 50 ms 每拍是一次无线 HTTP 往返,还要与 GD32 0xC5主动推送周期对齐;再快只会让请求排队积压ecat50 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 的档位要同时关掉两处显示,否则界面会撒谎:tickStuck卡顿黄标 —— 该档贴着往返耗时,尾部抖动就会撞上"上一拍还在飞", 这是设计行为,再报"连续 N 拍未按时完成"就是误报;- 「≈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 号电机 就把覆盖集合收缩到那一个窗口,后面的槽再也扫不到(自测第 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)或 localStoragedcu_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 归因结论):观测代码自身是热循环里 最大的流程不稳定源。600µs,250µs 档一次就吃掉整拍还多 —— 固定节拍触发、与周期 长短无关,所以 250µs 和 200µs 成功率几乎一样;铁证:迟到拍的相邻间隔恰好落在 mod-8 位置(194/250 对)。修复:分位数彻底移出循环,读侧cyc_us分位数原先每 8 拍在 PDO 循环内重算一次,每次排序 7 段 × 400 样本 ≈400_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,超时拍 98190µs —— Npcap/I226 驱动发送路径抖动),43% 是 receive 等待(基线 100µs, 尾部到 3ms —— 从站响应 + NIC RX DPC);而 pre(拿锁+重拿 GIL)max 仅 7µs,证明空载 时那 0.5% 根本不是 GIL 争抢,是 pysoem 两次调用自身的驱动抖动,Python 侧已无税可削。 UI 轮询叠加时才另加0.31%(cadence_diag 在 hammer 下把部分超时拍归因到 HTTP 时间窗, 但空载超时率 2.11% 与 hammer=1 的 2.10% 几乎相同 → 超时拍主体是驱动固有抖动,非 HTTP)。 两个测量陷阱:①/api/motor/dbg本身很重(一次 dump 2048 拍元组 + json 序列化长时间 持 GIL),连续抓会自污染,归因必须"静默一段后单次导出";② skip_total/Σk 会高估损失 (一拍微超就把整个过期网格拍跳过记 k=1,实际只丢超出松弛量的部分),真丢吞吐 = Σ(k-1)。 归因工具:/api/motor/dbg(每拍时间线+网格跳过数+GC/HTTP 事件 日志,读内存不碰总线、不进独占路由)与tools/ecat/cadence_diag.py(把迟到拍与 GC/HTTP 时间窗求交集做归因,stdlib-only)。 - 测速脚本自己也曾是"假负载"(2026-09-04 二次踩坑):rate_probe 旧版
sample()的分子取最后一次轮询的 cycles、分母却在循环退出后才算,退出前那次sleep(0.1)让 dt 比 cycles 窗口多 0.1s —— Hz 被系统性低估 3~5% 且与周期无关, 把真实的 99.2% 报成 96%,一度被误读成"HTTP 轮询负载吃掉 3 个点"。修复后读数与 cadence_diag 的拍间时间线(不依赖 cycles 轮询)互相印证。教训:两条独立测量 路径对不上时,先怀疑测量本身,再怀疑系统;带载/空载的区分要用拍间时间线做基准。 - 前端:
motorDbg.state.tp.setPdoPeriod(0.25)(恢复默认setPdoPeriod(4))。 取值存 localStoragedcu_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。
- 物理墙:真机一送一回 ≈165µs。下限
-
EtherCAT 链路全自动接入,切过去即用,不需要先双击
tools/ecat的 vbs。 四级按需自愈,每级都幂等(已连接就只复用,不重复动总线):- 服务没起 →
esp32_server._ecat_autostart()直接拉起tools/ecat\build_native\ecat_native.exe(阶段 4 起不再经 venv/pythonw, 启动即绑口,实测 <1s 就绪)。两个要点不变:必须先探测再拉起,因为 native 独立启动时会清场(对 8090..8095 逐个优雅 shutdown,那会把用户已 开着的实例关掉、从站带回 INIT);拉起时必须带--parent-pid,既跳过 那段清场,也让它随本服务退出而自我了断,不留孤儿占着 Npcap 适配器和端口。 - 网卡没开 →
ensureOpen()调/api/open,代替用户去ecat.html点"连接"。 网卡匹配子串在工具栏「链路」右边的输入框里改(默认I226, 存localStorage的dcu_ecat_adapter)。 - 没进 OP →
/api/motor/start内部自己走 PREOP→SAFEOP→OP(会自动重试一次, 最坏约 24s,所以前端超时给 30s、代理也给 30s)。 - 循环没跑 → 同一个
/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 路由 输出 vsnative_enc_golden.json金标准,4 组用例逐字节,byte0=seq 状态字节 除外 + seq 递增校验;不碰网卡,native 在跑即可)。金标准是阶段 2 与 Python_fill_can_out逐字节对拍通过后固化的;改fill_can_out/pack_tx_batch后必跑。 原 16 项假从站后端自测(含窗口自适应 SparseSlave 专项)已随 Python 后端删除, 其覆盖结论记录在tools/ecat/MIGRATION.md。node scripts/test_ecat_autostart.mjs—— 自动接入链条(stub 后端, 不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。node scripts/test_motor_refresh.mjs—— 刷新速度每链路一份(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自动带回、换回 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 主站工具集(同类结构的姊妹工具)。