- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
1043 lines
45 KiB
Python
1043 lines
45 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/POST /api/motor/ui-state → 读/写电机页配置快照(data/motor_ui_state.json)
|
||
GET/POST /api/lab/ui-state → 读/写 lab/spi_test 配置快照
|
||
|
||
为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输,
|
||
后者的后端 tools/ecat 的 EtherCAT 服务只绑 127.0.0.1 且完全没有 CORS
|
||
(既无 Access-Control-Allow-Origin, 也没有 do_OPTIONS —— 预检直接 501)。
|
||
我们的 API 是 POST + application/json, 属非简单请求, 浏览器必发预检,
|
||
因此页面无法直接跨端口调它。改那边要动另一个 git 仓库, 把页面搬过去又会让
|
||
WiFi/ECAT 分裂成两份页面, 所以在本服务做同源转发, 两个仓库都不用改。
|
||
|
||
模块边界(P2 / R8–R11,2026-09-23):
|
||
- ecat_client.py 端口探测、裸 socket HTTP、ESF1 快发、路径归一化、代理
|
||
- ecat_proc.py ecat_native 进程治理(跨进程锁 / autostart / kill / RealTime)
|
||
- 本文件 HTTP/静态/lease/窗口/GuiApi;对脚本 re-export 常用符号
|
||
|
||
桌面 vs 浏览器双路径(R10)——最终同 ecat_client,不删桥:
|
||
- 浏览器 = HTTP 代理:页面 fetch POST /ecat/api/* → Handler → ecat_client.ecat_proxy
|
||
- 桌面 APP = GuiApi 桥:JS pywebview.api.ecat_api → _GuiApi.ecat_api
|
||
→ ecat_client.api_call(不再套一层 proxy HTTP)
|
||
两者共用 normalize_ecat_api_path + _ecat_post。桌面桥少一跳 HTTP;
|
||
浏览器没有 pywebview.api,只能走同源代理。
|
||
|
||
目录约定: tabs.html 在根(工具入口), 功能页在 pages/, 自包含实验页在 lab/。
|
||
|
||
启动模式:
|
||
--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 socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.request
|
||
import webbrowser
|
||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
# BASE_DIR 只在此定义一次(R11);ecat_proc 路径由 init(BASE_DIR) 注入。
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# EtherCAT 客户端 / 进程治理模块(R8)
|
||
import ecat_client
|
||
import ecat_proc
|
||
import lan_probe
|
||
|
||
ecat_proc.init(BASE_DIR)
|
||
|
||
# ── 兼容 re-export:脚本/测试从 esp32_server 取这些符号 ──────────────
|
||
from ecat_client import ( # noqa: E402
|
||
ECAT_PORTS,
|
||
ECAT_PROXY_PREFIX,
|
||
ESF_FRAME_W,
|
||
ESF_MAGIC,
|
||
FSOK_MAGIC,
|
||
_ecat_find_port,
|
||
_ecat_post,
|
||
_ecat_proxy,
|
||
ecat_proxy,
|
||
ecat_send_raw_frames,
|
||
normalize_ecat_api_path,
|
||
pack_esf,
|
||
unpack_fsok,
|
||
)
|
||
from ecat_proc import ( # noqa: E402
|
||
ECAT_DEFAULT_ADAPTER,
|
||
ECAT_DIR,
|
||
ECAT_NATIVE_EXE,
|
||
_kill_all_ecat_native,
|
||
_watch_parent,
|
||
autostart as _ecat_autostart,
|
||
)
|
||
|
||
# 电机页配置落盘: CMD 表单 / 命令序列 / 刷新速度 / 链路与 EtherCAT 偏好等。
|
||
# 存 JSON 而不是只靠 localStorage —— WebView2/清缓存后 localStorage 会没,
|
||
# 用户要的「关掉之后还在电脑上」必须是真文件。路径固定在工具目录下, 便于备份。
|
||
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"
|
||
|
||
# R11: ui-state 双路由一张表驱动 GET/POST(路径 → (绝对路径, 相对路径))
|
||
UI_STATE_ROUTES = {
|
||
"/api/motor/ui-state": (UI_STATE_PATH, UI_STATE_REL),
|
||
"/api/lab/ui-state": (LAB_UI_STATE_PATH, LAB_UI_STATE_REL),
|
||
}
|
||
|
||
# 单实例互斥: 已有实例时, 新双击不创建第二个窗口(会隐形/端口漂移),
|
||
# 而是通过 /api/focus 让旧实例的窗口到前台。
|
||
g_mutex = None
|
||
|
||
# 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>"
|
||
|
||
g_httpd = None
|
||
g_child = None
|
||
g_port = None
|
||
g_win = None
|
||
g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程
|
||
|
||
|
||
class _GuiApi:
|
||
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。
|
||
|
||
形态约定(R10,见模块 docstring):
|
||
- 网页/浏览器标签:无 pywebview,电机页 EtherCAT 走 HTTP /ecat/api/* 代理。
|
||
- 桌面 APP (esp32_app.vbs --window):JS 调本类 ecat_api → ecat_client.api_call,
|
||
与代理同一套路径归一化 + 同一 _ecat_post,不再在桥内再造一层 proxy HTTP。
|
||
iframe 页从 window.top.pywebview.api 取(同源)。
|
||
"""
|
||
|
||
def page_loaded(self):
|
||
"""页面每次加载完成后由注入脚本调用: 把窗口带到前台。"""
|
||
bring_window_foreground(g_win)
|
||
|
||
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 ecat_client.g_ecat_port or ecat_client.g_fast_send_port == 0:
|
||
try:
|
||
ecat_client._discover_fast_send_port(0.8)
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"ok": True,
|
||
"mode": "desktop",
|
||
"has_ecat_bridge": True,
|
||
"ecat_port": ecat_client.g_ecat_port,
|
||
"has_fast_send": bool(ecat_client.g_fast_send_port),
|
||
"fast_send_port": ecat_client.g_fast_send_port,
|
||
}
|
||
|
||
def ecat_send_raw(self, frames, timeout_ms=2000):
|
||
"""桌面桥 PDO 快发:JS → ecat_client 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_client → ecat_native。
|
||
|
||
R10: 内部直接调 ecat_client.api_call(同一路径归一化 + 同一代理核心),
|
||
不经浏览器 HTTP,也不在桥内再造一层 proxy 转发。
|
||
|
||
path: "motor/send" | "/api/motor/send" | "/ecat/api/motor/send"
|
||
body: dict / JSON 字符串 / 省略
|
||
返回与 native 同构的 dict。
|
||
"""
|
||
return ecat_client.api_call(path, body, timeout_ms)
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── 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 轮。
|
||
|
||
注意:这里用模块全局名 _ecat_find_port / _ecat_post(可被测试 patch),
|
||
不要用 ecat_client._ecat_post——租约测试注入点在 esp32_server 命名空间。
|
||
"""
|
||
global g_ecat_motor_live
|
||
with g_app_lock:
|
||
if not g_ecat_motor_live:
|
||
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 那种高频轮询不在表内, 不进这里分支)。
|
||
路径归一化与桥/代理共用(R9)。
|
||
"""
|
||
global g_ecat_motor_live
|
||
_proxy_path, target = normalize_ecat_api_path(path)
|
||
tail = target[len("/api/"):] if target.startswith("/api/") else target.lstrip("/")
|
||
if tail == "motor/start":
|
||
target_flag, flag = True, True
|
||
elif tail in ("motor/stop", "op/stop", "op/start", "open", "close", "shutdown"):
|
||
target_flag, 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
|
||
# R11: ui-state 双路由一张表驱动
|
||
if path in UI_STATE_ROUTES:
|
||
p, rel = UI_STATE_ROUTES[path]
|
||
self._handle_ui_state_get(p, rel)
|
||
return
|
||
# 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳
|
||
if path in ("/", "/index.html"):
|
||
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
|
||
# R11: ui-state 双路由一张表驱动
|
||
if path_l in UI_STATE_ROUTES:
|
||
p, rel = UI_STATE_ROUTES[path_l]
|
||
self._handle_ui_state_post(p, rel)
|
||
return
|
||
if path_l == "/api/lan/probe":
|
||
# 局域网设备发现: 无状态, 一次请求探一批(≤24), 探完就回。
|
||
# 编排(候选表/分批/进度/取消/缓存)全在页面 js/common/lan-picker.js。
|
||
self._handle_lan_probe()
|
||
return
|
||
if path.startswith(ECAT_PROXY_PREFIX):
|
||
# 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 ecat_client)。
|
||
# 超时给足: /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"{}"
|
||
# /api/open 正常只要 ~0.1s(实测 107ms),但刷写 commit 后那一拍不算正常:
|
||
# 新 APP 起板看到 warm 残值会自己补一发 LAN9252 RST#(esc_lan9252.cpp:441),
|
||
# 芯片+PHY 整颗复位 → I210 链路重协商 1~2s,open 的广播/statecheck 全撞上
|
||
# 去,实测能跨过 6s 档 → 代理假超时 → 非幂等只回 503(2026-10-09 21:49 现场)。
|
||
timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
|
||
"/flash", "/eeprom/write",
|
||
"/eeprom/read", "/eeprom/verify",
|
||
"/open")) 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
|
||
# 对非幂等路径**不会**重发(P0/R1),只回 503 请用户重试;假超时仍会
|
||
# 干扰刷写进度判断。
|
||
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, path, rel):
|
||
"""配置快照 GET(R11:motor/lab 共用,表驱动)。"""
|
||
st = _read_ui_state(path)
|
||
body = json.dumps(
|
||
{"ok": True, "state": st, "path": 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_lan_probe(self):
|
||
"""POST /api/lan/probe — 局域网设备探测原语(一批 ≤24 个 IP,探完就回)。
|
||
|
||
刻意不建 job 表 / 不常驻线程 / 不落缓存:编排全在页面
|
||
(js/common/lan-picker.js),这里由每连接一线程的 ThreadingHTTPServer
|
||
自然限流。页面中途关标签最坏是让本线程跑满这一批的截止时长。
|
||
"""
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
length = 0
|
||
raw = self.rfile.read(min(length, 65536)) if length > 0 else b""
|
||
if length == 0 or length > 65536:
|
||
self._post_json(json.dumps({"ok": False, "errors": ["bad-body"]},
|
||
ensure_ascii=False).encode("utf-8"), 400)
|
||
return
|
||
try:
|
||
obj = json.loads(raw.decode("utf-8", "replace"))
|
||
except ValueError:
|
||
obj = {}
|
||
if not isinstance(obj, dict):
|
||
obj = {}
|
||
ips = obj.get("ips") if isinstance(obj.get("ips"), list) else []
|
||
ports = obj.get("ports") if isinstance(obj.get("ports"), list) else None
|
||
if not ports:
|
||
ports = [80]
|
||
try:
|
||
tmo = int(obj.get("timeout_ms") or 750)
|
||
except (TypeError, ValueError):
|
||
tmo = 750
|
||
try:
|
||
out = lan_probe.probe_batch(
|
||
ips, ports=ports, path=obj.get("path") or lan_probe.PROBE_PATH,
|
||
timeout_ms=tmo)
|
||
except Exception as e: # noqa: BLE001
|
||
out = {"ok": False, "results": [], "scan": {"candidates": len(ips)},
|
||
"errors": ["%s: %s" % (type(e).__name__, e)]}
|
||
if obj.get("local"):
|
||
# 同一次往返带回"本机在哪段",页面才说得出扫的是哪段、依据是什么。
|
||
try:
|
||
out["local"] = lan_probe.local_net()
|
||
except Exception as e: # noqa: BLE001
|
||
out["local"] = {"ok": False, "err": type(e).__name__}
|
||
self._post_json(json.dumps(out, ensure_ascii=False).encode("utf-8"))
|
||
|
||
def _handle_ui_state_post(self, path, rel):
|
||
"""配置快照 POST(R11:motor/lab 共用,表驱动)。"""
|
||
self._write_ui_state_route(path, rel)
|
||
|
||
def _post_json(self, body, status=200):
|
||
self.send_response(status)
|
||
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 bring_window_foreground(win):
|
||
"""把 pywebview 窗口恢复并置顶。前台焦点唯一入口(R11 去重)。"""
|
||
if win is None:
|
||
return
|
||
try:
|
||
win.show()
|
||
except Exception:
|
||
pass
|
||
hwnd = None
|
||
try:
|
||
hwnd = win.native.Handle # WinForms Form
|
||
except Exception:
|
||
try:
|
||
hwnd = win.native
|
||
except Exception:
|
||
return
|
||
try:
|
||
hwnd = int(hwnd)
|
||
ctypes.windll.user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
||
ctypes.windll.user32.SetForegroundWindow(hwnd)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
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 _port_taken(port):
|
||
"""Windows 的 SO_REUSEADDR 允许同端口重复 bind(实测会双监听、连接随机落),
|
||
故先用一次真实连接探测占用, 不能只靠 bind 是否抛错。
|
||
|
||
与 ecat_client._port_taken 同语义;本处保留供 _bind_server 使用,
|
||
并作为可 patch 的 server 命名空间符号。
|
||
"""
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||
s.settimeout(0.3)
|
||
return s.connect_ex(("127.0.0.1", port)) == 0
|
||
|
||
|
||
def _run_browser(args):
|
||
"""浏览器模式: 服务跑在本进程主线程。"""
|
||
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/lan/probe 探测原语")
|
||
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():
|
||
bring_window_foreground(g_win) # 新双击 → 旧窗口到前台
|
||
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)
|
||
bring_window_foreground(g_win)
|
||
|
||
threading.Thread(target=_show_watchdog, daemon=True).start()
|
||
webview.start()
|
||
_stop_child(g_child, g_port)
|
||
os._exit(0)
|
||
|
||
|
||
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()
|