""" Phone call event receiver + WebSocket broadcaster. The Grandstream UCM sends a simple HTTP GET to /api/phone/call-event with caller ID and extension details. We broadcast that to all connected WebSocket clients (manager dashboard Phone page). The call-event endpoint is intentionally unauthenticated so the UCM device (which has no JWT) can POST to it directly on the local LAN. """ import asyncio import json import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query router = APIRouter() logger = logging.getLogger(__name__) # In-memory list of active WebSocket connections to the Phone page. _connections: list[WebSocket] = [] @router.websocket("/ws") async def phone_ws(websocket: WebSocket): await websocket.accept() _connections.append(websocket) logger.debug("phone_ws: client connected (%d total)", len(_connections)) try: # Keep the connection alive; client drives nothing, server pushes events. while True: await asyncio.sleep(25) await websocket.send_text(json.dumps({"type": "ping"})) except (WebSocketDisconnect, Exception): pass finally: _connections.remove(websocket) logger.debug("phone_ws: client disconnected (%d total)", len(_connections)) @router.get("/call-event") async def call_event( caller: str = Query(..., description="Caller ID number from UCM"), ext: str = Query("", description="Dialled extension"), ): """ Called by the Grandstream UCM Action URL on every incoming call. Example: GET /api/phone/call-event?caller=2106001234&ext=100 """ payload = json.dumps({"type": "incoming_call", "caller": caller, "ext": ext}) dead = [] for ws in list(_connections): try: await ws.send_text(payload) except Exception: dead.append(ws) for ws in dead: try: _connections.remove(ws) except ValueError: pass logger.info("phone call-event: caller=%s ext=%s (notified %d clients)", caller, ext, len(_connections) - len(dead)) return {"status": "ok", "notified": len(_connections) - len(dead)}