""" HF Space / Gradio live BLE consumer — connects to operator tunnel (not localhost on cloud). Set LYGO_BLE_WS_URL=wss://your-tunnel.example/ws or pass URL in UI. """ from __future__ import annotations import json import os import threading import time from typing import Any _lock = threading.Lock() _state: dict[str, Any] = { "connected": False, "last_error": "", "latest_ibi_ms": 0, "buffer_size": 0, "h_min": 0.0, "seed": "", "ibi_history": [], "ws_url": "", } _ws_thread: threading.Thread | None = None _ws_stop = threading.Event() def _apply_message(data: dict) -> None: global _state with _lock: if data.get("type") == "ibi": ibi = int(data.get("ibi_ms") or 0) if ibi > 0: _state["latest_ibi_ms"] = ibi hist = list(_state.get("ibi_history") or []) hist.append(ibi) _state["ibi_history"] = hist[-100:] _state["buffer_size"] = int(data.get("buffer_size") or _state.get("buffer_size") or 0) if data.get("h_min") is not None: _state["h_min"] = float(data["h_min"]) elif data.get("type") == "seed": _state["seed"] = str(data.get("seed") or data.get("seed_256") or "") if data.get("h_min") is not None: _state["h_min"] = float(data["h_min"]) _state["buffer_size"] = 0 elif data.get("type") in ("snapshot", "hello"): if data.get("latest_ibi_ms"): _state["latest_ibi_ms"] = int(data["latest_ibi_ms"]) if data.get("ibi_history"): _state["ibi_history"] = list(data["ibi_history"])[-100:] if data.get("buffer_size") is not None: _state["buffer_size"] = int(data["buffer_size"]) if data.get("h_min") is not None: _state["h_min"] = float(data["h_min"]) if data.get("seed_256"): _state["seed"] = str(data["seed_256"]) def _ws_loop(url: str) -> None: try: import websocket # websocket-client except ImportError: with _lock: _state["last_error"] = "pip install websocket-client" _state["connected"] = False return def on_message(_ws, message: str) -> None: try: _apply_message(json.loads(message)) except json.JSONDecodeError: pass def on_open(_ws) -> None: with _lock: _state["connected"] = True _state["last_error"] = "" def on_error(_ws, err) -> None: with _lock: _state["connected"] = False _state["last_error"] = str(err)[:200] def on_close(_ws, *_args) -> None: with _lock: _state["connected"] = False while not _ws_stop.is_set(): app = websocket.WebSocketApp(url, on_message=on_message, on_open=on_open, on_error=on_error, on_close=on_close) app.run_forever(ping_interval=20, ping_timeout=10) if _ws_stop.is_set(): break time.sleep(2.0) def connect_ws(url: str) -> str: global _ws_thread url = (url or "").strip() if not url: url = os.environ.get("LYGO_BLE_WS_URL", "").strip() if not url: return "Set **LYGO_BLE_WS_URL** (Space secret) or paste your `wss://` tunnel URL." if url.startswith("https://"): url = "wss://" + url[len("https://") :] if url.startswith("http://"): url = "ws://" + url[len("http://") :] _ws_stop.set() if _ws_thread and _ws_thread.is_alive(): _ws_thread.join(timeout=2.0) _ws_stop.clear() with _lock: _state["ws_url"] = url _ws_thread = threading.Thread(target=_ws_loop, args=(url,), daemon=True) _ws_thread.start() return f"Connecting to `{url}` (tunnel → local :8788)…" def disconnect_ws() -> str: _ws_stop.set() with _lock: _state["connected"] = False return "Disconnected." def refresh_dashboard() -> tuple: """Returns Gradio outputs: ibi, buffer, h_min, seed, lineplot df, hist list, status md.""" import pandas as pd with _lock: st = dict(_state) hist = st.get("ibi_history") or [] seed = st.get("seed") or "" if not seed: seed = "Awaiting 64 IBIs…" status = ( f"**WS:** `{'connected' if st.get('connected') else 'disconnected'}` · " f"**URL:** `{st.get('ws_url') or '—'}`" ) if st.get("last_error"): status += f"\n\nError: `{st['last_error']}`" if not st.get("ws_url"): status += ( "\n\nCloud HF cannot use `localhost`. Expose local ingest with **Cloudflare Tunnel** or **ngrok** " "to port **8788**, then set `LYGO_BLE_WS_URL`." ) wave = hist[-50:] if hist else [0] df = pd.DataFrame({"time": list(range(len(wave))), "ibi": wave}) return ( float(st.get("latest_ibi_ms") or 0), int(st.get("buffer_size") or 0), float(st.get("h_min") or 0.0), seed, df, wave, status, ) def load_local_snapshot_from_file(path: str) -> str: """Fallback: read latest_seed.json from synced workspace (no live WS).""" from pathlib import Path p = Path(path) if not p.is_file(): return "No local seed file." data = json.loads(p.read_text(encoding="utf-8")) _apply_message({"type": "seed", "seed": data.get("seed_256"), "h_min": data.get("h_min")}) return f"Loaded seed preview `{str(data.get('seed_preview', ''))[:16]}` from file."