Files
DCU_DesktopController/esp32_server.py
T
cbb 67dd1702f0 fix(server): P0 代理非幂等不重发 + 跨进程 native 互斥复用 + 默认网卡 I210
R1: autostart 后禁止自动重发 flash/motor/send 等副作用路径;
    超时类失败也不再串打其它端口。
R2: README 默认网卡改为与代码一致的 I210。
R3: 命名互斥锁 + 先复用健康监听实例,避免 GUI/服务子进程互相 kill。
2026-09-21 02:14:16 +08:00

1654 lines
69 KiB
Python
Raw 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
"""esp32_server.py — ESP32 调试控制台的本地页面服务器 + 桌面窗口外壳。
本目录的 HTML/CSS/JS 是纯前端: 页面本体由本服务器提供(同源相对路径),
而所有业务 API 由 ESP32 固件的 80 端口跨域提供(js/common/base-url.js 里的
设备 IP, 存 localStorage 的 dcu_esp_ip)。因此本脚本不碰任何硬件, 只做静态服务
—— 唯一例外是 /ecat/api/* 反向代理(见下)。
GET / → 302 到 /tabs.html (标签外壳, 工具主页)
GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/)
GET /ws/app-lease → app 窗口租约 WebSocket: 所有 app 窗口断开后自动
停 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 → 写电机页配置快照到磁盘(关页/改动时前端自动刷)
为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输,
后者的后端 tools/ecat 的 EtherCAT 服务只绑 127.0.0.1 且完全没有 CORS
(既无 Access-Control-Allow-Origin, 也没有 do_OPTIONS —— 预检直接 501)。
我们的 API 是 POST + application/json, 属非简单请求, 浏览器必发预检,
因此页面无法直接跨端口调它。改那边要动另一个 git 仓库, 把页面搬过去又会让
WiFi/ECAT 分裂成两份页面, 所以在本服务做同源转发, 两个仓库都不用改。
目录约定: tabs.html 在根(工具入口), 功能页在 pages/, 自包含实验页在 lab/。
启动模式:
--window pywebview 桌面窗口(esp32_app.vbs) —— GUI 进程 + 独立服务子进程
默认 浏览器标签页(esp32_web.vbs / serve.bat)
窗口模式为何要分两个进程: 实测 pythonw + pythonnet(pywebview 依赖) 环境下,
Python 后台线程会被静默杀死(py-spy 可见 serve 线程凭空消失, 页面随即失联)。
把 HTTP 服务放进独立子进程可彻底规避, 见 tools/ecat 窗口模式同款结构。
pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTML 响应注入
_reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。
"""
import argparse
import base64
import collections
import ctypes
import hashlib
import json
import os
import re
import socket
import struct
import subprocess
import sys
import threading
import time
import urllib.request
import webbrowser
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 电机页配置落盘: CMD 表单 / 命令序列 / 刷新速度 / 链路与 EtherCAT 偏好等。
# 存 JSON 而不是只靠 localStorage —— WebView2/清缓存后 localStorage 会没,
# 用户要的「关掉之后还在电脑上」必须是真文件。路径固定在工具目录下, 便于备份。
UI_STATE_PATH = os.path.join(BASE_DIR, "data", "motor_ui_state.json")
UI_STATE_REL = "data/motor_ui_state.json"
# lab/spi_test 观测面板配置(PDO 档位/采样率/开关按钮)。与电机页分文件,
# 避免任一页面整包覆盖时把对方字段抹掉。
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"
# 单实例互斥: 已有实例时, 新双击不创建第二个窗口(会隐形/端口漂移),
# 而是通过 /api/focus 让旧实例的窗口到前台。
g_mutex = None
# pywebview 窗口内的刷新能力: 悬浮按钮 + F5/Ctrl+R。浏览器 tab 打开时同样无害。
# __HOME__ 由模块加载时替换为工具主页路径(tabs.html 标签外壳)。
# 注入脚本对三种上下文自适应:
# 1) iframe 内的功能页(标签外壳承载): 移除悬浮按钮, 但仍覆写 window.open/_blank
# 让页面里的"新窗口打开"在 iframe 内跳转, 不弹系统浏览器;
# 2) 顶层标签外壳(tabs.html, 含 #viewHost 多 iframe 宿主): ⟳ 只刷新激活的
# .page-frame, 其它标签保活帧不销毁、不 reload;
# 3) 顶层独立页(浏览器直开某 html): ⟳ 刷新自身, 非主页时显示 ⌂ 回到标签外壳。
_RELOAD_SNIPPET = """
<style>#__rl,#__home{position:fixed;right:14px;width:34px;height:34px;z-index:99999;
border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(20,22,28,.72);
color:#9fb0c3;font-size:17px;line-height:32px;text-align:center;cursor:pointer;
user-select:none;opacity:.55;transition:opacity .15s}
#__rl{bottom:14px}#__home{bottom:56px;font-size:19px}
#__rl:hover,#__home:hover{opacity:1}#__rl:active{transform:rotate(90deg)}</style>
<div id="__rl" title="刷新当前页 (F5)">&#x21bb;</div>
<div id="__home" title="返回标签主页" style="display:none">&#x2302;</div>
<script>(function(){
var TOP=(window.top===window.self);
if(!TOP){var _r=document.getElementById('__rl'),_h=document.getElementById('__home');
if(_r)_r.remove(); if(_h)_h.remove();}
window.open=function(u){if(u)location.href=u;return null;};
document.addEventListener('click',function(e){
var a=e.target&&e.target.closest?e.target.closest('a[target=_blank]'):null;
if(a&&a.href){e.preventDefault();location.href=a.href;}
},true);
function reloadCur(){
if(TOP){
// 多 iframe 保活外壳:只 reload 激活帧。重载外壳会销毁全部保活文档。
var f=document.querySelector('.page-frame.active');
if(!f) f=document.getElementById('view');
if(f&&f.tagName==='IFRAME'){
try{f.contentWindow.location.reload();}catch(_){f.src=f.src;}
return;
}
}
location.reload();
}
window.addEventListener('keydown',function(e){
if(e.key==='F5'||(e.ctrlKey&&(e.key==='r'||e.key==='R'))){e.preventDefault();reloadCur();}
});
if(!TOP) return;
document.getElementById('__rl').addEventListener('click',reloadCur);
var home=document.getElementById('__home');
var p=location.pathname;
var isShell=!!document.getElementById('viewHost')||!!document.getElementById('view');
// 顶层且非标签外壳(浏览器直开 pages/xxx.html) → 给个 ⌂ 回到外壳。
// 判定基准是外壳自身(有 #viewHost / 路径为 tabs.html 或 /), 不能再看 index.html:
// 重排后 pages/index.html 只是普通功能页, 直开它同样该显示 ⌂。
if(!isShell&&!/\\/$/.test(p)&&!/\\/tabs\\.html$/.test(p)){
home.style.display='block';
home.addEventListener('click',function(){location.href='__HOME__';});
}
function ready(){try{window.pywebview&&window.pywebview.api&&
window.pywebview.api.page_loaded()}catch(e){}}
document.addEventListener('DOMContentLoaded',ready);
window.addEventListener('load',ready);
})();</script>
"""
_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html")
_RELOAD_MARK = "</body>"
# 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/<slug>)启动时,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("<IBBH", ESF_MAGIC, n, 0, 0))
for fr in items:
if not isinstance(fr, dict):
raise ValueError(f"帧必须是对象,收到 {type(fr).__name__}")
ch = int(fr.get("ch", 0))
dlc = int(fr.get("dlc", 0) or 0)
if ch < 0 or ch > 2:
raise ValueError(f"通道必须是 0/1/2,收到 {ch}")
if dlc < 0 or dlc > 64:
raise ValueError(f"DLC 超出 CANFD 上限 64: {dlc}")
fid = fr.get("id", 0)
if isinstance(fid, str):
fid = int(fid, 0)
data = _hex_to_bytes64(fr.get("data"))
out += struct.pack("<BBHI", ch & 0xFF, dlc & 0xFF, 0, int(fid) & 0xFFFFFFFF)
out += data
return bytes(out)
def unpack_fsok(resp8: bytes) -> dict:
if not resp8 or len(resp8) < 8:
return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK 响应过短"]}
magic, ok, queued, backlog = struct.unpack_from("<IBBH", resp8, 0)
if magic != FSOK_MAGIC:
return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK magic 不匹配"]}
return {
"ok": bool(ok),
"queued": int(queued),
"backlog": int(backlog),
"log": [] if ok else ["native 快发拒绝(未连接/解析失败)"],
}
def _note_fast_port(obj) -> None:
global g_fast_send_port
if isinstance(obj, dict):
p = obj.get("fast_send_port")
if isinstance(p, (int, float)) and int(p) > 0:
g_fast_send_port = int(p)
def _discover_fast_send_port(timeout=2.0, force=False) -> int:
global g_fast_send_port
if g_fast_send_port and not force:
return g_fast_send_port
try:
_status, data = _ecat_proxy(ECAT_PROXY_PREFIX + "native/health", b"{}", timeout)
obj = json.loads(data.decode("utf-8", "replace") or "{}")
_note_fast_port(obj)
if force and not isinstance(obj, dict):
g_fast_send_port = 0
except Exception:
if force:
g_fast_send_port = 0
return g_fast_send_port
def _close_fast_sock() -> None:
global g_fast_sock
with g_fast_sock_lock:
s = g_fast_sock
g_fast_sock = None
if s is not None:
try:
s.close()
except Exception:
pass
def _fast_sock_connect(port: int, timeout: float):
sock = socket.create_connection(("127.0.0.1", int(port)), timeout=None)
sock.settimeout(timeout)
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
return sock
def ecat_send_raw_frames(frames, timeout_ms=2000) -> dict:
"""ESF1 快发:**仅**持久 TCP → native fast_send_port。
2026-09-23 用户要求堵死慢发:删除 HTTP send_raw / JSON motor/send 回落。
失败即 ok:false,由前端暴露错误;不自动走慢路径。
持久连接上的 sendall/recv **必须整段持锁**,否则并发请求会把 FSOK
错配到别的帧。连接失败时清掉缓存端口并重探一次(仍只走 TCP)。
"""
global g_fast_sock, g_fast_send_port
try:
body = pack_esf(frames)
except Exception as e:
return {"ok": False, "queued": 0, "backlog": 0, "path": "none",
"log": [f"ESF 打包失败: {e}"]}
timeout = max(0.2, float(timeout_ms or 2000) / 1000.0)
def _tcp_once(port: int) -> dict:
nonlocal body, timeout
global g_fast_sock
r = None
with g_fast_sock_lock:
if g_fast_sock is None:
g_fast_sock = _fast_sock_connect(port, timeout)
sock = g_fast_sock
sock.sendall(body)
resp = b""
while len(resp) < 8:
chunk = sock.recv(8 - len(resp))
if not chunk:
raise OSError("快发口连接已关闭")
resp += chunk
r = unpack_fsok(resp)
r["path"] = "fast_tcp"
return r
last_err = None
for attempt in (0, 1):
port = _discover_fast_send_port(timeout, force=(attempt == 1))
if not port:
last_err = "native 无 fast_send_port(服务未起或版本过旧)"
break
try:
return _tcp_once(port)
except Exception as e:
last_err = f"快发口异常: {e}"
_close_fast_sock()
g_fast_send_port = 0
if attempt == 0:
continue
return {
"ok": False,
"queued": 0,
"backlog": 0,
"path": "fast_tcp",
"log": [last_err or "二进制快发失败(已禁用 HTTP/JSON 回落)"],
}
def _set_ecat_realtime(pid: int) -> bool:
"""把 ecat_native 提到 RealTime 进程优先级。
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 控制接口。
形态约定:
- 网页/浏览器标签:无 pywebview,电机页 EtherCAT 走 HTTP /ecat/api/* 代理。
- 桌面 APP (esp32_app.vbs --window):JS 调本类 ecat_api,GUI 进程用与
代理同款的裸 socket 直连 ecat_native —— 省掉「浏览器 fetch → 服务子进程
→ 代理」两跳。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)
def reload_page(self):
win = g_win
if win is not None:
try:
win.load_url(win.url) # pywebview 无 reload, 用重载当前 URL 代替
except Exception:
pass
def bridge_info(self):
"""前端探测桌面桥是否可用。"""
if g_ecat_port or g_fast_send_port == 0:
try:
_discover_fast_send_port(0.8)
except Exception:
pass
return {
"ok": True,
"mode": "desktop",
"has_ecat_bridge": True,
"ecat_port": g_ecat_port,
"has_fast_send": bool(g_fast_send_port),
"fast_send_port": g_fast_send_port,
}
def ecat_send_raw(self, frames, timeout_ms=2000):
"""桌面桥 PDO 快发:JS → 本进程 ESF1 打包 → native 入队。
frames: [{ch, id, dlc, data(hex)}];也可传 {"frames":[...]}。
返回 {"ok","queued","backlog","path","log"},path 为实际走的通道。
"""
if isinstance(frames, dict):
frames = frames.get("frames") or ([frames] if "id" in frames or "data" in frames else [])
try:
return ecat_send_raw_frames(frames, timeout_ms)
except Exception as e:
return {"ok": False, "queued": 0, "backlog": 0,
"log": [f"原生桥快发异常: {type(e).__name__}: {e}"]}
def ecat_api(self, path, body=None, timeout_ms=4000):
"""桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。
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/<tail>
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
def _focus_existing(port):
"""让已有实例的窗口到前台。返回 True 表示确实存在旧实例。"""
try:
urllib.request.urlopen(urllib.request.Request(
f"http://127.0.0.1:{port}/api/focus", data=b"{}",
headers={"Content-Type": "application/json"}, method="POST"),
timeout=2)
return True
except Exception:
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/<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)
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 的自清理只覆盖"我拉起的进程死掉"这一种情况。用户
# 最常遇到的离开方式恰恰不走那条路:
# - 浏览器模式(esp32_web.vbs / serve.bat): 关掉浏览器窗口后本服务还在,
# 循环就一直空转 —— 从站卡在 OP, 自旋节拍占满一个 P 核, 直到下次手工清场;
# - 窗口模式复用现成 native(如之前用 ecat_web.vbs 起过): 关窗只结束本服务的
# 子进程, native 的 parent 是别人, 不会替它清场。
#
# 解法: 每个 app 页面与本页服务器保持一条 WebSocket 连接
# (js/common/app-lease.js → GET /ws/app-lease), 连接在 = "还有 app 窗口开着":
# - 不用心跳计时器: 断开靠 TCP FIN 感知, 浏览器后台标签页的定时器节流
# 弄不死一条已建立的连接, 也就不会在后台开着 app 时误停从站;
# - 外壳内切标签不断连(外壳 tabs.html 自己也持有连接),
# 与 transport-ecat.teardown "切到 WiFi 看看不该打断 EtherCAT" 的既有设计一致;
# - 最后一条断开后**隔 GRACE 再停**: F5 刷新的断连→重连只有几百毫秒,
# 不留这个窗口会把"刷新页面"误判成"关闭 app"(每次误停都要重进 OP,
# 还平白多撞一次 SSC 邮箱楔死的风险);
# - 只停"我们确认跑过"的循环: g_ecat_motor_live 仅在本服务代理过成功的
# /motor/start 时置位, 有人直接从 ecat 控制台(:8090)开的循环不受影响。
#
# 停法用 /api/op/stop(内部先 loop_stop 再把从站写回 INIT): 确定性地"从站已停",
# 不留"等 1s 看门狗把自己踢出 OP"的中间态; 下次 motor/start 本来就要现读
# AL 状态重走 PREOP→SAFEOP→OP, 提前回 INIT 不增加额外代价。
APP_LEASE_PATH = "/ws/app-lease"
APP_LEASE_GRACE_S = 3.0 # 断连→重连的吸收窗口(页面刷新 / 新窗口接手)
_WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 握手固定常量
g_app_leases = 0 # 当前挂着的租约连接数
g_app_lock = threading.Lock()
g_ecat_motor_live = False # 本服务确认过"PDO 循环在跑"(经代理观测)
g_ui_state_lock = threading.Lock() # 配置文件读写串行(原子替换, 防半截 JSON)
def _ws_accept_key(key):
"""Sec-WebSocket-Accept = base64(SHA1(key + magic)), RFC 6455。"""
return base64.b64encode(
hashlib.sha1((key + _WS_MAGIC).encode("ascii")).digest()).decode("ascii")
def _app_lease_release():
global g_app_leases
with g_app_lock:
g_app_leases = max(0, g_app_leases - 1)
empty = g_app_leases == 0
if empty:
_schedule_loop_release()
def _schedule_loop_release(attempt=0):
"""GRACE 秒后若仍无一条租约连接且循环在跑, 停掉它。
多个断连各自起一个定时器没关系 —— _check 里现读现判, 条件不满足就静默退出。
"""
def _check():
with g_app_lock:
if g_app_leases > 0:
return # 有窗口重连上了(F5/新窗口), 本次释放作废
_ecat_stop_motor_loop(attempt)
t = threading.Timer(APP_LEASE_GRACE_S if attempt == 0 else 3.0, _check)
t.daemon = True
t.start()
def _ecat_stop_motor_loop(attempt, why="app 全部窗口已关闭"):
"""经代理让后端停循环 + 从站退回 INIT。幂等, 失败最多再试 2 轮。"""
global g_ecat_motor_live
with g_app_lock:
if not g_ecat_motor_live:
return
port = _ecat_find_port()
if port is None:
with g_app_lock:
g_ecat_motor_live = False # 后端本来就不在了, 没东西可停
return
ok = False
try:
status, data = _ecat_post(port, "/api/op/stop", b"{}", 5.0)
ok = status == 200 and _json_ok(data)
except Exception:
pass
if ok:
with g_app_lock:
g_ecat_motor_live = False
print(f"[lease] {why} → EtherCAT PDO 循环已停, 从站退回 INIT", flush=True)
return
if attempt < 2:
_schedule_loop_release(attempt + 1) # 后端可能正忙(持有 bus 锁), 稍后再试
else:
with g_app_lock:
g_ecat_motor_live = False
print("[lease] 警告: app 已关闭但 EtherCAT 循环停止失败"
"(服务无响应?), 请手工关闭 EtherCAT 服务", flush=True)
def _json_ok(data):
"""后端信封 ok:true 的判定。不能用子串匹配: json.dumps 会写成 "ok": true
(带空格), 真 native 是 C 打印的紧凑格式 —— 两种都得认, 低频路由正经解析一次。"""
try:
return json.loads(data.decode("utf-8", "replace")).get("ok") is True
except Exception:
return False
def _read_ui_state(path=None):
"""读配置快照 JSON。文件不存在/损坏 → None(前端回落 localStorage)。"""
p = path or UI_STATE_PATH
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
def _write_ui_state(obj, path=None, rel=None):
"""原子写配置快照。只接受 dict。默认写 data/motor_ui_state.json。"""
if not isinstance(obj, dict):
raise ValueError("ui-state 必须是 JSON 对象")
p = path or UI_STATE_PATH
r = rel or UI_STATE_REL
os.makedirs(os.path.dirname(p), exist_ok=True)
tmp = p + ".tmp"
with g_ui_state_lock:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, separators=(",", ":"))
f.flush()
os.fsync(f.fileno())
os.replace(tmp, p)
return {"ok": True, "path": r, "savedAt": obj.get("savedAt")}
def _note_ecat_loop(path, status, body):
"""代理透传时顺手记下"循环跑没跑", 免得租约空了去停别人的或白停一次。
判定只针对低频路由(motor/state 那种高频轮询不在表内, 不进这里分支)。
"""
global g_ecat_motor_live
tail = path[len(ECAT_PROXY_PREFIX):]
if tail == "motor/start":
target, flag = True, True
elif tail in ("motor/stop", "op/stop", "op/start", "open", "close", "shutdown"):
target, flag = True, False
else:
return
if status == 200 and _json_ok(body): # 后端 200 + ok:true 才算数
with g_app_lock:
g_ecat_motor_live = flag
class Handler(SimpleHTTPRequestHandler):
server_version = "Esp32Tools/1.0"
def __init__(self, *a, **kw):
super().__init__(*a, directory=BASE_DIR, **kw)
def log_message(self, fmt, *args):
pass # 静默访问日志
def end_headers(self):
# 本地调试: 禁缓存, 避免改了 css/js 还得手工 bump ?v= 才生效
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
def do_GET(self):
path = self.path.split("?")[0].lower()
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()
return
# 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳
if path in ("/", "/index.html"):
self.send_response(302)
self.send_header("Location", "/tabs.html")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path.endswith(".html") or not os.path.splitext(path)[1]:
# HTML(含无扩展名的 SPA 路由): 注入刷新悬浮按钮/快捷键后返回
fp = self.translate_path(self.path)
if os.path.isdir(fp):
fp = os.path.join(fp, "index.html")
try:
with open(fp, "rb") as f:
html = f.read().decode("utf-8", "replace")
except OSError:
self.send_error(404, "File not found")
return
if _RELOAD_MARK in html:
html = html.replace(_RELOAD_MARK, _RELOAD_SNIPPET + _RELOAD_MARK, 1)
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
# WebView2 无 Cache-Control 时按启发式缓存 HTML,而注入刷新只是
# load_url(同 URL) 不回源——改了页面窗口里永远还是旧版(实测)。
# no-cache = 可缓存但每次必须 revalidate,配合 Last-Modified 变更即回源。
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
else:
super().do_GET()
def do_POST(self):
path = self.path.split("?")[0]
path_l = path.lower()
if path == "/api/focus":
# 已有实例响应: 新实例应当退场
body = b'{"ok":true,"focused":true}'
self._post_json(body)
threading.Thread(target=lambda: print("FOCUS=1", flush=True), daemon=True).start()
return
if path == "/api/shutdown":
body = b'{"ok":true}'
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()
return
if path.startswith(ECAT_PROXY_PREFIX):
# 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 _ecat_proxy 注释)。
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
# 实测最坏 ~24s, 用默认 6s 会在正常进 OP 的过程中假失败。
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
raw = self.rfile.read(length) if length > 0 else b"{}"
timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
"/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0
# EtherCAT 直灌 BOOT: run 要走 519 个邮箱帧 + 整区预擦, 几十秒量级。
# 默认 6s 会在"正在正常刷写"的过程中假失败 —— 浏览器报错离场, native
# 却还在往 flash 里写, 比不刷更糟。同一路由下的 open/push 都是纯内存
# 毫秒级, 不连它们一起放大(后端没起时探测会干等几分钟), 故按 body 区分。
# probe/reboot 走 boot_dl_probe 的两级邮箱自愈: 最坏 2×2s statecheck +
# 重枚举 + 三次 700ms SDO 读 ≈ 7s+。这里必须给足, 因为 _ecat_proxy 超时
# 后会拉起后端并**把同一个 POST 重发一次**(见其尾部重试), 带副作用的请求
# 假超时 = 让后端把写过的命令再写一遍。
if path.endswith("/ota/ecat"):
timeout = 300.0 if b'"run"' in raw else 60.0
try:
status, body = _ecat_proxy(path, raw, timeout)
_note_ecat_loop(path, status, body)
except Exception as e:
# 窗口模式 stderr 是父进程 PIPE,写满会冻死服务 —— 不要 traceback
status, body = 503, json.dumps(
{"ok": False, "log": [f"代理异常: {type(e).__name__}: {e}"]},
ensure_ascii=False).encode("utf-8")
self._post_json(body, status)
return
self.send_error(404)
def _handle_ws_app_lease(self):
"""app 窗口租约 WebSocket(契约见 APP_LEASE_PATH 一节)。
只做握手然后盯断开: 浏览器发来什么都不需要回应(连 WS 帧都不用解析,
读到的字节直接丢弃), 本线程唯一的产出是"断开时调一次 release"。
ThreadingHTTPServer 每连接一线程, 这里每个 app 窗口占一条阻塞在
recv 上的线程, 数量就是窗口数, 可以忽略。
"""
key = self.headers.get("Sec-WebSocket-Key") or ""
if "websocket" not in (self.headers.get("Upgrade") or "").lower() or not key:
self.send_error(400, "此端点仅支持 WebSocket 握手")
return
self.wfile.write(
("HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {_ws_accept_key(key)}\r\n"
"\r\n").encode("ascii"))
self.wfile.flush()
global g_app_leases
with g_app_lock:
g_app_leases += 1
try:
self._ws_pump(self.connection)
except OSError:
pass # 强杀/复位: 连接报错同样算断开
finally:
_app_lease_release()
@staticmethod
def _ws_pump(conn):
"""读掉对端的 WS 帧直到连接结束, 唯一的产出是"断开时返回"。
不能只 recv 丢弃: 浏览器/客户端关连接前会先发 Close 控制帧并**等回显**
才拆 TCP(Edge 实测: 不回则导航走后半条连接挂着, FIN 永远等不到,
租约不释放)。所以 Close(0x8) 必须回一个空 Close, Ping(0x9) 顺手回
Pong(0xA); 文本/二进制帧没有语义, 读掉即可。
"""
rf = conn.makefile("rb")
while True:
hdr = rf.read(2)
if len(hdr) < 2:
return # FIN: 对端已关闭
op = hdr[0] & 0x0F
ln = hdr[1] & 0x7F
if ln == 126:
ln = int.from_bytes(rf.read(2), "big")
elif ln == 127:
ln = int.from_bytes(rf.read(8), "big")
if ln > 65536:
return # 租约通道不该有大帧, 当协议错误断开
mask = rf.read(4) if hdr[1] & 0x80 else b""
payload = rf.read(ln) if ln else b""
if mask and payload:
payload = bytes(c ^ mask[i & 3] for i, c in enumerate(payload))
if op == 0x8: # Close → 回显空 Close, 租约到此为止
try:
conn.sendall(b"\x88\x00")
except OSError:
pass
return
if op == 0x9: # Ping → Pong(原样带回 payload)
try:
conn.sendall(bytes([0x8A, len(payload)]) + payload)
except OSError:
pass
def _handle_ui_state_get(self):
"""电机页配置快照 GET。有文件 → ok+state;无 → ok+state:null。"""
st = _read_ui_state(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},
ensure_ascii=False).encode("utf-8")
self._post_json(body)
def _read_ui_post_body(self):
"""读 POST body 并解包 {state:{...}} 信封。失败时返回 (None, err_msg)。"""
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
if length <= 0 or length > 2 * 1024 * 1024:
return None, "空 body 或超过 2MB"
raw = self.rfile.read(length)
try:
obj = json.loads(raw.decode("utf-8", "replace"))
except Exception as e:
return None, f"JSON 解析失败: {type(e).__name__}"
if isinstance(obj, dict) and "state" in obj and isinstance(obj.get("state"), dict):
obj = obj["state"]
return obj, None
def _write_ui_state_route(self, path, rel):
"""POST → 原子写配置快照。路径固定在服务端,不接受客户端指定。"""
obj, err = self._read_ui_post_body()
if err is not None:
self._post_json(json.dumps(
{"ok": False, "log": [err]}, ensure_ascii=False).encode("utf-8"), 400)
return
try:
result = _write_ui_state(obj, path=path, rel=rel)
except Exception as e:
self._post_json(json.dumps(
{"ok": False, "log": [f"写入失败: {type(e).__name__}: {e}"]},
ensure_ascii=False).encode("utf-8"), 500)
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 _post_json(self, body, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _shutdown_self():
# 退出前把"我们启动过的"EtherCAT 循环先停掉。租约断开后的停循环靠 3s 延迟
# 定时器, 而定时器随本进程死亡一起消失; 窗口模式(_stop_child → 本路由)是
# 正常关窗的主路径, 复用的 ecat_native(parent 是别的进程)不会替我们收尾。
# attempt=2 表示尽力一次即止, 不在退出路径上重试磨蹭。
try:
_ecat_stop_motor_loop(2, why="本服务进程退出")
except Exception:
pass
if g_httpd is not None:
try:
g_httpd.shutdown()
except Exception:
pass
def _win32_foreground(native_handle):
"""SetForegroundWindow: pywebview Window 没有 focus(), 要走 Win32。"""
try:
hwnd = int(native_handle)
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 缓冲一满子进程写日志即永久阻塞
# → 整个服务冻死(实测"窗口经常卡死"的根因)。此处静音, 父侧另有排空线程双保险。
def handle_error(self, request, client_address):
pass
def _bind_server(port):
for _ in range(6):
if _port_taken(port):
print(f"端口 {port} 已被占用, 尝试 {port + 1}")
port += 1
continue
try:
return QuietServer(("127.0.0.1", port), Handler), port
except OSError:
print(f"端口 {port} 绑定失败, 尝试 {port + 1}")
port += 1
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 _run_browser(args):
"""浏览器模式: 服务跑在本进程主线程。"""
global g_httpd
if args.parent_pid:
_watch_parent(args.parent_pid)
httpd, port = _bind_server(args.port)
if httpd is None:
raise SystemExit("无法绑定端口")
g_httpd = httpd
url = f"http://127.0.0.1:{port}/tabs.html"
print(f"PORT={port}", flush=True) # 窗口模式父进程据此得知端口
print(f"ESP32 调试控制台: {url} (Ctrl+C 退出)")
print("页面里的设备 IP 决定 API 目标(默认 192.168.31.127), 本服务只提供页面")
if not args.no_browser:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
# 同 _shutdown_self: 进程要没了, 别让租约定时器带着"停循环"的责任一起死
try:
_ecat_stop_motor_loop(2, why="本服务进程退出")
except Exception:
pass
httpd.server_close()
print("\n已退出")
def _bind_child_lifetime(child):
"""把服务子进程放进 Job Object, 父进程句柄一关(正常退出/被任务管理器强杀/崩溃)
由内核连坐结束子进程, 杜绝"窗口关了端口还占着"的孤儿服务。
句柄故意不关闭: 生命周期就是靠父进程存活维系的。"""
k32 = ctypes.windll.kernel32
class _JOBLIM(ctypes.Structure):
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64),
("PerJobUserTimeLimit", ctypes.c_int64),
("LimitFlags", ctypes.c_uint32),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", ctypes.c_uint32),
("Affinity", ctypes.POINTER(ctypes.c_ulong)),
("PriorityClass", ctypes.c_uint32),
("SchedulingClass", ctypes.c_uint32)]
class _IOC(ctypes.Structure):
_fields_ = [("ReadOperationCount", ctypes.c_uint64),
("WriteOperationCount", ctypes.c_uint64),
("OtherOperationCount", ctypes.c_uint64),
("ReadTransferCount", ctypes.c_uint64),
("WriteTransferCount", ctypes.c_uint64),
("OtherTransferCount", ctypes.c_uint64)]
class _EXLIM(ctypes.Structure):
_fields_ = [("BasicLimitInformation", _JOBLIM),
("IoInfo", _IOC),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t)]
try:
job = k32.CreateJobObjectW(None, None)
if not job:
return None
info = _EXLIM()
info.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE
if not k32.SetInformationJobObject(job, 9, ctypes.byref(info),
ctypes.sizeof(info)): # ExtendedLimitInformation
return None
h = k32.OpenProcess(0x1F0FFF, False, child.pid) # PROCESS_ALL_ACCESS
if not h:
return None
k32.AssignProcessToJobObject(job, h)
k32.CloseHandle(h)
return job
except Exception:
return None
def _stop_child(child, port):
"""通知服务子进程优雅退出, 超时则强杀。"""
if child is None or child.poll() is not None:
return
try:
urllib.request.urlopen(urllib.request.Request(
f"http://127.0.0.1:{port}/api/shutdown", data=b"{}",
headers={"Content-Type": "application/json"}, method="POST"),
timeout=3)
except Exception:
pass
try:
child.wait(timeout=3)
except Exception:
pass
if child.poll() is None:
child.kill()
try:
child.wait(timeout=3)
except Exception:
pass
def _fail_box(title, msg):
"""GUI 进程启动失败时弹原生对话框, 避免无声无息。"""
try:
ctypes.windll.user32.MessageBoxW(None, msg, title, 0x10) # MB_ICONERROR
except Exception:
pass
def _acquire_single_instance():
"""命名 mutex 单实例守卫。返回 False=已有实例在跑。"""
global g_mutex
try:
g_mutex = ctypes.windll.kernel32.CreateMutexW(None, False, "DCU_Esp32Tools_SingleInstance")
return ctypes.windll.kernel32.GetLastError() != 183 # ERROR_ALREADY_EXISTS
except Exception:
return True # mutex 不可用时退化为允许多开
def _spawn_service_child(extra_args, fail_title):
"""启动服务子进程并持续排空其 stdout。
必须一直读: PIPE 缓冲 ~4KB, 读停则子进程任何 print/traceback 写满即永久阻塞。"""
script = os.path.abspath(__file__)
child = subprocess.Popen(
[sys.executable, script] + extra_args,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=BASE_DIR)
global g_job
g_job = _bind_child_lifetime(child) # 父进程消失 → 内核连坐杀子进程
holder = {"port": None, "focus": False}
tail = collections.deque(maxlen=40)
focus_evt = threading.Event()
def _drain():
for raw in child.stdout:
s = raw.decode("utf-8", "replace").rstrip()
if s:
tail.append(s)
if s.startswith("PORT=") and holder["port"] is None:
try:
holder["port"] = int(s[5:])
except ValueError:
pass
elif s.startswith("FOCUS="):
holder["focus"] = True
focus_evt.set()
threading.Thread(target=_drain, daemon=True).start()
deadline = time.time() + 30
while time.time() < deadline:
if holder["port"] is not None or child.poll() is not None:
break
time.sleep(0.1)
port = holder["port"]
if port is None:
child.terminate()
_fail_box(fail_title,
"\n".join(tail) or "服务子进程未在 30 秒内报告端口(无输出)")
os._exit(1)
return child, port, focus_evt
def _run_window(webview, args):
"""桌面窗口模式: 本进程只做 GUI, HTTP 服务跑在子进程。"""
global g_child, g_port, g_win
if not _acquire_single_instance():
# 已有实例: 尝试把它的窗口顶到前台后退出。旧实例是修过 focus 钩子的
# 新版本才能响应; 旧版无响应时提示用户用旧窗口。
probed = False
for p in (args.port, args.port + 1, args.port + 2):
if _focus_existing(p):
probed = True
break
if probed:
os._exit(0)
_fail_box("ESP32 调试控制台",
"已有一个实例在运行, 但无法唤醒其窗口。\n"
"请到任务管理器结束所有 pythonw.exe 后重新打开。")
os._exit(0)
child, port, focus_evt = _spawn_service_child(
["--no-browser", "--port", str(args.port),
"--parent-pid", str(os.getpid())], "ESP32 调试控制台启动失败")
g_child = child
g_port = port
# 启动 URL 带启动时间戳: 每次开窗必然缓存 miss。老缓存条目没有 no-cache
# 指令时 WebView2 仍可能直接复用,时间戳强制绕开——配合 HTML 响应的
# Cache-Control: no-cache,此后改页面窗口刷新即可见。
url = f"http://127.0.0.1:{port}/tabs.html?v={int(time.time())}"
for _ in range(50): # 等 HTTP 就绪(最多 ~10s)
try:
urllib.request.urlopen(url, timeout=1)
break
except Exception:
if g_child.poll() is not None:
_fail_box("ESP32 调试控制台启动失败", "服务子进程意外退出")
os._exit(1)
time.sleep(0.2)
# 保留 GPU 加速(页面依赖)。曾默认 --disable-gpu,用户要求恢复。
# 白屏现象:首帧可见后立刻全白,更像 renderer/GPU 在 loaded 后崩溃,
# 或 watchdog 误 load_url 二次导航 —— 此处不再自动二次 load。
def _monitor():
while True:
if focus_evt.is_set():
_gui_bringToFront() # 新双击 → 旧窗口到前台
focus_evt.clear()
if g_child.poll() is not None:
os._exit(0) # 子进程没了 → GUI 一起退
time.sleep(0.5)
threading.Thread(target=_monitor, daemon=True).start()
g_win = webview.create_window("ESP32 调试控制台 — DCU", url,
width=1600, height=1000, min_size=(1100, 720),
js_api=_GuiApi(), hidden=False, text_select=True)
print(f"桌面窗口模式: {url} (关窗即退出)")
def _show_watchdog():
# 只负责前台,不再二次 load_url(二次导航会造成「闪一下再白」)
deadline = time.time() + 15
while time.time() < deadline:
if g_win.events.loaded.is_set():
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)
threading.Thread(target=_show_watchdog, daemon=True).start()
webview.start()
_stop_child(g_child, g_port)
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)
ap.add_argument("--no-browser", action="store_true")
ap.add_argument("--window", action="store_true",
help="pywebview 桌面窗口模式")
ap.add_argument("--parent-pid", type=int, default=0,
help="内部用: GUI 父进程 pid, 父进程消失时本服务自动退出")
args = ap.parse_args()
if args.window:
try:
import webview
except ImportError:
print("pywebview 未安装: pip install pywebview —— 回退浏览器模式")
webview = None
if webview is not None:
_run_window(webview, args)
return
_run_browser(args)
if __name__ == "__main__":
main()