18 个文件平铺根目录太乱, 按角色归拢: web/ ecat.html/css + motor.html/js —— 前端资源 diag/ ecat_scan / ecat_sniff / ecat_esc_regs —— 独立诊断脚本, 互不依赖 launch/ 5 个 .bat/.vbs 双击入口 根目录只留 ecat_server / ecat_tool / ecat_flash_eeprom / test_motor_backend: 这几个互相 import(tool→flash_eeprom, test→server), 挪进子目录就得改 import 或加包, 收益不抵成本。 配套改动: _STATIC_MAP 的值加 web/ 前缀 —— URL 路径刻意保持不变(/、/ecat.css、 /motor.html), 页面内相对引用、书签、外部链接一律不用改。 launch/ 里 %~dp0 现在指向 launch\, 五个启动器统一改为先取上一层作 ROOT (bat 用 %~dp0.., vbs 用两次 GetParentFolderName), run_scan 顺带指向 diag\ecat_scan.py。 验证: 7 个 py 全编译通过; test_motor_backend 8 项全通过; 服务实起后 6 条 静态路由全 200 且 MIME 正确(js 是 application/javascript)、404 兜底正常; Edge headless 渲染主页与 motor 页, CSS 294 规则加载、9 个节点样式生效、 零加载失败零 JS 异常; bat/vbs 的 ROOT 解析实测指到项目根, 五个目标文件 全部命中; ecat_tool adapters 与 diag/ecat_scan.py 实跑正常。
1945 lines
82 KiB
Python
1945 lines
82 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()
|
||
|
||
# 本进程启动时刻:用于 404 时判断"源码已改但服务没重启"(见 Handler._not_found)
|
||
SERVER_START_TIME = time.time()
|
||
|
||
# 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 板供电、网线是否接到该网口"]}
|
||
# config_init() 返回从站数;**失败时返回 -1 而不是 0**,而 -1 是真值,
|
||
# 旧写法 `if not n` 放行了 -1,随后 g_master.slaves[0] 抛 IndexError
|
||
# (板子断电/网线拔出时点"连接"就会崩)。这里按 n <= 0 统一判失败。
|
||
if n <= 0:
|
||
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 03:00 探针实锤:楔死本质是 SSC 的 u16EscAddrSendMbx 错位
|
||
# (响应帧写到 SM1 start+4/+8 且逐会话累积,断电才清零),主站从 0x1200 读
|
||
# 永远对不上帧头;SM 寄存器本身(start=0x1000/0x1200,act=01)恒正确。
|
||
# 此转储用于区分"SM 配置坏"与"SSC 地址错位"(后者:SM0/SM1 正常但 SDO
|
||
# 持续失败;固件<1.5.52 无自愈,需断电重启;1.5.52+ 守护自动写回恢复)。
|
||
try:
|
||
sm_dump = " ".join(
|
||
f"SM{i}={bytes(s._fprd(0x0800 + i * 8, 8, 20000)).hex().upper()}"
|
||
for i in range(4))
|
||
sm_info = (f"; SM现场[0x0800..0x081F,各8B: start,len,ctl,st,act,pdi]: {sm_dump}"
|
||
" —— SM0/SM1 配置正常却持续失败=SSC 邮箱地址错位楔死")
|
||
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}
|
||
|
||
|
||
# ---------------------------------------------------------------- CAN over PDO
|
||
# 从站固件 1.5.56 起过程数据扩到 384B/640B —— LAN9252 的硬上限
|
||
# (布局定义见 DCU_APP/GD32E508_DCU_APP/comm/ethercat_can_bridge.h)。
|
||
#
|
||
# 为何是 384+640:邮箱占 0x1000-0x13FF,过程数据区只剩 0x1400-0x1FFF=3072B,
|
||
# SM2/SM3 都是 3-buffer 实占 3×size ⇒ out+in ≤ 1024B。384+640 恰好用满。
|
||
#
|
||
# 输出 PDO(主站→从站, 384B):
|
||
# 段1 [0..47] 单帧发送,与 1.5.55 逐字节兼容
|
||
# [0]seq [1]ch [2]dlc [3]flags [4..7]id(LE) [8]win_base
|
||
# [9..15]rsvd [16..47]data(32B)。dlc=0 表示本次只发批量区
|
||
# 段2 [48..383] CANFD 批量发送区: [48]count [49]flags [50..51]rsvd
|
||
# [52..] 帧数组,每项 (8B 头 + dlc 数据) 紧密排列
|
||
# 帧头: [0]ch [1]dlc [2]flags [3]result [4..7]id(LE)
|
||
# 输入 PDO(从站→主站, 640B):
|
||
# 段1 [0..31] 桥状态 + 短快照,与 1.5.53 逐字节兼容(CAN 调试页照旧可用)
|
||
# 段2 [32..319] 电机状态窗口: [32]active [33]boot [34]win_base [35]win_count
|
||
# [36..39]global_seq [40..] 7×40B 槽
|
||
# 槽: [0]node [1]ch [2]online [3]dlc [4..7]seq [8..39]STATUS(32B)
|
||
# 段3 [320..359] 最近一帧完整快照(版本/CONFIG 帧 DLC24 装得下)
|
||
# 段4 [360..639] CANFD 批量接收区: [360]count [361]lost [362]pending
|
||
# [363]rsvd [364..] 帧数组,项布局同发送向
|
||
PDO_OUT_LEN = 384
|
||
PDO_IN_LEN = 640
|
||
|
||
CAN_TX_DATA_MAX = 32 # 段1 单帧数据区(一帧电机 CMD 是 DLC 32)
|
||
CAN_RX_DATA_MAX = 8 # 段1 短快照,保持兼容
|
||
CAN_XRX_DATA_MAX = 32 # 段3 长快照
|
||
CAN_FD_DATA_MAX = 64 # 批量区单帧上限(CANFD 全量)
|
||
|
||
TX_OFF_WIN = 8
|
||
TX_OFF_DATA = 16
|
||
|
||
# 批量帧项头布局(收发两向共用)
|
||
FRM_OFF_CH = 0
|
||
FRM_OFF_DLC = 1
|
||
FRM_OFF_FLAGS = 2
|
||
FRM_OFF_RES = 3
|
||
FRM_OFF_ID = 4
|
||
FRM_HDR_SIZE = 8
|
||
|
||
# 段2: 批量发送区
|
||
TXB_OFF_COUNT = 48
|
||
TXB_OFF_FLAGS = 49
|
||
TXB_OFF_ITEMS = 52
|
||
TXB_BYTES = PDO_OUT_LEN - TXB_OFF_ITEMS # 332B
|
||
TXB_MAX_FRAMES = TXB_BYTES // (FRM_HDR_SIZE + CAN_FD_DATA_MAX) # 4 (全 DLC64)
|
||
|
||
MOTW_OFF_ACTIVE = 32
|
||
MOTW_OFF_BOOT = 33
|
||
MOTW_OFF_WIN_BASE = 34
|
||
MOTW_OFF_WIN_COUNT = 35
|
||
MOTW_OFF_GLOBAL_SEQ = 36
|
||
MOTW_OFF_SLOTS = 40
|
||
MOT_SLOT_SIZE = 40
|
||
MOT_SLOTS_PER_PDO = 7
|
||
MOT_STATUS_DLC = 32
|
||
|
||
XRX_OFF_CH = 320
|
||
XRX_OFF_DLC = 321
|
||
XRX_OFF_FLAGS = 322
|
||
XRX_OFF_ID = 324
|
||
XRX_OFF_DATA = 328
|
||
|
||
# 段4: 批量接收区
|
||
RXB_OFF_COUNT = 360
|
||
RXB_OFF_LOST = 361
|
||
RXB_OFF_PENDING = 362
|
||
RXB_OFF_ITEMS = 364
|
||
RXB_BYTES = PDO_IN_LEN - RXB_OFF_ITEMS # 276B
|
||
RXB_MAX_FRAMES = RXB_BYTES // (FRM_HDR_SIZE + CAN_FD_DATA_MAX) # 3 (全 DLC64)
|
||
|
||
# 电机状态表规格(须与 GD32 device/motor_state.h 一致)
|
||
MOTOR_MAX_NODES = 9
|
||
MOTOR_CHANNELS = 3
|
||
MOTOR_SLOT_COUNT = MOTOR_MAX_NODES * MOTOR_CHANNELS # 27
|
||
MOTOR_SCAN_CYCLES = -(-MOTOR_SLOT_COUNT // MOT_SLOTS_PER_PDO) # 4
|
||
|
||
# 发送序号:每调一次 /api/can/send 自增。固件按"变化沿"触发发送,所以
|
||
# 必须由主站维护单调递增的序号,而不能每次都发同一个值。
|
||
_can_seq = 0
|
||
# 最近一次要写进输出 PDO 的字节(在后续周期里保持不变,固件靠 seq 去重,
|
||
# 不会重复发送;保持不变是为了让 seq 字段稳定可回显)。
|
||
_can_out = bytearray(PDO_OUT_LEN)
|
||
|
||
CAN_TX_RESULT_DESC = {
|
||
0x00: "OK",
|
||
0xE2: "BUS_OFF",
|
||
0xE3: "TEC 过高",
|
||
0xE4: "邮箱满",
|
||
0xE5: "参数无效",
|
||
}
|
||
|
||
|
||
def _can_pump(cycles=3):
|
||
"""跑若干个 PDO 周期,让输出帧下发、输入帧回读。
|
||
|
||
固件的发送在 APPL_Application 里执行、结果在同周期写进输入 PDO,
|
||
但主站的一次 send/receive 只保证"这一帧过程数据被交换",
|
||
要读到 ack 回显通常需要再跑 1~2 个周期,故默认 3 个。
|
||
"""
|
||
s = g_master.slaves[0]
|
||
out_len = len(bytes(s.output))
|
||
wkc = 0
|
||
for _ in range(max(1, cycles)):
|
||
if out_len > 0:
|
||
s.output = bytes(_can_out[:out_len]).ljust(out_len, b"\x00")
|
||
g_master.send_processdata()
|
||
wkc = g_master.receive_processdata(2000)
|
||
time.sleep(0.002)
|
||
return wkc, bytes(s.input)
|
||
|
||
|
||
def _can_decode_input(inp):
|
||
"""把输入 PDO 的段 1(桥状态 + 短快照)解成结构化状态。
|
||
|
||
段 1 布局自 1.5.53 未变,故本函数不随扩容改动 —— CAN 调试页照旧可用。
|
||
段 3 的完整 payload 由 _decode_xrx() 单独解。
|
||
"""
|
||
if len(inp) < 32:
|
||
return {"valid": False}
|
||
flags = inp[3]
|
||
dlc = inp[2]
|
||
st = {
|
||
"valid": True,
|
||
"ack": inp[0],
|
||
"rx_valid": bool(flags & 0x01),
|
||
"truncated": bool(flags & 0x02),
|
||
"ch": inp[1],
|
||
"dlc": dlc,
|
||
"id": struct.unpack_from("<I", inp, 4)[0],
|
||
"data": inp[8:8 + min(dlc, CAN_RX_DATA_MAX)].hex().upper(),
|
||
"rx_count": struct.unpack_from("<I", inp, 16)[0],
|
||
"tx_result": inp[20],
|
||
"backlog": inp[21],
|
||
"tx_count": struct.unpack_from("<H", inp, 22)[0],
|
||
"drop": struct.unpack_from("<H", inp, 24)[0],
|
||
}
|
||
st["tx_result_text"] = CAN_TX_RESULT_DESC.get(st["tx_result"],
|
||
f"0x{st['tx_result']:02X}")
|
||
return st
|
||
|
||
|
||
def _decode_xrx(inp):
|
||
"""解段 3:最近一帧**完整** payload(版本帧/CONFIG 帧都是 DLC 24)。"""
|
||
if len(inp) < XRX_OFF_DATA + CAN_XRX_DATA_MAX:
|
||
return None
|
||
if not (inp[XRX_OFF_FLAGS] & 0x01):
|
||
return None
|
||
dlc = min(inp[XRX_OFF_DLC], CAN_XRX_DATA_MAX)
|
||
return {
|
||
"ch": inp[XRX_OFF_CH],
|
||
"dlc": inp[XRX_OFF_DLC],
|
||
"id": struct.unpack_from("<I", inp, XRX_OFF_ID)[0],
|
||
"data": inp[XRX_OFF_DATA:XRX_OFF_DATA + dlc].hex().upper(),
|
||
}
|
||
|
||
|
||
def _decode_motor_window(inp):
|
||
"""解段 2:电机状态窗口 → {slot_index: slot_dict}。
|
||
|
||
只回传 node_id != 0 的槽;空槽没有意义,前端按 node/ch 索引即可。
|
||
"""
|
||
out = {}
|
||
if len(inp) < MOTW_OFF_SLOTS + MOT_SLOTS_PER_PDO * MOT_SLOT_SIZE:
|
||
return out, {}
|
||
base = inp[MOTW_OFF_WIN_BASE]
|
||
count = min(inp[MOTW_OFF_WIN_COUNT], MOT_SLOTS_PER_PDO)
|
||
head = {
|
||
"active": inp[MOTW_OFF_ACTIVE],
|
||
"boot_active": bool(inp[MOTW_OFF_BOOT]),
|
||
"win_base": base,
|
||
"win_count": count,
|
||
"global_seq": struct.unpack_from("<I", inp, MOTW_OFF_GLOBAL_SEQ)[0],
|
||
}
|
||
for i in range(count):
|
||
off = MOTW_OFF_SLOTS + i * MOT_SLOT_SIZE
|
||
node = inp[off]
|
||
if node == 0:
|
||
continue
|
||
out[base + i] = {
|
||
"slot": base + i,
|
||
"node": node,
|
||
"ch": inp[off + 1],
|
||
"online": bool(inp[off + 2]),
|
||
"dlc": inp[off + 3],
|
||
"seq": struct.unpack_from("<I", inp, off + 4)[0],
|
||
"data": inp[off + 8:off + 8 + MOT_STATUS_DLC].hex().upper(),
|
||
}
|
||
return out, head
|
||
|
||
|
||
def _pack_tx_batch(frames, stop_on_error=False):
|
||
"""把多帧 CAN 打包进输出 PDO 的批量发送区(段2)。
|
||
|
||
返回 (装入帧数, 用掉的字节数)。装不下的帧留给调用方下一批 ——
|
||
这里按**字节**判边界而不是按帧数:全 DLC8 能装 20 帧,全 DLC64 只能装 4 帧,
|
||
用固定帧数上限会白白浪费三分之二的空间。
|
||
"""
|
||
_can_out[TXB_OFF_COUNT] = 0
|
||
_can_out[TXB_OFF_FLAGS] = 0x01 if stop_on_error else 0x00
|
||
off = TXB_OFF_ITEMS
|
||
n = 0
|
||
for ch, can_id, dlc, data in frames:
|
||
dlc = min(int(dlc), CAN_FD_DATA_MAX)
|
||
if off + FRM_HDR_SIZE + dlc > PDO_OUT_LEN:
|
||
break
|
||
_can_out[off + FRM_OFF_CH] = ch & 0xFF
|
||
_can_out[off + FRM_OFF_DLC] = dlc
|
||
_can_out[off + FRM_OFF_FLAGS] = 0
|
||
_can_out[off + FRM_OFF_RES] = 0
|
||
struct.pack_into("<I", _can_out, off + FRM_OFF_ID, can_id & 0xFFFFFFFF)
|
||
_can_out[off + FRM_HDR_SIZE:off + FRM_HDR_SIZE + dlc] = \
|
||
bytes(data[:dlc]).ljust(dlc, b"\x00")
|
||
off += FRM_HDR_SIZE + dlc
|
||
n += 1
|
||
_can_out[TXB_OFF_COUNT] = n
|
||
return n, off - TXB_OFF_ITEMS
|
||
|
||
|
||
def _batch_capacity(frames):
|
||
"""算出这批帧里前多少帧能装进一个 PDO 周期(纯计算,不写缓冲)。"""
|
||
off = TXB_OFF_ITEMS
|
||
n = 0
|
||
for _ch, _id, dlc, _data in frames:
|
||
dlc = min(int(dlc), CAN_FD_DATA_MAX)
|
||
if off + FRM_HDR_SIZE + dlc > PDO_OUT_LEN:
|
||
break
|
||
off += FRM_HDR_SIZE + dlc
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def _decode_rx_batch(inp):
|
||
"""解码输入 PDO 的批量接收区(段4)。
|
||
|
||
count 是从站声明的帧数,但边界判据始终是"头+数据是否完整落在 PDO 内" ——
|
||
不信任对端给的计数,越界即停。
|
||
"""
|
||
if len(inp) <= RXB_OFF_ITEMS:
|
||
return {"count": 0, "lost": 0, "pending": 0, "frames": []}
|
||
count = inp[RXB_OFF_COUNT]
|
||
frames = []
|
||
off = RXB_OFF_ITEMS
|
||
for _ in range(min(count, RXB_BYTES // FRM_HDR_SIZE)):
|
||
if off + FRM_HDR_SIZE > len(inp):
|
||
break
|
||
dlc = inp[off + FRM_OFF_DLC]
|
||
if dlc > CAN_FD_DATA_MAX or off + FRM_HDR_SIZE + dlc > len(inp):
|
||
break
|
||
frames.append({
|
||
"ch": inp[off + FRM_OFF_CH],
|
||
"dlc": dlc,
|
||
"valid": bool(inp[off + FRM_OFF_FLAGS] & 0x01),
|
||
"id": struct.unpack_from("<I", inp, off + FRM_OFF_ID)[0],
|
||
"data": inp[off + FRM_HDR_SIZE:off + FRM_HDR_SIZE + dlc].hex().upper(),
|
||
})
|
||
off += FRM_HDR_SIZE + dlc
|
||
return {
|
||
"count": count,
|
||
"lost": inp[RXB_OFF_LOST], # 从站环形队列满导致的丢帧(增量)
|
||
"pending": inp[RXB_OFF_PENDING], # 仍待回收的帧数 >0 说明主站该提速
|
||
"frames": frames,
|
||
}
|
||
|
||
|
||
def _fill_can_out(ch, can_id, dlc, data, clear_stats=False, win_base=None,
|
||
batch=None, stop_on_error=False):
|
||
"""把一帧待发 CAN 写进输出 PDO 暂存区,并推进 seq(变化沿触发发送)。
|
||
|
||
win_base=None 表示保持当前的电机状态窗口下标不动 —— 窗口滚动由
|
||
_pdo_worker 独占推进,发送路径不该顺手把它改回去。
|
||
|
||
batch 给出额外的多帧列表 [(ch,id,dlc,data)],一并放进段2 批量区,
|
||
与单帧共用同一个 seq 沿:从站在一次 processOutput 里先发单帧再发批量。
|
||
dlc=0 表示只发批量、不发单帧。
|
||
"""
|
||
global _can_seq
|
||
_can_seq = (_can_seq + 1) & 0xFF
|
||
if _can_seq == 0: # 0 与固件的"未初始化"基准无法区分,跳过
|
||
_can_seq = 1
|
||
keep_win = _can_out[TX_OFF_WIN] if win_base is None else (win_base & 0xFF)
|
||
|
||
_can_out[:] = bytes(PDO_OUT_LEN)
|
||
_can_out[0] = _can_seq
|
||
_can_out[1] = ch
|
||
_can_out[2] = dlc
|
||
_can_out[3] = 0x01 if clear_stats else 0x00
|
||
struct.pack_into("<I", _can_out, 4, can_id & 0xFFFFFFFF)
|
||
_can_out[TX_OFF_WIN] = keep_win
|
||
if dlc:
|
||
_can_out[TX_OFF_DATA:TX_OFF_DATA + dlc] = data[:dlc]
|
||
if batch:
|
||
_pack_tx_batch(batch, stop_on_error)
|
||
return _can_seq
|
||
|
||
|
||
def api_can_send(req):
|
||
"""经 EtherCAT 过程数据发一帧 CAN。
|
||
|
||
请求: {ch:0..2, id:int|"0x..", dlc:0..32, data:"HEXSTR", clear_stats:bool}
|
||
"""
|
||
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}
|
||
|
||
try:
|
||
ch = int(req.get("ch") or 0)
|
||
raw_id = req.get("id", 0)
|
||
can_id = int(raw_id, 16) if isinstance(raw_id, str) and raw_id.lower().startswith("0x") \
|
||
else int(raw_id or 0)
|
||
data = bytes.fromhex((req.get("data") or "").replace(" ", ""))
|
||
dlc = int(req.get("dlc") if req.get("dlc") not in (None, "") else len(data))
|
||
except (TypeError, ValueError) as e:
|
||
return {"ok": False, "log": [f"参数解析失败: {e}"]}
|
||
|
||
if ch not in (0, 1, 2):
|
||
return {"ok": False, "log": [f"通道必须是 0/1/2,收到 {ch}"]}
|
||
if dlc > CAN_TX_DATA_MAX:
|
||
return {"ok": False, "log": [f"DLC {dlc} 超出 PDO 单帧上限 {CAN_TX_DATA_MAX}"]}
|
||
if len(data) < dlc:
|
||
data = data.ljust(dlc, b"\x00")
|
||
|
||
seq = _fill_can_out(ch, can_id, dlc, data, bool(req.get("clear_stats")))
|
||
|
||
wkc, inp = _can_pump()
|
||
st = _can_decode_input(inp)
|
||
acked = st.get("ack") == seq
|
||
txres = st.get("tx_result", 0)
|
||
|
||
log = [f"已下发 CH{ch} 0x{can_id:X} DLC{dlc} "
|
||
f"{data[:dlc].hex().upper() or '(空)'} seq={seq}"]
|
||
if not acked:
|
||
log.append(f"警告: 从站回显 ack={st.get('ack')} != seq={seq},"
|
||
"过程数据可能未被应用层处理(确认已进 OP、固件 ≥1.5.55)")
|
||
elif txres != 0:
|
||
log.append(f"从站发送失败: {st.get('tx_result_text')} "
|
||
"(0xE4 邮箱满=总线无应答/未接收器;0xE2 BUS_OFF=接线或终端电阻)")
|
||
return {"ok": True, "log": log, "wkc": wkc,
|
||
"expected_wkc": g_master.expected_wkc, "seq": seq,
|
||
"acked": acked, **st}
|
||
|
||
|
||
def api_can_state(req):
|
||
"""跑一个 PDO 周期并回读 CAN 桥状态(接收帧 + 计数)。"""
|
||
if g_master is None:
|
||
return {"ok": False, "log": ["未连接"]}
|
||
if not g_op_active:
|
||
return {"ok": False, "log": ["未进 OP —— 先点『进入 OP』或发送一帧"]}
|
||
# 电机后台循环在跑时不要再自己踩 PDO:直接给它的最新快照,
|
||
# 否则两个写者抢 s.output,且额外周期会打乱窗口滚动节奏。
|
||
snap = _motor_snapshot()
|
||
if snap is not None and snap.get("bridge"):
|
||
return {"ok": True, "log": [], "wkc": snap.get("wkc", 0),
|
||
"expected_wkc": snap.get("expected_wkc", 0), **snap["bridge"]}
|
||
wkc, inp = _can_pump(cycles=1)
|
||
return {"ok": True, "log": [], "wkc": wkc,
|
||
"expected_wkc": g_master.expected_wkc, **_can_decode_input(inp)}
|
||
|
||
|
||
# ------------------------------------------------------------ 电机控制台后端
|
||
#
|
||
# 为什么必须有后台循环线程,而不是沿用「一次 HTTP 请求 = 一次 PDO 周期」:
|
||
#
|
||
# _require_op_ready 给 SM2 写了 1000ms 过程数据看门狗(set_watchdog)。请求驱动
|
||
# 的周期一旦被任何慢调用挡住(EEPROM 烧写数十秒、_require_op_ready 最长 12s、
|
||
# 甚至浏览器后台标签把 setInterval 节流到 ≥1s),从站就会 OP→SAFEOP 报
|
||
# AL 0x001B,而 g_op_active 仍是 True —— 服务端继续"报告成功",故障静默。
|
||
# 所以周期性喂数据是**正确性需求**,不只是性能优化。
|
||
#
|
||
# 线程纪律(与本文件既有 g_lock 约定共存):
|
||
# - 只有本线程调 send_processdata/receive_processdata,HTTP 路由一律读快照;
|
||
# - 每周期取一次 g_lock,睡眠**在锁外**,绝不持锁 sleep;
|
||
# - CoE/EEPROM 类路由本身持 g_lock,与本线程天然互斥(邮箱楔死风险见上方注释);
|
||
# - 快照用整体 dict 重绑定发布,读者无需加锁(CPython 引用赋值原子)。
|
||
_motor_thread = None
|
||
_motor_stop = threading.Event()
|
||
_motor_snap = None # 最新快照(整体替换,不原地改)
|
||
_motor_cmd_q = collections.deque() # 待下发的 CAN 帧队列(线程安全)
|
||
# 批量接收区回收到的帧(有界)。1.5.56 起从站一周期能回多帧,前端按需一次取走;
|
||
# maxlen 防前端长时间不取时内存无限增长 —— 满了丢最旧的,快照里 lost/pending
|
||
# 已经能告诉前端"有没有漏"。
|
||
_motor_rx_log = collections.deque(maxlen=2000)
|
||
_motor_period_ms = 4 # 后台周期,默认 4ms(250Hz) —— 远快于 1s 看门狗
|
||
_motor_win = 0 # 当前窗口起始槽号,由本线程独占推进
|
||
|
||
|
||
def _motor_snapshot():
|
||
return _motor_snap
|
||
|
||
|
||
def _pdo_worker():
|
||
"""后台 PDO 循环:持续喂看门狗、滚动扫描电机状态窗口、下发排队的 CAN 帧。"""
|
||
global _motor_snap, _motor_win, g_op_active
|
||
slots = {} # 槽号 -> 槽快照(跨周期累积,窗口只覆盖自己那部分)
|
||
head = {}
|
||
errors = 0
|
||
while not _motor_stop.is_set():
|
||
t0 = time.perf_counter()
|
||
try:
|
||
with g_lock:
|
||
if g_master is None or not g_op_active:
|
||
raise RuntimeError("未连接或未进 OP")
|
||
s = g_master.slaves[0]
|
||
|
||
# 有排队的命令帧就装进输出 PDO。
|
||
#
|
||
# 1.5.56:一周期能发多帧了。此前受单帧槽限制,每周期只能发一帧
|
||
# (seq 沿语义要求每个 seq 至少被从站看到一个周期),4ms 周期即
|
||
# 250 帧/s 上限,9 轴同步下发要 36ms 才发完、各轴不同拍。
|
||
# 现在把第一帧放单帧区、其余放批量区,共用同一个 seq 沿,
|
||
# 从站在一次 processOutput 里全部发出 —— 真正的同步下发。
|
||
sent = None
|
||
nsent = 0
|
||
if _motor_cmd_q:
|
||
ch, can_id, dlc, data = _motor_cmd_q.popleft()
|
||
# 按字节容量决定这一批能带几帧(全 DLC8 可 20 帧,全 DLC64 仅 4 帧)
|
||
pend = list(_motor_cmd_q)
|
||
take = _batch_capacity(pend)
|
||
batch = [_motor_cmd_q.popleft() for _ in range(take)]
|
||
sent = _fill_can_out(ch, can_id, dlc, data, batch=batch or None)
|
||
nsent = 1 + len(batch)
|
||
|
||
# 推进窗口:每周期换一段,MOTOR_SCAN_CYCLES 个周期扫完全表
|
||
_can_out[TX_OFF_WIN] = _motor_win & 0xFF
|
||
|
||
out_len = len(bytes(s.output))
|
||
if out_len > 0:
|
||
s.output = bytes(_can_out[:out_len]).ljust(out_len, b"\x00")
|
||
g_master.send_processdata()
|
||
wkc = g_master.receive_processdata(2000)
|
||
inp = bytes(s.input)
|
||
expected = g_master.expected_wkc
|
||
|
||
win_slots, win_head = _decode_motor_window(inp)
|
||
slots.update(win_slots)
|
||
if win_head:
|
||
head = win_head
|
||
rxb = _decode_rx_batch(inp)
|
||
# 批量接收区的帧按到达顺序累积到一个有界缓冲,供前端一次取走。
|
||
# 用 deque(maxlen) 而不是无界 list:前端若长时间不取,内存不能无限涨。
|
||
if rxb["frames"]:
|
||
_motor_rx_log.extend(rxb["frames"])
|
||
_motor_snap = {
|
||
"ts": time.time(),
|
||
"wkc": wkc,
|
||
"expected_wkc": expected,
|
||
"cycles": (_motor_snap or {}).get("cycles", 0) + 1,
|
||
"period_ms": _motor_period_ms,
|
||
"sent_seq": sent,
|
||
"sent_frames": nsent,
|
||
"queue_depth": len(_motor_cmd_q),
|
||
"bridge": _can_decode_input(inp),
|
||
"xrx": _decode_xrx(inp),
|
||
"rx_batch": rxb,
|
||
"head": head,
|
||
"motors": sorted(slots.values(), key=lambda m: (m["ch"], m["node"])),
|
||
}
|
||
errors = 0
|
||
_motor_win = (_motor_win + MOT_SLOTS_PER_PDO) % (
|
||
MOTOR_SCAN_CYCLES * MOT_SLOTS_PER_PDO)
|
||
except Exception as e: # noqa: BLE001 - 循环不能因单次异常退出
|
||
errors += 1
|
||
if errors in (1, 50) or errors % 500 == 0:
|
||
print(f"[PDO] 周期异常({errors}): {type(e).__name__}: {e}", flush=True)
|
||
if errors > 3:
|
||
# 连续失败:很可能已掉出 OP。清标志让下次请求走完整的重进 OP,
|
||
# 而不是继续假装在线(这正是旧实现 0x001B 静默失败的成因)。
|
||
g_op_active = False
|
||
_motor_snap = {"ts": time.time(), "error": f"{type(e).__name__}: {e}",
|
||
"cycles": (_motor_snap or {}).get("cycles", 0)}
|
||
_motor_stop.wait(0.2)
|
||
continue
|
||
# 睡眠必须在锁外:持锁 sleep 会把所有 HTTP 路由一起冻住
|
||
dt = time.perf_counter() - t0
|
||
_motor_stop.wait(max(0.0, _motor_period_ms / 1000.0 - dt))
|
||
|
||
|
||
def _motor_thread_start(period_ms=None):
|
||
"""启动后台 PDO 循环(幂等)。"""
|
||
global _motor_thread, _motor_period_ms
|
||
if period_ms:
|
||
_motor_period_ms = max(1, min(1000, int(period_ms)))
|
||
if _motor_thread is not None and _motor_thread.is_alive():
|
||
return False
|
||
_motor_stop.clear()
|
||
_motor_thread = threading.Thread(target=_pdo_worker, name="pdo-cycle",
|
||
daemon=True)
|
||
_motor_thread.start()
|
||
return True
|
||
|
||
|
||
def _motor_thread_stop():
|
||
"""停后台 PDO 循环并等它退出(必须在拿 g_lock 之前调,否则自锁)。"""
|
||
global _motor_thread
|
||
if _motor_thread is None:
|
||
return False
|
||
_motor_stop.set()
|
||
_motor_thread.join(timeout=2.0)
|
||
_motor_thread = None
|
||
return True
|
||
|
||
|
||
def api_motor_start(req):
|
||
"""进 OP 并启动后台 PDO 循环。
|
||
|
||
@note dispatcher 已在取 g_lock 前停掉旧循环,本函数在持锁状态下启动新线程。
|
||
新线程第一次取 g_lock 会等到本路由返回,属正常排队,不是死锁。
|
||
"""
|
||
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}
|
||
else:
|
||
log = []
|
||
started = _motor_thread_start(req.get("period_ms"))
|
||
log.append(f"后台 PDO 循环{'已启动' if started else '已在运行'}: "
|
||
f"周期 {_motor_period_ms}ms,窗口 {MOT_SLOTS_PER_PDO} 槽/周期,"
|
||
f"{MOTOR_SCAN_CYCLES} 周期扫完 {MOTOR_SLOT_COUNT} 槽 "
|
||
f"(全表刷新 ≈{_motor_period_ms * MOTOR_SCAN_CYCLES}ms)")
|
||
return {"ok": True, "log": log, "period_ms": _motor_period_ms}
|
||
|
||
|
||
def api_motor_stop(req):
|
||
"""停后台 PDO 循环(保持 OP,不动总线状态)。
|
||
|
||
@note 实际的 stop/join 由 dispatcher 在锁外完成(见 _BUS_EXCLUSIVE_PATHS),
|
||
这里只报告结果 —— 持锁 join 一个要抢同一把锁的线程会自锁死。
|
||
"""
|
||
return {"ok": True, "log": [f"后台 PDO 循环已停止(周期曾为 {_motor_period_ms}ms)"]}
|
||
|
||
|
||
def api_motor_state(req):
|
||
"""读最新电机状态快照(不碰总线,纯读内存,可高频轮询)。"""
|
||
snap = _motor_snapshot()
|
||
if snap is None:
|
||
return {"ok": False, "log": ["后台 PDO 循环未启动 —— 先调 /api/motor/start"]}
|
||
if "error" in snap:
|
||
return {"ok": False, "log": [f"PDO 周期异常: {snap['error']}"], **snap}
|
||
return {"ok": True, "log": [], **snap}
|
||
|
||
|
||
def api_motor_send(req):
|
||
"""把一帧(或多帧)电机 CAN 排入后台循环下发队列。
|
||
|
||
请求: {frames:[{ch,id,dlc,data}]} 或单帧 {ch,id,dlc,data}
|
||
1.5.56 起 _pdo_worker 每周期能取多帧(首帧走单帧区、其余走批量区,
|
||
共用一个 seq 沿),所以一次提交 9 轴指令会在**同一个 PDO 周期**发出,
|
||
而不是排队 9 个周期。DLC 支持到 64(CANFD 全量)。
|
||
"""
|
||
if g_master is None:
|
||
return {"ok": False, "log": ["未连接"]}
|
||
frames = req.get("frames")
|
||
if not frames:
|
||
frames = [req]
|
||
queued = []
|
||
for f in frames:
|
||
try:
|
||
ch = int(f.get("ch") or 0)
|
||
raw_id = f.get("id", 0)
|
||
can_id = int(raw_id, 16) if isinstance(raw_id, str) and \
|
||
raw_id.lower().startswith("0x") else int(raw_id or 0)
|
||
data = bytes.fromhex((f.get("data") or "").replace(" ", ""))
|
||
dlc = int(f.get("dlc") if f.get("dlc") not in (None, "") else len(data))
|
||
except (TypeError, ValueError) as e:
|
||
return {"ok": False, "log": [f"参数解析失败: {e}"]}
|
||
if ch not in (0, 1, 2):
|
||
return {"ok": False, "log": [f"通道必须是 0/1/2,收到 {ch}"]}
|
||
if dlc > CAN_FD_DATA_MAX:
|
||
return {"ok": False,
|
||
"log": [f"DLC {dlc} 超出 CANFD 上限 {CAN_FD_DATA_MAX}"]}
|
||
if len(data) < dlc:
|
||
data = data.ljust(dlc, b"\x00")
|
||
_motor_cmd_q.append((ch, can_id, dlc, data))
|
||
queued.append(f"CH{ch} 0x{can_id:X} DLC{dlc}")
|
||
|
||
if _motor_thread is None or not _motor_thread.is_alive():
|
||
# 没有后台循环就没人取队列。同步跑一次,避免命令悄无声息地烂在队列里。
|
||
# 本函数已由 dispatcher 持 g_lock 调用,此处不能再取锁(非可重入锁)。
|
||
if not g_op_active:
|
||
log, ok = _require_op_ready(*_dc_args(req))
|
||
if not ok:
|
||
return {"ok": False, "log": log}
|
||
# 同步路径也要用批量区:否则"后台循环没开"时会退化成每周期一帧,
|
||
# 多轴同步下发的时序保证在这条路上就悄悄丢了(自测第 6 项即抓到这点)。
|
||
n = 0
|
||
cycles = 0
|
||
while _motor_cmd_q:
|
||
ch, can_id, dlc, data = _motor_cmd_q.popleft()
|
||
take = _batch_capacity(list(_motor_cmd_q))
|
||
batch = [_motor_cmd_q.popleft() for _ in range(take)]
|
||
_fill_can_out(ch, can_id, dlc, data, batch=batch or None)
|
||
_can_pump(cycles=2)
|
||
n += 1 + len(batch)
|
||
cycles += 1
|
||
return {"ok": True, "queued": 0, "sent_sync": n, "pdo_cycles": cycles,
|
||
"log": [f"后台循环未运行,已同步下发 {n} 帧"
|
||
f"({cycles} 个 PDO 周期): " + "; ".join(queued)]}
|
||
|
||
return {"ok": True, "queued": len(queued), "backlog": len(_motor_cmd_q),
|
||
"batch_max_bytes": TXB_BYTES,
|
||
"log": []}
|
||
|
||
|
||
def api_motor_rx(req):
|
||
"""取走批量接收区累积的 CAN 帧(不碰总线,纯读内存)。
|
||
|
||
请求: {limit:int} —— 一次最多取多少帧(默认全取)
|
||
取走即从缓冲移除,语义是"消费"而非"查看",避免前端反复处理同一批帧。
|
||
"""
|
||
limit = req.get("limit")
|
||
try:
|
||
limit = int(limit) if limit not in (None, "") else len(_motor_rx_log)
|
||
except (TypeError, ValueError):
|
||
limit = len(_motor_rx_log)
|
||
out = []
|
||
for _ in range(max(0, min(limit, len(_motor_rx_log)))):
|
||
out.append(_motor_rx_log.popleft())
|
||
return {"ok": True, "log": [], "frames": out, "remain": len(_motor_rx_log)}
|
||
|
||
|
||
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 返回 → 进程退出。"""
|
||
# 先停后台 PDO 循环再关设备:否则线程会继续对已 close 的 master 发过程数据。
|
||
# 必须在取 g_lock 之前停(join 的目标线程自己也抢这把锁)。
|
||
try:
|
||
_motor_thread_stop()
|
||
except Exception:
|
||
pass
|
||
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/can/send": ("POST", api_can_send),
|
||
"/api/can/state": ("POST", api_can_state),
|
||
# 电机控制台(motor.html):状态读快照、命令走后台循环队列
|
||
"/api/motor/start": ("POST", api_motor_start),
|
||
"/api/motor/stop": ("POST", api_motor_stop),
|
||
"/api/motor/state": ("POST", api_motor_state),
|
||
"/api/motor/send": ("POST", api_motor_send),
|
||
"/api/motor/rx": ("POST", api_motor_rx),
|
||
"/api/sdo": ("POST", api_sdo),
|
||
"/api/sync_diag": ("POST", api_sync_diag),
|
||
"/api/shutdown": ("POST", api_shutdown),
|
||
}
|
||
|
||
# 需要独占总线的路由:dispatcher 会在取 g_lock 之前先停掉后台 PDO 循环。
|
||
# 判据是"会自己驱动 PDO 周期、或走 CoE/EEPROM 邮箱"——两者都不能与后台循环并行:
|
||
# - 抢 s.output / send_processdata,输出帧互相覆盖;
|
||
# - 邮箱类操作与周期数据并发是历史上邮箱楔死 + SOEM 分段读崩溃的诱因。
|
||
# /api/motor/start 与 /api/motor/stop 也在内:它们要 start/join 那个同样抢
|
||
# g_lock 的线程,若在持锁状态下 join 会直接自锁死。
|
||
_BUS_EXCLUSIVE_PATHS = frozenset({
|
||
"/api/open", "/api/close",
|
||
"/api/eeprom/read", "/api/eeprom/write", "/api/eeprom/verify", "/api/flash",
|
||
"/api/op/start", "/api/op/cycle", "/api/op/stop",
|
||
"/api/can/send",
|
||
"/api/sdo", "/api/sync_diag",
|
||
"/api/motor/start", "/api/motor/stop",
|
||
"/api/shutdown",
|
||
})
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "EcatTool/1.0"
|
||
|
||
def log_message(self, fmt, *args):
|
||
pass # 静默访问日志
|
||
|
||
def _not_found(self, path):
|
||
"""404 响应。
|
||
|
||
网页(ecat.html)每次请求都从磁盘重读,而 API 路由表是进程启动时固化的:
|
||
改完 ecat_server.py 不重启服务,就会出现"页面上有新按钮、点了报 not found"
|
||
这种自相矛盾的现象(2026-09-03 实测踩到)。这里直接比对源码 mtime 与进程
|
||
启动时刻,把这个错因写进响应,省得下次再从零排查。
|
||
"""
|
||
log = [f"接口不存在: {path}"]
|
||
try:
|
||
if os.path.getmtime(os.path.abspath(__file__)) > SERVER_START_TIME:
|
||
log.append("原因: ecat_server.py 在本服务启动之后被修改过 —— "
|
||
"当前进程仍是旧版本,没有这个接口。")
|
||
log.append("解决: 点右上角「退出服务」(或结束 pythonw 进程)后重新"
|
||
"双击 ecat_app.vbs / ecat_web.vbs 启动。")
|
||
except OSError:
|
||
pass
|
||
self._send(404, json.dumps({"ok": False, "log": log}).encode("utf-8"))
|
||
|
||
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)
|
||
|
||
# 静态文件白名单。注意 Content-Type 必须按后缀给对:
|
||
# - .js 用 text/html 会被浏览器的严格 MIME 检查拒掉(module script 与 Worker
|
||
# 都会直接报错不执行),这是加新页面最容易踩的坑;
|
||
# - 只有 HTML 才注入 _RELOAD_SNIPPET(注入到 js/css 里会破坏文件)。
|
||
# URL 路径保持不变(/、/ecat.css、/motor.html…),只是磁盘上挪进了 web/,
|
||
# 所以页面内的相对引用、书签、外部链接都不用改。
|
||
_STATIC_MAP = {
|
||
"/": "web/ecat.html",
|
||
"/ecat.html": "web/ecat.html",
|
||
"/index.html": "web/ecat.html",
|
||
"/ecat.css": "web/ecat.css",
|
||
# 电机控制台(EtherCAT 版)。样式直接复用 ecat.css —— 它本身就是 ESP32
|
||
# motor.css 的副本(md5 一致),再拷一份 motor.css 只会多出一处会漂移的副本。
|
||
"/motor.html": "web/motor.html",
|
||
"/motor.js": "web/motor.js",
|
||
}
|
||
_CTYPE_BY_EXT = {
|
||
".html": "text/html; charset=utf-8",
|
||
".css": "text/css; charset=utf-8",
|
||
".js": "application/javascript; charset=utf-8",
|
||
}
|
||
|
||
def do_GET(self):
|
||
path = self.path.split("?")[0]
|
||
name = self._STATIC_MAP.get(path)
|
||
if name:
|
||
fp = os.path.join(BASE_DIR, name)
|
||
ext = os.path.splitext(name)[1].lower()
|
||
ctype = self._CTYPE_BY_EXT.get(ext, "application/octet-stream")
|
||
try:
|
||
with open(fp, "rb") as f:
|
||
body = f.read()
|
||
if ext == ".html":
|
||
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._not_found(path)
|
||
|
||
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._not_found(path)
|
||
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
|
||
|
||
# /api/motor/state 与 /api/motor/rx 只读内存(快照 / 接收缓冲),不碰总线
|
||
# —— 放在 g_lock 外面。否则高频轮询会和后台 PDO 线程(每 4ms 取一次锁)
|
||
# 排队互等,既拖慢响应又给循环加抖动。
|
||
if path in ("/api/motor/state", "/api/motor/rx"):
|
||
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"))
|
||
return
|
||
|
||
# 独占总线的操作必须先停后台 PDO 循环,且必须在取 g_lock **之前** 停:
|
||
# _motor_thread_stop() 要 join 的那个线程自己也在抢 g_lock,
|
||
# 持锁去 join 会直接自锁死。停完再进锁,语义等价于"独占期间不喂看门狗",
|
||
# 从站可能掉出 OP —— 这些操作本来就要重进 OP,可接受。
|
||
if path in _BUS_EXCLUSIVE_PATHS:
|
||
_motor_thread_stop()
|
||
|
||
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()
|