""" SLMM (Sound Level Meter Manager) Proxy Router Proxies requests from Terra-View to the standalone SLMM backend service. SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication. """ from fastapi import APIRouter, HTTPException, Request, Response, WebSocket, WebSocketDisconnect from fastapi.responses import StreamingResponse import httpx import websockets import asyncio import logging import os logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/slmm", tags=["slmm"]) # SLMM backend URL - configurable via environment variable SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") # WebSocket URL derived from HTTP URL SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://") @router.get("/health") async def check_slmm_health(): """ Check if the SLMM backend service is reachable and healthy. """ try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(f"{SLMM_BASE_URL}/health") if response.status_code == 200: data = response.json() return { "status": "ok", "slmm_status": "connected", "slmm_url": SLMM_BASE_URL, "slmm_version": data.get("version", "unknown"), "slmm_response": data } else: return { "status": "degraded", "slmm_status": "error", "slmm_url": SLMM_BASE_URL, "detail": f"SLMM returned status {response.status_code}" } except httpx.ConnectError: return { "status": "error", "slmm_status": "unreachable", "slmm_url": SLMM_BASE_URL, "detail": "Cannot connect to SLMM backend. Is it running?" } except Exception as e: return { "status": "error", "slmm_status": "error", "slmm_url": SLMM_BASE_URL, "detail": str(e) } # WebSocket routes MUST come before the catch-all route @router.websocket("/{unit_id}/stream") async def proxy_websocket_stream(websocket: WebSocket, unit_id: str): """ Proxy WebSocket connections to SLMM's /stream endpoint. This allows real-time streaming of measurement data from NL43 devices through the Terra-View unified interface. """ await websocket.accept() logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}") # Build target WebSocket URL target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream" logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}") backend_ws = None try: # Connect to SLMM backend WebSocket backend_ws = await websockets.connect(target_ws_url) logger.info(f"Connected to SLMM backend WebSocket for {unit_id}") # Create tasks for bidirectional communication async def forward_to_backend(): """Forward messages from client to SLMM backend""" try: while True: data = await websocket.receive_text() await backend_ws.send(data) except WebSocketDisconnect: logger.info(f"Client WebSocket disconnected for {unit_id}") except Exception as e: logger.error(f"Error forwarding to backend: {e}") async def forward_to_client(): """Forward messages from SLMM backend to client""" try: async for message in backend_ws: await websocket.send_text(message) except websockets.exceptions.ConnectionClosed: logger.info(f"Backend WebSocket closed for {unit_id}") except Exception as e: logger.error(f"Error forwarding to client: {e}") # Run both forwarding tasks concurrently await asyncio.gather( forward_to_backend(), forward_to_client(), return_exceptions=True ) except websockets.exceptions.WebSocketException as e: logger.error(f"WebSocket error connecting to SLMM backend: {e}") try: await websocket.send_json({ "error": "Failed to connect to SLMM backend", "detail": str(e) }) except Exception: pass except Exception as e: logger.error(f"Unexpected error in WebSocket proxy for {unit_id}: {e}") try: await websocket.send_json({ "error": "Internal server error", "detail": str(e) }) except Exception: pass finally: # Clean up connections if backend_ws: try: await backend_ws.close() except Exception: pass try: await websocket.close() except Exception: pass logger.info(f"WebSocket proxy closed for {unit_id}") @router.websocket("/{unit_id}/live") async def proxy_websocket_live(websocket: WebSocket, unit_id: str): """ Proxy WebSocket connections to SLMM's /live endpoint. Alternative WebSocket endpoint that may be used by some frontend components. """ await websocket.accept() logger.info(f"WebSocket connection accepted for SLMM unit {unit_id} (live endpoint)") # Build target WebSocket URL - try /stream endpoint as SLMM uses that for WebSocket target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream" logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}") backend_ws = None try: # Connect to SLMM backend WebSocket backend_ws = await websockets.connect(target_ws_url) logger.info(f"Connected to SLMM backend WebSocket for {unit_id} (live endpoint)") # Create tasks for bidirectional communication async def forward_to_backend(): """Forward messages from client to SLMM backend""" try: while True: data = await websocket.receive_text() await backend_ws.send(data) except WebSocketDisconnect: logger.info(f"Client WebSocket disconnected for {unit_id} (live)") except Exception as e: logger.error(f"Error forwarding to backend (live): {e}") async def forward_to_client(): """Forward messages from SLMM backend to client""" try: async for message in backend_ws: await websocket.send_text(message) except websockets.exceptions.ConnectionClosed: logger.info(f"Backend WebSocket closed for {unit_id} (live)") except Exception as e: logger.error(f"Error forwarding to client (live): {e}") # Run both forwarding tasks concurrently await asyncio.gather( forward_to_backend(), forward_to_client(), return_exceptions=True ) except websockets.exceptions.WebSocketException as e: logger.error(f"WebSocket error connecting to SLMM backend (live): {e}") try: await websocket.send_json({ "error": "Failed to connect to SLMM backend", "detail": str(e) }) except Exception: pass except Exception as e: logger.error(f"Unexpected error in WebSocket proxy for {unit_id} (live): {e}") try: await websocket.send_json({ "error": "Internal server error", "detail": str(e) }) except Exception: pass finally: # Clean up connections if backend_ws: try: await backend_ws.close() except Exception: pass try: await websocket.close() except Exception: pass logger.info(f"WebSocket proxy closed for {unit_id} (live)") # HTTP catch-all route MUST come after specific routes (including WebSocket routes) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def proxy_to_slmm(path: str, request: Request): """ Proxy all requests to the SLMM backend service. This allows Terra-View to act as a unified frontend for all device types, while SLMM remains a standalone backend service. """ # Build target URL target_url = f"{SLMM_BASE_URL}/api/nl43/{path}" # Get query parameters query_params = dict(request.query_params) # Get request body if present body = None if request.method in ["POST", "PUT", "PATCH"]: try: body = await request.body() except Exception as e: logger.error(f"Failed to read request body: {e}") body = None # Get headers (exclude host and other proxy-specific headers) headers = dict(request.headers) headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"] proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude} logger.info(f"Proxying {request.method} request to SLMM: {target_url}") try: async with httpx.AsyncClient(timeout=30.0) as client: # Forward the request to SLMM response = await client.request( method=request.method, url=target_url, params=query_params, headers=proxy_headers, content=body ) # Return the response from SLMM return Response( content=response.content, status_code=response.status_code, headers=dict(response.headers), media_type=response.headers.get("content-type") ) except httpx.ConnectError: logger.error(f"Failed to connect to SLMM backend at {SLMM_BASE_URL}") raise HTTPException( status_code=503, detail=f"SLMM backend service unavailable. Is SLMM running on {SLMM_BASE_URL}?" ) except httpx.TimeoutException: logger.error(f"Timeout connecting to SLMM backend at {SLMM_BASE_URL}") raise HTTPException( status_code=504, detail="SLMM backend timeout" ) except Exception as e: logger.error(f"Error proxying to SLMM: {e}") raise HTTPException( status_code=500, detail=f"Failed to proxy request to SLMM: {str(e)}" )