Files

498 lines
21 KiB
Python
Raw Permalink 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_client.py — EtherCAT native 客户端(端口探测 / 裸 socket HTTP / ESF1 快发)。
从 esp32_server.py 外提(R8)。本模块只做「怎么跟 ecat_native 说话」:
- 路径归一化(R9:桥与代理共用 normalize_ecat_api_path)
- 端口探测(8090..8095 漂移)与裸 socket HTTP/1.0 客户端
- ESF1 二进制快发打包/解包 + 持久 TCP
- 代理重发策略(R1/P0:非幂等不重发、先复用健康实例由 proc 负责)
双路径说明(R10)——两条入口,同一 ecat_client:
- 浏览器 = HTTP 代理:
页面 fetch POST /ecat/api/* → esp32_server.Handler.do_POST
→ ecat_client.ecat_proxy → _ecat_post
- 桌面 APP = GuiApi 桥:
JS window.pywebview.api.ecat_api → esp32_server._GuiApi.ecat_api
→ ecat_client.api_call → ecat_proxy → _ecat_post
两者最终都走本模块的 normalize_ecat_api_path + _ecat_post/_ecat_proxy。
不删桥的理由:桌面桥少一跳 HTTP(GUI 进程直连 native);浏览器没有
pywebview.api,只能走同源代理。外部 API 语义不变。
进程治理(autostart / kill / RealTime / 跨进程锁)在 ecat_proc.py。
本模块通过 set_autostart_hook 接收回调,避免 ecat_client ↔ ecat_proc
循环 import。BASE_DIR 只在 esp32_server 定义一次并传给 ecat_proc.init。
"""
from __future__ import annotations
import json
import socket
import struct
import threading
# ── EtherCAT 后端反向代理常量 ──────────────────────────────────────────
# 电机页的 EtherCAT 传输要打 tools/ecat 的 ecat_native.exe 的 /api/motor/*,
# 但那个服务**只绑 127.0.0.1 且完全没有 CORS**。本服务做同源转发。
# 端口自动探测: EtherCAT 服务(含 native)会在 8090..8095 间漂移。
ECAT_PROXY_PREFIX = "/ecat/api/"
ECAT_PORTS = (8090, 8091, 8092, 8093, 8094, 8095)
g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 8090 试起
# ── 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()
# ── R1/P0: 代理重发策略 ────────────────────────────────────────────────
# 连接失败后 autostart 再打一次同一 POST,对写路径 = 可能二次执行。
# 仅对明确只读/可安全重试的路由允许 autostart 后自动重发一次。
_ECAT_IDEMPOTENT_TAILS = frozenset({
"status", "native/health", "app/load", "motor/state", "motor/rx",
"motor/version", "motor/dbg", "load",
})
# ecat_proc.init 注入的进程拉起回调: () -> (port|None, log_lines)
_autostart_hook = None
def set_autostart_hook(fn):
"""由 ecat_proc.init 调用:注入 autostart,避免循环 import。"""
global _autostart_hook
_autostart_hook = fn
def _autostart():
if _autostart_hook is None:
return None, ["EtherCAT 进程管理未初始化(ecat_proc.init 未调用)"]
return _autostart_hook()
# ── R9: 桥与代理共用的路径归一化 ──────────────────────────────────────
def normalize_ecat_api_path(path):
"""任意形态 ECAT API 路径 → (proxy_path, native_target)。
输入可为:
"motor/send" | "api/motor/send" | "/api/motor/send"
| "/ecat/api/motor/send" | "ecat/api/motor/send"
返回:
proxy_path = "/ecat/api/<tail>" # 浏览器 HTTP 代理路径
native_target = "/api/<tail>" # 直连 native 的路径
_GuiApi.ecat_api(桌面桥)与 _ecat_proxy(浏览器代理)共用本函数,
删除两边各自实现的剥前缀逻辑(R9)。
"""
p = str(path or "").strip()
p = p.split("?", 1)[0]
while p.startswith("/"):
p = p[1:]
if p.startswith("ecat/api/"):
p = p[len("ecat/"):] # → api/<tail>
elif p.startswith("ecat/"):
p = p[len("ecat/"):]
if p.startswith("api/"):
tail = p[len("api/"):]
else:
tail = p
proxy_path = ECAT_PROXY_PREFIX + tail
native_target = "/api/" + tail
return proxy_path, native_target
# ── 端口探测 ──────────────────────────────────────────────────────────
def _port_taken(port):
"""Windows 的 SO_REUSEADDR 允许同端口重复 bind(实测会双监听、连接随机落),
故先用一次真实连接探测占用, 不能只靠 bind 是否抛错。"""
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 _ecat_find_port():
"""返回正在监听的 ecat 服务端口, 没有则 None。"""
ports = ([g_ecat_port] if g_ecat_port else []) + \
[p for p in ECAT_PORTS if p != g_ecat_port]
for p in ports:
if _port_taken(p):
return p
return None
# ── ESF1 打包/解包 ────────────────────────────────────────────────────
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 回落)"],
}
# ── 幂等判定 / 连接拒绝判定(P0 保留)────────────────────────────────
def _ecat_target_idempotent(target: str) -> bool:
"""/api/<tail> 是否允许 autostart 后自动重发。默认 False(偏安全)。"""
tail = target[len("/api/"):] if target.startswith("/api/") else target.lstrip("/")
tail = tail.split("?", 1)[0].strip("/")
if tail in _ECAT_IDEMPOTENT_TAILS:
return True
# 显式只读前缀
if tail.startswith(("native/", "diag/")) and "stop" not in tail and "kill" not in tail:
return True
return False
def _ecat_connect_refused(exc) -> bool:
"""异常是否属于「根本没连上」(请求未发出,重发安全)。"""
if isinstance(exc, ConnectionRefusedError):
return True
if isinstance(exc, OSError):
if getattr(exc, "winerror", None) in (10061, 10065):
return True
if getattr(exc, "errno", None) in (111, 10061):
return True
msg = str(exc).lower()
return ("refused" in msg) or ("actively refused" in msg)
# ── 裸 socket HTTP 客户端 + 代理 ──────────────────────────────────────
def _ecat_post(port, path, body, timeout, connect_timeout=None, ctype=None):
"""向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
给 socket 设 timeout 会让 Python 把它切成非阻塞模式, 而 Windows 上非阻塞
connect 要靠 select 轮询完成, 于是撞上约 10ms 的系统调度粒度。实测
(本机回环, /api/motor/state):
urllib.urlopen(req, timeout=5) p50 11.0 ms
http.client + timeout p50 10.7 ms
裸 socket 连接阶段不设 timeout p50 0.58 ms
后端自己只用 0.58ms, 那 10ms 全是这一层白交的过路费 ——
电机页 EtherCAT 链路的刷新上限直接被它按在 ~76Hz。
所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接
要么立刻成功要么立刻 ECONNREFUSED, 不会挂住), 连上之后才设读写超时。
connect_timeout: 只给冷路径(逐个试漂移端口)用的连接超时。**热路径(缓存
端口)必须保持 None(阻塞连接)**——本机对死端口无论阻塞还是 settimeout 都
不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 才回 WOULDBLOCK), 但活端口
的阻塞 connect 只要 ~0.6ms, 两害相权取其轻。
"""
payload = (
f"POST {path} HTTP/1.0\r\n"
f"Host: 127.0.0.1:{port}\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"
).encode("ascii") + (body or b"")
sock = socket.create_connection(("127.0.0.1", port), timeout=connect_timeout)
try:
sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响已完成的 connect
sock.sendall(payload)
chunks = []
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
finally:
sock.close()
raw = b"".join(chunks)
head, _, data = raw.partition(b"\r\n\r\n")
first = head.split(b"\r\n", 1)[0].split(b" ", 2)
if len(first) < 2 or not first[1].isdigit():
raise OSError(f"EtherCAT 后端回了无法解析的响应头: {head[:120]!r}")
# 404 等业务错误要原样透传: 后端的 404 体里带
# "服务在改动之后没重启" 的排查提示, 吞掉它会让人白排查半天。
return int(first[1]), data
def ecat_proxy(path, body, timeout=6.0, ctype=None):
"""把 /ecat/api/xxx 转发到 EtherCAT 后端的 /api/xxx。
返回 (status, body_bytes)。路径归一化与桌面桥共用 normalize_ecat_api_path(R9)。
端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
EtherCAT 后端会在 8090..8095 之间漂移(端口被占就挪),
写死 8090 会在它挪了之后表现为"页面点了没反应"。
连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封,
这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。
2026-09-12: 先用 _port_taken 快探,全灭则立刻 autostart;
不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。
ctype: 可选 Content-Type(ESF1 二进制 body 用 application/octet-stream)。
P0/R1: 非幂等请求 autostart 后**不**自动重发,返回 503 由用户/前端显式重试。
"""
global g_ecat_port
# R9: 与 GuiApi 桥共用归一化。"motor/state" | "/api/motor/state"
# | "/ecat/api/motor/state" → target="/api/motor/state"
_proxy_path, target = normalize_ecat_api_path(path)
idem = _ecat_target_idempotent(target)
# 端口尝试顺序: 先缓存口, 失败再试其余漂移口(带短连接超时), 全灭才拉起。
# **健康路径上绝不能对死端口做预探测** —— 本机 Windows 对回环死端口的
# connect 不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 回 WOULDBLOCK),
# 2026-09-12 实测每请求预探 5 个死端口 = 恒定 1.5s, 电机页"本页请求耗时
# 1517ms"、刷新被按到 0.66Hz 皆此。缓存口命中的代价只有 ~0.6ms。
ports = ([g_ecat_port] if g_ecat_port else []) + \
[p for p in ECAT_PORTS if p != g_ecat_port]
last = None
for port in ports:
try:
# 缓存口连不上大概率是后端挪了/死了, 阻塞 connect ~2s 可接受;
# 其余候选口用 0.3s 连接超时, 避免逐口 2s(全灭时 12s+)。
status, data = _ecat_post(
port, target, body, timeout,
connect_timeout=None if port == g_ecat_port else 0.3,
ctype=ctype)
g_ecat_port = port
return status, data
except Exception as e: # 连不上/超时 → 视情况试下一个端口
last = e
if g_ecat_port == port:
g_ecat_port = None # 缓存端口失效, 下轮重新全量探测
# P0/R1: 非幂等请求一旦可能已到达旧实例(超时/非连接拒绝),
# 禁止再打其它端口——否则 flash/motor/send 等会被重复执行。
if not idem and not _ecat_connect_refused(e):
break
# 无存活端口或全失败 → 单实例拉起;**非幂等路径禁止自动重发**
start_logs = []
try:
port, start_logs = _autostart()
except Exception as e:
last = e
port = None
start_logs = [f"autostart 异常: {type(e).__name__}: {e}"]
if port:
g_ecat_port = port
if not idem:
# 源请求可能已在旧实例上执行,或在超时途中被接收。
# 自动重发会二次写 flash / 重复 motor/start。返回 503,
# 由前端/用户显式重试(后端已就绪,重试通常一次即成功)。
msg = (
f"EtherCAT 服务已自动拉起,但 {target} 为非幂等路径,"
"已按约定不自动重发,请重试一次"
)
body = json.dumps(
{"ok": False, "log": start_logs + [msg]},
ensure_ascii=False).encode("utf-8")
return 503, body
try:
status, data = _ecat_post(port, target, body, timeout, ctype=ctype)
return status, data
except Exception as e:
last = e
msg = (f"EtherCAT 服务不可用: {type(last).__name__ if last else 'unknown'}")
body = json.dumps({"ok": False, "log": start_logs + [msg]},
ensure_ascii=False).encode("utf-8")
return 503, body
# 原名兼容:server / 脚本 / 测试 patch 点
_ecat_proxy = ecat_proxy
def _body_to_bytes(body) -> bytes:
"""桥/代理共用的 body 序列化:dict → JSON,str/bytes 原样。"""
if body is None:
return b"{}"
if isinstance(body, (bytes, bytearray)):
return bytes(body)
if isinstance(body, str):
return body.encode("utf-8")
try:
return json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
except Exception as e:
raise ValueError(f"body 序列化失败: {type(e).__name__}: {e}") from e
def api_call(path, body=None, timeout_ms=4000) -> dict:
"""统一 ECAT API 调用入口(R10)。桌面桥 GuiApi.ecat_api 直接调这里。
浏览器路径不经过本函数(HTTP → Handler → ecat_proxy),但两者最终
共用 normalize_ecat_api_path + ecat_proxy/_ecat_post,无第二套协议栈。
返回与 native 同构的 dict(ok/log/...)。
"""
proxy_path, _target = normalize_ecat_api_path(path)
try:
raw = _body_to_bytes(body)
except ValueError as e:
return {"ok": False, "log": [f"原生桥 {e}"]}
timeout = max(0.2, float(timeout_ms or 4000) / 1000.0)
try:
status, data = ecat_proxy(proxy_path, raw, timeout)
except Exception as e:
return {"ok": False, "log": [f"原生桥异常: {type(e).__name__}: {e}"]}
try:
obj = json.loads(data.decode("utf-8", "replace") or "{}")
except Exception:
obj = {"ok": False, "log": ["原生桥响应非 JSON"]}
if not isinstance(obj, dict):
obj = {"ok": False, "log": ["原生桥响应不是对象"]}
obj.setdefault("ok", status == 200)
if status >= 400 and "log" not in obj:
obj["log"] = [f"HTTP {status}"]
_note_fast_port(obj)
return obj