diff --git a/ecat_client.py b/ecat_client.py new file mode 100644 index 0000000..1a1a2d8 --- /dev/null +++ b/ecat_client.py @@ -0,0 +1,497 @@ +#!/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 diff --git a/ecat_proc.py b/ecat_proc.py new file mode 100644 index 0000000..85252d8 --- /dev/null +++ b/ecat_proc.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""ecat_proc.py — ecat_native 进程治理(跨进程锁 / autostart / kill / RealTime)。 + +从 esp32_server.py 外提(R8)。职责是「谁有权把 ecat_native 拉起来/杀掉」, +不碰 HTTP/静态/lease——那些留在 esp32_server.py。 + +路径注入:BASE_DIR **只在 esp32_server 定义一次**,启动时调用 +`init(BASE_DIR)` 设置本模块的 ECAT_DIR / ECAT_NATIVE_EXE,并向 +ecat_client 注册 autostart 钩子。 + +保留 P0 行为(不得回退): + - 跨进程命名互斥锁 _EcatProcXLock(GUI 进程 + 服务子进程串行 kill/start) + - **先复用健康监听实例**,绝不 kill 对方刚拉起的 native + - 默认网卡 I210(与 js/common/device-link.js / pages/motor.html 一致,R2) + - 拉起时带 --parent-pid,native 在本服务退出后自我了断 + - stdout 落盘(DEVNULL 会吞掉崩溃现场) +""" +from __future__ import annotations + +import ctypes +import os +import subprocess +import threading +import time + +import ecat_client + +# 网卡默认子串:与 js/common/device-link.js / pages/motor.html 保持一致(R2) +ECAT_DEFAULT_ADAPTER = "I210" + +ECAT_DIR = None +ECAT_NATIVE_EXE = None + +g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None) +g_ecat_log_fp = None # native stdout 落盘句柄(见 autostart 内注释) +g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程 + +# R3: 跨进程互斥(GUI 进程 + 服务子进程都可能 autostart/kill native) +_ECAT_XPROC_MUTEX_NAME = "Local\\DCU_Esp32Tools_EcatNativeProc" + + +def _resolve_ecat_dir(base_dir): + """定位 tools/ecat(ecat_native.exe 的仓库,与本仓库平级)。 + + 从 git worktree(.../tools/ESP32/.worktrees/)启动时,BASE_DIR 的 + 上一级是 .worktrees 而不是 tools,直接 join("..","ecat") 会拼成 + .../ESP32/.worktrees/ecat,找不到 exe。先走回主检出再取兄弟目录。 + """ + d = os.path.abspath(base_dir) + parent = os.path.dirname(d) + if os.path.basename(parent) == ".worktrees": + d = os.path.dirname(parent) # → .../tools/ESP32 主检出 + return os.path.normpath(os.path.join(d, "..", "ecat")) + + +def init(base_dir: str) -> dict: + """由 esp32_server 在导入时调用:注入 BASE_DIR,注册 autostart 钩子。""" + global ECAT_DIR, ECAT_NATIVE_EXE + ECAT_DIR = _resolve_ecat_dir(base_dir) + ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe") + ecat_client.set_autostart_hook(autostart) + return {"ecat_dir": ECAT_DIR, "native_exe": ECAT_NATIVE_EXE} + + +class _EcatProcXLock: + """Windows 命名互斥锁:串行化所有会 kill/拉起 ecat_native 的路径。""" + + def __init__(self, timeout_ms=60000): + self.timeout_ms = timeout_ms + self._handle = None + self._owned = False + + def __enter__(self): + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + self._handle = k32.CreateMutexW(None, False, _ECAT_XPROC_MUTEX_NAME) + if not self._handle: + return self + r = k32.WaitForSingleObject(self._handle, self.timeout_ms) + # WAIT_OBJECT_0=0, WAIT_ABANDONED=0x80 + self._owned = r in (0, 0x80) + return self + + def __exit__(self, *exc): + if not self._handle: + return False + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + try: + if self._owned: + k32.ReleaseMutex(self._handle) + finally: + k32.CloseHandle(self._handle) + self._handle = None + return False + + +def _set_ecat_realtime(pid: int) -> bool: + """把 ecat_native 提到 RealTime 进程优先级。 + + native 自己 SetPriorityClass(RT) 会被系统静默钳成 High(令牌无 + SeIncreaseBasePriorityPrivilege)。由本服务(启动方)从外部设置才能粘住。 + pdo 线程已是 TIME_CRITICAL:进程类 High→线程 15,RealTime→线程 31, + 后者才扛得住桌面 DPC/调度抢占。实测 1ms 档 late ~12/s → 个位数。 + 失败返回 False,不影响启动。 + """ + try: + import ctypes.wintypes as wt # noqa: F401 + PROCESS_SET_INFORMATION = 0x0200 + REALTIME_PRIORITY_CLASS = 0x00000100 + HIGH_PRIORITY_CLASS = 0x00000080 + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + h = k32.OpenProcess(PROCESS_SET_INFORMATION, False, int(pid)) + if not h: + return False + try: + if k32.SetPriorityClass(h, REALTIME_PRIORITY_CLASS): + return True + return bool(k32.SetPriorityClass(h, HIGH_PRIORITY_CLASS)) + finally: + k32.CloseHandle(h) + except Exception: + return False + + +def _ecat_reuse_listening_native(): + """端口上已有健康 native → 直接复用(可能是另一进程刚拉起的),不 kill。""" + g_port = ecat_client.g_ecat_port + ports = ([g_port] if g_port else []) + \ + [p for p in ecat_client.ECAT_PORTS if p != g_port] + for p in ports: + if not ecat_client._port_taken(p): + continue + try: + status, data = ecat_client._ecat_post( + p, "/api/native/health", b"{}", 1.0, + connect_timeout=0.4) + if status == 200: + return p + except Exception: + continue + return None + + +def _kill_all_ecat_native(): + """强杀所有 ecat_native.exe,返回杀掉的个数。 + + 两个 native 并存会抢 Npcap 适配器:后起的抓不到从站、HTTP 超时、 + PDO 空转。启动新实例前必须保证旧的死透。只按进程名杀,不碰 python。 + """ + try: + out = subprocess.check_output( + ["tasklist", "/FI", "IMAGENAME eq ecat_native.exe", + "/FO", "CSV", "/NH"], + text=True, stderr=subprocess.DEVNULL, timeout=8, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) or "" + except Exception: + return 0 + pids = [] + for line in (out or "").splitlines(): + parts = [p.strip().strip('"') for p in line.split(",")] + if len(parts) >= 2 and parts[0].lower() == "ecat_native.exe": + try: + pids.append(int(parts[1])) + except ValueError: + pass + if not pids: + return 0 + for pid in pids: + try: + subprocess.run( + ["taskkill", "/F", "/PID", str(pid)], + capture_output=True, timeout=5, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except Exception: + pass + # 等 Npcap 句柄随进程退出由内核回收 + deadline = time.time() + 3.0 + while time.time() < deadline: + try: + again = subprocess.check_output( + ["tasklist", "/FI", "IMAGENAME eq ecat_native.exe", + "/FO", "CSV", "/NH"], + text=True, stderr=subprocess.DEVNULL, timeout=4, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) or "" + if "ecat_native.exe" not in (again or "").lower(): + break + except Exception: + break + time.sleep(0.2) + else: + time.sleep(0.3) + return len(pids) + + +def autostart(): + """按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。 + + 单实例策略(2026-09-12,R3 加固 2026-09-23): + - 跨进程命名互斥锁串行化 kill/start(GUI 进程与服务子进程共用)。 + - **先探测健康监听实例并复用**,绝不 kill 对方刚拉起的 native。 + - 本进程子进程仍存活 → 只等它听口,**绝不 kill_all**(否则启动中的实例 + 会被自己下一次请求杀掉,形成 8s kill_stale 死循环)。 + - 子进程已死 / 从未拉起且无健康监听 → 先 taskkill 全部残留 ecat_native,再起唯一实例。 + port 为 None 表示拉不起来。 + """ + global g_ecat_child, g_ecat_log_fp + if not ECAT_NATIVE_EXE: + return None, ["ecat_proc.init 未调用(BASE_DIR 未注入)"] + with _EcatProcXLock(): + with g_ecat_lock: + logs = [] + + # ── R3: 另一进程(GUI/服务)已在跑的健康实例 → 复用 ── + reuse = _ecat_reuse_listening_native() + if reuse: + ecat_client.g_ecat_port = reuse + return reuse, [f"复用已在监听的 EtherCAT native (端口 {reuse})"] + + # ── 我们自己拉起的还活着:只复用/等它,不杀 ── + if g_ecat_child is not None and g_ecat_child.poll() is None: + port = ecat_client._ecat_find_port() + if port: + ecat_client.g_ecat_port = port + return port, [] + # 仍在启动(kill_stale/绑口最多约 10s):等完再判 + deadline = time.time() + 10 + while time.time() < deadline: + if g_ecat_child.poll() is not None: + break + port = ecat_client._ecat_find_port() + if port: + ecat_client.g_ecat_port = port + return port, [] + time.sleep(0.3) + if g_ecat_child.poll() is None: + return None, logs + ["EtherCAT 服务仍在启动中(未监听端口),请稍候再试"] + g_ecat_child = None + + if g_ecat_child is not None and g_ecat_child.poll() is not None: + g_ecat_child = None + + if not os.path.isfile(ECAT_NATIVE_EXE): + return None, [ + f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}", + "请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。", + ] + + # 无健康监听才清残留:禁止双实例抢网卡 + killed = _kill_all_ecat_native() + if killed: + logs.append(f"已清掉 {killed} 个残留 ecat_native(单实例,防抢网卡)") + + try: + # 日志落文件而不是 DEVNULL:native 的 stdout 是崩溃/楔死/自动重拉的 + # 唯一现场(WEDGE、kill_stale、进 OP 全打这里),DEVNULL 吞掉后只能靠 + # Windows 事件日志反推(2026-09-12 教训)。追加写,重启服务不清零。 + if g_ecat_log_fp is None: + g_ecat_log_fp = open( + os.path.join(ECAT_DIR, "build_native", "native_stdout.log"), + "ab", buffering=0) + g_ecat_child = subprocess.Popen( + [ECAT_NATIVE_EXE, "--substr", ECAT_DEFAULT_ADAPTER, + "--mask", "0x50", + "--web", "src/web", "--parent-pid", str(os.getpid())], + cwd=ECAT_DIR, + stdout=g_ecat_log_fp, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})") + if _set_ecat_realtime(g_ecat_child.pid): + logs.append(f"EtherCAT 进程优先级已提到 RealTime(pid {g_ecat_child.pid})") + else: + logs.append("EtherCAT 进程 RealTime 提权失败(保持默认/High, 可手动设)") + + except OSError as e: + return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"] + + deadline = time.time() + 12 + while time.time() < deadline: + if g_ecat_child.poll() is not None: + return None, logs + [ + f"EtherCAT 守护进程启动后立刻退出(exit={g_ecat_child.returncode}) —— " + "多为 Npcap 未安装或 8090..8095 全被占; " + "可双击 tools/ecat/ecat_web.bat 看具体报错"] + port = ecat_client._ecat_find_port() + if port: + ecat_client.g_ecat_port = port + logs.append(f"EtherCAT 服务已就绪(端口 {port})") + return port, logs + time.sleep(0.3) + return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"] + + +# 旧名兼容(server / 脚本) +_ecat_autostart = autostart + + +def watch_parent(ppid): + """兜底看门狗(Job Object 万一不可用时): 父 GUI 进程消失就自我了断, + 避免窗口关了服务还在后台占端口。""" + def _loop(): + k32 = ctypes.windll.kernel32 + h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE + if not h: + return + k32.WaitForSingleObject(h, 0xFFFFFFFF) # 父进程退出即返回 + os._exit(0) + threading.Thread(target=_loop, daemon=True).start() + + +_watch_parent = watch_parent diff --git a/esp32_server.py b/esp32_server.py index af9817a..f111746 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -12,8 +12,8 @@ 停 EtherCAT PDO 循环并从站退回 INIT(见 _app_lease 一节) POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出) POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径> - GET /api/motor/ui-state → 读电机页配置快照(data/motor_ui_state.json) - POST /api/motor/ui-state → 写电机页配置快照到磁盘(关页/改动时前端自动刷) + GET/POST /api/motor/ui-state → 读/写电机页配置快照(data/motor_ui_state.json) + GET/POST /api/lab/ui-state → 读/写 lab/spi_test 配置快照 为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输, 后者的后端 tools/ecat 的 EtherCAT 服务只绑 127.0.0.1 且完全没有 CORS @@ -22,6 +22,18 @@ 因此页面无法直接跨端口调它。改那边要动另一个 git 仓库, 把页面搬过去又会让 WiFi/ECAT 分裂成两份页面, 所以在本服务做同源转发, 两个仓库都不用改。 +模块边界(P2 / R8–R11,2026-09-23): + - ecat_client.py 端口探测、裸 socket HTTP、ESF1 快发、路径归一化、代理 + - ecat_proc.py ecat_native 进程治理(跨进程锁 / autostart / kill / RealTime) + - 本文件 HTTP/静态/lease/窗口/GuiApi;对脚本 re-export 常用符号 + +桌面 vs 浏览器双路径(R10)——最终同 ecat_client,不删桥: + - 浏览器 = HTTP 代理:页面 fetch POST /ecat/api/* → Handler → ecat_client.ecat_proxy + - 桌面 APP = GuiApi 桥:JS pywebview.api.ecat_api → _GuiApi.ecat_api + → ecat_client.api_call(不再套一层 proxy HTTP) + 两者共用 normalize_ecat_api_path + _ecat_post。桌面桥少一跳 HTTP; + 浏览器没有 pywebview.api,只能走同源代理。 + 目录约定: tabs.html 在根(工具入口), 功能页在 pages/, 自包含实验页在 lab/。 启动模式: @@ -42,9 +54,7 @@ import ctypes import hashlib import json import os -import re import socket -import struct import subprocess import sys import threading @@ -53,8 +63,40 @@ import urllib.request import webbrowser from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +# BASE_DIR 只在此定义一次(R11);ecat_proc 路径由 init(BASE_DIR) 注入。 BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# EtherCAT 客户端 / 进程治理模块(R8) +import ecat_client +import ecat_proc + +ecat_proc.init(BASE_DIR) + +# ── 兼容 re-export:脚本/测试从 esp32_server 取这些符号 ────────────── +from ecat_client import ( # noqa: E402 + ECAT_PORTS, + ECAT_PROXY_PREFIX, + ESF_FRAME_W, + ESF_MAGIC, + FSOK_MAGIC, + _ecat_find_port, + _ecat_post, + _ecat_proxy, + ecat_proxy, + ecat_send_raw_frames, + normalize_ecat_api_path, + pack_esf, + unpack_fsok, +) +from ecat_proc import ( # noqa: E402 + ECAT_DEFAULT_ADAPTER, + ECAT_DIR, + ECAT_NATIVE_EXE, + _kill_all_ecat_native, + _watch_parent, + autostart as _ecat_autostart, +) + # 电机页配置落盘: CMD 表单 / 命令序列 / 刷新速度 / 链路与 EtherCAT 偏好等。 # 存 JSON 而不是只靠 localStorage —— WebView2/清缓存后 localStorage 会没, # 用户要的「关掉之后还在电脑上」必须是真文件。路径固定在工具目录下, 便于备份。 @@ -65,6 +107,12 @@ UI_STATE_REL = "data/motor_ui_state.json" LAB_UI_STATE_PATH = os.path.join(BASE_DIR, "data", "spi_test_ui_state.json") LAB_UI_STATE_REL = "data/spi_test_ui_state.json" +# R11: ui-state 双路由一张表驱动 GET/POST(路径 → (绝对路径, 相对路径)) +UI_STATE_ROUTES = { + "/api/motor/ui-state": (UI_STATE_PATH, UI_STATE_REL), + "/api/lab/ui-state": (LAB_UI_STATE_PATH, LAB_UI_STATE_REL), +} + # 单实例互斥: 已有实例时, 新双击不创建第二个窗口(会隐形/端口漂移), # 而是通过 /api/focus 让旧实例的窗口到前台。 g_mutex = None @@ -131,341 +179,26 @@ user-select:none;opacity:.55;transition:opacity .15s} _RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html") _RELOAD_MARK = "" -# BASE_DIR 在文件头已定义(L56),此处不得重复绑定 - g_httpd = None g_child = None g_port = None g_win = None g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程 -# ── EtherCAT 后端反向代理 ────────────────────────────────────────────── -# 电机页的 EtherCAT 传输要打 tools/ecat 的 EtherCAT 服务(native)的 /api/motor/*, -# 但那个服务**只绑 127.0.0.1 且完全没有 CORS**(无 Access-Control-Allow-Origin, -# 也没有 do_OPTIONS —— 预检直接 501)。而我们的 API 是 POST + JSON, -# 属非简单请求, 浏览器必发预检 → 从本页面(:2625)直接 fetch 它(:8090)必然失败。 -# -# 三种解法里选反向代理: -# 1) 改 EtherCAT 后端加 CORS —— 那是另一个仓库, 不该为本页面改; -# 2) 把电机页搬进 EtherCAT 后端的静态白名单 —— 且会让 -# WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求; -# 3) 本服务代理 —— 页面永远同源(:2625), 两个仓库都不用动。 -# 端口自动探测: EtherCAT 服务(含 native)会在 8090..8095 间漂移。 -ECAT_PROXY_PREFIX = "/ecat/api/" -ECAT_PORTS = (8090, 8091, 8092, 8093, 8094, 8095) -g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 8090 试起 - -# ── EtherCAT 服务自动启动 ───────────────────────────────────────────── -# 电机页切到 EtherCAT 链路时, 不该要求用户先手动起 EtherCAT 后端。 -# 本服务在第一次代理请求时按需拉起 tools/ecat 的服务进程。 -# -# 阶段 4 切换(2026-09-04): 后端从 Python(ecat_server.py, 已彻底删除含备份) -# 换成原生守护进程 build_native\ -# ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python -# 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约): -# 1) **单实例、后起独占**(2026-09-12 收紧): 只有本进程拉起且仍存活的子进程才复用; -# 起新进程前 taskkill 全部残留 ecat_native.exe。两个实例并存会抢 Npcap -# (后起的抓不到从站、HTTP 超时、PDO 空转)。native 启动时也会 kill_stale_instances, -# 与本处强杀双重保险。 -# 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断, -# 不留孤儿进程占着 Npcap 适配器和端口。 -# 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。 -def _resolve_ecat_dir(base_dir): - """定位 tools/ecat(ecat_native.exe 的仓库,与本仓库平级)。 - - 从 git worktree(.../tools/ESP32/.worktrees/)启动时,BASE_DIR 的 - 上一级是 .worktrees 而不是 tools,直接 join("..","ecat") 会拼成 - .../ESP32/.worktrees/ecat,找不到 exe。先走回主检出再取兄弟目录。 - """ - d = os.path.abspath(base_dir) - parent = os.path.dirname(d) - if os.path.basename(parent) == ".worktrees": - d = os.path.dirname(parent) # → .../tools/ESP32 主检出 - return os.path.normpath(os.path.join(d, "..", "ecat")) - - -ECAT_DIR = _resolve_ecat_dir(BASE_DIR) -ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe") -# 网卡默认子串:与 js/common/device-link.js / pages/motor.html 保持一致(R2) -ECAT_DEFAULT_ADAPTER = "I210" -g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None) -g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释) -g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程 -# R3: 跨进程互斥(GUI 进程 + 服务子进程都可能 autostart/kill native) -_ECAT_XPROC_MUTEX_NAME = "Local\\DCU_Esp32Tools_EcatNativeProc" - - -class _EcatProcXLock: - """Windows 命名互斥锁:串行化所有会 kill/拉起 ecat_native 的路径。""" - - def __init__(self, timeout_ms=60000): - self.timeout_ms = timeout_ms - self._handle = None - self._owned = False - - def __enter__(self): - k32 = ctypes.WinDLL("kernel32", use_last_error=True) - self._handle = k32.CreateMutexW(None, False, _ECAT_XPROC_MUTEX_NAME) - if not self._handle: - return self - r = k32.WaitForSingleObject(self._handle, self.timeout_ms) - # WAIT_OBJECT_0=0, WAIT_ABANDONED=0x80 - self._owned = r in (0, 0x80) - return self - - def __exit__(self, *exc): - if not self._handle: - return False - k32 = ctypes.WinDLL("kernel32", use_last_error=True) - try: - if self._owned: - k32.ReleaseMutex(self._handle) - finally: - k32.CloseHandle(self._handle) - self._handle = None - return False - - -def _ecat_reuse_listening_native(): - """端口上已有健康 native → 直接复用(可能是另一进程刚拉起的),不 kill。""" - 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 not _port_taken(p): - continue - try: - status, data = _ecat_post( - p, "/api/native/health", b"{}", 1.0, - connect_timeout=0.4) - if status == 200: - return p - except Exception: - continue - return None - - -# ── 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(" 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 回落)"], - } - - -def _set_ecat_realtime(pid: int) -> bool: - """把 ecat_native 提到 RealTime 进程优先级。 - - native 自己 SetPriorityClass(RT) 会被系统静默钳成 High(令牌无 - SeIncreaseBasePriorityPrivilege)。由本服务(启动方)从外部设置才能粘住。 - pdo 线程已是 TIME_CRITICAL:进程类 High→线程 15,RealTime→线程 31, - 后者才扛得住桌面 DPC/调度抢占。实测 1ms 档 late ~12/s → 个位数。 - 失败返回 False,不影响启动。 - """ - try: - import ctypes - import ctypes.wintypes as wt - PROCESS_SET_INFORMATION = 0x0200 - REALTIME_PRIORITY_CLASS = 0x00000100 - HIGH_PRIORITY_CLASS = 0x00000080 - k32 = ctypes.WinDLL("kernel32", use_last_error=True) - h = k32.OpenProcess(PROCESS_SET_INFORMATION, False, int(pid)) - if not h: - return False - try: - if k32.SetPriorityClass(h, REALTIME_PRIORITY_CLASS): - return True - return bool(k32.SetPriorityClass(h, HIGH_PRIORITY_CLASS)) - finally: - k32.CloseHandle(h) - except Exception: - return False - class _GuiApi: """暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。 - 形态约定: + 形态约定(R10,见模块 docstring): - 网页/浏览器标签:无 pywebview,电机页 EtherCAT 走 HTTP /ecat/api/* 代理。 - - 桌面 APP (esp32_app.vbs --window):JS 调本类 ecat_api,GUI 进程用与 - 代理同款的裸 socket 直连 ecat_native —— 省掉「浏览器 fetch → 服务子进程 - → 代理」两跳。iframe 页从 window.top.pywebview.api 取(同源)。 + - 桌面 APP (esp32_app.vbs --window):JS 调本类 ecat_api → ecat_client.api_call, + 与代理同一套路径归一化 + 同一 _ecat_post,不再在桥内再造一层 proxy HTTP。 + iframe 页从 window.top.pywebview.api 取(同源)。 """ def page_loaded(self): """页面每次加载完成后由注入脚本调用: 把窗口带到前台。""" - win = g_win - if win is not None: - try: - win.show() - except Exception: - pass - h = _native_handle(win) if win is not None else None - if h is not None: - _win32_foreground(h) + bring_window_foreground(g_win) def reload_page(self): win = g_win @@ -477,22 +210,22 @@ class _GuiApi: def bridge_info(self): """前端探测桌面桥是否可用。""" - if g_ecat_port or g_fast_send_port == 0: + if ecat_client.g_ecat_port or ecat_client.g_fast_send_port == 0: try: - _discover_fast_send_port(0.8) + ecat_client._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, + "ecat_port": ecat_client.g_ecat_port, + "has_fast_send": bool(ecat_client.g_fast_send_port), + "fast_send_port": ecat_client.g_fast_send_port, } def ecat_send_raw(self, frames, timeout_ms=2000): - """桌面桥 PDO 快发:JS → 本进程 ESF1 打包 → native 入队。 + """桌面桥 PDO 快发:JS → ecat_client ESF1 打包 → native 入队。 frames: [{ch, id, dlc, data(hex)}];也可传 {"frames":[...]}。 返回 {"ok","queued","backlog","path","log"},path 为实际走的通道。 @@ -506,55 +239,16 @@ class _GuiApi: "log": [f"原生桥快发异常: {type(e).__name__}: {e}"]} def ecat_api(self, path, body=None, timeout_ms=4000): - """桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。 + """桌面壳原生桥:JS → GUI 进程 → ecat_client → ecat_native。 + + R10: 内部直接调 ecat_client.api_call(同一路径归一化 + 同一代理核心), + 不经浏览器 HTTP,也不在桥内再造一层 proxy 转发。 path: "motor/send" | "/api/motor/send" | "/ecat/api/motor/send" body: dict / JSON 字符串 / 省略 返回与 native 同构的 dict。 """ - 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/"):] - 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 # /ecat/api/ - - if body is None: - raw = b"{}" - elif isinstance(body, (bytes, bytearray)): - raw = bytes(body) - elif isinstance(body, str): - raw = body.encode("utf-8") - else: - try: - raw = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - except Exception as e: - return {"ok": False, "log": [f"原生桥 body 序列化失败: {type(e).__name__}: {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 + return ecat_client.api_call(path, body, timeout_ms) def _focus_existing(port): @@ -569,334 +263,6 @@ def _focus_existing(port): return False -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 - - -def _kill_all_ecat_native(): - """强杀所有 ecat_native.exe,返回杀掉的个数。 - - 两个 native 并存会抢 Npcap 适配器:后起的抓不到从站、HTTP 超时、 - PDO 空转。启动新实例前必须保证旧的死透。只按进程名杀,不碰 python。 - """ - try: - out = subprocess.check_output( - ["tasklist", "/FI", "IMAGENAME eq ecat_native.exe", - "/FO", "CSV", "/NH"], - text=True, stderr=subprocess.DEVNULL, timeout=8, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) or "" - except Exception: - return 0 - pids = [] - for line in (out or "").splitlines(): - parts = [p.strip().strip('"') for p in line.split(",")] - if len(parts) >= 2 and parts[0].lower() == "ecat_native.exe": - try: - pids.append(int(parts[1])) - except ValueError: - pass - if not pids: - return 0 - for pid in pids: - try: - subprocess.run( - ["taskkill", "/F", "/PID", str(pid)], - capture_output=True, timeout=5, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - except Exception: - pass - # 等 Npcap 句柄随进程退出由内核回收 - deadline = time.time() + 3.0 - while time.time() < deadline: - try: - again = subprocess.check_output( - ["tasklist", "/FI", "IMAGENAME eq ecat_native.exe", - "/FO", "CSV", "/NH"], - text=True, stderr=subprocess.DEVNULL, timeout=4, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) or "" - if "ecat_native.exe" not in (again or "").lower(): - break - except Exception: - break - time.sleep(0.2) - else: - time.sleep(0.3) - return len(pids) - - -def _ecat_autostart(): - """按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。 - - 单实例策略(2026-09-12,R3 加固 2026-09-23): - - 跨进程命名互斥锁串行化 kill/start(GUI 进程与服务子进程共用)。 - - **先探测健康监听实例并复用**,绝不 kill 对方刚拉起的 native。 - - 本进程子进程仍存活 → 只等它听口,**绝不 kill_all**(否则启动中的实例 - 会被自己下一次请求杀掉,形成 8s kill_stale 死循环)。 - - 子进程已死 / 从未拉起且无健康监听 → 先 taskkill 全部残留 ecat_native,再起唯一实例。 - port 为 None 表示拉不起来。 - """ - global g_ecat_child, g_ecat_port, g_ecat_log_fp - with _EcatProcXLock(): - with g_ecat_lock: - logs = [] - - # ── R3: 另一进程(GUI/服务)已在跑的健康实例 → 复用 ── - reuse = _ecat_reuse_listening_native() - if reuse: - g_ecat_port = reuse - return reuse, [f"复用已在监听的 EtherCAT native (端口 {reuse})"] - - # ── 我们自己拉起的还活着:只复用/等它,不杀 ── - if g_ecat_child is not None and g_ecat_child.poll() is None: - port = _ecat_find_port() - if port: - g_ecat_port = port - return port, [] - # 仍在启动(kill_stale/绑口最多约 10s):等完再判 - deadline = time.time() + 10 - while time.time() < deadline: - if g_ecat_child.poll() is not None: - break - port = _ecat_find_port() - if port: - g_ecat_port = port - return port, [] - time.sleep(0.3) - if g_ecat_child.poll() is None: - return None, logs + ["EtherCAT 服务仍在启动中(未监听端口),请稍候再试"] - g_ecat_child = None - - if g_ecat_child is not None and g_ecat_child.poll() is not None: - g_ecat_child = None - - if not os.path.isfile(ECAT_NATIVE_EXE): - return None, [ - f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}", - "请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。", - ] - - # 无健康监听才清残留:禁止双实例抢网卡 - killed = _kill_all_ecat_native() - if killed: - logs.append(f"已清掉 {killed} 个残留 ecat_native(单实例,防抢网卡)") - - try: - # 日志落文件而不是 DEVNULL:native 的 stdout 是崩溃/楔死/自动重拉的 - # 唯一现场(WEDGE、kill_stale、进 OP 全打这里),DEVNULL 吞掉后只能靠 - # Windows 事件日志反推(2026-09-12 教训)。追加写,重启服务不清零。 - if g_ecat_log_fp is None: - g_ecat_log_fp = open( - os.path.join(ECAT_DIR, "build_native", "native_stdout.log"), - "ab", buffering=0) - g_ecat_child = subprocess.Popen( - [ECAT_NATIVE_EXE, "--substr", ECAT_DEFAULT_ADAPTER, - "--mask", "0x50", - "--web", "src/web", "--parent-pid", str(os.getpid())], - cwd=ECAT_DIR, - stdout=g_ecat_log_fp, stderr=subprocess.STDOUT, - stdin=subprocess.DEVNULL, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})") - if _set_ecat_realtime(g_ecat_child.pid): - logs.append(f"EtherCAT 进程优先级已提到 RealTime(pid {g_ecat_child.pid})") - else: - logs.append("EtherCAT 进程 RealTime 提权失败(保持默认/High, 可手动设)") - - except OSError as e: - return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"] - - deadline = time.time() + 12 - while time.time() < deadline: - if g_ecat_child.poll() is not None: - return None, logs + [ - f"EtherCAT 守护进程启动后立刻退出(exit={g_ecat_child.returncode}) —— " - "多为 Npcap 未安装或 8090..8095 全被占; " - "可双击 tools/ecat/ecat_web.bat 看具体报错"] - port = _ecat_find_port() - if port: - g_ecat_port = port - logs.append(f"EtherCAT 服务已就绪(端口 {port})") - return port, logs - time.sleep(0.3) - return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"] - - -# ── R1: 代理重发策略 ──────────────────────────────────────────────────── -# 连接失败后 autostart 再打一次同一 POST,对写路径 = 可能二次执行。 -# 仅对明确只读/可安全重试的路由允许 autostart 后自动重发一次。 -_ECAT_IDEMPOTENT_TAILS = frozenset({ - "status", "native/health", "app/load", "motor/state", "motor/rx", - "motor/version", "motor/dbg", "load", -}) - - -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) - - -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)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port: - EtherCAT 后端会在 8090..8095 之间漂移(端口被占就挪), - 写死 8090 会在它挪了之后表现为"页面点了没反应"。 - - 连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封, - 这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。 - - 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) - target = "/api/" + path[len(ECAT_PROXY_PREFIX):] - 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 # 缓存端口失效, 下轮重新全量探测 - # R1: 非幂等请求一旦可能已到达旧实例(超时/非连接拒绝), - # 禁止再打其它端口——否则 flash/motor/send 等会被重复执行。 - if not idem and not _ecat_connect_refused(e): - break - - # 无存活端口或全失败 → 单实例拉起;**非幂等路径禁止自动重发** - start_logs = [] - try: - port, start_logs = _ecat_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 - - # ── app 窗口租约: "关掉 app 就必须停掉从站" 的兜底 ──────────────────────── # PDO 循环起来后是在 ecat_native 里一直跑的: 从站侧 1000ms 过程数据看门狗 # 要求持续喂, 而 native 的自清理只覆盖"我拉起的进程死掉"这一种情况。用户 @@ -962,7 +328,11 @@ def _schedule_loop_release(attempt=0): def _ecat_stop_motor_loop(attempt, why="app 全部窗口已关闭"): - """经代理让后端停循环 + 从站退回 INIT。幂等, 失败最多再试 2 轮。""" + """经代理让后端停循环 + 从站退回 INIT。幂等, 失败最多再试 2 轮。 + + 注意:这里用模块全局名 _ecat_find_port / _ecat_post(可被测试 patch), + 不要用 ecat_client._ecat_post——租约测试注入点在 esp32_server 命名空间。 + """ global g_ecat_motor_live with g_app_lock: if not g_ecat_motor_live: @@ -1033,13 +403,15 @@ def _note_ecat_loop(path, status, body): """代理透传时顺手记下"循环跑没跑", 免得租约空了去停别人的或白停一次。 判定只针对低频路由(motor/state 那种高频轮询不在表内, 不进这里分支)。 + 路径归一化与桥/代理共用(R9)。 """ global g_ecat_motor_live - tail = path[len(ECAT_PROXY_PREFIX):] + _proxy_path, target = normalize_ecat_api_path(path) + tail = target[len("/api/"):] if target.startswith("/api/") else target.lstrip("/") if tail == "motor/start": - target, flag = True, True + target_flag, flag = True, True elif tail in ("motor/stop", "op/stop", "op/start", "open", "close", "shutdown"): - target, flag = True, False + target_flag, flag = True, False else: return if status == 200 and _json_ok(body): # 后端 200 + ok:true 才算数 @@ -1066,11 +438,10 @@ class Handler(SimpleHTTPRequestHandler): if path == APP_LEASE_PATH: self._handle_ws_app_lease() return - if path == "/api/motor/ui-state": - self._handle_ui_state_get() - return - if path == "/api/lab/ui-state": - self._handle_lab_ui_state_get() + # R11: ui-state 双路由一张表驱动 + if path in UI_STATE_ROUTES: + p, rel = UI_STATE_ROUTES[path] + self._handle_ui_state_get(p, rel) return # 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳 if path in ("/", "/index.html"): @@ -1119,14 +490,13 @@ class Handler(SimpleHTTPRequestHandler): self._post_json(body) threading.Thread(target=_shutdown_self, daemon=True).start() return - if path_l == "/api/motor/ui-state": - self._handle_ui_state_post() - return - if path_l == "/api/lab/ui-state": - self._handle_lab_ui_state_post() + # R11: ui-state 双路由一张表驱动 + if path_l in UI_STATE_ROUTES: + p, rel = UI_STATE_ROUTES[path_l] + self._handle_ui_state_post(p, rel) return if path.startswith(ECAT_PROXY_PREFIX): - # 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 _ecat_proxy 注释)。 + # 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 ecat_client)。 # 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次, # 实测最坏 ~24s, 用默认 6s 会在正常进 OP 的过程中假失败。 try: @@ -1142,9 +512,9 @@ class Handler(SimpleHTTPRequestHandler): # 却还在往 flash 里写, 比不刷更糟。同一路由下的 open/push 都是纯内存 # 毫秒级, 不连它们一起放大(后端没起时探测会干等几分钟), 故按 body 区分。 # probe/reboot 走 boot_dl_probe 的两级邮箱自愈: 最坏 2×2s statecheck + - # 重枚举 + 三次 700ms SDO 读 ≈ 7s+。这里必须给足, 因为 _ecat_proxy 超时 - # 后会拉起后端并**把同一个 POST 重发一次**(见其尾部重试), 带副作用的请求 - # 假超时 = 让后端把写过的命令再写一遍。 + # 重枚举 + 三次 700ms SDO 读 ≈ 7s+。这里必须给足——超时后 ecat_proxy + # 对非幂等路径**不会**重发(P0/R1),只回 503 请用户重试;假超时仍会 + # 干扰刷写进度判断。 if path.endswith("/ota/ecat"): timeout = 300.0 if b'"run"' in raw else 60.0 try: @@ -1226,19 +596,11 @@ class Handler(SimpleHTTPRequestHandler): except OSError: pass - def _handle_ui_state_get(self): - """电机页配置快照 GET。有文件 → ok+state;无 → ok+state:null。""" - st = _read_ui_state(UI_STATE_PATH) + def _handle_ui_state_get(self, path, rel): + """配置快照 GET(R11:motor/lab 共用,表驱动)。""" + st = _read_ui_state(path) body = json.dumps( - {"ok": True, "state": st, "path": UI_STATE_REL}, - ensure_ascii=False).encode("utf-8") - self._post_json(body) - - def _handle_lab_ui_state_get(self): - """lab/spi_test 配置快照 GET。""" - st = _read_ui_state(LAB_UI_STATE_PATH) - body = json.dumps( - {"ok": True, "state": st, "path": LAB_UI_STATE_REL}, + {"ok": True, "state": st, "path": rel}, ensure_ascii=False).encode("utf-8") self._post_json(body) @@ -1275,13 +637,9 @@ class Handler(SimpleHTTPRequestHandler): return self._post_json(json.dumps(result, ensure_ascii=False).encode("utf-8")) - def _handle_ui_state_post(self): - """电机页配置快照 POST → data/motor_ui_state.json。""" - self._write_ui_state_route(UI_STATE_PATH, UI_STATE_REL) - - def _handle_lab_ui_state_post(self): - """lab/spi_test 配置快照 POST → data/spi_test_ui_state.json。""" - self._write_ui_state_route(LAB_UI_STATE_PATH, LAB_UI_STATE_REL) + def _handle_ui_state_post(self, path, rel): + """配置快照 POST(R11:motor/lab 共用,表驱动)。""" + self._write_ui_state_route(path, rel) def _post_json(self, body, status=200): self.send_response(status) @@ -1307,34 +665,30 @@ def _shutdown_self(): pass -def _win32_foreground(native_handle): - """SetForegroundWindow: pywebview Window 没有 focus(), 要走 Win32。""" +def bring_window_foreground(win): + """把 pywebview 窗口恢复并置顶。前台焦点唯一入口(R11 去重)。""" + if win is None: + return try: - hwnd = int(native_handle) + win.show() + except Exception: + pass + hwnd = None + try: + hwnd = win.native.Handle # WinForms Form + except Exception: + try: + hwnd = win.native + except Exception: + return + try: + hwnd = int(hwnd) ctypes.windll.user32.ShowWindow(hwnd, 9) # SW_RESTORE ctypes.windll.user32.SetForegroundWindow(hwnd) except Exception: pass -def _native_handle(win): - try: - return win.native.Handle # WinForms Form - except Exception: - try: - return win.native - except Exception: - return None - - -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 - - class QuietServer(ThreadingHTTPServer): # 客户端中途断开(刷新/关窗)会触发 handle_error 打 traceback 到 stderr; # 窗口模式下 stderr 是父进程的 PIPE, 4KB 缓冲一满子进程写日志即永久阻塞 @@ -1357,17 +711,16 @@ def _bind_server(port): return None, port -def _watch_parent(ppid): - """兜底看门狗(Job Object 万一不可用时): 父 GUI 进程消失就自我了断, - 避免窗口关了服务还在后台占端口。""" - def _loop(): - k32 = ctypes.windll.kernel32 - h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE - if not h: - return - k32.WaitForSingleObject(h, 0xFFFFFFFF) # 父进程退出即返回 - os._exit(0) - threading.Thread(target=_loop, daemon=True).start() +def _port_taken(port): + """Windows 的 SO_REUSEADDR 允许同端口重复 bind(实测会双监听、连接随机落), + 故先用一次真实连接探测占用, 不能只靠 bind 是否抛错。 + + 与 ecat_client._port_taken 同语义;本处保留供 _bind_server 使用, + 并作为可 patch 的 server 命名空间符号。 + """ + 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 _run_browser(args): @@ -1579,7 +932,7 @@ def _run_window(webview, args): def _monitor(): while True: if focus_evt.is_set(): - _gui_bringToFront() # 新双击 → 旧窗口到前台 + bring_window_foreground(g_win) # 新双击 → 旧窗口到前台 focus_evt.clear() if g_child.poll() is not None: os._exit(0) # 子进程没了 → GUI 一起退 @@ -1599,13 +952,7 @@ def _run_window(webview, args): time.sleep(0.3) break time.sleep(0.4) - try: - g_win.show() - except Exception: - pass - h = _native_handle(g_win) - if h is not None: - _win32_foreground(h) + bring_window_foreground(g_win) threading.Thread(target=_show_watchdog, daemon=True).start() webview.start() @@ -1613,20 +960,6 @@ def _run_window(webview, args): os._exit(0) -def _gui_bringToFront(): - """把 GUI 窗口恢复/置顶(从子进程 monitor 线程调用)。""" - win = g_win - if win is None: - return - try: - win.show() - except Exception: - pass - h = _native_handle(win) - if h is not None: - _win32_foreground(h) - - def main(): ap = argparse.ArgumentParser(description="ESP32 调试控制台页面服务器") ap.add_argument("--port", type=int, default=2625) diff --git a/scripts/test_app_lease.py b/scripts/test_app_lease.py index d8358aa..d7fb9a2 100644 --- a/scripts/test_app_lease.py +++ b/scripts/test_app_lease.py @@ -17,13 +17,14 @@ import urllib.request sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import esp32_server as srv # noqa: E402 +import ecat_client # noqa: E402 # P2: _ecat_post/_ecat_find_port 实现在此模块 WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" calls = [] # 假 _ecat_post 记录到的请求路径 calls_lock = threading.Lock() -def fake_post(port, path, body, timeout): +def fake_post(port, path, body, timeout, **kwargs): with calls_lock: calls.append(path) # 带空格的 "ok": true —— 故意与真 native 的紧凑格式不同, @@ -89,8 +90,12 @@ def wait_flag(want, timeout=8.0): def main(): # 加速测试: GRACE 从 3s 缩到 0.3s(重试轮次间隔同用该值, 一并缩短无妨) srv.APP_LEASE_GRACE_S = 0.3 + # P2 拆分后:lease 停循环路径用 esp32_server 命名空间; + # HTTP 代理路径在 ecat_client 内部解析 _ecat_post —— 两处都要 patch。 srv._ecat_post = fake_post srv._ecat_find_port = lambda: 9999 + ecat_client._ecat_post = fake_post + ecat_client._ecat_find_port = lambda: 9999 # 找空闲端口起真服务 with socket.socket() as probe: