feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本
变更点: 1. esp32_server.py - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在) - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP 2. js/common/desktop-bridge.js(新) - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥 3. connection.js + responsive.css + components.css - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画 - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘 4. device-link.js - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时 - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期 5. motor/main.js + transport-ecat.js - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出 - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp 6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态 7. .gitignore - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
This commit is contained in:
+178
-1
@@ -12,6 +12,8 @@
|
||||
停 EtherCAT PDO 循环并从站退回 INIT(见 _app_lease 一节)
|
||||
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
|
||||
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径>
|
||||
GET /api/motor/ui-state → 读电机页配置快照(data/motor_ui_state.json)
|
||||
POST /api/motor/ui-state → 写电机页配置快照到磁盘(关页/改动时前端自动刷)
|
||||
|
||||
为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输,
|
||||
后者的后端 tools/ecat 的 EtherCAT 服务只绑 127.0.0.1 且完全没有 CORS
|
||||
@@ -52,6 +54,16 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 电机页配置落盘: CMD 表单 / 命令序列 / 刷新速度 / 链路与 EtherCAT 偏好等。
|
||||
# 存 JSON 而不是只靠 localStorage —— WebView2/清缓存后 localStorage 会没,
|
||||
# 用户要的「关掉之后还在电脑上」必须是真文件。路径固定在工具目录下, 便于备份。
|
||||
UI_STATE_PATH = os.path.join(BASE_DIR, "data", "motor_ui_state.json")
|
||||
UI_STATE_REL = "data/motor_ui_state.json"
|
||||
# lab/spi_test 观测面板配置(PDO 档位/采样率/开关按钮)。与电机页分文件,
|
||||
# 避免任一页面整包覆盖时把对方字段抹掉。
|
||||
LAB_UI_STATE_PATH = os.path.join(BASE_DIR, "data", "spi_test_ui_state.json")
|
||||
LAB_UI_STATE_REL = "data/spi_test_ui_state.json"
|
||||
|
||||
# 单实例互斥: 已有实例时, 新双击不创建第二个窗口(会隐形/端口漂移),
|
||||
# 而是通过 /api/focus 让旧实例的窗口到前台。
|
||||
g_mutex = None
|
||||
@@ -208,7 +220,14 @@ def _set_ecat_realtime(pid: int) -> bool:
|
||||
|
||||
|
||||
class _GuiApi:
|
||||
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
|
||||
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。
|
||||
|
||||
形态约定:
|
||||
- 网页/浏览器标签:无 pywebview,电机页 EtherCAT 走 HTTP /ecat/api/* 代理。
|
||||
- 桌面 APP (esp32_app.vbs --window):JS 调本类 ecat_api,GUI 进程用与
|
||||
代理同款的裸 socket 直连 ecat_native —— 省掉「浏览器 fetch → 服务子进程
|
||||
→ 代理」两跳。iframe 页从 window.top.pywebview.api 取(同源)。
|
||||
"""
|
||||
|
||||
def page_loaded(self):
|
||||
"""页面每次加载完成后由注入脚本调用: 把窗口带到前台。"""
|
||||
@@ -230,6 +249,65 @@ class _GuiApi:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def bridge_info(self):
|
||||
"""前端探测桌面桥是否可用。"""
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "desktop",
|
||||
"has_ecat_bridge": True,
|
||||
"ecat_port": g_ecat_port,
|
||||
}
|
||||
|
||||
def ecat_api(self, path, body=None, timeout_ms=4000):
|
||||
"""桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。
|
||||
|
||||
path: "motor/send" | "/api/motor/send" | "/ecat/api/motor/send"
|
||||
body: dict / JSON 字符串 / 省略
|
||||
返回与 native 同构的 dict。
|
||||
"""
|
||||
p = str(path or "").strip()
|
||||
p = p.split("?", 1)[0]
|
||||
while p.startswith("/"):
|
||||
p = p[1:]
|
||||
if p.startswith("ecat/api/"):
|
||||
p = p[len("ecat/"):]
|
||||
elif p.startswith("ecat/"):
|
||||
p = p[len("ecat/"):]
|
||||
if p.startswith("api/"):
|
||||
tail = p[len("api/"):]
|
||||
else:
|
||||
tail = p
|
||||
proxy_path = ECAT_PROXY_PREFIX + tail # /ecat/api/<tail>
|
||||
|
||||
if body is None:
|
||||
raw = b"{}"
|
||||
elif isinstance(body, (bytes, bytearray)):
|
||||
raw = bytes(body)
|
||||
elif isinstance(body, str):
|
||||
raw = body.encode("utf-8")
|
||||
else:
|
||||
try:
|
||||
raw = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
except Exception as e:
|
||||
return {"ok": False, "log": [f"原生桥 body 序列化失败: {type(e).__name__}: {e}"]}
|
||||
|
||||
timeout = max(0.2, float(timeout_ms or 4000) / 1000.0)
|
||||
try:
|
||||
status, data = _ecat_proxy(proxy_path, raw, timeout)
|
||||
except Exception as e:
|
||||
return {"ok": False, "log": [f"原生桥异常: {type(e).__name__}: {e}"]}
|
||||
|
||||
try:
|
||||
obj = json.loads(data.decode("utf-8", "replace") or "{}")
|
||||
except Exception:
|
||||
obj = {"ok": False, "log": ["原生桥响应非 JSON"]}
|
||||
if not isinstance(obj, dict):
|
||||
obj = {"ok": False, "log": ["原生桥响应不是对象"]}
|
||||
obj.setdefault("ok", status == 200)
|
||||
if status >= 400 and "log" not in obj:
|
||||
obj["log"] = [f"HTTP {status}"]
|
||||
return obj
|
||||
|
||||
|
||||
def _focus_existing(port):
|
||||
"""让已有实例的窗口到前台。返回 True 表示确实存在旧实例。"""
|
||||
@@ -537,6 +615,7 @@ _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):
|
||||
@@ -609,6 +688,34 @@ def _json_ok(data):
|
||||
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):
|
||||
"""代理透传时顺手记下"循环跑没跑", 免得租约空了去停别人的或白停一次。
|
||||
|
||||
@@ -646,6 +753,12 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
if path == APP_LEASE_PATH:
|
||||
self._handle_ws_app_lease()
|
||||
return
|
||||
if path == "/api/motor/ui-state":
|
||||
self._handle_ui_state_get()
|
||||
return
|
||||
if path == "/api/lab/ui-state":
|
||||
self._handle_lab_ui_state_get()
|
||||
return
|
||||
# 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_response(302)
|
||||
@@ -681,6 +794,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
path_l = path.lower()
|
||||
if path == "/api/focus":
|
||||
# 已有实例响应: 新实例应当退场
|
||||
body = b'{"ok":true,"focused":true}'
|
||||
@@ -692,6 +806,12 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
self._post_json(body)
|
||||
threading.Thread(target=_shutdown_self, daemon=True).start()
|
||||
return
|
||||
if path_l == "/api/motor/ui-state":
|
||||
self._handle_ui_state_post()
|
||||
return
|
||||
if path_l == "/api/lab/ui-state":
|
||||
self._handle_lab_ui_state_post()
|
||||
return
|
||||
if path.startswith(ECAT_PROXY_PREFIX):
|
||||
# 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 _ecat_proxy 注释)。
|
||||
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
|
||||
@@ -793,6 +913,63 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _handle_ui_state_get(self):
|
||||
"""电机页配置快照 GET。有文件 → ok+state;无 → ok+state:null。"""
|
||||
st = _read_ui_state(UI_STATE_PATH)
|
||||
body = json.dumps(
|
||||
{"ok": True, "state": st, "path": UI_STATE_REL},
|
||||
ensure_ascii=False).encode("utf-8")
|
||||
self._post_json(body)
|
||||
|
||||
def _handle_lab_ui_state_get(self):
|
||||
"""lab/spi_test 配置快照 GET。"""
|
||||
st = _read_ui_state(LAB_UI_STATE_PATH)
|
||||
body = json.dumps(
|
||||
{"ok": True, "state": st, "path": LAB_UI_STATE_REL},
|
||||
ensure_ascii=False).encode("utf-8")
|
||||
self._post_json(body)
|
||||
|
||||
def _read_ui_post_body(self):
|
||||
"""读 POST body 并解包 {state:{...}} 信封。失败时返回 (None, err_msg)。"""
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length <= 0 or length > 2 * 1024 * 1024:
|
||||
return None, "空 body 或超过 2MB"
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
obj = json.loads(raw.decode("utf-8", "replace"))
|
||||
except Exception as e:
|
||||
return None, f"JSON 解析失败: {type(e).__name__}"
|
||||
if isinstance(obj, dict) and "state" in obj and isinstance(obj.get("state"), dict):
|
||||
obj = obj["state"]
|
||||
return obj, None
|
||||
|
||||
def _write_ui_state_route(self, path, rel):
|
||||
"""POST → 原子写配置快照。路径固定在服务端,不接受客户端指定。"""
|
||||
obj, err = self._read_ui_post_body()
|
||||
if err is not None:
|
||||
self._post_json(json.dumps(
|
||||
{"ok": False, "log": [err]}, ensure_ascii=False).encode("utf-8"), 400)
|
||||
return
|
||||
try:
|
||||
result = _write_ui_state(obj, path=path, rel=rel)
|
||||
except Exception as e:
|
||||
self._post_json(json.dumps(
|
||||
{"ok": False, "log": [f"写入失败: {type(e).__name__}: {e}"]},
|
||||
ensure_ascii=False).encode("utf-8"), 500)
|
||||
return
|
||||
self._post_json(json.dumps(result, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def _handle_ui_state_post(self):
|
||||
"""电机页配置快照 POST → data/motor_ui_state.json。"""
|
||||
self._write_ui_state_route(UI_STATE_PATH, UI_STATE_REL)
|
||||
|
||||
def _handle_lab_ui_state_post(self):
|
||||
"""lab/spi_test 配置快照 POST → data/spi_test_ui_state.json。"""
|
||||
self._write_ui_state_route(LAB_UI_STATE_PATH, LAB_UI_STATE_REL)
|
||||
|
||||
def _post_json(self, body, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
Reference in New Issue
Block a user