1197 lines
50 KiB
Python
1197 lines
50 KiB
Python
#!/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)">↻</div>
|
|||
|
|
<div id="__home" title="返回主页" style="display:none">⌂</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 = 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("<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
|
|||
|
|
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("<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:
|
|||
|
|
# 2026-09-03 自动重试一次:实测"断开重连后正常",把该恢复力内建——
|
|||
|
|
# 带 ACK 完整回 INIT(清从站残留错误位/SM 状态) + config_init 重枚举
|
|||
|
|
# (重写 SM/FMMU,等效重连的核心步骤) + 完整状态序列重来。
|
|||
|
|
# 固件≥1.5.48 的 0x204 守护也会在这轮里自愈 AL Event Mask。
|
|||
|
|
log.append("自动重试:完整回 INIT + 重枚举后重新执行序列…")
|
|||
|
|
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})")
|
|||
|
|
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()
|