498 lines
21 KiB
Python
498 lines
21 KiB
Python
#!/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
|