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

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 主站工具集(同类结构的姊妹工具)。
S
Description
No description provided
Readme
27 MiB
Languages
JavaScript 58.3%
HTML 18.1%
Python 13.2%
CSS 10.2%