Files
DCU_ECAT_Master/ecat_server.py
T
cbb a1ec486c74 fix(server): 修模块级变量粘连, op/start 入口预清理重枚举
1) g_op_active = Falseg_httpd = None 这行把 None 赋给了 g_op_active 和
   笔误变量 Falseg_httpd, 导致 g_httpd 在模块级从未定义, 第 744 行
   if g_httpd is not None 在服务启动前命中即 NameError。拆成两行。
   语法合法故初次提交未拦住。

2) _require_op_ready 入口无条件预清理(回 INIT + config_init 重枚举):
   同一连接反复停止/启动而不重枚举时从站邮箱通路逐轮退化, 第 4 轮
   config_map() 内 SOEM CoE 分段读的无边界 memcpy 吃到楔死邮箱异常帧
   触发 0xC0000005 闪退。置于入口使所有调用方全覆盖, 单从站成本 ~100ms。
   op_start 的重试分支因此简化为直接重跑序列。

3) config_map 失败时附带邮箱 SM 寄存器现场, 便于对照 GD32 串口日志。
   地址按 SM = 0x0800 + 8n 修正为 SM0=0x0800 / SM1=0x0808;
   原写的 0x0810/0x0818 实为 SM2/SM3(过程数据), 抓的现场会误导判断。
2026-09-03 02:35:24 +08:00

