"""HTTP and WebSocket routes for the embedded radar web UI. Every handler is a thin shell over the :class:`WebController` (which forwards to the AppWindow's existing buttons) and the :class:`RingBroadcaster` (the single frame source). There is no ownership gating: the web UI lives inside the process that already owns the hardware, so its controls are simply that process's buttons. """ from __future__ import annotations import asyncio import contextlib import logging from fastapi import APIRouter, Body, HTTPException, Request, WebSocket, WebSocketDisconnect from python_app.webui.controller import WebActionError, WebController from python_app.webui.streaming import RingBroadcaster logger = logging.getLogger(__name__) router = APIRouter() def _controller(request: Request) -> WebController: return request.app.state.controller async def _run_action(func, *args) -> None: """Run a (blocking) controller control call off the event loop, mapping failures to 400. The control methods run the desktop action synchronously and raise ``ValueError`` (rejected input) or ``WebActionError`` (the action itself failed) — both become a 400 the browser shows, instead of the old silent "ok". Running in a worker thread keeps the async event loop free while the GUI thread does the work. """ try: await asyncio.to_thread(func, *args) except (ValueError, WebActionError) as exc: logger.warning("Web UI action failed: %s", exc) raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/api/status") async def get_status(request: Request) -> dict: return _controller(request).status() @router.post("/api/start") async def post_start(request: Request) -> dict: logger.info("Web UI request: start") controller = _controller(request) await _run_action(controller.start) return controller.status() @router.post("/api/single_capture") async def post_single_capture(request: Request) -> dict: logger.info("Web UI request: single capture") controller = _controller(request) await _run_action(controller.single_capture) return controller.status() @router.post("/api/stop") async def post_stop(request: Request) -> dict: logger.info("Web UI request: stop") controller = _controller(request) await _run_action(controller.stop) return controller.status() @router.post("/api/start_recording") async def post_start_recording( request: Request, path: str = Body("", embed=True), name: str = Body("", embed=True), count: int = Body(..., embed=True), ) -> dict: logger.info("Web UI request: start with disk recording (count=%s)", count) controller = _controller(request) await _run_action(controller.start_recording, path, name, count) return controller.status() @router.post("/api/tmp_reference") async def post_tmp_reference(request: Request) -> dict: logger.info("Web UI request: capture temporary reference") controller = _controller(request) await _run_action(controller.capture_tmp_reference) return controller.status() @router.post("/api/remove_last") async def post_remove_last(request: Request) -> dict: logger.info("Web UI request: remove last measurement") controller = _controller(request) await _run_action(controller.remove_last_measurement) return controller.status() @router.get("/api/configs") async def get_configs(request: Request) -> dict: return {"names": _controller(request).list_configs()} @router.post("/api/load_config") async def post_load_config(request: Request, name: str = Body(..., embed=True)) -> dict: logger.info("Web UI request: load config %r", name) controller = _controller(request) await _run_action(controller.load_config, name) return controller.status() @router.post("/api/save_dataset") async def post_save_dataset( request: Request, path: str = Body("", embed=True), name: str = Body("", embed=True), ) -> dict: logger.info("Web UI request: save dataset") controller = _controller(request) await _run_action(controller.save_dataset, path, name) return controller.status() @router.get("/api/live_settings") async def get_live_settings(request: Request) -> list: return _controller(request).current_live_settings() @router.post("/api/live_settings") async def post_live_settings(request: Request, fields: dict = Body(default={})) -> list: try: return _controller(request).apply_live_settings(fields) except ValueError as exc: logger.warning("Web UI rejected live settings update: %s", exc) raise HTTPException(status_code=400, detail=str(exc)) from exc @router.websocket("/ws") async def ws(websocket: WebSocket) -> None: """Stream frames and status to one client until it disconnects.""" await websocket.accept() broadcaster: RingBroadcaster = websocket.app.state.broadcaster queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1) broadcaster.register(queue) logger.info("Web UI client connected") try: while True: await websocket.send_json(await queue.get()) except WebSocketDisconnect: logger.info("Web UI client disconnected") finally: broadcaster.unregister(queue) with contextlib.suppress(Exception): await websocket.close()