Files
DCU_ECAT_Master/ecat_server.py
T
cbb beb33ffe50 refactor: 按角色分 web/ diag/ launch/ 三层, 根目录只留主程序
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 实跑正常。
2026-09-03 22:08:38 +08:00

1945 lines
82 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""ecat_server.py — DCU LAN9252 EtherCAT 主站网页工具后端。
用 venv 里的 pysoem 提供 REST API,浏览器打开 ecat.html 操作:
GET / → ecat.html
GET /api/adapters → Npcap 适配器列表
POST /api/open {substr} 连接(打开适配器+config_init)
POST /api/status {} 读身份/AL 状态
POST /api/close {} 断开
POST /api/eeprom/read {start,count} 读 word 返回 hex 行
POST /api/eeprom/write {data:hex} 写+回读校验(跳过 0xFF)
POST /api/eeprom/verify {data:hex} 只读校验
POST /api/flash {data:hex|null} 烧录(null=gen_eeprom.py 镜像)
POST /api/op/start {dc_cycle_us?,dc_shift_us?} PREOP→config_map→[DC]→SAFEOP→OP
POST /api/op/cycle {outputs:hex|null} 发送一次过程数据,返回输入
POST /api/op/stop {} 回 INIT
POST /api/sdo/read {index,sub,size}
POST /api/sdo/write {index,sub,hex}
POST /api/sync_diag {} 读 0x2000 SYNC Diag, 返回 sync0/sync1/escirq/uptime + sync0 速率
POST /api/shutdown {} 断开设备并退出服务进程
所有 pysoem 访问经全局锁串行化。日志随每个响应以 log[] 返回。
启动模式: --window 用 pywebview 桌面窗口(ecat_app.vbs); 默认浏览器 tab(ecat_web.vbs/bat)。
"""
import argparse
import collections
import ctypes
import io
import json
import os
import struct
import subprocess
import sys
import threading
import time
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE_DIR)
try:
import pysoem
except ImportError:
print("pysoem 无法加载:请先安装 Npcap(本目录 npcap-1.88.exe,勾选 WinPcap API-compatible Mode)")
sys.exit(1)
EEPROM_SIZE = 4096
AL_STATE = {0x00: "NONE", 0x01: "INIT", 0x02: "PREOP",
0x03: "BOOT", 0x04: "SAFEOP", 0x08: "OP"}
# pywebview 窗口没有浏览器刷新按钮, 注入悬浮 ⟳ + F5/Ctrl+R 拦截(浏览器 tab 下同样无害)
# 另: WebView2 默认把 window.open / target=_blank 甩给系统浏览器; 而经 pywebview
# 在 js_api 线程里 create_window 会撞 UI 线程(实测卡死+崩溃), 故统一改为当前窗口内跳转。
_RELOAD_SNIPPET = """
<style>#__rl,#__home{position:fixed;right:14px;width:34px;height:34px;z-index:99999;
border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(20,22,28,.72);
color:#9fb0c3;font-size:17px;line-height:32px;text-align:center;cursor:pointer;
user-select:none;opacity:.55;transition:opacity .15s}
#__rl{bottom:14px}#__home{bottom:56px;font-size:19px}
#__rl:hover,#__home:hover{opacity:1}#__rl:active{transform:rotate(90deg)}</style>
<div id="__rl" title="刷新页面 (F5)">&#x21bb;</div>
<div id="__home" title="返回主页" style="display:none">&#x2302;</div>
<script>(function(){
function go(){location.reload();}
document.getElementById('__rl').addEventListener('click',go);
window.addEventListener('keydown',function(e){
if(e.key==='F5'||(e.ctrlKey&&(e.key==='r'||e.key==='R'))){e.preventDefault();go();}
});
var home=document.getElementById('__home');
var p=location.pathname;
if(!/\\/$/.test(p)&&!/\\/index\\.html$/.test(p)){
home.style.display='block';
home.addEventListener('click',function(){location.href='__HOME__';});
}
window.open=function(u){if(u)location.href=u;return null;};
document.addEventListener('click',function(e){
var a=e.target&&e.target.closest?e.target.closest('a[target=_blank]'):null;
if(a&&a.href){e.preventDefault();location.href=a.href;}
},true);
function ready(){try{window.pywebview&&window.pywebview.api&&
window.pywebview.api.page_loaded()}catch(e){}}
document.addEventListener('DOMContentLoaded',ready);
window.addEventListener('load',ready);
})();</script>
"""
_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/")
g_lock = threading.Lock()
# 本进程启动时刻:用于 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()