Files
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

1043 lines
45 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""esp32_server.py — ESP32 调试控制台的本地页面服务器 + 桌面窗口外壳。
本目录的 HTML/CSS/JS 是纯前端: 页面本体由本服务器提供(同源相对路径),
而所有业务 API 由 ESP32 固件的 80 端口跨域提供(js/common/base-url.js 里的
设备 IP, 存 localStorage 的 dcu_esp_ip)。因此本脚本不碰任何硬件, 只做静态服务
—— 唯一例外是 /ecat/api/* 反向代理(见下)。
GET / → 302 到 /tabs.html (标签外壳, 工具主页)
GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/)
GET /ws/app-lease → app 窗口租约 WebSocket: 所有 app 窗口断开后自动
停 EtherCAT PDO 循环并从站退回 INIT(见 _app_lease 一节)
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径>
GET/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)">&#x21bb;</div>
<div id="__home" title="返回标签主页" style="display:none">&#x2302;</div>
<script>(function(){
var TOP=(window.top===window.self);
if(!TOP){var _r=document.getElementById('__rl'),_h=document.getElementById('__home');
if(_r)_r.remove(); if(_h)_h.remove();}
window.open=function(u){if(u)location.href=u;return null;};
document.addEventListener('click',function(e){
var a=e.target&&e.target.closest?e.target.closest('a[target=_blank]'):null;
if(a&&a.href){e.preventDefault();location.href=a.href;}
},true);
function reloadCur(){
if(TOP){
// 多 iframe 保活外壳:只 reload 激活帧。重载外壳会销毁全部保活文档。
var f=document.querySelector('.page-frame.active');
if(!f) f=document.getElementById('view');
if(f&&f.tagName==='IFRAME'){
try{f.contentWindow.location.reload();}catch(_){f.src=f.src;}
return;
}
}
location.reload();
}
window.addEventListener('keydown',function(e){
if(e.key==='F5'||(e.ctrlKey&&(e.key==='r'||e.key==='R'))){e.preventDefault();reloadCur();}
});
if(!TOP) return;
document.getElementById('__rl').addEventListener('click',reloadCur);
var home=document.getElementById('__home');
var p=location.pathname;
var isShell=!!document.getElementById('viewHost')||!!document.getElementById('view');
// 顶层且非标签外壳(浏览器直开 pages/xxx.html) → 给个 ⌂ 回到外壳。
// 判定基准是外壳自身(有 #viewHost / 路径为 tabs.html 或 /), 不能再看 index.html:
// 重排后 pages/index.html 只是普通功能页, 直开它同样该显示 ⌂。
if(!isShell&&!/\\/$/.test(p)&&!/\\/tabs\\.html$/.test(p)){
home.style.display='block';
home.addEventListener('click',function(){location.href='__HOME__';});
}
function ready(){try{window.pywebview&&window.pywebview.api&&
window.pywebview.api.page_loaded()}catch(e){}}
document.addEventListener('DOMContentLoaded',ready);
window.addEventListener('load',ready);
})();</script>
"""
_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html")
_RELOAD_MARK = "</body>"
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()