1223 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 = """
<style>#__rl,#__home{position:fixed;right:14px;width:34px;height:34px;z-index:99999;
border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(20,22,28,.72);
color:#9fb0c3;font-size:17px;line-height:32px;text-align:center;cursor:pointer;
user-select:none;opacity:.55;transition:opacity .15s}
#__rl{bottom:14px}#__home{bottom:56px;font-size:19px}
#__rl:hover,#__home:hover{opacity:1}#__rl:active{transform:rotate(90deg)}</style>
<div id="__rl" title="刷新页面 (F5)">&#x21bb;</div>
<div id="__home" title="返回主页" style="display:none">&#x2302;</div>
<script>(function(){
function go(){location.reload();}
document.getElementById('__rl').addEventListener('click',go);
window.addEventListener('keydown',function(e){
if(e.key==='F5'||(e.ctrlKey&&(e.key==='r'||e.key==='R'))){e.preventDefault();go();}
});
var home=document.getElementById('__home');
var p=location.pathname;
if(!/\\/$/.test(p)&&!/\\/index\\.html$/.test(p)){
home.style.display='block';
home.addEventListener('click',function(){location.href='__HOME__';});
}
window.open=function(u){if(u)location.href=u;return null;};
document.addEventListener('click',function(e){
var a=e.target&&e.target.closest?e.target.closest('a[target=_blank]'):null;
if(a&&a.href){e.preventDefault();location.href=a.href;}
},true);
function ready(){try{window.pywebview&&window.pywebview.api&&
window.pywebview.api.page_loaded()}catch(e){}}
document.addEventListener('DOMContentLoaded',ready);
window.addEventListener('load',ready);
})();</script>
"""
_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("<H", _eeprom_read_word(slave, w))[0]
for w in range(start, start + count)]
rows = []
for base in range(0, count, 8):
chunk = words[base:base + 8]
rows.append({"addr": start + base,
"hex": " ".join(f"{v:04X}" for v in chunk)})
return {"ok": True, "rows": rows,
"raw": "".join(f"{v:04X}" for v in words)}
def api_eeprom_write(req):
if g_master is None:
return {"ok": False, "log": ["未连接"]}
log = []
data = bytes.fromhex(req.get("data", ""))
if len(data) % 2:
data += b"\xFF"
if len(data) > 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("<H", data, word * 2)[0]
if val == 0xFFFF:
continue
raw = struct.pack("<H", val)
slave.eeprom_write(word, raw)
if _eeprom_read_word(slave, word) != raw:
log.append(f"word 0x{word:04X} 校验失败")
n_fail += 1
n += 1
log.append(f"写入 {n} 字, 失败 {n_fail} 字")
return {"ok": n_fail == 0, "log": log, "written": n, "failed": n_fail}
def api_eeprom_verify(req):
if g_master is None:
return {"ok": False, "log": ["未连接"]}
log = []
data = bytes.fromhex(req.get("data", ""))
if len(data) % 2:
data += b"\xFF"
slave = g_master.slaves[0]
diffs = []
for word in range(min(len(data), EEPROM_SIZE) // 2):
expect = struct.unpack_from("<H", data, word * 2)[0]
actual = struct.unpack("<H", _eeprom_read_word(slave, word))[0]
if expect != actual:
diffs.append({"word": word, "expect": expect, "actual": actual})
if len(diffs) <= 10:
log.append(f"word 0x{word:04X}: 文件 0x{expect:04X} != 芯片 0x{actual:04X}")
ok = not diffs
log.append("verify: OK, 全部一致" if ok else f"verify: {len(diffs)} 字不符")
return {"ok": ok, "log": log, "diffs": len(diffs)}
def api_flash(req):
if g_master is None:
return {"ok": False, "log": ["未连接"]}
log = []
data_hex = req.get("data")
if data_hex:
img = bytes.fromhex(data_hex)
label = "上传的 bin"
else:
from ecat_flash_eeprom import load_image # 自带 gen_eeprom.py 自校验
img = load_image()
label = "gen_eeprom.py 镜像"
if len(img) % 2:
img += b"\xFF"
if len(img) > 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("<H", img, word * 2)[0]
if val == 0xFFFF:
continue
raw = struct.pack("<H", val)
slave.eeprom_write(word, raw)
if _eeprom_read_word(slave, word) != raw:
log.append(f"word 0x{word:04X} 校验失败")
n_fail += 1
n += 1
if n % 64 == 0:
log.append(f" ... 已写 {n} 字")
log.append(f"刷写完成: 成功 {n} 字, 失败 {n_fail} 字")
if n_fail:
return {"ok": False, "log": log}
log.append(">>> 板子断电重新上电后 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("<I", bytes(s.sdo_read(0x2000, 3, 4)))[0]
if escirq_prev is not None and irq == escirq_prev:
escirq_stalled = True
escirq_prev = irq
except Exception:
pass
# read_state: 1 帧 BRD(无错误时)或 1 帧 FPRD(有错误位时,带真实 AL Status Code)。
# 注意 SOEM ecx_readstate 语义:slaves[0].state 为掩码值(慢路径含错误位),
# 无错误时 code 直接置 0;有错误位时逐从站 FPRD 拿真实 code。
try:
g_master.read_state()
except Exception:
pass
state = g_master.slaves[0].state & 0x0F
code = g_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 立起状态(PREOP→SAFEOP 迁移毫秒级,
# 等待收紧到 500ms 别吞掉预算),并允许立即重请 OP
if state < pysoem.SAFEOP_STATE:
g_master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK
g_master.write_state()
g_master.state_check(pysoem.SAFEOP_STATE, 500_000)
next_rewrite = 0.0
# 重请 OP(节流):从站 SAFEOP→OP 挂起由 LRW 驱动完成,重写只在请求可能
# 被吞的场景(首次/恢复后/ESM 超时回落清错)做,不刷屏
if time.time() >= 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<expected 时查 FMMU/SM2)。"
"请抓 GD32 串口 [ECAT][AL] evt/req 日志对照。"], False
mode = f"DC {dc_cycle_us}us" if dc_cycle_us else "free-run"
log.append(f"已进 OP({mode}): 输出 {len(bytes(s.output))}B / 输入 {len(bytes(s.input))}B")
g_op_active = True
return log, True
def _dc_args(req):
"""从请求里取 DC 参数(缺省/空值 → 0 = free-run)。"""
return (int(req.get("dc_cycle_us") or 0), int(req.get("dc_shift_us") or 0))
def api_op_start(req):
global g_op_active, _sync_diag_prev
_sync_diag_prev = None # 新一轮运行,速率基线清零
dc = _dc_args(req)
log, ok = _require_op_ready(*dc)
if not ok:
# 自动重试一次:_require_op_ready 入口已自带"回 INIT + config_init 重枚举"
# 预清理(2026-09-03 01:49 复现实锤:楔死邮箱上跑 config_map 会触发 SOEM
# CoE 分段读的无边界 memcpy → 0xC0000005 原生崩溃闪退,预清理是唯一可靠
# 规避路径),重试即完整等效"断开重连"。
log.append("自动重试:重新执行完整序列(入口含重枚举预清理)…")
log2, ok = _require_op_ready(*dc)
log += log2
if not ok:
g_op_active = False
return {"ok": False, "log": log}
return {"ok": True, "log": log}
def api_op_cycle(req):
global g_op_active
if g_master is None:
return {"ok": False, "log": ["未连接"]}
if not g_op_active:
log, ok = _require_op_ready(*_dc_args(req))
if not ok:
return {"ok": False, "log": log}
out_hex = req.get("outputs")
s = g_master.slaves[0]
out_len = len(bytes(s.output))
if out_hex and out_len > 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("<I", bytes(s.sdo_read(0x2000, sub, 4)))[0]
for sub in (1, 2, 3, 4)]
except Exception as e:
_sync_diag_prev = None
return {"ok": False, "log": [f"SDO 读 0x2000 失败: {type(e).__name__}: {e}"
" —— 从站固件需 ≥1.5.31 且已连接(≥PREOP)"]}
sync0, sync1, escirq, uptime = vals
now = time.monotonic()
rate = None
if _sync_diag_prev is not None:
p0, pnow = _sync_diag_prev
dt = now - pnow
if dt > 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 "</body>" in html:
html = html.replace(
"</body>", _RELOAD_SNIPPET + "</body>", 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()