From d594b8fd94ebbeb0595829703e1b5d9fe89653a5 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Thu, 3 Sep 2026 01:56:29 +0800 Subject: [PATCH] =?UTF-8?q?init:=20DCU=20LAN9252=20EtherCAT=20=E4=B8=BB?= =?UTF-8?q?=E7=AB=99=E5=B7=A5=E5=85=B7=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现有工具纳入版本管理: - ecat_tool.py: 一体化命令行主站(status/flash/eeprom/sdo/send) - ecat_server.py + ecat.html/css: 网页/桌面控制台(端口 8090) - ecat_scan.py / ecat_esc_regs.py / ecat_sniff.py: bring-up 扫描、 网络侧 ESC 寄存器诊断、裸帧抓包对照 - ecat_flash_eeprom.py: 经网线烧写 SII 镜像 + 逐字校验 - *.bat / *.vbs 启动入口 新增 .gitignore(排除 venv/ 与 __pycache__)、.gitattributes(不改写换行符)、 requirements.txt(锁定 pysoem 1.1.13 等依赖)、README.md。 --- .gitattributes | 2 + .gitignore | 23 + README.md | 43 ++ _repro_hang.py | 78 +++ ecat.bat | 8 + ecat.css | 797 ++++++++++++++++++++++++++++ ecat.html | 477 +++++++++++++++++ ecat_app.vbs | 12 + ecat_esc_regs.py | 281 ++++++++++ ecat_flash_eeprom.py | 125 +++++ ecat_scan.py | 120 +++++ ecat_server.py | 1196 ++++++++++++++++++++++++++++++++++++++++++ ecat_sniff.py | 92 ++++ ecat_tool.py | 562 ++++++++++++++++++++ ecat_web.bat | 7 + ecat_web.vbs | 8 + requirements.txt | 14 + run_scan.bat | 9 + 18 files changed, 3854 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 _repro_hang.py create mode 100644 ecat.bat create mode 100644 ecat.css create mode 100644 ecat.html create mode 100644 ecat_app.vbs create mode 100644 ecat_esc_regs.py create mode 100644 ecat_flash_eeprom.py create mode 100644 ecat_scan.py create mode 100644 ecat_server.py create mode 100644 ecat_sniff.py create mode 100644 ecat_tool.py create mode 100644 ecat_web.bat create mode 100644 ecat_web.vbs create mode 100644 requirements.txt create mode 100644 run_scan.bat diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b3ecb8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# 该工具集在 Windows 下开发运行,保持文件换行符原样,避免 checkout/commit 时被改写 +* -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29a92c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# 虚拟环境(本地生成,勿入库) +venv/ +.venv/ +env/ + +# 日志与抓包产物 +*.log +*.pcap +*.pcapng +sniff_*.txt + +# 编辑器 / 系统 +.vscode/ +.idea/ +*.swp +Thumbs.db +desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..c927c37 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# ecat — DCU LAN9252 EtherCAT 主站工具集 + +在 Windows 上用 Python + pysoem(SOEM)直接从网卡驱动 DCU 的 LAN9252 EtherCAT 从站: +扫描总线、读写 SII EEPROM、CoE SDO 读写、PDO 循环收发,以及一个本地网页/桌面控制台。 + +## 环境准备 + +- Python 3.12(venv 不入库) +- [Npcap](https://npcap.com/):pysoem 收发裸 EtherCAT 帧的前提 +- 一块直连从站的物理网卡(示例中为 Intel I226) + +```bat +py -3.12 -m venv venv +venv\Scripts\python.exe -m pip install -r requirements.txt +``` + +## 目录内容 + +| 文件 | 说明 | +|------|------| +| `ecat_tool.py` | 一体化命令行主站:`adapters` / `status` / `flash` / `eeprom` / `sdo` / `send` 等子命令 | +| `ecat_server.py` | 网页控制台后端(REST API + 静态文件),默认端口 **8090**(8080 已被 ESP32 tools 占用) | +| `ecat.html` / `ecat.css` | 网页控制台前端 | +| `ecat_scan.py` | 最小化 bring-up 扫描:从站身份 + SII dump,`--op` 追加 PDO 测试 | +| `ecat_esc_regs.py` | 从**网络侧**直读 LAN9252 ESC 寄存器,绕开 GD32/PDI,用于判断 EEPROM 加载是否真的失败;支持 `--reload` | +| `ecat_flash_eeprom.py` | 通过网线烧写 `gen_eeprom.py` 生成的 SII 镜像,逐字回读校验(烧完需断电重启让 ESC 重新加载) | +| `ecat_sniff.py` | 旁路抓包,hexdump SOEM 自己发出的帧,用于对照裸帧格式 | +| `_repro_hang.py` | 复现 op/start 卡住→闪退的最小脚本,带 faulthandler 看门狗 | + +## 启动方式 + +| 入口 | 效果 | +|------|------| +| `ecat_app.vbs` | pywebview 桌面窗口,无控制台无浏览器标签(已针对部分 GPU 驱动白屏加 `--disable-gpu-compositing`) | +| `ecat_web.vbs` | 后台起服务并打开浏览器标签,无控制台窗口 | +| `ecat_web.bat` | 控制台模式,可见日志,调试用 | +| `ecat.bat` | 命令行工具入口,如 `ecat.bat status`、`ecat.bat flash` | +| `run_scan.bat` | 直接跑 bring-up 扫描,无参数则列出 Npcap 适配器 | + +## 注意 + +- 前端 CSS/JS 改动后记得 bump `?v=` 缓存版本,否则浏览器会加载旧代码。 +- EEPROM 烧写后必须断电冷启动,ESC 才会重新加载配置。 diff --git a/_repro_hang.py b/_repro_hang.py new file mode 100644 index 0000000..e2a30fc --- /dev/null +++ b/_repro_hang.py @@ -0,0 +1,78 @@ +"""_repro_hang.py — 复现 2026-09-03 01:40 "op/start 卡住→闪退"。 + +复刻 ecat.html 的 UI 操作序列(100ms 周期 + 2s pollSync + 手动 stop/start 节奏): + open → 循环 N 轮{ op/start → 立即 cycle+sync_diag → cycle@100ms → ~2s 后 op/stop } → close +faulthandler 45s 看门狗: 卡住时自动转储所有线程栈(op/start 有界 ~26s, 超过 45s = 真卡死)。 +退出码非 0xC0000005 之外的异常退出也会打印。 +""" +import faulthandler +import sys +import time + +faulthandler.enable() # 捕获原生访问违例(SEH)并打印 Python 栈 = 闪退根因现场 +faulthandler.dump_traceback_later(45, exit=False) + +import ecat_server as es + + +def t(): + return time.strftime("%H:%M:%S") + + +def show(res): + for line in res.get("log", []): + print(f" [{t()}] {line}", flush=True) + + +def main(): + rounds = int(sys.argv[1]) if len(sys.argv) > 1 else 30 + r = es.api_open({"substr": "I226"}) + show(r) + if not r.get("ok"): + print("open 失败, 退出", flush=True) + return 1 + + for i in range(rounds): + print(f"=== round {i} op/start ===", flush=True) + r = es.api_op_start({"dc_cycle_us": 0}) + show(r) + if not r.get("ok"): + print(f" [{t()}] op/start 失败, 继续下一轮", flush=True) + time.sleep(1.0) + continue + # 复刻 doCycle() 立即一次 + pollSync() 立即一次 + r = es.api_op_cycle({"outputs": ""}) + if not r.get("ok"): + show(r) + r = es.api_sync_diag({}) + if not r.get("ok"): + show(r) + # 100ms 周期跑 ~2s, 中途 2s 处再 pollSync 一次 + end = time.time() + 2.0 + synced = False + while time.time() < end: + r = es.api_op_cycle({"outputs": ""}) + if not r.get("ok"): + show(r) + break + if not synced and time.time() > end - 1.0: + synced = True + r = es.api_sync_diag({}) + if not r.get("ok"): + show(r) + time.sleep(0.1) + print(f" [{t()}] op/stop", flush=True) + r = es.api_op_stop({}) + show(r) + time.sleep(1.0) # 用户点击间隔 + + es.api_close({}) + print(f"[{t()}] 全部 {rounds} 轮完成, 无卡死", flush=True) + faulthandler.cancel_dump_traceback_later() + return 0 + + +if __name__ == "__main__": + rc = main() + print(f"EXIT rc={rc}", flush=True) + sys.exit(rc) diff --git a/ecat.bat b/ecat.bat new file mode 100644 index 0000000..e20d29b --- /dev/null +++ b/ecat.bat @@ -0,0 +1,8 @@ +@echo off +rem EtherCAT master tool for DCU LAN9252 (scan/flash/send/monitor) +rem Examples: ecat.bat status | ecat.bat flash | ecat.bat send --u64 0x1,0x2,0x3,0x4 --duration 5 +chcp 65001 >nul +set PYTHONUTF8=1 +cd /d "%~dp0" +"%~dp0venv\Scripts\python.exe" ecat_tool.py %* +pause diff --git a/ecat.css b/ecat.css new file mode 100644 index 0000000..ba4b0ed --- /dev/null +++ b/ecat.css @@ -0,0 +1,797 @@ +/* motor.css - 电机控制台页面样式 + * 由 motor.html 内联 + + + + +
+
+ + EtherCAT 控制台 + DCU LAN9252 +
+
+
+ + + + +
+
+
+ ESC EEPROM 直读 · 烧录后断电重启生效 +
+
+ AL — +
+
+ +
+ + 未连接 — 输入适配器匹配串(默认 I226)后点击连接 +
+ + +
+ + + + + +
+
+ EEPROM · SII / AT24C32 + 4096 B · word 寻址 +
+
+
+
+ start word 地址 + +
+
+ count word 数 + +
+
+   + +
+
+
—
+
+ + + + +
+
+ 烧录逐字校验约数秒; 完成后板子断电重启, ESC 才重载 PDI/SYNC 配置。 +
+
+
全局日志
+
+
+ +
+
+
+
+ + +
+
+ 过程数据 · 4×64bit + 输出 — / 输入 — +
+
+ + +
+
+ +
+
+
输出 u64[0]
+
输出 u64[1]
+
输出 u64[2]
+
输出 u64[3]
+
+
+
+ 周期 ms + +
+
+ DC SYNC0 us + +
+
+   + +
+
+
+
—
IN u64[0]
+
—
IN u64[1]
+
—
IN u64[2]
+
—
IN u64[3]
+
+
+
周期0
+
WKC 错误0
+
输出区—
+
输入区—
+
SYNC0—
+
SYNC0/s—
+
+
—
+
+ 循环期间每周期都把上方 4 个输出值写入输出 PDO, 修改即时生效。 +
+
+ +
+
+
index
+
sub
+
size 读
+
 
+
+
+
写 hex 字节流
+
 
+
+
—
+
+
+
+ +
+ + + + + + + diff --git a/ecat_app.vbs b/ecat_app.vbs new file mode 100644 index 0000000..d3481ed --- /dev/null +++ b/ecat_app.vbs @@ -0,0 +1,12 @@ +' DCU EtherCAT desktop window (pywebview/WebView2). No console, no browser tab. +' Browser-tab launcher: ecat_web.vbs 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 +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 & "\ecat_server.py"" --window", 0, False diff --git a/ecat_esc_regs.py b/ecat_esc_regs.py new file mode 100644 index 0000000..601a7f3 --- /dev/null +++ b/ecat_esc_regs.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""ESC 网络侧寄存器诊断(绕过 GD32/HBI,直接从 EtherCAT 线上读 LAN9252)。 + +背景:GD32 侧 1.5.37 实测 EEPROM 300ms 不加载(DL_STATUS/EEP_CSR 经 PDI 读全 0), +但 PDI 侧读全 0 也可能是"PDI 未使能"的次生现象,不能区分: + A) EEPROM boot 加载真的失败(I2C 无应答 / CRC 错 / 寻址错) + B) 加载其实成功,PDI 通路另有问题 +本工具从网络侧读真实寄存器一锤定音,并可用 --reload 命令 ESC 当场重新加载验证。 + +步骤: + 1) pysoem 读 SII 前 16 字 + 重算 CRC -> EEPROM 内容是否完好(网络侧 I2C 是否可用) + 2) Npcap 裸 EtherCAT 帧 APRD 读 0x0000/0x0010/0x0110/0x0130/0x0140/0x0500/0x0502 + -> EEP Status(0x0502) 的 loaded/NACK/CRC 错误位 + 3) --reload:APWR 0x0502=0x0300 触发重新加载,再读一遍状态对比 + +用法:ecat_esc_regs.py [adapter-substr] [--reload] (默认 I226) +""" +import ctypes +import struct +import sys +import time + +import pysoem + +ECAT_ETHER_TYPE = 0x88A4 +CMD_APRD = 0x07 +CMD_APWR = 0x08 + +REGS = [ + (0x0000, "Type/Revision"), + (0x0010, "Station Address"), + (0x0110, "DL Status"), + (0x0130, "AL Status"), + (0x0140, "ESC Config(PDI type)"), + (0x0500, "EEPROM Control"), + (0x0502, "EEPROM Status"), +] + +EEP_FORCE_ECAT = 0x0001 # 0x0500 bit0: ECAT 侧强制取得 EEPROM 接口 +EEP_CMD_RELOAD = 0x0300 # 0x0502: reload 命令(同 SOEM EC_ECMD_RELOAD) + + +def crc8_sii(data): + """ETG.1000.6 word-7 checksum: CRC-8 poly 0x07, init 0xFF。""" + crc = 0xFF + for b in data: + crc ^= b + for _ in range(8): + crc = ((crc << 1) ^ 0x07) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF + return crc + + +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 + raise SystemExit(f"没有找到描述包含 {substr!r} 的适配器") + + +# ---------- 第 1 步:pysoem 读 SII 内容 + CRC 复算 ---------- +def sii_check(adapter_name): + master = pysoem.Master() + master.open(adapter_name) + try: + if not master.config_init(): + print("!! 总线上没有从站应答") + return False + slave = master.slaves[0] + # 与 ecat_flash_eeprom.py 相同的读回偏移探针(写完即恢复) + probe = b"\xC3\xA5" + slave.eeprom_write(0x0200, probe) + rb = bytes(slave.eeprom_read(0x0200)) + off = rb.find(probe) + slave.eeprom_write(0x0200, b"\xff\xff") + if off < 0: + print(f"!! 探针字读写失败,读回 {rb.hex().upper()}") + print("!! 网络侧 EEPROM 访问已坏 -> I2C 物理层(R12 SDA 上拉/EEPROM 焊接)") + return False + words = [] + for w in range(16): + rb = bytes(slave.eeprom_read(w)) + words.append(struct.unpack_from(" " + f"{'MATCH' if crc_calc == crc_stored else '*** MISMATCH ***'}") + vid = words[8] | (words[9] << 16) + pc = words[10] | (words[11] << 16) + print(f" VendorID=0x{vid:08X} ProductCode=0x{pc:08X}") + return crc_calc == crc_stored + finally: + master.close() + + +# ---------- 第 2 步:Npcap 裸 EtherCAT 帧读 ESC 寄存器 ---------- +class PcapPktHdr(ctypes.Structure): + _fields_ = [("tv_sec", ctypes.c_long), ("tv_usec", ctypes.c_long), + ("caplen", ctypes.c_uint32), ("len", ctypes.c_uint32)] + + +def load_wpcap(): + for cand in ("wpcap.dll", r"C:\Windows\System32\Npcap\wpcap.dll"): + try: + return ctypes.WinDLL(cand) + except OSError: + continue + raise SystemExit("找不到 wpcap.dll(Npcap)") + + +def ecat_frame(cmd, idx, adp, ado, payload): + """帧布局按 ecat_sniff.py 对 SOEM 流量的实测: + EtherCAT 头 len = datagram 区长度(不含自身 2 字节); + datagram 头 = 10 字节(cmd/idx/adp/ado/len/irq=0),后接 data + WKC(2)。""" + dst = b"\xff" * 6 + src = bytes([0x02, 0x44, 0x43, 0x55, 0x00, 0x01]) # 本地管理 MAC,仅作源标识 + eth = dst + src + struct.pack(">H", ECAT_ETHER_TYPE) + dlen = 10 + len(payload) + 2 + ecat_hdr = struct.pack(" I2C 物理层: R12 SDA 上拉/焊接)") + if v & 0x2000: + bits.append("ERR:CRC(SII 校验失败 -> 内容或寻址)") + if v & 0x4000: + bits.append("ERR:CMD") + if v & 0x8000: + bits.append("BUSY") + return "|".join(bits) if bits else "(无错误位置位)" + + +def dump_regs(bus, tag): + vals = {} + print(f"--- ESC 寄存器({tag})---") + for addr, name in REGS: + v = bus.read16(addr) + vals[addr] = v + if v is None: + print(f" 0x{addr:04X} {name}: *** 无应答 ***") + continue + extra = "" + if addr == 0x0502: + extra = " | " + decode_eep_status(v) + elif addr == 0x0140: + extra = f" | pdi_type=0x{v & 0xFF:02X}(期望 0x8D)" + elif addr == 0x0110: + extra = f" | pdi_oper={v & 0x1}" + print(f" 0x{addr:04X} {name}: 0x{v:04X}{extra}") + return vals + + +def main(): + substr = "I226" + do_reload = False + for arg in sys.argv[1:]: + if arg == "--reload": + do_reload = True + else: + substr = arg + adapter = find_adapter(substr) + print(f"适配器: {adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc}") + + crc_ok = sii_check(adapter.name) + + bus = RawBus(adapter.name) + try: + vals = dump_regs(bus, "当前") + if do_reload: + print("\n触发 EEPROM 重新加载(0x0500=0x0001, 0x0502=0x0300)…") + bus.transact(CMD_APWR, 0x0500, struct.pack(" GD32 侧读全 0 不是加载失败,而是 HBI 访问核心寄存器的通路问题,转查 PDI。") + elif st & 0x1000: + print("EEPROM 加载失败:NACK —— LAN9252 在 I2C 上得不到 EEPROM 应答。") + print("=> 硬件:量 AT24C32 pin6(SCL)/pin5(SDA) 空闲电平应≈3.3V;") + print(" SDA 若≈0V 或悬空 -> R12(10k->D3V3) 没焊(原理图标 DNP 的那颗)。") + elif st & 0x2000: + print("EEPROM 加载失败:CRC 校验错。") + print("=> 内容/CRC/寻址问题:上方 SII CRC 重算" + + ("也不符 -> 重跑 ecat_flash_eeprom.py 重刷" if not crc_ok + else "相符 -> 内容对但 loader 读错位置,查 E2PSIZE strap 寻址")) + elif not (st & 0x0800): + print("loaded=0 且无错误位——loader 未跑/未跑完,异常状态,把完整输出发回分析。") + else: + print("loaded=1 但 pdi_type != 0x8D——EEPROM word0 不对,重刷 SII。") + + +if __name__ == "__main__": + main() diff --git a/ecat_flash_eeprom.py b/ecat_flash_eeprom.py new file mode 100644 index 0000000..24e8339 --- /dev/null +++ b/ecat_flash_eeprom.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Flash the SII EEPROM image (generated by gen_eeprom.py) into the LAN9252 +over the EtherCAT cable — no external programmer, no HBI needed. + +Usage: + ecat_flash_eeprom.py [adapter-substr] (default: I226) + +Steps: load gen_eeprom.py in-process (its asserts validate the image), +probe the EEPROM byte order with a scratch word, write every non-0xFFFF +word with immediate read-back verify, then restore the scratch word. +Afterwards POWER CYCLE the board so the ESC reloads its config. +""" +import contextlib +import importlib.util +import io +import struct +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 + + +def load_image(): + spec = importlib.util.spec_from_file_location("gen_eeprom", GEN) + mod = importlib.util.module_from_spec(spec) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + spec.loader.exec_module(mod) # runs its own self-check asserts + img = bytes(mod.eeprom) + print(f"镜像加载成功: {len(img)} 字节, checksum=0x{img[14]:02X}, " + f"VendorID=0x{struct.unpack_from(' 1 else "I226" + img = load_image() + slave = open_slave(substr) + verify_off = probe_word_layout(slave) + + total = len(img) // 2 + n_written = n_fail = 0 + fails = [] + for word in range(total): + val = struct.unpack_from(">> 请给板子断电重新上电(ESC 重新加载 EEPROM 配置),") + print(">>> 然后运行 run_scan.bat I226 验证身份字。") + + +if __name__ == "__main__": + main() diff --git a/ecat_scan.py b/ecat_scan.py new file mode 100644 index 0000000..4b9ab8a --- /dev/null +++ b/ecat_scan.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +"""EtherCAT master bring-up test for the DCU LAN9252 slave (pysoem >= 1.1 API). + +Usage: + ecat_scan.py list all Npcap adapters and exit + ecat_scan.py open the first adapter whose description matches + the substring (e.g. "I226"), scan the bus and + print slave identity + SII EEPROM dump + ecat_scan.py --op additionally request OP and run PDO cycles for 2s +""" +import sys +import time + +try: + import pysoem +except ImportError: + print("pysoem 无法加载:请先安装 Npcap(本目录下的 npcap-1.88.exe," + "安装时保持勾选 'Install WinPcap API-compatible Mode')") + sys.exit(1) + + +AL_STATE = {1: "INIT", 2: "PREOP", 4: "SAFEOP", 8: "OP"} + + +def al_state_name(code): + return AL_STATE.get(code, f"0x{code:02x}") + + +def list_adapters(): + for i, a in enumerate(pysoem.find_adapters()): + desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc + print(f"[{i}] {desc}\n {a.name}") + + +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 + + +def dump_eeprom(slave, count=0x10): + print(" SII EEPROM dump (每行 8 个 word 地址,原始 32bit 返回):") + for base in range(0, count, 8): + words = [] + for w in range(base, base + 8): + try: + v = bytes(slave.eeprom_read(w)) + words.append(v.hex().upper()) + except Exception: + words.append("ERRRRRRRR") + print(f" 0x{base:02X}: {' '.join(words)}") + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("-l", "--list"): + list_adapters() + return + + substr = sys.argv[1] + want_op = "--op" in sys.argv[2:] + + adapter = find_adapter(substr) + if adapter is None: + print(f"没有找到描述包含 {substr!r} 的适配器,当前列表:") + list_adapters() + sys.exit(1) + + desc = adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc + master = pysoem.Master() + master.open(adapter.name) + + try: + n = master.config_init() + except Exception as e: + print(f"总线上没有发现从站({type(e).__name__}: {e}):" + "请检查 DCU 板子是否上电、网线是否接到 I226-V 网口") + sys.exit(2) + + if not n: + print("总线上没有发现从站:请检查 DCU 板子是否上电、网线是否接到 I226-V 网口") + sys.exit(2) + + print(f"\n适配器: {desc}") + print(f"发现 {n} 个从站:") + for i, s in enumerate(master.slaves): + print(f" [{i}] name={s.name!r}") + print(f" VendorID=0x{s.man:08X} ProductCode=0x{s.id:08X} Revision={s.rev}") + print(f" AL state={al_state_name(s.state)} AL status code=0x{s.al_status:04X}" + f" DC={s.dc_sync}") + + dump_eeprom(master.slaves[0]) + + if not want_op: + return + + master.config_map() + if master.state_check(pysoem.SAFEOP_STATE, 2000) != pysoem.SAFEOP_STATE: + print(f"进入 SAFEOP 失败, 当前 AL 状态: {al_state_name(master.state)}") + sys.exit(3) + + t_end = time.time() + 2.0 + frames = 0 + while time.time() < t_end: + master.send_processdata() + master.receive_processdata(2000) + frames += 1 + time.sleep(0.001) + + master.state = pysoem.OP_STATE + master.write_state() + if master.state_check(pysoem.OP_STATE, 2000) == pysoem.OP_STATE: + print(f"已进入 OP, PDO 循环 {frames} 帧正常") + else: + print(f"进入 OP 失败, 当前 AL 状态: {al_state_name(master.state)}") + + +if __name__ == "__main__": + main() diff --git a/ecat_server.py b/ecat_server.py new file mode 100644 index 0000000..09a0b9a --- /dev/null +++ b/ecat_server.py @@ -0,0 +1,1196 @@ +#!/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)。 +""" +import argparse +import collections +import ctypes +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() + +# 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 = Falseg_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 板供电、网线是否接到该网口"]} + if not n: + 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 + 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: + return log + [f"config_map 失败: {type(e).__name__}: {e}"], 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} + + +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 返回 → 进程退出。""" + 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 _focus_existing(port): + """让已有实例的窗口到前台。返回 True 表示确实存在旧实例。""" + try: + urllib.request.urlopen(urllib.request.Request( + f"http://127.0.0.1:{port}/api/focus", data=b"{}", + headers={"Content-Type": "application/json"}, method="POST"), + timeout=2) + return True + except Exception: + return False + + +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/sdo": ("POST", api_sdo), + "/api/sync_diag": ("POST", api_sync_diag), + "/api/shutdown": ("POST", api_shutdown), +} + + +class Handler(BaseHTTPRequestHandler): + server_version = "EcatTool/1.0" + + def log_message(self, fmt, *args): + pass # 静默访问日志 + + 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) + + def do_GET(self): + path = self.path.split("?")[0] + static_map = {"/": "ecat.html", "/ecat.html": "ecat.html", + "/index.html": "ecat.html", "/ecat.css": "ecat.css"} + if path in static_map: + fp = os.path.join(BASE_DIR, static_map[path]) + try: + with open(fp, "rb") as f: + body = f.read() + if path.endswith(".css"): + ctype = "text/css; charset=utf-8" + else: + ctype = "text/html; charset=utf-8" + 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._send(404, b'{"ok":false,"log":["not found"]}') + + 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._send(404, b'{"ok":false,"log":["not found"]}') + 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 + 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 + + +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 + if getattr(args, "parent_pid", 0): + _watch_parent(args.parent_pid) + 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 + if not _acquire_single_instance(): + probed = False + for p in (8090, 8091, 8092): + if _focus_existing(p): + probed = True + break + if probed: + os._exit(0) + _fail_box("EtherCAT 控制台", + "已有一个实例在运行, 但无法唤醒其窗口。\n" + "请到任务管理器结束所有 pythonw.exe 后重新打开。") + os._exit(0) + + 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/ecat_sniff.py b/ecat_sniff.py new file mode 100644 index 0000000..fb870d6 --- /dev/null +++ b/ecat_sniff.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""抓 SOEM 自己发的 EtherCAT 帧,对照裸帧格式。 +另开 pcap 句柄抓包,同时在同进程里跑 pysoem eeprom_read,hexdump 收发帧。 +用法:ecat_sniff.py [adapter-substr]""" +import ctypes +import struct +import sys +import threading +import time + +import pysoem + +ECAT = b"\x88\xa4" + + +class PcapPktHdr(ctypes.Structure): + _fields_ = [("tv_sec", ctypes.c_long), ("tv_usec", ctypes.c_long), + ("caplen", ctypes.c_uint32), ("len", ctypes.c_uint32)] + + +def main(): + substr = sys.argv[1] if len(sys.argv) > 1 else "I226" + adapter = None + for a in pysoem.find_adapters(): + desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc + if substr.lower() in desc.lower(): + adapter = a + break + if adapter is None: + raise SystemExit("适配器未找到") + name = adapter.name.decode() if isinstance(adapter.name, bytes) else adapter.name + + wpcap = ctypes.WinDLL("wpcap.dll") + wpcap.pcap_open_live.restype = ctypes.c_void_p + wpcap.pcap_open_live.argtypes = [ctypes.c_char_p] + [ctypes.c_int] * 3 + [ctypes.c_char_p] + wpcap.pcap_next_ex.restype = ctypes.c_int + wpcap.pcap_next_ex.argtypes = [ctypes.c_void_p, + ctypes.POINTER(ctypes.POINTER(PcapPktHdr)), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))] + wpcap.pcap_close.restype = None + wpcap.pcap_close.argtypes = [ctypes.c_void_p] + errbuf = ctypes.create_string_buffer(256) + handle = wpcap.pcap_open_live(name.encode(), 65535, 1, 50, errbuf) + if not handle: + raise SystemExit(f"pcap_open_live 失败: {errbuf.value.decode(errors='replace')}") + + def soem_work(): + try: + master = pysoem.Master() + master.open(adapter.name) + master.config_init() + slave = master.slaves[0] + slave.eeprom_read(0) + master.close() + except Exception as e: + print(f"soem_work: {type(e).__name__}: {e}") + + t = threading.Thread(target=soem_work, daemon=True) + t.start() + + frames = [] + deadline = time.time() + 4.0 + ph = ctypes.POINTER(PcapPktHdr)() + pd = ctypes.POINTER(ctypes.c_ubyte)() + while time.time() < deadline and len(frames) < 8: + r = wpcap.pcap_next_ex(handle, ctypes.byref(ph), ctypes.byref(pd)) + if r != 1: + continue + data = bytes(bytearray(pd[:ph.contents.caplen])) + if len(data) >= 14 and data[12:14] == ECAT: + frames.append(data) + wpcap.pcap_close(handle) + + print(f"抓到 {len(frames)} 个 EtherCAT 帧") + for i, f in enumerate(frames): + src = ":".join(f"{b:02X}" for b in f[6:12]) + print(f"\n[{i}] len={len(f)} src={src}") + for off in range(14, min(len(f), 14 + 48), 16): + chunk = f[off:off + 16] + print(f" +{off - 14:02d}: " + " ".join(f"{b:02X}" for b in chunk)) + # 解析前两个 datagram 头字段,确定头部布局 + elen = struct.unpack_from("> 12}") + d = f[16:] + if len(d) >= 12: + cmd, idx, adp, ado, ln = struct.unpack_from(" EEPROM_SIZE: + raise SystemExit(f"镜像 {len(img)}B 超过 EEPROM 容量 {EEPROM_SIZE}B") + # 字节序探针(写入 0x1234 找读回偏移,随后恢复擦除态) + test = b"\x34\x12" + slave.eeprom_write(0x0200, test) + rb = bytes(slave.eeprom_read(0x0200)) + off = rb.find(test) + if off < 0: + raise SystemExit(f"EEPROM 写探针失败: 读回 {rb.hex().upper()}") + slave.eeprom_write(0x0200, b"\xFF\xFF") + print(f"字节序探针 OK (读回偏移 {off})") + + n_written = n_fail = 0 + fails = [] + for word in range(len(img) // 2): + val = struct.unpack_from(">> 板子断电重新上电后 ESC 才会重载配置(身份字/PDI/SYNC)") + + +def cmd_flash(args): + if args.bin: + with open(args.bin, "rb") as f: + img = f.read() + label = os.path.basename(args.bin) + if len(img) % 2: + img += b"\xFF" + else: + from ecat_flash_eeprom import load_image # 自带 gen_eeprom.py 自校验 + img = load_image() + label = "gen_eeprom.py" + print(f"镜像: {label}, {len(img)} 字节") + master, desc = open_master(args.substr) + print(f"适配器: {desc}, 从站应答正常") + flash_image(master.slaves[0], img, label) + + +def cmd_eeprom(args): + master, _ = open_master(args.substr) + slave = master.slaves[0] + if args.op == "read": + start, count = args.start, args.count + rows = [] + for w in range(start, start + count): + rows.append(struct.unpack(" EEPROM_SIZE: + raise SystemExit(f"{len(data)}B 超过 EEPROM 容量 {EEPROM_SIZE}B") + n = n_fail = 0 + for word in range(len(data) // 2): + val = struct.unpack_from(" FAIL")) + sys.exit(0 if n_fail == 0 else 3) + elif args.op == "verify": + with open(args.file, "rb") as f: + data = f.read() + if len(data) % 2: + data += b"\xFF" + diffs = 0 + for word in range(min(len(data), EEPROM_SIZE) // 2): + expect = struct.unpack_from(" FAIL")) + sys.exit(0 if diffs == 0 else 3) + + +# ---------------------------------------------------------------- SDO +def cmd_sdo(args): + master, desc = open_master(args.substr) + s = master.slaves[0] + if args.op == "read": + try: + data = bytes(s.sdo_read(args.index, args.sub, args.size)) + except Exception as e: + raise SystemExit(f"SDO 读失败: {type(e).__name__}: {e}") + print(f"0x{args.index:04X}:{args.sub} = {data.hex().upper()} ({len(data)}B)") + else: + try: + payload = bytes.fromhex(args.hex.replace(" ", "")) + except ValueError: + raise SystemExit("HEX 非法: 只能是十六进制字节流") + try: + s.sdo_write(args.index, args.sub, payload) + except Exception as e: + raise SystemExit(f"SDO 写失败: {type(e).__name__}: {e}") + print(f"0x{args.index:04X}:{args.sub} <- {payload.hex().upper()} ({len(payload)}B) OK") + + +# ---------------------------------------------------------------- PDO 收发 +def enter_op(master, dc_cycle_us=0, dc_shift_us=0): + """config_map → (可选 DC) → SAFEOP → OP。 + + dc_cycle_us=0 时保持 free-run(SYNC0 引脚无脉冲,从站 Sync0_Isr 不会触发)。 + 非 0 时先 config_dc() 测量传播延迟,再对从站下发 SYNC0 周期, + 必须在 config_map() 之后、进 SAFEOP 之前完成(ETG.1020 DC 配置顺序)。 + """ + master.state = pysoem.PREOP_STATE | pysoem.STATE_ACK + master.write_state() + if master.state_check(pysoem.PREOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.PREOP_STATE: + # read_state 拿真实 AL Status Code(广播 state_check 不刷新 al_status, + # 陈旧缓存恒显示 0x0000)。首败常见 0x0016/0x001D 类:停止回 INIT 时 SSC + # 禁用了邮箱 SM0/SM1,CheckSmSettings 拒绝 INIT→PREOP,重新 config_init 即恢复。 + try: + master.read_state() + except Exception: + pass + codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves] + raise SystemExit(f"进 PREOP 失败: AL={codes}(真实值) —— 从站未 ack AL Control=PREOP。" + "0x0016/0x001D 类=邮箱 SM0/SM1 被停止时禁用(重跑即恢复);" + "抓 GD32 串口看 [ECAT][AL] evt/req") + + # free-run 时清残留 DC 激活(2026-09-03 00:27 卡死根因之一):之前跑过 DC 的 + # 0x0981 激活位跨重连不清,从站会按 DC 同步模式等 PLL,free-run 下永不进 OP。 + # 必须在 config_map(其内部请求 SAFEOP)之前清除。 + if not dc_cycle_us: + try: + master.slaves[0].dc_sync(False, 0, 0) + print("已清除残留 DC 激活(free-run)") + except Exception as e: + print(f"WARN: 清 DC 激活失败({type(e).__name__}: {e})") + + master.config_map() + + # 不要写 0x1C32:01 / 0x1C33:01 去改 SyncType:该子索引只允许在 PREOP 写 + # (ACCESS_WRITE_PREOP),而 config_map() 已把从站推到 SAFEOP,实测返回 + # SdoError 0x08000022(当前状态不可写)。而且没必要——SSC 在 PREOP→SAFEOP 的 + # StartInputHandler() 会自动按 DC 寄存器/PDO 长度推导出 SM_SYNCHRON + # (ecatslv.c:849-880),coeappl.c 里的 FREERUN 只是上电初值。 + + if dc_cycle_us: + has_dc = master.config_dc() + if not has_dc: + raise SystemExit("config_dc(): 总线上没有支持 DC 的从站 —— " + "检查 SII word1 高字节(0x0151 Sync/Latch 配置)是否为 0xCC") + master.slaves[0].dc_sync(True, dc_cycle_us * 1000, dc_shift_us * 1000) + print(f"DC 已启用: SYNC0 周期 {dc_cycle_us}us" + + (f", shift {dc_shift_us}us" if dc_shift_us else "") + + f", 主站 DC 时间={master.dc_time}ns") + + # 显式带 ACK 写 SAFEOP(config_map 内部已推过一次,这里再立一次并顺带清掉 + # 上一轮残留的错误位——SSC 只有带 ACK 位的写才能清错+迁移,普通写会被忽略) + master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK + master.write_state() + if master.state_check(pysoem.SAFEOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.SAFEOP_STATE: + try: + master.read_state() # 真实 AL Status Code(广播 state_check 不刷新) + except Exception: + pass + codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves] + raise SystemExit(f"进 SAFEOP 失败: AL={codes}(真实值) —— 检查 SII 的 SM/FMMU 类别" + "(EEPROM 是否已刷)与 GD32 SSC 是否运行") + + # 主站侧直写过程数据看门狗(双保险,SOEM set_watchdog → FPWR 0x0420 = 1000ms)。 + # 从站固件 ≥1.5.45 也会写 1s,但板上固件未更新时 100ms 复位默认值会在 OP 等待期 + # 触发 0x001B 拖回 SAFEOP;主站每次连接直接写,不依赖固件版本。 + try: + master.slaves[0].set_watchdog('processdata', 1000.0) + except Exception as e: + print(f"WARN: 主站侧写看门狗失败({type(e).__name__}: {e})") + + # SAFEOP -> OP:从站有输出 PDO 时,SSC 要求先收到至少一帧过程数据才 ack OP + # (ecatslv.c:1993 的 `nPdOutputSize == 0 || bEcatFirstOutputsReceived`), + # 否则迁移被挂起、随后回落,表现为 "进 OP 失败 ... code=0x0000"。 + # 所以必须边发 processdata 边等,不能只 write_state 后干等。 + # AL Control 一律带 ACK 位(0x10):SSC 对"错误位已置+无 ACK+请求≠INIT"的请求 + # 直接忽略(ecatslv.c:1376-1384),残留的 0x001B 错误位不清掉,写 OP 无人应答。 + master.state = pysoem.OP_STATE | pysoem.STATE_ACK + master.write_state() + + # 状态查询用 read_state() 而非 state_check():后者 slave=0 广播模式不更新 + # slavelist[0].ALstatuscode(报错里的 code 是陈旧缓存,恒 0x0000),且内部 + # 1ms 轮询发帧风暴。read_state 无错误时 1 帧 BRD,有错误位时逐从站 FPRD + # 拿**真实 AL Status Code**。失败时输出真实错误码+状态轨迹。 + deadline = time.time() + 12.0 # 12s:覆盖从站 ESM 超时(9s,固件≥1.5.47)+恢复重试 + trail = [] + last_key = None + state = None + code = 0 + wkc_samples = [] + next_rewrite = 0.0 # OP|ACK 重写节流:每 200ms 最多一次(避免 AL Control 写风暴) + while time.time() < deadline: + master.send_processdata() + wkc = master.receive_processdata(2000) + if not wkc_samples or wkc_samples[-1] != (wkc, master.expected_wkc): + wkc_samples.append((wkc, master.expected_wkc)) + try: + master.read_state() + except Exception: + pass + state = master.slaves[0].state & 0x0F + code = master.slaves[0].al_status + key = (state, code) + if key != last_key: + trail.append((al_name(state), code)) + last_key = key + if state == pysoem.OP_STATE and code == 0: + break + # 跌回 PREOP/INIT 时:带 ACK 重写 SAFEOP 立起状态,再带 ACK 重请 OP + if state < pysoem.SAFEOP_STATE: + master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK + master.write_state() + master.state_check(pysoem.SAFEOP_STATE, 500_000) + next_rewrite = 0.0 + if time.time() >= next_rewrite: + master.state = pysoem.OP_STATE | pysoem.STATE_ACK + master.write_state() + next_rewrite = time.time() + 0.2 + time.sleep(0.005) # 循环节奏 ~7ms:LRW 间隔稳低于任何看门狗配置 + + if state != pysoem.OP_STATE or code != 0: + try: + master.read_state() + except Exception: + pass + st = master.slaves[0] + trail.append((al_name(st.state & 0x0F), st.al_status)) + try: + wd_ms = st.get_watchdog('processdata') + wd_info = f", 从站 PD 看门狗={wd_ms:.0f}ms" + except Exception: + wd_info = "" + wkc_info = " / ".join(f"wkc={w}/{e}" for w, e in wkc_samples[:6]) + raise SystemExit( + f"进 OP 失败: AL={al_name(st.state & 0x0F)} code=0x{st.al_status:04X}(真实值{wd_info}) —— " + f"等待期轨迹: {' -> '.join(f'{n}+0x{c:04X}' for n, c in trail)}; LRW WKC: {wkc_info}。" + "0x001B=SM watchdog;0x001A=DC 残留激活;0x001D/0x001E=SM 配置不匹配;" + "0x0000=从站在等首帧输出。请抓 GD32 串口 [ECAT][AL] evt/req 日志对照") + + +def leave_init(master): + try: + master.state = pysoem.INIT_STATE + master.write_state() + except Exception: + pass + + +def run_pdo(master, out_data, rate_ms, duration, label, dc_cycle_us=0, dc_shift_us=0): + out_len = len(bytes(master.slaves[0].output)) + if out_data is None: + out_data = bytes(out_len) + if len(out_data) < out_len: + out_data += bytes(out_len - len(out_data)) + try: + master.slaves[0].output = out_data + except Exception as e: + raise SystemExit(f"写输出缓冲失败({type(e).__name__}: {e}) —— output 长度={out_len}") + + enter_op(master, dc_cycle_us, dc_shift_us) + mode = f"DC {dc_cycle_us}us" if dc_cycle_us else "free-run" + print(f"[{label}] 已进 OP({mode}): 输出 {out_len}B / 输入 " + f"{len(bytes(master.slaves[0].input))}B, expected_wkc={master.expected_wkc}" + f"{', Ctrl+C 退出' if duration is None else f', 运行 {duration}s'}") + t_end = None if duration is None else time.time() + duration + cycles = wkc_errs = 0 + last = 0.0 + try: + while True: + master.send_processdata() + wkc = master.receive_processdata(2000) + cycles += 1 + if wkc < master.expected_wkc: + wkc_errs += 1 + now = time.time() + if now - last >= 0.5: + last = now + inp = bytes(master.slaves[0].input) + extra = "" + if len(inp) >= 32: + u64 = struct.unpack("<4Q", inp[:32]) + extra = " u64=" + ",".join(f"0x{x:X}" for x in u64) + print(f"cycles={cycles} wkc_err={wkc_errs} in={inp.hex().upper()}{extra}") + if t_end is not None and now >= t_end: + break + time.sleep(rate_ms / 1000.0) + except KeyboardInterrupt: + print("\n用户中断") + finally: + leave_init(master) + print(f"[{label}] 结束: {cycles} 周期, wkc_err={wkc_errs}, 已回 INIT") + + +def parse_output_data(args): + if args.hex: + try: + return bytes.fromhex(args.hex.replace(" ", "")) + except ValueError: + raise SystemExit("--hex 非法: 只能是十六进制字节流") + if args.u64: + try: + vals = [int(v, 0) for v in args.u64.split(",")] + except ValueError: + raise SystemExit("--u64 非法: 逗号分隔的整数(支持 0x 前缀)") + if len(vals) > 4: + raise SystemExit("--u64 最多 4 个(4×64bit=32B)") + return struct.pack(f"<{len(vals)}Q", *vals) + return None + + +def cmd_send(args): + master, desc = open_master(args.substr) + print(f"适配器: {desc}") + run_pdo(master, parse_output_data(args), args.rate_ms, + None if args.forever else args.duration, "send", + args.dc_cycle_us, args.dc_shift_us) + + +def cmd_monitor(args): + master, desc = open_master(args.substr) + print(f"适配器: {desc}") + run_pdo(master, None, args.rate_ms, + None if args.forever else args.duration, "monitor", + args.dc_cycle_us, args.dc_shift_us) + + +# ---------------------------------------------------------------- main +def main(): + p = argparse.ArgumentParser(description="DCU LAN9252 EtherCAT 主站工具") + sub = p.add_subparsers(dest="cmd", required=False) + + sub.add_parser("adapters", help="列出 Npcap 适配器") + + sp = sub.add_parser("status", help="扫描从站身份/AL 状态") + sp.add_argument("substr", nargs="?", default="I226") + + sp = sub.add_parser("flash", help="烧录 EEPROM + 逐字校验") + sp.add_argument("--bin", help="指定 bin(默认用 gen_eeprom.py 镜像)") + sp.add_argument("substr", nargs="?", default="I226") + + sp = sub.add_parser("eeprom", help="EEPROM read/write/verify") + sp.add_argument("op", choices=["read", "write", "verify"]) + sp.add_argument("file_or_start", nargs="?", default=None) + sp.add_argument("count_or_file", nargs="?", default=None) + sp.add_argument("--out") + sp.add_argument("substr", nargs="?", default="I226") + + sp = sub.add_parser("sdo", help="CoE SDO read/write") + sp.add_argument("op", choices=["read", "write"]) + sp.add_argument("index", type=lambda v: int(v, 0)) + sp.add_argument("sub", type=lambda v: int(v, 0)) + sp.add_argument("hex", nargs="?", default=None) + sp.add_argument("--size", type=lambda v: int(v, 0), default=8) + sp.add_argument("substr", nargs="?", default="I226") + + sp = sub.add_parser("send", help="进 OP 循环发送输出 PDO") + sp.add_argument("--hex", help="输出字节流(≤输出区长度)") + sp.add_argument("--u64", help="4 个 64bit, 逗号分隔, 如 0x1,0x2,0x3,0x4") + sp.add_argument("--rate-ms", type=int, default=100) + sp.add_argument("--duration", type=float, default=5.0) + sp.add_argument("--forever", action="store_true") + sp.add_argument("--dc-cycle-us", type=int, default=0, + help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)") + sp.add_argument("--dc-shift-us", type=int, default=0, + help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效") + sp.add_argument("substr", nargs="?", default="I226") + + sp = sub.add_parser("monitor", help="进 OP 只读输入 PDO") + sp.add_argument("--rate-ms", type=int, default=200) + sp.add_argument("--duration", type=float, default=10.0) + sp.add_argument("--forever", action="store_true") + sp.add_argument("--dc-cycle-us", type=int, default=0, + help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)") + sp.add_argument("--dc-shift-us", type=int, default=0, + help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效") + sp.add_argument("substr", nargs="?", default="I226") + + args = p.parse_args() + if args.cmd is None: + # 双击/无参数时默认执行 status + print("(未指定子命令, 默认执行 status; 完整用法: ecat_tool.py -h)\n") + args.cmd = "status" + args.substr = "I226" + if args.cmd == "adapters": + list_adapters() + elif args.cmd == "status": + cmd_status(args) + elif args.cmd == "flash": + cmd_flash(args) + elif args.cmd == "eeprom": + if args.op == "read": + if args.file_or_start is None or args.count_or_file is None: + raise SystemExit("用法: eeprom read START COUNT [--out FILE]") + args.start = int(args.file_or_start, 0) + args.count = int(args.count_or_file, 0) + else: + if args.file_or_start is None: + raise SystemExit(f"用法: eeprom {args.op} FILE") + args.file = args.file_or_start + cmd_eeprom(args) + elif args.cmd == "sdo": + if args.op == "write" and args.hex is None: + raise SystemExit("用法: sdo write IDX SUB HEX") + cmd_sdo(args) + elif args.cmd == "send": + cmd_send(args) + elif args.cmd == "monitor": + cmd_monitor(args) + + +if __name__ == "__main__": + main() diff --git a/ecat_web.bat b/ecat_web.bat new file mode 100644 index 0000000..a489858 --- /dev/null +++ b/ecat_web.bat @@ -0,0 +1,7 @@ +@echo off +rem DCU EtherCAT master web console (serves ecat.html + pysoem API) +chcp 65001 >nul +set PYTHONUTF8=1 +cd /d "%~dp0" +"%~dp0venv\Scripts\python.exe" ecat_server.py +pause diff --git a/ecat_web.vbs b/ecat_web.vbs new file mode 100644 index 0000000..72b5522 --- /dev/null +++ b/ecat_web.vbs @@ -0,0 +1,8 @@ +' DCU EtherCAT web console - hidden launcher (no console window). +' Double-click me. Console-mode debug launcher (visible logs): ecat_web.bat +Set sh = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") +base = fso.GetParentFolderName(WScript.ScriptFullName) +sh.CurrentDirectory = base +sh.Environment("PROCESS")("PYTHONUTF8") = "1" +sh.Run """" & base & "\venv\Scripts\python.exe"" """ & base & "\ecat_server.py""", 0, False diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e436f5c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +# venv 不入库,用以下命令重建: +# py -3.12 -m venv venv +# venv\Scripts\python.exe -m pip install -r requirements.txt +# 另需系统安装 Npcap(pysoem 抓包/发包依赖)。 +pysoem==1.1.13 +pywebview==6.2.1 +bottle==0.13.4 +pythonnet==3.1.0 +clr-loader==0.3.1 +cffi==2.1.1 +pycparser==3.0 +proxy-tools==0.1.0 +typing_extensions==4.16.0 +py-spy==0.4.2 diff --git a/run_scan.bat b/run_scan.bat new file mode 100644 index 0000000..f24eb15 --- /dev/null +++ b/run_scan.bat @@ -0,0 +1,9 @@ +@echo off +rem EtherCAT master bring-up scan (LAN9252) +rem Usage: run_scan.bat -> list Npcap adapters +rem run_scan.bat I226 -> scan EtherCAT slaves (add --op for PDO test) +chcp 65001 >nul +set PYTHONUTF8=1 +cd /d "%~dp0" +"%~dp0venv\Scripts\python.exe" ecat_scan.py %* +pause