Files
DCU_DesktopController/README.md
T

260 lines
18 KiB
Markdown
Raw Normal View History

# 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 主站工具集(同类结构的姊妹工具)。