feat!: Python 后端整体切换为原生 C/SOEM 守护进程(ecat_native)
big-bang 迁移(硬约束: Npcap 网卡独占, 无法双主站并存), 四阶段真机验收的 完整契约与对拍记录见 MIGRATION.md —— Python 已删, 该文件是唯一行为基准。 - 新增 ecat_native.c: 与 Python 同构的全路由 native 守护 —— 总线生命周期 open/close/status/op.start/op.stop, 电机 motor.start/stop/spin/state/ send/rx/period/dbg, can/send|state, 配置维护 sdo/eeprom.read|write| verify/flash/sync_diag, 静态托管 src/web, shutdown/focus/--parent-pid 看门狗/独立启动清场/端口漂移; 独占 8090 即生产链路。PDO 热循环天花板 ~5kHz → ~7kHz。 - 删除 src/ecat_server.py(2826 行)、src/test_motor_backend.py(684 行)、 ecat_app.vbs(桌面窗口随 pywebview 依赖一并移除): Python 后端不留副本。 - 编码回归改金标准: native_enc_diff.py 对照 native_enc_golden.json (阶段 2 与 Python _fill_can_out 逐字节对拍通过后固化, 不碰网卡), 当场抓掉 json_tok 不跳闭合引号会静默丢 motor/send 参数的真 bug。 - build_native.bat: 编译前跑 gen_eeprom.py 生成 eeprom_image.h 供 native #include —— 同一生成器同一套断言, 断言失败构建中止; 改 SII 重跑即可。 - 修 flash 探针字历史隐患(本文件与 ecat_native.c 同步改): SCRATCH_WORD 0x0200 实际落在 TxPDO 类别区内部(类别区已长到 0x0281), 单独跑 eeprom 探针不烧录会把真数据擦成空洞; 移到 SII END 之后的 0x0400。 - 启动器 ecat_web.vbs/bat 改拉 build_native\ecat_native.exe (--substr I226 --web src\web)。 - 收录 soem-src wpcap 链接库(vendored, 构建必需)。 两轮回归修复(细节见 MIGRATION.md): 1) op/start 卡 AL 0x001B(SM 看门狗): 固件"自己写 1s 看门狗"的假设破产, do_op_start 第 5 步改为 ~5ms 节奏喂帧 ≥1s 再请求 OP, 24s 两次失败 变 1.2s 进 OP。 2) OP 态数据冻结(从站 PDI_Isr SM2 分支被旗标挡住, 固件侧根因): 主站加 楔死看门狗(输出窗口轮转 + 回显冻结 128 周期判死)与 /api/motor/state 自动恢复(10s 冷却, 快照新增 wr 字段)。 已知差异: SDO 失败文本无 pysoem abort code 粒度; 250µs 亚毫秒周期从站 吃不下, 修复前勿用。build_native/ 构建产物入 .gitignore, 由脚本重建。
This commit is contained in:
@@ -20,6 +20,9 @@ sii.json
|
||||
*.sii
|
||||
eeprom_*.bin
|
||||
|
||||
# native 构建产物(.obj/.exe/eeprom_image.h/exports.txt 等), 由 build_native.bat 重建
|
||||
build_native/
|
||||
|
||||
# 一次性日志分析脚本(硬编码临时路径, 无留存价值)
|
||||
_analyze_log.py
|
||||
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# ecat_server.py → ecat_native(C/SOEM) 迁移计划
|
||||
|
||||
## 背景与硬约束
|
||||
- **动机**:原生 C/SOEM 把 PDO 热循环天花板从 Python 的 ~5kHz 抬到 ~7kHz(见 README 原型 A)。
|
||||
但控制面路由(电机命令/CAN/SDO/eeprom/前端)跑在 UI 速率,迁到 C **没有性能收益**,
|
||||
迁移动机是"单一原生进程、去掉 Python 运行时依赖",不是快。
|
||||
- **硬约束:网卡独占**。Npcap 上两个 EtherCAT 主站抢一条总线会互相打架。所以**不能**
|
||||
"native 管 3 条路由、Python 管其余"长期并存 —— 迁移是 **big-bang 切换**:native 做到
|
||||
功能齐平 → 整体接管 8090 → 才删 Python。中途 Python 始终是生产链路,native 用
|
||||
`ECAT_NATIVE_URL` 转发或独立端口(8091)验证,二者不同时持网卡。
|
||||
- **验证纪律**:每个阶段 native 侧真机验证通过(wkc 达标、行为与 Python 一致)才推进下一阶段;
|
||||
全部阶段完成 + 回归对齐后,才删除 ecat_server.py 及其 web 资产。
|
||||
|
||||
## 现状(2026-09-04:迁移完成)
|
||||
- native(阶段 1~4 全部完成):生命周期 `adapters/open/status/close/op.start/op.stop` +
|
||||
电机控制 `motor.start/stop/spin/state/send/rx/period/dbg` + `can/send` + `can/state`
|
||||
+ 配置维护 `sdo`/`eeprom.read|write|verify`/`flash`/`sync_diag`
|
||||
+ 进程生命周期 `shutdown`/`focus`/`--parent-pid`/清场/端口漂移
|
||||
+ 静态托管(motor.html/ecat.html/css/js) + 健康端点 + `native/enctest` 编码对照。
|
||||
前端调用的 24 条路由全覆盖,native 独占 8090 即生产链路。
|
||||
- Python:已彻底删除(2026-09-04 收官,含备份 zip)。`src/` 只剩 CLI 工具、
|
||||
前端资产与诊断脚本;编码回归改打金标准(见阶段 4)。
|
||||
|
||||
## 分阶段(按依赖排序)
|
||||
### 阶段 1:总线生命周期 + 状态机拆分 + 前端托管(✅ 已完成,2026-09-05 真机验证)
|
||||
把 native 启动的"一把梭"拆成与 Python 同构的可控状态机,并加只读生命周期路由 + 静态托管:
|
||||
- 路由:`/api/adapters`、`/api/open`(ec_init+config_init,停 PREOP)、`/api/status`、
|
||||
`/api/close`、`/api/op/start`(ACK 序列进 OP)、`/api/op/stop`、`/api/motor/start`(起循环)、
|
||||
`/api/motor/stop`(停循环)、`/api/motor/spin`。
|
||||
- 静态:GET `/`、`/motor.html`、`/ecat.html`、`/ecat.css`、`/motor.js`(从 src/web/ 读盘)。
|
||||
- 状态位:g_bus_open / g_op / g_loop_running,迁移到与 Python 一致;`--auto` 可选启动即拉起
|
||||
(给 cadence/rate 压测用),默认关、纯路由驱动。控制路由改总线前先 `loop_stop()`
|
||||
(对应 Python 的 `_BUS_EXCLUSIVE_PATHS` 停循环),避免与 PDO 线程并发操作同一 ecx_port。
|
||||
- **验收结果**:open→op/start→motor/start 经前端实际用的同套路由驱动到 `bus_open/op/loop`
|
||||
全 1、wkc 3/3;cadence_diag 端到端 3990Hz(99.8%)、work p50 139µs、路由设周期生效;
|
||||
motor/stop→loop:0、op/stop→退回 INIT 均正确;GET / 返回 ecat.html(26KB)。
|
||||
- **踩坑**:Npcap 设备名 `\Device\NPF_...` 含反斜杠,原样塞进 JSON 报 Invalid \escape →
|
||||
加 `jesc()` 转义所有嵌入变数字符串(adapters desc/name、从站名、desc)。
|
||||
|
||||
### 阶段 2:PDO 循环功能核心(✅ 已完成,2026-09-05 真机验证)
|
||||
把 Python `_pdo_worker` 的功能搬进 native 循环:命令队列泵入(首帧段1 + 批量区段2 共用
|
||||
seq 沿)、槽窗口自适应(cover_bases + 每 64 周期发现窗 + 首圈全扫)、rx 批量回收、
|
||||
完整快照。**验证分两层**:
|
||||
- **编码逐字节对照**(`native_enc_diff.py`,不碰网卡):4 组用例(DLC32 单帧 / 20×DLC8
|
||||
装满+第21帧越界 / dlc=0 只发批量 / 混合 DLC+win 保持)的 384B 输出与 Python
|
||||
`_fill_can_out` **逐字节一致**,seq 同步。当场抓出并修掉 json_tok 不跳键闭合引号
|
||||
的真 bug(该 bug 会让 motor/send 的 id/data 静默全丢 —— 没有这层对照就会带上线)。
|
||||
- **真机闭环**:open→op/start→motor/start(4ms) 后,电机快照与 Python 基准逐字段一致
|
||||
(slot2/node1、slot5/node2、ch2、online、seq 连续递增);探针帧发往**空载通道 ch0**
|
||||
(电机在 ch2,不碰真实电机总线)→ `sent_seq:1`→队列清空→固件 **ack=1 回显**、
|
||||
**tx_count=1**(帧真的发上了 CAN 总线)、tx_result=OK;motor/rx 取回 5 帧 remain 1630
|
||||
(ch2 电机流量经批量区真实回流)。自适应窗口 `[2]` 与 Python 一致(修了一个移植
|
||||
bug:首圈全扫结束分支读陈旧 g_bases,改为当场重算 cover_bases,对齐 Python 1673 行)。
|
||||
- 与 Python 的两处有意差异(阶段 4 对齐):① motor/send/can/send 在循环未启动时报
|
||||
"先 motor/start"(Python 走同步下发 fallback);② can/state 纯读快照不现场 pump。
|
||||
- 本轮未动真实电机命令:任何能驱动电机的帧应由用户在 UI 知情后下发。
|
||||
|
||||
### 阶段 3:配置/维护面(✅ 已完成,2026-09-04 真机验证)
|
||||
`/api/sdo`、`/api/eeprom/read|write|verify`、`/api/flash`、`/api/sync_diag` —— 全部走
|
||||
独占总线(进 `g_bus_lock` 前先 `loop_stop()`,镜像 Python `_BUS_EXCLUSIVE_PATHS`:
|
||||
邮箱/EEPROM 与周期数据并发是历史上邮箱楔死 + SOEM 分段读崩溃的诱因)。
|
||||
- **默认烧录镜像**:`build_native.bat` 在编译前跑 `gen_eeprom.py > build_native/eeprom_image.h`,
|
||||
ecat_native.c `#include` 它。等价于 Python 的 `from ecat_flash_eeprom import load_image`:
|
||||
同一份生成器、同一套自校验断言(断言失败 → 非零退出 → 构建中止),改 SII 只需重跑脚本。
|
||||
生成的数组与 `gen_eeprom.eeprom` **逐字节一致**(离线校验)。
|
||||
- **真机对拍**(Python 停机 → native 独占网卡 → 逐项比对 → 恢复 Python):
|
||||
- SDO 读 `0x2000:1..4`:与 Python 基准同形同值(sync0/sync1=0 free-run、escirq/uptime 递增)。
|
||||
- SDO 写只读对象:Python 回 SdoError abort 文本,native 回 `SdoError wkc=0` —— 判定
|
||||
(ok=false) 与 JSON 形状一致,仅异常文本粒度不同(pysoem 的 abort code 拿不到,已记差异)。
|
||||
- EEPROM 读整片 2048 字:`raw`/`rows` 与 Python **逐字节一致**(4096B 全等)。
|
||||
- `eeprom/verify` 同值数据 → `diffs:0` + 单行 "verify: OK, 全部一致";注入 1 字差异 →
|
||||
`diffs:1` + 差异行 + 汇总行,与 Python 语义(含 `<=10` 只打前 11 行)一致。
|
||||
- `eeprom/write` 同值写回 641 非-FF 字 → `written:641 failed:0`(每字写后立即读回校验)。
|
||||
- `flash` 默认镜像 → 642 字全成功;回读整片与构建期镜像 **零差异**。
|
||||
- **抓到并修掉的 2 个真 bug**:
|
||||
① `logs_add` 不写 NUL 终止符:日志缓冲跨请求复用,`%s` 会把上一次请求的残留一起吐出
|
||||
(实测 verify 返回 "OK, 全部一致" 后面粘着上一轮的 449 行差异,且是非法 JSON)。
|
||||
② flash 失败分支 `"log":[...]"written":n}` 缺逗号 —— 非法 JSON。按 Python 语义改为
|
||||
失败时不带 `written` 键。
|
||||
- **顺带修掉一个跨实现的历史隐患(Python/native/独立脚本三处同改)**:flash 的字节序探针字
|
||||
用的是 `0x0200`,注释说"在 0xFF 填充区深处、远超类别区",**但 SII 类别区早已长到
|
||||
word 0x0281**(STRINGS/General/FMMU/SyncM/RxPDO 196字/TxPDO 324字,END 在 0x0282),
|
||||
0x0200 落在 **TxPDO 类别内部**:探针后"恢复"成 0xFFFF 就把那一字真数据擦成空洞。
|
||||
实测本机芯片 word 0x0200=0xFFFF 而镜像是 0x401B —— 正是历次探针留下的,只因烧录紧随
|
||||
其后重写全片才一直没暴露(单独跑 eeprom 探针不烧录就会留下坏 SII)。改用 END 之后的
|
||||
`0x0400`;复跑 flash 后芯片与镜像零差异、探针字干净回到 0xFFFF。
|
||||
- **本轮未动真实电机命令**(同阶段 2 纪律)。恢复生产:Python 4ms、wkc 3/3、2 台电机
|
||||
在线 seq 递增、win_bases `[2]`;`test_motor_backend.py` 16/16 全绿。
|
||||
|
||||
### 阶段 4:杂项 + 切换(✅ 已完成,2026-09-04 真机验证,Python 已归档)
|
||||
- **can/send 重写为 Python 同款**(此前误并入 motor/send 队列路径 —— 那是真差异,
|
||||
ecat.html CAN 调试页依赖同步 ack 回显):独占停循环 → 未 OP 自动 bring-up(吃
|
||||
`dc_cycle_us`)→ 单帧 DLC≤32 + `clear_stats` → pump 3 拍 → `acked` 判定 + 警告文案
|
||||
→ 桥字段**拍平**进响应(renderCanState 直接读顶层)。`can/state` 同款拍平,
|
||||
循环活着给快照、没跑 pump 1 拍。`motor/send` 补同步 fallback:循环停时批量 pump
|
||||
(3 帧 DLC1 一周期发完,`sent_sync:3 pdo_cycles:1` 实测)。
|
||||
- **do_op_start 全量化**(镜像 `_require_op_ready`):入口无条件预清理(回 INIT +
|
||||
config_init 重枚举,防楔死邮箱上 config_map 原生崩溃)、free-run 清残留 DC 激活
|
||||
(0x0981 跨重连不消失,残留会让 SAFEOP→OP 永久等 PLL)、可选 DC(config_dc +
|
||||
dcsync0)、多行 log 数组、op/start 失败自动重试一次。`motor/start` 补"现读核实 OP"
|
||||
(看门狗踢出后自动重进,实测触发)。
|
||||
- **生命周期契约**:`/api/shutdown`(回响应→优雅退:停循环→INIT→关适配器)、
|
||||
`/api/focus`(探活应答)、`--parent-pid` 看门狗(父进程消失→同款优雅退)、
|
||||
独立启动清场(8090..8095 逐个优雅 shutdown + 按进程名强杀兜底,不碰 python*)、
|
||||
端口漂移 + `PORT=` 行、默认端口 8090。
|
||||
- **切换**:`esp32_server._ecat_autostart()` 改拉 `build_native\ecat_native.exe`
|
||||
(带 `--parent-pid`,cwd=tools/ecat);`ecat_web.vbs/bat` 改起 native。
|
||||
Python 服务代码先归档 `legacy_py/`、切换验证通过后**已删除**(2026-09-04,
|
||||
曾留备份 zip,同日确认后**连备份一并彻底删除**:`ecat_server.py` 行为基准、
|
||||
`test_motor_backend.py` 假从站回归套件、`ecat_app.vbs` 桌面窗口模式均已不存在,
|
||||
本文件的逐字段对拍记录是唯一留存的契约文档)。
|
||||
编码面回归改打金标准:`native_enc_diff.py` 对照 `native_enc_golden.json`
|
||||
(阶段 2 与 Python 逐字节对拍通过后固化,byte0=seq 状态字节除外 + seq 递增校验)。
|
||||
`ecat_tool.py` CLI 仍用 `src/ecat_flash_eeprom.py`(活依赖,已搬回 src)。
|
||||
**ECAT_NATIVE_URL 转发层随切换作废**(native 就是后端,不再需要代理)。
|
||||
- **真机验收**(全走 UI 同款链路):停 Python → native 独立起(清场路径顺带验证:
|
||||
新实例优雅替换旧实例)→ open/op.start/motor.start(4ms)/send/rx/sdo/eeprom/period
|
||||
全通 → 8080 重启新码 → 代理透传 motor/state ✓ → autostart 拉起(`--parent-pid`
|
||||
正确)✓ → 杀 8080 看 native 自我了断(端口释放、从站回 INIT)✓ → 重启 8080
|
||||
自动拉起 → 生产态 4ms/wkc 3/3/2 电机/win_bases[2] 恢复 ✓。
|
||||
- **切换中抓到并修掉 2 个真 bug**:① can/send 路由里 `int cs`(clear_stats)遮蔽了
|
||||
`handle_conn(SOCKET cs)` 的 socket —— 响应发到句柄 0,总线侧成功、HTTP 侧空响应;
|
||||
② 端口漂移循环把 `port++` 写在 for 增量段,绑定成功那轮也自增 —— 日志报出一个
|
||||
没在听的端口(实听 8090、日志 8091)。
|
||||
- **保留的已知差异**(都已在代码注释记录,影响面为零或已验证等价):
|
||||
SDO 失败文本无 pysoem 的 abort code 粒度;~~主站侧不直写过程数据看门狗
|
||||
(从站固件 ≥1.5.45 自己写 1s,Python 那次直写是"双保险")~~ **此假设已破产,
|
||||
见下方"回归 2026-09-05"**;spin 路由无副作用(native 恒自旋)。
|
||||
|
||||
## 回归 2026-09-05:native 无法进 OP(AL 0x001B),已修复
|
||||
- **症状**:`/api/op/start` 卡 SAFEOP→OP,重试一次仍失败,`AL=0x04 code=0x001b`
|
||||
(Sync manager watchdog);`/api/esc/read` 手发 LRW 得 `wkc=1`(期望 3,输出写
|
||||
不匹配、输入读匹配 —— SM2 已被从站禁用)。
|
||||
- **排除面**(全部实测干净):SM/FMMU/DC/看门狗寄存器与 SII、固件 CoE
|
||||
(0x1C12→0x1600 48×8B=384、0x1C13→0x1A00 80×8B=640)逐项一致;PREOP 下 SDO
|
||||
正常(邮箱未楔死);free-run 同样失败(与 DC 无关);**pysoem CLI 同总线同从站
|
||||
成功进 OP + 1237 帧 PDO 正常** —— 从站健康,问题在主站侧。
|
||||
- **根因**:SSC 的 SAFEOP→OP 转换挂起等待 `bEcatFirstOutputsReceived`,而
|
||||
`CheckIfEcatError` 在过程数据看门狗触发位过期时**每 1ms 清一次该标志**。从站
|
||||
看门狗实测是复位默认 **100ms**(divider 0x400=2498→100µs × time 0x420=1000)——
|
||||
固件 1.5.45 的"启动写 1s"(`EscLan9252::configPdWatchdog`)这次上电没生效
|
||||
(需串口看 WARN 行确认是写回失败还是 ESC 事后被复位)。native `do_op_start`
|
||||
第 5 步的喂帧节奏是 **~200ms/帧 > 100ms WD** → 标志永远立不住 → ~9s 超时写
|
||||
0x001B 并 StopOutputHandler。Python 时代不炸是因为"直写 0x420=1s 双保险",
|
||||
迁移时按"固件自己会写 1s"删掉了 —— 这就是回归点。
|
||||
- **修复**(不依赖从站 WD 长度):`do_op_start` 第 5 步改为先以 ~5ms 节奏喂
|
||||
≥1s(250 帧),再请求 OP 并继续 5ms 喂帧轮询(最多 ~3s,每 ~200ms 补写一次
|
||||
AL Control 兜底)。op/start 从"24s 两次失败"变 **1.2s 进 OP**。
|
||||
- **验证**:open→op/start(DC 4ms)→motor/start(4ms) 全链路:wkc 3/3、双电机在线
|
||||
(slot2/N1、slot5/N2)、seq 递增、win_bases=[2],与 Python 生产基准一致。
|
||||
## 回归 2026-09-05(下午):OP 态数据冻结("切到 ECAT 一瞬间有数据然后没了")
|
||||
- **症状**:进 OP 成功、wkc 3/3、网卡 ~33Mbps 流量正常,但从站输入区停更
|
||||
(slot seq / 桥 rx_count / global_seq 全冻结),ack 不回显;UI 2s 后全节点判离线。
|
||||
换固件 1.5.56→1.5.58 无效;用户实测 1.5.56 + 旧 Python 主站完全正常。
|
||||
- **排除面**(全部实测):`native_enc_diff.py` 金标准 4 用例逐字节通过(输出编码与
|
||||
Python 一致);主站输入解析偏移与固件 `ethercat_can_bridge.h` 逐字节对齐
|
||||
(MOTW 32/33/34/35/36/40、槽 40B、XRX 320..、RXB 364..);PREOP 下 SDO 正常。
|
||||
⇒ 主站编码/解析/链路全干净,wkc=3 证明 FMMU 层写匹配正常。
|
||||
- **根因(固件侧,其源码自述)**:`dc_app.c` `EcatDbg_GuardAleventMask` 的
|
||||
1.5.48/1.5.49 注释原文——"停止回 INIT 后重进 OP **概率性**失败:主站 LRW
|
||||
WKC=3/3 但 bEcatFirstOutputsReceived 永不置位……evt=0x0400 在 0x220 挂起 10s
|
||||
无人处理(PDI_Isr 的 SM2 分支从未执行),escirq 2.6/s 证明 EXTI 只在 AL Control
|
||||
事件时跳动"。实测冻结态 escirq 只随邮箱事件 +3~5/s(4ms LRW 应 ~250/s)、
|
||||
can/send 探针 ack=0,与该记录完全吻合。**楔死点 = PDI_Isr SM2 分支被内部旗标
|
||||
(bEscIntEnabled 门控)挡住 → ECAT_Application→EcatPdo_Process→buildInput 停 →
|
||||
输入停更 + 输出不消费**;固件自愈"仅 SAFEOP 触发"(注释原文),OP 态无人管。
|
||||
触发条件正是"停止回 INIT 后重进 OP"——native 主站每次 motor/start 异常恢复、
|
||||
每次传输切换都走这条路,比 Python 时代更频繁,故表现为"上位机更新后坏"。
|
||||
- **主站侧防护(本轮新增,已真机验证)**:
|
||||
- `pdo_worker` 楔死看门狗:主站每周期轮转输出窗口字节 WIN(自适应窗+每 64 周期
|
||||
发现窗),健康从站输入段 2 的 win_base 回显必然跟着变;回显冻结超
|
||||
128 周期+2s(单基窗时发现窗可能与自适应窗同值,最久 128 周期)即楔死。
|
||||
与电机流量无关,零误报(30s 稳态实测无误报)。
|
||||
- `/api/motor/state` 自动恢复:检测后 0.5s,TryEnter g_bus_lock(防与互斥路由
|
||||
互等死锁)→ 完整重拉 INIT→PREOP→SAFEOP→OP,10s 冷却。快照新增 `wr` 字段
|
||||
(自动恢复次数);WEDGE 现场打 console(含 win_echo/ack/gseq/rx/wkc/period,
|
||||
供与固件串口日志对时)。
|
||||
- **遗留(固件侧,根因)**:① 串口抓一次复现,看 [ECAT][AEM] WARN 行
|
||||
(固件自述"一行日志即可定位楔死路径");② 把 1.5.49 自愈扩展到 OP 态
|
||||
(判据:LRW 在跑而 ack/win 回显不动——主站看门狗的判据可直接搬);③ 排查
|
||||
bEscIntEnabled/bDcSyncActive 旗标卡值;④ 怀疑与 DC 模式相关
|
||||
(`ethercat_can_bridge.cpp` seqlock 注释:DC 下 buildInput 跑在 Sync0_Isr——
|
||||
本轮实测冻结多发生在 DC 会话)。另:250µs 亚毫秒周期从站吃不下
|
||||
(槽刷新率掉 99%,见上节),修复前别用。
|
||||
|
||||
## 迁移结论
|
||||
Python→native 的价值不在控制面(UI 速率下两者无感),在于 PDO 热循环天花板
|
||||
~5kHz→~7kHz 与尾抖动干净;那 0.5% 的 I226 驱动周期维护停顿与语言无关。
|
||||
`legacy_py/` 与备份 zip 均已删除,Python 后端不留副本:以后改路由语义的参照就是
|
||||
本文件的对拍记录 + 真机验收;编码面回归用 `native_enc_diff.py`(金标准模式,无 Python 依赖)。
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
**DCU LAN9252 EtherCAT 主站工具集**(工作目录 `DCU/tools/ecat`)
|
||||
|
||||
在 Windows 上用 Python + pysoem(SOEM)直接从网卡驱动 DCU 的 LAN9252 EtherCAT 从站:
|
||||
**生产后端是原生 C/SOEM 守护进程** `build_native/ecat_native.exe`(`ecat_native.c` +
|
||||
`build_native.bat` 编译,见 `MIGRATION.md` 的迁移记录):同一套 REST 契约、同一批
|
||||
前端页面,PDO 热循环天花板从 Python 的 ~5kHz 提到 ~7kHz。Python 版后端已彻底删除
|
||||
(2026-09-04,含备份);编码面回归改打金标准
|
||||
`native_enc_golden.json`(`native_enc_diff.py`)。命令行工具 `ecat_tool.py`
|
||||
仍是 Python + pysoem(低频诊断用,不与时序敏感的热循环抢网卡)。
|
||||
|
||||
在 Windows 上直接从网卡驱动 DCU 的 LAN9252 EtherCAT 从站:
|
||||
扫描总线、读写 SII EEPROM、CoE SDO 读写、PDO 循环收发,以及一个本地网页/桌面控制台。
|
||||
|
||||
## 环境准备
|
||||
|
||||
- Python 3.12(venv 不入库)
|
||||
- [Npcap](https://npcap.com/):pysoem 收发裸 EtherCAT 帧的前提
|
||||
- Python 3.12(venv 不入库;命令行工具与 legacy 后端用)
|
||||
- [Npcap](https://npcap.com/):收发裸 EtherCAT 帧的前提(native 与 pysoem 共用)
|
||||
- VS2022(Community 即可):编译 `build_native.bat` 产出原生守护进程
|
||||
- 一块直连从站的物理网卡(示例中为 Intel I226)
|
||||
|
||||
```bat
|
||||
@@ -22,32 +30,33 @@ venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||
|
||||
```
|
||||
ecat/
|
||||
├─ ecat_app.vbs ← 桌面窗口(推荐)
|
||||
├─ ecat_web.vbs ← 浏览器标签
|
||||
├─ ecat_web.vbs ← 浏览器标签(启动 native 守护进程, 隐藏控制台)
|
||||
├─ ecat_web.bat ← 控制台模式(看日志)
|
||||
├─ ecat.bat ← 命令行工具
|
||||
├─ run_scan.bat ← bring-up 扫描
|
||||
├─ ecat_native.c ← 原生守护进程(生产后端, 全路由)
|
||||
├─ build_native.bat ← 编译 native(先跑 gen_eeprom.py 生成 SII 默认镜像头)
|
||||
├─ soem-src/ ← vendored SOEM(从 pysoem sdist 解出, 有一处补丁)
|
||||
├─ MIGRATION.md ← Python→native 迁移记录(契约/验收/已知差异)
|
||||
├─ native_enc_diff.py ← 编码回归: native enctest vs 金标准(不碰网卡)
|
||||
├─ native_enc_golden.json ← 金标准(阶段 2 与 Python 逐字节对拍通过后固化)
|
||||
├─ README.md / requirements.txt
|
||||
├─ build_native/ ← ecat_native.exe + eeprom_image.h 等构建产物
|
||||
├─ src/
|
||||
│ ├─ ecat_server.py 网页控制台后端(REST API + 静态文件), 默认端口 8090
|
||||
│ ├─ ecat_tool.py 一体化命令行主站
|
||||
│ ├─ ecat_flash_eeprom.py 经网线烧写 SII 镜像(被 ecat_tool flash 复用)
|
||||
│ ├─ test_motor_backend.py 电机后端自测(不接硬件)
|
||||
│ ├─ web/ 前端资源(URL 路径不变, 仍是 / 与 /motor.html)
|
||||
│ ├─ web/ 前端资源(URL 路径不变, 仍是 / 与 /motor.html; native 也托管这里)
|
||||
│ └─ diag/ 独立诊断脚本(互不依赖, 直接单跑)
|
||||
└─ venv/ 本地虚拟环境(不入库)
|
||||
└─ venv/ 本地虚拟环境(不入库; CLI 与构建期 gen_eeprom 用)
|
||||
```
|
||||
|
||||
`src/` 根下那四个 .py 平放是有原因的:`ecat_tool.py` import `ecat_flash_eeprom`、
|
||||
`test_motor_backend.py` import `ecat_server`,互相 import 的必须同层,再往下分
|
||||
就得改 import 或做成包,不值得。
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/ecat_tool.py` | 一体化命令行主站:`adapters` / `status` / `flash` / `eeprom` / `sdo` / `send` 等子命令 |
|
||||
| `src/ecat_server.py` | 网页控制台后端(REST API + 静态文件),默认端口 **8090**(8080 已被 ESP32 tools 占用) |
|
||||
| `src/ecat_flash_eeprom.py` | 通过网线烧写 `gen_eeprom.py` 生成的 SII 镜像,逐字回读校验(烧完需断电重启让 ESC 重新加载) |
|
||||
| `src/test_motor_backend.py` | 电机后端自测,**不接硬件**(假从站模拟 GD32 buildInput/processOutput)。跑法:在 `src/` 下 `..\venv\Scripts\python.exe test_motor_backend.py` |
|
||||
| `ecat_native.c` + `build_native.bat` | **生产后端**:原生 C/SOEM 守护进程(全路由 + 生命周期契约),默认端口 **8090**(8080 已被 ESP32 tools 占用)。迁移记录见 `MIGRATION.md` |
|
||||
| `src/ecat_flash_eeprom.py` | 通过网线烧写 `gen_eeprom.py` 生成的 SII 镜像,逐字回读校验(烧完需断电重启让 ESC 重新加载)。被 `ecat_tool.py flash` 复用 |
|
||||
| `native_enc_diff.py` + `native_enc_golden.json` | 编码回归:native `enctest` 路由输出 vs 金标准(阶段 2 与 Python `_fill_can_out` 逐字节对拍通过后固化)。改 `fill_can_out`/`pack_tx_batch` 后必跑 |
|
||||
| ~~`legacy_py/`~~ | Python 后端已彻底删除(2026-09-04 切换收官,含备份 zip)。行为基准只存在于 `MIGRATION.md` 的逐字段对拍记录里;`ecat_app.vbs` 桌面窗口模式随之退役 |
|
||||
| `src/web/ecat.html` + `ecat.css` | 网页控制台前端 |
|
||||
| `src/web/motor.html` + `motor.js` | 电机控制台:N1–N9 九节点,走 EtherCAT 过程数据(不经 WiFi/HTTP)。节点总览、8 种 Control_Mode、STATUS 实时解码、示波器曲线 |
|
||||
| `src/diag/ecat_scan.py` | 最小化 bring-up 扫描:从站身份 + SII dump,`--op` 追加 PDO 测试 |
|
||||
@@ -60,8 +69,7 @@ ecat/
|
||||
|
||||
| 入口 | 效果 |
|
||||
|------|------|
|
||||
| `ecat_app.vbs` | pywebview 桌面窗口,无控制台无浏览器标签(已针对部分 GPU 驱动白屏加 `--disable-gpu-compositing`) |
|
||||
| `ecat_web.vbs` | 后台起服务并打开浏览器标签,无控制台窗口 |
|
||||
| `ecat_web.vbs` | 后台起 native 守护进程并打开浏览器标签,无控制台窗口 |
|
||||
| `ecat_web.bat` | 控制台模式,可见日志,调试用 |
|
||||
| `ecat.bat` | 命令行工具入口,如 `ecat.bat status`、`ecat.bat flash` |
|
||||
| `run_scan.bat` | 直接跑 bring-up 扫描,无参数则列出 Npcap 适配器 |
|
||||
|
||||
+6
-1
@@ -3,12 +3,17 @@ setlocal
|
||||
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" >nul
|
||||
cd /d G:\Robot_v2\DCU\tools\ecat
|
||||
if not exist build_native mkdir build_native
|
||||
rem SII default image (ecat_native.c's #include "eeprom_image.h"): gen_eeprom.py stdout is
|
||||
rem already a clean C header (self-check line goes to stderr); assert failure -> non-zero
|
||||
rem exit -> build aborts. To change SII just rerun this script.
|
||||
venv\Scripts\python.exe "G:\Robot_v2\DCU\DCU_APP\GD32E508_DCU_APP\comm\ssc\port\gen_eeprom.py" > build_native\eeprom_image.h
|
||||
if errorlevel 1 (echo [FAIL] gen_eeprom.py image generation failed & exit /b 1)
|
||||
if not exist build_native\wpcap.lib (
|
||||
dumpbin /exports C:\Windows\System32\Npcap\wpcap.dll > build_native\exports.txt
|
||||
venv\Scripts\python.exe exports2def.py build_native\exports.txt build_native\wpcap.def wpcap
|
||||
lib /def:build_native\wpcap.def /machine:X64 /out:build_native\wpcap.lib
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
cl /nologo /O2 /W3 /utf-8 /D_CRT_SECURE_NO_WARNINGS /DWIN32 /I soem-src\soem /I soem-src\osal /I soem-src\osal\win32 /I soem-src\oshw\win32 /I soem-src\oshw\win32\wpcap\Include /Fo:build_native\ ecat_native.c soem-src\soem\ethercatbase.c soem-src\soem\ethercatcoe.c soem-src\soem\ethercatconfig.c soem-src\soem\ethercatdc.c soem-src\soem\ethercateoe.c soem-src\soem\ethercatfoe.c soem-src\soem\ethercatmain.c soem-src\soem\ethercatprint.c soem-src\soem\ethercatsoe.c soem-src\osal\win32\osal.c soem-src\oshw\win32\nicdrv.c soem-src\oshw\win32\oshw.c /link /OUT:build_native\ecat_native.exe build_native\wpcap.lib ws2_32.lib winmm.lib
|
||||
cl /nologo /O2 /W3 /utf-8 /D_CRT_SECURE_NO_WARNINGS /DWIN32 /I build_native /I soem-src\soem /I soem-src\osal /I soem-src\osal\win32 /I soem-src\oshw\win32 /I soem-src\oshw\win32\wpcap\Include /Fo:build_native\ ecat_native.c soem-src\soem\ethercatbase.c soem-src\soem\ethercatcoe.c soem-src\soem\ethercatconfig.c soem-src\soem\ethercatdc.c soem-src\soem\ethercateoe.c soem-src\soem\ethercatfoe.c soem-src\soem\ethercatmain.c soem-src\soem\ethercatprint.c soem-src\soem\ethercatsoe.c soem-src\osal\win32\osal.c soem-src\oshw\win32\nicdrv.c soem-src\oshw\win32\oshw.c /link /OUT:build_native\ecat_native.exe build_native\wpcap.lib ws2_32.lib winmm.lib
|
||||
if errorlevel 1 exit /b 1
|
||||
echo [OK] build_native\ecat_native.exe
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
' DCU EtherCAT desktop window (pywebview/WebView2). No console, no browser tab.
|
||||
' Browser-tab launcher: ecat_web.vbs Console-mode debug launcher: ecat_web.bat
|
||||
' 入口在项目根, 代码在 src\; 工作目录设为 src 以便 py 之间 import 正常解析。
|
||||
Set sh = CreateObject("WScript.Shell")
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
base = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||
sh.CurrentDirectory = base & "\src"
|
||||
sh.Environment("PROCESS")("PYTHONUTF8") = "1"
|
||||
' WebView2: keep GPU rasterization but disable GPU compositing.
|
||||
' Full hardware acceleration white-screens this window on some GPU drivers;
|
||||
' --disable-gpu-compositing fixes it while still using the GPU for raster.
|
||||
sh.Environment("PROCESS")("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") = "--disable-gpu-compositing"
|
||||
sh.Run """" & base & "\venv\Scripts\pythonw.exe"" """ & base & "\src\ecat_server.py"" --window", 0, False
|
||||
+2007
-175
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -1,8 +1,7 @@
|
||||
@echo off
|
||||
rem DCU EtherCAT master web console (serves src\web\ecat.html + pysoem API)
|
||||
rem 入口在项目根, 代码在 src\; cd 进 src 是为了让 py 之间的 import 正常解析。
|
||||
rem DCU EtherCAT master web console (native C/SOEM daemon; serves src\web + /api/*)
|
||||
rem Build first: build_native.bat (VS2022 + Npcap). Debug launcher with visible logs.
|
||||
chcp 65001 >nul
|
||||
set PYTHONUTF8=1
|
||||
cd /d "%~dp0src"
|
||||
"%~dp0venv\Scripts\python.exe" ecat_server.py
|
||||
cd /d "%~dp0"
|
||||
"%~dp0build_native\ecat_native.exe" --substr I226 --web src\web
|
||||
pause
|
||||
|
||||
+4
-5
@@ -1,9 +1,8 @@
|
||||
' DCU EtherCAT web console - hidden launcher (no console window).
|
||||
' Double-click me. Console-mode debug launcher (visible logs): ecat_web.bat
|
||||
' 入口在项目根, 代码在 src\; 工作目录设为 src 以便 py 之间 import 正常解析。
|
||||
' Phase 4: backend is the native daemon (build_native\ecat_native.exe), no Python.
|
||||
' Double-click me. Console-mode debug launcher: ecat_web.bat
|
||||
Set sh = CreateObject("WScript.Shell")
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
base = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||
sh.CurrentDirectory = base & "\src"
|
||||
sh.Environment("PROCESS")("PYTHONUTF8") = "1"
|
||||
sh.Run """" & base & "\venv\Scripts\python.exe"" """ & base & "\src\ecat_server.py""", 0, False
|
||||
sh.CurrentDirectory = base
|
||||
sh.Run """" & base & "\build_native\ecat_native.exe"" --substr I226 --web src\web", 0, False
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""native_enc_diff.py — CAN 输出编码回归(不碰网卡,无 Python 后端依赖)。
|
||||
|
||||
阶段 4 切换后 Python 基准已删除;本脚本改为对照 native_enc_golden.json
|
||||
(阶段 2 与 Python _fill_can_out 逐字节对拍通过后固化的 4 组用例)。
|
||||
byte0 是 seq(守护进程状态相关,每次 enctest 都 +1),不参与比对;
|
||||
其余 383 字节必须逐字节一致,另校验 seq 两两 +1(跳 0) 的递增语义。
|
||||
|
||||
用法:native 在 8090 跑着即可(不需要 open/OP):python native_enc_diff.py
|
||||
重新生成金标准(需 legacy 备份里的 ecat_server.py,一般不需要):见 git 历史/zip。
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
GOLDEN = "native_enc_golden.json"
|
||||
URL = "http://127.0.0.1:8090/api/native/enctest"
|
||||
|
||||
|
||||
def main():
|
||||
g = json.load(open(GOLDEN, encoding="utf-8"))
|
||||
cases = g["cases"]
|
||||
body = {"cases": CASES}
|
||||
req = urllib.request.Request(URL, data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req) as r:
|
||||
d = json.loads(r.read())
|
||||
got = d["hexes"]
|
||||
ok = True
|
||||
if len(got) != len(cases):
|
||||
print(f"用例数不符: golden={len(cases)} native={len(got)}")
|
||||
return 1
|
||||
for i, (a, b) in enumerate(zip(cases, got)):
|
||||
if a[2:] != b[2:]:
|
||||
ok = False
|
||||
diff = [j for j in range(2, len(a), 2) if a[j:j+2] != b[j:j+2]]
|
||||
print(f"用例 {i}: 不一致!差异字节偏移 {diff[:12]}")
|
||||
j = diff[0]
|
||||
print(f" golden[{j}:{j+24}] = {a[j:j+24]}")
|
||||
print(f" native[{j}:{j+24}] = {b[j:j+24]}")
|
||||
else:
|
||||
print(f"用例 {i}: ✅ 383B 逐字节一致(byte0=seq 除外)")
|
||||
seqs = [int(h[:2], 16) for h in got]
|
||||
|
||||
def nxt(s):
|
||||
s = (s + 1) & 0xFF
|
||||
return 1 if s == 0 else s
|
||||
inc = all(seqs[i + 1] == nxt(seqs[i]) for i in range(len(seqs) - 1))
|
||||
print(f"seq 递增: {seqs} {'✅' if inc else '❌'}")
|
||||
return 0 if ok and inc else 1
|
||||
|
||||
|
||||
# 与阶段 2 对拍时同一组用例(纯数据)。改动这里 = 改动金标准语义,
|
||||
# 必须连同 native_enc_golden.json 一起重新生成。
|
||||
CASES = [
|
||||
# 基础单帧:DLC32 电机 CMD
|
||||
{"win": 0, "ch": 1, "id": 0x30200, "dlc": 32, "data": bytes(range(32)).hex(), "batch": []},
|
||||
# 批量边界:20×DLC8(332B 区,每项 16B → 20 项 320B,第 21 项 336>332 越界)
|
||||
{"win": 5, "ch": 0, "id": 0x123, "dlc": 8, "data": "AABBCCDDEEFF0011",
|
||||
"batch": [{"ch": 1, "id": 0x300 + i, "dlc": 8, "data": "0102030405060708"} for i in range(21)]},
|
||||
# dlc=0 只发批量;6×DLC64(每项 72B → 4 项 288B,第 5 项 360 越界)
|
||||
{"win": 20, "ch": 2, "id": 0, "dlc": 0, "data": "",
|
||||
"batch": [{"ch": 2, "id": 0xFFFFF, "dlc": 64, "data": "EE" * 64} for _ in range(6)]},
|
||||
# 混合 DLC + win 保持
|
||||
{"win": 255, "ch": 1, "id": 7, "dlc": 32, "data": "11" * 32,
|
||||
"batch": [{"ch": 0, "id": 9, "dlc": 32, "data": "22" * 32},
|
||||
{"ch": 1, "id": 10, "dlc": 4, "data": "33445566"},
|
||||
{"ch": 2, "id": 11, "dlc": 12, "data": "55" * 12}]},
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"note": "阶段2 与 Python _fill_can_out 逐字节对拍通过后固化(byte0=seq 状态相关不参与比对)。改 fill_can_out/pack_tx_batch 后跑 native_enc_diff.py 对照。",
|
||||
"cases": [
|
||||
"01012000000203000000000000000000000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"02000800230100000500000000000000AABBCCDDEEFF00110000000000000000000000000000000000000000000000001400000001080000000300000102030405060708010800000103000001020304050607080108000002030000010203040506070801080000030300000102030405060708010800000403000001020304050607080108000005030000010203040506070801080000060300000102030405060708010800000703000001020304050607080108000008030000010203040506070801080000090300000102030405060708010800000A0300000102030405060708010800000B0300000102030405060708010800000C0300000102030405060708010800000D0300000102030405060708010800000E0300000102030405060708010800000F030000010203040506070801080000100300000102030405060708010800001103000001020304050607080108000012030000010203040506070801080000130300000102030405060708000000000000000000000000",
|
||||
"0302000000000000140000000000000000000000000000000000000000000000000000000000000000000000000000000400000002400000FFFF0F00EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE02400000FFFF0F00EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE02400000FFFF0F00EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE02400000FFFF0F00EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"0401200007000000FF0000000000000011111111111111111111111111111111111111111111111111111111111111110300000000200000090000002222222222222222222222222222222222222222222222222222222222222222010400000A00000033445566020C00000B0000005555555555555555555555550000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
],
|
||||
"seqs": [
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -19,7 +19,12 @@ import sys
|
||||
import pysoem
|
||||
|
||||
GEN = r"G:\Robot_v2\DCU\DCU_APP\GD32E508_DCU_APP\comm\ssc\port\gen_eeprom.py"
|
||||
SCRATCH_WORD = 0x0200 # inside the 0xFF filler, far beyond the categories
|
||||
# Must sit in the *real* 0xFF filler, past the SII END word. The category area now
|
||||
# reaches word 0x0281 (STRINGS/General/FMMU/SyncM/RxPDO 196w/TxPDO 324w, END at
|
||||
# 0x0282), so the old 0x0200 was *inside* the TxPDO category: restoring the probe
|
||||
# to 0xFFFF punched a hole in real data. Measured 2026-09-04: chip word 0x0200 read
|
||||
# 0xFFFF while the image has 0x401B — only the full rewrite right after hid it.
|
||||
SCRATCH_WORD = 0x0400
|
||||
|
||||
|
||||
def load_image():
|
||||
|
||||
-2826
File diff suppressed because it is too large
Load Diff
@@ -1,684 +0,0 @@
|
||||
"""EtherCAT 电机控制台后端自测(不接硬件)。
|
||||
|
||||
用一个模拟 GD32 buildInput/processOutput 行为的假从站,验证:
|
||||
1. DLC 32 的电机 CMD 能完整下发(旧 24B 布局会静默截断后 8 字节)
|
||||
2. 后台 PDO 循环靠滚动窗口扫完 27 个槽
|
||||
3. STATUS 完整 32B 回传(旧 8B 短快照根本装不下)
|
||||
4. 命令队列按 seq 沿下发,不互相覆盖
|
||||
5. 循环可干净停止
|
||||
6. 【1.5.56】批量发送区:多帧在**同一个周期**发出(同步下发)
|
||||
7. 【1.5.56】批量接收区:一周期回收多帧,含 DLC64 CANFD
|
||||
8. 【1.5.56】兼容性:批量区为空时,字节布局与 1.5.55 完全一致
|
||||
9. 窗口自适应:稀疏总线(27 槽只有 2 个有主)下单槽每周期都刷新,
|
||||
而不是旧实现的每 4 周期一次
|
||||
10. 发现扫描:热插上来的电机仍能被找到并纳入常规窗口
|
||||
(含 slot 21..26 这段尾部 —— 朴素的"+7 到越界归零"游标会永久漏掉它)
|
||||
11. 自旋节拍源:自动阈值/显式覆盖,以及 1ms 档确实跑得准
|
||||
12. 自旋不得持 GIL 空转(否则 HTTP 线程被饿死,真机实测劣化到 40ms)
|
||||
13. P 核识别与钉核:混合架构下自旋钉到快核(P 核)组,且钉核是线程级的
|
||||
14. api_motor_start 的 OP 状态核实:已在 OP 能正常启动,从站掉出 OP 能发现
|
||||
|
||||
跑法: python test_motor_backend.py
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import os
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, BASE)
|
||||
|
||||
# pysoem 依赖 Npcap 与真网卡,自测里用桩替掉
|
||||
_fake = types.ModuleType("pysoem")
|
||||
_fake.find_adapters = lambda: []
|
||||
_fake.INIT_STATE, _fake.PREOP_STATE = 1, 2
|
||||
_fake.SAFEOP_STATE, _fake.OP_STATE = 4, 8
|
||||
_fake.STATE_ACK = 0x10
|
||||
_fake.Master = object
|
||||
sys.modules["pysoem"] = _fake
|
||||
|
||||
import ecat_server as S # noqa: E402
|
||||
|
||||
SLOT = S.MOT_SLOT_SIZE
|
||||
NSLOT = S.MOT_SLOTS_PER_PDO
|
||||
|
||||
|
||||
class FakeSlave:
|
||||
"""按 comm/ethercat_can_bridge.cpp 的语义模拟从站侧。
|
||||
|
||||
state/al_status 是 api_motor_start 核实"是否真在 OP"时要读的字段(pysoem
|
||||
的 Slave 也有这两个),默认给 OP,掉线场景由测试临时改。
|
||||
"""
|
||||
|
||||
state = 8 # OP_STATE
|
||||
al_status = 0
|
||||
|
||||
def __init__(self):
|
||||
self._out = bytes(S.PDO_OUT_LEN)
|
||||
self.rx_frames = [] # 从站"发出"的帧 (ch,id,dlc,hexdata)
|
||||
self.tx_cycles = [] # 每个 seq 沿发出的帧数,用来证明"同一周期多帧"
|
||||
self.last_seq = None # 对应固件 s_lastSeq / s_seqPrimed
|
||||
self.tx_count = 0
|
||||
self.pending_rx = [] # 待经批量接收区回给主站的帧
|
||||
self.rx_lost = 0
|
||||
|
||||
@property
|
||||
def output(self):
|
||||
return self._out
|
||||
|
||||
@output.setter
|
||||
def output(self, v):
|
||||
self._out = bytes(v)
|
||||
seq = self._out[0]
|
||||
# 固件语义:首次只记基准不发送;之后仅 seq 变化沿触发一次发送
|
||||
if self.last_seq is not None and seq != self.last_seq:
|
||||
n = 0
|
||||
# 段1 单帧区(dlc=0 表示本次只发批量)
|
||||
dlc = self._out[2]
|
||||
if dlc:
|
||||
self.rx_frames.append((
|
||||
self._out[1],
|
||||
struct.unpack_from("<I", self._out, 4)[0],
|
||||
dlc,
|
||||
self._out[S.TX_OFF_DATA:S.TX_OFF_DATA + dlc].hex().upper(),
|
||||
))
|
||||
n += 1
|
||||
# 段2 批量发送区:逐帧解析 (8B 头 + dlc),越界即停
|
||||
count = self._out[S.TXB_OFF_COUNT]
|
||||
off = S.TXB_OFF_ITEMS
|
||||
for _ in range(count):
|
||||
if off + S.FRM_HDR_SIZE > S.PDO_OUT_LEN:
|
||||
break
|
||||
d = self._out[off + S.FRM_OFF_DLC]
|
||||
if off + S.FRM_HDR_SIZE + d > S.PDO_OUT_LEN:
|
||||
break
|
||||
self.rx_frames.append((
|
||||
self._out[off + S.FRM_OFF_CH],
|
||||
struct.unpack_from("<I", self._out, off + S.FRM_OFF_ID)[0],
|
||||
d,
|
||||
self._out[off + S.FRM_HDR_SIZE:
|
||||
off + S.FRM_HDR_SIZE + d].hex().upper(),
|
||||
))
|
||||
off += S.FRM_HDR_SIZE + d
|
||||
n += 1
|
||||
if n:
|
||||
self.tx_cycles.append(n)
|
||||
self.tx_count += n
|
||||
self.last_seq = seq
|
||||
|
||||
@property
|
||||
def input(self):
|
||||
b = bytearray(S.PDO_IN_LEN)
|
||||
b[0] = self.last_seq or 0
|
||||
struct.pack_into("<H", b, 22, self.tx_count)
|
||||
win = self._out[S.TX_OFF_WIN]
|
||||
b[S.MOTW_OFF_ACTIVE] = S.MOTOR_SLOT_COUNT
|
||||
b[S.MOTW_OFF_WIN_BASE] = win
|
||||
cnt = 0
|
||||
for i in range(NSLOT):
|
||||
idx = win + i
|
||||
if idx >= S.MOTOR_SLOT_COUNT:
|
||||
break
|
||||
off = S.MOTW_OFF_SLOTS + i * SLOT
|
||||
b[off] = idx // S.MOTOR_CHANNELS + 1 # node 1..9
|
||||
b[off + 1] = idx % S.MOTOR_CHANNELS # ch 0..2
|
||||
b[off + 2] = 1 # online
|
||||
b[off + 3] = 32 # dlc
|
||||
struct.pack_into("<I", b, off + 4, 1000 + idx)
|
||||
struct.pack_into("<h", b, off + 8, idx * 100) # MCU_temp ×0.01
|
||||
cnt += 1
|
||||
b[S.MOTW_OFF_WIN_COUNT] = cnt
|
||||
|
||||
# 段4 批量接收区:按字节容量装,装不下的留到下周期(模拟 fillRxBatch)
|
||||
off = S.RXB_OFF_ITEMS
|
||||
n = 0
|
||||
while self.pending_rx:
|
||||
ch, cid, dlc, data = self.pending_rx[0]
|
||||
if off + S.FRM_HDR_SIZE + dlc > S.PDO_IN_LEN:
|
||||
break
|
||||
self.pending_rx.pop(0)
|
||||
b[off + S.FRM_OFF_CH] = ch
|
||||
b[off + S.FRM_OFF_DLC] = dlc
|
||||
b[off + S.FRM_OFF_FLAGS] = 0x01
|
||||
struct.pack_into("<I", b, off + S.FRM_OFF_ID, cid)
|
||||
b[off + S.FRM_HDR_SIZE:off + S.FRM_HDR_SIZE + dlc] = data[:dlc]
|
||||
off += S.FRM_HDR_SIZE + dlc
|
||||
n += 1
|
||||
b[S.RXB_OFF_COUNT] = n
|
||||
b[S.RXB_OFF_LOST] = self.rx_lost
|
||||
b[S.RXB_OFF_PENDING] = len(self.pending_rx)
|
||||
return bytes(b)
|
||||
|
||||
|
||||
class SparseSlave(FakeSlave):
|
||||
"""只有少数槽有主的从站 —— 真实现场的样子(27 槽容量,实际接 2 台)。
|
||||
|
||||
FakeSlave 把 27 个槽全填满,那正好是自适应窗口毫无收益的最坏情况,
|
||||
测不出窗口浪费。这里按 `present` 集合填槽,并记录每个槽被窗口命中的次数,
|
||||
用来算"单个电机每几个周期才被读到一次"。
|
||||
"""
|
||||
|
||||
def __init__(self, present):
|
||||
super().__init__()
|
||||
self.present = set(present)
|
||||
self.slot_hits = {} # 槽号 -> 被窗口盖住并回传的次数
|
||||
|
||||
@property
|
||||
def input(self):
|
||||
b = bytearray(super().input)
|
||||
# 覆盖掉父类填的"全满"槽区,改按 present 填
|
||||
win = self._out[S.TX_OFF_WIN]
|
||||
for i in range(NSLOT):
|
||||
off = S.MOTW_OFF_SLOTS + i * SLOT
|
||||
b[off:off + SLOT] = bytes(SLOT) # 先清成空槽
|
||||
cnt = 0
|
||||
for i in range(NSLOT):
|
||||
idx = win + i
|
||||
if idx >= S.MOTOR_SLOT_COUNT:
|
||||
break
|
||||
cnt += 1
|
||||
if idx not in self.present:
|
||||
continue # 空槽: node_id 留 0
|
||||
off = S.MOTW_OFF_SLOTS + i * SLOT
|
||||
b[off] = idx // S.MOTOR_CHANNELS + 1
|
||||
b[off + 1] = idx % S.MOTOR_CHANNELS
|
||||
b[off + 2] = 1
|
||||
b[off + 3] = 32
|
||||
# seq 每次命中都要变,否则前端的 seq 活性判断看不出在刷新
|
||||
self.slot_hits[idx] = self.slot_hits.get(idx, 0) + 1
|
||||
struct.pack_into("<I", b, off + 4, self.slot_hits[idx])
|
||||
struct.pack_into("<h", b, off + 8, idx * 100)
|
||||
b[S.MOTW_OFF_ACTIVE] = len(self.present)
|
||||
b[S.MOTW_OFF_WIN_BASE] = win
|
||||
b[S.MOTW_OFF_WIN_COUNT] = cnt
|
||||
return bytes(b)
|
||||
|
||||
|
||||
class FakeMaster:
|
||||
"""state/al_status 供 api_motor_start 的"核实是否真在 OP"那段读取。"""
|
||||
|
||||
expected_wkc = 3
|
||||
|
||||
def __init__(self):
|
||||
self.slaves = [FakeSlave()]
|
||||
|
||||
def send_processdata(self):
|
||||
pass
|
||||
|
||||
def receive_processdata(self, timeout):
|
||||
return 3
|
||||
|
||||
def read_state(self):
|
||||
return 8
|
||||
|
||||
|
||||
def main():
|
||||
S.g_master = FakeMaster()
|
||||
S.g_op_active = True
|
||||
slave = S.g_master.slaves[0]
|
||||
|
||||
# 完成 seq priming(等价于固件上电后的第一个输出周期)
|
||||
slave.output = bytes(S.PDO_OUT_LEN)
|
||||
|
||||
print("=== 1. 单帧 CMD (DLC 32) 往返 ===")
|
||||
cmd = bytes(range(32))
|
||||
r = S.api_can_send({"ch": 1, "id": "0x30200", "dlc": 32, "data": cmd.hex()})
|
||||
assert r["ok"] and r["acked"], r
|
||||
ch, can_id, dlc, data = slave.rx_frames[-1]
|
||||
print(f" 从站收到 ch={ch} id=0x{can_id:X} dlc={dlc}")
|
||||
assert dlc == 32, f"DLC 被截断成 {dlc}"
|
||||
assert data == cmd.hex().upper(), "32B payload 不一致"
|
||||
print(" [OK] DLC32 完整送达,未被旧 24B 上限截断")
|
||||
|
||||
print("\n=== 2. 后台循环 + 窗口滚动扫全表 ===")
|
||||
S._motor_thread_start(period_ms=2)
|
||||
time.sleep(0.4)
|
||||
snap = S.api_motor_state({})
|
||||
assert snap["ok"], snap
|
||||
motors = snap["motors"]
|
||||
print(f" cycles={snap['cycles']} 聚合槽数={len(motors)}/{S.MOTOR_SLOT_COUNT}")
|
||||
assert len(motors) == S.MOTOR_SLOT_COUNT, f"只扫到 {len(motors)} 槽"
|
||||
nodes = sorted({m["node"] for m in motors})
|
||||
chans = sorted({m["ch"] for m in motors})
|
||||
print(f" 节点={nodes} 通道={chans}")
|
||||
assert nodes == list(range(1, 10)) and chans == [0, 1, 2]
|
||||
print(f" [OK] 开机首圈 {S.MOTOR_SCAN_CYCLES} 窗口扫完全表,node×ch 双键齐全")
|
||||
|
||||
print("\n=== 3. STATUS payload 完整 32B ===")
|
||||
m5 = next(m for m in motors if m["slot"] == 5)
|
||||
raw = bytes.fromhex(m5["data"])
|
||||
temp = struct.unpack_from("<h", raw, 0)[0] * 0.01
|
||||
print(f" slot5 node={m5['node']} ch={m5['ch']} len={len(raw)}B mcuTemp={temp}°C")
|
||||
assert len(raw) == 32, f"STATUS 只有 {len(raw)}B"
|
||||
assert abs(temp - 5.0) < 1e-6
|
||||
print(" [OK] 32B STATUS 完整(旧 8B 短快照装不下,decodeStatus 会直接拒绝)")
|
||||
|
||||
print("\n=== 4. 命令队列经后台循环下发 ===")
|
||||
before = len(slave.rx_frames)
|
||||
S.api_motor_send({"frames": [
|
||||
{"ch": 0, "id": "0x10200", "dlc": 32, "data": "AA" * 32},
|
||||
{"ch": 1, "id": "0x20200", "dlc": 32, "data": "BB" * 32},
|
||||
{"ch": 2, "id": "0x30301", "dlc": 24, "data": "CC" * 24},
|
||||
]})
|
||||
time.sleep(0.3)
|
||||
sent = slave.rx_frames[before:]
|
||||
for f in sent:
|
||||
print(f" ch={f[0]} id=0x{f[1]:X} dlc={f[2]}")
|
||||
assert len(sent) == 3, f"队列丢帧: 只发出 {len(sent)}/3"
|
||||
assert [f[2] for f in sent] == [32, 32, 24]
|
||||
print(" [OK] 3 帧全部下发,未互相覆盖")
|
||||
|
||||
print("\n=== 5. 循环可干净停止 ===")
|
||||
S._motor_thread_stop()
|
||||
assert S._motor_thread is None
|
||||
print(" [OK] 线程已退出")
|
||||
|
||||
# ─────────── 1.5.56 批量收发 ───────────
|
||||
print(f"\n=== 6. 批量发送:多帧同一周期发出 (容量 {S.TXB_BYTES}B) ===")
|
||||
slave.tx_cycles.clear()
|
||||
before = len(slave.rx_frames)
|
||||
nine = [{"ch": i % 3, "id": f"0x{i+1:X}0200", "dlc": 32,
|
||||
"data": f"{i:02X}" * 32} for i in range(9)]
|
||||
S.api_motor_send({"frames": nine})
|
||||
S._motor_thread_start(period_ms=2)
|
||||
time.sleep(0.25)
|
||||
S._motor_thread_stop()
|
||||
sent = slave.rx_frames[before:]
|
||||
print(f" 下发 9 帧 DLC32 -> 从站收到 {len(sent)} 帧,"
|
||||
f"各 seq 沿帧数={slave.tx_cycles}")
|
||||
assert len(sent) == 9, f"批量下发丢帧: {len(sent)}/9"
|
||||
assert max(slave.tx_cycles) > 1, \
|
||||
f"仍是每周期一帧({slave.tx_cycles}),批量区没生效"
|
||||
# 关键:9 帧 DLC32 应在 1 个周期内发完(单帧区1 + 批量区8),而非排队 9 个周期
|
||||
assert len(slave.tx_cycles) <= 2, \
|
||||
f"9 帧用了 {len(slave.tx_cycles)} 个周期,未达到同步下发"
|
||||
ids = [f"0x{f[1]:X}" for f in sent]
|
||||
print(f" 帧序 ={ids}")
|
||||
assert [f[2] for f in sent] == [32] * 9, "批量帧 DLC 有截断"
|
||||
print(f" [OK] 9 帧在 {len(slave.tx_cycles)} 个周期内发完(旧实现需 9 个周期)")
|
||||
|
||||
print(f"\n=== 7. 批量接收:一周期回收多帧 (容量 {S.RXB_BYTES}B) ===")
|
||||
# 含一帧满长 DLC64,验证 CANFD 全量能过
|
||||
slave.pending_rx = [
|
||||
(0, 0x10100, 32, bytes(range(32))),
|
||||
(1, 0x20100, 32, bytes(range(32, 64))),
|
||||
(2, 0x30100, 64, bytes(range(64))), # CANFD 满长
|
||||
(0, 0x40100, 8, bytes(range(8))),
|
||||
]
|
||||
S._motor_rx_log.clear()
|
||||
S._motor_thread_start(period_ms=2)
|
||||
time.sleep(0.25)
|
||||
snap = S.api_motor_state({})
|
||||
S._motor_thread_stop()
|
||||
got = S.api_motor_rx({})
|
||||
frames = got["frames"]
|
||||
print(f" 回收 {len(frames)} 帧, rx_batch.count={snap['rx_batch']['count']}, "
|
||||
f"pending={snap['rx_batch']['pending']}, lost={snap['rx_batch']['lost']}")
|
||||
for f in frames:
|
||||
print(f" ch={f['ch']} id=0x{f['id']:X} dlc={f['dlc']}")
|
||||
assert len(frames) == 4, f"批量接收丢帧: {len(frames)}/4"
|
||||
assert [f["dlc"] for f in frames] == [32, 32, 64, 8], "接收 DLC 有错"
|
||||
fd = next(f for f in frames if f["dlc"] == 64)
|
||||
assert bytes.fromhex(fd["data"]) == bytes(range(64)), "DLC64 payload 不一致"
|
||||
assert got["remain"] == 0, "取走后缓冲应清空"
|
||||
print(" [OK] 4 帧含 DLC64 满长 CANFD 全部完整回收")
|
||||
|
||||
print("\n=== 8. 兼容性:批量区为空时布局与 1.5.55 一致 ===")
|
||||
S._fill_can_out(1, 0x30200, 32, bytes(range(32)))
|
||||
out = bytes(S._can_out)
|
||||
assert out[1] == 1 and out[2] == 32
|
||||
assert struct.unpack_from("<I", out, 4)[0] == 0x30200
|
||||
assert out[S.TX_OFF_DATA:S.TX_OFF_DATA + 32] == bytes(range(32))
|
||||
assert out[S.TXB_OFF_COUNT] == 0, "未请求批量时 count 必须为 0"
|
||||
assert all(v == 0 for v in out[S.TXB_OFF_ITEMS:]), "未请求批量时帧区必须全零"
|
||||
print(f" 单帧区 [0..47] 与 1.5.55 逐字节一致,批量区 count=0 全零")
|
||||
print(" [OK] 老主站/CAN 调试页行为不变")
|
||||
|
||||
print("\n=== 9. 窗口自适应:稀疏总线上单槽刷新率 ===")
|
||||
# 真实现场是 27 槽里只有 2 个有主(9×3 是容量不是接入数)。旧实现每周期
|
||||
# 窗口 +7 无脑轮扫 4 个窗口,其中 3 个整窗全是空槽,于是单个电机的状态
|
||||
# 只有 周期率/4 的更新速度。这一项证明自适应窗口把它拉回"每周期都刷"。
|
||||
sparse = SparseSlave({2, 5}) # node1/ch2 与 node2/ch2,与实测现场一致
|
||||
S.g_master.slaves[0] = sparse
|
||||
sparse.output = bytes(S.PDO_OUT_LEN) # seq priming
|
||||
S._motor_snap = None
|
||||
S._motor_thread_start(period_ms=2)
|
||||
time.sleep(0.6)
|
||||
snap = S.api_motor_state({})
|
||||
S._motor_thread_stop()
|
||||
|
||||
cycles = snap["cycles"]
|
||||
print(f" bases={snap['win_bases']} (只 1 个窗口即可盖住 slot 2 与 5)")
|
||||
print(f" slot_period_ms={snap['slot_period_ms']} (= period_ms × 窗口数)")
|
||||
assert len(snap["win_bases"]) == 1, \
|
||||
f"稀疏总线不该用 {len(snap['win_bases'])} 个窗口"
|
||||
assert snap["slot_period_ms"] == snap["period_ms"], \
|
||||
"单槽刷新周期应等于 PDO 周期"
|
||||
|
||||
# 每个在线槽被"读到"的次数:命中率应接近 100%,而非旧实现的 ~25%
|
||||
hits = sparse.slot_hits
|
||||
for slot in (2, 5):
|
||||
ratio = hits.get(slot, 0) / max(1, cycles)
|
||||
print(f" slot {slot}: {hits.get(slot,0)}/{cycles} 周期命中 = {ratio*100:.0f}%")
|
||||
assert ratio > 0.90, f"slot {slot} 命中率仅 {ratio*100:.0f}%,窗口没盖住"
|
||||
# 空槽不该再白占窗口:整体扫描量应远低于旧实现的 27 槽/4 周期
|
||||
print(f" [OK] 在线电机每周期都刷新(旧实现每 4 周期才 1 次 = 25%)")
|
||||
|
||||
print("\n=== 10. 发现扫描:新插上的电机仍能被找到 ===")
|
||||
# 自适应窗口只盯"已知的槽",必须靠周期性发现扫描找到新电机,
|
||||
# 否则热插拔上来的电机永远不出现 —— 这是自适应的必要配套。
|
||||
sparse2 = SparseSlave({2})
|
||||
S.g_master.slaves[0] = sparse2
|
||||
sparse2.output = bytes(S.PDO_OUT_LEN)
|
||||
S._motor_snap = None
|
||||
S._motor_thread_start(period_ms=1)
|
||||
time.sleep(0.3)
|
||||
sparse2.present.add(24) # 热插:node9/ch0 上线
|
||||
deadline = time.time() + 5.0
|
||||
found = False
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
snap = S.api_motor_state({})
|
||||
if any(m["slot"] == 24 for m in (snap.get("motors") or [])):
|
||||
found = True
|
||||
break
|
||||
S._motor_thread_stop()
|
||||
print(f" 发现间隔 MOTOR_DISCOVERY_EVERY={S.MOTOR_DISCOVERY_EVERY} 周期")
|
||||
assert found, "发现扫描没找到新上线的 slot 24(自适应窗口把它永久漏掉了)"
|
||||
print(f" bases={snap['win_bases']} → 新槽已纳入常规覆盖")
|
||||
assert any(b <= 24 < b + S.MOT_SLOTS_PER_PDO for b in snap["win_bases"]), \
|
||||
"slot 24 被发现了但没纳入常规窗口,下一拍又会漏"
|
||||
print(" [OK] 热插的电机能被发现并纳入每周期刷新")
|
||||
|
||||
print("\n=== 11. 自旋节拍源:策略 + 实际精度 ===")
|
||||
# 分段实测表明一次周期的总线开销只有 ~165us,1.36ms 里 1.2ms 是
|
||||
# Windows 内核睡眠的粒度超调。自旋能把周期跑准,代价是占满一个核,
|
||||
# 所以"什么时候自旋"和"自旋到底准不准"都得有测试守着。
|
||||
S._motor_spin = None
|
||||
S._motor_period_ms = 4
|
||||
assert S._spin_effective() is False, "4ms 默认不该自旋(内核睡眠够用,别白烧 CPU)"
|
||||
# 显式开一次之后,下一次不带 spin 的请求必须回到"自动",不能沿用 True。
|
||||
# 早先 _motor_thread_start 写成 `if spin is not None` 才赋值,于是手动开过
|
||||
# 一次自旋就永久粘住,长周期也照样烧一个核,自动阈值形同失效。
|
||||
S._motor_thread_start(4, True)
|
||||
assert S._spin_effective() is True, "显式 spin=True 必须生效"
|
||||
S._motor_thread_stop()
|
||||
S._motor_thread_start(4, None)
|
||||
assert S._spin_effective() is False, \
|
||||
"省略 spin 必须回到自动模式(4ms→不自旋),不能粘住上一次的 True"
|
||||
S._motor_thread_stop()
|
||||
S._motor_period_ms = 2
|
||||
S._motor_period_ms = 2
|
||||
assert S._spin_effective() is True, "2ms 该自动自旋(内核睡眠在这个档睡不准)"
|
||||
S._motor_period_ms = 1
|
||||
assert S._spin_effective() is True, "1ms 该自动自旋"
|
||||
S._motor_period_ms = 1
|
||||
S._motor_spin = False
|
||||
assert S._spin_effective() is False, "用户显式关掉就必须关(省 CPU 优先)"
|
||||
S._motor_period_ms = 8
|
||||
S._motor_spin = True
|
||||
assert S._spin_effective() is True, "用户显式打开就必须在长周期也打开"
|
||||
S._motor_spin = None
|
||||
print(" [OK] 自动阈值 / 显式覆盖 都按预期")
|
||||
|
||||
# 真量一次精度。**只断言自旋档达标,不断言"自旋比睡眠快"** ——
|
||||
# Windows 的定时器分辨率是全局且粘滞的(别的进程调过 timeBeginPeriod 就会
|
||||
# 一直生效),粗粒度时睡眠能慢到 700Hz、细粒度时睡眠也能跑到 ~1000Hz。
|
||||
# 拿两者比值当判据会让这条测试随机翻脸,真正要保证的是:
|
||||
# 1) 自旋档能稳定跑到设定周期(下面第 12 项再保证它不饿死别人)
|
||||
S.g_master.slaves[0] = SparseSlave({2})
|
||||
S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN)
|
||||
def rate(spin, period=1):
|
||||
S._motor_spin = spin
|
||||
S._motor_period_ms = period
|
||||
S._motor_snap = None
|
||||
S._cyc_t.clear()
|
||||
S._motor_thread_start()
|
||||
time.sleep(1.0) # 先让周期稳定下来
|
||||
a = S._motor_snapshot()["cycles"]; ta = time.perf_counter()
|
||||
time.sleep(1.5)
|
||||
b = S._motor_snapshot()["cycles"]; tb = time.perf_counter()
|
||||
S._motor_thread_stop()
|
||||
return (b - a) / (tb - ta)
|
||||
h_sleep = rate(False)
|
||||
h_spin = rate(True)
|
||||
print(f" 内核睡眠: {h_sleep:6.1f} Hz 自旋: {h_spin:6.1f} Hz (目标 1000 Hz)")
|
||||
print(f" (睡眠档快慢取决于本机定时器分辨率, 只作参考不作判据)")
|
||||
assert h_spin > 850, f"1ms 自旋档只跑到 {h_spin:.0f}Hz,离目标太远"
|
||||
print(f" [OK] 自旋稳定跑到设定周期的 {h_spin/10:.0f}%(1ms 档)")
|
||||
|
||||
print("\n=== 12. 自旋不得饿死其它线程(GIL) ===")
|
||||
# 真机踩过的坑:第一版自旋写成 `while time.perf_counter() < d: pass`,
|
||||
# 于是读快照的 HTTP p50 从 0.6ms 劣化到 40ms —— 页面直接废了。
|
||||
# 原因是 CPython GIL 切换间隔默认 5ms,纯 Python 循环可以连拿好几个 5ms。
|
||||
# 这一项量"自旋在跑时,别的线程被延迟多久",是那条修复的回归守卫。
|
||||
S.g_master.slaves[0] = SparseSlave({2})
|
||||
S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN)
|
||||
S._motor_spin = True
|
||||
S._motor_period_ms = 1
|
||||
S._motor_snap = None
|
||||
S._cyc_t.clear()
|
||||
S._motor_thread_start()
|
||||
time.sleep(0.6) # 让自旋先跑起来
|
||||
lat = []
|
||||
t_end = time.perf_counter() + 1.5
|
||||
while time.perf_counter() < t_end: # 模拟 HTTP 线程的活: 醒来干点事再睡
|
||||
t0 = time.perf_counter()
|
||||
time.sleep(0.0002)
|
||||
lat.append((time.perf_counter() - t0) * 1000)
|
||||
S._motor_thread_stop()
|
||||
S._motor_spin = None
|
||||
lat.sort()
|
||||
p50, p90, mx = lat[len(lat)//2], lat[int(len(lat)*.9)], lat[-1]
|
||||
print(f" 自旋跑 1ms 周期时,其它线程 sleep(0.2ms) 实测 "
|
||||
f"p50 {p50:.2f}ms p90 {p90:.2f}ms max {mx:.2f}ms (n={len(lat)})")
|
||||
# 纯 Python 自旋时 p50 约 5.7ms(=GIL 切换间隔);放 GIL 的自旋回到 ~0.5ms 基线
|
||||
assert p50 < 2.0, (
|
||||
f"自旋把其它线程饿到 p50 {p50:.1f}ms —— 自旋循环一定又在持 GIL 空转,"
|
||||
"必须走 _spin_until(ctypes QPC),否则 HTTP 路由会全排在 GIL 后面")
|
||||
print(" [OK] 自旋不持 GIL,HTTP 线程不受影响")
|
||||
|
||||
print("\n=== 13. P 核识别与钉核 ===")
|
||||
# 自旋要占满一个逻辑处理器,钉到 P 核(快核组)让节拍跑在单核性能最高的核上。
|
||||
# 分类逻辑单独测(不跑 1.6s 的真实探测),用本机实测到的两组数字喂它。
|
||||
def bits(m):
|
||||
return [b for b in range(64) if m >> b & 1]
|
||||
|
||||
# 本机 i7-14700K 的真实测量值:P 核 11.9~16.7ms(含被 SMT 拖累的),E 核 24.7~29.7ms
|
||||
real = ([(lp, 13.0 + (lp % 4)) for lp in range(16)] +
|
||||
[(lp, 25.0 + (lp % 5)) for lp in range(16, 28)])
|
||||
got = bits(S._classify_fast(real))
|
||||
assert got == list(range(16)), f"本机拓扑应识别出 P 核 0..15,实际 {got}"
|
||||
print(f" [OK] 真实混合拓扑 → P 核 {got[0]}..{got[-1]}(16 个)")
|
||||
|
||||
# 关键回归:固定倍数阈值会把慢一点的 P 核误判成 E 核,间隙法不会 ——
|
||||
# 被 SMT 拖累的 16.5ms 核必须仍留在 P 组里
|
||||
smt = [(lp, 11.0 if lp < 8 else 16.5) for lp in range(16)] + \
|
||||
[(lp, 28.0) for lp in range(16, 24)]
|
||||
got = bits(S._classify_fast(smt))
|
||||
assert got == list(range(16)), f"被 SMT 拖累的 P 核不该被踢出快核组,实际 {got}"
|
||||
print(" [OK] 被 SMT 拖慢的 P 核没被误判成 E 核")
|
||||
|
||||
# 纯对称 CPU(全同速)必须老实承认分不出来 → 返回 0 = 不钉
|
||||
flat = [(lp, 15.0 + lp * 0.01) for lp in range(8)]
|
||||
assert S._classify_fast(flat) == 0, "对称 CPU 不该硬分出一个'快核'集合"
|
||||
assert S._classify_fast([(0, 1.0), (1, 9.0)]) == 0, "样本太少不该下结论"
|
||||
only_one_fast = [(0, 10.0)] + [(lp, 30.0) for lp in range(1, 8)]
|
||||
assert S._classify_fast(only_one_fast) == 0, "快核只剩 1 个说明分错了"
|
||||
only_one_slow = [(lp, 10.0) for lp in range(7)] + [(7, 30.0)]
|
||||
assert S._classify_fast(only_one_slow) == 0, "慢核只剩 1 个说明分错了(离群噪声不是拓扑)"
|
||||
print(" [OK] 对称/样本不足/单核离群 都安全返回不钉核")
|
||||
|
||||
# 钉核只影响 PDO 线程自己,不能把整个进程(含 HTTP 线程)钉死
|
||||
S.g_pcore_mask = 0xF0
|
||||
S.g_pcore_probe_done.set()
|
||||
box = {}
|
||||
def _pinned():
|
||||
S.g_pcore_probe_done.wait(2.0)
|
||||
if S._pcore_pin_enabled() and S.g_pcore_probe_done.is_set() and S.g_pcore_mask:
|
||||
S._set_thread_affinity_mask(S.g_pcore_mask)
|
||||
box["m"] = S._get_thread_affinity_mask()
|
||||
th = threading.Thread(target=_pinned); th.start(); th.join(timeout=5)
|
||||
assert bits(box.get("m", 0)) == [4, 5, 6, 7], f"线程亲和性没生效: {box}"
|
||||
assert bits(S._get_thread_affinity_mask()) != [4, 5, 6, 7], \
|
||||
"主线程亲和性被改了 —— 钉核必须是线程级的"
|
||||
print(" [OK] 钉核是线程级的,HTTP 线程与主线程不受影响")
|
||||
S.g_pcore_mask = 0
|
||||
|
||||
print("\n=== 14. api_motor_start 的 OP 状态核实 ===")
|
||||
# 真机踩过的坑:给 api_motor_start 加了"读从站真实状态"的核实逻辑后,
|
||||
# 函数里出现了对 g_op_active 的赋值却没声明 global —— 整个路由直接抛
|
||||
# UnboundLocalError,电机页 EtherCAT 链路全挂。假从站自测原先都绕开这个
|
||||
# 函数(直接调 _motor_thread_start),所以完全没抓到,这里补上。
|
||||
S.g_master.slaves[0] = SparseSlave({2})
|
||||
S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN)
|
||||
S.g_op_active = True
|
||||
S._motor_spin = None
|
||||
r = S.api_motor_start({"period_ms": 2})
|
||||
assert r["ok"], f"已在 OP 时重复 start 不该失败: {r.get('log')}"
|
||||
assert r["spin"] is True, "2ms 档应自动自旋"
|
||||
print(f" [OK] 已在 OP 时 start 正常(spin={r['spin']},无 UnboundLocalError)")
|
||||
S._motor_thread_stop()
|
||||
|
||||
# 从站被看门狗踢出 OP 后,g_op_active 是过期缓存;必须现读状态发现并走重进,
|
||||
# 否则新循环每拍都撞"未进 OP",界面绿着却全是 --。
|
||||
# 这里只验证"检测到 + 走了重进流程",不验证重进成功 —— 完整 OP 序列要
|
||||
# config_init/config_map/config_dc/state_check/write_state/sdo_read/
|
||||
# set_watchdog/dc_sync/_fprd 一整套,假从站撑不起来,硬凑会变成在测桩本身。
|
||||
S.g_op_active = True
|
||||
S.g_master.slaves[0].state = 4 # SAFEOP
|
||||
r = S.api_motor_start({"period_ms": 4})
|
||||
joined = " / ".join(r.get("log") or [])
|
||||
assert "掉出 OP" in joined, f"没检测到从站已掉出 OP,仍拿过期标志空转: {joined[:160]}"
|
||||
assert not r["ok"], "重进失败时必须如实报错,不能假装启动成功"
|
||||
print(" [OK] 检测到从站掉出 OP 并走重进流程(不拿过期标志空转)")
|
||||
S.g_master.slaves[0].state = 8
|
||||
S._motor_thread_stop()
|
||||
S.g_op_active = True
|
||||
S._motor_spin = None
|
||||
|
||||
print("\n=== 15. 亚毫秒周期 + /api/motor/period 热调 ===")
|
||||
# 需求:内核级要能跑 250us(4kHz),且周期随时可调。守两条:
|
||||
# a) period 允许亚毫秒 float,并按 [MIN,MAX] 钳位;
|
||||
# b) 热调路由**不停循环**、也不进总线互斥表 —— 停循环 = 掐断喂狗,
|
||||
# 正是把真机板子弄楔死的那条路(见 api_motor_spin 注释)。
|
||||
assert "/api/motor/period" not in S._BUS_EXCLUSIVE_PATHS, \
|
||||
"周期热调绝不能进总线互斥表:dispatcher 停循环会掐断喂狗"
|
||||
|
||||
assert S._parse_period_ms({"period_ms": 0.25}) == 0.25
|
||||
assert S._parse_period_ms({"period_us": 500}) == 0.5
|
||||
assert S._parse_period_ms({"period_ms": 0.0001}) == S.MOTOR_PERIOD_MIN_MS
|
||||
assert S._parse_period_ms({"period_ms": 5000}) == S.MOTOR_PERIOD_MAX_MS
|
||||
assert S._parse_period_ms({"period_ms": 0}) is None
|
||||
assert S._parse_period_ms({"period_ms": float("nan")}) is None
|
||||
assert S._parse_period_ms({}) is None
|
||||
print(" [OK] period_ms/period_us 解析与钳位(0/NaN/缺省视为没给)")
|
||||
|
||||
# 热调速测:假从站的收发是纯内存操作(µs 级),这里量的是
|
||||
# "自旋+float 周期"这套机制能不能把循环真正驱动到 4kHz 档,
|
||||
# 不含真机 ~165us 的总线时间(那部分由 rate_probe 在板上量)。
|
||||
S.g_master.slaves[0] = SparseSlave({2, 5})
|
||||
S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN)
|
||||
S._motor_spin = None
|
||||
S._motor_thread_start(period_ms=4)
|
||||
time.sleep(0.15)
|
||||
r = S.api_motor_period({"period_ms": 0.25})
|
||||
assert r["ok"] and r["period_ms"] == 0.25, r
|
||||
assert r["spin"] is True, "0.25ms 周期在自动档下必须自旋(睡眠粒度睡不出)"
|
||||
assert S._motor_thread.is_alive(), "热调周期把循环弄停了"
|
||||
|
||||
def _rate(sample_s=1.0):
|
||||
t0 = time.perf_counter()
|
||||
c0 = (S._motor_snapshot() or {}).get("cycles", 0)
|
||||
time.sleep(sample_s)
|
||||
c1 = (S._motor_snapshot() or {}).get("cycles", 0)
|
||||
return (c1 - c0) / (time.perf_counter() - t0)
|
||||
|
||||
hz = _rate(1.0)
|
||||
assert hz > 3000, f"0.25ms 周期只跑出 {hz:.0f}Hz,机制没到 4kHz 档"
|
||||
snap = S._motor_snapshot()
|
||||
assert snap["period_ms"] == 0.25 and snap["slot_period_ms"] == 0.25, snap
|
||||
print(f" [OK] 热调 4ms→0.25ms 循环不停,实测 {hz:.0f}Hz(假从站 ≥3000)")
|
||||
|
||||
# 强制睡眠 + 亚毫秒周期 → 必须明确警告(睡眠粒度 > 目标周期,跑不到)
|
||||
S._motor_spin = False
|
||||
r = S.api_motor_period({"period_us": 250})
|
||||
assert r["ok"] and r["spin"] is False
|
||||
assert "内核睡眠" in " ".join(r["log"]), f"该警告的没警告: {r['log']}"
|
||||
S._motor_spin = None
|
||||
|
||||
# 换回长周期:自动档应退回内核睡眠(不白烧一个核)
|
||||
r = S.api_motor_period({"period_ms": 4})
|
||||
assert r["ok"] and r["spin"] is False
|
||||
assert S._motor_thread.is_alive()
|
||||
print(" [OK] 换回 4ms 自动退回内核睡眠,循环全程没停过")
|
||||
S._motor_thread_stop()
|
||||
|
||||
# float 周期也要能从 motor/start 一路传进来(前端首启路径)
|
||||
S._motor_spin = None
|
||||
assert S._motor_thread_start(period_ms=0.5) is True
|
||||
assert S._motor_period_ms == 0.5
|
||||
S._motor_thread_stop()
|
||||
S._motor_period_ms = 4
|
||||
S._motor_spin = None
|
||||
print(" [OK] motor/start 也收 float 周期(0.5ms)")
|
||||
|
||||
print("\n=== 16. 真机总线耗时下的网格收敛(4kHz 保底的忠实代理)===")
|
||||
# 第 15 项的假从站 send/receive 是 µs 级内存桩,**没建模真机 165µs 总线时间**,
|
||||
# 所以它证明不了"166us 工作 + 250us 网格"这个真机核心交互能不能收敛到 4000Hz。
|
||||
# 这里给收发注入按真机分段实测拆开的耗时(send 48us + receive 118us = 166us),
|
||||
# 且用 _spin_until 释放 GIL —— 和真机 pysoem 在 Npcap 调用期间放 GIL 的行为一致。
|
||||
# 这是不接硬件能产出的最强预测:工作 166us < 周期 250us,网格不该丢拍,
|
||||
# 平均率应收敛到 ~4000Hz;若收敛不了,说明缺口是结构性的,得上真机前就预警。
|
||||
BUS_SEND_US, BUS_RX_US = 48.0, 118.0
|
||||
|
||||
class BusCostMaster(FakeMaster):
|
||||
def send_processdata(self):
|
||||
S._spin_until(time.perf_counter() + BUS_SEND_US / 1e6)
|
||||
|
||||
def receive_processdata(self, timeout):
|
||||
S._spin_until(time.perf_counter() + BUS_RX_US / 1e6)
|
||||
return 3
|
||||
|
||||
S.g_master = BusCostMaster()
|
||||
S.g_master.slaves[0] = SparseSlave({2, 5})
|
||||
S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN)
|
||||
S._motor_spin = None
|
||||
S._motor_thread_start(period_ms=0.25)
|
||||
time.sleep(0.2)
|
||||
|
||||
def _rate(sample_s):
|
||||
t0 = time.perf_counter()
|
||||
c0 = (S._motor_snapshot() or {}).get("cycles", 0)
|
||||
time.sleep(sample_s)
|
||||
c1 = (S._motor_snapshot() or {}).get("cycles", 0)
|
||||
return (c1 - c0) / (time.perf_counter() - t0)
|
||||
|
||||
hz = _rate(1.5)
|
||||
# 分位数改读侧现算(_cyc_fresh),从 api_motor_state 拿顺带验证这条路径
|
||||
cyc = S.api_motor_state({}).get("cyc_us") or {}
|
||||
tot = (cyc.get("total") or {}).get("p50")
|
||||
print(f" 166us 总线负载下 250us 网格实测 {hz:.0f}Hz (cyc_us.total p50={tot}us)")
|
||||
# 工作 166us 远小于 250us 周期,网格不该丢拍 → 应收敛到接近 4000。
|
||||
# 阈值取 3700 留 GIL/调度噪声余量;真机若明显低于这个值才需要动 send/receive 合并。
|
||||
assert hz > 3700, \
|
||||
f"166us 工作负载下网格只收敛到 {hz:.0f}Hz,缺口是结构性的,真机大概率也到不了 4kHz"
|
||||
print(" [OK] 真机同等总线负载下网格仍收敛到 ~4kHz → 保底 4kHz 的强预测")
|
||||
# 防回归:分位数排序一旦爬回 PDO 循环,每第 8 拍必超时(真机实测 86.6%)。
|
||||
# 用源码级检查钉死,比任何数值断言都稳。
|
||||
import inspect
|
||||
assert "_cyc_stats(" not in inspect.getsource(S._pdo_worker), \
|
||||
"_cyc_stats 又回到 PDO 热循环了 —— 必须走读侧 _cyc_fresh"
|
||||
S._motor_thread_stop()
|
||||
S.g_master = FakeMaster()
|
||||
S._motor_period_ms = 4
|
||||
S._motor_spin = None
|
||||
|
||||
print("\n全部通过 ✅")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user