""" Fan-out hub between the robot bridge and every connected browser. One bridge produces state; N browsers consume it. Each client gets its own bounded queue so a slow tab (a backgrounded phone, say) can never stall the telemetry loop for everyone else - it just drops frames. """ from __future__ import annotations import asyncio import json import time from collections import deque from typing import Any class Hub: def __init__(self, history_seconds: int = 120, sample_hz: int = 10): self._clients: set[asyncio.Queue] = set() self._lock = asyncio.Lock() self._series: dict[str, deque] = {} self._history_points = max(60, int(history_seconds * sample_hz)) self._events: deque = deque(maxlen=400) # -- client lifecycle --------------------------------------------------- async def register(self) -> asyncio.Queue: queue: asyncio.Queue = asyncio.Queue(maxsize=32) async with self._lock: self._clients.add(queue) return queue async def unregister(self, queue: asyncio.Queue) -> None: async with self._lock: self._clients.discard(queue) @property def client_count(self) -> int: return len(self._clients) # -- broadcast ---------------------------------------------------------- async def broadcast(self, kind: str, payload: Any) -> None: message = json.dumps({"type": kind, "ts": time.time(), "data": payload}, separators=(",", ":"), default=str) dead = [] for queue in list(self._clients): try: queue.put_nowait(message) except asyncio.QueueFull: # Drop the oldest frame rather than the newest - stale telemetry # is worth less than current telemetry. try: queue.get_nowait() queue.put_nowait(message) except (asyncio.QueueEmpty, asyncio.QueueFull): dead.append(queue) for queue in dead: await self.unregister(queue) # -- time series -------------------------------------------------------- def record(self, key: str, value: float, ts: float | None = None) -> None: """Append one sample to a named series for the dashboard's charts.""" if value is None: return try: value = float(value) except (TypeError, ValueError): return series = self._series.get(key) if series is None: series = deque(maxlen=self._history_points) self._series[key] = series series.append((ts if ts is not None else time.time(), round(value, 4))) def series(self, key: str, limit: int | None = None) -> list[list[float]]: data = list(self._series.get(key, ())) if limit: data = data[-limit:] return [[t, v] for t, v in data] def all_series(self, limit: int | None = None) -> dict[str, list]: return {k: self.series(k, limit) for k in self._series} def series_keys(self) -> list[str]: return sorted(self._series) # -- event log ---------------------------------------------------------- def log(self, level: str, source: str, message: str, detail: Any = None) -> dict: entry = { "ts": time.time(), "level": level, "source": source, "message": message, "detail": detail, } self._events.append(entry) return entry def events(self, limit: int = 200) -> list[dict]: return list(self._events)[-limit:] async def emit(self, level: str, source: str, message: str, detail: Any = None) -> None: await self.broadcast("event", self.log(level, source, message, detail))