"""Output target routes: processing control, state, metrics, events, overlay. Extracted from output_targets.py to keep files under 800 lines. """ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_color_strip_store, get_output_target_store, get_picture_source_store, get_processor_manager, ) from wled_controller.api.schemas.output_targets import ( BulkTargetRequest, BulkTargetResponse, TargetMetricsResponse, TargetProcessingState, ) from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.utils import get_logger logger = get_logger(__name__) router = APIRouter() # ===== BULK PROCESSING CONTROL ENDPOINTS ===== @router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"]) async def bulk_start_processing( body: BulkTargetRequest, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Start processing for multiple output targets. Returns lists of started IDs and per-ID errors.""" started: list[str] = [] errors: dict[str, str] = {} for target_id in body.ids: try: target_store.get_target(target_id) await manager.start_processing(target_id) started.append(target_id) logger.info(f"Bulk start: started processing for target {target_id}") except ValueError as e: errors[target_id] = str(e) except RuntimeError as e: msg = str(e) for t in target_store.get_all_targets(): if t.id in msg: msg = msg.replace(t.id, f"'{t.name}'") errors[target_id] = msg except Exception as e: logger.error(f"Bulk start: failed to start target {target_id}: {e}") errors[target_id] = str(e) return BulkTargetResponse(started=started, errors=errors) @router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"]) async def bulk_stop_processing( body: BulkTargetRequest, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors.""" stopped: list[str] = [] errors: dict[str, str] = {} for target_id in body.ids: try: await manager.stop_processing(target_id) stopped.append(target_id) logger.info(f"Bulk stop: stopped processing for target {target_id}") except ValueError as e: errors[target_id] = str(e) except Exception as e: logger.error(f"Bulk stop: failed to stop target {target_id}: {e}") errors[target_id] = str(e) return BulkTargetResponse(stopped=stopped, errors=errors) # ===== PROCESSING CONTROL ENDPOINTS ===== @router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"]) async def start_processing( target_id: str, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Start processing for a output target.""" try: # Verify target exists in store target_store.get_target(target_id) await manager.start_processing(target_id) logger.info(f"Started processing for target {target_id}") return {"status": "started", "target_id": target_id} except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except RuntimeError as e: # Resolve target IDs to human-readable names in error messages msg = str(e) for t in target_store.get_all_targets(): if t.id in msg: msg = msg.replace(t.id, f"'{t.name}'") raise HTTPException(status_code=409, detail=msg) except Exception as e: logger.error(f"Failed to start processing: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"]) async def stop_processing( target_id: str, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Stop processing for a output target.""" try: await manager.stop_processing(target_id) logger.info(f"Stopped processing for target {target_id}") return {"status": "stopped", "target_id": target_id} except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to stop processing: {e}") raise HTTPException(status_code=500, detail=str(e)) # ===== STATE & METRICS ENDPOINTS ===== @router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"]) async def get_target_state( target_id: str, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get current processing state for a target.""" try: state = manager.get_target_state(target_id) return TargetProcessingState(**state) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to get target state: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"]) async def get_target_metrics( target_id: str, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get processing metrics for a target.""" try: metrics = manager.get_target_metrics(target_id) return TargetMetricsResponse(**metrics) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to get target metrics: {e}") raise HTTPException(status_code=500, detail=str(e)) # ===== STATE CHANGE EVENT STREAM ===== @router.websocket("/api/v1/events/ws") async def events_ws( websocket: WebSocket, token: str = Query(""), ): """WebSocket for real-time state change events. Auth via ?token=.""" from wled_controller.api.auth import verify_ws_token if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return await websocket.accept() manager = get_processor_manager() queue = manager.subscribe_events() try: while True: event = await queue.get() await websocket.send_json(event) except WebSocketDisconnect: pass except Exception: pass finally: manager.unsubscribe_events(queue) # ===== OVERLAY VISUALIZATION ===== @router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"]) async def start_target_overlay( target_id: str, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), target_store: OutputTargetStore = Depends(get_output_target_store), color_strip_store: ColorStripStore = Depends(get_color_strip_store), picture_source_store: PictureSourceStore = Depends(get_picture_source_store), ): """Start screen overlay visualization for a target. Displays a transparent overlay on the target display showing: - Border sampling zones (colored rectangles) - LED position markers (numbered dots) - Pixel-to-LED mapping ranges (colored segments) - Calibration info text """ try: # Get target name from store target = target_store.get_target(target_id) if not target: raise ValueError(f"Target {target_id} not found") # Pre-load calibration and display info from the CSS store so the overlay # can start even when processing is not currently running. calibration = None display_info = None if isinstance(target, WledOutputTarget) and target.color_strip_source_id: first_css_id = target.color_strip_source_id if first_css_id: try: css = color_strip_store.get_source(first_css_id) if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration: calibration = css.calibration # Resolve the display this CSS is capturing from wled_controller.api.routes.color_strip_sources import _resolve_display_index ps_id = getattr(css, "picture_source_id", "") or "" display_index = _resolve_display_index(ps_id, picture_source_store) displays = get_available_displays() if displays: display_index = min(display_index, len(displays) - 1) display_info = displays[display_index] except Exception as e: logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}") await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info) return {"status": "started", "target_id": target_id} except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except RuntimeError as e: raise HTTPException(status_code=409, detail=str(e)) except Exception as e: logger.error(f"Failed to start overlay: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"]) async def stop_target_overlay( target_id: str, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Stop screen overlay visualization for a target.""" try: await manager.stop_overlay(target_id) return {"status": "stopped", "target_id": target_id} except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to stop overlay: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"]) async def get_overlay_status( target_id: str, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Check if overlay is active for a target.""" try: active = manager.is_overlay_active(target_id) return {"target_id": target_id, "active": active} except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # ===== LED PREVIEW WEBSOCKET ===== @router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws") async def led_preview_ws( websocket: WebSocket, target_id: str, token: str = Query(""), ): """WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=.""" from wled_controller.api.auth import verify_ws_token if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return await websocket.accept() manager = get_processor_manager() try: manager.add_led_preview_client(target_id, websocket) except ValueError: await websocket.close(code=4004, reason="Target not found") return try: while True: await websocket.receive_text() except WebSocketDisconnect: pass finally: manager.remove_led_preview_client(target_id, websocket)