diff --git a/.gitignore b/.gitignore index 24dcacd..42f609f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..f943703 --- /dev/null +++ b/MIGRATION.md @@ -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 依赖)。 diff --git a/README.md b/README.md index 5a91940..91a84a1 100644 --- a/README.md +++ b/README.md @@ -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 适配器 | diff --git a/build_native.bat b/build_native.bat index 526f351..76987d9 100644 --- a/build_native.bat +++ b/build_native.bat @@ -1,14 +1,19 @@ -@echo off -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 -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 -if errorlevel 1 exit /b 1 -echo [OK] build_native\ecat_native.exe +@echo off +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 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 diff --git a/ecat_app.vbs b/ecat_app.vbs deleted file mode 100644 index 98af173..0000000 --- a/ecat_app.vbs +++ /dev/null @@ -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 diff --git a/ecat_native.c b/ecat_native.c index 688bff2..b9c205c 100644 --- a/ecat_native.c +++ b/ecat_native.c @@ -1,31 +1,95 @@ -/* ecat_native.c — 原型 A:原生 PDO 守护进程(SOEM 直用,无 Python/GIL) +/* ecat_native.c — 原生 EtherCAT 守护进程(迁移阶段 4:全路由 + 生命周期契约) * - * 目的:验证"换语言"能把 PDO 循环的天花板从 Python 的 ~5kHz 抬到多高、 - * 尾抖动能多干净。总线核心收发本来就是 C(pysoem 只是 SOEM 的包装), - * 这里把热循环从解释器里拿出来,其余一切不动。 + * 阶段 1:生命周期状态机 + 静态托管(已真机验证)。 + * 阶段 2:PDO 循环接管 CAN-over-EtherCAT 桥 —— 命令队列泵入(单帧+批量区共用 seq 沿)、 + * 电机状态窗口自适应轮转(cover_bases + 发现窗)、批量接收回收(rx log)、 + * 完整快照(motors/bridge/xrx/rx_batch/head)。字节布局与 ecat_server.py + * 逐字节对齐(布局注释见 Python 686-705 行 / ethercat_can_bridge.h)。 + * 阶段 3:配置/维护面 —— SDO 读写、EEPROM 读/写/校验、flash(默认镜像在构建期由 + * gen_eeprom.py 生成 eeprom_image.h 嵌入,源仍是 gen_eeprom 的自校验断言)、 + * sync_diag(速率基线与 Python _sync_diag_prev 同契约:op 进/出清零)。 + * 全部走独占总线路由:先停 PDO 循环再碰邮箱/EEPROM,镜像 + * Python _BUS_EXCLUSIVE_PATHS —— 邮箱与周期数据并发是历史上邮箱楔死的诱因。 * - * 契约:复刻 ecat_server.py 的 3 条热路由(POST /api/motor/state|period|dbg), - * 只绑 127.0.0.1、无 CORS —— 与 Python 服务同口径,cadence_diag/rate_probe - * 直接 --base http://127.0.0.1:8091 就能打过来。其余路由仍留在 Python。 + * 契约:复刻 ecat_server.py 路由与 JSON 口径,只绑 127.0.0.1、无 CORS。 + * 网卡独占 → 与 Python 不同时跑(big-bang 切换,见 MIGRATION.md)。 * - * 时间线元组与 Python 完全同构:(t0, ts, tm, tr, te, k) - * t0=循环顶 ts=send 前(原生无锁等待,≈t0) tm=send 后 tr=receive 后 - * te=循环底(跳拍判定时刻) k=本拍网格跳过数 - * 时刻用 QueryPerformanceCounter 秒 —— 与 Python perf_counter 同一时间基 - * (实测差 ≤6µs),cadence_diag 的差分口径不变。 + * 时间线元组与 Python 同构:(t0, ts, tm, tr, te, k),QPC 秒(与 perf_counter 同基)。 */ #include #include #include +#include #include #include +#include #include #include #include "ethercat.h" -#define RING 2048 /* 与 Python _cyc_t maxlen 同 */ +/* SII 默认烧录镜像:构建期由 gen_eeprom.py 的 stdout 生成(自校验断言不过则构建失败)。 + * 定义 ESI_EEPROM_IMAGE[4096] + ESI_EEPROM_SIZE,见 build_native.bat。 */ +#include "eeprom_image.h" + +#define RING 2048 #define IO_MAP_SIZE 4096 -#define STATS_N 400 /* state 里 cyc_us 分位数的样本窗口,同 _cyc_stats(n=400) */ +#define STATS_N 400 +#define BUSY_TIMEOUT 3000 +#define EEPROM_SIZE 4096 /* AT24C32,与 ecat_server.py 常量一致 */ +#define SDO_BUF_MAX 1024 +#define EE_WORDS (EEPROM_SIZE / 2) +/* flash 字节序探针用的临时字。必须在 SII END 之后的真 0xFF 填充区, + * 详见 /api/flash 里的注释(老脚本的 0x0200 在 TxPDO 类别内部)。 */ +#define EE_SCRATCH_WORD 0x0400 + + +/* ── CAN 桥线格式常量(与 ecat_server.py 一一对应)── */ +#define PDO_OUT_LEN 384 +#define PDO_IN_LEN 640 +#define CAN_FD_DATA_MAX 64 +#define CAN_TX_DATA_MAX 32 /* can/send 单帧区上限(Python 的越界判据) */ +#define CAN_RX_DATA_MAX 8 +#define CAN_XRX_DATA_MAX 32 +#define TX_OFF_WIN 8 +#define TX_OFF_DATA 16 +#define FRM_OFF_CH 0 +#define FRM_OFF_DLC 1 +#define FRM_OFF_FLAGS 2 +#define FRM_OFF_RES 3 +#define FRM_OFF_ID 4 +#define FRM_HDR_SIZE 8 +#define TXB_OFF_COUNT 48 +#define TXB_OFF_FLAGS 49 +#define TXB_OFF_ITEMS 52 +#define TXB_BYTES (PDO_OUT_LEN - TXB_OFF_ITEMS) /* 332 */ +#define MOTW_OFF_ACTIVE 32 +#define MOTW_OFF_BOOT 33 +#define MOTW_OFF_WIN_BASE 34 +#define MOTW_OFF_WIN_COUNT 35 +#define MOTW_OFF_GLOBAL_SEQ 36 +#define MOTW_OFF_SLOTS 40 +#define MOT_SLOT_SIZE 40 +#define MOT_SLOTS_PER_PDO 7 +#define MOT_STATUS_DLC 32 +#define XRX_OFF_CH 320 +#define XRX_OFF_DLC 321 +#define XRX_OFF_FLAGS 322 +#define XRX_OFF_ID 324 +#define XRX_OFF_DATA 328 +#define RXB_OFF_COUNT 360 +#define RXB_OFF_LOST 361 +#define RXB_OFF_PENDING 362 +#define RXB_OFF_ITEMS 364 +#define RXB_BYTES (PDO_IN_LEN - RXB_OFF_ITEMS) /* 276 */ +#define RXB_MAX_FRAMES (RXB_BYTES / (FRM_HDR_SIZE + CAN_FD_DATA_MAX)) +#define MOTOR_SLOT_COUNT 27 +#define MOTOR_WIN_BASE_MAX (MOTOR_SLOT_COUNT - MOT_SLOTS_PER_PDO) /* 20 */ +#define MOTOR_DISCOVERY_EVERY 64 +#define N_FULL_BASES 4 /* range(0,27,7) */ +static const int g_full_bases[N_FULL_BASES] = { 0, 7, 14, 21 }; +/* 发现基址 = min(0/7/14/21, 20) 去重 = [0,7,14,20](与 MOTOR_DISCOVERY_BASES 同式) */ +static const int g_discovery_bases[4] = { 0, 7, 14, 20 }; +#define N_DISC_BASES 4 /* ── 计时原语 ── */ static double g_qpc2s; @@ -38,52 +102,416 @@ static inline double now_s(void) static inline void spin_until(double deadline) { while (now_s() < deadline) - YieldProcessor(); /* PAUSE:SMT 兄弟友好,不烧功耗 */ + YieldProcessor(); } -/* ── 共享状态 ── */ -typedef struct { double t0, ts, tm, tr, te; int k; } beat_t; +/* ── 帧与队列 ── */ +typedef struct { int ch; uint32 id; int dlc; uint8 data[CAN_FD_DATA_MAX]; } frame_t; -static beat_t g_ring[RING]; -static int g_ring_i; /* 写指针:仅 PDO 线程动 */ -static long long g_total; /* 累计拍数 */ -static long long g_skip_total; /* 累计网格跳过拍 */ -static CRITICAL_SECTION g_ring_lock; /* 保护 ring/total/skip 的读侧 */ -static CRITICAL_SECTION g_cfg_lock; /* 保护 g_period_ms */ -static double g_period_ms = 0.25; +#define CMD_Q 512 +static frame_t g_cmd_q[CMD_Q]; +static int g_cmd_head, g_cmd_len; +static CRITICAL_SECTION g_cmd_lock; + +#define RX_Q 2048 +#define RX_BATCH_CAP 34 +static frame_t g_rx_q[RX_Q]; +static int g_rx_valid[RX_Q]; +static int g_rx_head, g_rx_len; +static CRITICAL_SECTION g_rx_lock; + +/* ── 解码后的桥状态(PDO 线程写,路由读)── */ +typedef struct { uint8 node, ch, online, dlc; uint32 seq; uint8 data[MOT_STATUS_DLC]; } slot_t; +typedef struct { int valid; uint8 ack, ch, flags, dlc; uint32 id, rx_count; + uint8 tx_result, backlog; uint16 tx_count, drop; uint8 data[CAN_RX_DATA_MAX]; } bridge_t; +typedef struct { int valid; int ch, dlc; uint32 id; int n; uint8 data[CAN_XRX_DATA_MAX]; } xrx_t; +typedef struct { int count, lost, pending, n; frame_t fr[RXB_MAX_FRAMES]; } rxb_t; + +static slot_t g_slots[MOTOR_SLOT_COUNT]; +static struct { int valid, active, boot, wbase, wcount; uint32 gseq; } g_head; +static bridge_t g_bridge; +static xrx_t g_xrx; +static rxb_t g_rxb; +static int g_bases[4], g_n_bases; /* 当前生效的自适应基址 */ +static int g_win; +static int g_sent_seq, g_sent_frames; +static CRITICAL_SECTION g_state_lock; + +/* ── 总线状态机 ── */ +static LONG g_bus_open, g_op, g_loop_running; static volatile LONG g_stop; +static HANDLE g_pdo_th; +static CRITICAL_SECTION g_bus_lock; + +/* ── OP 态应用层楔死看门狗(机理与判据见 pdo_worker 内长注释)── + * 检测在循环线程(每周期比对输入窗口回显),恢复在路由侧(motor/state,UI 每 + * 50ms 轮询):循环线程不能自己抢 g_bus_lock——互斥路由 loop_stop 等线程退出 + * 时,线程若又在等锁就死锁。TryEnter:有互斥路由在跑就让过,下一拍再试。 */ +static volatile LONG g_wedge_at; /* 检测时刻 (LONG)now_s();0=无楔死 */ +static volatile LONG g_wedge_cooldown; /* 恢复后冷却截止时刻,防反复重拉 */ +static long long g_wedge_recoveries; /* 已自动恢复次数(motor/state 暴露为 wr) */ + +/* ── 时间线环 + 计数 + 配置 ── */ +typedef struct { double t0, ts, tm, tr, te; int k; } beat_t; +static beat_t g_ring[RING]; +static int g_ring_i; +static long long g_total; +static long long g_skip_total; +static CRITICAL_SECTION g_ring_lock; +static CRITICAL_SECTION g_cfg_lock; +static double g_period_ms = 0.25; static volatile LONG g_wkc; static long long g_expected_wkc; static int g_slavecount; static char g_slave_names[16][64]; +static char g_desc[256]; static uint8 g_iomap[IO_MAP_SIZE]; -static ULONGLONG g_pcore_mask; /* --mask,0=不钉 */ +static ULONGLONG g_pcore_mask; +static char g_webdir[512] = "src/web"; -/* ── PDO 线程:网格 + 自旋 + 分段计时,逻辑与 _pdo_worker 一一对应 ── */ +/* 输出 PDO 暂存:跨周期保持(固件按 seq 变化沿去重,保持不变是为了 seq 可回显) */ +static uint8 g_can_out[PDO_OUT_LEN]; +static int g_can_seq; + +/* sync_diag 速率基线(对应 Python _sync_diag_prev)。定义在此因 do_op_* 也要清零; + * 使用见文件尾部阶段 3 路由。 */ +static int g_syncprev_valid; +static uint32 g_syncprev_sync0; +static double g_syncprev_ts; + +/* ── 小工具 ── */ +static void le32put(uint8 *p, uint32 v) { p[0] = (uint8)v; p[1] = (uint8)(v >> 8); p[2] = (uint8)(v >> 16); p[3] = (uint8)(v >> 24); } +static uint32 le32get(const uint8 *p) { return (uint32)p[0] | ((uint32)p[1] << 8) | ((uint32)p[2] << 16) | ((uint32)p[3] << 24); } +static int cmp_int(const void *a, const void *b) { return *(const int *)a - *(const int *)b; } +static int cmp_d(const void *a, const void *b) +{ + double x = *(const double *)a, y = *(const double *)b; + return (x > y) - (x < y); +} + +/* ── CAN 桥编码(与 _pack_tx_batch/_batch_capacity/_fill_can_out 同语义)── */ +static int batch_capacity(const frame_t *fr, int n) +{ + int off = TXB_OFF_ITEMS, i, c = 0, dlc; + for (i = 0; i < n; i++) { + dlc = fr[i].dlc > CAN_FD_DATA_MAX ? CAN_FD_DATA_MAX : fr[i].dlc; + if (off + FRM_HDR_SIZE + dlc > PDO_OUT_LEN) break; + off += FRM_HDR_SIZE + dlc; + c++; + } + return c; +} +static int pack_tx_batch(const frame_t *fr, int n) +{ + int off = TXB_OFF_ITEMS, i, c = 0, dlc; + g_can_out[TXB_OFF_COUNT] = 0; + g_can_out[TXB_OFF_FLAGS] = 0; /* 默认 stop_on_error=False */ + for (i = 0; i < n; i++) { + dlc = fr[i].dlc > CAN_FD_DATA_MAX ? CAN_FD_DATA_MAX : fr[i].dlc; + if (off + FRM_HDR_SIZE + dlc > PDO_OUT_LEN) break; + g_can_out[off + FRM_OFF_CH] = (uint8)(fr[i].ch & 0xFF); + g_can_out[off + FRM_OFF_DLC] = (uint8)dlc; + g_can_out[off + FRM_OFF_FLAGS] = 0; + g_can_out[off + FRM_OFF_RES] = 0; + le32put(g_can_out + off + FRM_OFF_ID, fr[i].id); + memset(g_can_out + off + FRM_HDR_SIZE, 0, dlc); + memcpy(g_can_out + off + FRM_HDR_SIZE, fr[i].data, dlc); + off += FRM_HDR_SIZE + dlc; + c++; + } + g_can_out[TXB_OFF_COUNT] = (uint8)c; + return c; +} +static int fill_can_out(const frame_t *first, const frame_t *batch, int nbatch, + int clear_stats) +{ + uint8 win; + g_can_seq = (g_can_seq + 1) & 0xFF; + if (g_can_seq == 0) g_can_seq = 1; /* 0 与固件"未初始化"无法区分,跳过 */ + win = g_can_out[TX_OFF_WIN]; /* 窗口由循环独占推进,发送路径保持 */ + memset(g_can_out, 0, sizeof g_can_out); + g_can_out[0] = (uint8)g_can_seq; + g_can_out[1] = (uint8)first->ch; + g_can_out[2] = (uint8)first->dlc; + g_can_out[3] = clear_stats ? 0x01 : 0x00; + le32put(g_can_out + 4, first->id); + g_can_out[TX_OFF_WIN] = win; + if (first->dlc > 0) + memcpy(g_can_out + TX_OFF_DATA, first->data, first->dlc); + if (batch && nbatch > 0) pack_tx_batch(batch, nbatch); + return g_can_seq; +} + +/* ── 窗口覆盖(_cover_bases 同算法;空集回退全表轮扫)── */ +static int cover_bases(int bases[4]) +{ + int todo[MOTOR_SLOT_COUNT], n = 0, i, k = 0, base, end; + for (i = 0; i < MOTOR_SLOT_COUNT; i++) + if (g_slots[i].node != 0) todo[n++] = i; + if (n == 0) { memcpy(bases, g_full_bases, sizeof g_full_bases); return N_FULL_BASES; } + qsort(todo, n, sizeof(int), cmp_int); + i = 0; + while (i < n && k < 4) { + base = todo[i] > MOTOR_WIN_BASE_MAX ? MOTOR_WIN_BASE_MAX : todo[i]; + end = base + MOT_SLOTS_PER_PDO; + bases[k++] = base; + while (i < n && todo[i] < end) i++; + } + return k; +} + +/* ── 输入解码(字节布局逐一对齐 Python 的 decode 族)── */ + +/* 段1 桥状态解码,从 decode_input 拆出:can/send、can/state 的同步 pump 路径 + * 只要桥字段(不碰槽/批量区),对应 Python 的 _can_decode_input。 */ +static void bridge_decode(const uint8 *inp, int ilen, bridge_t *b) +{ + int dlc; + memset(b, 0, sizeof *b); + if (ilen < 32) return; + b->valid = 1; + b->ack = inp[0]; + b->ch = inp[1]; + b->dlc = inp[2]; + b->flags = inp[3]; + b->id = le32get(inp + 4); + b->rx_count = le32get(inp + 16); + b->tx_result = inp[20]; + b->backlog = inp[21]; + b->tx_count = (uint16)(inp[22] | (inp[23] << 8)); + b->drop = (uint16)(inp[24] | (inp[25] << 8)); + dlc = inp[2] > CAN_RX_DATA_MAX ? CAN_RX_DATA_MAX : inp[2]; + memcpy(b->data, inp + 8, dlc); +} + +/* 就地驱动 n 个 PDO 周期(等价 Python _can_pump):把当前 g_can_out 推出去、 + * 读回输入并解桥。只在循环未运行、由 HTTP 线程持 g_bus_lock 独占总线时调用 —— + * 与 worker 并发踩周期是两个写者抢 s.output,正是独占路由要防的事。 */ +static int pump_cycles(int n, bridge_t *b) +{ + uint8 inp[PDO_IN_LEN]; + int ol, ilen = 0, i, wkc = 0; + if (b) memset(b, 0, sizeof *b); + ol = ec_slave[1].Obytes; if (ol > PDO_OUT_LEN) ol = PDO_OUT_LEN; + for (i = 0; i < (n < 1 ? 1 : n); i++) { + if (ol > 0) memcpy(ec_slave[1].outputs, g_can_out, ol); + ec_send_processdata(); + wkc = ec_receive_processdata(EC_TIMEOUTRET); + ilen = ec_slave[1].Ibytes; if (ilen > (int)sizeof inp) ilen = (int)sizeof inp; + if (ilen > 0) memcpy(inp, ec_slave[1].inputs, ilen); + Sleep(2); + } + if (b) bridge_decode(inp, ilen, b); + return wkc; +} + +static void decode_input(const uint8 *inp, int ilen) +{ + int base, count, i, off, dlc, slot; + frame_t newfr[RXB_MAX_FRAMES]; + int nnew = 0, nfr; + int frflags[RXB_MAX_FRAMES]; + + bridge_decode(inp, ilen, &g_bridge); + if (ilen >= MOTW_OFF_SLOTS + MOT_SLOTS_PER_PDO * MOT_SLOT_SIZE) { + g_head.valid = 1; + g_head.active = inp[MOTW_OFF_ACTIVE]; + g_head.boot = inp[MOTW_OFF_BOOT]; + base = inp[MOTW_OFF_WIN_BASE]; + count = inp[MOTW_OFF_WIN_COUNT] > MOT_SLOTS_PER_PDO ? MOT_SLOTS_PER_PDO : inp[MOTW_OFF_WIN_COUNT]; + g_head.wbase = base; + g_head.wcount = count; + g_head.gseq = le32get(inp + MOTW_OFF_GLOBAL_SEQ); + for (i = 0; i < count; i++) { + slot = base + i; + off = MOTW_OFF_SLOTS + i * MOT_SLOT_SIZE; + if (slot >= MOTOR_SLOT_COUNT) break; + if (inp[off] == 0) continue; /* 空槽无意义 */ + g_slots[slot].node = inp[off]; + g_slots[slot].ch = inp[off + 1]; + g_slots[slot].online = inp[off + 2] ? 1 : 0; + g_slots[slot].dlc = inp[off + 3]; + g_slots[slot].seq = le32get(inp + off + 4); + memcpy(g_slots[slot].data, inp + off + 8, MOT_STATUS_DLC); + } + } + if (ilen >= XRX_OFF_DATA + CAN_XRX_DATA_MAX && (inp[XRX_OFF_FLAGS] & 0x01)) { + g_xrx.valid = 1; + g_xrx.ch = inp[XRX_OFF_CH]; + g_xrx.dlc = inp[XRX_OFF_DLC]; + g_xrx.id = le32get(inp + XRX_OFF_ID); + g_xrx.n = g_xrx.dlc > CAN_XRX_DATA_MAX ? CAN_XRX_DATA_MAX : g_xrx.dlc; + memset(g_xrx.data, 0, sizeof g_xrx.data); + memcpy(g_xrx.data, inp + XRX_OFF_DATA, g_xrx.n); + } else { + g_xrx.valid = 0; + } + g_rxb.count = g_rxb.lost = g_rxb.pending = g_rxb.n = 0; + if (ilen > RXB_OFF_ITEMS) { + off = RXB_OFF_ITEMS; + g_rxb.count = inp[RXB_OFF_COUNT]; + g_rxb.lost = inp[RXB_OFF_LOST]; + g_rxb.pending = inp[RXB_OFF_PENDING]; + nfr = g_rxb.count; + /* 不信任对端计数:边界判据始终是头+数据完整落在 PDO 内 */ + for (i = 0; i < nfr && nnew < RXB_MAX_FRAMES; i++) { + if (off + FRM_HDR_SIZE > ilen) break; + dlc = inp[off + FRM_OFF_DLC]; + if (dlc > CAN_FD_DATA_MAX || off + FRM_HDR_SIZE + dlc > ilen) break; + memset(&newfr[nnew], 0, sizeof newfr[nnew]); + newfr[nnew].ch = inp[off + FRM_OFF_CH]; + newfr[nnew].dlc = dlc; + newfr[nnew].id = le32get(inp + off + FRM_OFF_ID); + memcpy(newfr[nnew].data, inp + off + FRM_HDR_SIZE, dlc); + frflags[nnew] = (inp[off + FRM_OFF_FLAGS] & 0x01) ? 1 : 0; + if (g_rxb.n < RXB_MAX_FRAMES) { + g_rxb.fr[g_rxb.n] = newfr[nnew]; + g_rxb.n++; + } + nnew++; + off += FRM_HDR_SIZE + dlc; + } + } + if (nnew > 0) { + EnterCriticalSection(&g_rx_lock); + for (i = 0; i < nnew; i++) { + if (g_rx_len >= RX_Q) break; /* 有界:满了丢最旧语义由 maxlen 近似 */ + { + int t = (g_rx_head + g_rx_len) % RX_Q; + g_rx_q[t] = newfr[i]; + g_rx_valid[t] = frflags[i]; + g_rx_len++; + } + } + LeaveCriticalSection(&g_rx_lock); + } +} + +/* ── PDO 线程:与 _pdo_worker 对应的功能循环 ── */ static DWORD WINAPI pdo_worker(LPVOID arg) { double per, t0, ts, tm, tr, now, te, next_t = 0; - int have_next = 0, k, i, wkc; + int have_next = 0, k, wkc, out_len, in_len; + int bases[4], n_bases, bi = 0, discovery = 0, disco_base = 0, sweep_left; + int n_known_prev = 0; + double wedge_change; /* 楔死看门狗:回显上次变化时刻 */ + uint8 wedge_echo = 0xFFu; /* 楔死看门狗:上次窗口回显值 */ + uint8 inp[PDO_IN_LEN]; + frame_t first, batch[32], pend[32]; SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); if (g_pcore_mask) SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR)g_pcore_mask); + EnterCriticalSection(&g_state_lock); + n_bases = cover_bases(bases); + memcpy(g_bases, bases, n_bases * sizeof(int)); + g_n_bases = n_bases; + LeaveCriticalSection(&g_state_lock); + sweep_left = n_bases; + bi = 0; + g_win = bases[0]; + wedge_change = now_s(); + InterlockedExchange(&g_wedge_at, 0); + while (!InterlockedOr(&g_stop, 0)) { t0 = now_s(); EnterCriticalSection(&g_cfg_lock); per = g_period_ms / 1000.0; LeaveCriticalSection(&g_cfg_lock); + if (per <= 0) per = 0.004; if (!have_next) { next_t = t0 + per; have_next = 1; } - ts = now_s(); /* 原生无锁无填充 → pre≈0 */ + /* 1) 泵命令队列:首帧段1,其余按字节容量打包段2,共用一个 seq 沿 */ + { + int take = 0; + EnterCriticalSection(&g_cmd_lock); + if (g_cmd_len > 0) { + int j, avail; + first = g_cmd_q[g_cmd_head]; + g_cmd_head = (g_cmd_head + 1) % CMD_Q; + g_cmd_len--; + avail = g_cmd_len < 32 ? g_cmd_len : 32; + for (j = 0; j < avail; j++) pend[j] = g_cmd_q[(g_cmd_head + j) % CMD_Q]; + take = batch_capacity(pend, avail); + for (j = 0; j < take; j++) { + batch[j] = g_cmd_q[g_cmd_head]; + g_cmd_head = (g_cmd_head + 1) % CMD_Q; + g_cmd_len--; + } + g_sent_seq = fill_can_out(&first, batch, take, 0); + g_sent_frames = 1 + take; + } else { + g_sent_frames = 0; + } + LeaveCriticalSection(&g_cmd_lock); + } + /* 2) 窗口基址写段1,输出拷进过程数据镜像,交换 */ + g_can_out[TX_OFF_WIN] = (uint8)(g_win & 0xFF); + out_len = (int)ec_slave[1].Obytes; + if (out_len > 0) { + int ol = out_len > PDO_OUT_LEN ? PDO_OUT_LEN : out_len; + memcpy(ec_slave[1].outputs, g_can_out, ol); + } + + ts = now_s(); ec_send_processdata(); tm = now_s(); wkc = ec_receive_processdata(EC_TIMEOUTRET); tr = now_s(); InterlockedExchange(&g_wkc, wkc); - /* 绝对网格截止点 + 超期跳过(不挪锚点),与 Python 同 */ + in_len = (int)ec_slave[1].Ibytes; + if (in_len > PDO_IN_LEN) in_len = PDO_IN_LEN; + memcpy(inp, ec_slave[1].inputs, in_len); + EnterCriticalSection(&g_state_lock); + decode_input(inp, in_len); + { /* 出现没见过的槽 → 立刻重算覆盖;首圈扫完前保持全表轮扫 */ + int n_known = 0, i; + for (i = 0; i < MOTOR_SLOT_COUNT; i++) + if (g_slots[i].node != 0) n_known++; + if (n_known != n_known_prev) { + n_known_prev = n_known; + if (sweep_left <= 0) { + n_bases = cover_bases(bases); + if (bi >= n_bases) bi = 0; + memcpy(g_bases, bases, n_bases * sizeof(int)); + g_n_bases = n_bases; + } + } + } + /* ── OP 态应用层楔死看门狗 ── + * 固件 dc_app.c 1.5.48/1.5.49 注释自述的已知问题:INIT↔OP 循环概率性 + * 楔死 PDI_Isr 的 SM2 分支——ESC 硬件层一切正常(wkc=3、AL=OP、SDO/邮箱 + * 活),但 ECAT_Application→EcatPdo_Process→buildInput 不再被调:输入区 + * 停更、ack 不回显。固件自愈"仅 SAFEOP 触发"(其注释原文),OP 态无人管, + * 数据就此永久冻结——实测"切到 ECAT 一瞬间有数据然后没了"即此。 + * 判据:主站每周期轮转输出窗口字节 WIN(自适应窗 + 每 64 周期发现窗), + * 健康从站输入段 2 的 win_base 回显必然跟着变;单基窗时发现窗可能恰与 + * 自适应窗同值,回显最久 128 周期才变,阈值取 128 周期+2s 余量。回显冻结 + * 超过阈值即楔死。与电机流量无关(电机静默时回显照样轮转),零误报。 */ + if (g_op && g_head.valid && in_len >= MOTW_OFF_SLOTS) { + if ((uint8)g_head.wbase != wedge_echo) { + wedge_echo = (uint8)g_head.wbase; + wedge_change = t0; + } else if (t0 - wedge_change > 128.0 * per + 2.0 + && !InterlockedOr(&g_wedge_at, 0) + && t0 >= (double)InterlockedOr(&g_wedge_cooldown, 0)) { + InterlockedExchange(&g_wedge_at, (LONG)t0); + printf("[native][WEDGE] OP 态输入回显冻结 %.1fs(win_echo=%u ack=%u " + "gseq=%u rx=%u wkc=%d period=%gms)——固件应用层楔死,待路由侧自动重拉\n", + t0 - wedge_change, (unsigned)wedge_echo, + (unsigned)g_bridge.ack, (unsigned)g_head.gseq, + (unsigned)g_bridge.rx_count, (int)InterlockedOr(&g_wkc, 0), + g_period_ms); + fflush(stdout); + } + } else { + wedge_echo = 0xFFu; + wedge_change = t0; + } + LeaveCriticalSection(&g_state_lock); + now = now_s(); k = 0; if (next_t <= now) { @@ -91,16 +519,46 @@ static DWORD WINAPI pdo_worker(LPVOID arg) } te = now; - EnterCriticalSection(&g_ring_lock); - i = g_ring_i; - g_ring_i = (i + 1) % RING; - g_ring[i].t0 = t0; g_ring[i].ts = ts; g_ring[i].tm = tm; - g_ring[i].tr = tr; g_ring[i].te = te; g_ring[i].k = k; - g_total++; - g_skip_total += k; - LeaveCriticalSection(&g_ring_lock); + { + int ri; + EnterCriticalSection(&g_ring_lock); + ri = g_ring_i; + g_ring_i = (ri + 1) % RING; + g_ring[ri].t0 = t0; g_ring[ri].ts = ts; g_ring[ri].tm = tm; + g_ring[ri].tr = tr; g_ring[ri].te = te; g_ring[ri].k = k; + g_total++; + g_skip_total += k; + LeaveCriticalSection(&g_ring_lock); + } - if (k == 0) { /* 没超期才等下一拍;超期直接落下去补 */ + /* 3) 窗口推进:首圈全扫 → 自适应轮转,每 64 周期插一个发现窗 */ + if (sweep_left > 0) { + sweep_left--; + if (sweep_left == 0) { + /* 首圈扫完 → 按已发现的槽重算覆盖(与 Python sweep 结束处 + `bases = _cover_bases(slots.keys())` 一致;不能读 g_bases —— + 首圈期间 fresh 重算被 sweep_left<=0 门挡着,g_bases 还是全表) */ + EnterCriticalSection(&g_state_lock); + n_bases = cover_bases(bases); + memcpy(g_bases, bases, n_bases * sizeof(int)); + g_n_bases = n_bases; + LeaveCriticalSection(&g_state_lock); + bi = 0; + g_win = bases[0]; + } else { + bi = (bi + 1) % n_bases; + g_win = bases[bi]; + } + } else if (++discovery >= MOTOR_DISCOVERY_EVERY) { + discovery = 0; + g_win = g_discovery_bases[disco_base]; + disco_base = (disco_base + 1) % N_DISC_BASES; + } else { + bi = (bi + 1) % n_bases; + g_win = bases[bi]; + } + + if (k == 0) { spin_until(next_t); next_t += per; } @@ -108,12 +566,292 @@ static DWORD WINAPI pdo_worker(LPVOID arg) return 0; } -/* ── 分位数(读侧现算,同 _cyc_fresh 的口径)── */ -static int cmp_d(const void *a, const void *b) +static void loop_stop(void) { - double x = *(const double *)a, y = *(const double *)b; - return (x > y) - (x < y); + if (g_loop_running) { + InterlockedExchange(&g_stop, 1); + if (g_pdo_th) { WaitForSingleObject(g_pdo_th, BUSY_TIMEOUT); CloseHandle(g_pdo_th); g_pdo_th = NULL; } + g_loop_running = 0; + InterlockedExchange(&g_stop, 0); + } } +static void loop_start(void) +{ + if (!g_loop_running) { + InterlockedExchange(&g_stop, 0); + g_pdo_th = CreateThread(NULL, 0, pdo_worker, NULL, 0, NULL); + g_loop_running = 1; + } +} + +static void do_close(void); +static int logs_add(char *logs, int cap, int *lp, const char *fmt, ...); + +/* ── 总线 bring-up ── */ +static int do_open(const char *substr, char *out, int osz) +{ + ec_adaptert *ad, *pick = NULL; + for (ad = ec_find_adapters(); ad; ad = ad->next) + if (strstr(ad->desc, substr) || strstr(ad->name, substr)) { pick = ad; break; } + if (!pick) { snprintf(out, osz, "没有找到描述包含 '%s' 的适配器", substr); return 0; } + if (g_bus_open) { ec_close(); g_bus_open = 0; } + if (!ec_init(pick->name)) { snprintf(out, osz, "ec_init 失败: %s", pick->name); return 0; } + /* 预清理:广播 INIT|ACK 清残留错误位(SSC 对带错误且不含 ACK 的请求直接忽略) */ + ec_slave[0].state = EC_STATE_INIT | EC_STATE_ACK; + ec_writestate(0); + ec_statecheck(0, EC_STATE_INIT, 1000000); + if (ec_config_init(FALSE) <= 0) { + ec_close(); + snprintf(out, osz, "总线上没有发现从站——检查 DCU 板供电、网线"); + return 0; + } + memset(g_can_out, 0, sizeof g_can_out); + memset(g_slots, 0, sizeof g_slots); + memset(&g_head, 0, sizeof g_head); + memset(&g_bridge, 0, sizeof g_bridge); + memset(&g_xrx, 0, sizeof g_xrx); + g_bus_open = 1; + g_slavecount = ec_slavecount; + snprintf(g_desc, sizeof g_desc, "%s", pick->desc); + snprintf(out, osz, "已连接 %s(枚举 %d 从站,停在 PREOP)", pick->desc, ec_slavecount); + return 1; +} +/* PREOP→(DC)→SAFEOP→OP 全流程,镜像 Python _require_op_ready: + * 入口无条件预清理、AL Control 一律带 ACK、free-run 清残留 DC 激活、进 OP 边发边写。 + * dc_cycle_us=0 保持 free-run。日志逐行追加进调用方缓冲(对应 Python 的多行 log[])。 */ +static int do_op_start(int dc_cycle_us, int dc_shift_us, char *logs, int cap, int *lsz) +{ + int i; + char mode[48]; + g_syncprev_valid = 0; /* 新一轮运行,速率基线清零(Python api_op_start 同款) */ + if (!g_bus_open) { logs_add(logs, cap, lsz, "未 open,先调 /api/open"); return 0; } + + /* 0) 预清理:回 INIT + config_init 重枚举(等效断开重连)。Python 的实测结论 + * (2026-09-03 01:49):同一连接反复进出 OP 而不重枚举,从站邮箱/PDI 通路逐轮 + * 退化,config_map 内 SOEM CoE 分段读可能吃到楔死邮箱的异常帧 → 原生崩溃。 */ + logs_add(logs, cap, lsz, "预清理:回 INIT + config_init 重枚举(等效断开重连,防楔死邮箱上 config_map 原生崩溃)…"); + ec_slave[0].state = EC_STATE_INIT | EC_STATE_ACK; + ec_writestate(0); + ec_statecheck(0, EC_STATE_INIT, 1000000); + if (ec_config_init(FALSE) <= 0) + logs_add(logs, cap, lsz, "WARN: 预清理重枚举没找到从站(供电/网线?),继续原序列"); + + /* 1) INIT→PREOP:AL Control 必须带 ACK —— SSC 对"错误位已置且请求不带 ACK"直接忽略 */ + ec_slave[0].state = EC_STATE_PRE_OP | EC_STATE_ACK; + ec_writestate(0); + if (ec_statecheck(0, EC_STATE_PRE_OP, 2000000) != EC_STATE_PRE_OP) { + ec_readstate(); + logs_add(logs, cap, lsz, "进 PREOP 失败: AL=0x%02x code=0x%04x —— 从站未 ack(0x0016/0x001D 类=邮箱 SM 被停止时禁用,重试即恢复)", + ec_slave[1].state & 0x0F, ec_slave[1].ALstatuscode); + return 0; + } + logs_add(logs, cap, lsz, "已进入 PREOP"); + + /* 1.4) 过程数据看门狗兜底写 1s:固件 configPdWatchdog 想写 0x420=10000, + * 实测读回恒 1000(=100ms)。进 OP 尝试期主站 200ms 才发一帧 LRW, + * 100ms 看门狗必过期 → ESC 锁存 0x440 并停发 SM2 事件;本从站 + * 0x1C32/0x1C33 是 SM 同步模式(bEscIntEnabled=TRUE),PDO 桥双向全靠 + * SM2 事件驱动 → 事件一被抑制,输出不处理(ack 冻结)、输入不刷新 + * (global_seq 冻结),且 ESC 收写仍计 WKC,主站侧看似一切正常。 + * PREOP 阶段写 1s:尝试期 200ms 节拍与稳态循环都远小于 1s。 */ + if (ec_FPWRw(ec_slave[1].configadr, 0x0420, 10000, EC_TIMEOUTRET) > 0) + logs_add(logs, cap, lsz, "过程数据看门狗已写 1s (0x420=10000)"); + else + logs_add(logs, cap, lsz, "WARN: 0x420 写 1s 失败,进 OP 尝试期可能被 100ms 看门狗熬过期"); + + /* 1.5) free-run 清残留 DC 激活:0x0981 跨断开重连不消失,残留会让从站在 + * SAFEOP→OP 改等 PLL 序列而永久卡住。必须在 config_map 之前清。 */ + if (!dc_cycle_us) { + ec_dcsync0(1, FALSE, 0, 0); + logs_add(logs, cap, lsz, "已清除残留 DC 激活(free-run)"); + } + + /* 2) 映射 PDO(config_map 内部经 CoE 读 0x1C12/0x1C13,并把从站推到 SAFEOP)*/ + ec_config_map(&g_iomap); + ec_readstate(); + g_expected_wkc = ec_group[0].outputsWKC * 2 + ec_group[0].inputsWKC; + for (i = 1; i <= ec_slavecount && i <= 16; i++) + snprintf(g_slave_names[i - 1], 64, "%s", ec_slave[i].name); + + /* 3) 可选分布时钟 */ + if (dc_cycle_us) { + if (!ec_configdc()) { + logs_add(logs, cap, lsz, "config_dc(): 总线上没有支持 DC 的从站 —— 检查 SII word1 高字节(0x0151 Sync/Latch)是否为 0xCC"); + return 0; + } + ec_dcsync0(1, TRUE, (uint32)dc_cycle_us * 1000u, (int32)(dc_shift_us * 1000)); + if (dc_shift_us) logs_add(logs, cap, lsz, "DC 已启用: SYNC0 周期 %dus, shift %dus", dc_cycle_us, dc_shift_us); + else logs_add(logs, cap, lsz, "DC 已启用: SYNC0 周期 %dus", dc_cycle_us); + } + snprintf(mode, sizeof mode, dc_cycle_us ? "DC %dus" : "free-run", dc_cycle_us); + + /* 4) SAFEOP|ACK(幂等确认,SOEM 的 config_map 已请求过一次)*/ + ec_slave[0].state = EC_STATE_SAFE_OP | EC_STATE_ACK; + ec_writestate(0); + if (ec_statecheck(0, EC_STATE_SAFE_OP, 2000000) != EC_STATE_SAFE_OP) { + ec_readstate(); + logs_add(logs, cap, lsz, "进 SAFEOP 失败: AL=0x%02x code=0x%04x", ec_slave[1].state & 0x0F, ec_slave[1].ALstatuscode); + return 0; + } + logs_add(logs, cap, lsz, "已进入 SAFEOP"); + + /* 5) SAFEOP→OP:SSC 要求过程数据在流动才放行。喂帧间隔必须远小于从站 + * 过程数据看门狗(0x420):SSC 的 CheckIfEcatError 在 WD 触发位过期时 + * 每 1ms 清一次 bEcatFirstOutputsReceived,挂起的 SAFEOP→OP 转换就永远 + * 完成不了,~9s 后超时写 AL 0x001B 并禁用 SM2(之后 LRW 只剩读匹配, + * wkc 3→1)。从站固件 1.5.45 起自己把 0x420 写成 1s,但实测该写会不 + * 生效(复位默认 100ms:divider 0x400=2498→100µs × time 0x420=1000), + * 所以主站不能依赖 WD 长度 —— 先以 ~5ms 喂 ≥1s 再请求 OP,之后继续 + * 喂帧轮询状态(最多 ~3s)。对应 Python 时代"直写 0x420 双保险"的 + * 替代方案:节奏比看门狗快,比任何 WD 设置都稳。 */ + for (i = 0; i < 250; i++) { + ec_send_processdata(); + ec_receive_processdata(EC_TIMEOUTRET); + Sleep(4); + } + ec_slave[0].state = EC_STATE_OPERATIONAL | EC_STATE_ACK; + ec_writestate(0); + for (i = 0; i < 600; i++) { + ec_send_processdata(); + ec_receive_processdata(EC_TIMEOUTRET); + ec_readstate(); + if ((ec_slave[1].state & 0x0F) == EC_STATE_OPERATIONAL) break; + if ((i % 50) == 49) { ec_slave[0].state = EC_STATE_OPERATIONAL | EC_STATE_ACK; ec_writestate(0); } + Sleep(4); + } + if ((ec_slave[1].state & 0x0F) != EC_STATE_OPERATIONAL) { + logs_add(logs, cap, lsz, "进 OP 失败(AL=0x%02x code=0x%04x)", + ec_slave[1].state & 0x0F, ec_slave[1].ALstatuscode); + return 0; + } + g_op = 1; + logs_add(logs, cap, lsz, "已进 OP(%s): 输出 %dB / 输入 %dB,expected_wkc=%lld", + mode, ec_slave[1].Obytes, ec_slave[1].Ibytes, g_expected_wkc); + return 1; +} +static void do_op_stop(void) +{ + g_syncprev_valid = 0; + ec_slave[0].state = EC_STATE_INIT | EC_STATE_ACK; + ec_writestate(0); + ec_statecheck(0, EC_STATE_INIT, 1000000); + g_op = 0; + InterlockedExchange(&g_wedge_at, 0); +} +static void do_close(void) +{ + g_syncprev_valid = 0; + if (g_bus_open) { + ec_slave[0].state = EC_STATE_INIT; + ec_writestate(0); + ec_close(); + } + g_bus_open = 0; + g_op = 0; + g_desc[0] = 0; + InterlockedExchange(&g_wedge_at, 0); +} + +/* 优雅退出:停循环 → 从站回 INIT → 关适配器 → 结束进程。shutdown 路由与 + * --parent-pid 看门狗共用。强杀会让 LAN9252 停在 OP、SSC 邮箱可能楔死, + * 下次开机 config_map 一片 MailboxError,只能断电 —— 见记忆 + * ecat-mailbox-wedge-needs-mcu-reset。 */ +static void graceful_exit(const char *why) +{ + EnterCriticalSection(&g_bus_lock); + loop_stop(); + do_close(); + LeaveCriticalSection(&g_bus_lock); + fprintf(stderr, "[native] 优雅退出(%s)\n", why); + fflush(stderr); + Sleep(150); + ExitProcess(0); +} +static DWORD WINAPI parent_watch(LPVOID p) +{ + HANDLE h = OpenProcess(SYNCHRONIZE, FALSE, (DWORD)(DWORD_PTR)p); + if (!h) return 0; + WaitForSingleObject(h, INFINITE); + graceful_exit("父进程已退出"); + return 0; +} + +/* ── 陈旧实例清场(独立启动时;镜像 Python _kill_stale_instances)── + * 本工具要独占 Npcap 适配器:两个实例并存时后起的抓不到从站(身份读成 + * M:00010001),端口漂移又让它静默跑到别的口,页面永远"探测中→连接失败"。 + * 优雅优先:对旧实例调 /api/shutdown —— 对 native 和 Python 实例都成立。 + * 强杀兜底只按进程名杀 ecat_native.exe,绝不碰 python*.exe(会误杀用户程序)。 */ +static int port_taken(int port) +{ + SOCKET s = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in sa; + int r; + if (s == INVALID_SOCKET) return 0; + sa.sin_family = AF_INET; + sa.sin_port = htons((u_short)port); + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + r = connect(s, (struct sockaddr *)&sa, sizeof sa); /* 回环上拒绝是瞬时的 */ + closesocket(s); + return r == 0; +} +static void post_shutdown(int port) +{ + static const char req[] = + "POST /api/shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Content-Type: application/json\r\nContent-Length: 2\r\n" + "Connection: close\r\n\r\n{}"; + SOCKET s = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in sa; + char rb[256]; + DWORD tv = 3000; + if (s == INVALID_SOCKET) return; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char *)&tv, sizeof tv); + sa.sin_family = AF_INET; + sa.sin_port = htons((u_short)port); + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(s, (struct sockaddr *)&sa, sizeof sa) == 0) { + send(s, req, (int)sizeof req - 1, 0); + while (recv(s, rb, sizeof rb, 0) > 0) { } + } + closesocket(s); +} +static void kill_stale_instances(void) +{ + int p, acted = 0, killed = 0; + DWORD me = GetCurrentProcessId(); + PROCESSENTRY32 pe; + HANDLE snap; + for (p = 8090; p <= 8095; p++) { + if (!port_taken(p)) continue; + post_shutdown(p); + printf("[native] 旧实例(端口 %d): 已请求优雅退出(从站回 INIT)\n", p); + acted = 1; + } + { + ULONGLONG dl = GetTickCount64() + 8000; + while (GetTickCount64() < dl) { + int any = 0; + for (p = 8090; p <= 8095; p++) if (port_taken(p)) any = 1; + if (!any) break; + Sleep(300); + } + } + snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) { + pe.dwSize = sizeof pe; + if (Process32First(snap, &pe)) do { + if (!_stricmp(pe.szExeFile, "ecat_native.exe") && pe.th32ProcessID != me) { + HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (h) { TerminateProcess(h, 1); CloseHandle(h); killed++; } + } + } while (Process32Next(snap, &pe)); + CloseHandle(snap); + } + if (killed) printf("[native] 强杀残留 ecat_native 进程 %d 个\n", killed); + if (acted) Sleep(1000); /* Npcap 句柄随进程退出由内核回收,给它一拍 */ +} + +/* ── 分位数(读侧现算,同 _cyc_fresh 口径)── */ static void pct3(const double *v, int n, char *out, size_t osz) { double *s = (double *)malloc(n * sizeof(double)); @@ -125,8 +863,6 @@ static void pct3(const double *v, int n, char *out, size_t osz) s[(int)(0.5 * (n - 1))], s[(int)(0.9 * (n - 1))], s[n - 1]); free(s); } - -/* 取最近 n 拍(按时间序)。ring 单写者,读侧加锁拷贝。 */ static int recent_beats(beat_t *dst, int n) { int have, start, i; @@ -138,7 +874,6 @@ static int recent_beats(beat_t *dst, int n) LeaveCriticalSection(&g_ring_lock); return n; } - static void build_cyc_us(char *out, size_t osz) { static beat_t b[RING]; @@ -163,7 +898,7 @@ static void build_cyc_us(char *out, size_t osz) m, swork, sw, sr, st); } -/* ── 极简 HTTP/1.1(POST-only JSON,thread-per-connection)── */ +/* ── HTTP 工具 ── */ static int json_num(const char *body, const char *key, double *val) { const char *p = strstr(body, key); @@ -179,18 +914,387 @@ static int json_num(const char *body, const char *key, double *val) *val = v; return 1; } +static void json_str(const char *body, const char *key, char *out, int osz) +{ + const char *p = strstr(body, key); + if (!p) { out[0] = 0; return; } + p += strlen(key); + while (*p && *p != ':') p++; + if (*p != ':') { out[0] = 0; return; } + p++; + while (*p == ' ' || *p == '"' || *p == '\t') p++; + int i = 0; + while (*p && *p != '"' && i < osz - 1) out[i++] = *p++; + out[i] = 0; +} +static void jesc(char *dst, size_t dsz, const char *src) +{ + size_t o = 0; + for (; *src && o + 6 < dsz; src++) { + unsigned char c = (unsigned char)*src; + if (c == '"' || c == '\\') { dst[o++] = '\\'; dst[o++] = (char)c; } + else if (c < 0x20) { o += (size_t)snprintf(dst + o, dsz - o, "\\u%04x", c); } + else dst[o++] = (char)c; + } + dst[o] = 0; +} +/* 提取 key 的原始 token(字符串去引号 / 数字原样) */ +static int json_tok(const char *obj, const char *key, char *tok, int tsz) +{ + const char *p = obj; + int n = (int)strlen(key); + for (;;) { + p = strstr(p, key); + if (!p) return 0; + if (p > obj && (isalnum((unsigned char)p[-1]) || p[-1] == '_')) { p += n; continue; } + p += n; + /* JSON 键带引号:"id" 匹配后下一个字符是闭合引号,必须一并跳过才见 ':' */ + while (*p == ' ' || *p == '\t' || *p == '"') p++; + if (*p != ':') continue; + p++; + while (*p == ' ' || *p == '\t') p++; + if (*p == '"') { + int i = 0; p++; + while (*p && *p != '"' && i < tsz - 1) tok[i++] = *p++; + tok[i] = 0; + return 1; + } else { + int i = 0; + while (*p && *p != ',' && *p != '}' && *p != ']' && *p != ' ' && i < tsz - 1) tok[i++] = *p++; + tok[i] = 0; + return i > 0; + } + } +} +static int hexval(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +static int hex_decode(const char *s, uint8 *out, int cap) +{ + int n = 0; + while (*s && n < cap) { + int hi, lo; + while (*s == ' ') s++; + if (!*s) break; + hi = hexval(*s++); + lo = hexval(*s++); + if (hi < 0 || lo < 0) break; + out[n++] = (uint8)((hi << 4) | lo); + } + return n; +} +static void hex_encode(const uint8 *d, int n, char *out) +{ + static const char hx[] = "0123456789ABCDEF"; + int i; + for (i = 0; i < n; i++) { out[i * 2] = hx[d[i] >> 4]; out[i * 2 + 1] = hx[d[i] & 0xF]; } + out[n * 2] = 0; +} +static int parse_frame(const char *obj, frame_t *f) +{ + char tok[160]; + double v; + memset(f, 0, sizeof *f); + if (json_tok(obj, "ch", tok, sizeof tok)) f->ch = (int)strtol(tok, NULL, 0); + if (json_tok(obj, "id", tok, sizeof tok)) { + if (tok[0] == '0' && (tok[1] == 'x' || tok[1] == 'X')) f->id = (uint32)strtoul(tok + 2, NULL, 16); + else if (strchr(tok, 'x') || strchr(tok, 'X')) { + char *xp = strchr(tok, 'x'); + if (!xp) xp = strchr(tok, 'X'); + f->id = (uint32)strtoul(xp + 1, NULL, 16); + } + else f->id = (uint32)strtoul(tok, NULL, 10); + } + if (json_tok(obj, "data", tok, sizeof tok) && tok[0]) + f->dlc = hex_decode(tok, f->data, CAN_FD_DATA_MAX); + if (json_num(obj, "dlc", &v) && v >= 0) { + int want = (int)v; + if (want > f->dlc) { /* data 短于 dlc:ljust 补 0(memset 已保证) */ } + f->dlc = want; + } + if (f->dlc > CAN_FD_DATA_MAX) return -1; + return 0; +} + +static void send_http(SOCKET cs, int code, const char *ctype, const char *body, int len) +{ + char hdr[256]; + int hl = snprintf(hdr, sizeof hdr, + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nCache-Control: no-store\r\n" + "Content-Length: %d\r\nConnection: close\r\n\r\n", + code, code == 200 ? "OK" : "Not Found", ctype, len); + send(cs, hdr, hl, 0); + if (len > 0) send(cs, body, len, 0); +} +static void send_json(SOCKET cs, const char *body) { send_http(cs, 200, "application/json; charset=utf-8", body, (int)strlen(body)); } + +static const char *ctype_for(const char *path) +{ + const char *e = strrchr(path, '.'); + if (!e) return "application/octet-stream"; + if (!strcmp(e, ".html")) return "text/html; charset=utf-8"; + if (!strcmp(e, ".css")) return "text/css; charset=utf-8"; + if (!strcmp(e, ".js")) return "application/javascript; charset=utf-8"; + return "application/octet-stream"; +} +static void serve_static(SOCKET cs, const char *path) +{ + const char *rel = "ecat.html"; + char fp[1024]; + FILE *f; + char *buf; + long sz; + if (!strcmp(path, "/motor.html")) rel = "motor.html"; + else if (!strcmp(path, "/ecat.css")) rel = "ecat.css"; + else if (!strcmp(path, "/motor.js")) rel = "motor.js"; + else if (!strcmp(path, "/") || !strcmp(path, "/index.html") || !strcmp(path, "/ecat.html")) rel = "ecat.html"; + else { send_http(cs, 404, "text/plain", "missing", 7); return; } + _snprintf(fp, sizeof fp, "%s/%s", g_webdir, rel); + f = fopen(fp, "rb"); + if (!f) { send_http(cs, 404, "text/plain", "missing", 7); return; } + fseek(f, 0, SEEK_END); sz = ftell(f); fseek(f, 0, SEEK_SET); + buf = (char *)malloc(sz > 0 ? sz : 1); + if (buf && fread(buf, 1, sz, f) == (size_t)sz) + send_http(cs, 200, ctype_for(rel), buf, (int)sz); + else + send_http(cs, 500, "text/plain", "read error", 10); + free(buf); + fclose(f); +} + +/* ── 快照 JSON(motors/bridge/xrx/rx_batch/head + cyc_us)── */ +static void emit_bridge_json(char *resp, int rsz, int *prl) +{ + char hx[CAN_RX_DATA_MAX * 2 + 2], en[96]; + int rl = *prl, d; + bridge_t b; + EnterCriticalSection(&g_state_lock); + b = g_bridge; + LeaveCriticalSection(&g_state_lock); + if (!b.valid) { rl += snprintf(resp + rl, rsz - rl, "{\"valid\":false}"); *prl = rl; return; } + d = b.dlc > CAN_RX_DATA_MAX ? CAN_RX_DATA_MAX : b.dlc; + memset(hx, 0, sizeof hx); + hex_encode(b.data, d, hx); + jesc(en, sizeof en, + b.tx_result == 0x00 ? "OK" : + b.tx_result == 0xE2 ? "BUS_OFF" : + b.tx_result == 0xE3 ? "TEC 过高" : + b.tx_result == 0xE4 ? "邮箱满" : + b.tx_result == 0xE5 ? "参数无效" : ""); + if (en[0] == 0) snprintf(en, sizeof en, "0x%02X", b.tx_result); + rl += snprintf(resp + rl, rsz - rl, + "{\"valid\":true,\"ack\":%d,\"rx_valid\":%s,\"truncated\":%s,\"ch\":%d,\"dlc\":%d," + "\"id\":%u,\"data\":\"%s\",\"rx_count\":%u,\"tx_result\":%d,\"backlog\":%d," + "\"tx_count\":%d,\"drop\":%d,\"tx_result_text\":\"%s\"}", + b.ack, (b.flags & 0x01) ? "true" : "false", (b.flags & 0x02) ? "true" : "false", + b.ch, b.dlc, b.id, hx, b.rx_count, b.tx_result, b.backlog, + b.tx_count, b.drop, en); + *prl = rl; +} +/* Python 的 can/send、can/state 用 {**st} 把桥字段拍平到顶层 —— ecat.html 的 + * renderCanState 直接读 r.rx_count/r.tx_result_text。每键自带前置逗号, + * 调用方先写完自己的键再接上即可。 */ +/* tx_result 文本(Python CAN_TX_RESULT_DESC 同一张表)。返回内部静态串,只读短用。 */ +static const char *bridge_tx_text(uint8 code) +{ + switch (code) { + case 0x00: return "OK"; + case 0xE2: return "BUS_OFF"; + case 0xE3: return "TEC 过高"; + case 0xE4: return "邮箱满"; + case 0xE5: return "参数无效"; + default: return ""; /* 调用方格式化 0x%02X */ + } +} + +static void emit_bridge_flat(char *resp, int rsz, int *prl, const bridge_t *b) +{ + char hx[CAN_RX_DATA_MAX * 2 + 2], en[96]; + int rl = *prl, d; + if (!b->valid) { rl += snprintf(resp + rl, rsz - rl, ",\"valid\":false"); *prl = rl; return; } + d = b->dlc > CAN_RX_DATA_MAX ? CAN_RX_DATA_MAX : b->dlc; + memset(hx, 0, sizeof hx); + hex_encode(b->data, d, hx); + jesc(en, sizeof en, + b->tx_result == 0x00 ? "OK" : + b->tx_result == 0xE2 ? "BUS_OFF" : + b->tx_result == 0xE3 ? "TEC 过高" : + b->tx_result == 0xE4 ? "邮箱满" : + b->tx_result == 0xE5 ? "参数无效" : ""); + if (en[0] == 0) snprintf(en, sizeof en, "0x%02X", b->tx_result); + rl += snprintf(resp + rl, rsz - rl, + ",\"valid\":true,\"ack\":%d,\"rx_valid\":%s,\"truncated\":%s,\"ch\":%d,\"dlc\":%d," + "\"id\":%u,\"data\":\"%s\",\"rx_count\":%u,\"tx_result\":%d,\"backlog\":%d," + "\"tx_count\":%d,\"drop\":%d,\"tx_result_text\":\"%s\"", + b->ack, (b->flags & 0x01) ? "true" : "false", (b->flags & 0x02) ? "true" : "false", + b->ch, b->dlc, b->id, hx, b->rx_count, b->tx_result, b->backlog, + b->tx_count, b->drop, en); + *prl = rl; +} + +static void emit_xrx_json(char *resp, int rsz, int *prl) +{ + char hx[CAN_XRX_DATA_MAX * 2 + 2]; int rl = *prl; + xrx_t x; + EnterCriticalSection(&g_state_lock); + x = g_xrx; + LeaveCriticalSection(&g_state_lock); + if (!x.valid) { rl += snprintf(resp + rl, rsz - rl, "null"); *prl = rl; return; } + hex_encode(x.data, x.n, hx); + rl += snprintf(resp + rl, rsz - rl, "{\"ch\":%d,\"dlc\":%d,\"id\":%u,\"data\":\"%s\"}", + x.ch, x.dlc, x.id, hx); + *prl = rl; +} +static void emit_rxb_json(char *resp, int rsz, int *prl, int with_frames) +{ + char hx[CAN_FD_DATA_MAX * 2 + 2]; + int rl = *prl, i; + rxb_t rb; + EnterCriticalSection(&g_state_lock); + rb = g_rxb; + LeaveCriticalSection(&g_state_lock); + rl += snprintf(resp + rl, rsz - rl, "{\"count\":%d,\"lost\":%d,\"pending\":%d", + rb.count, rb.lost, rb.pending); + if (with_frames) { + rl += snprintf(resp + rl, rsz - rl, ",\"frames\":["); + for (i = 0; i < rb.n; i++) { + hex_encode(rb.fr[i].data, rb.fr[i].dlc, hx); + rl += snprintf(resp + rl, rsz - rl, "%s{\"ch\":%d,\"dlc\":%d,\"valid\":true,\"id\":%u,\"data\":\"%s\"}", + i ? "," : "", rb.fr[i].ch, rb.fr[i].dlc, rb.fr[i].id, hx); + } + rl += snprintf(resp + rl, rsz - rl, "]"); + } else { + rl += snprintf(resp + rl, rsz - rl, ",\"frames\":[]}"); + *prl = rl; + return; + } + rl += snprintf(resp + rl, rsz - rl, "}"); + *prl = rl; +} +static void build_state_json(char *resp, int rsz) +{ + char extra[4096]; + slot_t slots[MOTOR_SLOT_COUNT]; + int i, j, rl, ord[MOTOR_SLOT_COUNT], no = 0, qdepth; + int n_bases, bases[4], hv, active, boot, wbase, wcount; + uint32 gseq; + double period, slot_period; + long long cycles, expected; + int wkc, seq, frames; + + build_cyc_us(extra, sizeof extra); + EnterCriticalSection(&g_state_lock); + memcpy(slots, g_slots, sizeof slots); + hv = g_head.valid; active = g_head.active; boot = g_head.boot; + wbase = g_head.wbase; wcount = g_head.wcount; gseq = g_head.gseq; + n_bases = g_n_bases; if (n_bases > 4) n_bases = 4; + memcpy(bases, g_bases, n_bases * sizeof(int)); + period = g_period_ms; cycles = g_total; expected = g_expected_wkc; wkc = (int)g_wkc; + seq = g_sent_seq; frames = g_sent_frames; + slot_period = period * (n_bases > 0 ? n_bases : 1); + LeaveCriticalSection(&g_state_lock); + EnterCriticalSection(&g_cmd_lock); qdepth = g_cmd_len; LeaveCriticalSection(&g_cmd_lock); + + for (i = 0; i < MOTOR_SLOT_COUNT; i++) + if (slots[i].node != 0) ord[no++] = i; + for (i = 1; i < no; i++) + for (j = i; j > 0; j++) { + int a = ord[j - 1], c = ord[j]; + if (slots[a].ch > slots[c].ch || (slots[a].ch == slots[c].ch && slots[a].node > slots[c].node)) { + ord[j - 1] = c; ord[j] = a; + } else break; + } + + rl = snprintf(resp, rsz, + "{\"ok\":true,\"log\":[],\"cycles\":%lld,\"period_ms\":%.4f,\"spin\":true," + "\"wkc\":%d,\"expected_wkc\":%lld,\"slot_period_ms\":%.4f,\"win_bases\":[", + cycles, period, wkc, expected, slot_period); + for (i = 0; i < n_bases; i++) rl += snprintf(resp + rl, rsz - rl, "%s%d", i ? "," : "", bases[i]); + rl += snprintf(resp + rl, rsz - rl, "],\"queue_depth\":%d,\"sent_seq\":%d,\"sent_frames\":%d,\"ts\":0,\"head\":", + qdepth, seq, frames); + if (hv) + rl += snprintf(resp + rl, rsz - rl, + "{\"active\":%d,\"boot_active\":%s,\"win_base\":%d,\"win_count\":%d,\"global_seq\":%u}", + active, boot ? "true" : "false", wbase, wcount, gseq); + else rl += snprintf(resp + rl, rsz - rl, "{}"); + rl += snprintf(resp + rl, rsz - rl, ",\"motors\":["); + for (i = 0; i < no; i++) { + slot_t *s = &slots[ord[i]]; + char hx[MOT_STATUS_DLC * 2 + 2]; + hex_encode(s->data, s->dlc > MOT_STATUS_DLC ? MOT_STATUS_DLC : s->dlc, hx); + rl += snprintf(resp + rl, rsz - rl, + "%s{\"slot\":%d,\"node\":%d,\"ch\":%d,\"online\":%s,\"dlc\":%d,\"seq\":%u,\"data\":\"%s\"}", + i ? "," : "", ord[i], s->node, s->ch, s->online ? "true" : "false", + s->dlc, s->seq, hx); + } + rl += snprintf(resp + rl, rsz - rl, "],\"bridge\":"); + emit_bridge_json(resp, rsz, &rl); + rl += snprintf(resp + rl, rsz - rl, ",\"xrx\":"); + emit_xrx_json(resp, rsz, &rl); + rl += snprintf(resp + rl, rsz - rl, ",\"rx_batch\":"); + emit_rxb_json(resp, rsz, &rl, 1); + rl += snprintf(resp + rl, rsz - rl, ",\"wr\":%lld,%s}", g_wedge_recoveries, extra); +} + +/* ═════════ 阶段 3 辅助:SDO / EEPROM / sync_diag ═════════ */ + +/* EEPROM 类路由的大缓冲:4096 字全差异时日志上十万字节,放不进栈。做成文件级 + * static 而不是函数内 static,是为了让"靠 g_bus_lock 串行化"这件事在声明处就看得见 + * —— 五条阶段 3 路由全部先 EnterCriticalSection(&g_bus_lock) 再碰它。 */ +static char g_ee_logs[160 * 1024]; +static uint8 g_ee_data[EEPROM_SIZE]; +static char g_ee_hex[2 * EEPROM_SIZE + 8]; + +/* pysoem Slave.eeprom_read(word) → uint32(低 16 位是数据字)。EC_VER1 封装在从站号上 + * 做 eeprom2master 仲裁;单从站总线直接用 slave 1,与 Python 的 slaves[0] 同物。 */ +static uint16 ee_read_word(uint16 word) +{ + return (uint16)(ec_readeeprom(1, word, EC_TIMEOUTEEP) & 0xFFFF); +} +static int ee_write_word(uint16 word, uint16 val) +{ + return ec_writeeeprom(1, word, val, EC_TIMEOUTEEP) > 0; +} + +/* 往 JSON 字符串数组里追加一条(已转义中文/ASCII,无特殊字符)。调用方负责 + * 前后括号。返回新长度。 + * 缓冲是 static(4096 字 EEPROM 的日志放不进栈)且跨请求复用,故每次追加都必须 + * 补 NUL:漏了就会把上一次请求的残留日志一起 %s 出去(实测 verify 返回 + * "OK, 全部一致" 后面粘着上一轮的 449 行差异)。 */ +static int logs_add(char *logs, int cap, int *lp, const char *fmt, ...) +{ + char tmp[256]; + va_list ap; + int add, o; + va_start(ap, fmt); + add = vsnprintf(tmp, sizeof tmp, fmt, ap); + va_end(ap); + if (add < 0) return *lp; + if (*lp + add + 4 >= cap) return *lp; /* 溢出保护:日志截断可接受 */ + o = *lp; + if (o) logs[o++] = ','; + logs[o++] = '"'; + memcpy(logs + o, tmp, add); + o += add; + logs[o++] = '"'; + logs[o] = 0; + *lp = o; + return o; +} static void handle_conn(SOCKET cs) { char *buf = (char *)malloc(65536); - int len = 0, i, cl = 0; - char *hdr_end, *body, path[128] = ""; - char *resp = (char *)malloc(256 * 1024); - int rl = 0; - char extra[4096]; + char *resp = (char *)malloc(512 * 1024); + char msg[512], substr[64] = "I226"; + int len = 0, i, cl = 0, rl; + char *hdr_end, *body, method[8] = "", path[128] = ""; if (!buf || !resp) { closesocket(cs); free(buf); free(resp); return; } - /* 读到头 */ for (;;) { int n = recv(cs, buf + len, 65535 - len, 0); if (n <= 0) goto done; @@ -199,78 +1303,832 @@ static void handle_conn(SOCKET cs) if (hdr_end) break; } body = hdr_end + 4; - { /* Content-Length + 收满 body */ - char *p = buf; - for (p = strstr(p, "\n"); p && p < hdr_end; p = strstr(p + 1, "\n")) { - /* p 停在上一行的 \n 上,下一行从 p+1 开始 */ + { + char *p; + for (p = strstr(buf, "\n"); p && p < hdr_end; p = strstr(p + 1, "\n")) if (!_strnicmp(p + 1, "content-length:", 15)) { cl = atoi(p + 16); break; } - } - while ((body + cl) > (buf + len)) { + while (cl > 0 && (body + cl) > (buf + len)) { int need = (int)((body + cl) - (buf + len)); int r = recv(cs, buf + len, need > 65535 - len ? 65535 - len : need, 0); if (r <= 0) goto done; len += r; buf[len] = 0; } if (cl > 65535 - (int)(body - buf)) cl = (int)(buf + len - body); - body[cl] = 0; + if (cl >= 0) body[cl] = 0; } - if (sscanf(buf, "%*s %127s", path) != 1) goto done; + if (sscanf(buf, "%7s %127s", method, path) != 2) goto done; + if (!strcmp(method, "GET")) { serve_static(cs, path); goto done; } + + /* focus:Python 里是"把 GUI 窗口带到前台"的信号(esp32_server._focus_existing + * 用它探活)。native 无窗口,只需如实应答存在。 */ + if (!strcmp(path, "/api/focus")) { send_json(cs, "{\"ok\":true,\"focused\":true}"); goto done; } + + if (!strcmp(path, "/api/adapters")) { + ec_adaptert *ad; int idx = 0; char ed[512], en[512]; + rl = snprintf(resp, 512 * 1024, "{\"adapters\":["); + for (ad = ec_find_adapters(); ad; ad = ad->next, idx++) { + jesc(ed, sizeof ed, ad->desc); jesc(en, sizeof en, ad->name); + rl += snprintf(resp + rl, 512 * 1024 - rl, "%s{\"index\":%d,\"desc\":\"%s\",\"name\":\"%s\"}", + idx ? "," : "", idx, ed, en); + } + rl += snprintf(resp + rl, 512 * 1024 - rl, "]}"); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/open")) { + int ok; + json_str(body, "substr", substr, sizeof substr); + if (!substr[0]) strcpy(substr, "I226"); + EnterCriticalSection(&g_bus_lock); + loop_stop(); + ok = do_open(substr, msg, sizeof msg); + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":%s,\"log\":[\"%s\"]}", ok ? "true" : "false", msg); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/status")) { + char en[160], ed[320]; + if (!g_bus_open) { send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; } + EnterCriticalSection(&g_bus_lock); + ec_readstate(); + jesc(en, sizeof en, g_slave_names[0]); jesc(ed, sizeof ed, g_desc); + rl = snprintf(resp, 512 * 1024, + "{\"ok\":true,\"slave\":{\"name\":\"%s\",\"state\":%d,\"al_status\":%d,\"desc\":\"%s\"}}", + en, ec_slave[1].state & 0x0F, ec_slave[1].ALstatuscode, ed); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/close")) { + EnterCriticalSection(&g_bus_lock); + loop_stop(); + do_close(); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":true,\"log\":[\"已断开\"]}"); goto done; + } + if (!strcmp(path, "/api/shutdown")) { + send_json(cs, "{\"ok\":true,\"log\":[\"后台服务即将退出; 双击 ecat_web.vbs 可再次启动\"]}"); + Sleep(300); /* 让响应在回环上真正发出去 */ + graceful_exit("shutdown 路由"); + goto done; + } + if (!strcmp(path, "/api/op/start")) { + double v; + int dc_cycle = 0, dc_shift = 0, ok, lsz = 0; + if (json_num(body, "dc_cycle_us", &v)) dc_cycle = (int)v; + if (json_num(body, "dc_shift_us", &v)) dc_shift = (int)v; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + g_ee_logs[0] = 0; + ok = do_op_start(dc_cycle, dc_shift, g_ee_logs, sizeof g_ee_logs, &lsz); + if (!ok) { + /* 失败自动重试一次(api_op_start 同款):入口预清理=重枚举,重试即"断开重连" */ + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "自动重试:重新执行完整序列(入口含重枚举预清理)…"); + ok = do_op_start(dc_cycle, dc_shift, g_ee_logs, sizeof g_ee_logs, &lsz); + } + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":%s,\"log\":[%s]}", ok ? "true" : "false", g_ee_logs); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/op/stop")) { + EnterCriticalSection(&g_bus_lock); + loop_stop(); + do_op_stop(); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":true,\"log\":[\"已退回 INIT\"]}"); goto done; + } + if (!strcmp(path, "/api/motor/start")) { + double v; + int dc_cycle = 0, dc_shift = 0, lsz = 0; + EnterCriticalSection(&g_bus_lock); + g_ee_logs[0] = 0; + loop_stop(); + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + if (json_num(body, "dc_cycle_us", &v)) dc_cycle = (int)v; + if (json_num(body, "dc_shift_us", &v)) dc_shift = (int)v; + /* g_op 只是"上次进过 OP"的记忆,不是从站真实状态:循环停超 1000ms 过程 + * 数据看门狗就把它踢出 OP(0x001B),这里必须现读核实(api_motor_start 注释)。 */ + if (g_op) { + ec_readstate(); + if ((ec_slave[1].state & 0x0F) != EC_STATE_OPERATIONAL) { + g_op = 0; + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, + "检测到从站已掉出 OP(过程数据看门狗超时),重新走 PREOP→SAFEOP→OP"); + } + } + if (!g_op && !do_op_start(dc_cycle, dc_shift, g_ee_logs, sizeof g_ee_logs, &lsz)) { + g_op = 0; + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[%s]}", g_ee_logs); + send_json(cs, resp); goto done; + } + if (json_num(body, "period_ms", &v) && v >= 0.0625 && v <= 1000.0) { + EnterCriticalSection(&g_cfg_lock); g_period_ms = v; LeaveCriticalSection(&g_cfg_lock); + } + loop_start(); + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, + "后台 PDO 循环已启动(native): 周期 %gms,窗口 7 槽/周期," + "窗口自适应(只覆盖有主的槽,在线电机每周期都刷新),每 64 周期插一次发现扫描以找新电机", + g_period_ms); + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, + "节拍源: 自旋(native 恒自旋: 周期跑得准,但会占满一个 P 核)"); + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[%s],\"period_ms\":%.4f,\"spin\":true}", + g_ee_logs, g_period_ms); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/motor/stop")) { + EnterCriticalSection(&g_bus_lock); + loop_stop(); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":true,\"log\":[\"PDO 循环已停\"]}"); goto done; + } + if (!strcmp(path, "/api/motor/spin")) { + send_json(cs, "{\"ok\":true,\"log\":[\"native 亚毫秒恒自旋,spin 无副作用\"]}"); goto done; + } + /* motor/send:循环活着 → 排进队列(worker 同 seq 沿批量下发);循环没跑 → + * 同步下发(Python 的 fallback:排队等于让命令烂在队列里)。非独占路由, + * 但要拿 g_bus_lock 双检 g_loop_running —— 防"检查后、踩周期前"motor/start 插进来。 */ + if (!strcmp(path, "/api/motor/send")) { + static frame_t fr[64]; + int nf = 0, backlog = 0, ok = 1; + double v; + const char *fa, *p; + char desc[2560]; + int dpos = 0, lsz = 0, dc_cycle = 0, dc_shift = 0; + if (json_num(body, "dc_cycle_us", &v)) dc_cycle = (int)v; + if (json_num(body, "dc_shift_us", &v)) dc_shift = (int)v; + fa = strstr(body, "\"frames\""); + if (fa && (p = strchr(fa, '[')) != NULL) { + while (nf < 64 && (p = strchr(p, '{')) != NULL) { + int depth = 0, j; + for (j = 0; p[j]; j++) { + if (p[j] == '{') depth++; + else if (p[j] == '}') { depth--; if (!depth) break; } + } + if (depth) break; + { + char obj[2048]; + int on = j + 1; + if (on >= (int)sizeof obj) on = (int)sizeof obj - 1; + memcpy(obj, p, on); obj[on] = 0; + if (parse_frame(obj, &fr[nf]) == 0) nf++; + else { ok = 0; break; } + } + p = p + j + 1; + } + } else { + if (parse_frame(body, &fr[0]) == 0) nf = 1; + else ok = 0; + } + if (!ok) { + int dlc64 = -1; + if (json_num(body, "dlc", &v)) dlc64 = (int)v; + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"DLC %d 超出 CANFD 上限 64\"]}", dlc64); + send_json(cs, resp); goto done; + } + if (!g_bus_open) { send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; } + for (i = 0; i < nf; i++) { + if (fr[i].ch < 0 || fr[i].ch > 2) { + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"通道必须是 0/1/2,收到 %d\"]}", fr[i].ch); + send_json(cs, resp); goto done; + } + dpos += snprintf(desc + dpos, sizeof desc - dpos, "%sCH%d 0x%X DLC%d", + dpos ? "; " : "", fr[i].ch, fr[i].id, fr[i].dlc); + } + EnterCriticalSection(&g_bus_lock); + /* 镜像 Python api_motor_send 的顺序:先入队,再看循环活着没 —— 同步 fallback + * 因此还会顺带清掉更早的积压(那些帧本来也该发)。 */ + EnterCriticalSection(&g_cmd_lock); + for (i = 0; i < nf; i++) { + if (g_cmd_len >= CMD_Q) break; + g_cmd_q[(g_cmd_head + g_cmd_len) % CMD_Q] = fr[i]; + g_cmd_len++; + } + backlog = g_cmd_len; + LeaveCriticalSection(&g_cmd_lock); + if (g_loop_running) { + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, + "{\"ok\":true,\"queued\":%d,\"backlog\":%d,\"batch_max_bytes\":%d,\"log\":[]}", + nf, backlog, TXB_BYTES); + send_json(cs, resp); goto done; + } + /* 循环没在跑:备好 OP 后同步下发(Python 的 _require_op_ready fallback)。 + * 备好 OP 失败时帧留在队列里 —— 与 Python 返回 ok:false 但不丢帧一致。 */ + loop_stop(); + g_ee_logs[0] = 0; + if (!g_op && !do_op_start(dc_cycle, dc_shift, g_ee_logs, sizeof g_ee_logs, &lsz)) { + g_op = 0; + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[%s]}", g_ee_logs); + send_json(cs, resp); goto done; + } + { + int n = 0, cycles = 0; + frame_t first; + static frame_t bfr[64]; + for (;;) { + int have, nb, q; + EnterCriticalSection(&g_cmd_lock); + have = g_cmd_len; + if (have == 0) { + LeaveCriticalSection(&g_cmd_lock); + break; + } + first = g_cmd_q[g_cmd_head]; + g_cmd_head = (g_cmd_head + 1) % CMD_Q; + g_cmd_len--; + have--; + nb = have > 32 ? 32 : have; + for (q = 0; q < nb; q++) bfr[q] = g_cmd_q[(g_cmd_head + q) % CMD_Q]; + nb = batch_capacity(bfr, nb); + g_cmd_head = (g_cmd_head + nb) % CMD_Q; + g_cmd_len -= nb; + LeaveCriticalSection(&g_cmd_lock); + fill_can_out(&first, nb ? bfr : NULL, nb, 0); + pump_cycles(2, NULL); + n += 1 + nb; + cycles++; + } + rl = snprintf(resp, 512 * 1024, + "{\"ok\":true,\"queued\":0,\"sent_sync\":%d,\"pdo_cycles\":%d," + "\"log\":[\"后台循环未运行,已同步下发 %d 帧(%d 个 PDO 周期): ", n, cycles, n, cycles); + if (dpos >= (int)sizeof desc) dpos = (int)sizeof desc - 1; + memcpy(resp + rl, desc, dpos + 1); rl += dpos; + rl += snprintf(resp + rl, 512 * 1024 - rl, "\"]}"); + } + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + /* can/send(ecat.html CAN 调试页):独占 + 3 拍 pump,**同步回读 ack**,桥字段 + * 拍平进响应(renderCanState 直接读顶层)。与 motor/send 不同,它不走队列。 */ + if (!strcmp(path, "/api/can/send")) { + frame_t f; + double v; + int lsz = 0, dc_cycle = 0, dc_shift = 0, clr = 0, wkc, seq, acked; + bridge_t b; + char tok[16], dhs[CAN_TX_DATA_MAX * 2 + 2]; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + g_ee_logs[0] = 0; + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + if (json_num(body, "dc_cycle_us", &v)) dc_cycle = (int)v; + if (json_num(body, "dc_shift_us", &v)) dc_shift = (int)v; + if (!g_op && !do_op_start(dc_cycle, dc_shift, g_ee_logs, sizeof g_ee_logs, &lsz)) { + g_op = 0; + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[%s]}", g_ee_logs); + send_json(cs, resp); goto done; + } + if (json_num(body, "dlc", &v) && (int)v > CAN_TX_DATA_MAX) { + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"DLC %d 超出 PDO 单帧上限 %d\"]}", (int)v, CAN_TX_DATA_MAX); + send_json(cs, resp); goto done; + } + if (parse_frame(body, &f) < 0) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"DLC 超出 PDO 单帧上限 32\"]}"); goto done; + } + if (f.ch < 0 || f.ch > 2) { + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"通道必须是 0/1/2,收到 %d\"]}", f.ch); + send_json(cs, resp); goto done; + } + if (json_tok(body, "clear_stats", tok, sizeof tok) && + (tok[0] == 't' || tok[0] == 'T' || tok[0] == '1')) clr = 1; + memset(dhs, 0, sizeof dhs); + hex_encode(f.data, f.dlc > CAN_TX_DATA_MAX ? CAN_TX_DATA_MAX : f.dlc, dhs); + seq = fill_can_out(&f, NULL, 0, clr); + wkc = pump_cycles(3, &b); + acked = (b.valid && b.ack == seq); + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "已下发 CH%d 0x%X DLC%d %s seq=%d", + f.ch, f.id, f.dlc, f.dlc ? dhs : "(空)", seq); + if (!acked) + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, + "警告: 从站回显 ack=%d != seq=%d,过程数据可能未被应用层处理(确认已进 OP、固件 ≥1.5.55)", + b.ack, seq); + else if (b.tx_result != 0) { + const char *tx = bridge_tx_text(b.tx_result); + if (tx[0]) logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, + "从站发送失败: %s (0xE4 邮箱满=总线无应答/未接收器;0xE2 BUS_OFF=接线或终端电阻)", tx); + else logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "从站发送失败: 0x%02X", b.tx_result); + } + rl = snprintf(resp, 512 * 1024, + "{\"ok\":true,\"log\":[%s],\"wkc\":%d,\"expected_wkc\":%lld,\"seq\":%d,\"acked\":%s", + g_ee_logs, wkc, g_expected_wkc, seq, acked ? "true" : "false"); + emit_bridge_flat(resp, 512 * 1024, &rl, &b); + rl += snprintf(resp + rl, 512 * 1024 - rl, "}"); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/motor/rx")) { + int limit = -1, n; + double v; + if (json_num(body, "limit", &v)) limit = (int)v; + EnterCriticalSection(&g_rx_lock); + n = limit < 0 ? g_rx_len : (limit < g_rx_len ? limit : g_rx_len); + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[],\"frames\":["); + for (i = 0; i < n; i++) { + int t = (g_rx_head + i) % RX_Q; + char hx[CAN_FD_DATA_MAX * 2 + 2]; + hex_encode(g_rx_q[t].data, g_rx_q[t].dlc, hx); + rl += snprintf(resp + rl, 512 * 1024 - rl, + "%s{\"ch\":%d,\"dlc\":%d,\"valid\":%s,\"id\":%u,\"data\":\"%s\"}", + i ? "," : "", g_rx_q[t].ch, g_rx_q[t].dlc, + g_rx_valid[t] ? "true" : "false", g_rx_q[t].id, hx); + } + g_rx_head = (g_rx_head + n) % RX_Q; + g_rx_len -= n; + rl += snprintf(resp + rl, 512 * 1024 - rl, "],\"remain\":%d}", g_rx_len); + LeaveCriticalSection(&g_rx_lock); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/can/state")) { + bridge_t b; + int wkc; + EnterCriticalSection(&g_bus_lock); + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + if (!g_op) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未进 OP —— 先点『进入 OP』或发送一帧\"]}"); goto done; + } + if (g_loop_running) { + /* 循环在跑:给它的最新快照,别再自己踩周期(两个写者抢 outputs, + * 额外周期还会打乱窗口滚动 —— api_can_state 注释同款) */ + EnterCriticalSection(&g_state_lock); + b = g_bridge; + LeaveCriticalSection(&g_state_lock); + wkc = (int)g_wkc; + } else { + wkc = pump_cycles(1, &b); + } + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[],\"wkc\":%d,\"expected_wkc\":%lld", + wkc, g_expected_wkc); + emit_bridge_flat(resp, 512 * 1024, &rl, &b); + rl += snprintf(resp + rl, 512 * 1024 - rl, "}"); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } if (!strcmp(path, "/api/motor/state")) { - build_cyc_us(extra, sizeof extra); - rl = snprintf(resp, 256 * 1024, - "{\"ok\":true,\"log\":[],\"cycles\":%lld,\"period_ms\":%.4f,\"spin\":true," - "\"wkc\":%d,\"expected_wkc\":%d,\"slot_period_ms\":%.4f,\"win_bases\":0," - "\"queue_depth\":0,\"sent_seq\":0,\"sent_frames\":0,\"ts\":0," - "\"head\":{},\"motors\":[", - g_total, g_period_ms, (int)g_wkc, (int)g_expected_wkc, g_period_ms); - for (i = 0; i < g_slavecount && i < 16; i++) - rl += snprintf(resp + rl, 256 * 1024 - rl, "%s{\"name\":\"%s\",\"valid\":true}", - i ? "," : "", g_slave_names[i]); - rl += snprintf(resp + rl, 256 * 1024 - rl, "],\"bridge\":{},\"xrx\":{},\"rx_batch\":{},%s}", extra); - } else if (!strcmp(path, "/api/motor/period")) { + /* 楔死自动恢复:检测在循环线程(窗口回显冻结,见 pdo_worker),恢复在 + * 本路由(UI 每 50ms 轮询,必然及时到达)。检测后留 0.5s 缓冲(给在途 + * 请求收尾),TryEnter 避免与互斥路由互等(拿不到锁就下拍再试)。恢复 + * 与 motor/start 掉 OP 同款:完整重拉 INIT→PREOP→SAFEOP→OP,实测能把 + * 固件 OP 态应用层楔死拉回正常。10s 冷却防反复重拉。 */ + if (g_wedge_at && g_loop_running) { + double nw = now_s(); + if (nw - (double)g_wedge_at > 0.5 + && nw >= (double)g_wedge_cooldown + && TryEnterCriticalSection(&g_bus_lock)) { + char wl[1024]; + int wlsz = 0, wok; + InterlockedExchange(&g_wedge_at, 0); + g_wedge_cooldown = (LONG)(nw + 10.0); + loop_stop(); + wok = do_op_start(0, 0, wl, sizeof wl, &wlsz); + loop_start(); + g_wedge_recoveries++; + printf("[native][WEDGE] 自动重拉%s:%.*s\n", wok ? "成功" : "失败", + wlsz > 0 ? wlsz : 0, wl); + fflush(stdout); + LeaveCriticalSection(&g_bus_lock); + } + } + build_state_json(resp, 512 * 1024); + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/motor/period")) { double v = -1, old = g_period_ms; if (json_num(body, "period_us", &v) && v > 0) v /= 1000.0; else if (!json_num(body, "period_ms", &v)) v = -1; - if (v < 0.0625 || v > 1000.0) { - rl = snprintf(resp, 256 * 1024, - "{\"ok\":false,\"log\":[\"周期 %s 超出允许范围 [62.5us, 1000ms]\"]}", body); - } else { - EnterCriticalSection(&g_cfg_lock); - g_period_ms = v; - LeaveCriticalSection(&g_cfg_lock); - rl = snprintf(resp, 256 * 1024, - "{\"ok\":true,\"log\":[\"PDO 周期(native) %gms → %gms\"],\"period_ms\":%.4f}", - old, v, v); + if (v < 0.0625 || v > 1000.0) + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"周期超出允许范围 [62.5us,1000ms]\"]}"); + else { + EnterCriticalSection(&g_cfg_lock); g_period_ms = v; LeaveCriticalSection(&g_cfg_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[\"PDO 周期(native) %gms → %gms\"],\"period_ms\":%.4f}", old, v, v); } - } else if (!strcmp(path, "/api/motor/dbg")) { + send_json(cs, resp); goto done; + } + if (!strcmp(path, "/api/motor/dbg")) { static beat_t b[RING]; int n = recent_beats(b, RING); - rl = snprintf(resp, 256 * 1024, + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"period_ms\":%.4f,\"spin\":true,\"gc_enabled\":false," "\"cycles\":%lld,\"skip_total\":%lld,\"cyc\":[", g_period_ms, g_total, g_skip_total); for (i = 0; i < n; i++) - rl += snprintf(resp + rl, 256 * 1024 - rl, + rl += snprintf(resp + rl, 512 * 1024 - rl, "%s[%.7f,%.7f,%.7f,%.7f,%.7f,%d]", i ? "," : "", b[i].t0, b[i].ts, b[i].tm, b[i].tr, b[i].te, b[i].k); - rl += snprintf(resp + rl, 256 * 1024 - rl, "],\"gc_events\":[],\"http\":[]}"); - } else if (!strcmp(path, "/") || !strcmp(path, "/api/native/health")) { - rl = snprintf(resp, 256 * 1024, - "{\"ok\":true,\"name\":\"ecat_native\",\"slaves\":%d,\"wkc\":%d,\"cycles\":%lld}", - g_slavecount, (int)g_wkc, g_total); - } else { - rl = snprintf(resp, 256 * 1024, - "{\"ok\":false,\"log\":[\"接口不存在(native 原型只有 state/period/dbg): %s\"]}", path); + rl += snprintf(resp + rl, 512 * 1024 - rl, "],\"gc_events\":[],\"http\":[]}"); + send_json(cs, resp); goto done; } - { /* 发送 */ - char hdr[256]; - int hl = snprintf(hdr, sizeof hdr, - "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\n" - "Cache-Control: no-store\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", rl); - send(cs, hdr, hl, 0); - send(cs, resp, rl, 0); + if (!strcmp(path, "/") || !strcmp(path, "/api/native/health")) { + int qd; + EnterCriticalSection(&g_cmd_lock); qd = g_cmd_len; LeaveCriticalSection(&g_cmd_lock); + rl = snprintf(resp, 512 * 1024, + "{\"ok\":true,\"name\":\"ecat_native\",\"bus_open\":%d,\"op\":%d,\"loop\":%d," + "\"slaves\":%d,\"wkc\":%d,\"cycles\":%lld,\"queue_depth\":%d}", + g_bus_open, g_op, g_loop_running, g_slavecount, (int)g_wkc, g_total, qd); + send_json(cs, resp); goto done; } + if (!strcmp(path, "/api/native/enctest")) { + /* 纯编码对照(不碰网卡):跑 fill_can_out 返回每用例 384B hex, + 与 Python _fill_can_out 逐字节比对。case 顶层键必须先于 batch 出现 + (手搓解析器取首次匹配)。 */ + static frame_t bfr[64]; + const char *p = strstr(body, "\"cases\""); + char tok[80]; + int nc = 0; + p = p ? strchr(p, '[') : NULL; + rl = snprintf(resp, 512 * 1024, "{\"hexes\":["); + while (p && nc < 32 && (p = strchr(p, '{')) != NULL) { + char casebuf[4096]; + int depth = 0, j, on, q, nb = 0, win = 0, dlc; + double v; + frame_t first; + for (j = 0; p[j]; j++) { + if (p[j] == '{') depth++; + else if (p[j] == '}') { depth--; if (!depth) break; } + } + if (depth) break; + on = j + 1; + if (on >= (int)sizeof casebuf) on = (int)sizeof casebuf - 1; + memcpy(casebuf, p, on); casebuf[on] = 0; + memset(&first, 0, sizeof first); + if (json_num(casebuf, "win", &v)) win = (int)v; + g_can_out[TX_OFF_WIN] = (uint8)win; + if (json_num(casebuf, "ch", &v)) first.ch = (int)v; + if (json_tok(casebuf, "id", tok, sizeof tok)) { + if (tok[0] == '0' && (tok[1] == 'x' || tok[1] == 'X')) first.id = (uint32)strtoul(tok + 2, NULL, 16); + else if (strchr(tok, 'x') || strchr(tok, 'X')) { + char *xp = strchr(tok, 'x'); if (!xp) xp = strchr(tok, 'X'); + first.id = (uint32)strtoul(xp + 1, NULL, 16); + } else first.id = (uint32)strtoul(tok, NULL, 10); + } + if (json_tok(casebuf, "data", tok, sizeof tok) && tok[0]) + first.dlc = hex_decode(tok, first.data, CAN_FD_DATA_MAX); + if (json_num(casebuf, "dlc", &v) && v >= 0) first.dlc = (int)v; + { /* batch 数组 */ + const char *ba = strstr(casebuf, "\"batch\""); + if (ba && (ba = strchr(ba, '[')) != NULL) { + while (nb < 32 && (ba = strchr(ba, '{')) != NULL) { + char ob[512]; + int d2 = 0, k2, o2; + for (k2 = 0; ba[k2]; k2++) { + if (ba[k2] == '{') d2++; + else if (ba[k2] == '}') { d2--; if (!d2) break; } + } + if (d2) break; + o2 = k2 + 1; + if (o2 >= (int)sizeof ob) o2 = (int)sizeof ob - 1; + memcpy(ob, ba, o2); ob[o2] = 0; + if (parse_frame(ob, &bfr[nb]) == 0) nb++; + ba = ba + k2 + 1; + } + } + } + fill_can_out(&first, bfr, nb, 0); + (void)dlc; + rl += snprintf(resp + rl, 512 * 1024 - rl, "%s\"", nc ? "," : ""); + for (q = 0; q < PDO_OUT_LEN; q++) rl += snprintf(resp + rl, 512 * 1024 - rl, "%02X", g_can_out[q]); + rl += snprintf(resp + rl, 512 * 1024 - rl, "\""); + nc++; + p = p + j + 1; + } + rl += snprintf(resp + rl, 512 * 1024 - rl, "],\"seq\":%d}", g_can_seq); + send_json(cs, resp); goto done; + } + /* ═════════ 阶段 3:配置/维护面(独占总线,镜像 Python _BUS_EXCLUSIVE_PATHS)═════════ + * 语义逐条对齐 ecat_server.py 的 api_sdo / api_eeprom_* / api_flash / api_sync_diag: + * 先停 PDO 循环再碰邮箱/EEPROM —— 两者并发是历史上邮箱楔死的诱因。 + * 唯一允许的差异:异常文本(pysoem 的 SdoError/MailboxError 消息)拿不到, + * 用 ret/wkc 数值近似,JSON 形状不变。 */ + + if (!strcmp(path, "/api/sdo")) { + char opt[8] = "", tok[32]; + static uint8 payload[EEPROM_SIZE]; + uint8 buf[SDO_BUF_MAX]; + char hexs[2 * sizeof payload + 2]; + double v; + int idx, sub, sz = 8, wkc; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + json_tok(body, "op", opt, sizeof opt); + idx = json_num(body, "index", &v) ? (int)v + : (json_tok(body, "index", tok, sizeof tok) ? (int)strtol(tok, NULL, 0) : 0); + sub = json_num(body, "sub", &v) ? (int)v + : (json_tok(body, "sub", tok, sizeof tok) ? (int)strtol(tok, NULL, 0) : 0); + if (json_num(body, "size", &v) && (int)v > 0) { + sz = (int)v; if (sz > SDO_BUF_MAX) sz = SDO_BUF_MAX; + } + if (!strcmp(opt, "read")) { + int real = sz, q; + char hx[SDO_BUF_MAX * 2 + 2]; + wkc = ec_SDOread(1, (uint16)idx, (uint8)sub, FALSE, &real, buf, EC_TIMEOUTRXM); + if (wkc > 0 && real >= 0) { + for (q = 0; q < real && q < SDO_BUF_MAX; q++) sprintf(hx + 2 * q, "%02x", buf[q]); + hx[2 * (real > SDO_BUF_MAX ? SDO_BUF_MAX : real)] = 0; + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[\"SDO 0x%04X:%d = ", idx, sub); + for (q = 0; q < real && q < SDO_BUF_MAX; q++) rl += snprintf(resp + rl, 512 * 1024 - rl, "%02X", buf[q]); + rl += snprintf(resp + rl, 512 * 1024 - rl, "\"],\"data\":\"%s\"}", hx); + } else { + rl = snprintf(resp, 512 * 1024, + "{\"ok\":false,\"log\":[\"SDO 失败: SdoError wkc=%d ret=%d\"]}", wkc, real); + } + } else { + int plen, q; + json_str(body, "hex", hexs, sizeof hexs); + plen = hex_decode(hexs, payload, (int)sizeof payload); + if (plen <= 0) { + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"SDO 失败: hex 缺失或为空\"]}"); + } else { + wkc = ec_SDOwrite(1, (uint16)idx, (uint8)sub, FALSE, plen, payload, EC_TIMEOUTRXM); + if (wkc > 0) { + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[\"SDO 0x%04X:%d <- ", idx, sub); + for (q = 0; q < plen; q++) rl += snprintf(resp + rl, 512 * 1024 - rl, "%02X", payload[q]); + rl += snprintf(resp + rl, 512 * 1024 - rl, " OK\"]}"); + } else { + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"SDO 失败: SdoError wkc=%d\"]}", wkc); + } + } + } + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + if (!strcmp(path, "/api/sync_diag")) { + uint32 vals[4]; + int i, okall = 1; + double now = now_s(), rate = -1.0; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + for (i = 0; i < 4; i++) { + uint8 b[8]; + int real = 4; + int wkc = ec_SDOread(1, 0x2000, (uint8)(i + 1), FALSE, &real, b, EC_TIMEOUTRXM); + if (wkc <= 0 || real < 4) { okall = 0; break; } + vals[i] = le32get(b); + } + if (!okall) { + g_syncprev_valid = 0; /* 基线失效(固件<1.5.31/掉线):清,别留下半个样本 */ + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"SDO 读 0x2000 失败 —— 从站固件需 ≥1.5.31 且已连接(≥PREOP)\"]}"); + goto done; + } + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[],\"sync0\":%u,\"sync1\":%u," + "\"escirq\":%u,\"uptime\":%u,\"sync0_rate\":", + vals[0], vals[1], vals[2], vals[3]); + if (g_syncprev_valid) { + double dt = now - g_syncprev_ts; + if (dt > 0.05) { + rate = (double)(uint32)(vals[0] - g_syncprev_sync0) / dt; + rl += snprintf(resp + rl, 512 * 1024 - rl, "%.1f", rate); + } else rl += snprintf(resp + rl, 512 * 1024 - rl, "null"); + } else rl += snprintf(resp + rl, 512 * 1024 - rl, "null"); + g_syncprev_valid = 1; g_syncprev_sync0 = vals[0]; g_syncprev_ts = now; + rl += snprintf(resp + rl, 512 * 1024 - rl, "}"); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + /* ESC 寄存器裸读诊断: {"start":544,"count":2} 读从站 1 寄存器(十进制)。 + * 先手发一次 LRW 再读 —— 若输出真进了 SM2,读回的 0x220 bit10 应挂起; + * lrw_wkc 直接回答"过程数据有没有到达从站"(0x001B 排查用)。 */ + if (!strcmp(path, "/api/esc/read")) { + uint8 buf[512]; + double v; + int start = 0, count = 16, i, rwkc, lwkc; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + if (json_num(body, "start", &v)) start = (int)v; + if (json_num(body, "count", &v)) count = (int)v; + if (count < 1) count = 1; + if (count > (int)sizeof buf) count = (int)sizeof buf; + ec_send_processdata(); + lwkc = ec_receive_processdata(EC_TIMEOUTRET); + rwkc = ec_FPRD(ec_slave[1].configadr, (uint16)start, (uint16)count, buf, EC_TIMEOUTRET); + rl = snprintf(resp, 512 * 1024, + "{\"ok\":%s,\"lrw_wkc\":%d,\"rd_wkc\":%d,\"hex\":\"", + rwkc > 0 ? "true" : "false", lwkc, rwkc); + for (i = 0; i < count; i++) rl += snprintf(resp + rl, 512 * 1024 - rl, "%02X", buf[i]); + rl += snprintf(resp + rl, 512 * 1024 - rl, "\"}"); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + /* ESC 寄存器裸写诊断: {"start":1056,"val":10000} 写从站 1 的 16-bit 寄存器。 */ + if (!strcmp(path, "/api/esc/write")) { + double v; + int start = 0, val = 0, wkc; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + if (json_num(body, "start", &v)) start = (int)v; + if (json_num(body, "val", &v)) val = (int)v; + wkc = ec_FPWRw(ec_slave[1].configadr, (uint16)start, (uint16)(val & 0xFFFF), EC_TIMEOUTRET); + rl = snprintf(resp, 512 * 1024, "{\"ok\":%s,\"wkc\":%d}", wkc > 0 ? "true" : "false", wkc); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + if (!strcmp(path, "/api/eeprom/read")) { + uint16 words[EE_WORDS]; + double v; + int start = 0, count = 16, wi, base; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + if (json_num(body, "start", &v)) start = (int)v; + if (json_num(body, "count", &v)) count = (int)v; + if (count < 1) count = 1; + if (count > EE_WORDS) count = EE_WORDS; + if (start < 0) start = 0; + if (start > EE_WORDS - 1) start = EE_WORDS - 1; + for (wi = 0; wi < count; wi++) words[wi] = ee_read_word((uint16)(start + wi)); + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"rows\":["); + for (base = 0; base < count; base += 8) { + rl += snprintf(resp + rl, 512 * 1024 - rl, "%s{\"addr\":%d,\"hex\":\"", base ? "," : "", start + base); + for (wi = base; wi < base + 8 && wi < count; wi++) + rl += snprintf(resp + rl, 512 * 1024 - rl, "%s%04X", wi > base ? " " : "", words[wi]); + rl += snprintf(resp + rl, 512 * 1024 - rl, "\"}"); + } + rl += snprintf(resp + rl, 512 * 1024 - rl, "],\"raw\":\""); + for (wi = 0; wi < count; wi++) rl += snprintf(resp + rl, 512 * 1024 - rl, "%04X", words[wi]); + rl += snprintf(resp + rl, 512 * 1024 - rl, "\"}"); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + if (!strcmp(path, "/api/eeprom/write")) { + int dlen, word, n = 0, nfail = 0, lsz = 0, ok; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + g_ee_logs[0] = 0; + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + json_str(body, "data", g_ee_hex, sizeof g_ee_hex); + dlen = hex_decode(g_ee_hex, g_ee_data, (int)sizeof g_ee_data); + if (dlen % 2 && dlen < (int)sizeof g_ee_data) g_ee_data[dlen++] = 0xFF; + if (dlen > EEPROM_SIZE) { + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"%dB 超过 EEPROM 容量 %dB\"]}", dlen, EEPROM_SIZE); + send_json(cs, resp); goto done; + } + for (word = 0; word < dlen / 2; word++) { + uint16 val = (uint16)(g_ee_data[2 * word] | (g_ee_data[2 * word + 1] << 8)); + if (val == 0xFFFF) continue; + if (!ee_write_word((uint16)word, val)) { /* pysoem 抛 EepromError → dispatcher 的兜底文案 */ + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"服务器异常: EepromError: word 写入失败\"]}"); goto done; + } + if (ee_read_word((uint16)word) != val) { + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "word 0x%04X 校验失败", word); + nfail++; + } + n++; + } + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "写入 %d 字, 失败 %d 字", n, nfail); + ok = (nfail == 0); + rl = snprintf(resp, 512 * 1024, "{\"ok\":%s,\"log\":[%s],\"written\":%d,\"failed\":%d}", + ok ? "true" : "false", g_ee_logs, n, nfail); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + if (!strcmp(path, "/api/eeprom/verify")) { + int dlen, word, ndiff = 0, lsz = 0, ok; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + g_ee_logs[0] = 0; + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + json_str(body, "data", g_ee_hex, sizeof g_ee_hex); + dlen = hex_decode(g_ee_hex, g_ee_data, (int)sizeof g_ee_data); + if (dlen % 2 && dlen < (int)sizeof g_ee_data) g_ee_data[dlen++] = 0xFF; + for (word = 0; word < (dlen > EEPROM_SIZE ? EEPROM_SIZE : dlen) / 2; word++) { + uint16 expect = (uint16)(g_ee_data[2 * word] | (g_ee_data[2 * word + 1] << 8)); + uint16 actual = ee_read_word((uint16)word); + if (expect != actual) { + if (ndiff <= 10) /* Python: len(diffs)<=10 时进 log —— 实际会打第 11 行,原样镜像 */ + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "word 0x%04X: 文件 0x%04X != 芯片 0x%04X", word, expect, actual); + ndiff++; + } + } + ok = (ndiff == 0); + if (ok) logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "verify: OK, 全部一致"); + else logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "verify: %d 字不符", ndiff); + rl = snprintf(resp, 512 * 1024, "{\"ok\":%s,\"log\":[%s],\"diffs\":%d}", ok ? "true" : "false", g_ee_logs, ndiff); + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + if (!strcmp(path, "/api/flash")) { + const uint8 *img; + int ilen, word, n = 0, nfail = 0, lsz = 0; + uint32 rb32; + EnterCriticalSection(&g_bus_lock); + loop_stop(); + g_ee_logs[0] = 0; + if (!g_bus_open) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"未连接\"]}"); goto done; + } + json_str(body, "data", g_ee_hex, sizeof g_ee_hex); + if (g_ee_hex[0]) { + ilen = hex_decode(g_ee_hex, g_ee_data, (int)sizeof g_ee_data); + img = g_ee_data; + if (ilen % 2 && ilen < (int)sizeof g_ee_data) { g_ee_data[ilen++] = 0xFF; } + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "烧录 上传的 bin: %d 字节", ilen); + } else { + img = ESI_EEPROM_IMAGE; + ilen = (int)sizeof(ESI_EEPROM_IMAGE); + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "烧录 gen_eeprom.py 镜像: %d 字节", ilen); + } + if (ilen > EEPROM_SIZE) { + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"%dB 超过 EEPROM 容量 %dB\"]}", ilen, EEPROM_SIZE); + send_json(cs, resp); goto done; + } + /* 字节序探针:写 0x1234 看落在读回 4 字节的哪个半字。 + * 探针字必须落在**真**的 0xFF 填充区:SII 类别区已长到 word 0x0281 + * (STRINGS/General/FMMU/SyncM/RxPDO196字/TxPDO324字,END 在 word 0x0282), + * 老脚本用的 0x0200 其实在 TxPDO 类别**内部** —— 探针后"恢复"成 0xFFFF + * 就把那一字真数据擦成空洞(本机实测芯片 word 0x0200 = 0xFFFF 而镜像是 + * 0x401B,正是这么来的;只因烧录紧随其后重写全片才没爆)。改用 0x0400, + * 在 4096B 容量内、远超 END,且被 0xFF 跳过逻辑天然忽略。 */ + if (!ee_write_word(EE_SCRATCH_WORD, 0x1234)) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"服务器异常: EepromError: word 写入失败\"]}"); goto done; + } + rb32 = (uint32)ec_readeeprom(1, EE_SCRATCH_WORD, EC_TIMEOUTEEP); + if (((rb32 & 0xFFFF) != 0x1234) && ((rb32 >> 16) != 0x1234)) { + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "EEPROM 写探针失败: 读回 %08X", rb32); + LeaveCriticalSection(&g_bus_lock); + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[%s]}", g_ee_logs); + send_json(cs, resp); goto done; + } + ee_write_word(EE_SCRATCH_WORD, 0xFFFF); + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "字节序探针 OK"); + for (word = 0; word < ilen / 2; word++) { + uint16 val = (uint16)(img[2 * word] | (img[2 * word + 1] << 8)); + if (val == 0xFFFF) continue; + if (!ee_write_word((uint16)word, val)) { + LeaveCriticalSection(&g_bus_lock); + send_json(cs, "{\"ok\":false,\"log\":[\"服务器异常: EepromError: word 写入失败\"]}"); goto done; + } + if (ee_read_word((uint16)word) != val) { + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "word 0x%04X 校验失败", word); + nfail++; + } + n++; + if (n % 64 == 0) logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, " ... 已写 %d 字", n); + } + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, "刷写完成: 成功 %d 字, 失败 %d 字", n, nfail); + if (nfail) { + /* Python api_flash 失败分支只回 {ok,log},不带 written —— 原样镜像 */ + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[%s]}", g_ee_logs); + } else { + logs_add(g_ee_logs, sizeof g_ee_logs, &lsz, ">>> 板子断电重新上电后 ESC 才会重载配置(身份字/PDI/SYNC)"); + rl = snprintf(resp, 512 * 1024, "{\"ok\":true,\"log\":[%s],\"written\":%d}", g_ee_logs, n); + } + LeaveCriticalSection(&g_bus_lock); + send_json(cs, resp); goto done; + } + + rl = snprintf(resp, 512 * 1024, "{\"ok\":false,\"log\":[\"接口不存在(native): %s\"]}", path); + send_json(cs, resp); done: closesocket(cs); free(buf); @@ -286,105 +2144,79 @@ static DWORD WINAPI conn_thread(LPVOID p) int main(int argc, char **argv) { const char *substr = "I226"; - int port = 8091, i; + int port = 8090, i, auto_up = 0, tries; + int parent_pid = 0; LARGE_INTEGER f; - ec_adaptert *ad, *pick = NULL; WSADATA wsa; - SOCKET ls; + SOCKET ls = INVALID_SOCKET; struct sockaddr_in sa; - HANDLE th; - for (i = 1; i < argc - 0; i++) { + for (i = 1; i < argc; i++) { if (!strcmp(argv[i], "--substr") && i + 1 < argc) substr = argv[++i]; else if (!strcmp(argv[i], "--port") && i + 1 < argc) port = atoi(argv[++i]); - else if (!strcmp(argv[i], "--mask") && i + 1 < argc) - g_pcore_mask = strtoull(argv[++i], NULL, 0); + else if (!strcmp(argv[i], "--mask") && i + 1 < argc) g_pcore_mask = strtoull(argv[++i], NULL, 0); + else if (!strcmp(argv[i], "--web") && i + 1 < argc) snprintf(g_webdir, sizeof g_webdir, "%s", argv[++i]); + else if (!strcmp(argv[i], "--parent-pid") && i + 1 < argc) parent_pid = atoi(argv[++i]); + else if (!strcmp(argv[i], "--no-browser")) { /* 与 Python 参数面兼容:native 本就不弹浏览器 */ } + else if (!strcmp(argv[i], "--auto")) auto_up = 1; } QueryPerformanceFrequency(&f); g_qpc2s = 1.0 / (double)f.QuadPart; InitializeCriticalSection(&g_ring_lock); InitializeCriticalSection(&g_cfg_lock); - - /* 找网卡(desc/name 含 substr) */ - for (ad = ec_find_adapters(); ad; ad = ad->next) { - if (strstr(ad->desc, substr) || strstr(ad->name, substr)) { pick = ad; break; } - } - if (!pick) { - fprintf(stderr, "[native] 找不到含 \"%s\" 的网卡\n", substr); - return 2; - } - if (!ec_init(pick->name)) { - fprintf(stderr, "[native] ec_init 失败: %s\n", pick->name); - return 3; - } - printf("[native] 打开网卡: %s\n", pick->desc); - - /* 预清理:广播 INIT|ACK 清掉上一主站(可能被强杀)残留的 AL 错误位,再重枚举。 - SSC 的 AL_ControlInd 对"错误位已置 + 请求不含 ACK(bit4)"的迁移直接忽略 - (ecatslv.c),这是 native 之前卡 PREOP 的根因 —— 与 Python motor/start 的 - 预清理同理。config_init 会重写 SM0/SM1,等效"断开重连"。 */ - ec_slave[0].state = EC_STATE_INIT | EC_STATE_ACK; - ec_writestate(0); - ec_statecheck(0, EC_STATE_INIT, 1000000); - if (ec_config_init(FALSE) <= 0) { - fprintf(stderr, "[native] 枚举从站失败(邮箱楔死需断电重启板子)\n"); - ec_close(); - return 4; - } - ec_config_map(&g_iomap); - ec_readstate(); - /* LRW 的期望 WKC:每从站读输出+1、写输入+1、回读+1 → outputsWKC*2 + inputsWKC - (与 pysoem 同式;本机 1 从站 = 1*2+1 = 3,与实测 wkc 一致) */ - g_expected_wkc = ec_group[0].outputsWKC * 2 + ec_group[0].inputsWKC; - g_slavecount = ec_slavecount; - for (i = 1; i <= ec_slavecount && i <= 16; i++) - snprintf(g_slave_names[i - 1], 64, "%s", ec_slave[i].name); - printf("[native] 枚举 %d 从站, config_map 后 AL=0x%02x, expected_wkc=%lld\n", - ec_slavecount, ec_slave[1].state & 0x0F, g_expected_wkc); - - /* config_map 内部请求 SAFEOP 不带 ACK,残留错误位下会被忽略 → 显式补一次 - SAFEOP|ACK 写,确保到 SAFEOP。 */ - ec_slave[0].state = EC_STATE_SAFE_OP | EC_STATE_ACK; - ec_writestate(0); - ec_statecheck(0, EC_STATE_SAFE_OP, 2000000); - - /* SAFEOP→OP:从站要先收到≥1帧过程数据才 ack OP,且带错误位时只有 OP|ACK - 才清错并迁移 → 边发 PD 边带 ACK 重写 OP,每 200ms 一轮,readstate 拿真实态。 - 进 OP 后再起稳态线程(避免主线程与线程并发操作同一 ecx_port 打架)。 */ - for (i = 0; i < 60; i++) { - ec_send_processdata(); - ec_receive_processdata(EC_TIMEOUTRET); - ec_slave[0].state = EC_STATE_OPERATIONAL | EC_STATE_ACK; - ec_writestate(0); - ec_readstate(); - if ((ec_slave[1].state & 0x0F) == EC_STATE_OPERATIONAL) break; - Sleep(200); - } - if ((ec_slave[1].state & 0x0F) != EC_STATE_OPERATIONAL) { - fprintf(stderr, "[native] 进 OP 失败(AL=0x%02x code=0x%04x)—— 若反复如此," - "可能是上一主站退出后 SSC 邮箱楔死,需断电重启板子\n", - ec_slave[1].state & 0x0F, ec_slave[1].ALstatuscode); - ec_close(); - return 5; - } - printf("[native] OP 达成: %d 从站\n", ec_slavecount); - - th = CreateThread(NULL, 0, pdo_worker, NULL, 0, NULL); - SetThreadPriority(th, THREAD_PRIORITY_TIME_CRITICAL); - + InitializeCriticalSection(&g_bus_lock); + InitializeCriticalSection(&g_state_lock); + InitializeCriticalSection(&g_cmd_lock); + InitializeCriticalSection(&g_rx_lock); WSAStartup(MAKEWORD(2, 2), &wsa); - ls = socket(AF_INET, SOCK_STREAM, 0); - { int one = 1; setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, (char *)&one, sizeof one); } - sa.sin_family = AF_INET; - sa.sin_port = htons((u_short)port); - sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 只绑 127.0.0.1,同 Python 口径 */ - if (bind(ls, (struct sockaddr *)&sa, sizeof sa) == SOCKET_ERROR) { - fprintf(stderr, "[native] bind %d 失败: %d\n", port, WSAGetLastError()); + + if (parent_pid) { + /* 被 esp32_server 拉起:跳过清场(会把用户开着的实例优雅关掉), + * 但父进程消失时自我了断,不留孤儿占 Npcap/端口。 */ + HANDLE t = CreateThread(NULL, 0, parent_watch, (LPVOID)(DWORD_PTR)parent_pid, 0, NULL); + if (t) CloseHandle(t); + } else { + kill_stale_instances(); + } + + if (auto_up) { + char m[4096]; + int lsz = 0; + m[0] = 0; + if (do_open(substr, m, sizeof m)) { + printf("[native] %s\n", m); fflush(stdout); + if (do_op_start(0, 0, m, sizeof m, &lsz)) { printf("[native] %s\n", m); fflush(stdout); loop_start(); } + } else fprintf(stderr, "[native] auto open 失败: %s\n", m); + } + + /* 端口漂移(镜像 _bind_server):被占就试下一个,最多 6 个。 + * 注意别把 port++ 放进 for 增量段 —— 绑定成功那轮也会走增量, + * 日志就会报出一个没在听的端口(实测监听 8090、日志写 8091)。 + * SO_REUSEADDR 保留 —— Windows 上它允许重复 bind(双监听抢连接),Python + * 同样带着它、靠"先探测+清场"兜底,这里维持同构。 */ + for (tries = 0; tries < 6 && ls == INVALID_SOCKET; tries++) { + int p = port + tries; + if (port_taken(p)) { printf("端口 %d 已被占用, 尝试 %d\n", p, p + 1); continue; } + ls = socket(AF_INET, SOCK_STREAM, 0); + { int one = 1; setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, (char *)&one, sizeof one); } + sa.sin_family = AF_INET; + sa.sin_port = htons((u_short)p); + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(ls, (struct sockaddr *)&sa, sizeof sa) == SOCKET_ERROR) { + printf("端口 %d 绑定失败(%d), 尝试 %d\n", p, WSAGetLastError(), p + 1); + closesocket(ls); ls = INVALID_SOCKET; continue; + } + listen(ls, 32); + port = p; + } + if (ls == INVALID_SOCKET) { + fprintf(stderr, "[native] 无法绑定端口 %d..%d\n", port, port + 5); return 6; } - listen(ls, 32); - printf("[native] 监听 http://127.0.0.1:%d (state/period/dbg)\n", port); + printf("PORT=%d\n", port); + printf("[native] 监听 http://127.0.0.1:%d (阶段4: 全路由 + 生命周期契约)\n", port); + fflush(stdout); for (;;) { SOCKET cs = accept(ls, NULL, NULL); diff --git a/ecat_web.bat b/ecat_web.bat index 4914d9d..1c981cd 100644 --- a/ecat_web.bat +++ b/ecat_web.bat @@ -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 diff --git a/ecat_web.vbs b/ecat_web.vbs index 94f1b8a..75afadb 100644 --- a/ecat_web.vbs +++ b/ecat_web.vbs @@ -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 diff --git a/native_enc_diff.py b/native_enc_diff.py new file mode 100644 index 0000000..92742b8 --- /dev/null +++ b/native_enc_diff.py @@ -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()) diff --git a/native_enc_golden.json b/native_enc_golden.json new file mode 100644 index 0000000..93f0967 --- /dev/null +++ b/native_enc_golden.json @@ -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 + ] +} \ No newline at end of file diff --git a/soem-src/oshw/win32/wpcap/Lib/Packet.lib b/soem-src/oshw/win32/wpcap/Lib/Packet.lib new file mode 100644 index 0000000..81618bc Binary files /dev/null and b/soem-src/oshw/win32/wpcap/Lib/Packet.lib differ diff --git a/soem-src/oshw/win32/wpcap/Lib/libpacket.a b/soem-src/oshw/win32/wpcap/Lib/libpacket.a new file mode 100644 index 0000000..4d49131 Binary files /dev/null and b/soem-src/oshw/win32/wpcap/Lib/libpacket.a differ diff --git a/soem-src/oshw/win32/wpcap/Lib/libwpcap.a b/soem-src/oshw/win32/wpcap/Lib/libwpcap.a new file mode 100644 index 0000000..65b1870 Binary files /dev/null and b/soem-src/oshw/win32/wpcap/Lib/libwpcap.a differ diff --git a/soem-src/oshw/win32/wpcap/Lib/wpcap.lib b/soem-src/oshw/win32/wpcap/Lib/wpcap.lib new file mode 100644 index 0000000..f832e04 Binary files /dev/null and b/soem-src/oshw/win32/wpcap/Lib/wpcap.lib differ diff --git a/soem-src/oshw/win32/wpcap/Lib/x64/Packet.lib b/soem-src/oshw/win32/wpcap/Lib/x64/Packet.lib new file mode 100644 index 0000000..30c1540 Binary files /dev/null and b/soem-src/oshw/win32/wpcap/Lib/x64/Packet.lib differ diff --git a/soem-src/oshw/win32/wpcap/Lib/x64/wpcap.lib b/soem-src/oshw/win32/wpcap/Lib/x64/wpcap.lib new file mode 100644 index 0000000..d5559f8 Binary files /dev/null and b/soem-src/oshw/win32/wpcap/Lib/x64/wpcap.lib differ diff --git a/src/ecat_flash_eeprom.py b/src/ecat_flash_eeprom.py index 24e8339..6d1850a 100644 --- a/src/ecat_flash_eeprom.py +++ b/src/ecat_flash_eeprom.py @@ -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(): diff --git a/src/ecat_server.py b/src/ecat_server.py deleted file mode 100644 index 9468cb4..0000000 --- a/src/ecat_server.py +++ /dev/null @@ -1,2826 +0,0 @@ -#!/usr/bin/env python3 -"""ecat_server.py — DCU LAN9252 EtherCAT 主站网页工具后端。 - -用 venv 里的 pysoem 提供 REST API,浏览器打开 ecat.html 操作: - GET / → ecat.html - GET /api/adapters → Npcap 适配器列表 - POST /api/open {substr} 连接(打开适配器+config_init) - POST /api/status {} 读身份/AL 状态 - POST /api/close {} 断开 - POST /api/eeprom/read {start,count} 读 word 返回 hex 行 - POST /api/eeprom/write {data:hex} 写+回读校验(跳过 0xFF) - POST /api/eeprom/verify {data:hex} 只读校验 - POST /api/flash {data:hex|null} 烧录(null=gen_eeprom.py 镜像) - POST /api/op/start {dc_cycle_us?,dc_shift_us?} PREOP→config_map→[DC]→SAFEOP→OP - POST /api/op/cycle {outputs:hex|null} 发送一次过程数据,返回输入 - POST /api/op/stop {} 回 INIT - POST /api/sdo/read {index,sub,size} - POST /api/sdo/write {index,sub,hex} - POST /api/sync_diag {} 读 0x2000 SYNC Diag, 返回 sync0/sync1/escirq/uptime + sync0 速率 - POST /api/shutdown {} 断开设备并退出服务进程 - -所有 pysoem 访问经全局锁串行化。日志随每个响应以 log[] 返回。 - -启动模式: --window 用 pywebview 桌面窗口(ecat_app.vbs); 默认浏览器 tab(ecat_web.vbs/bat)。 - -单实例: 本工具独占 Npcap 适配器, 两个实例并存时后起的那个抓不到从站。 -故每次启动先清掉旧实例(先请求其 /api/shutdown 让从站回 INIT, 再强杀兜底), -见 _kill_stale_instances()。 -""" -import argparse -import collections -import ctypes -import gc -import io -import json -import os -import struct -import subprocess -import sys -import threading -import time -import urllib.request -import webbrowser -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, BASE_DIR) - -try: - import pysoem -except ImportError: - print("pysoem 无法加载:请先安装 Npcap(本目录 npcap-1.88.exe,勾选 WinPcap API-compatible Mode)") - sys.exit(1) - -EEPROM_SIZE = 4096 -AL_STATE = {0x00: "NONE", 0x01: "INIT", 0x02: "PREOP", - 0x03: "BOOT", 0x04: "SAFEOP", 0x08: "OP"} - -# pywebview 窗口没有浏览器刷新按钮, 注入悬浮 ⟳ + F5/Ctrl+R 拦截(浏览器 tab 下同样无害) -# 另: WebView2 默认把 window.open / target=_blank 甩给系统浏览器; 而经 pywebview -# 在 js_api 线程里 create_window 会撞 UI 线程(实测卡死+崩溃), 故统一改为当前窗口内跳转。 -_RELOAD_SNIPPET = """ - -
↻
- - -""" -_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/") - -g_lock = threading.Lock() - -# 本进程启动时刻:用于 404 时判断"源码已改但服务没重启"(见 Handler._not_found) -SERVER_START_TIME = time.time() - -# state_check 的 timeout 单位是微秒(pysoem: "Timeout value in us"); -# 2000000us = 2s,与 SOEM 的 EC_TIMEOUTSTATE 默认值一致。 -EC_TIMEOUT_STATE_US = 2_000_000 - -g_master = None -g_desc = "" -g_op_active = False -g_httpd = None -g_child = None # 窗口模式: HTTP 服务子进程(仅 GUI 父进程使用) -g_port = None -g_win = None -g_mutex = None -g_job = None # Job Object 句柄: GUI 父进程一死内核连坐杀服务子进程 - - -def al_name(code): - return AL_STATE.get(code, f"0x{code:02X}") - - -def find_adapter(substr): - for a in pysoem.find_adapters(): - desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc - if substr.lower() in desc.lower(): - return a - return None - - -# ---------------------------------------------------------------- handlers -def api_adapters(req): - ads = [] - for i, a in enumerate(pysoem.find_adapters()): - desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc - ads.append({"index": i, "desc": desc, "name": a.name}) - return {"adapters": ads} - - -def api_open(req): - global g_master, g_desc, g_op_active - substr = str(req.get("substr") or "I226") - log = [] - adapter = find_adapter(substr) - if adapter is None: - names = [a.desc.decode() if isinstance(a.desc, bytes) else a.desc - for a in pysoem.find_adapters()] - return {"ok": False, "log": [f"没有找到描述包含 '{substr}' 的适配器"] + names} - desc = adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc - if g_master is not None: - try: - g_master.close() - except Exception: - pass - g_master = None - m = pysoem.Master() - m.open(adapter.name) - try: - n = m.config_init() - except Exception as e: - try: - m.close() - except Exception: - pass - return {"ok": False, - "log": [f"总线上没有发现从站({type(e).__name__}: {e})", - "检查 DCU 板供电、网线是否接到该网口"]} - # config_init() 返回从站数;**失败时返回 -1 而不是 0**,而 -1 是真值, - # 旧写法 `if not n` 放行了 -1,随后 g_master.slaves[0] 抛 IndexError - # (板子断电/网线拔出时点"连接"就会崩)。这里按 n <= 0 统一判失败。 - if n <= 0: - try: - m.close() - except Exception: - pass - return {"ok": False, "log": ["总线上没有发现从站——检查 DCU 板供电、网线"]} - g_master = m - g_desc = desc - g_op_active = False - log.append(f"已连接 {desc}") - st = api_status({})["slave"] - log.append(f"身份: name={st['name']!r} VendorID=0x{st['man']:08X} " - f"ProductCode=0x{st['id']:08X} AL={al_name(st['state'])}") - if st["man"] == 0xFFFFFFFF: - log.append("身份字全 F = EEPROM 无效, 请先在 EEPROM 页烧录") - return {"ok": True, "log": log, "slave": st, "desc": desc} - - -def api_status(req): - if g_master is None: - return {"ok": False, "log": ["未连接"]} - s = g_master.slaves[0] - try: - g_master.read_state() - except Exception: - pass - return {"ok": True, - "slave": {"name": s.name, "man": s.man, "id": s.id, "rev": s.rev, - "state": s.state & 0x0F, "al_status": s.al_status, - "desc": g_desc}} - - -def api_close(req): - global g_master, g_desc, g_op_active - if g_master is not None: - try: - g_master.state = pysoem.INIT_STATE - g_master.write_state() - except Exception: - pass - try: - g_master.close() - except Exception: - pass - g_master = None - g_desc = "" - g_op_active = False - return {"ok": True, "log": ["已断开"]} - - -def _eeprom_read_word(slave, word): - return bytes(slave.eeprom_read(word))[:2] - - -def api_eeprom_read(req): - if g_master is None: - return {"ok": False, "log": ["未连接"]} - start = int(req.get("start", 0)) - count = int(req.get("count", 16)) - count = max(1, min(count, EEPROM_SIZE // 2)) - start = max(0, min(start, EEPROM_SIZE // 2 - 1)) - slave = g_master.slaves[0] - words = [struct.unpack(" EEPROM_SIZE: - return {"ok": False, "log": [f"{len(data)}B 超过 EEPROM 容量 {EEPROM_SIZE}B"]} - slave = g_master.slaves[0] - n = n_fail = 0 - for word in range(len(data) // 2): - val = struct.unpack_from(" EEPROM_SIZE: - return {"ok": False, "log": [f"{len(img)}B 超过 EEPROM 容量 {EEPROM_SIZE}B"]} - log.append(f"烧录 {label}: {len(img)} 字节") - slave = g_master.slaves[0] - test = b"\x34\x12" - slave.eeprom_write(0x0200, test) - rb = bytes(slave.eeprom_read(0x0200)) - off = rb.find(test) - if off < 0: - return {"ok": False, "log": log + [f"EEPROM 写探针失败: 读回 {rb.hex().upper()}"]} - slave.eeprom_write(0x0200, b"\xFF\xFF") - log.append("字节序探针 OK") - n = n_fail = 0 - for word in range(len(img) // 2): - val = struct.unpack_from(">> 板子断电重新上电后 ESC 才会重载配置(身份字/PDI/SYNC)") - return {"ok": True, "log": log, "written": n} - - -def _require_op_ready(dc_cycle_us=0, dc_shift_us=0): - """进 OP: [预清理重枚举] → PREOP → config_map → (可选 DC) → SAFEOP → OP。返回 (log, ok)。 - - 注意 pysoem 语义:state_check(X) 只是轮询等待从站到达 X,不会发送 AL Control; - 真正请求状态迁移必须 g_master.state = X_STATE 后 write_state()。旧版本进 SAFEOP - 只 state_check 不 write_state、且从不请求 PREOP,导致从站永远停在 INIT(code=0x0000) - ——从站固件其实收到 AL Control=PREOP/SAFEOP 会立刻 ack(所有 APPL_ 迁移回调返回 0)。 - - state_check 的 timeout 单位是**微秒**(pysoem docstring: "Timeout value in us"), - 此前各处传 2000 实际只等 2ms(SOEM 默认 EC_TIMEOUTSTATE=2000000=2s);PREOP/SAFEOP - 能过只是因为从站 ack 很快,属侥幸,故统一用 EC_TIMEOUT_STATE_US。 - - dc_cycle_us=0 保持 free-run(从站 SYNC0 引脚无脉冲, Sync0_Isr 不触发); - 非 0 时在 config_map 之后、进 SAFEOP 之前配置分布时钟。 - """ - global g_op_active - log = [] - if g_master is None: - return ["未连接"], False - - # 0) 无条件预清理:完整回 INIT + config_init 重枚举(2026-09-03 01:49 复现实锤)。 - # 同一连接上反复 停止/启动 而不重枚举时,从站邮箱/PDI 通路逐轮退化:第 2 轮起 - # OP 态 SDO 读 0x2000 全部 WkcError(即 UI 的"SDO 读 0x2000 失败"),第 4 轮 - # config_map() 内 SOEM CoE 读(ethercatcoe.c ecx_SDOread 分段传输循环 memcpy - # 无边界检查,上游缺陷)吃到楔死邮箱的异常帧 → 原生访问违例(0xC0000005) - # → 服务进程闪退、GUI 连带退出。与 01:40 用户日志逐行吻合:最后一轮"进入 OP" - # 后无任何输出 = 进程死在 config_map,响应未及回传。config_init 重写 SM0/SM1 - # + 重建 slavelist,与实测恒有效的"断开重连"同机理;单从站成本 ~100ms。 - # 置于入口(而非失败后)使所有调用方(op_start/自动重试/op_cycle 兜底)全覆盖, - # 且在 s = slaves[0] 取引用之前执行(config_init 会重建 slaves 列表)。 - log.append("预清理:回 INIT + config_init 重枚举(等效断开重连,防楔死邮箱上 config_map 原生崩溃)…") - try: - g_master.state = pysoem.INIT_STATE | pysoem.STATE_ACK - g_master.write_state() - g_master.state_check(pysoem.INIT_STATE, EC_TIMEOUT_STATE_US) - g_master.config_init() - except Exception as e: - log.append(f"WARN: 预清理失败({type(e).__name__}: {e}),继续原序列") - - s = g_master.slaves[0] - - # 1) INIT -> PREOP:SSC 在此启用邮箱 SM(APPL_StartMailboxHandler 直接 ack) - # AL Control 一律带 ACK 位(bit4=0x10):SSC AL_ControlInd(ecatslv.c:1372-1387)对 - # "AL Status 错误位已置 + 请求不带 ACK + 请求≠INIT"的请求**直接 return 忽略**。 - # 上一轮看门狗 0x001B 拖回 SAFEOP/断开残留的错误位不清掉,写什么状态都石沉大海, - # 这正是重启后 "进 PREOP 失败: AL=SAFEOP code=0x0000" 的根因——1.5.45 注释里 - # "同态写即 ack"的判断是错的:错误位置位时 SSC 对同态写同样忽略,只有带 ACK - # 位的写才清错并继续执行迁移(SOEM 官方 ecatcheck 恢复套路也是写 当前态|ACK)。 - # 无错误时 ACK 位被从站无害消费,故统一带上。 - try: - g_master.state = pysoem.PREOP_STATE | pysoem.STATE_ACK - g_master.write_state() - except Exception as e: - return log + [f"请求 PREOP 失败: {type(e).__name__}: {e}"], False - if g_master.state_check(pysoem.PREOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.PREOP_STATE: - # read_state 拿真实 AL Status Code(广播 state_check 不更新 slavelist[].al_status, - # 旧报文里的 code=0x0000 是陈旧缓存)。首败常见真实码:0x0016/0x001D 类 - # INVALIDMBXCFGINPREOP —— 停止回 INIT 时 SSC MBX_StopMailboxHandler 禁用了 - # SM0/SM1(mailbox.c:359-361),CheckSmSettings 在 INIT→PREOP 要求邮箱 SM 已使能, - # 拒绝迁移且从站自身不会重新使能(死锁闭环)——自动重试里的 config_init() 重写 - # SM0/SM1(ethercatconfig.c:279-280)正是解药,与"断开重连有效"同机理。 - try: - g_master.read_state() - except Exception: - pass - st0 = g_master.slaves[0] - return log + [f"进 PREOP 失败: AL={al_name(st0.state & 0x0F)} " - f"code=0x{st0.al_status:04X}(真实值) —— 从站未 ack AL Control=PREOP。" - "code=0x0016/0x001D 类=邮箱 SM0/SM1 被停止时禁用(自动重试即恢复);" - "抓 GD32 串口看 [ECAT][AL] evt/req:有 req=0x02 却没 -> 0x02=固件问题;" - "完全没有=AL Control 没到达 ESC(查网线/主站发包)"], False - log.append("已进入 PREOP") - - # 1.5) free-run 时清残留 DC 激活(2026-09-03 00:27 卡死的根因之一)。 - # 之前跑过 DC 模式(dc_sync(True,...))会在 ESC 留下 0x0981 激活位,跨"断开重连" - # 不会消失(只有断电/主站主动清除)。残留激活使从站在 PREOP→SAFEOP 的 - # UpdateSyncManagerParameter(ecatslv.c:754)读到 DC unit active,按 DC 同步模式 - # 推导 SyncType → bDcSyncActive=TRUE → SAFEOP→OP 改为等待 PLL 序列;而 free-run - # 主站不发 SYNC0,PLL 永不 valid,从站永久挂在 SAFEOP+0x0000(等待期轨迹恒定、 - # LRW 每 7ms 也无效)。必须在 config_map(其内部请求 SAFEOP)之前清除。 - if not dc_cycle_us: - try: - s.dc_sync(False, 0, 0) - log.append("已清除残留 DC 激活(free-run)") - except Exception as e: - log.append(f"WARN: 清 DC 激活失败({type(e).__name__}: {e})") - - # 2) 映射 PDO(须在 PREOP 下经 CoE SDO 读 0x1C12/0x1C13) - try: - g_master.config_map() - except Exception as e: - # 顺带抓全部 SM 寄存器现场(各 8B: start/len/ctl/status/activate/pdi)。 - # 2026-09-03 03:00 探针实锤:楔死本质是 SSC 的 u16EscAddrSendMbx 错位 - # (响应帧写到 SM1 start+4/+8 且逐会话累积,断电才清零),主站从 0x1200 读 - # 永远对不上帧头;SM 寄存器本身(start=0x1000/0x1200,act=01)恒正确。 - # 此转储用于区分"SM 配置坏"与"SSC 地址错位"(后者:SM0/SM1 正常但 SDO - # 持续失败;固件<1.5.52 无自愈,需断电重启;1.5.52+ 守护自动写回恢复)。 - try: - sm_dump = " ".join( - f"SM{i}={bytes(s._fprd(0x0800 + i * 8, 8, 20000)).hex().upper()}" - for i in range(4)) - sm_info = (f"; SM现场[0x0800..0x081F,各8B: start,len,ctl,st,act,pdi]: {sm_dump}" - " —— SM0/SM1 配置正常却持续失败=SSC 邮箱地址错位楔死") - except Exception: - sm_info = "" - return log + [f"config_map 失败: {type(e).__name__}: {e}{sm_info}"], False - - # 注意:这里**不要**写 0x1C32:01 / 0x1C33:01。 - # - # 曾经加过这两个 SDO 写(想把 SyncType 从 FREERUN 改成 SM-Synchron),实测被从站 - # 拒绝: SdoError 0x08000022 "Data cannot be transferred ... present device state"。 - # 原因有两层,都说明这个写法本身就是错的: - # 1) 0x1C3x:01 的访问权限是 ACCESS_READ | ACCESS_WRITE_PREOP(coeappl.c 的 - # asEntryDesc0x1C3x),而 pysoem 的 config_map() 内部已把从站推到 SAFEOP, - # 此时已不允许写 —— 故报“当前状态不可写”。 - # 2) 根本不需要写:SSC 在 PREOP→SAFEOP 的 StartInputHandler() 里会**自动** - # 按 DC 寄存器和 PDO 长度推导 SyncType(ecatslv.c:849-880)。DC 未启用且 - # 有输出 PDO 时它自己就会设成 0x1C32=SM_SYNCHRON / 0x1C33=SM2_SYNCHRON, - # 并置 bEscIntEnabled=TRUE。coeappl.c 里的 FREERUN 只是上电初值。 - # 输入 PDO 不更新的真因在从站固件(dc_app.c 覆写 AL Event Mask),已在 1.5.44 修掉。 - - # 3) 主站侧直写过程数据看门狗(双保险,SOEM set_watchdog → FPWR 0x0420 = 1000ms)。 - # 从站固件 ≥1.5.45 的 configPdWatchdog() 也会把它写成 1s,但若板上固件未更新/ - # 烧录不确定,100ms 复位默认值会在 OP 等待期(LRW 间隔 ~20ms 之外的长空窗, - # 或停止/重启间隙)触发 0x001B 拖回 SAFEOP。主站在每次 config_map 后直接写, - # 不依赖固件版本。0x0420 是 ESC 寄存器,无 AL 状态限制,任何状态可写。 - try: - s.set_watchdog('processdata', 1000.0) - except Exception as e: - log.append(f"WARN: 主站侧写看门狗失败({type(e).__name__}: {e})," - "依赖从站固件 ≥1.5.45 的 1s 配置") - - # 4) 可选分布时钟 - if dc_cycle_us: - try: - has_dc = g_master.config_dc() - except Exception as e: - return log + [f"config_dc 失败: {type(e).__name__}: {e}"], False - if not has_dc: - return log + ["config_dc(): 总线上没有支持 DC 的从站 —— " - "检查 SII word1 高字节(0x0151 Sync/Latch)是否为 0xCC"], False - try: - g_master.slaves[0].dc_sync(True, dc_cycle_us * 1000, dc_shift_us * 1000) - except Exception as e: - return log + [f"dc_sync 失败: {type(e).__name__}: {e}"], False - log.append(f"DC 已启用: SYNC0 周期 {dc_cycle_us}us" - + (f", shift {dc_shift_us}us" if dc_shift_us else "")) - - # 4) PREOP -> SAFEOP - try: - g_master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK - g_master.write_state() - except Exception as e: - return log + [f"请求 SAFEOP 失败: {type(e).__name__}: {e}"], False - if g_master.state_check(pysoem.SAFEOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.SAFEOP_STATE: - try: - g_master.read_state() # 真实 AL Status Code(同 PREOP 处:广播 state_check 不刷新) - except Exception: - pass - st1 = g_master.slaves[0] - return log + [f"进 SAFEOP 失败: AL={al_name(st1.state & 0x0F)} " - f"code=0x{st1.al_status:04X}(真实值) —— 若卡在 code 0x001A/0x0011 等,查 SM2/SM3 " - "或 PDO 长度与 dc_app.c 是否一致"], False - log.append("已进入 SAFEOP") - - # 5) SAFEOP -> OP - # - # 关键(ETG.1000.6 / SSC ecatslv.c:1993):从站有输出 PDO 时,SAFEOP->OP 的条件是 - # if (nPdOutputSize == 0 || bEcatFirstOutputsReceived) - # 即**必须先收到至少一帧过程数据**才会 ack OP,否则 SSC 把迁移挂起 - # (bApplEsmPending) 等数据;等不到就在自己的 ESM 超时里回落 SAFEOP。 - # 现象正是 "进 OP 失败: AL=PREOP/SAFEOP code=0x0000"——code=0 说明不是被拒绝 - # (被拒会给 0x001B SMWATCHDOG / 0x001A SYNCERROR),而是从没被喂过数据。 - # 因此这里必须边发 processdata 边等 OP,不能只 write_state 后干等。 - # - # 2026-09-02 实测发现的重启卡死(概率约 1/5):停止后再启动时从站已回落到 PREOP, - # SSC 的 AL_ControlInd 对"AL Status 带 STATE_CHANGE 错误位"的新请求会**忽略** - # (ecatslv.c:1376-1384:先清除错误位后直接 return,不生成迁移)——上一轮看门狗 - # 0x001B 拖回 SAFEOP/断开时留下的错误位没被 ack。直接 write_state(OP) 无人应答, - # 现象即"卡住,断开重连才恢复"(重新连接走 config_init→readstate 时主站发的一次 - # 状态轮询恰好完成错误 ack,之后一切正常)。 - # 2026-09-02 22:15 复测后二次修正(上一轮修复无效的三处原因): - # (a) "同态写即 ack"前提错误——SSC 仅在 AL Control 带 ACK 位(bit4=0x10)时才 - # 清错误并继续执行迁移(ecatslv.c:1373-1378),普通同态写在错误位置位时 - # 同样被忽略。所有 AL Control 写一律带 ACK(SOEM ecatcheck 恢复套路一致: - # 写 当前态|ACK 清错后再写目标态;无错误时 ACK 被从站无害消费)。 - # (b) state_check 轮询 200ms 使 OP 等待期 LRW 帧间隔 ~205ms——超过从站 - # 固件<1.5.45 的 100ms SM 看门狗,CheckIfEcatError() 每 100ms 把 - # bEcatFirstOutputsReceived 清回 FALSE,SAFEOP→OP 挂起到 ESM 超时回落。 - # (c) 回落判断必须先 0x0F 掩掉错误位:PREOP+错误=0x12>0x04,旧代码 - # `state < SAFEOP_STATE` 漏判,重写的 OP 又被 SSC 忽略,空转到 3s 超时。 - # 2026-09-03 00:04 三次修正(SOEM v1.4 源码 ecx_statecheck/ecx_writestate 核对后): - # (d) 旧循环用 state_check(OP, 20ms) 查询——该函数在 slave=0 广播模式下走 BRD, - # **不更新 slavelist[0].ALstatuscode**,报错里的 code 是 config_map 时的 - # 陈旧缓存(恒 0x0000),真实错误码被隐藏;且其内部 1ms 间隔轮询,20ms 内 - # 发 ~15 帧 BRD,3s 等待期 ~1500 帧广播风暴。现改用 read_state()(每轮 - # 1 帧 FPRD,同时刷新真实 state+al_status),失败时输出的是**真实错误码**。 - # (e) 状态轨迹:记录等待期从站状态变化序列(去重),失败时随错误码一起报出, - # 下次失败可直接看到从站被谁打了下去(如 SAFEOP→PREOP 掉落瞬间)。 - # (f) 掉 PREOP 恢复时 state_check(SAFEOP, 2s) 阻塞过长(吞掉 3s 预算的 2/3), - # 从站 PREOP→SAFEOP 迁移是毫秒级,收紧到 500ms。 - g_master.state = pysoem.OP_STATE | pysoem.STATE_ACK - g_master.write_state() - - # 2026-09-03 00:27 三次修正:等待期 12s(原 3s)。从站 ESM 超时(SAFEOP2OPTIMEOUT - # =9s)只有在固件 ≥1.547(ECAT_TIMER_INT=0)才真正起效:超时后从站以 0x001A/0x001B - # 拒绝回落,本循环每 200ms 的 OP|ACK 重写会清错误位并重试,总预算 12s 才够覆盖 - # "超时(9s)+恢复(≤1s)+完成"的完整序列。固件未更新时该兜底不生效,只能靠 - # 上面的 DC 清除 + 看门狗直写规避。 - # 同时记录 LRW 的 WKC:若 wkc < expected,说明输出根本没写进 SM2(FMMU/SM2 配置 - # 或从站 SM 被禁)——这是区分"主站侧数据没到"与"从站侧不收"的关键证据。 - deadline = time.time() + 12.0 - trail = [] # 状态轨迹:(状态名, 错误码),去重记录 - last_key = None - state = None - code = 0 - wkc = None - wkc_samples = [] # (wkc, expected) 去重采样 - escirq_prev = None # 从站 ESC IRQ 计数(0x2000:3, PDI_Isr 存活证据,每秒采一次) - escirq_stalled = False - next_escirq_poll = 0.0 - next_rewrite = 0.0 # OP|ACK 重写节流:每 200ms 最多一次(避免 AL Control 写风暴) - while time.time() < deadline: - # 先喂一帧输出:让从站置上 bEcatFirstOutputsReceived - g_master.send_processdata() - wkc = g_master.receive_processdata(2000) - if not wkc_samples or wkc_samples[-1] != (wkc, g_master.expected_wkc): - wkc_samples.append((wkc, g_master.expected_wkc)) - # 每秒采一次从站 ESC IRQ 计数(SDO 0x2000:3,固件≥1.5.31):持续不变 = - # PDI_Isr 没在触发(AL Event Mask 0x204 丢 SM2 位 / IRQ 通路断)—— - # 这正是 WKC=3/3 却 0x001B 挂起的判别性证据 - if time.time() >= next_escirq_poll: - next_escirq_poll = time.time() + 1.0 - try: - irq = struct.unpack("= next_rewrite: - g_master.state = pysoem.OP_STATE | pysoem.STATE_ACK - g_master.write_state() - next_rewrite = time.time() + 0.2 - time.sleep(0.005) # 循环节奏 ~7ms:LRW 间隔稳低于任何看门狗配置 - - if state != pysoem.OP_STATE or code != 0: - try: - g_master.read_state() - except Exception: - pass - st = g_master.slaves[0] - trail.append((al_name(st.state & 0x0F), st.al_status)) - # 读从站实际生效的过程数据看门狗(0x0420):100ms=旧固件且直写也没生效, - # 1000ms=1.5.45+ 或主站直写已生效 - try: - wd_ms = s.get_watchdog('processdata') - wd_info = f", 从站 PD 看门狗={wd_ms:.0f}ms" - except Exception: - wd_info = "" - irq_info = f", 从站 escirq={escirq_prev}" + ("(停滞!PDI_Isr 未触发→0x204 丢 SM2 位,固件≥1.5.48 会自愈)" if escirq_stalled else "") - wkc_info = " / ".join(f"wkc={w}/{e}" for w, e in wkc_samples[:6]) - return log + [f"进 OP 失败: AL={al_name(st.state & 0x0F)} " - f"code=0x{st.al_status:04X}(真实值{wd_info}{irq_info}) —— " - f"等待期轨迹: {' -> '.join(f'{n}+0x{c:04X}' for n, c in trail)}; " - f"LRW WKC: {wkc_info}。" - "code=0x001B(SM watchdog)=数据没真正进 SM2;" - "code=0x001A=DC SYNC 序列问题(ESC 有残留 DC 激活?固件<1.5.47 清不掉);" - "code=0x001D/0x001E=SM 配置不匹配;" - "code=0x0000=从站在等首帧输出(wkc 0: - data = bytes.fromhex(out_hex) - data = (data + bytes(out_len))[:out_len] - s.output = data - g_master.send_processdata() - wkc = g_master.receive_processdata(2000) - inp = bytes(s.input) - st = {"wkc": wkc, "expected_wkc": g_master.expected_wkc, - "input": inp.hex().upper(), "out_len": out_len, "in_len": len(inp)} - if len(inp) >= 32: - st["u64"] = [f"0x{x:X}" for x in struct.unpack("<4Q", inp[:32])] - return {"ok": True, "log": [], **st} - - -# ---------------------------------------------------------------- CAN over PDO -# 从站固件 1.5.56 起过程数据扩到 384B/640B —— LAN9252 的硬上限 -# (布局定义见 DCU_APP/GD32E508_DCU_APP/comm/ethercat_can_bridge.h)。 -# -# 为何是 384+640:邮箱占 0x1000-0x13FF,过程数据区只剩 0x1400-0x1FFF=3072B, -# SM2/SM3 都是 3-buffer 实占 3×size ⇒ out+in ≤ 1024B。384+640 恰好用满。 -# -# 输出 PDO(主站→从站, 384B): -# 段1 [0..47] 单帧发送,与 1.5.55 逐字节兼容 -# [0]seq [1]ch [2]dlc [3]flags [4..7]id(LE) [8]win_base -# [9..15]rsvd [16..47]data(32B)。dlc=0 表示本次只发批量区 -# 段2 [48..383] CANFD 批量发送区: [48]count [49]flags [50..51]rsvd -# [52..] 帧数组,每项 (8B 头 + dlc 数据) 紧密排列 -# 帧头: [0]ch [1]dlc [2]flags [3]result [4..7]id(LE) -# 输入 PDO(从站→主站, 640B): -# 段1 [0..31] 桥状态 + 短快照,与 1.5.53 逐字节兼容(CAN 调试页照旧可用) -# 段2 [32..319] 电机状态窗口: [32]active [33]boot [34]win_base [35]win_count -# [36..39]global_seq [40..] 7×40B 槽 -# 槽: [0]node [1]ch [2]online [3]dlc [4..7]seq [8..39]STATUS(32B) -# 段3 [320..359] 最近一帧完整快照(版本/CONFIG 帧 DLC24 装得下) -# 段4 [360..639] CANFD 批量接收区: [360]count [361]lost [362]pending -# [363]rsvd [364..] 帧数组,项布局同发送向 -PDO_OUT_LEN = 384 -PDO_IN_LEN = 640 - -CAN_TX_DATA_MAX = 32 # 段1 单帧数据区(一帧电机 CMD 是 DLC 32) -CAN_RX_DATA_MAX = 8 # 段1 短快照,保持兼容 -CAN_XRX_DATA_MAX = 32 # 段3 长快照 -CAN_FD_DATA_MAX = 64 # 批量区单帧上限(CANFD 全量) - -TX_OFF_WIN = 8 -TX_OFF_DATA = 16 - -# 批量帧项头布局(收发两向共用) -FRM_OFF_CH = 0 -FRM_OFF_DLC = 1 -FRM_OFF_FLAGS = 2 -FRM_OFF_RES = 3 -FRM_OFF_ID = 4 -FRM_HDR_SIZE = 8 - -# 段2: 批量发送区 -TXB_OFF_COUNT = 48 -TXB_OFF_FLAGS = 49 -TXB_OFF_ITEMS = 52 -TXB_BYTES = PDO_OUT_LEN - TXB_OFF_ITEMS # 332B -TXB_MAX_FRAMES = TXB_BYTES // (FRM_HDR_SIZE + CAN_FD_DATA_MAX) # 4 (全 DLC64) - -MOTW_OFF_ACTIVE = 32 -MOTW_OFF_BOOT = 33 -MOTW_OFF_WIN_BASE = 34 -MOTW_OFF_WIN_COUNT = 35 -MOTW_OFF_GLOBAL_SEQ = 36 -MOTW_OFF_SLOTS = 40 -MOT_SLOT_SIZE = 40 -MOT_SLOTS_PER_PDO = 7 -MOT_STATUS_DLC = 32 - -XRX_OFF_CH = 320 -XRX_OFF_DLC = 321 -XRX_OFF_FLAGS = 322 -XRX_OFF_ID = 324 -XRX_OFF_DATA = 328 - -# 段4: 批量接收区 -RXB_OFF_COUNT = 360 -RXB_OFF_LOST = 361 -RXB_OFF_PENDING = 362 -RXB_OFF_ITEMS = 364 -RXB_BYTES = PDO_IN_LEN - RXB_OFF_ITEMS # 276B -RXB_MAX_FRAMES = RXB_BYTES // (FRM_HDR_SIZE + CAN_FD_DATA_MAX) # 3 (全 DLC64) - -# 电机状态表规格(须与 GD32 device/motor_state.h 一致) -MOTOR_MAX_NODES = 9 -MOTOR_CHANNELS = 3 -MOTOR_SLOT_COUNT = MOTOR_MAX_NODES * MOTOR_CHANNELS # 27 -# 扫完全表需要的窗口数(27 槽 / 7 槽每窗 = 4)。仅用于开机首圈全表扫描与发现 -# 扫描的节奏;**稳态下单槽刷新率不再除以它** —— 窗口会收缩到只盖有主的槽, -# 见 _cover_bases。前端要显示"数据多新"应读快照里的 slot_period_ms。 -MOTOR_SCAN_CYCLES = -(-MOTOR_SLOT_COUNT // MOT_SLOTS_PER_PDO) # 4 - -# 发送序号:每调一次 /api/can/send 自增。固件按"变化沿"触发发送,所以 -# 必须由主站维护单调递增的序号,而不能每次都发同一个值。 -_can_seq = 0 -# 最近一次要写进输出 PDO 的字节(在后续周期里保持不变,固件靠 seq 去重, -# 不会重复发送;保持不变是为了让 seq 字段稳定可回显)。 -_can_out = bytearray(PDO_OUT_LEN) - -CAN_TX_RESULT_DESC = { - 0x00: "OK", - 0xE2: "BUS_OFF", - 0xE3: "TEC 过高", - 0xE4: "邮箱满", - 0xE5: "参数无效", -} - - -def _can_pump(cycles=3): - """跑若干个 PDO 周期,让输出帧下发、输入帧回读。 - - 固件的发送在 APPL_Application 里执行、结果在同周期写进输入 PDO, - 但主站的一次 send/receive 只保证"这一帧过程数据被交换", - 要读到 ack 回显通常需要再跑 1~2 个周期,故默认 3 个。 - """ - s = g_master.slaves[0] - out_len = len(bytes(s.output)) - wkc = 0 - for _ in range(max(1, cycles)): - if out_len > 0: - s.output = bytes(_can_out[:out_len]).ljust(out_len, b"\x00") - g_master.send_processdata() - wkc = g_master.receive_processdata(2000) - time.sleep(0.002) - return wkc, bytes(s.input) - - -def _can_decode_input(inp): - """把输入 PDO 的段 1(桥状态 + 短快照)解成结构化状态。 - - 段 1 布局自 1.5.53 未变,故本函数不随扩容改动 —— CAN 调试页照旧可用。 - 段 3 的完整 payload 由 _decode_xrx() 单独解。 - """ - if len(inp) < 32: - return {"valid": False} - flags = inp[3] - dlc = inp[2] - st = { - "valid": True, - "ack": inp[0], - "rx_valid": bool(flags & 0x01), - "truncated": bool(flags & 0x02), - "ch": inp[1], - "dlc": dlc, - "id": struct.unpack_from(" PDO_OUT_LEN: - break - _can_out[off + FRM_OFF_CH] = ch & 0xFF - _can_out[off + FRM_OFF_DLC] = dlc - _can_out[off + FRM_OFF_FLAGS] = 0 - _can_out[off + FRM_OFF_RES] = 0 - struct.pack_into(" PDO_OUT_LEN: - break - off += FRM_HDR_SIZE + dlc - n += 1 - return n - - -def _decode_rx_batch(inp): - """解码输入 PDO 的批量接收区(段4)。 - - count 是从站声明的帧数,但边界判据始终是"头+数据是否完整落在 PDO 内" —— - 不信任对端给的计数,越界即停。 - """ - if len(inp) <= RXB_OFF_ITEMS: - return {"count": 0, "lost": 0, "pending": 0, "frames": []} - count = inp[RXB_OFF_COUNT] - frames = [] - off = RXB_OFF_ITEMS - for _ in range(min(count, RXB_BYTES // FRM_HDR_SIZE)): - if off + FRM_HDR_SIZE > len(inp): - break - dlc = inp[off + FRM_OFF_DLC] - if dlc > CAN_FD_DATA_MAX or off + FRM_HDR_SIZE + dlc > len(inp): - break - frames.append({ - "ch": inp[off + FRM_OFF_CH], - "dlc": dlc, - "valid": bool(inp[off + FRM_OFF_FLAGS] & 0x01), - "id": struct.unpack_from("0 说明主站该提速 - "frames": frames, - } - - -def _fill_can_out(ch, can_id, dlc, data, clear_stats=False, win_base=None, - batch=None, stop_on_error=False): - """把一帧待发 CAN 写进输出 PDO 暂存区,并推进 seq(变化沿触发发送)。 - - win_base=None 表示保持当前的电机状态窗口下标不动 —— 窗口滚动由 - _pdo_worker 独占推进,发送路径不该顺手把它改回去。 - - batch 给出额外的多帧列表 [(ch,id,dlc,data)],一并放进段2 批量区, - 与单帧共用同一个 seq 沿:从站在一次 processOutput 里先发单帧再发批量。 - dlc=0 表示只发批量、不发单帧。 - """ - global _can_seq - _can_seq = (_can_seq + 1) & 0xFF - if _can_seq == 0: # 0 与固件的"未初始化"基准无法区分,跳过 - _can_seq = 1 - keep_win = _can_out[TX_OFF_WIN] if win_base is None else (win_base & 0xFF) - - _can_out[:] = bytes(PDO_OUT_LEN) - _can_out[0] = _can_seq - _can_out[1] = ch - _can_out[2] = dlc - _can_out[3] = 0x01 if clear_stats else 0x00 - struct.pack_into(" CAN_TX_DATA_MAX: - return {"ok": False, "log": [f"DLC {dlc} 超出 PDO 单帧上限 {CAN_TX_DATA_MAX}"]} - if len(data) < dlc: - data = data.ljust(dlc, b"\x00") - - seq = _fill_can_out(ch, can_id, dlc, data, bool(req.get("clear_stats"))) - - wkc, inp = _can_pump() - st = _can_decode_input(inp) - acked = st.get("ack") == seq - txres = st.get("tx_result", 0) - - log = [f"已下发 CH{ch} 0x{can_id:X} DLC{dlc} " - f"{data[:dlc].hex().upper() or '(空)'} seq={seq}"] - if not acked: - log.append(f"警告: 从站回显 ack={st.get('ack')} != seq={seq}," - "过程数据可能未被应用层处理(确认已进 OP、固件 ≥1.5.55)") - elif txres != 0: - log.append(f"从站发送失败: {st.get('tx_result_text')} " - "(0xE4 邮箱满=总线无应答/未接收器;0xE2 BUS_OFF=接线或终端电阻)") - return {"ok": True, "log": log, "wkc": wkc, - "expected_wkc": g_master.expected_wkc, "seq": seq, - "acked": acked, **st} - - -def api_can_state(req): - """跑一个 PDO 周期并回读 CAN 桥状态(接收帧 + 计数)。""" - if g_master is None: - return {"ok": False, "log": ["未连接"]} - if not g_op_active: - return {"ok": False, "log": ["未进 OP —— 先点『进入 OP』或发送一帧"]} - # 电机后台循环在跑时不要再自己踩 PDO:直接给它的最新快照, - # 否则两个写者抢 s.output,且额外周期会打乱窗口滚动节奏。 - snap = _motor_snapshot() - if snap is not None and snap.get("bridge"): - return {"ok": True, "log": [], "wkc": snap.get("wkc", 0), - "expected_wkc": snap.get("expected_wkc", 0), **snap["bridge"]} - wkc, inp = _can_pump(cycles=1) - return {"ok": True, "log": [], "wkc": wkc, - "expected_wkc": g_master.expected_wkc, **_can_decode_input(inp)} - - -# ------------------------------------------------------------ 电机控制台后端 -# -# 为什么必须有后台循环线程,而不是沿用「一次 HTTP 请求 = 一次 PDO 周期」: -# -# _require_op_ready 给 SM2 写了 1000ms 过程数据看门狗(set_watchdog)。请求驱动 -# 的周期一旦被任何慢调用挡住(EEPROM 烧写数十秒、_require_op_ready 最长 12s、 -# 甚至浏览器后台标签把 setInterval 节流到 ≥1s),从站就会 OP→SAFEOP 报 -# AL 0x001B,而 g_op_active 仍是 True —— 服务端继续"报告成功",故障静默。 -# 所以周期性喂数据是**正确性需求**,不只是性能优化。 -# -# 线程纪律(与本文件既有 g_lock 约定共存): -# - 只有本线程调 send_processdata/receive_processdata,HTTP 路由一律读快照; -# - 每周期取一次 g_lock,睡眠**在锁外**,绝不持锁 sleep; -# - CoE/EEPROM 类路由本身持 g_lock,与本线程天然互斥(邮箱楔死风险见上方注释); -# - 快照用整体 dict 重绑定发布,读者无需加锁(CPython 引用赋值原子)。 -_motor_thread = None -_motor_stop = threading.Event() -_motor_snap = None # 最新快照(整体替换,不原地改) -_motor_cmd_q = collections.deque() # 待下发的 CAN 帧队列(线程安全) -# 批量接收区回收到的帧(有界)。1.5.56 起从站一周期能回多帧,前端按需一次取走; -# maxlen 防前端长时间不取时内存无限增长 —— 满了丢最旧的,快照里 lost/pending -# 已经能告诉前端"有没有漏"。 -_motor_rx_log = collections.deque(maxlen=2000) -# 最近若干个 PDO 周期的完整时间线 (t0, send_start, recv_start, recv_end, -# loop_end, grid_skips),单位 perf_counter。t0=循环顶(取锁前),loop_end=算完 -# 下一拍截止点的瞬间 —— 这两点之间才是"这一拍真正占用的时间"。 -# 为什么要拆到这一层:cyc_us 只量 send/receive 一段,而 250us/200us 档 96~97% -# 的达成率**不随档位变化** —— 说明丢的拍不是"总线差几十 us"这种比例性的事, -# 更像有一类固定频率的事件(GC 暂停/HTTP 线程抢 GIL/系统杂活)一拍吃掉一整个 -# 时隙。没被量到的区间(锁等待、窗口解码+快照构建、每 8 拍的分位数排序、自旋 -# 结束后重拿 GIL)全在 loop_end-t0 与 recv_end-t0 的差里。原始数据由 -# /api/motor/dbg 导出,和 GC/HTTP 事件时间线做交集归因;热循环只多一次元组重建。 -_cyc_t = collections.deque(maxlen=2048) -_skip_total = 0 # 本周期设置以来网格跳过的拍数合计(仅 PDO 线程写) - -# GC .stop-the-world 事件 (结束时刻, 时长us, 代数, 触发线程名)。gen2 收集会停 -# 所有线程几 ms,是"某一拍凭空超时"的头号嫌疑;记下来才能和时间线对质。 -_gc_events = collections.deque(maxlen=1024) -_gc_start = {} -# 增量计数器 [c0,c1,c2, max0,max1,max2, pdo线程触发数]。_cyc_stats 每次拍快照 -# 都拷一份进 cyc_us.gc —— 必须是 O(1),绝不在热循环里扫 deque。 -_gc_stat = [0, 0, 0, 0.0, 0.0, 0.0, 0] - - -def _gc_cb(event, info=None): - """gc.callbacks 回调,记录每次分代收集的起止。CPython 3.12 的调用形状是 - callback(event: str, info: dict) —— event 为 'start'/'stop',info 带 - generation/collected;参数顺序写反会让 .get 抛 AttributeError 被自己的 - try 吞掉,得到"GC 从没发生过"的假象(2026-09-04 实踩)。回调在"触发收集的 - 那个线程"上跑,所以 PDO 线程自己分配触发 gen0 时线程名就是 pdo-cycle。""" - try: - gen = (info or {}).get("generation") - if gen is None or gen < 0 or gen > 2: - return - if event == "start": - _gc_start[gen] = time.perf_counter() - elif event == "stop": - t = _gc_start.pop(gen, None) - dur = (time.perf_counter() - t) * 1e6 if t else 0.0 - name = threading.current_thread().name - _gc_events.append((time.perf_counter(), round(dur, 1), gen, name)) - _gc_stat[gen] += 1 - if dur > _gc_stat[3 + gen]: - _gc_stat[3 + gen] = round(dur, 1) - if name == "pdo-cycle": - _gc_stat[6] += 1 - except Exception: - pass # 观测器绝不允许把被观测的系统带崩 - - -gc.callbacks.append(_gc_cb) - -# HTTP 请求进/出时间线 (t_in, t_out, path)。ThreadingHTTPServer 每个请求一个 -# 线程,序列化快照的那几百 us 都持着 GIL —— PDO 线程自旋结束时要在 GIL 门口 -# 排队,这就是"自旋明明是准的,拍还是迟了"的另一号嫌疑。 -_http_t = collections.deque(maxlen=2048) -# ── cyc_us 分位数**绝不在 PDO 循环里算**(2026-09-04 实测定罪) ── -# 旧版每 8 拍调一次 _cyc_stats:7 段×400 样本的排序要 400~600us,比一整个 -# 250us 时隙还大 —— 时间线按拍号 mod 8 严丝合缝地超时(真机 194/250 个超时拍 -# 恰好间隔 8),达成率被自家观测代码从 97% 打到 86.6%。分位数只有 HTTP 读者 -# 要看,就让他们(在请求线程里)按需算,带 TTL 去重;没人看时热循环零成本。 -_CYC_TTL_S = 0.25 -_cyc_cached = None # 最近一次算好的分位数(任何线程可写,GIL 下原子) -_cyc_cached_t = 0.0 -_motor_period_ms = 4.0 # 后台周期,默认 4ms(250Hz) —— 远快于 1s 看门狗 -_motor_win = 0 # 当前窗口起始槽号,由本线程独占推进 - -# PDO 周期的允许范围。下限 62.5us(16kHz) 只是**允许请求**的最小值;实际跑不跑 -# 得到由物理决定:真机一送一回 ≈165us,比这更快会自然自限(周期跑超目标值, -# 循环本身不会坏)。留这么低是为了极限测速时能直接报目标档而不用改代码。 -MOTOR_PERIOD_MIN_MS = 0.0625 -MOTOR_PERIOD_MAX_MS = 1000.0 - - -def _fmt_period(ms): - """周期的显示写法:<1ms 用 µs,与前端状态栏保持一致。""" - return f"{ms * 1000:g}us" if ms < 1 else f"{ms:g}ms" - - -def _parse_period_ms(req): - """从请求里取 period_ms(float,允许亚毫秒) 或 period_us(int),钳到允许范围。 - - 亚毫秒档位(0.25ms=4kHz 等)只在自旋节拍下才有意义:内核睡眠在 Windows 上 - 粒度 ~0.5ms,根本睡不出 250us。周期与自旋的组合由 _spin_effective 判断, - 这里只管解析,不掺节拍策略。 - """ - v = req.get("period_us") - if v is None: - v = req.get("period_ms") - if v is None: - return None - ms = float(v) - else: - ms = float(v) / 1000.0 - if ms != ms: # NaN 请求直接当没给 - return None - if ms <= 0: # 0/负数一律视为"没给",别被钳成最小周期 - return None - return max(MOTOR_PERIOD_MIN_MS, min(MOTOR_PERIOD_MAX_MS, ms)) - -# ── 窗口自适应:别把 EtherCAT 当以太网用 ── -# -# 旧实现每周期把窗口 +7 无脑轮扫 27 个槽(4 周期一圈),于是**单个电机**的状态 -# 只有 周期率/4 的更新速度 —— 实测 4ms 周期下总线跑 225Hz,而每个槽只有 56Hz。 -# 而 27 个槽里通常只有 2 个有主(9 节点×3 通道是**容量**,不是实际接入数), -# 也就是说 4 个窗口里 3 个整窗都在传空槽,280B/周期只有 80B 有效。 -# -# 关键事实(读固件 ethercat_can_bridge.cpp:248 得到,不是猜的):从站的 win_base -# 是**任意槽号**,它只是从 base 开始逐槽填到越界即停,并不要求 base 是 7 的倍数。 -# 所以只要让窗口盖住"有主的那几个槽",一个窗口就够 —— 每周期都刷全部在线电机, -# 单槽刷新率直接等于 PDO 周期率,快 4 倍且一行总线开销都没多加。 -# -# 仍需周期性全表扫描:新插上的电机若落在窗口外,不扫就永远发现不了。 -# 每 MOTOR_DISCOVERY_EVERY 周期插一个发现窗口,4ms 周期下全表 ≈1s 覆盖一轮, -# 小于前端 STALE_OFFLINE_MS(2s),不会把新电机拖到误判离线。 -MOTOR_DISCOVERY_EVERY = 64 -# 窗口能落的最大起点:再往后窗口尾部就越界了,白扔覆盖能力。 -MOTOR_WIN_BASE_MAX = max(0, MOTOR_SLOT_COUNT - MOT_SLOTS_PER_PDO) -# 发现扫描要走的窗口起点序列。必须用它而不是"每次 +7 到越界就归零": -# 27 槽 / 7 槽一窗时 0,7,14 之后是 21,而 21 > MOTOR_WIN_BASE_MAX(20), -# 朴素写法会在这里直接绕回 0,于是 slot 21..26 永远扫不到 —— 挂在 node8/node9 -# 上的电机热插后再也发现不了。末窗钳到 20 才能盖住尾部。 -MOTOR_DISCOVERY_BASES = sorted(set( - [min(b, MOTOR_WIN_BASE_MAX) - for b in range(0, MOTOR_SLOT_COUNT, MOT_SLOTS_PER_PDO)])) - -# ── 自旋调度:短周期下唯一能跑准的办法 ── -# -# 分段实测(真机, period_ms=1)把一次周期的开销拆开看: -# send_processdata 调用 ≈ 48 us -# receive_processdata 等帧 ≈ 118 us ← EtherCAT 真正的总线时间 -# 解码 + 建快照 ≈ 5 us -# 合计 ≈ 165 us → 理论上能跑 ≈6000 Hz -# 可实测周期是 1360 us。差的 1.2ms **全在"睡到下一拍"这一步**: -# Windows 的内核定时器粒度太粗,Event.wait(1ms) 实测 p50 1.075 / p90 2.003ms, -# 稳定超调 0.4~1.0ms。而且没法"先睡后自旋"补救 —— 睡过头时截止点已经过了, -# 自旋无事可做(实测 hybrid 与裸 wait 完全一样)。 -# -# 唯一能跑准的就是自旋到绝对截止点:实测 p50 1.000 / p90 1.001ms,抖动 0.000ms。 -# 代价是**吃满一个核**(≈99% CPU),所以默认只在短周期自动开启,且必须能让用户关。 -MOTOR_SPIN_AUTO_MAX_MS = 2 # 周期 ≤ 此值时自动用自旋(内核睡眠在这个档根本睡不准) -_motor_spin = None # None=按周期自动;True/False=用户显式指定 - - -# ── 自旋线程钉到 P 核(快核组)── -# -# 自旋必然 100% 占住一个逻辑处理器。这条线程是全进程最latency敏感的执行体 -# (PDO 节拍),钉到 P 核让它跑在单核性能最高、解释器段最短的核上;E 核留给 -# 系统后台杂活和 HTTP 线程的溢出。2026-09-05 按用户要求从"钉 E 核"改为"钉 P 核"。 -# -# 为什么用"实测吞吐"而不是查 API:这台机器上 -# GetLogicalProcessorInformationEx → 直接 ERROR_BAD_LENGTH(31),连 size 查询都失败 -# GetSystemCpuSetInformation → 能返回但结构布局对不上(32 组/896 字节, -# 元素大小不整除),EfficiencyClass 拿不到 -# GetDynamicProcessorCounts → 该构建里根本没导出 -# 处理器组 → 只有 1 个组,组号区分不出 P/E -# 所以退而求其次:把线程逐个钉到每个逻辑处理器上跑同一段纯计算,按耗时聚类。 -# 实测本机 P 核 11.9~16.7ms、E 核 24.7~29.7ms,中间有 8ms 的空档,分得很开。 -# 用"最大间隙"而不是固定倍数(如 0.75×最慢)分类 —— 后者会把被 SMT 兄弟拖累的 -# P 核(实测 16.7ms)误判成 E 核。 -# -# 关掉/覆盖:ECAT_PIN_PCORES=0 完全不钉;ECAT_PCORE_MASK=0xFFFF 手工指定。 -_PCORE_PROBE_ITERS = 300000 -_PCORE_PROBE_REPS = 3 -g_pcore_mask = 0 # 探测结果缓存(0 = 未探测/不可用) -g_pcore_probe_done = threading.Event() - - -def _start_pcore_probe(): - """服务启动时后台探测 P 核,赶在任何总线操作之前跑完。 - - 必须在启动阶段做而不是等 PDO 线程自己探:探测期间那个线程不喂过程数据, - 从站的 1000ms 看门狗会把它踢出 OP,重进时又可能撞上 SSC 邮箱楔死。 - """ - if os.name != "nt" or not _pcore_pin_enabled(): - g_pcore_probe_done.set() - return - env = _pcore_mask_from_env() - if env: - g_pcore_mask = env - g_pcore_probe_done.set() - return - - def _run(): - global g_pcore_mask - try: - g_pcore_mask = _detect_pcore_mask() - except Exception as e: # noqa: BLE001 - 探测失败只是不钉核,不能影响服务 - print(f"[PCORE] 探测失败,自旋线程不钉核: {type(e).__name__}: {e}", flush=True) - finally: - g_pcore_probe_done.set() - threading.Thread(target=_run, name="pcore-probe", daemon=True).start() - - -def _pcore_pin_enabled(): - """ECAT_PIN_PCORES=0 可完全关掉钉核(比如用户已经用别的工具管过亲和性)。""" - return os.environ.get("ECAT_PIN_PCORES", "1") != "0" - - -def _pcore_mask_from_env(): - """ECAT_PCORE_MASK=0xFFFF 手工指定,跳过实测(实测偶尔会被后台负载干扰)。""" - v = os.environ.get("ECAT_PCORE_MASK", "").strip() - if not v: - return 0 - try: - return int(v, 0) & 0xFFFFFFFFFFFFFFFF - except ValueError: - return 0 - - -def _probe_core_speed(lp, iters=_PCORE_PROBE_ITERS, reps=_PCORE_PROBE_REPS): - """把当前线程钉到 lp 上跑 reps 次纯计算,返回最快一次的墙钟毫秒。""" - box = [] - - def run(): - _set_thread_affinity(lp) - x = 0 - for i in range(2000): # 预热,顺带让调度器落位 - x += i - for _ in range(reps): - t0 = time.perf_counter() - y = 0 - for i in range(iters): - y += i * i - box.append((time.perf_counter() - t0) * 1000.0) - th = threading.Thread(target=run) - th.start() - th.join(timeout=10.0) - return min(box) if box else None - - -def _classify_fast(speeds): - """从 [(逻辑处理器号, 耗时ms)] 里挑出快核(P 核),返回位掩码;分不开返回 0。 - - 用"最大间隙"而不是固定倍数(如 0.75×最慢):本机实测 P 核 11.9~16.7ms、 - E 核 24.7~29.7ms,但被 SMT 兄弟拖累的 P 核也能到 16.7ms —— 固定倍数会把 - 它们误判成 E 核,间隙法不会。 - 额外要求间隙足够大(≥最快核的 25%)且两组各至少 2 个核,否则视为"这台机器 - 分不出两类核"(纯对称 CPU 或噪声太大),宁可不钉也别钉错。 - """ - if len(speeds) < 4: - return 0 - vals = sorted(s for _, s in speeds) - best_gap, best_cut = 0.0, None - for i in range(1, len(vals)): - gap = vals[i] - vals[i - 1] - if gap > best_gap: - best_gap, best_cut = gap, vals[i] - if best_cut is None or best_gap < vals[0] * 0.25: - return 0 - fast = [lp for lp, s in speeds if s < best_cut] - slow = [lp for lp, s in speeds if s >= best_cut] - if len(fast) < 2 or len(slow) < 2: - return 0 - mask = 0 - for lp in fast: - mask |= 1 << lp - return mask - - -def _detect_pcore_mask(): - """实测找出快核(P 核)集合,返回亲和性位掩码;拿不到结果返回 0。""" - n = os.cpu_count() or 0 - if n < 4: - return 0 - speeds = [] - for lp in range(n): - s = _probe_core_speed(lp) - if s is None: - return 0 - speeds.append((lp, s)) - return _classify_fast(speeds) - - -class _GROUP_AFFINITY(ctypes.Structure): - _fields_ = [("Mask", ctypes.c_ulonglong), ("Group", ctypes.c_ushort), - ("Reserved", ctypes.c_ushort * 3)] - - -def _get_thread_affinity_mask(): - """当前线程的亲和性掩码(自测用来确认钉核只影响本线程)。 - - 不能用 SetThreadAffinityMask(0) 来"只查不改" —— 掩码必须非零,那样只会失败。 - """ - if os.name != "nt": - return 0 - try: - k32 = ctypes.windll.kernel32 - ga = _GROUP_AFFINITY() - k32.GetThreadGroupAffinity.restype = ctypes.c_int - k32.GetThreadGroupAffinity.argtypes = [ctypes.c_void_p, - ctypes.POINTER(_GROUP_AFFINITY)] - if not k32.GetThreadGroupAffinity(k32.GetCurrentThread(), ctypes.byref(ga)): - return 0 - return ga.Mask or 0 - except Exception: - return 0 - - -def _set_thread_affinity(lp): - return _set_thread_affinity_mask(1 << lp) - - -def _set_thread_affinity_mask(mask): - if os.name != "nt": - return 0 - try: - k32 = ctypes.windll.kernel32 - h = k32.GetCurrentThread() - k32.SetThreadAffinityMask.restype = ctypes.c_ulonglong - k32.SetThreadAffinityMask.argtypes = [ctypes.c_void_p, ctypes.c_ulonglong] - return k32.SetThreadAffinityMask(h, mask) or 0 - except Exception: - return 0 - - -# ── 自旋必须放掉 GIL,否则会把整个服务饿死 ── -# -# 第一版自旋写成 `while time.perf_counter() < deadline: pass`,真机实测 -# HTTP 读快照的 p50 从 0.6ms 直接劣化到 **40ms** —— 页面等于废了。 -# 原因:CPython 的 GIL 切换间隔默认 5ms,而纯 Python 循环里 perf_counter() -# 是带 GIL 调用的,自旋线程可以连拿好几个 5ms 不放,其它线程(每个 HTTP 请求 -# 一个线程)全排在后面。单独复现:自旋线程存在时,别的线程 sleep(0.1ms) -# 实测 p50 5.70ms;换成下面这种放 GIL 的自旋后回到 0.54ms(即基线)。 -# -# 做法是用 ctypes 调 QueryPerformanceCounter —— ctypes 外部调用默认**释放 GIL**, -# 所以自旋期间其它线程能自由跑。精度 0.1us(QPC 频率 10MHz),绰绰有余。 -class _QpcTS(ctypes.Structure): - _fields_ = [("lo", ctypes.c_ulong), ("hi", ctypes.c_long)] - - -if os.name == "nt": - _qpc = ctypes.windll.kernel32.QueryPerformanceCounter - _qpc.restype = ctypes.c_int - _qpc.argtypes = [ctypes.POINTER(_QpcTS)] - _QPC_HZ = 1e7 # QPC 固定 10MHz - - def _spin_until(deadline_s): - """自旋到 time.perf_counter() 的绝对时刻,全程不持 GIL。""" - target = int(deadline_s * _QPC_HZ) - t = _QpcTS() - while True: - _qpc(ctypes.byref(t)) - if (t.hi * 4294967296 + t.lo) >= target: - return -else: # 非 Windows 兜底:精度差但不致命 - def _spin_until(deadline_s): - while time.perf_counter() < deadline_s: - time.sleep(0) - - -def _cover_bases(slots): - """求覆盖 slots 所需的最少窗口起点集合(贪心,窗口长 MOT_SLOTS_PER_PDO)。 - - slots 为空(还没发现任何电机)时回退成全表轮扫,否则一个槽都读不到。 - """ - if not slots: - return list(range(0, MOTOR_SLOT_COUNT, MOT_SLOTS_PER_PDO)) - bases = [] - todo = sorted(slots) - i = 0 - while i < len(todo): - # 起点尽量靠后(但不超过 todo[i]),让窗口多盖住后面的槽 - base = min(todo[i], MOTOR_WIN_BASE_MAX) - bases.append(base) - end = base + MOT_SLOTS_PER_PDO # 覆盖 [base, end) - while i < len(todo) and todo[i] < end: - i += 1 - return bases - - -def _cyc_stats(n=400): - """把最近 n 个周期的完整时间线压成分位数(微秒)。样本不足返回 None。 - - 字段口径(单位 us): - send/rx/total 取锁后→发完→收完(旧字段,前端在用,口径不能动) - pre 取锁本身(等 g_lock) + 装帧。周期越小它越显眼 —— HTTP 的 - 独占路由持锁做总线操作时会直接顶在这里。 - tail 收完→循环尾:窗口解码、快照 dict、每 8 拍的分位数排序。 - cyc_us 时代完全没量这一段,它是"看不见的税"。 - work t0→loop_end,这一拍真正占用的总时间。work>周期 才会跳拍。 - iv 相邻两拍 t0 的间隔 = 实际拍间期,均值倒数就是实测 Hz。 - late/lost 窗口里超时拍数与网格损失拍数。 - gc 分代收集计数(全周期累计,来自 _gc_stat 增量器,O(1))。 - """ - s = list(_cyc_t)[-n:] - if len(s) < 20: - return None - send = sorted((b - a) * 1e6 for _, a, b, _, _, _ in s) - rx = sorted((c - b) * 1e6 for _, _, b, c, _, _ in s) - tot = sorted((c - a) * 1e6 for _, a, _, c, _, _ in s) - pre = sorted((a - t) * 1e6 for t, a, _, _, _, _ in s) - tail = sorted((e - c) * 1e6 for _, _, _, c, e, _ in s) - work = sorted((e - t) * 1e6 for t, _, _, _, e, _ in s) - iv = sorted((s[i][0] - s[i - 1][0]) * 1e6 for i in range(1, len(s))) - - def pct(v, p): - return round(v[min(len(v) - 1, int(len(v) * p))], 1) - - def sec(v): - return {"p50": pct(v, .5), "p90": pct(v, .9), "max": round(v[-1], 1)} - return {"n": len(s), - "send": sec(send), "rx": sec(rx), "total": sec(tot), - "pre": sec(pre), "tail": sec(tail), "work": sec(work), "iv": sec(iv), - "late": sum(1 for x in s if x[5] > 0), - "lost": sum(x[5] for x in s), - "gc": {"n": list(_gc_stat[:3]), "max_us": list(_gc_stat[3:6]), - "pdo": _gc_stat[6]}} - - -def _cyc_fresh(max_age=_CYC_TTL_S): - """读侧按需取分位数:距上次计算超过 max_age 秒才重算,否则吃缓存。 - 计算发生在**调用方线程**(HTTP),PDO 线程绝不碰排序。见 _CYC_TTL_S 注释。""" - global _cyc_cached, _cyc_cached_t - now = time.perf_counter() - if _cyc_cached is None or now - _cyc_cached_t > max_age: - _cyc_cached = _cyc_stats() - _cyc_cached_t = now - return _cyc_cached - - -def _spin_effective(): - """本周期到底会不会自旋(供日志/快照/前端显示,别让用户猜 CPU 去哪了)。""" - if _motor_spin is not None: - return _motor_spin - return _motor_period_ms <= MOTOR_SPIN_AUTO_MAX_MS - - -if os.name == "nt": - _SET_TPR = ctypes.windll.kernel32.SetThreadPriority - _SET_TPR.argtypes = [ctypes.c_void_p, ctypes.c_int] - _GET_CUR_TH = ctypes.windll.kernel32.GetCurrentThread - _GET_CUR_TH.restype = ctypes.c_void_p # 伪句柄 -2,必须按指针宽度传,x64 上不能当 int - - def _set_spin_priority(spin: bool): - """自旋拍给线程挂 TIME_CRITICAL,普通拍回落 NORMAL。 - - 250us 档实测卡在 ~89% 达成率:截止点自旋本身是准的,但线程按普通优先级 - 时零星被系统杂活抢占,每次迟到几十 us 就从周期预算里真扣掉几十 us。 - 钉核只是选了一群核,没规定这群核上谁先跑。钉到 P 核组后这条理由依然 - 成立:P 组里还有 HTTP 线程和解释器主线程,TIME_CRITICAL 保证自旋线程 - 优先占住自己那个 LP,HTTP 溢出到组内其余核(本机 16 个 P 线程,远多于 - 并发 HTTP 线程数);不自旋时回落 NORMAL,别白白压着内核睡眠路径的响应性。 - """ - try: - _SET_TPR(_GET_CUR_TH(), 15 if spin else 0) # 15=TIME_CRITICAL, 0=NORMAL - except Exception: # 优先级只是锦上添花 - pass -else: - def _set_spin_priority(spin: bool): - pass - - -def _motor_wait_until(deadline_s): - """等 perf_counter 时间轴上的**绝对**截止点,可被 _motor_stop 立刻打断。 - - 自旋:精度到 us(实测抖动 0.000ms),代价是吃满一个核。 - 内核睡眠:省 CPU,但 Windows 粒度会让它稳定超调 0.4~1.0ms。 - 已过点直接返回(调用方靠重排网格决定是否追拍,见 _pdo_worker)。 - """ - now = time.perf_counter() - if deadline_s <= now: - _motor_stop.is_set() - return - if _spin_effective(): - # 自旋必须走 _spin_until(内部放 GIL)。写成纯 Python 循环会把 HTTP - # 线程饿死 —— 真机实测读快照 p50 从 0.6ms 劣化到 40ms。 - # 代价是自旋期间不响应 _motor_stop,但单次自旋 ≤ 一个周期(≤1s), - # 停线程最坏多等一拍,可接受。 - _spin_until(deadline_s) - _motor_stop.is_set() - else: - _motor_stop.wait(deadline_s - now) - - -def _motor_snapshot(): - return _motor_snap - - -def _pdo_worker(): - """后台 PDO 循环:持续喂看门狗、滚动扫描电机状态窗口、下发排队的 CAN 帧。""" - global _motor_snap, _motor_win, g_op_active, _skip_total - # 自旋会 100% 占住一个逻辑处理器,钉到 P 核组让节拍跑在单核性能最高的核上。 - # **只读缓存,绝不在这儿现场探测**:探测要跑 1.6s,而这段时间本线程不喂 - # 过程数据,从站 SM2 的 1000ms 看门狗会直接把它踢出 OP —— 正是把板子弄楔死 - # 的那条路径。探测由 _start_pcore_probe() 在服务启动时(还没碰总线)后台做完。 - if _pcore_pin_enabled() and g_pcore_probe_done.is_set() and g_pcore_mask: - _set_thread_affinity_mask(g_pcore_mask) - slots = {} # 槽号 -> 槽快照(跨周期累积,窗口只覆盖自己那部分) - head = {} - errors = 0 - # 窗口自适应状态(见 _cover_bases 上方注释)。 - bases = _cover_bases(()) # 还没发现电机 → 先全表轮扫 - bi = 0 # 当前用 bases 里的第几个窗口 - discovery = 0 # 距下次插入"发现窗口"还差几周期 - disco_base = 0 # 发现扫描游标: MOTOR_DISCOVERY_BASES 的下标 - # 启动后先老老实实扫完一整圈再切自适应。否则第一个窗口刚看见 1 号电机就把 - # bases 收缩到那一个窗口,后面的槽再也扫不到 —— 自测第 2 项(要求聚合齐 - # 全部 27 槽)正是这么炸的。一圈 = len(bases) 个周期,4ms 下仅 16ms。 - sweep_left = len(bases) - rt_state = None # 线程优先级是否已按自旋状态设置 - next_t = None # 下一拍的绝对截止点(perf_counter 时间轴) - while not _motor_stop.is_set(): - t0 = time.perf_counter() - try: - with g_lock: - if g_master is None or not g_op_active: - raise RuntimeError("未连接或未进 OP") - s = g_master.slaves[0] - - # 有排队的命令帧就装进输出 PDO。 - # - # 1.5.56:一周期能发多帧了。此前受单帧槽限制,每周期只能发一帧 - # (seq 沿语义要求每个 seq 至少被从站看到一个周期),4ms 周期即 - # 250 帧/s 上限,9 轴同步下发要 36ms 才发完、各轴不同拍。 - # 现在把第一帧放单帧区、其余放批量区,共用同一个 seq 沿, - # 从站在一次 processOutput 里全部发出 —— 真正的同步下发。 - sent = None - nsent = 0 - if _motor_cmd_q: - ch, can_id, dlc, data = _motor_cmd_q.popleft() - # 按字节容量决定这一批能带几帧(全 DLC8 可 20 帧,全 DLC64 仅 4 帧) - pend = list(_motor_cmd_q) - take = _batch_capacity(pend) - batch = [_motor_cmd_q.popleft() for _ in range(take)] - sent = _fill_can_out(ch, can_id, dlc, data, batch=batch or None) - nsent = 1 + len(batch) - - # 告诉从站本周期要哪一段槽。窗口起点由循环末尾自适应推进, - # 从站接受任意 base(不必是 7 的倍数),见 _cover_bases 注释。 - _can_out[TX_OFF_WIN] = _motor_win & 0xFF - - out_len = len(bytes(s.output)) - if out_len > 0: - s.output = bytes(_can_out[:out_len]).ljust(out_len, b"\x00") - # 分段计时: 定位"周期为什么压不到 1ms"到底花在哪一步。 - # 总线上一帧 1024B @100Mbit 只要约 82us, 实测周期却是 ~1360us, - # 差一个数量级 —— 说明瓶颈在驱动/协议栈, 不在 EtherCAT。 - _ts = time.perf_counter() - g_master.send_processdata() - _tm = time.perf_counter() - wkc = g_master.receive_processdata(2000) - _tr = time.perf_counter() - _cyc_t.append((t0, _ts, _tm, _tr, _tr, 0)) - inp = bytes(s.input) - expected = g_master.expected_wkc - - win_slots, win_head = _decode_motor_window(inp) - # 出现没见过的槽就**立刻**重算覆盖,不等下一次发现扫描。 - # 否则从启动(或热插)到下一个发现边界之间最多 MOTOR_DISCOVERY_EVERY - # 个周期都在跑全表轮扫,这段时间里单槽刷新率仍是慢的 —— 自测里 - # 表现为命中率只有 80% 而不是 ~100%。 - fresh = [k for k in win_slots if k not in slots] - slots.update(win_slots) - if fresh and sweep_left <= 0: - bases = _cover_bases(slots.keys()) - bi = 0 - if win_head: - head = win_head - rxb = _decode_rx_batch(inp) - # 批量接收区的帧按到达顺序累积到一个有界缓冲,供前端一次取走。 - # 用 deque(maxlen) 而不是无界 list:前端若长时间不取,内存不能无限涨。 - if rxb["frames"]: - _motor_rx_log.extend(rxb["frames"]) - cyc = (_motor_snap or {}).get("cycles", 0) + 1 - _motor_snap = { - "ts": time.time(), - "wkc": wkc, - "expected_wkc": expected, - "cycles": cyc, - "period_ms": _motor_period_ms, - # 单个电机状态的真实刷新周期。窗口自适应后通常 = period_ms - # (一个窗口盖住全部在线槽 → 每周期都刷),而不再是 period_ms×4。 - # 前端要显示"数据多新"就得用这个,拿 period_ms 硬算会谎报。 - "slot_period_ms": _motor_period_ms * len(bases), - "win_bases": list(bases), - # 周期分段耗时(us): 读侧(/api/motor/state → _cyc_fresh)会把它刷 - # 成新值再回给调用方;放在这儿只是让旧消费者拿到的键不消失。 - # 样本不足时为 None。PDO 线程自己**不算**分位数(见 _CYC_TTL_S)。 - "cyc_us": _cyc_cached, - # 自旋调度是否生效。True 时本进程会吃满一个核,必须让前端看得见。 - "spin": _spin_effective(), - # 自旋线程被钉到的逻辑处理器掩码(0=没钉,说明探测失败或已关掉)。 - # 前端把它显示出来,用户才知道那个核落在哪组。 - "pcore_mask": g_pcore_mask, - "sent_seq": sent, - "sent_frames": nsent, - "queue_depth": len(_motor_cmd_q), - "bridge": _can_decode_input(inp), - "xrx": _decode_xrx(inp), - "rx_batch": rxb, - "head": head, - "motors": sorted(slots.values(), key=lambda m: (m["ch"], m["node"])), - } - errors = 0 - - # ── 推进窗口 ── - # 正常情况在 bases(只覆盖有主的槽)里轮转;每 MOTOR_DISCOVERY_EVERY - # 周期让位给一个发现窗口,好让新插上的电机能被看见。 - if sweep_left > 0: - # 开机首圈:走完整轮扫再切自适应(见 sweep_left 定义处) - sweep_left -= 1 - if sweep_left == 0: - bases = _cover_bases(slots.keys()) - bi = 0 - _motor_win = bases[0] - else: - bi = (bi + 1) % len(bases) - _motor_win = bases[bi] - elif (discovery := discovery + 1) >= MOTOR_DISCOVERY_EVERY: - discovery = 0 - _motor_win = MOTOR_DISCOVERY_BASES[disco_base] - disco_base = (disco_base + 1) % len(MOTOR_DISCOVERY_BASES) - # 发现窗口可能带回新槽 → 重算覆盖集合。用已知槽号(slots 的 key) - # 而不是 online 标志:电机短暂掉线时不该立刻把它踢出窗口, - # 否则它一恢复就得等下一轮发现扫描才被看到。 - bases = _cover_bases(slots.keys()) - bi = 0 - else: - bi = (bi + 1) % len(bases) - _motor_win = bases[bi] - except Exception as e: # noqa: BLE001 - 循环不能因单次异常退出 - errors += 1 - if errors in (1, 50) or errors % 500 == 0: - print(f"[PDO] 周期异常({errors}): {type(e).__name__}: {e}", flush=True) - if errors > 3: - # 连续失败:很可能已掉出 OP。清标志让下次请求走完整的重进 OP, - # 而不是继续假装在线(这正是旧实现 0x001B 静默失败的成因)。 - g_op_active = False - _motor_snap = {"ts": time.time(), "error": f"{type(e).__name__}: {e}", - "cycles": (_motor_snap or {}).get("cycles", 0)} - _motor_stop.wait(0.2) - continue - # 睡眠必须在锁外:持锁 sleep 会把所有 HTTP 路由一起冻住 - # 节拍源可能被人热改(spin 标志随时可变),翻转时才动一次线程优先级。 - want_rt = _spin_effective() - if want_rt != rt_state: - _set_spin_priority(want_rt) - rt_state = want_rt - # ── 下一拍 = 绝对网格上的截止点,误差不逐拍累加 ── - # 旧写法"截止点 = 本拍 t0 + 周期":自旋/重拿 GIL 每拍迟到 ε,下拍截止点 - # 就跟着后移 ε,ε 是**每拍固定税**(250us 档实测 ε≈30us → 只有 89%)。 - # 改成 网格截止点 = 锚点 + n×周期:这拍的迟到由下拍吸收,长期平均率 - # = 精确 1/周期;工作耗时真的超周期时重排网格,不去追越拉越远的旧账。 - now = time.perf_counter() - per = _motor_period_ms / 1000.0 - if next_t is None: - next_t = t0 + per - if next_t <= now: - # 这拍工作耗时超了周期:**跳过**过期的网格拍而不是挪锚点 —— - # 挪锚点会把每次超出的零头白丢,那正是 250us 档 8% 系统缺口的来源。 - # 跳过则平均率仍收敛到 1/周期;持续超周期时循环自然跑满(自限)。 - k = 0 - while next_t <= now: - next_t += per - k += 1 - _skip_total += k - try: - _cyc_t[-1] = (t0, _ts, _tm, _tr, now, k) - except IndexError: - pass # 热改周期时整条被清了,这拍丢弃 - else: - try: - _cyc_t[-1] = (t0, _ts, _tm, _tr, now, 0) - except IndexError: - pass - _motor_wait_until(next_t) - next_t += per - - -def _motor_thread_start(period_ms=None, spin=None): - """启动后台 PDO 循环(幂等)。 - - spin: None=按周期自动(≤MOTOR_SPIN_AUTO_MAX_MS 才自旋),True/False=显式指定。 - """ - global _motor_thread, _motor_period_ms, _motor_spin, _cyc_cached, _skip_total - p = _parse_period_ms({"period_ms": period_ms}) - if p is not None: - _motor_period_ms = p - # 每次调用都按本次请求重算,**不能**沿用上一次:spin 省略就是"交回自动"。 - # 早先写成 `if spin is not None` 才赋值,结果用户手动开过一次自旋后, - # 之后所有长周期请求都会继续带着 spin=True,自动阈值形同失效。 - _motor_spin = None if spin is None else bool(spin) - if _motor_thread is not None and _motor_thread.is_alive(): - return False - _motor_stop.clear() - _cyc_t.clear() # 换周期后旧样本没意义,别混进分段统计 - _cyc_cached = None # 缓存的分位数同样作废,等新周期的第一批样本 - _skip_total = 0 - _motor_thread = threading.Thread(target=_pdo_worker, name="pdo-cycle", - daemon=True) - _motor_thread.start() - return True - - -def _motor_thread_stop(): - """停后台 PDO 循环并等它退出(必须在拿 g_lock 之前调,否则自锁)。""" - global _motor_thread - if _motor_thread is None: - return False - _motor_stop.set() - _motor_thread.join(timeout=2.0) - _motor_thread = None - return True - - -def api_motor_spin(req): - """只换节拍源(自旋/内核睡眠),**绝不动 PDO 循环**。 - - 为什么不复用 /api/motor/start:那条路由在 _BUS_EXCLUSIVE_PATHS 里, - dispatcher 会先 `_motor_thread_stop()` 停掉循环再执行。从站 SM2 的过程数据 - 看门狗只有 1000ms,停手超过这个数从站就掉出 OP(AL 0x001B),而 g_op_active - 当时仍是 True,于是 motor/start 跳过重进 OP、循环空转报错,再想重枚举就撞上 - SSC 邮箱楔死(2026-09-04 实测把一块好板子这么弄死过两次)。 - 自旋只是循环里读的一个标志,热改即可,没有任何理由重启它。 - - 请求: {spin: true|false|null} null/省略 = 交回按周期自动决定 - """ - global _motor_spin - v = req.get("spin") - _motor_spin = None if v is None else bool(v) - return {"ok": True, "log": [], "spin": _spin_effective(), - "period_ms": _motor_period_ms} - - -def api_motor_period(req): - """热换 PDO 周期,**绝不动 PDO 循环**(理由同 api_motor_spin)。 - - 请求: {period_ms: float} 或 {period_us: int}。允许亚毫秒 —— 0.25ms 即 4kHz。 - 后台循环每拍都重读 _motor_period_ms,改完下一拍就生效,过程数据喂狗全程 - 不断流,所以随时可调、可反复调;与 motor/start(会停循环、有看门狗风险) - 是本质不同的两条路。 - - 物理上限参考(真机分段实测):一送一回 ≈165us,比这更快的请求会被循环 - 自然自限(实测率低于目标),不会把循环弄坏。用 rate_probe 现场量。 - """ - global _motor_period_ms, _cyc_cached, _skip_total - ms = _parse_period_ms(req) - if ms is None: - return {"ok": False, "log": ["缺少 period_ms(>0, 单位 ms) 或 period_us(µs)"]} - old = _motor_period_ms - _motor_period_ms = ms - _cyc_t.clear() # 旧周期的分段样本与新周期无关 - _cyc_cached = None - _skip_total = 0 - _gc_events.clear() # dbg 时间线同理:跨档的样本会互相污染归因 - spin = _spin_effective() - log = [f"PDO 周期 {_fmt_period(old)} → {_fmt_period(ms)}"] - if _motor_thread is None or not _motor_thread.is_alive(): - log.append("后台循环当前没在跑,新周期将在下次 /api/motor/start 时生效") - if _motor_spin is False and ms <= MOTOR_SPIN_AUTO_MAX_MS: - log.append("注意: 节拍源被强制为内核睡眠,Windows 睡眠粒度 ~0.5ms 大于" - "目标周期,实测率会远低于目标。要跑准请走 /api/motor/spin " - "设 spin:true(或省略交回自动)。") - return {"ok": True, "log": log, "period_ms": _motor_period_ms, "spin": spin} - - -def api_motor_dbg(req): - """"这一拍的耗时到底藏在哪一段"的原始时间线导出。见 _cyc_t 注释块。 - - 默认动作是 dump:最近 2048 拍 (t0,send,rx,rx_end,loop_end,跳拍数)、GC 事件 - (结束时刻/时长/代数/线程)、HTTP 请求进出。归因要把三条时间线对齐求交集 - (cadence_diag.py),所以给原始样本而不是分位数 —— 分位数会把"一次 3ms 的 - gen2 收集"抹平成看不见的 p90。 - - action=gc 开关垃圾回收跑 A/B:若关掉 GC 后超时拍大幅消失,责任即锁定。 - 关 GC 会让循环的分配不再回收(短测无害,长跑泄漏),纯诊断开关,别留常态。 - """ - if req.get("action") == "gc": - en = req.get("enable") - if en is True: - gc.enable() - elif en is False: - gc.disable() - return {"ok": True, "gc_enabled": gc.isenabled()} - return {"ok": True, - "period_ms": _motor_period_ms, "spin": _spin_effective(), - "gc_enabled": gc.isenabled(), - "cycles": (_motor_snap or {}).get("cycles", 0), - "skip_total": _skip_total, - "cyc": list(_cyc_t), "gc_events": list(_gc_events), - "http": list(_http_t)} - - -def api_motor_start(req): - """进 OP 并启动后台 PDO 循环。 - - 请求: {period_ms?: float, spin?: bool} - period_ms —— 后台周期,默认 4ms。允许亚毫秒(float,如 0.25=4kHz), - 下限 MOTOR_PERIOD_MIN_MS。**运行中改周期请走 - /api/motor/period**,别用本路由(会重启循环)。 - spin —— 是否自旋调度。不给就按周期自动(≤2ms 自旋)。自旋能把周期 - 跑准(实测抖动 0.000ms)但吃满一个核;关掉则退回内核睡眠, - 省 CPU 但周期会超调 0.4~1.0ms。见 _motor_wait_until 注释。 - 亚毫秒周期只有自旋才跑得动(睡眠粒度 ~0.5ms)。 - 只想改这一项请走 /api/motor/spin,别用本路由(会重启循环)。 - - @note dispatcher 已在取 g_lock 前停掉旧循环,本函数在持锁状态下启动新线程。 - 新线程第一次取 g_lock 会等到本路由返回,属正常排队,不是死锁。 - """ - # 下面要写 g_op_active(发现从站已掉出 OP 时清掉它),必须声明 global, - # 否则函数里一有赋值,前面的读取就会抛 UnboundLocalError。 - global g_op_active - if g_master is None: - return {"ok": False, "log": ["未连接"]} - # g_op_active 只是我们"上次成功进过 OP"的记忆,**不是**从站的真实状态。 - # 停掉后台循环超过 1000ms,从站的过程数据看门狗就会把它踢出 OP(AL 0x001B), - # 而这个标志仍是 True —— 于是这里跳过重进 OP,新循环每拍都撞"未进 OP", - # 用户看到的是"服务活着但全是 --"。所以必须现读一次状态来核实。 - really_op = False - if g_op_active: - try: - g_master.read_state() - really_op = (g_master.slaves[0].state & 0x0F) == 8 # OP_STATE - except Exception: - really_op = False - if not really_op: - g_op_active = False - log0 = ["检测到从站已掉出 OP(过程数据看门狗超时),重新走 PREOP→SAFEOP→OP"] - else: - log0 = [] - else: - log0 = [] - if not g_op_active: - log, ok = _require_op_ready(*_dc_args(req)) - # 失败时也要带上 log0:否则"我们检测到从站掉出 OP 才去重进、结果重进失败" - # 这条关键上下文被丢掉,用户只看到一串 config_map 报错,无从判断起因。 - if not ok: - return {"ok": False, "log": log0 + log} - else: - log = [] - log = log0 + log - spin = req.get("spin") - started = _motor_thread_start(req.get("period_ms"), - None if spin is None else bool(spin)) - spinning = _spin_effective() - log.append(f"后台 PDO 循环{'已启动' if started else '已在运行'}: " - f"周期 {_fmt_period(_motor_period_ms)},窗口 {MOT_SLOTS_PER_PDO} 槽/周期," - f"窗口自适应(只覆盖有主的槽,在线电机每周期都刷新)," - f"每 {MOTOR_DISCOVERY_EVERY} 周期插一次发现扫描以找新电机") - log.append("节拍源: " + ("自旋(周期跑得准,但会占满一个 CPU 核)" - if spinning else - "内核睡眠(省 CPU,但 Windows 粒度会让周期超调约 0.5ms)")) - return {"ok": True, "log": log, "period_ms": _motor_period_ms, - "spin": spinning} - - -def api_motor_stop(req): - """停后台 PDO 循环(保持 OP,不动总线状态)。 - - @note 实际的 stop/join 由 dispatcher 在锁外完成(见 _BUS_EXCLUSIVE_PATHS), - 这里只报告结果 —— 持锁 join 一个要抢同一把锁的线程会自锁死。 - """ - return {"ok": True, "log": [f"后台 PDO 循环已停止(周期曾为 {_fmt_period(_motor_period_ms)})"]} - - -def api_motor_state(req): - """读最新电机状态快照(不碰总线,纯读内存,可高频轮询)。 - - cyc_us 分位数在这里(请求线程)经 _cyc_fresh 现算 —— 绝不能在 PDO 循环里 - 排序:那 400~600us 的排序恰好塞爆一整个 250us 时隙,实测把每第 8 拍顶 - 超时。读侧算,最坏是这条 HTTP 回复自己慢 0.5ms,热循环毫发无损。 - """ - snap = _motor_snapshot() - if snap is None: - return {"ok": False, "log": ["后台 PDO 循环未启动 —— 先调 /api/motor/start"]} - if "error" in snap: - return {"ok": False, "log": [f"PDO 周期异常: {snap['error']}"], **snap} - out = {"ok": True, "log": [], **snap} - out["cyc_us"] = _cyc_fresh() - return out - - -def api_motor_send(req): - """把一帧(或多帧)电机 CAN 排入后台循环下发队列。 - - 请求: {frames:[{ch,id,dlc,data}]} 或单帧 {ch,id,dlc,data} - 1.5.56 起 _pdo_worker 每周期能取多帧(首帧走单帧区、其余走批量区, - 共用一个 seq 沿),所以一次提交 9 轴指令会在**同一个 PDO 周期**发出, - 而不是排队 9 个周期。DLC 支持到 64(CANFD 全量)。 - """ - if g_master is None: - return {"ok": False, "log": ["未连接"]} - frames = req.get("frames") - if not frames: - frames = [req] - queued = [] - for f in frames: - try: - ch = int(f.get("ch") or 0) - raw_id = f.get("id", 0) - can_id = int(raw_id, 16) if isinstance(raw_id, str) and \ - raw_id.lower().startswith("0x") else int(raw_id or 0) - data = bytes.fromhex((f.get("data") or "").replace(" ", "")) - dlc = int(f.get("dlc") if f.get("dlc") not in (None, "") else len(data)) - except (TypeError, ValueError) as e: - return {"ok": False, "log": [f"参数解析失败: {e}"]} - if ch not in (0, 1, 2): - return {"ok": False, "log": [f"通道必须是 0/1/2,收到 {ch}"]} - if dlc > CAN_FD_DATA_MAX: - return {"ok": False, - "log": [f"DLC {dlc} 超出 CANFD 上限 {CAN_FD_DATA_MAX}"]} - if len(data) < dlc: - data = data.ljust(dlc, b"\x00") - _motor_cmd_q.append((ch, can_id, dlc, data)) - queued.append(f"CH{ch} 0x{can_id:X} DLC{dlc}") - - if _motor_thread is None or not _motor_thread.is_alive(): - # 没有后台循环就没人取队列。同步跑一次,避免命令悄无声息地烂在队列里。 - # 本函数已由 dispatcher 持 g_lock 调用,此处不能再取锁(非可重入锁)。 - if not g_op_active: - log, ok = _require_op_ready(*_dc_args(req)) - if not ok: - return {"ok": False, "log": log} - # 同步路径也要用批量区:否则"后台循环没开"时会退化成每周期一帧, - # 多轴同步下发的时序保证在这条路上就悄悄丢了(自测第 6 项即抓到这点)。 - n = 0 - cycles = 0 - while _motor_cmd_q: - ch, can_id, dlc, data = _motor_cmd_q.popleft() - take = _batch_capacity(list(_motor_cmd_q)) - batch = [_motor_cmd_q.popleft() for _ in range(take)] - _fill_can_out(ch, can_id, dlc, data, batch=batch or None) - _can_pump(cycles=2) - n += 1 + len(batch) - cycles += 1 - return {"ok": True, "queued": 0, "sent_sync": n, "pdo_cycles": cycles, - "log": [f"后台循环未运行,已同步下发 {n} 帧" - f"({cycles} 个 PDO 周期): " + "; ".join(queued)]} - - return {"ok": True, "queued": len(queued), "backlog": len(_motor_cmd_q), - "batch_max_bytes": TXB_BYTES, - "log": []} - - -def api_motor_rx(req): - """取走批量接收区累积的 CAN 帧(不碰总线,纯读内存)。 - - 请求: {limit:int} —— 一次最多取多少帧(默认全取) - 取走即从缓冲移除,语义是"消费"而非"查看",避免前端反复处理同一批帧。 - """ - limit = req.get("limit") - try: - limit = int(limit) if limit not in (None, "") else len(_motor_rx_log) - except (TypeError, ValueError): - limit = len(_motor_rx_log) - out = [] - for _ in range(max(0, min(limit, len(_motor_rx_log)))): - out.append(_motor_rx_log.popleft()) - return {"ok": True, "log": [], "frames": out, "remain": len(_motor_rx_log)} - - -def api_op_stop(req): - global g_op_active, _sync_diag_prev - g_op_active = False - _sync_diag_prev = None - if g_master is None: - return {"ok": False, "log": ["未连接"]} - try: - g_master.state = pysoem.INIT_STATE - g_master.write_state() - return {"ok": True, "log": ["已回 INIT"]} - except Exception as e: - return {"ok": False, "log": [f"回 INIT 失败: {type(e).__name__}: {e}"]} - - -def api_sdo(req): - if g_master is None: - return {"ok": False, "log": ["未连接"]} - s = g_master.slaves[0] - op = req.get("op") - idx = int(req.get("index", 0)) - sub = int(req.get("sub", 0)) - try: - if op == "read": - data = bytes(s.sdo_read(idx, sub, int(req.get("size", 8)))) - return {"ok": True, "log": [f"SDO 0x{idx:04X}:{sub} = {data.hex().upper()}"], - "data": data.hex()} - payload = bytes.fromhex(req.get("hex", "")) - s.sdo_write(idx, sub, payload) - return {"ok": True, - "log": [f"SDO 0x{idx:04X}:{sub} <- {payload.hex().upper()} OK"]} - except Exception as e: - return {"ok": False, "log": [f"SDO 失败: {type(e).__name__}: {e}"]} - - -# 上一次 /api/sync_diag 的快照: (sync0, monotonic_ts)。速率用主站时间戳算, -# 不依赖从站 uptime 分辨率;op start/stop 时清空,防从站重启后计数器归零算出假高速。 -_sync_diag_prev = None - - -def api_sync_diag(req): - """读 0x2000 SYNC Diag (sub1..4 = sync0/sync1/escirq/uptime 秒, UINT32)。 - - 从站快照在 APPL_InputMapping 每 PDO 周期刷新(固件 ≥1.5.31),故速率真实 - 反映 SYNC0 脉冲;free-run 下 sync0 恒 0 → 速率 0。""" - global _sync_diag_prev - if g_master is None: - return {"ok": False, "log": ["未连接"]} - s = g_master.slaves[0] - try: - vals = [struct.unpack(" 0.05: - rate = round(((sync0 - p0) & 0xFFFFFFFF) / dt, 1) - _sync_diag_prev = (sync0, now) - return {"ok": True, "log": [], "sync0": sync0, "sync1": sync1, - "escirq": escirq, "uptime": uptime, "sync0_rate": rate} - - -def _do_shutdown(): - """子进程(browser 模式)退出路径: 断开设备 → serve_forever 返回 → 进程退出。""" - # 先停后台 PDO 循环再关设备:否则线程会继续对已 close 的 master 发过程数据。 - # 必须在取 g_lock 之前停(join 的目标线程自己也抢这把锁)。 - try: - _motor_thread_stop() - except Exception: - pass - try: - with g_lock: - api_close({}) - except Exception: - pass - if g_httpd is not None: - try: - g_httpd.shutdown() - except Exception: - pass - - -class _JsApi: - """暴露给 pywebview 窗口内 JS 的接口 (window.pywebview.api), 仅 GUI 父进程使用。""" - - def page_loaded(self): - """页面每次加载完成后由注入脚本调用: 把窗口带到前台。""" - win = g_win - if win is not None: - try: - win.show() - except Exception: - pass - h = _native_handle(win) if win is not None else None - if h is not None: - _win32_foreground(h) - - def quit_app(self): - def _q(): - _stop_child(g_child, g_port) - os._exit(0) - threading.Thread(target=_q, daemon=True).start() - - -def _win32_foreground(native_handle): - """SetForegroundWindow: pywebview Window 没有 focus(), 要走 Win32。""" - try: - hwnd = int(native_handle) - ctypes.windll.user32.ShowWindow(hwnd, 9) # SW_RESTORE - ctypes.windll.user32.SetForegroundWindow(hwnd) - except Exception: - pass - - -def _native_handle(win): - try: - return win.native.Handle # WinForms Form - except Exception: - try: - return win.native - except Exception: - return None - - -def _gui_bringToFront(): - """把 GUI 窗口恢复/置顶(从子进程 monitor 线程调用)。""" - win = g_win - if win is None: - return - try: - win.show() - except Exception: - pass - h = _native_handle(win) - if h is not None: - _win32_foreground(h) - - -def _acquire_single_instance(): - """命名 mutex 单实例守卫。返回 False=已有实例在跑。""" - global g_mutex - try: - g_mutex = ctypes.windll.kernel32.CreateMutexW(None, False, "DCU_EcatTools_SingleInstance") - return ctypes.windll.kernel32.GetLastError() != 183 # ERROR_ALREADY_EXISTS - except Exception: - return True # mutex 不可用时退化为允许多开 - - -def api_shutdown(req): - threading.Thread(target=_do_shutdown, daemon=True).start() - return {"ok": True, "log": ["后台服务即将退出; 双击 ecat_web.vbs 可再次启动"]} - - -ROUTES = { - "/api/adapters": ("GET", api_adapters), - "/api/open": ("POST", api_open), - "/api/status": ("POST", api_status), - "/api/close": ("POST", api_close), - "/api/eeprom/read": ("POST", api_eeprom_read), - "/api/eeprom/write": ("POST", api_eeprom_write), - "/api/eeprom/verify": ("POST", api_eeprom_verify), - "/api/flash": ("POST", api_flash), - "/api/op/start": ("POST", api_op_start), - "/api/op/cycle": ("POST", api_op_cycle), - "/api/op/stop": ("POST", api_op_stop), - "/api/can/send": ("POST", api_can_send), - "/api/can/state": ("POST", api_can_state), - # 电机控制台(motor.html):状态读快照、命令走后台循环队列 - "/api/motor/start": ("POST", api_motor_start), - # 故意**不**放进 _BUS_EXCLUSIVE_PATHS:它只改一个标志,重启循环反而会 - # 掐断过程数据喂狗把从站弄掉出 OP。见 api_motor_spin 的注释。 - "/api/motor/spin": ("POST", api_motor_spin), - # 同理:周期热改只是换个循环每拍重读的变量,喂狗不断流。亚毫秒周期 - # (250us=4kHz)全靠这条路随时调,不必重启循环。见 api_motor_period。 - "/api/motor/period": ("POST", api_motor_period), - "/api/motor/stop": ("POST", api_motor_stop), - "/api/motor/state": ("POST", api_motor_state), - # 诊断时间线导出(读内存,不碰总线),同样不进独占路由。见 api_motor_dbg。 - "/api/motor/dbg": ("POST", api_motor_dbg), - "/api/motor/send": ("POST", api_motor_send), - "/api/motor/rx": ("POST", api_motor_rx), - "/api/sdo": ("POST", api_sdo), - "/api/sync_diag": ("POST", api_sync_diag), - "/api/shutdown": ("POST", api_shutdown), -} - -# 需要独占总线的路由:dispatcher 会在取 g_lock 之前先停掉后台 PDO 循环。 -# 判据是"会自己驱动 PDO 周期、或走 CoE/EEPROM 邮箱"——两者都不能与后台循环并行: -# - 抢 s.output / send_processdata,输出帧互相覆盖; -# - 邮箱类操作与周期数据并发是历史上邮箱楔死 + SOEM 分段读崩溃的诱因。 -# /api/motor/start 与 /api/motor/stop 也在内:它们要 start/join 那个同样抢 -# g_lock 的线程,若在持锁状态下 join 会直接自锁死。 -_BUS_EXCLUSIVE_PATHS = frozenset({ - "/api/open", "/api/close", - "/api/eeprom/read", "/api/eeprom/write", "/api/eeprom/verify", "/api/flash", - "/api/op/start", "/api/op/cycle", "/api/op/stop", - "/api/can/send", - "/api/sdo", "/api/sync_diag", - "/api/motor/start", "/api/motor/stop", - "/api/shutdown", -}) - -# ── 原生守护进程转发(原型 A:把 PDO 热循环交给 C/SOEM,抬天花板 ~5kHz→~7kHz)── -# 设了 ECAT_NATIVE_URL(如 http://127.0.0.1:8091) 就把下面 3 条热路由原样转给 native, -# 让现有前端/脚本透明驱动原生循环。前提:native 独占网卡时 Python 自己的 PDO 循环 -# 必须没在跑(别调 motor/start),否则两个主站抢同一条总线。默认关,不影响现有链路。 -_NATIVE_URL = os.environ.get("ECAT_NATIVE_URL", "").rstrip("/") -_NATIVE_PATHS = frozenset({"/api/motor/state", "/api/motor/period", "/api/motor/dbg"}) - - -def _native_forward(path, raw_body): - """原样转发 POST 到 native,返回其响应字节。 - - 绝不给 urlopen 设 timeout —— 设了会让 connect 走 select 轮询,每请求白加约 - 10ms(见记忆 esp32-ecat-proxy-must-not-set-socket-timeout / - python-socket-timeout-nonblocking-connect-windows)。 - """ - req = urllib.request.Request( - _NATIVE_URL + path, data=raw_body or b"{}", - headers={"Content-Type": "application/json"}, method="POST") - with urllib.request.urlopen(req) as resp: - return resp.read() - - -class Handler(BaseHTTPRequestHandler): - server_version = "EcatTool/1.0" - - def log_message(self, fmt, *args): - pass # 静默访问日志 - - def _not_found(self, path): - """404 响应。 - - 网页(ecat.html)每次请求都从磁盘重读,而 API 路由表是进程启动时固化的: - 改完 ecat_server.py 不重启服务,就会出现"页面上有新按钮、点了报 not found" - 这种自相矛盾的现象(2026-09-03 实测踩到)。这里直接比对源码 mtime 与进程 - 启动时刻,把这个错因写进响应,省得下次再从零排查。 - """ - log = [f"接口不存在: {path}"] - try: - if os.path.getmtime(os.path.abspath(__file__)) > SERVER_START_TIME: - log.append("原因: ecat_server.py 在本服务启动之后被修改过 —— " - "当前进程仍是旧版本,没有这个接口。") - log.append("解决: 点右上角「退出服务」(或结束 pythonw 进程)后重新" - "双击 ecat_app.vbs / ecat_web.vbs 启动。") - except OSError: - pass - self._send(404, json.dumps({"ok": False, "log": log}).encode("utf-8")) - - def _send(self, code, body, ctype="application/json; charset=utf-8"): - self.send_response(code) - self.send_header("Content-Type", ctype) - self.send_header("Cache-Control", "no-store") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - # 静态文件白名单。注意 Content-Type 必须按后缀给对: - # - .js 用 text/html 会被浏览器的严格 MIME 检查拒掉(module script 与 Worker - # 都会直接报错不执行),这是加新页面最容易踩的坑; - # - 只有 HTML 才注入 _RELOAD_SNIPPET(注入到 js/css 里会破坏文件)。 - # URL 路径保持不变(/、/ecat.css、/motor.html…),只是磁盘上挪进了 web/, - # 所以页面内的相对引用、书签、外部链接都不用改。 - _STATIC_MAP = { - "/": "web/ecat.html", - "/ecat.html": "web/ecat.html", - "/index.html": "web/ecat.html", - "/ecat.css": "web/ecat.css", - # 电机控制台(EtherCAT 版)。样式直接复用 ecat.css —— 它本身就是 ESP32 - # motor.css 的副本(md5 一致),再拷一份 motor.css 只会多出一处会漂移的副本。 - "/motor.html": "web/motor.html", - "/motor.js": "web/motor.js", - } - _CTYPE_BY_EXT = { - ".html": "text/html; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".js": "application/javascript; charset=utf-8", - } - - def do_GET(self): - # 包一层进出时间戳进 _http_t:PDO 拍超时要和"HTTP 线程正持着 GIL 序列化 - # 快照"的时间窗求交集,才知道丢的拍是不是被抢了 GIL。见 api_motor_dbg。 - t_in = time.perf_counter() - try: - self._do_GET() - finally: - _http_t.append((t_in, time.perf_counter(), self.path.split("?")[0])) - - def _do_GET(self): - path = self.path.split("?")[0] - name = self._STATIC_MAP.get(path) - if name: - fp = os.path.join(BASE_DIR, name) - ext = os.path.splitext(name)[1].lower() - ctype = self._CTYPE_BY_EXT.get(ext, "application/octet-stream") - try: - with open(fp, "rb") as f: - body = f.read() - if ext == ".html": - html = body.decode("utf-8", "replace") - if "" in html: - html = html.replace( - "", _RELOAD_SNIPPET + "", 1) - body = html.encode("utf-8") - self._send(200, body, ctype) - except OSError: - self._send(404, b"missing") - return - route = ROUTES.get(path) - if route and route[0] == "GET": - with g_lock: - try: - result = route[1]({}) - except Exception as e: - result = {"ok": False, "log": [f"服务器异常: {type(e).__name__}: {e}"]} - self._send(200, json.dumps(result).encode("utf-8")) - return - self._not_found(path) - - def do_POST(self): - t_in = time.perf_counter() - try: - self._do_POST() - finally: - _http_t.append((t_in, time.perf_counter(), self.path.split("?")[0])) - - def _do_POST(self): - path = self.path.split("?")[0] - if path == "/api/focus": - body = b'{"ok":true,"focused":true}' - self._post_json(body) - threading.Thread(target=lambda: print("FOCUS=1", flush=True), daemon=True).start() - return - route = ROUTES.get(path) - if not route or route[0] != "POST": - self._not_found(path) - return - try: - length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(length) if length else b"{}" - req = json.loads(raw.decode("utf-8") or "{}") - except Exception as e: - self._send(200, json.dumps( - {"ok": False, "log": [f"请求解析失败: {e}"]}).encode("utf-8")) - return - - # 原生守护进程转发(原型 A):ECAT_NATIVE_URL 设了就把 3 条热路由原样转给 - # native。失败时如实报"不可达",绝不静默回退到本地循环(那会掩盖 native 挂了)。 - if _NATIVE_URL and path in _NATIVE_PATHS: - try: - self._send(200, _native_forward(path, raw)) - except Exception as e: - self._send(200, json.dumps({"ok": False, "log": [ - f"native 守护进程不可达({_NATIVE_URL}): {type(e).__name__}: {e}"]}).encode("utf-8")) - return - - # /api/motor/state 与 /api/motor/rx 只读内存(快照 / 接收缓冲),不碰总线 - # —— 放在 g_lock 外面。否则高频轮询会和后台 PDO 线程(每 4ms 取一次锁) - # 排队互等,既拖慢响应又给循环加抖动。 - if path in ("/api/motor/state", "/api/motor/rx"): - try: - result = route[1](req) - except Exception as e: - result = {"ok": False, "log": [f"服务器异常: {type(e).__name__}: {e}"]} - self._send(200, json.dumps(result).encode("utf-8")) - return - - # 独占总线的操作必须先停后台 PDO 循环,且必须在取 g_lock **之前** 停: - # _motor_thread_stop() 要 join 的那个线程自己也在抢 g_lock, - # 持锁去 join 会直接自锁死。停完再进锁,语义等价于"独占期间不喂看门狗", - # 从站可能掉出 OP —— 这些操作本来就要重进 OP,可接受。 - if path in _BUS_EXCLUSIVE_PATHS: - _motor_thread_stop() - - with g_lock: - try: - result = route[1](req) - except Exception as e: - result = {"ok": False, "log": [f"服务器异常: {type(e).__name__}: {e}"]} - self._send(200, json.dumps(result).encode("utf-8")) - - def _post_json(self, body): - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - -class QuietServer(ThreadingHTTPServer): - # 客户端中途断开(刷新/关窗)会触发 handle_error 打 traceback 到 stderr; - # 窗口模式下 stderr 是父进程的 PIPE, 4KB 缓冲一满子进程写日志即永久阻塞 - # → 整个服务冻死(实测"窗口经常卡死"的根因)。此处静音, 父侧另有排空线程双保险。 - def handle_error(self, request, client_address): - pass - - -def _port_taken(port): - """Windows 的 SO_REUSEADDR 允许同端口重复 bind(实测会双监听、连接随机落), - 故先用一次真实连接探测占用, 不能只靠 bind 是否抛错。""" - import socket - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(0.3) - return s.connect_ex(("127.0.0.1", port)) == 0 - - -# 本工具要**独占** Npcap 适配器: 两个实例并存时后起的那个 config_init 抓不到 -# 从站(身份读成 M:00010001), 而 _bind_server 的端口自动挪移让它静默跑到 8091, -# 页面于是永远"探测中→连接失败"。故启动时主动清掉旧实例, 而不是与之共存。 -_INSTANCE_PORTS = (8090, 8091, 8092, 8093, 8094, 8095) - - -def _kill_stale_instances(): - """杀掉本机其它 ecat_server 实例, 返回日志行列表。 - - 优雅优先: 先按端口调旧实例的 /api/shutdown, 让它把从站带回 INIT 再退。 - 直接强杀会让 LAN9252 停在 OP 且 SSC 邮箱可能楔死, 下次 config_map 报一片 - MailboxError, 必须复位 MCU 才能恢复 —— 那正是本次要消除的故障。 - 强杀只作兜底(进程已挂/HTTP 不响应)。 - """ - logs = [] - for port in _INSTANCE_PORTS: - if not _port_taken(port): - continue - try: - urllib.request.urlopen(urllib.request.Request( - f"http://127.0.0.1:{port}/api/shutdown", data=b"{}", - headers={"Content-Type": "application/json"}, method="POST"), - timeout=4) - logs.append(f"旧实例(端口 {port}): 已请求优雅退出(从站回 INIT)") - except Exception: - continue - - # 等端口真正释放: 旧实例要走 停PDO→api_close→httpd.shutdown, 需要一点时间。 - deadline = time.time() + 8 - while time.time() < deadline: - if not any(_port_taken(p) for p in _INSTANCE_PORTS): - break - time.sleep(0.3) - - killed = _force_kill_ecat_processes() - if killed: - logs.append(f"强杀残留 ecat_server 进程 {len(killed)} 个: " - + ",".join(str(p) for p in killed)) - # Npcap 句柄随进程退出由内核回收, 但要给它一拍时间, 否则新实例 open 偶发失败 - if logs: - time.sleep(1.0) - return logs - - -def _force_kill_ecat_processes(): - """按命令行匹配强杀残留 ecat_server 进程, 返回被杀 pid 列表。 - - 用 WMI 查命令行而不是按进程名: 进程名一律是 python/pythonw, 按名字杀会 - 连带干掉 ESP32 工具和用户其它 Python 程序。 - - 两层收窄, 避免误杀: - 1. 进程名必须是 python*.exe —— 只在命令行里**提到**过 ecat_server.py 的 - shell/编辑器/grep 不能杀(实测 bash -c "... ecat_server.py ..." 会命中); - 2. 自身与自身祖先豁免: venv\\Scripts\\python(w).exe 是个转发壳, 会把系统 - 解释器当子进程再起一遍, 同一次启动在 WMI 里因此有多条匹配记录。 - """ - procs = {} - try: - out = subprocess.run( - ["powershell", "-NoProfile", "-Command", - "Get-CimInstance Win32_Process | Where-Object { " - "$_.Name -like 'python*' -and " - "$_.CommandLine -match 'ecat_server\\.py' } | ForEach-Object " - "{ \"$($_.ProcessId) $($_.ParentProcessId)\" }"], - capture_output=True, text=True, timeout=20, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).stdout - except Exception: - return [] - for line in out.splitlines(): - parts = line.split() - if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit(): - procs[int(parts[0])] = int(parts[1]) - - me = os.getpid() - protected = {me} - cur = me # 沿 ppid 向上豁免(venv 转发壳链) - for _ in range(8): # 上限防父子环导致死循环 - parent = procs.get(cur) - # 只在**匹配集合内**向上走: 若把不匹配的启动器(explorer/wscript)也豁免, - # 下面的后代展开会连旧实例一起保护 —— 新旧实例往往同一个启动器, - # 那样清场会变成一次空操作(已由单测覆盖)。 - if parent is None or parent not in procs or parent in protected: - break - protected.add(parent) - cur = parent - changed = True # 再豁免自己的后代(窗口模式的服务子进程) - while changed: - changed = False - for pid, ppid in procs.items(): - if ppid in protected and pid not in protected: - protected.add(pid) - changed = True - - killed = [] - for pid in procs: - if pid in protected: - continue - # PROCESS_TERMINATE(0x0001): 杀不掉(权限/已退出)就跳过, 不让启动流程卡住 - try: - k32 = ctypes.windll.kernel32 - h = k32.OpenProcess(0x0001, False, pid) - if h: - k32.TerminateProcess(h, 1) - k32.CloseHandle(h) - killed.append(pid) - except Exception: - pass - return killed - - -def _bind_server(port): - for _ in range(6): - if _port_taken(port): - print(f"端口 {port} 已被占用, 尝试 {port + 1}") - port += 1 - continue - try: - return QuietServer(("127.0.0.1", port), Handler), port - except OSError: - print(f"端口 {port} 绑定失败, 尝试 {port + 1}") - port += 1 - return None, port - - -def _watch_parent(ppid): - """兜底看门狗(Job Object 万一不可用时): GUI 父进程消失就自我了断, - 避免窗口关了服务还在后台占端口/占着 Npcap 适配器。""" - def _loop(): - k32 = ctypes.windll.kernel32 - h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE - if not h: - return - k32.WaitForSingleObject(h, 0xFFFFFFFF) - _do_shutdown() # 先断开设备回 INIT - time.sleep(1.5) - os._exit(0) - threading.Thread(target=_loop, daemon=True).start() - - -def _run_browser(args): - """浏览器模式: 服务跑在本进程主线程 (纯 pysoem 进程, 无 GUI 库)。""" - global g_httpd - # 开机就后台探测 P 核(约 1.6s)。放在这里是因为此刻还没碰过总线, - # 探测占用的 CPU 不会打断任何过程数据交换。见 _start_pcore_probe 注释。 - _start_pcore_probe() - if getattr(args, "parent_pid", 0): - _watch_parent(args.parent_pid) - else: - # 独立启动(ecat_web.vbs/bat 或直接命令行): 自己负责清场。 - # 窗口模式的服务子进程不走这里 —— GUI 父进程已经清过, 子进程再清一遍 - # 会把父进程连带杀掉(父子命令行都匹配 ecat_server.py)。 - for line in _kill_stale_instances(): - print(line, flush=True) - httpd, port = _bind_server(args.port) - if httpd is None: - raise SystemExit("无法绑定端口") - g_httpd = httpd - url = f"http://127.0.0.1:{port}/" - print(f"PORT={port}", flush=True) # 窗口模式父进程据此得知端口 - print(f"DCU EtherCAT 主站控制台: {url} (Ctrl+C 退出)") - if not args.no_browser: - threading.Timer(0.5, lambda: webbrowser.open(url)).start() - try: - httpd.serve_forever() - except KeyboardInterrupt: - pass - httpd.server_close() - print("\n已退出") - - -def _bind_child_lifetime(child): - """把服务子进程放进 Job Object, 父进程句柄一关(正常退出/被强杀/崩溃) - 由内核连坐结束子进程, 杜绝"窗口关了端口还占着"的孤儿服务。 - 句柄故意不关闭: 生命周期就是靠父进程存活维系的。""" - k32 = ctypes.windll.kernel32 - - class _JOBLIM(ctypes.Structure): - _fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), - ("PerJobUserTimeLimit", ctypes.c_int64), - ("LimitFlags", ctypes.c_uint32), - ("MinimumWorkingSetSize", ctypes.c_size_t), - ("MaximumWorkingSetSize", ctypes.c_size_t), - ("ActiveProcessLimit", ctypes.c_uint32), - ("Affinity", ctypes.POINTER(ctypes.c_ulong)), - ("PriorityClass", ctypes.c_uint32), - ("SchedulingClass", ctypes.c_uint32)] - - class _IOC(ctypes.Structure): - _fields_ = [("ReadOperationCount", ctypes.c_uint64), - ("WriteOperationCount", ctypes.c_uint64), - ("OtherOperationCount", ctypes.c_uint64), - ("ReadTransferCount", ctypes.c_uint64), - ("WriteTransferCount", ctypes.c_uint64), - ("OtherTransferCount", ctypes.c_uint64)] - - class _EXLIM(ctypes.Structure): - _fields_ = [("BasicLimitInformation", _JOBLIM), - ("IoInfo", _IOC), - ("ProcessMemoryLimit", ctypes.c_size_t), - ("JobMemoryLimit", ctypes.c_size_t), - ("PeakProcessMemoryUsed", ctypes.c_size_t), - ("PeakJobMemoryUsed", ctypes.c_size_t)] - - try: - job = k32.CreateJobObjectW(None, None) - if not job: - return None - info = _EXLIM() - info.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE - if not k32.SetInformationJobObject(job, 9, ctypes.byref(info), - ctypes.sizeof(info)): - return None - h = k32.OpenProcess(0x1F0FFF, False, child.pid) # PROCESS_ALL_ACCESS - if not h: - return None - k32.AssignProcessToJobObject(job, h) - k32.CloseHandle(h) - return job - except Exception: - return None - - -def _stop_child(child, port): - """通知服务子进程优雅退出(断开设备+回 INIT), 超时则强杀。""" - if child is None or child.poll() is not None: - return - try: - urllib.request.urlopen(urllib.request.Request( - f"http://127.0.0.1:{port}/api/shutdown", data=b"{}", - headers={"Content-Type": "application/json"}, method="POST"), - timeout=3) - except Exception: - pass - try: - child.wait(timeout=3) - except Exception: - pass - if child.poll() is None: - child.kill() - try: - child.wait(timeout=3) - except Exception: - pass - - -def _fail_box(title, msg): - """GUI 进程启动失败时弹原生对话框, 避免无声无息。""" - try: - ctypes.windll.user32.MessageBoxW(None, msg, title, 0x10) # MB_ICONERROR - except Exception: - pass - - -def _spawn_service_child(extra_args, fail_title): - """启动服务子进程并持续排空其 stdout。 - 必须一直读: PIPE 缓冲 ~4KB, 读停则子进程任何 print/traceback 写满即永久阻塞。""" - script = os.path.abspath(__file__) - child = subprocess.Popen( - [sys.executable, script] + extra_args, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=BASE_DIR) - global g_job - g_job = _bind_child_lifetime(child) # 父进程消失 → 内核连坐杀子进程 - holder = {"port": None} - tail = collections.deque(maxlen=40) - focus_evt = threading.Event() - - def _drain(): - for raw in child.stdout: - s = raw.decode("utf-8", "replace").rstrip() - if s: - tail.append(s) - if s.startswith("PORT=") and holder["port"] is None: - try: - holder["port"] = int(s[5:]) - except ValueError: - pass - elif s.startswith("FOCUS="): - focus_evt.set() - - threading.Thread(target=_drain, daemon=True).start() - - deadline = time.time() + 30 - while time.time() < deadline: - if holder["port"] is not None or child.poll() is not None: - break - time.sleep(0.1) - - port = holder["port"] - if port is None: - child.terminate() - _fail_box(fail_title, - "\n".join(tail) or "服务子进程未在 30 秒内报告端口(无输出)") - os._exit(1) - return child, port, focus_evt - - -def _run_window(webview): - """桌面窗口模式: 本进程只做 GUI, HTTP/pysoem 跑在子进程。 - 实测 pythonnet 环境下 Python 后台线程会静默死亡, 进程隔离根治。""" - global g_child, g_port, g_win - # 每次启动先清场, 而不是"发现旧实例就唤醒它然后自己退出"。 - # 旧行为的问题: 用户遇到界面卡死/连接失败时的第一反应就是重新打开 app, - # 而唤醒的正是那个已经坏掉的实例, 看起来像"重开也没用"。 - for line in _kill_stale_instances(): - print(line, flush=True) - # 清场后 mutex 随旧进程退出被内核释放, 但释放不是瞬时的, 重试几次。 - for _ in range(10): - if _acquire_single_instance(): - break - time.sleep(0.3) - else: - _fail_box("EtherCAT 控制台", - "已有一个实例在运行且无法结束。\n" - "请到任务管理器结束所有 pythonw.exe 后重新打开。") - os._exit(1) - - child, port, focus_evt = _spawn_service_child( - ["--no-browser", "--parent-pid", str(os.getpid())], - "EtherCAT 控制台启动失败") - g_child = child - g_port = port - url = f"http://127.0.0.1:{port}/" - for _ in range(50): # 等 HTTP 就绪(最多 ~10s) - try: - urllib.request.urlopen(url, timeout=1) - break - except Exception: - if g_child.poll() is not None: - _fail_box("EtherCAT 控制台启动失败", "服务子进程意外退出") - os._exit(1) - time.sleep(0.2) - - def _monitor(): - while True: - if focus_evt.is_set(): - _gui_bringToFront() - focus_evt.clear() - if g_child.poll() is not None: - os._exit(0) # 子进程已退(如页面"退出服务") → GUI 一起退 - time.sleep(0.5) - - threading.Thread(target=_monitor, daemon=True).start() - g_win = webview.create_window("EtherCAT 控制台 — DCU LAN9252", url, - width=1360, height=900, min_size=(1024, 700), - js_api=_JsApi(), hidden=True, text_select=True) - print(f"桌面窗口模式: {url} (关窗即退出)") - - def _show_watchdog(): - deadline = time.time() + 20 - while time.time() < deadline: - if g_win.events.loaded.is_set(): - time.sleep(1) - break - time.sleep(0.5) - try: - g_win.show() - except Exception: - pass - h = _native_handle(g_win) - if h is not None: - _win32_foreground(h) - - threading.Thread(target=_show_watchdog, daemon=True).start() - webview.start() - _stop_child(g_child, g_port) - os._exit(0) - - -def main(): - ap = argparse.ArgumentParser(description="DCU EtherCAT 主站网页工具后端") - # 8080 被 ESP32 tools web 占用, 本工具固定从 8090 起 - ap.add_argument("--port", type=int, default=8090) - ap.add_argument("--no-browser", action="store_true") - ap.add_argument("--window", action="store_true", - help="pywebview 桌面窗口模式(需 pip install pywebview)") - ap.add_argument("--parent-pid", type=int, default=0, - help="内部用: GUI 父进程 pid, 父进程消失时本服务自动退出") - args = ap.parse_args() - - if args.window: - try: - import webview - except ImportError: - print("pywebview 未安装: pip install pywebview —— 回退浏览器模式") - webview = None - if webview is not None: - _run_window(webview) - return - _run_browser(args) - - -if __name__ == "__main__": - main() diff --git a/src/test_motor_backend.py b/src/test_motor_backend.py deleted file mode 100644 index 254ce75..0000000 --- a/src/test_motor_backend.py +++ /dev/null @@ -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(" 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("= 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(" 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(" 被窗口盖住并回传的次数 - - @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(" 从站收到 {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(" 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())