"""Output target routes: CRUD, processing control, settings, state, metrics.""" import asyncio import base64 import io import secrets import time import numpy as np from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from PIL import Image from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_color_strip_store, get_device_store, get_pattern_template_store, get_picture_source_store, get_output_target_store, get_pp_template_store, get_processor_manager, get_template_store, ) from wled_controller.api.schemas.output_targets import ( ExtractedColorResponse, KCTestRectangleResponse, KCTestResponse, KeyColorsResponse, KeyColorsSettingsSchema, OutputTargetCreate, OutputTargetListResponse, OutputTargetResponse, OutputTargetUpdate, TargetMetricsResponse, TargetProcessingState, ) from wled_controller.config import get_config from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.filters import FilterRegistry, ImagePool from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.capture.screen_capture import ( calculate_average_color, calculate_dominant_color, calculate_median_color, get_available_displays, ) from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_source import PictureColorStripSource from wled_controller.storage import DeviceStore from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.key_colors_output_target import ( KeyColorsSettings, KeyColorsOutputTarget, ) from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.utils import get_logger logger = get_logger(__name__) router = APIRouter() def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema: """Convert core KeyColorsSettings to schema.""" return KeyColorsSettingsSchema( fps=settings.fps, interpolation_mode=settings.interpolation_mode, smoothing=settings.smoothing, pattern_template_id=settings.pattern_template_id, brightness=settings.brightness, brightness_value_source_id=settings.brightness_value_source_id, ) def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings: """Convert schema KeyColorsSettings to core.""" return KeyColorsSettings( fps=schema.fps, interpolation_mode=schema.interpolation_mode, smoothing=schema.smoothing, pattern_template_id=schema.pattern_template_id, brightness=schema.brightness, brightness_value_source_id=schema.brightness_value_source_id, ) def _target_to_response(target) -> OutputTargetResponse: """Convert an OutputTarget to OutputTargetResponse.""" if isinstance(target, WledOutputTarget): return OutputTargetResponse( id=target.id, name=target.name, target_type=target.target_type, device_id=target.device_id, color_strip_source_id=target.color_strip_source_id, brightness_value_source_id=target.brightness_value_source_id, fps=target.fps, keepalive_interval=target.keepalive_interval, state_check_interval=target.state_check_interval, min_brightness_threshold=target.min_brightness_threshold, adaptive_fps=target.adaptive_fps, protocol=target.protocol, description=target.description, created_at=target.created_at, updated_at=target.updated_at, ) elif isinstance(target, KeyColorsOutputTarget): return OutputTargetResponse( id=target.id, name=target.name, target_type=target.target_type, picture_source_id=target.picture_source_id, key_colors_settings=_kc_settings_to_schema(target.settings), description=target.description, created_at=target.created_at, updated_at=target.updated_at, ) else: return OutputTargetResponse( id=target.id, name=target.name, target_type=target.target_type, description=target.description, created_at=target.created_at, updated_at=target.updated_at, ) # ===== CRUD ENDPOINTS ===== @router.post("/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201) async def create_target( data: OutputTargetCreate, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Create a new output target.""" try: # Validate device exists if provided if data.device_id: device = device_store.get_device(data.device_id) if not device: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None # Create in store target = target_store.create_target( name=data.name, target_type=data.target_type, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, brightness_value_source_id=data.brightness_value_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, ) # Register in processor manager try: target.register_with_manager(manager) except ValueError as e: logger.warning(f"Could not register target {target.id} in processor manager: {e}") return _target_to_response(target) except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to create target: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"]) async def list_targets( _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), ): """List all output targets.""" targets = target_store.get_all_targets() responses = [_target_to_response(t) for t in targets] return OutputTargetListResponse(targets=responses, count=len(responses)) @router.get("/api/v1/output-targets/batch/states", tags=["Processing"]) async def batch_target_states( _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get processing state for all targets in a single request.""" return {"states": manager.get_all_target_states()} @router.get("/api/v1/output-targets/batch/metrics", tags=["Metrics"]) async def batch_target_metrics( _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get metrics for all targets in a single request.""" return {"metrics": manager.get_all_target_metrics()} @router.get("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]) async def get_target( target_id: str, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), ): """Get a output target by ID.""" try: target = target_store.get_target(target_id) return _target_to_response(target) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @router.put("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]) async def update_target( target_id: str, data: OutputTargetUpdate, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Update a output target.""" try: # Validate device exists if changing if data.device_id is not None and data.device_id: device = device_store.get_device(data.device_id) if not device: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") # Build KC settings with partial-update support: only apply fields that were # explicitly provided in the request body, merging with the existing settings. kc_settings = None if data.key_colors_settings is not None: incoming = data.key_colors_settings.model_dump(exclude_unset=True) try: existing_target = target_store.get_target(target_id) except ValueError: existing_target = None if isinstance(existing_target, KeyColorsOutputTarget): ex = existing_target.settings merged = KeyColorsSettingsSchema( fps=incoming.get("fps", ex.fps), interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode), smoothing=incoming.get("smoothing", ex.smoothing), pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id), brightness=incoming.get("brightness", ex.brightness), brightness_value_source_id=incoming.get("brightness_value_source_id", ex.brightness_value_source_id), ) kc_settings = _kc_schema_to_settings(merged) else: kc_settings = _kc_schema_to_settings(data.key_colors_settings) # Update in store target = target_store.update_target( target_id=target_id, name=data.name, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, brightness_value_source_id=data.brightness_value_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, key_colors_settings=kc_settings, description=data.description, ) # Detect KC brightness VS change (inside key_colors_settings) kc_brightness_vs_changed = False if data.key_colors_settings is not None: kc_incoming = data.key_colors_settings.model_dump(exclude_unset=True) if "brightness_value_source_id" in kc_incoming: kc_brightness_vs_changed = True # Sync processor manager (run in thread — css release/acquire can block) try: await asyncio.to_thread( target.sync_with_manager, manager, settings_changed=(data.fps is not None or data.keepalive_interval is not None or data.state_check_interval is not None or data.min_brightness_threshold is not None or data.adaptive_fps is not None or data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed), ) except ValueError: pass return _target_to_response(target) except HTTPException: raise except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to update target: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"]) async def delete_target( target_id: str, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Delete a output target. Stops processing first if active.""" try: # Stop processing if running try: await manager.stop_processing(target_id) except ValueError: pass # Remove from manager try: manager.remove_target(target_id) except (ValueError, RuntimeError): pass # Delete from store target_store.delete_target(target_id) logger.info(f"Deleted target {target_id}") except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to delete target: {e}") raise HTTPException(status_code=500, detail=str(e)) # ===== 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: raise HTTPException(status_code=409, detail=str(e)) 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)) # ===== KEY COLORS ENDPOINTS ===== @router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"]) async def get_target_colors( target_id: str, _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get latest extracted colors for a key-colors target (polling).""" try: raw_colors = manager.get_kc_latest_colors(target_id) colors = {} for name, (r, g, b) in raw_colors.items(): colors[name] = ExtractedColorResponse( r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}", ) from datetime import datetime return KeyColorsResponse( target_id=target_id, colors=colors, timestamp=datetime.utcnow(), ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"]) async def test_kc_target( target_id: str, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), source_store: PictureSourceStore = Depends(get_picture_source_store), template_store: TemplateStore = Depends(get_template_store), pattern_store: PatternTemplateStore = Depends(get_pattern_template_store), processor_manager: ProcessorManager = Depends(get_processor_manager), device_store: DeviceStore = Depends(get_device_store), pp_template_store=Depends(get_pp_template_store), ): """Test a key-colors target: capture a frame, extract colors from each rectangle.""" import httpx stream = None try: # 1. Load and validate KC target try: target = target_store.get_target(target_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) if not isinstance(target, KeyColorsOutputTarget): raise HTTPException(status_code=400, detail="Target is not a key_colors target") settings = target.settings # 2. Resolve pattern template if not settings.pattern_template_id: raise HTTPException(status_code=400, detail="No pattern template configured") try: pattern_tmpl = pattern_store.get_template(settings.pattern_template_id) except ValueError: raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}") rectangles = pattern_tmpl.rectangles if not rectangles: raise HTTPException(status_code=400, detail="Pattern template has no rectangles") # 3. Resolve picture source and capture a frame if not target.picture_source_id: raise HTTPException(status_code=400, detail="No picture source configured") try: chain = source_store.resolve_stream_chain(target.picture_source_id) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) raw_stream = chain["raw_stream"] if isinstance(raw_stream, StaticImagePictureSource): source = raw_stream.image_source if source.startswith(("http://", "https://")): async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: resp = await client.get(source) resp.raise_for_status() pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") else: from pathlib import Path path = Path(source) if not path.exists(): raise HTTPException(status_code=400, detail=f"Image file not found: {source}") pil_image = Image.open(path).convert("RGB") elif isinstance(raw_stream, ScreenCapturePictureSource): try: capture_template = template_store.get_template(raw_stream.capture_template_id) except ValueError: raise HTTPException( status_code=400, detail=f"Capture template not found: {raw_stream.capture_template_id}", ) display_index = raw_stream.display_index if capture_template.engine_type not in EngineRegistry.get_available_engines(): raise HTTPException( status_code=400, detail=f"Engine '{capture_template.engine_type}' is not available on this system", ) locked_device_id = processor_manager.get_display_lock_info(display_index) if locked_device_id: try: device = device_store.get_device(locked_device_id) device_name = device.name except Exception: device_name = locked_device_id raise HTTPException( status_code=409, detail=f"Display {display_index} is currently being captured by device '{device_name}'. " f"Please stop the device processing before testing.", ) stream = EngineRegistry.create_stream( capture_template.engine_type, display_index, capture_template.engine_config ) stream.initialize() screen_capture = stream.capture_frame() if screen_capture is None: raise RuntimeError("No frame captured") if isinstance(screen_capture.image, np.ndarray): pil_image = Image.fromarray(screen_capture.image) else: raise ValueError("Unexpected image format from engine") else: raise HTTPException(status_code=400, detail="Unsupported picture source type") # 3b. Apply postprocessing filters (if the picture source has a filter chain) pp_template_ids = chain.get("postprocessing_template_ids", []) if pp_template_ids and pp_template_store: img_array = np.array(pil_image) image_pool = ImagePool() for pp_id in pp_template_ids: try: pp_template = pp_template_store.get_template(pp_id) except ValueError: logger.warning(f"KC test: PP template {pp_id} not found, skipping") continue flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters) for fi in flat_filters: try: f = FilterRegistry.create_instance(fi.filter_id, fi.options) result = f.process_image(img_array, image_pool) if result is not None: img_array = result except ValueError: logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping") pil_image = Image.fromarray(img_array) # 4. Extract colors from each rectangle img_array = np.array(pil_image) h, w = img_array.shape[:2] calc_fns = { "average": calculate_average_color, "median": calculate_median_color, "dominant": calculate_dominant_color, } calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color) result_rects = [] for rect in rectangles: px_x = max(0, int(rect.x * w)) px_y = max(0, int(rect.y * h)) px_w = max(1, int(rect.width * w)) px_h = max(1, int(rect.height * h)) px_x = min(px_x, w - 1) px_y = min(px_y, h - 1) px_w = min(px_w, w - px_x) px_h = min(px_h, h - px_y) sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w] r, g, b = calc_fn(sub_img) result_rects.append(KCTestRectangleResponse( name=rect.name, x=rect.x, y=rect.y, width=rect.width, height=rect.height, color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"), )) # 5. Encode frame as base64 JPEG full_buffer = io.BytesIO() pil_image.save(full_buffer, format='JPEG', quality=90) full_buffer.seek(0) full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') image_data_uri = f"data:image/jpeg;base64,{full_b64}" return KCTestResponse( image=image_data_uri, rectangles=result_rects, interpolation_mode=settings.interpolation_mode, pattern_template_name=pattern_tmpl.name, ) except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except RuntimeError as e: raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}") except Exception as e: logger.error(f"Failed to test KC target: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) finally: if stream: try: stream.cleanup() except Exception as e: logger.error(f"Error cleaning up test stream: {e}") @router.websocket("/api/v1/output-targets/{target_id}/ws") async def target_colors_ws( websocket: WebSocket, target_id: str, token: str = Query(""), ): """WebSocket for real-time key color updates. Auth via ?token=.""" # Authenticate authenticated = False cfg = get_config() if token and cfg.auth.api_keys: for _label, api_key in cfg.auth.api_keys.items(): if secrets.compare_digest(token, api_key): authenticated = True break if not authenticated: await websocket.close(code=4001, reason="Unauthorized") return await websocket.accept() manager = get_processor_manager() try: manager.add_kc_ws_client(target_id, websocket) except ValueError: await websocket.close(code=4004, reason="Target not found") return try: while True: # Keep alive — wait for client messages (or disconnect) await websocket.receive_text() except WebSocketDisconnect: pass finally: manager.remove_kc_ws_client(target_id, 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=.""" authenticated = False cfg = get_config() if token and cfg.auth.api_keys: for _label, api_key in cfg.auth.api_keys.items(): if secrets.compare_digest(token, api_key): authenticated = True break if not authenticated: 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) # ===== 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=.""" authenticated = False cfg = get_config() if token and cfg.auth.api_keys: for _label, api_key in cfg.auth.api_keys.items(): if secrets.compare_digest(token, api_key): authenticated = True break if not authenticated: 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) 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 display_index = _resolve_display_index(css.picture_source_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))