"""Real-time channel to the dashboard. One WebSocket replaces all polling: connection state, speech lifecycle, errors and history updates are pushed the moment they happen. The socket also answers client pings so the UI can display an honest browser->backend round-trip time. """ from __future__ import annotations import asyncio import json import logging from typing import Any, Dict from fastapi import APIRouter, WebSocket, WebSocketDisconnect from ..core.events import Event, EventBus, EventType logger = logging.getLogger(__name__) ws_router = APIRouter() @ws_router.websocket("/ws") async def dashboard_socket(websocket: WebSocket) -> None: await websocket.accept() app = websocket.app bus: EventBus = app.state.event_bus manager = app.state.robot_manager speech = app.state.speech_service settings = app.state.settings subscription = bus.subscribe(name="ws") logger.debug("dashboard socket opened (subscribers=%d)", bus.subscriber_count) try: # Prime the page with everything it needs for a correct first paint. await _send( websocket, { "type": "hello", "data": { "config": settings.public_dict(), "status": _status(manager, speech), "history": speech.annotate_saved(speech.history.list()), }, }, ) pump = asyncio.create_task( _pump(websocket, subscription, speech), name="ws-pump" ) try: while True: raw = await websocket.receive_text() await _handle_client_message(websocket, raw, manager, speech) finally: pump.cancel() try: await pump except (asyncio.CancelledError, Exception): pass except WebSocketDisconnect: logger.debug("dashboard socket closed by client") except Exception as exc: # pragma: no cover - transport level logger.debug("dashboard socket error: %s", exc) finally: subscription.close() async def _pump(websocket: WebSocket, subscription, speech) -> None: """Forward bus events to this browser until cancelled.""" while True: event: Event = await subscription.get() if event is None: # pragma: no cover continue payload = event.to_dict() if event.type == EventType.ROBOT_STATUS: # The robot layer does not know about the speech queue; add it here so # every status the page sees carries a current busy flag. payload["data"] = dict(payload["data"]) payload["data"]["busy"] = speech.is_busy payload["data"]["activeRequestId"] = speech.active_request_id await _send(websocket, payload) async def _handle_client_message(websocket: WebSocket, raw: str, manager, speech) -> None: try: message: Dict[str, Any] = json.loads(raw) except json.JSONDecodeError: return kind = message.get("type") if kind == "ping": # Echo the client's timestamp so it can compute RTT without clock sync. await _send(websocket, {"type": "pong", "data": {"t": message.get("t")}}) elif kind == "status": await _send( websocket, {"type": EventType.ROBOT_STATUS, "data": _status(manager, speech)} ) elif kind == "reconnect": manager.request_reconnect() def _status(manager, speech) -> Dict[str, Any]: status = manager.status_dict() status["busy"] = speech.is_busy status["activeRequestId"] = speech.active_request_id return status async def _send(websocket: WebSocket, payload: Dict[str, Any]) -> None: await websocket.send_text(json.dumps(payload, ensure_ascii=False, default=str))