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:
cbb
2026-09-20 22:58:00 +08:00
parent e91d53e0fc
commit 383535bd1d
23 changed files with 2060 additions and 283 deletions
+178 -1
View File
@@ -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")