- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
484 lines
40 KiB
Markdown
484 lines
40 KiB
Markdown
# 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 主站工具集(同类结构的姊妹工具)。
|