R1: autostart 后禁止自动重发 flash/motor/send 等副作用路径;
超时类失败也不再串打其它端口。
R2: README 默认网卡改为与代码一致的 I210。
R3: 命名互斥锁 + 先复用健康监听实例,避免 GUI/服务子进程互相 kill。
1654 lines
69 KiB
Python
1654 lines
69 KiB
Python
#!/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)">↻</div>
|
||
<div id="__home" title="返回标签主页" style="display:none">⌂</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()
|