115 lines
3.7 KiB
Python
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))
|