feat(server): app 窗口租约, 全部窗口关闭自动停 EtherCAT 循环
PDO 循环起来后 native 侧一直跑, 其 --parent-pid 自清理只覆盖"本服务拉起 的实例死掉"; 浏览器模式关窗后本服务还在、窗口模式复用现成 ecat_native (parent 是别人)这两种常见离开方式都够不着 —— 从站卡在 OP、自旋节拍白占 一个核。现由每个 app 页面与页面服务器的 WebSocket 租约兜底: - esp32_server.py: 新增 GET /ws/app-lease(手写 RFC6455 握手 + 帧泵)。 连接计数归零后隔 3s GRACE 调后端 /api/op/stop 停循环退回 INIT; 断开 感知靠 TCP FIN / Close 帧回显而非心跳(后台标签页节流不误停); 代理 透传时 _note_ecat_loop 记账"循环是否本服务跑起来的", 只停自己确认过 的; /api/shutdown 与 serve_forever 退出路径同步补停(定时器随进程死亡)。 - js/common/app-lease.js: 租约客户端, 3~30s 退避重连, file:// 静默跳过。 - 接入 tabs.html(外壳持有, 内部切标签不断连)与 pages/motor.html(单独 打开时页面自持)。 - 测试: scripts/test_app_lease.py(裸 TCP 全路径, 假 _ecat_post) + scripts/test_app_lease_e2e.mjs(真 Edge headless + 假后端 8091, 不碰 真总线)。README 补设计动机与要点。
This commit is contained in:
+209
@@ -8,6 +8,8 @@
|
||||
|
||||
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/<路径>
|
||||
|
||||
@@ -32,8 +34,10 @@ pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTM
|
||||
_reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import collections
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -347,6 +351,127 @@ def _ecat_proxy(path, body, timeout=6.0):
|
||||
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"
|
||||
|
||||
@@ -363,6 +488,9 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
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)
|
||||
@@ -418,10 +546,78 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
"/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")
|
||||
@@ -431,6 +627,14 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
|
||||
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()
|
||||
@@ -520,6 +724,11 @@ def _run_browser(args):
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
# 同 _shutdown_self: 进程要没了, 别让租约定时器带着"停循环"的责任一起死
|
||||
try:
|
||||
_ecat_stop_motor_loop(2, why="本服务进程退出")
|
||||
except Exception:
|
||||
pass
|
||||
httpd.server_close()
|
||||
print("\n已退出")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user