Files
DCU_DesktopController/esp32_server.py
T

985 lines
42 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/<路径>
为什么要代理 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 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__))
# 单实例互斥: 已有实例时, 新双击不创建第二个窗口(会隐形/端口漂移),
# 而是通过 /api/focus 让旧实例的窗口到前台。
g_mutex = None
# pywebview 窗口内的刷新能力: 悬浮按钮 + F5/Ctrl+R。浏览器 tab 打开时同样无害。
# __HOME__ 由模块加载时替换为工具主页路径(tabs.html 标签外壳)。
# 注入脚本对三种上下文自适应:
# 1) iframe 内的功能页(标签外壳承载): 移除悬浮按钮, 但仍覆写 window.open/_blank
# 让页面里的"新窗口打开"在 iframe 内跳转, 不弹系统浏览器;
# 2) 顶层标签外壳(tabs.html, 含 #view iframe): ⟳ 刷新当前 iframe, 不显示 ⌂;
# 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){var f=document.getElementById('view');
if(f){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('view');
// 顶层且非标签外壳(浏览器直开 pages/xxx.html) → 给个 ⌂ 回到外壳。
// 判定基准是外壳自身(有 #view / 路径为 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 = os.path.dirname(os.path.abspath(__file__))
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) **必须先探测再拉起**。native 独立启动(无 --parent-pid)会清场: 对
# 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。
# 所以已有实例时只复用, 绝不重复拉起。
# 2) 拉起时**必须带 --parent-pid**: 一是跳过上面那个清场逻辑, 二是让它在
# 本服务退出后自我了断, 不留孤儿进程占着 Npcap 适配器和端口。
# 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。
ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat"))
ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe")
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
class _GuiApi:
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
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 _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 _ecat_autostart():
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
port 为 None 表示拉不起来(缺 exe / 起了但没监听)。
已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释:
native 独立启动会清场(优雅关掉旧实例), 不能重复拉起。
"""
global g_ecat_child, g_ecat_port
with g_ecat_lock:
port = _ecat_find_port()
if port: # 已经有人在跑(用户手开的, 或我们上次拉起的)
g_ecat_port = port
return port, []
logs = []
if not os.path.isfile(ECAT_NATIVE_EXE):
return None, [
f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}",
"请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。",
]
# 复用已被本进程拉起但已退出的槽位
if g_ecat_child is not None and g_ecat_child.poll() is not None:
g_ecat_child = None
if g_ecat_child is None:
try:
# --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后
# 自我了断(不留孤儿占 Npcap/端口)。
# --mask 0x10: PDO 自旋线程钉 LP4(i7-14700K P核2), 避开 DPC 集中的
# 核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。不钉的话
# 自旋线程随调度器漂移, 1ms 档周期抖动明显放大。
# cwd=tools/ecat: --web 的相对路径(src/web)按此解析。
g_ecat_child = subprocess.Popen(
[ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x10",
"--web", "src/web", "--parent-pid", str(os.getpid())],
cwd=ECAT_DIR,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
except OSError as e:
return None, [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
# 等它把端口监听起来。native 启动即绑口(ec_init 是懒的, 在 open 路由里),
# 给 10s 足够; 老 Python 版要等 pysoem 导入 + Npcap 枚举, 才需要 20s。
deadline = time.time() + 10
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 服务启动超时(10s 内未监听端口)"]
def _ecat_post(port, path, body, timeout):
"""向 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, 不会挂住), 连上之后才设读写超时。
"""
payload = (
f"POST {path} HTTP/1.0\r\n"
f"Host: 127.0.0.1:{port}\r\n"
"Content-Type: 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
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):
"""把 /ecat/api/xxx 转发到 EtherCAT 后端的 /api/xxx。
返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
EtherCAT 后端会在 8090..8095 之间漂移(端口被占就挪),
写死 8090 会在它挪了之后表现为"页面点了没反应"。
连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封,
这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。
"""
global g_ecat_port
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
target = "/api/" + path[len(ECAT_PROXY_PREFIX):]
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:
status, data = _ecat_post(port, target, body, timeout)
g_ecat_port = port
return status, data
except Exception as e: # 连不上/超时 → 试下一个端口
last = e
if g_ecat_port == port:
g_ecat_port = None # 缓存端口失效, 下轮重新全量探测
# 一个端口都不通 → 服务没在跑, 按需自动拉起后重试一次。
# 放在这里而不是每次请求前探测: 正常情况(服务在跑)一次都不会走到,
# 不给高频的 /motor/state 轮询增加任何开销。
port, start_logs = _ecat_autostart()
if port:
try:
status, data = _ecat_post(port, target, body, timeout)
g_ecat_port = port
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 循环在跑"(经代理观测)
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 _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
# 根路径不再有 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)))
self.end_headers()
self.wfile.write(body)
else:
super().do_GET()
def do_POST(self):
path = self.path.split("?")[0]
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.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
status, body = _ecat_proxy(path, raw, timeout)
_note_ecat_loop(path, status, body)
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 _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 = f"http://127.0.0.1:{port}/tabs.html"
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)
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=True, text_select=True)
print(f"桌面窗口模式: {url} (关窗即退出)")
def _show_watchdog():
# 页面加载后由 JS 调 page_loaded → show; 若 20s 仍未显示则强制 show
deadline = time.time() + 20
while time.time() < deadline:
if g_win.events.loaded.is_set():
time.sleep(1)
break
time.sleep(0.5)
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()