#!/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/" # 浏览器 HTTP 代理路径 native_target = "/api/" # 直连 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/ 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(" 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(" dict: if not resp8 or len(resp8) < 8: return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK 响应过短"]} magic, ok, queued, backlog = struct.unpack_from(" 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/ 是否允许 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