2026-09-03 00:10:18 +04:00

115 lines
3.7 KiB
Python

"""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))