feat(ecat): PDO 快发 ESF1 桌面强制直通,堵死慢发回落
桌面 APP 下 ECAT motor 发送只走 pywebview ESF1→native TCP; 删除桥内 HTTP/JSON 回落。附延迟测量与 pack 自测脚本与规格文档。
This commit is contained in:
+201
-5
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user