#!/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 = 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 板供电、网线是否接到该网口"]} 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 # 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 02:12 实测楔死时 activate 位/StartAddr 异常(INIT 态读回全零, # PREOP 态读回 SII 值),这是区分"邮箱 SM 被禁用/未配置"与"SSC 不处理邮箱" # 的关键证据;失败信息里直接带上,抓 GD32 串口时可对照。 # SM 寄存器 = 0x0800 + 8n, 邮箱是 SM0=0x0800 / SM1=0x0808; # 0x0810/0x0818 是 SM2/SM3(过程数据), 勿混用。 try: sm0 = bytes(s._fprd(0x0800, 8, 20000)).hex().upper() sm1 = bytes(s._fprd(0x0808, 8, 20000)).hex().upper() sm_info = f"; 邮箱SM现场 SM0={sm0} SM1={sm1}(act位=byte[6],01=使能)" 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} 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()