diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 4b5baa5..0ac6fa2 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -7,6 +7,7 @@ from .routes.devices import router as devices_router from .routes.templates import router as templates_router from .routes.postprocessing import router as postprocessing_router from .routes.picture_sources import router as picture_sources_router +from .routes.picture_targets import router as picture_targets_router router = APIRouter() router.include_router(system_router) @@ -14,5 +15,6 @@ router.include_router(devices_router) router.include_router(templates_router) router.include_router(postprocessing_router) router.include_router(picture_sources_router) +router.include_router(picture_targets_router) __all__ = ["router"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 6b5bccb..4c93bd0 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -5,12 +5,14 @@ from wled_controller.storage import DeviceStore from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore +from wled_controller.storage.picture_target_store import PictureTargetStore # Global instances (initialized in main.py) _device_store: DeviceStore | None = None _template_store: TemplateStore | None = None _pp_template_store: PostprocessingTemplateStore | None = None _picture_source_store: PictureSourceStore | None = None +_picture_target_store: PictureTargetStore | None = None _processor_manager: ProcessorManager | None = None @@ -42,6 +44,13 @@ def get_picture_source_store() -> PictureSourceStore: return _picture_source_store +def get_picture_target_store() -> PictureTargetStore: + """Get picture target store dependency.""" + if _picture_target_store is None: + raise RuntimeError("Picture target store not initialized") + return _picture_target_store + + def get_processor_manager() -> ProcessorManager: """Get processor manager dependency.""" if _processor_manager is None: @@ -55,11 +64,14 @@ def init_dependencies( processor_manager: ProcessorManager, pp_template_store: PostprocessingTemplateStore | None = None, picture_source_store: PictureSourceStore | None = None, + picture_target_store: PictureTargetStore | None = None, ): """Initialize global dependencies.""" - global _device_store, _template_store, _processor_manager, _pp_template_store, _picture_source_store + global _device_store, _template_store, _processor_manager + global _pp_template_store, _picture_source_store, _picture_target_store _device_store = device_store _template_store = template_store _processor_manager = processor_manager _pp_template_store = pp_template_store _picture_source_store = picture_source_store + _picture_target_store = picture_target_store diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 74db65f..a090721 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -1,6 +1,4 @@ -"""Device routes: CRUD, processing control, settings, brightness, calibration, metrics.""" - -from datetime import datetime +"""Device routes: CRUD, health state, brightness, calibration.""" import httpx from fastapi import APIRouter, HTTPException, Depends @@ -8,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Depends from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_device_store, + get_picture_target_store, get_processor_manager, ) from wled_controller.api.schemas.devices import ( @@ -17,17 +16,16 @@ from wled_controller.api.schemas.devices import ( DeviceCreate, DeviceListResponse, DeviceResponse, + DeviceStateResponse, DeviceUpdate, - MetricsResponse, - ProcessingSettings as ProcessingSettingsSchema, - ProcessingState, ) from wled_controller.core.calibration import ( calibration_from_dict, calibration_to_dict, ) -from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings +from wled_controller.core.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore +from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -35,6 +33,20 @@ logger = get_logger(__name__) router = APIRouter() +def _device_to_response(device) -> DeviceResponse: + """Convert a Device to DeviceResponse.""" + return DeviceResponse( + id=device.id, + name=device.name, + url=device.url, + led_count=device.led_count, + enabled=device.enabled, + calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), + created_at=device.created_at, + updated_at=device.updated_at, + ) + + # ===== DEVICE MANAGEMENT ENDPOINTS ===== @router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201) @@ -75,6 +87,8 @@ async def create_device( status_code=422, detail=f"Connection to {device_url} timed out. Check network connectivity." ) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=422, @@ -88,37 +102,18 @@ async def create_device( led_count=wled_led_count, ) - # Add to processor manager + # Register in processor manager for health monitoring manager.add_device( device_id=device.id, device_url=device.url, led_count=device.led_count, - settings=device.settings, calibration=device.calibration, ) - return DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status="disconnected", - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - picture_source_id=device.picture_source_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) + return _device_to_response(device) + except HTTPException: + raise except Exception as e: logger.error(f"Failed to create device: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -130,39 +125,9 @@ async def list_devices( store: DeviceStore = Depends(get_device_store), ): """List all attached WLED devices.""" - try: - devices = store.get_all_devices() - - device_responses = [ - DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status="disconnected", - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - picture_source_id=device.picture_source_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) - for device in devices - ] - - return DeviceListResponse(devices=device_responses, count=len(device_responses)) - - except Exception as e: - logger.error(f"Failed to list devices: {e}") - raise HTTPException(status_code=500, detail=str(e)) + devices = store.get_all_devices() + responses = [_device_to_response(d) for d in devices] + return DeviceListResponse(devices=responses, count=len(responses)) @router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) @@ -170,37 +135,12 @@ async def get_device( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), ): """Get device details by ID.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - # Determine status - status = "connected" if manager.is_processing(device_id) else "disconnected" - - return DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status=status, - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - picture_source_id=device.picture_source_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) + return _device_to_response(device) @router.put("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) @@ -213,76 +153,24 @@ async def update_device( ): """Update device information.""" try: - # Check if stream changed and device is processing (for hot-swap) - old_device = store.get_device(device_id) - stream_changed = ( - update_data.picture_source_id is not None - and update_data.picture_source_id != old_device.picture_source_id - ) - was_processing = manager.is_processing(device_id) - - # Update device device = store.update_device( device_id=device_id, name=update_data.name, url=update_data.url, enabled=update_data.enabled, - picture_source_id=update_data.picture_source_id, ) - # Sync processor state when stream changed - if stream_changed: - if was_processing: - # Hot-swap: restart with new settings - logger.info(f"Hot-swapping stream for device {device_id}") - try: - await manager.stop_processing(device_id) - manager.remove_device(device_id) - manager.add_device( - device_id=device.id, - device_url=device.url, - led_count=device.led_count, - settings=device.settings, - calibration=device.calibration, - picture_source_id=device.picture_source_id, - ) - await manager.start_processing(device_id) - logger.info(f"Successfully hot-swapped stream for device {device_id}") - except Exception as e: - logger.error(f"Error during stream hot-swap: {e}") - else: - # Not processing -- update processor state so next start uses new values - manager.remove_device(device_id) - manager.add_device( - device_id=device.id, - device_url=device.url, - led_count=device.led_count, - settings=device.settings, - calibration=device.calibration, - picture_source_id=device.picture_source_id, - ) + # Sync connection info in processor manager + try: + manager.update_device_info( + device_id, + device_url=update_data.url, + led_count=None, + ) + except ValueError: + pass - return DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status="disconnected", - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - picture_source_id=device.picture_source_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) + return _device_to_response(device) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -296,22 +184,33 @@ async def delete_device( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), + target_store: PictureTargetStore = Depends(get_picture_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): - """Delete/detach a device.""" + """Delete/detach a device. Returns 409 if referenced by a target.""" try: - # Stop processing if running - if manager.is_processing(device_id): - await manager.stop_processing(device_id) + # Check if any target references this device + refs = target_store.get_targets_for_device(device_id) + if refs: + names = ", ".join(t.name for t in refs) + raise HTTPException( + status_code=409, + detail=f"Device is referenced by target(s): {names}. Delete the target(s) first." + ) # Remove from manager - manager.remove_device(device_id) + try: + manager.remove_device(device_id) + except (ValueError, RuntimeError): + pass # Delete from storage store.delete_device(device_id) logger.info(f"Deleted device {device_id}") + except HTTPException: + raise except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: @@ -319,169 +218,28 @@ async def delete_device( raise HTTPException(status_code=500, detail=str(e)) -# ===== PROCESSING CONTROL ENDPOINTS ===== +# ===== DEVICE STATE (health only) ===== -@router.post("/api/v1/devices/{device_id}/start", tags=["Processing"]) -async def start_processing( +@router.get("/api/v1/devices/{device_id}/state", response_model=DeviceStateResponse, tags=["Devices"]) +async def get_device_state( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): - """Start screen processing for a device.""" - try: - # Verify device exists - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - await manager.start_processing(device_id) - - logger.info(f"Started processing for device {device_id}") - return {"status": "started", "device_id": device_id} - - except RuntimeError as e: - raise HTTPException(status_code=400, 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/devices/{device_id}/stop", tags=["Processing"]) -async def stop_processing( - device_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Stop screen processing for a device.""" - try: - await manager.stop_processing(device_id) - - logger.info(f"Stopped processing for device {device_id}") - return {"status": "stopped", "device_id": device_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)) - - -@router.get("/api/v1/devices/{device_id}/state", response_model=ProcessingState, tags=["Processing"]) -async def get_processing_state( - device_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Get current processing state for a device.""" - try: - state = manager.get_state(device_id) - return ProcessingState(**state) - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to get state: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ===== SETTINGS ENDPOINTS ===== - -@router.get("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) -async def get_settings( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), -): - """Get processing settings for a device.""" + """Get device health/connection state.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - return ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ) - - -@router.put("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) -async def update_settings( - device_id: str, - settings: ProcessingSettingsSchema, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Update processing settings for a device. - - Merges with existing settings so callers can send partial updates. - Only fields explicitly included in the request body are applied. - """ try: - # Get existing device to merge settings - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - existing = device.settings - sent = settings.model_fields_set # fields the client actually sent - - # Merge: only override fields the client explicitly provided - new_settings = ProcessingSettings( - display_index=settings.display_index if 'display_index' in sent else existing.display_index, - fps=settings.fps if 'fps' in sent else existing.fps, - border_width=settings.border_width if 'border_width' in sent else existing.border_width, - interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode, - brightness=settings.brightness if 'brightness' in sent else existing.brightness, - gamma=existing.gamma, - saturation=existing.saturation, - smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing, - state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval, - ) - - # Apply color_correction fields if explicitly sent - if 'color_correction' in sent and settings.color_correction: - cc_sent = settings.color_correction.model_fields_set - if 'brightness' in cc_sent: - new_settings.brightness = settings.color_correction.brightness - if 'gamma' in cc_sent: - new_settings.gamma = settings.color_correction.gamma - if 'saturation' in cc_sent: - new_settings.saturation = settings.color_correction.saturation - - # Update in storage - device = store.update_device(device_id, settings=new_settings) - - # Update in manager if device exists - try: - manager.update_settings(device_id, new_settings) - except ValueError: - # Device not in manager yet, that's ok - pass - - return ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ) - + state = manager.get_device_health_dict(device_id) + return DeviceStateResponse(**state) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to update settings: {e}") - raise HTTPException(status_code=500, detail=str(e)) -# ===== WLED BRIGHTNESS ENDPOINT ===== +# ===== WLED BRIGHTNESS ENDPOINTS ===== @router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) async def get_device_brightness( @@ -568,11 +326,10 @@ async def update_calibration( # Update in storage device = store.update_device(device_id, calibration=calibration) - # Update in manager if device exists + # Update in manager (also updates active target's cached calibration) try: manager.update_calibration(device_id, calibration) except ValueError: - # Device not in manager yet, that's ok pass return CalibrationSchema(**calibration_to_dict(device.calibration)) @@ -596,11 +353,7 @@ async def set_calibration_test_mode( store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): - """Toggle calibration test mode for specific edges. - - Send edges with colors to light them up, or empty edges dict to exit test mode. - While test mode is active, screen capture processing is paused. - """ + """Toggle calibration test mode for specific edges.""" try: device = store.get_device(device_id) if not device: @@ -641,23 +394,3 @@ async def set_calibration_test_mode( except Exception as e: logger.error(f"Failed to set test mode: {e}") raise HTTPException(status_code=500, detail=str(e)) - - -# ===== METRICS ENDPOINTS ===== - -@router.get("/api/v1/devices/{device_id}/metrics", response_model=MetricsResponse, tags=["Metrics"]) -async def get_metrics( - device_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Get processing metrics for a device.""" - try: - metrics = manager.get_metrics(device_id) - return MetricsResponse(**metrics) - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to get metrics: {e}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index 231c91c..f9dad08 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -14,6 +14,7 @@ from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_device_store, get_picture_source_store, + get_picture_target_store, get_pp_template_store, get_processor_manager, get_template_store, @@ -36,6 +37,7 @@ from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.filters import FilterRegistry, ImagePool from wled_controller.core.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore +from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore @@ -256,16 +258,16 @@ async def delete_picture_source( stream_id: str, _auth: AuthRequired, store: PictureSourceStore = Depends(get_picture_source_store), - device_store: DeviceStore = Depends(get_device_store), + target_store: PictureTargetStore = Depends(get_picture_target_store), ): """Delete a picture source.""" try: - # Check if any device references this stream - if store.is_referenced_by_device(stream_id, device_store): + # Check if any target references this stream + if store.is_referenced_by_target(stream_id, target_store): raise HTTPException( status_code=409, - detail="Cannot delete picture source: it is assigned to one or more devices. " - "Please reassign those devices before deleting.", + detail="Cannot delete picture source: it is assigned to one or more targets. " + "Please reassign those targets before deleting.", ) store.delete_stream(stream_id) except HTTPException: diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py new file mode 100644 index 0000000..4abe6bc --- /dev/null +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -0,0 +1,415 @@ +"""Picture target routes: CRUD, processing control, settings, state, metrics.""" + +from fastapi import APIRouter, HTTPException, Depends + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_device_store, + get_picture_target_store, + get_processor_manager, +) +from wled_controller.api.schemas.picture_targets import ( + PictureTargetCreate, + PictureTargetListResponse, + PictureTargetResponse, + PictureTargetUpdate, + ProcessingSettings as ProcessingSettingsSchema, + TargetMetricsResponse, + TargetProcessingState, +) +from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings +from wled_controller.storage import DeviceStore +from wled_controller.storage.picture_target import WledPictureTarget +from wled_controller.storage.picture_target_store import PictureTargetStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings: + """Convert schema ProcessingSettings to core ProcessingSettings.""" + settings = ProcessingSettings( + display_index=schema.display_index, + fps=schema.fps, + border_width=schema.border_width, + interpolation_mode=schema.interpolation_mode, + brightness=schema.brightness, + smoothing=schema.smoothing, + state_check_interval=schema.state_check_interval, + ) + if schema.color_correction: + settings.gamma = schema.color_correction.gamma + settings.saturation = schema.color_correction.saturation + # color_correction.brightness maps to settings.brightness + settings.brightness = schema.color_correction.brightness + return settings + + +def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchema: + """Convert core ProcessingSettings to schema ProcessingSettings.""" + from wled_controller.api.schemas.picture_targets import ColorCorrection + return ProcessingSettingsSchema( + display_index=settings.display_index, + fps=settings.fps, + border_width=settings.border_width, + interpolation_mode=settings.interpolation_mode, + brightness=settings.brightness, + smoothing=settings.smoothing, + state_check_interval=settings.state_check_interval, + color_correction=ColorCorrection( + gamma=settings.gamma, + saturation=settings.saturation, + brightness=settings.brightness, + ), + ) + + +def _target_to_response(target) -> PictureTargetResponse: + """Convert a PictureTarget to PictureTargetResponse.""" + settings_schema = _settings_to_schema(target.settings) if isinstance(target, WledPictureTarget) else ProcessingSettingsSchema() + return PictureTargetResponse( + id=target.id, + name=target.name, + target_type=target.target_type, + device_id=target.device_id if isinstance(target, WledPictureTarget) else "", + picture_source_id=target.picture_source_id if isinstance(target, WledPictureTarget) else "", + settings=settings_schema, + description=target.description, + created_at=target.created_at, + updated_at=target.updated_at, + ) + + +# ===== CRUD ENDPOINTS ===== + +@router.post("/api/v1/picture-targets", response_model=PictureTargetResponse, tags=["Targets"], status_code=201) +async def create_target( + data: PictureTargetCreate, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), + device_store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Create a new picture 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") + + # Convert settings + core_settings = _settings_to_core(data.settings) if data.settings else None + + # Create in store + target = target_store.create_target( + name=data.name, + target_type=data.target_type, + device_id=data.device_id, + picture_source_id=data.picture_source_id, + settings=core_settings, + description=data.description, + ) + + # Register in processor manager + if isinstance(target, WledPictureTarget) and target.device_id: + try: + manager.add_target( + target_id=target.id, + device_id=target.device_id, + settings=target.settings, + picture_source_id=target.picture_source_id, + ) + 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/picture-targets", response_model=PictureTargetListResponse, tags=["Targets"]) +async def list_targets( + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), +): + """List all picture targets.""" + targets = target_store.get_all_targets() + responses = [_target_to_response(t) for t in targets] + return PictureTargetListResponse(targets=responses, count=len(responses)) + + +@router.get("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"]) +async def get_target( + target_id: str, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), +): + """Get a picture 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/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"]) +async def update_target( + target_id: str, + data: PictureTargetUpdate, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), + device_store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Update a picture 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") + + # Convert settings + core_settings = _settings_to_core(data.settings) if data.settings else None + + # Update in store + target = target_store.update_target( + target_id=target_id, + name=data.name, + device_id=data.device_id, + picture_source_id=data.picture_source_id, + settings=core_settings, + description=data.description, + ) + + # Sync processor manager + if isinstance(target, WledPictureTarget): + try: + if data.settings is not None: + manager.update_target_settings(target_id, target.settings) + if data.picture_source_id is not None: + manager.update_target_source(target_id, target.picture_source_id) + if data.device_id is not None: + manager.update_target_device(target_id, target.device_id) + except ValueError: + # Target may not be registered in manager yet + 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/picture-targets/{target_id}", status_code=204, tags=["Targets"]) +async def delete_target( + target_id: str, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Delete a picture target. Stops processing first if active.""" + try: + # Stop processing if running + try: + if manager.is_target_processing(target_id): + 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/picture-targets/{target_id}/start", tags=["Processing"]) +async def start_processing( + target_id: str, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Start processing for a picture target.""" + try: + # Verify target exists + 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/picture-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 picture 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/picture-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/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) +async def get_target_settings( + target_id: str, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), +): + """Get processing settings for a target.""" + try: + target = target_store.get_target(target_id) + if isinstance(target, WledPictureTarget): + return _settings_to_schema(target.settings) + return ProcessingSettingsSchema() + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) +async def update_target_settings( + target_id: str, + settings: ProcessingSettingsSchema, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Update processing settings for a target. + + Merges with existing settings so callers can send partial updates. + """ + try: + target = target_store.get_target(target_id) + if not isinstance(target, WledPictureTarget): + raise HTTPException(status_code=400, detail="Target does not support processing settings") + + existing = target.settings + sent = settings.model_fields_set + + # Merge: only override fields the client explicitly provided + new_settings = ProcessingSettings( + display_index=settings.display_index if 'display_index' in sent else existing.display_index, + fps=settings.fps if 'fps' in sent else existing.fps, + border_width=settings.border_width if 'border_width' in sent else existing.border_width, + interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode, + brightness=settings.brightness if 'brightness' in sent else existing.brightness, + gamma=existing.gamma, + saturation=existing.saturation, + smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing, + state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval, + ) + + # Apply color_correction fields if explicitly sent + if 'color_correction' in sent and settings.color_correction: + cc_sent = settings.color_correction.model_fields_set + if 'brightness' in cc_sent: + new_settings.brightness = settings.color_correction.brightness + if 'gamma' in cc_sent: + new_settings.gamma = settings.color_correction.gamma + if 'saturation' in cc_sent: + new_settings.saturation = settings.color_correction.saturation + + # Update in store + target_store.update_target(target_id, settings=new_settings) + + # Update in manager + try: + manager.update_target_settings(target_id, new_settings) + except ValueError: + pass + + return _settings_to_schema(new_settings) + + 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 settings: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/picture-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)) diff --git a/server/src/wled_controller/api/schemas/__init__.py b/server/src/wled_controller/api/schemas/__init__.py index b5089dd..daba863 100644 --- a/server/src/wled_controller/api/schemas/__init__.py +++ b/server/src/wled_controller/api/schemas/__init__.py @@ -17,14 +17,21 @@ from .devices import ( Calibration, CalibrationTestModeRequest, CalibrationTestModeResponse, - ColorCorrection, DeviceCreate, DeviceListResponse, DeviceResponse, + DeviceStateResponse, DeviceUpdate, - MetricsResponse, +) +from .picture_targets import ( + ColorCorrection, + PictureTargetCreate, + PictureTargetListResponse, + PictureTargetResponse, + PictureTargetUpdate, ProcessingSettings, - ProcessingState, + TargetMetricsResponse, + TargetProcessingState, ) from .templates import ( EngineInfo, @@ -72,14 +79,19 @@ __all__ = [ "Calibration", "CalibrationTestModeRequest", "CalibrationTestModeResponse", - "ColorCorrection", "DeviceCreate", "DeviceListResponse", "DeviceResponse", + "DeviceStateResponse", "DeviceUpdate", - "MetricsResponse", + "ColorCorrection", + "PictureTargetCreate", + "PictureTargetListResponse", + "PictureTargetResponse", + "PictureTargetUpdate", "ProcessingSettings", - "ProcessingState", + "TargetMetricsResponse", + "TargetProcessingState", "EngineInfo", "EngineListResponse", "TemplateAssignment", diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 01a15a4..5af1f02 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -1,12 +1,10 @@ -"""Device-related schemas (CRUD, settings, calibration, processing state, metrics).""" +"""Device-related schemas (CRUD, calibration, device state).""" from datetime import datetime from typing import Dict, List, Literal, Optional from pydantic import BaseModel, Field -from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL - class DeviceCreate(BaseModel): """Request to create/attach a WLED device.""" @@ -21,34 +19,6 @@ class DeviceUpdate(BaseModel): name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) url: Optional[str] = Field(None, description="WLED device URL") enabled: Optional[bool] = Field(None, description="Whether device is enabled") - picture_source_id: Optional[str] = Field(None, description="Picture source ID") - - -class ColorCorrection(BaseModel): - """Color correction settings.""" - - gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0) - saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0) - brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0) - - -class ProcessingSettings(BaseModel): - """Processing settings for a device.""" - - display_index: int = Field(default=0, description="Display to capture", ge=0) - fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) - border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100) - interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") - brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) - smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0) - state_check_interval: int = Field( - default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, - description="Seconds between WLED health checks" - ) - color_correction: Optional[ColorCorrection] = Field( - default_factory=ColorCorrection, - description="Color correction settings" - ) class Calibration(BaseModel): @@ -109,12 +79,7 @@ class DeviceResponse(BaseModel): url: str = Field(description="WLED device URL") led_count: int = Field(description="Total number of LEDs") enabled: bool = Field(description="Whether device is enabled") - status: Literal["connected", "disconnected", "error"] = Field( - description="Connection status" - ) - settings: ProcessingSettings = Field(description="Processing settings") calibration: Optional[Calibration] = Field(None, description="Calibration configuration") - picture_source_id: str = Field(default="", description="ID of assigned picture source") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") @@ -126,16 +91,10 @@ class DeviceListResponse(BaseModel): count: int = Field(description="Number of devices") -class ProcessingState(BaseModel): - """Processing state for a device.""" +class DeviceStateResponse(BaseModel): + """Device health/connection state response.""" device_id: str = Field(description="Device ID") - processing: bool = Field(description="Whether processing is active") - fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") - fps_target: int = Field(description="Target FPS") - display_index: int = Field(description="Current display index") - last_update: Optional[datetime] = Field(None, description="Last successful update") - errors: List[str] = Field(default_factory=list, description="Recent errors") wled_online: bool = Field(default=False, description="Whether WLED device is reachable") wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms") wled_name: Optional[str] = Field(None, description="WLED device name") @@ -145,17 +104,5 @@ class ProcessingState(BaseModel): wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)") wled_last_checked: Optional[datetime] = Field(None, description="Last health check time") wled_error: Optional[str] = Field(None, description="Last health check error") - - -class MetricsResponse(BaseModel): - """Device metrics response.""" - - device_id: str = Field(description="Device ID") - processing: bool = Field(description="Whether processing is active") - fps_actual: Optional[float] = Field(None, description="Actual FPS") - fps_target: int = Field(description="Target FPS") - uptime_seconds: float = Field(description="Processing uptime in seconds") - frames_processed: int = Field(description="Total frames processed") - errors_count: int = Field(description="Total error count") - last_error: Optional[str] = Field(None, description="Last error message") - last_update: Optional[datetime] = Field(None, description="Last update timestamp") + test_mode: bool = Field(default=False, description="Whether calibration test mode is active") + test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode") diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py new file mode 100644 index 0000000..be513c2 --- /dev/null +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -0,0 +1,114 @@ +"""Picture target schemas (CRUD, processing state, settings, metrics).""" + +from datetime import datetime +from typing import Dict, List, Literal, Optional + +from pydantic import BaseModel, Field + +from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL + + +class ColorCorrection(BaseModel): + """Color correction settings.""" + + gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0) + saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0) + brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0) + + +class ProcessingSettings(BaseModel): + """Processing settings for a picture target.""" + + display_index: int = Field(default=0, description="Display to capture", ge=0) + fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) + border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100) + interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") + brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) + smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0) + state_check_interval: int = Field( + default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, + description="Seconds between WLED health checks" + ) + color_correction: Optional[ColorCorrection] = Field( + default_factory=ColorCorrection, + description="Color correction settings" + ) + + +class PictureTargetCreate(BaseModel): + """Request to create a picture target.""" + + name: str = Field(description="Target name", min_length=1, max_length=100) + target_type: str = Field(default="wled", description="Target type (wled)") + device_id: str = Field(default="", description="WLED device ID") + picture_source_id: str = Field(default="", description="Picture source ID") + settings: Optional[ProcessingSettings] = Field(None, description="Processing settings") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class PictureTargetUpdate(BaseModel): + """Request to update a picture target.""" + + name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100) + device_id: Optional[str] = Field(None, description="WLED device ID") + picture_source_id: Optional[str] = Field(None, description="Picture source ID") + settings: Optional[ProcessingSettings] = Field(None, description="Processing settings") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class PictureTargetResponse(BaseModel): + """Picture target response.""" + + id: str = Field(description="Target ID") + name: str = Field(description="Target name") + target_type: str = Field(description="Target type") + device_id: str = Field(default="", description="WLED device ID") + picture_source_id: str = Field(default="", description="Picture source ID") + settings: ProcessingSettings = Field(description="Processing settings") + description: Optional[str] = Field(None, description="Description") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + + +class PictureTargetListResponse(BaseModel): + """List of picture targets response.""" + + targets: List[PictureTargetResponse] = Field(description="List of picture targets") + count: int = Field(description="Number of targets") + + +class TargetProcessingState(BaseModel): + """Processing state for a picture target.""" + + target_id: str = Field(description="Target ID") + device_id: str = Field(description="Device ID") + processing: bool = Field(description="Whether processing is active") + fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") + fps_target: int = Field(description="Target FPS") + display_index: int = Field(description="Current display index") + last_update: Optional[datetime] = Field(None, description="Last successful update") + errors: List[str] = Field(default_factory=list, description="Recent errors") + wled_online: bool = Field(default=False, description="Whether WLED device is reachable") + wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms") + wled_name: Optional[str] = Field(None, description="WLED device name") + wled_version: Optional[str] = Field(None, description="WLED firmware version") + wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device") + wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs") + wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)") + wled_last_checked: Optional[datetime] = Field(None, description="Last health check time") + wled_error: Optional[str] = Field(None, description="Last health check error") + + +class TargetMetricsResponse(BaseModel): + """Target metrics response.""" + + target_id: str = Field(description="Target ID") + device_id: str = Field(description="Device ID") + processing: bool = Field(description="Whether processing is active") + fps_actual: Optional[float] = Field(None, description="Actual FPS") + fps_target: int = Field(description="Target FPS") + uptime_seconds: float = Field(description="Processing uptime in seconds") + frames_processed: int = Field(description="Total frames processed") + errors_count: int = Field(description="Total error count") + last_error: Optional[str] = Field(None, description="Last error message") + last_update: Optional[datetime] = Field(None, description="Last update timestamp") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index ffacf28..c75d5e7 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -57,6 +57,7 @@ class StorageConfig(BaseSettings): templates_file: str = "data/capture_templates.json" postprocessing_templates_file: str = "data/postprocessing_templates.json" picture_sources_file: str = "data/picture_sources.json" + picture_targets_file: str = "data/picture_targets.json" class LoggingConfig(BaseSettings): diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 662dddc..51184a9 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -83,9 +83,25 @@ class ProcessingMetrics: @dataclass -class ProcessorState: - """State of a running processor.""" +class DeviceState: + """State for a registered WLED device (health monitoring + calibration).""" + device_id: str + device_url: str + led_count: int + calibration: CalibrationConfig + health: DeviceHealth = field(default_factory=DeviceHealth) + health_task: Optional[asyncio.Task] = None + # Calibration test mode (works independently of target processing) + test_mode_active: bool = False + test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict) + + +@dataclass +class TargetState: + """State of a running picture target processor.""" + + target_id: str device_id: str device_url: str led_count: int @@ -98,10 +114,6 @@ class ProcessorState: task: Optional[asyncio.Task] = None metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics) previous_colors: Optional[list] = None - health: DeviceHealth = field(default_factory=DeviceHealth) - test_mode_active: bool = False - test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict) - health_task: Optional[asyncio.Task] = None # Resolved stream values (populated at start_processing time) resolved_display_index: Optional[int] = None resolved_target_fps: Optional[int] = None @@ -114,17 +126,16 @@ class ProcessorState: class ProcessorManager: - """Manages screen processing for multiple WLED devices.""" + """Manages screen processing for multiple WLED devices. + + Devices are registered for health monitoring and calibration. + Targets are registered for processing (streaming sources to devices). + """ def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None): - """Initialize processor manager. - - Args: - picture_source_store: PictureSourceStore instance (for stream resolution) - capture_template_store: TemplateStore instance (for engine lookup) - pp_template_store: PostprocessingTemplateStore instance (for PP settings) - """ - self._processors: Dict[str, ProcessorState] = {} + """Initialize processor manager.""" + self._devices: Dict[str, DeviceState] = {} + self._targets: Dict[str, TargetState] = {} self._health_monitoring_active = False self._http_client: Optional[httpx.AsyncClient] = None self._picture_source_store = picture_source_store @@ -141,141 +152,250 @@ class ProcessorManager: self._http_client = httpx.AsyncClient(timeout=5) return self._http_client + # ===== DEVICE MANAGEMENT (health monitoring + calibration) ===== + def add_device( self, device_id: str, device_url: str, led_count: int, - settings: Optional[ProcessingSettings] = None, calibration: Optional[CalibrationConfig] = None, - picture_source_id: str = "", ): - """Add a device for processing. + """Register a device for health monitoring. Args: device_id: Unique device identifier device_url: WLED device URL led_count: Number of LEDs - settings: Processing settings (uses defaults if None) calibration: Calibration config (creates default if None) - picture_source_id: Picture source ID """ - if device_id in self._processors: - raise ValueError(f"Device {device_id} already exists") - - if settings is None: - settings = ProcessingSettings() + if device_id in self._devices: + raise ValueError(f"Device {device_id} already registered") if calibration is None: calibration = create_default_calibration(led_count) - state = ProcessorState( + state = DeviceState( device_id=device_id, device_url=device_url, led_count=led_count, - settings=settings, calibration=calibration, - picture_source_id=picture_source_id, ) - self._processors[device_id] = state + self._devices[device_id] = state # Start health monitoring for this device if self._health_monitoring_active: self._start_device_health_check(device_id) - logger.info(f"Added device {device_id} with {led_count} LEDs") + logger.info(f"Registered device {device_id} with {led_count} LEDs") def remove_device(self, device_id: str): - """Remove a device. - - Args: - device_id: Device identifier + """Unregister a device. Raises: ValueError: If device not found + RuntimeError: If any target is using this device """ - if device_id not in self._processors: + if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") - # Stop processing if running - if self._processors[device_id].is_running: - raise RuntimeError(f"Cannot remove device {device_id} while processing") + # Check if any target is using this device + for target in self._targets.values(): + if target.device_id == device_id: + raise RuntimeError( + f"Cannot remove device {device_id}: target {target.target_id} is using it" + ) # Stop health check task self._stop_device_health_check(device_id) - del self._processors[device_id] - logger.info(f"Removed device {device_id}") + del self._devices[device_id] + logger.info(f"Unregistered device {device_id}") - def update_settings(self, device_id: str, settings: ProcessingSettings): - """Update processing settings for a device. - - Args: - device_id: Device identifier - settings: New settings - - Raises: - ValueError: If device not found - """ - if device_id not in self._processors: + def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None): + """Update device connection info.""" + if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") - self._processors[device_id].settings = settings - - # Recreate pixel mapper if interpolation mode changed - state = self._processors[device_id] - if state.pixel_mapper: - state.pixel_mapper = PixelMapper( - state.calibration, - interpolation_mode=settings.interpolation_mode, - ) - - logger.info(f"Updated settings for device {device_id}") + ds = self._devices[device_id] + if device_url is not None: + ds.device_url = device_url + if led_count is not None: + ds.led_count = led_count def update_calibration(self, device_id: str, calibration: CalibrationConfig): """Update calibration for a device. - Args: - device_id: Device identifier - calibration: New calibration config - - Raises: - ValueError: If device not found or calibration invalid + Also updates cached calibration in any active target for this device. """ - if device_id not in self._processors: + if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") - # Validate calibration calibration.validate() - # Check LED count matches - state = self._processors[device_id] - if calibration.get_total_leds() != state.led_count: + ds = self._devices[device_id] + if calibration.get_total_leds() != ds.led_count: raise ValueError( f"Calibration LED count ({calibration.get_total_leds()}) " - f"does not match device LED count ({state.led_count})" + f"does not match device LED count ({ds.led_count})" ) - state.calibration = calibration + ds.calibration = calibration - # Recreate pixel mapper if running - if state.pixel_mapper: - state.pixel_mapper = PixelMapper( - calibration, - interpolation_mode=state.settings.interpolation_mode, - ) + # Update any active target's cached calibration + pixel mapper + for ts in self._targets.values(): + if ts.device_id == device_id: + ts.calibration = calibration + if ts.pixel_mapper: + ts.pixel_mapper = PixelMapper( + calibration, + interpolation_mode=ts.settings.interpolation_mode, + ) logger.info(f"Updated calibration for device {device_id}") - def _resolve_stream_settings(self, state: ProcessorState): - """Resolve picture source chain to populate resolved_* metadata fields. + def get_device_state(self, device_id: str) -> DeviceState: + """Get device state (for health/calibration info).""" + if device_id not in self._devices: + raise ValueError(f"Device {device_id} not found") + return self._devices[device_id] - Resolves metadata (display_index, fps, engine info) for status reporting. - Actual stream creation is handled by LiveStreamManager. + def get_device_health(self, device_id: str) -> dict: + """Get health status for a device.""" + if device_id not in self._devices: + raise ValueError(f"Device {device_id} not found") + + h = self._devices[device_id].health + return { + "online": h.online, + "latency_ms": h.latency_ms, + "last_checked": h.last_checked, + "wled_name": h.wled_name, + "wled_version": h.wled_version, + "wled_led_count": h.wled_led_count, + "wled_rgbw": h.wled_rgbw, + "wled_led_type": h.wled_led_type, + "error": h.error, + } + + def get_device_health_dict(self, device_id: str) -> dict: + """Get device connection/health state as a state response dict.""" + if device_id not in self._devices: + raise ValueError(f"Device {device_id} not found") + + ds = self._devices[device_id] + h = ds.health + return { + "device_id": device_id, + "wled_online": h.online, + "wled_latency_ms": h.latency_ms, + "wled_name": h.wled_name, + "wled_version": h.wled_version, + "wled_led_count": h.wled_led_count, + "wled_rgbw": h.wled_rgbw, + "wled_led_type": h.wled_led_type, + "wled_last_checked": h.last_checked, + "wled_error": h.error, + "test_mode": ds.test_mode_active, + "test_mode_edges": list(ds.test_mode_edges.keys()), + } + + # ===== TARGET MANAGEMENT (processing) ===== + + def add_target( + self, + target_id: str, + device_id: str, + settings: Optional[ProcessingSettings] = None, + picture_source_id: str = "", + ): + """Register a target for processing. + + Args: + target_id: Unique target identifier + device_id: WLED device to stream to + settings: Processing settings + picture_source_id: Picture source ID """ + if target_id in self._targets: + raise ValueError(f"Target {target_id} already registered") + + if device_id not in self._devices: + raise ValueError(f"Device {device_id} not registered") + + ds = self._devices[device_id] + + state = TargetState( + target_id=target_id, + device_id=device_id, + device_url=ds.device_url, + led_count=ds.led_count, + settings=settings or ProcessingSettings(), + calibration=ds.calibration, + picture_source_id=picture_source_id, + ) + + self._targets[target_id] = state + logger.info(f"Registered target {target_id} for device {device_id}") + + def remove_target(self, target_id: str): + """Unregister a target. + + Raises: + ValueError: If target not found + RuntimeError: If target is currently processing + """ + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + + if self._targets[target_id].is_running: + raise RuntimeError(f"Cannot remove target {target_id} while processing") + + del self._targets[target_id] + logger.info(f"Unregistered target {target_id}") + + def update_target_settings(self, target_id: str, settings: ProcessingSettings): + """Update processing settings for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + + ts = self._targets[target_id] + ts.settings = settings + + # Recreate pixel mapper if interpolation mode changed + if ts.pixel_mapper: + ts.pixel_mapper = PixelMapper( + ts.calibration, + interpolation_mode=settings.interpolation_mode, + ) + + logger.info(f"Updated settings for target {target_id}") + + def update_target_source(self, target_id: str, picture_source_id: str): + """Update the picture source for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + self._targets[target_id].picture_source_id = picture_source_id + + def update_target_device(self, target_id: str, device_id: str): + """Update the device for a target (re-cache device info).""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + if device_id not in self._devices: + raise ValueError(f"Device {device_id} not registered") + + ds = self._devices[device_id] + ts = self._targets[target_id] + ts.device_id = device_id + ts.device_url = ds.device_url + ts.led_count = ds.led_count + ts.calibration = ds.calibration + + def _resolve_stream_settings(self, state: TargetState): + """Resolve picture source chain to populate resolved_* metadata fields.""" if not state.picture_source_id or not self._picture_source_store: - raise ValueError(f"Device {state.device_id} has no picture source assigned") + raise ValueError(f"Target {state.target_id} has no picture source assigned") from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource @@ -303,31 +423,42 @@ class ProcessorManager: state.resolved_engine_config = {} logger.info( - f"Resolved stream metadata for {state.device_id}: " + f"Resolved stream metadata for target {state.target_id}: " f"display={state.resolved_display_index}, fps={state.resolved_target_fps}, " f"engine={state.resolved_engine_type}, pp_templates={len(pp_template_ids)}" ) - async def start_processing(self, device_id: str): - """Start screen processing for a device. - - Resolves the picture source chain to determine capture engine, - display, FPS, and postprocessing settings. + async def start_processing(self, target_id: str): + """Start screen processing for a target. Args: - device_id: Device identifier + target_id: Target identifier Raises: - ValueError: If device not found or no picture source assigned - RuntimeError: If processing already running + ValueError: If target not found or no picture source assigned + RuntimeError: If processing already running or device conflict """ - if device_id not in self._processors: - raise ValueError(f"Device {device_id} not found") + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") - state = self._processors[device_id] + state = self._targets[target_id] if state.is_running: - raise RuntimeError(f"Processing already running for device {device_id}") + raise RuntimeError(f"Processing already running for target {target_id}") + + # Enforce one-target-per-device constraint + for other_id, other in self._targets.items(): + if other_id != target_id and other.device_id == state.device_id and other.is_running: + raise RuntimeError( + f"Device {state.device_id} is already being processed by target {other_id}" + ) + + # Refresh cached device info + if state.device_id in self._devices: + ds = self._devices[state.device_id] + state.device_url = ds.device_url + state.led_count = ds.led_count + state.calibration = ds.calibration # Resolve stream settings self._resolve_stream_settings(state) @@ -342,7 +473,6 @@ class ProcessorManager: "on": wled_state.get("on", True), "lor": wled_state.get("lor", 0), } - # AudioReactive is optional (usermod) if "AudioReactive" in wled_state: state.wled_state_before["AudioReactive"] = wled_state["AudioReactive"] logger.info(f"Saved WLED state before streaming: {state.wled_state_before}") @@ -357,27 +487,26 @@ class ProcessorManager: await state.wled_client.connect() if use_ddp: - logger.info(f"Device {device_id} using DDP protocol ({state.led_count} LEDs)") + logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)") except Exception as e: - logger.error(f"Failed to connect to WLED device {device_id}: {e}") + logger.error(f"Failed to connect to WLED device for target {target_id}: {e}") raise RuntimeError(f"Failed to connect to WLED device: {e}") - # Acquire live stream via LiveStreamManager (shared across devices) + # Acquire live stream via LiveStreamManager (shared across targets) try: live_stream = await asyncio.to_thread( self._live_stream_manager.acquire, state.picture_source_id ) state.live_stream = live_stream - # Update resolved metadata from actual live stream if live_stream.display_index is not None: state.resolved_display_index = live_stream.display_index state.resolved_target_fps = live_stream.target_fps logger.info( - f"Acquired live stream for device {device_id} " + f"Acquired live stream for target {target_id} " f"(picture_source={state.picture_source_id})" ) except Exception as e: - logger.error(f"Failed to initialize live stream for device {device_id}: {e}") + logger.error(f"Failed to initialize live stream for target {target_id}: {e}") if state.wled_client: await state.wled_client.disconnect() raise RuntimeError(f"Failed to initialize live stream: {e}") @@ -393,27 +522,20 @@ class ProcessorManager: state.previous_colors = None # Start processing task - state.task = asyncio.create_task(self._processing_loop(device_id)) + state.task = asyncio.create_task(self._processing_loop(target_id)) state.is_running = True - logger.info(f"Started processing for device {device_id}") + logger.info(f"Started processing for target {target_id}") - async def stop_processing(self, device_id: str): - """Stop screen processing for a device. + async def stop_processing(self, target_id: str): + """Stop screen processing for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") - Args: - device_id: Device identifier - - Raises: - ValueError: If device not found - """ - if device_id not in self._processors: - raise ValueError(f"Device {device_id} not found") - - state = self._processors[device_id] + state = self._targets[target_id] if not state.is_running: - logger.warning(f"Processing not running for device {device_id}") + logger.warning(f"Processing not running for target {target_id}") return # Stop processing @@ -428,7 +550,7 @@ class ProcessorManager: pass state.task = None - # Restore WLED state that was changed when streaming started + # Restore WLED state if state.wled_state_before: try: async with httpx.AsyncClient(timeout=5) as http: @@ -454,50 +576,45 @@ class ProcessorManager: logger.warning(f"Error releasing live stream: {e}") state.live_stream = None - logger.info(f"Stopped processing for device {device_id}") + logger.info(f"Stopped processing for target {target_id}") - async def _processing_loop(self, device_id: str): - """Main processing loop for a device. - - Reads frames from the LiveStream (which handles capture and optional - PP filters via the picture source chain). - """ - state = self._processors[device_id] + async def _processing_loop(self, target_id: str): + """Main processing loop for a target.""" + state = self._targets[target_id] settings = state.settings - # Use resolved values (populated by _resolve_stream_settings) target_fps = state.resolved_target_fps or settings.fps smoothing = settings.smoothing - - # These always come from device settings (LED projection) border_width = settings.border_width - wled_brightness = settings.brightness # WLED hardware brightness + wled_brightness = settings.brightness logger.info( - f"Processing loop started for {device_id} " + f"Processing loop started for target {target_id} " f"(display={state.resolved_display_index}, fps={target_fps})" ) frame_time = 1.0 / target_fps fps_samples = [] + # Check if the device has test mode active — skip capture while in test mode + device_state = self._devices.get(state.device_id) + try: while state.is_running: loop_start = time.time() # Skip capture/send while in calibration test mode - if state.test_mode_active: + if device_state and device_state.test_mode_active: await asyncio.sleep(frame_time) continue try: - # Get frame from live stream (handles capture + PP filters) + # Get frame from live stream capture = await asyncio.to_thread(state.live_stream.get_latest_frame) - # Skip processing if no new frame (screen unchanged) if capture is None: if state.metrics.frames_processed == 0: - logger.info(f"Capture returned None for {device_id} (no new frame yet)") + logger.info(f"Capture returned None for target {target_id} (no new frame yet)") await asyncio.sleep(frame_time) continue @@ -507,7 +624,7 @@ class ProcessorManager: # Map to LED colors led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels) - # Apply smoothing from postprocessing + # Apply smoothing if state.previous_colors and smoothing > 0: led_colors = await asyncio.to_thread( smooth_colors, @@ -525,7 +642,7 @@ class ProcessorManager: # Update metrics state.metrics.frames_processed += 1 if state.metrics.frames_processed <= 3 or state.metrics.frames_processed % 100 == 0: - logger.info(f"Frame {state.metrics.frames_processed} sent for {device_id} ({len(led_colors)} LEDs, bri={brightness_value})") + logger.info(f"Frame {state.metrics.frames_processed} sent for target {target_id} ({len(led_colors)} LEDs, bri={brightness_value})") state.metrics.last_update = datetime.utcnow() state.previous_colors = led_colors @@ -539,150 +656,75 @@ class ProcessorManager: except Exception as e: state.metrics.errors_count += 1 state.metrics.last_error = str(e) - logger.error(f"Processing error for device {device_id}: {e}", exc_info=True) + logger.error(f"Processing error for target {target_id}: {e}", exc_info=True) # FPS control elapsed = time.time() - loop_start sleep_time = max(0, frame_time - elapsed) - if sleep_time > 0: await asyncio.sleep(sleep_time) except asyncio.CancelledError: - logger.info(f"Processing loop cancelled for device {device_id}") + logger.info(f"Processing loop cancelled for target {target_id}") raise except Exception as e: - logger.error(f"Fatal error in processing loop for {device_id}: {e}") + logger.error(f"Fatal error in processing loop for target {target_id}: {e}") state.is_running = False raise finally: - logger.info(f"Processing loop ended for device {device_id}") + logger.info(f"Processing loop ended for target {target_id}") - def get_state(self, device_id: str) -> dict: - """Get current processing state for a device. + def get_target_state(self, target_id: str) -> dict: + """Get current processing state for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") - Args: - device_id: Device identifier - - Returns: - State dictionary - - Raises: - ValueError: If device not found - """ - if device_id not in self._processors: - raise ValueError(f"Device {device_id} not found") - - state = self._processors[device_id] + state = self._targets[target_id] metrics = state.metrics - h = state.health + + # Include WLED health info from the device + health_info = {} + if state.device_id in self._devices: + h = self._devices[state.device_id].health + health_info = { + "wled_online": h.online, + "wled_latency_ms": h.latency_ms, + "wled_name": h.wled_name, + "wled_version": h.wled_version, + "wled_led_count": h.wled_led_count, + "wled_rgbw": h.wled_rgbw, + "wled_led_type": h.wled_led_type, + "wled_last_checked": h.last_checked, + "wled_error": h.error, + } return { - "device_id": device_id, + "target_id": target_id, + "device_id": state.device_id, "processing": state.is_running, "fps_actual": metrics.fps_actual if state.is_running else None, "fps_target": state.resolved_target_fps or state.settings.fps, "display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], - "wled_online": h.online, - "wled_latency_ms": h.latency_ms, - "wled_name": h.wled_name, - "wled_version": h.wled_version, - "wled_led_count": h.wled_led_count, - "wled_rgbw": h.wled_rgbw, - "wled_led_type": h.wled_led_type, - "wled_last_checked": h.last_checked, - "wled_error": h.error, - "test_mode": state.test_mode_active, - "test_mode_edges": list(state.test_mode_edges.keys()), + **health_info, } - async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None: - """Set or clear calibration test mode for a device. + def get_target_metrics(self, target_id: str) -> dict: + """Get detailed metrics for a target.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") - When edges dict is non-empty, enters test mode and sends test pixel pattern. - When empty, exits test mode and clears LEDs. - """ - if device_id not in self._processors: - raise ValueError(f"Device {device_id} not found") - - state = self._processors[device_id] - - if edges: - state.test_mode_active = True - state.test_mode_edges = { - edge: tuple(color) for edge, color in edges.items() - } - await self._send_test_pixels(device_id) - else: - state.test_mode_active = False - state.test_mode_edges = {} - await self._send_clear_pixels(device_id) - - async def _send_test_pixels(self, device_id: str) -> None: - """Build and send test pixel array for active test edges.""" - state = self._processors[device_id] - pixels = [(0, 0, 0)] * state.led_count - - for edge_name, color in state.test_mode_edges.items(): - for seg in state.calibration.segments: - if seg.edge == edge_name: - for i in range(seg.led_start, seg.led_start + seg.led_count): - if i < state.led_count: - pixels[i] = color - break - - try: - if state.wled_client and state.is_running: - await state.wled_client.send_pixels(pixels) - else: - use_ddp = state.led_count > WLEDClient.HTTP_MAX_LEDS - async with WLEDClient(state.device_url, use_ddp=use_ddp) as wled: - await wled.send_pixels(pixels) - except Exception as e: - logger.error(f"Failed to send test pixels for {device_id}: {e}") - - async def _send_clear_pixels(self, device_id: str) -> None: - """Send all-black pixels to clear WLED output.""" - state = self._processors[device_id] - pixels = [(0, 0, 0)] * state.led_count - - try: - if state.wled_client and state.is_running: - await state.wled_client.send_pixels(pixels) - else: - use_ddp = state.led_count > WLEDClient.HTTP_MAX_LEDS - async with WLEDClient(state.device_url, use_ddp=use_ddp) as wled: - await wled.send_pixels(pixels) - except Exception as e: - logger.error(f"Failed to clear pixels for {device_id}: {e}") - - def get_metrics(self, device_id: str) -> dict: - """Get detailed metrics for a device. - - Args: - device_id: Device identifier - - Returns: - Metrics dictionary - - Raises: - ValueError: If device not found - """ - if device_id not in self._processors: - raise ValueError(f"Device {device_id} not found") - - state = self._processors[device_id] + state = self._targets[target_id] metrics = state.metrics - # Calculate uptime uptime_seconds = 0.0 if metrics.start_time and state.is_running: uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds() return { - "device_id": device_id, + "target_id": target_id, + "device_id": state.device_id, "processing": state.is_running, "fps_actual": metrics.fps_actual if state.is_running else None, "fps_target": state.settings.fps, @@ -693,44 +735,128 @@ class ProcessorManager: "last_update": metrics.last_update, } - def is_processing(self, device_id: str) -> bool: - """Check if device is currently processing. + def is_target_processing(self, target_id: str) -> bool: + """Check if target is currently processing.""" + if target_id not in self._targets: + raise ValueError(f"Target {target_id} not found") + return self._targets[target_id].is_running - Args: - device_id: Device identifier + def is_device_processing(self, device_id: str) -> bool: + """Check if any target is processing for a device.""" + for ts in self._targets.values(): + if ts.device_id == device_id and ts.is_running: + return True + return False - Returns: - True if processing + def get_processing_target_for_device(self, device_id: str) -> Optional[str]: + """Get the target_id that is currently processing for a device.""" + for ts in self._targets.values(): + if ts.device_id == device_id and ts.is_running: + return ts.target_id + return None - Raises: - ValueError: If device not found - """ - if device_id not in self._processors: + # ===== CALIBRATION TEST MODE (on device) ===== + + async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None: + """Set or clear calibration test mode for a device.""" + if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") - return self._processors[device_id].is_running + ds = self._devices[device_id] - def get_all_devices(self) -> list[str]: - """Get list of all device IDs. + if edges: + ds.test_mode_active = True + ds.test_mode_edges = { + edge: tuple(color) for edge, color in edges.items() + } + await self._send_test_pixels(device_id) + else: + ds.test_mode_active = False + ds.test_mode_edges = {} + await self._send_clear_pixels(device_id) - Returns: - List of device IDs - """ - return list(self._processors.keys()) + async def _send_test_pixels(self, device_id: str) -> None: + """Build and send test pixel array for active test edges.""" + ds = self._devices[device_id] + pixels = [(0, 0, 0)] * ds.led_count + + for edge_name, color in ds.test_mode_edges.items(): + for seg in ds.calibration.segments: + if seg.edge == edge_name: + for i in range(seg.led_start, seg.led_start + seg.led_count): + if i < ds.led_count: + pixels[i] = color + break + + try: + # Check if a target is running for this device (use its WLED client) + active_client = None + for ts in self._targets.values(): + if ts.device_id == device_id and ts.is_running and ts.wled_client: + active_client = ts.wled_client + break + + if active_client: + await active_client.send_pixels(pixels) + else: + use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS + async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled: + await wled.send_pixels(pixels) + except Exception as e: + logger.error(f"Failed to send test pixels for {device_id}: {e}") + + async def _send_clear_pixels(self, device_id: str) -> None: + """Send all-black pixels to clear WLED output.""" + ds = self._devices[device_id] + pixels = [(0, 0, 0)] * ds.led_count + + try: + active_client = None + for ts in self._targets.values(): + if ts.device_id == device_id and ts.is_running and ts.wled_client: + active_client = ts.wled_client + break + + if active_client: + await active_client.send_pixels(pixels) + else: + use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS + async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled: + await wled.send_pixels(pixels) + except Exception as e: + logger.error(f"Failed to clear pixels for {device_id}: {e}") + + # ===== DISPLAY LOCK INFO ===== + + def is_display_locked(self, display_index: int) -> bool: + """Check if a display is currently being captured by any target.""" + for state in self._targets.values(): + if state.is_running and state.settings.display_index == display_index: + return True + return False + + def get_display_lock_info(self, display_index: int) -> Optional[str]: + """Get the device ID that is currently capturing from a display.""" + for state in self._targets.values(): + if state.is_running and state.settings.display_index == display_index: + return state.device_id + return None + + # ===== LIFECYCLE ===== async def stop_all(self): - """Stop processing and health monitoring for all devices.""" + """Stop processing and health monitoring for all targets and devices.""" # Stop health monitoring await self.stop_health_monitoring() - # Stop processing - device_ids = list(self._processors.keys()) - for device_id in device_ids: - if self._processors[device_id].is_running: + # Stop all targets + target_ids = list(self._targets.keys()) + for target_id in target_ids: + if self._targets[target_id].is_running: try: - await self.stop_processing(device_id) + await self.stop_processing(target_id) except Exception as e: - logger.error(f"Error stopping device {device_id}: {e}") + logger.error(f"Error stopping target {target_id}: {e}") # Safety net: release any remaining managed live streams self._live_stream_manager.release_all() @@ -747,20 +873,20 @@ class ProcessorManager: async def start_health_monitoring(self): """Start background health checks for all registered devices.""" self._health_monitoring_active = True - for device_id in self._processors: + for device_id in self._devices: self._start_device_health_check(device_id) logger.info("Started health monitoring for all devices") async def stop_health_monitoring(self): """Stop all background health checks.""" self._health_monitoring_active = False - for device_id in list(self._processors.keys()): + for device_id in list(self._devices.keys()): self._stop_device_health_check(device_id) logger.info("Stopped health monitoring for all devices") def _start_device_health_check(self, device_id: str): """Start health check task for a single device.""" - state = self._processors.get(device_id) + state = self._devices.get(device_id) if not state: return if state.health_task and not state.health_task.done(): @@ -769,7 +895,7 @@ class ProcessorManager: def _stop_device_health_check(self, device_id: str): """Stop health check task for a single device.""" - state = self._processors.get(device_id) + state = self._devices.get(device_id) if not state or not state.health_task: return state.health_task.cancel() @@ -777,24 +903,24 @@ class ProcessorManager: async def _health_check_loop(self, device_id: str): """Background loop that periodically checks a WLED device via GET /json/info.""" - state = self._processors.get(device_id) + state = self._devices.get(device_id) if not state: return + + check_interval = DEFAULT_STATE_CHECK_INTERVAL + try: while self._health_monitoring_active: await self._check_device_health(device_id) - await asyncio.sleep(state.settings.state_check_interval) + await asyncio.sleep(check_interval) except asyncio.CancelledError: pass except Exception as e: logger.error(f"Fatal error in health check loop for {device_id}: {e}") async def _check_device_health(self, device_id: str): - """Check device health via GET /json/info. - - Determines online status, latency, device name and firmware version. - """ - state = self._processors.get(device_id) + """Check device health via GET /json/info.""" + state = self._devices.get(device_id) if not state: return url = state.device_url.rstrip("/") @@ -845,56 +971,3 @@ class ProcessorManager: wled_led_type=state.health.wled_led_type, error=str(e), ) - - def get_device_health(self, device_id: str) -> dict: - """Get health status for a device. - - Args: - device_id: Device identifier - - Returns: - Health status dictionary - """ - if device_id not in self._processors: - raise ValueError(f"Device {device_id} not found") - - h = self._processors[device_id].health - return { - "online": h.online, - "latency_ms": h.latency_ms, - "last_checked": h.last_checked, - "wled_name": h.wled_name, - "wled_version": h.wled_version, - "wled_led_count": h.wled_led_count, - "wled_rgbw": h.wled_rgbw, - "wled_led_type": h.wled_led_type, - "error": h.error, - } - - def is_display_locked(self, display_index: int) -> bool: - """Check if a display is currently being captured by any device. - - Args: - display_index: Display index to check - - Returns: - True if the display is actively being captured - """ - for state in self._processors.values(): - if state.is_running and state.settings.display_index == display_index: - return True - return False - - def get_display_lock_info(self, display_index: int) -> Optional[str]: - """Get the device ID that is currently capturing from a display. - - Args: - display_index: Display index to check - - Returns: - Device ID if locked, None otherwise - """ - for device_id, state in self._processors.items(): - if state.is_running and state.settings.display_index == display_index: - return device_id - return None diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index cebecfd..a9e1001 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -13,11 +13,13 @@ from wled_controller import __version__ from wled_controller.api import router from wled_controller.api.dependencies import init_dependencies from wled_controller.config import get_config -from wled_controller.core.processor_manager import ProcessorManager +from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings from wled_controller.storage import DeviceStore from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore +from wled_controller.storage.picture_target_store import PictureTargetStore +from wled_controller.storage.picture_target import WledPictureTarget from wled_controller.utils import setup_logging, get_logger # Initialize logging @@ -32,6 +34,7 @@ device_store = DeviceStore(config.storage.devices_file) template_store = TemplateStore(config.storage.templates_file) pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_templates_file) picture_source_store = PictureSourceStore(config.storage.picture_sources_file) +picture_target_store = PictureTargetStore(config.storage.picture_targets_file) processor_manager = ProcessorManager( picture_source_store=picture_source_store, @@ -40,6 +43,63 @@ processor_manager = ProcessorManager( ) +def _migrate_devices_to_targets(): + """One-time migration: create picture targets from legacy device settings. + + If the target store is empty and any device has legacy picture_source_id + or settings in raw JSON, migrate them to WledPictureTargets. + """ + if picture_target_store.count() > 0: + return # Already have targets, skip migration + + raw = device_store.load_raw() + devices_raw = raw.get("devices", {}) + if not devices_raw: + return + + migrated = 0 + for device_id, device_data in devices_raw.items(): + legacy_source_id = device_data.get("picture_source_id", "") + legacy_settings = device_data.get("settings", {}) + + if not legacy_source_id and not legacy_settings: + continue + + # Build ProcessingSettings from legacy data + from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL + settings = ProcessingSettings( + display_index=legacy_settings.get("display_index", 0), + fps=legacy_settings.get("fps", 30), + border_width=legacy_settings.get("border_width", 10), + brightness=legacy_settings.get("brightness", 1.0), + gamma=legacy_settings.get("gamma", 2.2), + saturation=legacy_settings.get("saturation", 1.0), + smoothing=legacy_settings.get("smoothing", 0.3), + interpolation_mode=legacy_settings.get("interpolation_mode", "average"), + state_check_interval=legacy_settings.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), + ) + + device_name = device_data.get("name", device_id) + target_name = f"{device_name} Target" + + try: + target = picture_target_store.create_target( + name=target_name, + target_type="wled", + device_id=device_id, + picture_source_id=legacy_source_id, + settings=settings, + description=f"Auto-migrated from device {device_name}", + ) + migrated += 1 + logger.info(f"Migrated device {device_id} -> target {target.id}") + except Exception as e: + logger.error(f"Failed to migrate device {device_id} to target: {e}") + + if migrated > 0: + logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings") + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager. @@ -69,14 +129,18 @@ async def lifespan(app: FastAPI): logger.info(f"Authorized clients: {client_labels}") logger.info("All API requests require valid Bearer token authentication") + # Run migration from legacy device settings to picture targets + _migrate_devices_to_targets() + # Initialize API dependencies init_dependencies( device_store, template_store, processor_manager, pp_template_store=pp_template_store, picture_source_store=picture_source_store, + picture_target_store=picture_target_store, ) - # Load existing devices into processor manager + # Register devices in processor manager for health monitoring devices = device_store.get_all_devices() for device in devices: try: @@ -84,15 +148,32 @@ async def lifespan(app: FastAPI): device_id=device.id, device_url=device.url, led_count=device.led_count, - settings=device.settings, calibration=device.calibration, - picture_source_id=device.picture_source_id, ) - logger.info(f"Loaded device: {device.name} ({device.id})") + logger.info(f"Registered device: {device.name} ({device.id})") except Exception as e: - logger.error(f"Failed to load device {device.id}: {e}") + logger.error(f"Failed to register device {device.id}: {e}") - logger.info(f"Loaded {len(devices)} devices from storage") + logger.info(f"Registered {len(devices)} devices for health monitoring") + + # Register picture targets in processor manager + targets = picture_target_store.get_all_targets() + registered_targets = 0 + for target in targets: + if isinstance(target, WledPictureTarget) and target.device_id: + try: + processor_manager.add_target( + target_id=target.id, + device_id=target.device_id, + settings=target.settings, + picture_source_id=target.picture_source_id, + ) + registered_targets += 1 + logger.info(f"Registered target: {target.name} ({target.id})") + except Exception as e: + logger.error(f"Failed to register target {target.id}: {e}") + + logger.info(f"Registered {registered_targets} picture target(s)") # Start background health monitoring for all devices await processor_manager.start_health_monitoring() diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 9131fa2..de9aabd 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -120,9 +120,8 @@ document.addEventListener('keydown', (e) => { { id: 'pp-template-modal', close: closePPTemplateModal }, { id: 'template-modal', close: closeTemplateModal }, { id: 'device-settings-modal', close: forceCloseDeviceSettingsModal }, - { id: 'capture-settings-modal', close: forceCloseCaptureSettingsModal }, { id: 'calibration-modal', close: forceCloseCalibrationModal }, - { id: 'stream-selector-modal', close: forceCloseStreamSelectorModal }, + { id: 'target-editor-modal', close: forceCloseTargetEditorModal }, { id: 'add-device-modal', close: closeAddDeviceModal }, ]; for (const m of modals) { @@ -581,6 +580,8 @@ function switchTab(name) { localStorage.setItem('activeTab', name); if (name === 'streams') { loadPictureSources(); + } else if (name === 'targets') { + loadTargets(); } } @@ -610,9 +611,8 @@ async function loadDevices() { const container = document.getElementById('devices-list'); if (!devices || devices.length === 0) { - container.innerHTML = `