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(过程数据), 抓的现场会误导判断。
1223 lines
51 KiB
Python
1223 lines
51 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 = 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()
|