feat(ecat): PDO 快发 ESF1 桌面强制直通,堵死慢发回落

桌面 APP 下 ECAT motor 发送只走 pywebview ESF1→native TCP;
删除桥内 HTTP/JSON 回落。附延迟测量与 pack 自测脚本与规格文档。
This commit is contained in:
cbb
2026-09-21 02:11:49 +08:00
parent 383535bd1d
commit 2c6235d920
13 changed files with 1267 additions and 21 deletions
+201 -5
View File
@@ -44,6 +44,7 @@ import json
import os
import re
import socket
import struct
import subprocess
import sys
import threading
@@ -189,6 +190,177 @@ g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已
g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释)
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
# ── PDO 快发(ESF1)── tools/ecat ecat_native 的二进制直通 ──────────
# 布局与 docs/compose/spec/pdo-fast-send.md 一致。**仅** native 专用 TCP 口
# (health.fast_send_port);2026-09-23 堵死慢发:已删除 HTTP/JSON 回落。
# 持久连接:连接阶段不设 timeout(Windows 非阻塞 connect 会撞
# ~10ms 调度粒度,见 _ecat_post 注释);连上后再 settimeout。
ESF_MAGIC = 0x31465345
FSOK_MAGIC = 0x4B4F5346
ESF_FRAME_W = 72
g_fast_send_port = 0 # 从 native health 发现的快发 TCP 口;0=未知/不可用
g_fast_sock = None
g_fast_sock_lock = threading.Lock()
def _hex_to_bytes64(data) -> bytes:
"""帧 data → 64 字节(短则右侧补 0)。"""
if data is None:
return b"\x00" * 64
if isinstance(data, (bytes, bytearray)):
b = bytes(data)[:64]
return b + b"\x00" * (64 - len(b))
s = str(data).strip().replace(" ", "")
if s.lower().startswith("0x"):
s = s[2:]
if len(s) % 2:
s = "0" + s
b = bytes.fromhex(s)[:64] if s else b""
return b + b"\x00" * (64 - len(b))
def pack_esf(frames) -> bytes:
"""frames=[{ch,id,dlc,data(hex)}] → ESF1 请求体。"""
items = list(frames or [])
n = len(items)
if n < 1 or n > 64:
raise ValueError(f"frames 数量必须 1..64,收到 {n}")
out = bytearray(struct.pack("<IBBH", ESF_MAGIC, n, 0, 0))
for fr in items:
if not isinstance(fr, dict):
raise ValueError(f"帧必须是对象,收到 {type(fr).__name__}")
ch = int(fr.get("ch", 0))
dlc = int(fr.get("dlc", 0) or 0)
if ch < 0 or ch > 2:
raise ValueError(f"通道必须是 0/1/2,收到 {ch}")
if dlc < 0 or dlc > 64:
raise ValueError(f"DLC 超出 CANFD 上限 64: {dlc}")
fid = fr.get("id", 0)
if isinstance(fid, str):
fid = int(fid, 0)
data = _hex_to_bytes64(fr.get("data"))
out += struct.pack("<BBHI", ch & 0xFF, dlc & 0xFF, 0, int(fid) & 0xFFFFFFFF)
out += data
return bytes(out)
def unpack_fsok(resp8: bytes) -> dict:
if not resp8 or len(resp8) < 8:
return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK 响应过短"]}
magic, ok, queued, backlog = struct.unpack_from("<IBBH", resp8, 0)
if magic != FSOK_MAGIC:
return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK magic 不匹配"]}
return {
"ok": bool(ok),
"queued": int(queued),
"backlog": int(backlog),
"log": [] if ok else ["native 快发拒绝(未连接/解析失败)"],
}
def _note_fast_port(obj) -> None:
global g_fast_send_port
if isinstance(obj, dict):
p = obj.get("fast_send_port")
if isinstance(p, (int, float)) and int(p) > 0:
g_fast_send_port = int(p)
def _discover_fast_send_port(timeout=2.0, force=False) -> int:
global g_fast_send_port
if g_fast_send_port and not force:
return g_fast_send_port
try:
_status, data = _ecat_proxy(ECAT_PROXY_PREFIX + "native/health", b"{}", timeout)
obj = json.loads(data.decode("utf-8", "replace") or "{}")
_note_fast_port(obj)
if force and not isinstance(obj, dict):
g_fast_send_port = 0
except Exception:
if force:
g_fast_send_port = 0
return g_fast_send_port
def _close_fast_sock() -> None:
global g_fast_sock
with g_fast_sock_lock:
s = g_fast_sock
g_fast_sock = None
if s is not None:
try:
s.close()
except Exception:
pass
def _fast_sock_connect(port: int, timeout: float):
sock = socket.create_connection(("127.0.0.1", int(port)), timeout=None)
sock.settimeout(timeout)
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
return sock
def ecat_send_raw_frames(frames, timeout_ms=2000) -> dict:
"""ESF1 快发:**仅**持久 TCP → native fast_send_port。
2026-09-23 用户要求堵死慢发:删除 HTTP send_raw / JSON motor/send 回落。
失败即 ok:false,由前端暴露错误;不自动走慢路径。
持久连接上的 sendall/recv **必须整段持锁**,否则并发请求会把 FSOK
错配到别的帧。连接失败时清掉缓存端口并重探一次(仍只走 TCP)。
"""
global g_fast_sock, g_fast_send_port
try:
body = pack_esf(frames)
except Exception as e:
return {"ok": False, "queued": 0, "backlog": 0, "path": "none",
"log": [f"ESF 打包失败: {e}"]}
timeout = max(0.2, float(timeout_ms or 2000) / 1000.0)
def _tcp_once(port: int) -> dict:
nonlocal body, timeout
global g_fast_sock
r = None
with g_fast_sock_lock:
if g_fast_sock is None:
g_fast_sock = _fast_sock_connect(port, timeout)
sock = g_fast_sock
sock.sendall(body)
resp = b""
while len(resp) < 8:
chunk = sock.recv(8 - len(resp))
if not chunk:
raise OSError("快发口连接已关闭")
resp += chunk
r = unpack_fsok(resp)
r["path"] = "fast_tcp"
return r
last_err = None
for attempt in (0, 1):
port = _discover_fast_send_port(timeout, force=(attempt == 1))
if not port:
last_err = "native 无 fast_send_port(服务未起或版本过旧)"
break
try:
return _tcp_once(port)
except Exception as e:
last_err = f"快发口异常: {e}"
_close_fast_sock()
g_fast_send_port = 0
if attempt == 0:
continue
return {
"ok": False,
"queued": 0,
"backlog": 0,
"path": "fast_tcp",
"log": [last_err or "二进制快发失败(已禁用 HTTP/JSON 回落)"],
}
def _set_ecat_realtime(pid: int) -> bool:
"""把 ecat_native 提到 RealTime 进程优先级。
@@ -251,13 +423,34 @@ class _GuiApi:
def bridge_info(self):
"""前端探测桌面桥是否可用。"""
if g_ecat_port or g_fast_send_port == 0:
try:
_discover_fast_send_port(0.8)
except Exception:
pass
return {
"ok": True,
"mode": "desktop",
"has_ecat_bridge": True,
"ecat_port": g_ecat_port,
"has_fast_send": bool(g_fast_send_port),
"fast_send_port": g_fast_send_port,
}
def ecat_send_raw(self, frames, timeout_ms=2000):
"""桌面桥 PDO 快发:JS → 本进程 ESF1 打包 → native 入队。
frames: [{ch, id, dlc, data(hex)}];也可传 {"frames":[...]}。
返回 {"ok","queued","backlog","path","log"},path 为实际走的通道。
"""
if isinstance(frames, dict):
frames = frames.get("frames") or ([frames] if "id" in frames or "data" in frames else [])
try:
return ecat_send_raw_frames(frames, timeout_ms)
except Exception as e:
return {"ok": False, "queued": 0, "backlog": 0,
"log": [f"原生桥快发异常: {type(e).__name__}: {e}"]}
def ecat_api(self, path, body=None, timeout_ms=4000):
"""桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。
@@ -306,6 +499,7 @@ class _GuiApi:
obj.setdefault("ok", status == 200)
if status >= 400 and "log" not in obj:
obj["log"] = [f"HTTP {status}"]
_note_fast_port(obj)
return obj
@@ -472,7 +666,7 @@ def _ecat_autostart():
return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
def _ecat_post(port, path, body, timeout, connect_timeout=None):
def _ecat_post(port, path, body, timeout, connect_timeout=None, ctype=None):
"""向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
@@ -496,7 +690,7 @@ def _ecat_post(port, path, body, timeout, connect_timeout=None):
payload = (
f"POST {path} HTTP/1.0\r\n"
f"Host: 127.0.0.1:{port}\r\n"
"Content-Type: application/json\r\n"
f"Content-Type: {ctype or 'application/json'}\r\n"
f"Content-Length: {len(body or b'')}\r\n"
"Connection: close\r\n"
"\r\n"
@@ -525,7 +719,7 @@ def _ecat_post(port, path, body, timeout, connect_timeout=None):
return int(first[1]), data
def _ecat_proxy(path, body, timeout=6.0):
def _ecat_proxy(path, body, timeout=6.0, ctype=None):
"""把 /ecat/api/xxx 转发到 EtherCAT 后端的 /api/xxx。
返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
@@ -537,6 +731,7 @@ def _ecat_proxy(path, body, timeout=6.0):
2026-09-12: 先用 _port_taken 快探,全灭则立刻 _ecat_autostart;
不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。
ctype: 可选 Content-Type(ESF1 二进制 body 用 application/octet-stream)。
"""
global g_ecat_port
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
@@ -555,7 +750,8 @@ def _ecat_proxy(path, body, timeout=6.0):
# 其余候选口用 0.3s 连接超时, 避免逐口 2s(全灭时 12s+)。
status, data = _ecat_post(
port, target, body, timeout,
connect_timeout=None if port == g_ecat_port else 0.3)
connect_timeout=None if port == g_ecat_port else 0.3,
ctype=ctype)
g_ecat_port = port
return status, data
except Exception as e: # 连不上/超时 → 试下一个端口
@@ -572,7 +768,7 @@ def _ecat_proxy(path, body, timeout=6.0):
start_logs = [f"autostart 异常: {type(e).__name__}: {e}"]
if port:
try:
status, data = _ecat_post(port, target, body, timeout)
status, data = _ecat_post(port, target, body, timeout, ctype=ctype)
g_ecat_port = port
return status, data
except Exception as e: