diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index d016d44..103bd1d 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -9,6 +9,7 @@ from .routes.postprocessing import router as postprocessing_router from .routes.picture_sources import router as picture_sources_router from .routes.pattern_templates import router as pattern_templates_router from .routes.picture_targets import router as picture_targets_router +from .routes.color_strip_sources import router as color_strip_sources_router from .routes.profiles import router as profiles_router router = APIRouter() @@ -18,6 +19,7 @@ router.include_router(templates_router) router.include_router(postprocessing_router) router.include_router(pattern_templates_router) router.include_router(picture_sources_router) +router.include_router(color_strip_sources_router) router.include_router(picture_targets_router) router.include_router(profiles_router) diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 4504dd0..10d2710 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -7,6 +7,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_target_store import PictureTargetStore +from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine @@ -17,6 +18,7 @@ _pp_template_store: PostprocessingTemplateStore | None = None _pattern_template_store: PatternTemplateStore | None = None _picture_source_store: PictureSourceStore | None = None _picture_target_store: PictureTargetStore | None = None +_color_strip_store: ColorStripStore | None = None _processor_manager: ProcessorManager | None = None _profile_store: ProfileStore | None = None _profile_engine: ProfileEngine | None = None @@ -64,6 +66,13 @@ def get_picture_target_store() -> PictureTargetStore: return _picture_target_store +def get_color_strip_store() -> ColorStripStore: + """Get color strip store dependency.""" + if _color_strip_store is None: + raise RuntimeError("Color strip store not initialized") + return _color_strip_store + + def get_processor_manager() -> ProcessorManager: """Get processor manager dependency.""" if _processor_manager is None: @@ -93,13 +102,14 @@ def init_dependencies( pattern_template_store: PatternTemplateStore | None = None, picture_source_store: PictureSourceStore | None = None, picture_target_store: PictureTargetStore | None = None, + color_strip_store: ColorStripStore | None = None, profile_store: ProfileStore | None = None, profile_engine: ProfileEngine | None = None, ): """Initialize global dependencies.""" global _device_store, _template_store, _processor_manager global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store - global _profile_store, _profile_engine + global _color_strip_store, _profile_store, _profile_engine _device_store = device_store _template_store = template_store _processor_manager = processor_manager @@ -107,5 +117,6 @@ def init_dependencies( _pattern_template_store = pattern_template_store _picture_source_store = picture_source_store _picture_target_store = picture_target_store + _color_strip_store = color_strip_store _profile_store = profile_store _profile_engine = profile_engine diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py new file mode 100644 index 0000000..ab63593 --- /dev/null +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -0,0 +1,258 @@ +"""Color strip source routes: CRUD and calibration test.""" + +from fastapi import APIRouter, Depends, HTTPException + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_color_strip_store, + get_picture_target_store, + get_processor_manager, +) +from wled_controller.api.schemas.color_strip_sources import ( + ColorStripSourceCreate, + ColorStripSourceListResponse, + ColorStripSourceResponse, + ColorStripSourceUpdate, + CSSCalibrationTestRequest, +) +from wled_controller.api.schemas.devices import ( + Calibration as CalibrationSchema, + CalibrationTestModeResponse, +) +from wled_controller.core.capture.calibration import ( + calibration_from_dict, + calibration_to_dict, +) +from wled_controller.core.processing.processor_manager import ProcessorManager +from wled_controller.storage.color_strip_source import PictureColorStripSource +from wled_controller.storage.color_strip_store import ColorStripStore +from wled_controller.storage.picture_target_store import PictureTargetStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _css_to_response(source) -> ColorStripSourceResponse: + """Convert a ColorStripSource to a ColorStripSourceResponse.""" + calibration = None + if isinstance(source, PictureColorStripSource) and source.calibration: + calibration = CalibrationSchema(**calibration_to_dict(source.calibration)) + + return ColorStripSourceResponse( + id=source.id, + name=source.name, + source_type=source.source_type, + picture_source_id=getattr(source, "picture_source_id", None), + fps=getattr(source, "fps", None), + brightness=getattr(source, "brightness", None), + saturation=getattr(source, "saturation", None), + gamma=getattr(source, "gamma", None), + smoothing=getattr(source, "smoothing", None), + interpolation_mode=getattr(source, "interpolation_mode", None), + calibration=calibration, + description=source.description, + created_at=source.created_at, + updated_at=source.updated_at, + ) + + +# ===== CRUD ENDPOINTS ===== + +@router.get("/api/v1/color-strip-sources", response_model=ColorStripSourceListResponse, tags=["Color Strip Sources"]) +async def list_color_strip_sources( + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), +): + """List all color strip sources.""" + sources = store.get_all_sources() + responses = [_css_to_response(s) for s in sources] + return ColorStripSourceListResponse(sources=responses, count=len(responses)) + + +@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201) +async def create_color_strip_source( + data: ColorStripSourceCreate, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), +): + """Create a new color strip source.""" + try: + calibration = None + if data.calibration is not None: + calibration = calibration_from_dict(data.calibration.model_dump()) + + source = store.create_source( + name=data.name, + source_type=data.source_type, + picture_source_id=data.picture_source_id, + fps=data.fps, + brightness=data.brightness, + saturation=data.saturation, + gamma=data.gamma, + smoothing=data.smoothing, + interpolation_mode=data.interpolation_mode, + calibration=calibration, + description=data.description, + ) + return _css_to_response(source) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to create color strip source: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"]) +async def get_color_strip_source( + source_id: str, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), +): + """Get a color strip source by ID.""" + try: + source = store.get_source(source_id) + return _css_to_response(source) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"]) +async def update_color_strip_source( + source_id: str, + data: ColorStripSourceUpdate, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Update a color strip source and hot-reload any running streams.""" + try: + calibration = None + if data.calibration is not None: + calibration = calibration_from_dict(data.calibration.model_dump()) + + source = store.update_source( + source_id=source_id, + name=data.name, + picture_source_id=data.picture_source_id, + fps=data.fps, + brightness=data.brightness, + saturation=data.saturation, + gamma=data.gamma, + smoothing=data.smoothing, + interpolation_mode=data.interpolation_mode, + calibration=calibration, + description=data.description, + ) + + # Hot-reload running stream (no restart needed for in-place param changes) + try: + manager._color_strip_stream_manager.update_source(source_id, source) + except Exception as e: + logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}") + + return _css_to_response(source) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to update color strip source: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"]) +async def delete_color_strip_source( + source_id: str, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + target_store: PictureTargetStore = Depends(get_picture_target_store), +): + """Delete a color strip source. Returns 409 if referenced by any LED target.""" + try: + if target_store.is_referenced_by_color_strip_source(source_id): + raise HTTPException( + status_code=409, + detail="Color strip source is referenced by one or more LED targets. " + "Delete or reassign the targets first.", + ) + store.delete_source(source_id) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to delete color strip source: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ===== CALIBRATION TEST ===== + +@router.put( + "/api/v1/color-strip-sources/{source_id}/calibration/test", + response_model=CalibrationTestModeResponse, + tags=["Color Strip Sources"], +) +async def test_css_calibration( + source_id: str, + body: CSSCalibrationTestRequest, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Temporarily light up LED edges to verify calibration. + + Pass a device_id and an edges dict with RGB colors. + Send an empty edges dict to exit test mode. + """ + try: + # Validate device exists in manager + if body.device_id not in manager._devices: + raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found") + + # Validate edge names and colors + valid_edges = {"top", "right", "bottom", "left"} + for edge_name, color in body.edges.items(): + if edge_name not in valid_edges: + raise HTTPException( + status_code=400, + detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}" + ) + if len(color) != 3 or not all(0 <= c <= 255 for c in color): + raise HTTPException( + status_code=400, + detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255.", + ) + + # Get CSS calibration to send the right pixel pattern + calibration = None + if body.edges: + try: + source = store.get_source(source_id) + if isinstance(source, PictureColorStripSource) and source.calibration: + calibration = source.calibration + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await manager.set_test_mode(body.device_id, body.edges, calibration) + + active_edges = list(body.edges.keys()) + logger.info( + f"CSS calibration test mode {'activated' if active_edges else 'deactivated'} " + f"for device {body.device_id} via CSS {source_id}: {active_edges}" + ) + + return CalibrationTestModeResponse( + test_mode=len(active_edges) > 0, + active_edges=active_edges, + device_id=body.device_id, + ) + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to set CSS calibration test mode: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index b128616..7d9afbc 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -15,9 +15,6 @@ from wled_controller.api.dependencies import ( get_processor_manager, ) from wled_controller.api.schemas.devices import ( - Calibration as CalibrationSchema, - CalibrationTestModeRequest, - CalibrationTestModeResponse, DeviceCreate, DeviceListResponse, DeviceResponse, @@ -27,10 +24,6 @@ from wled_controller.api.schemas.devices import ( DiscoverDevicesResponse, StaticColorUpdate, ) -from wled_controller.core.capture.calibration import ( - calibration_from_dict, - calibration_to_dict, -) from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore from wled_controller.storage.picture_target_store import PictureTargetStore @@ -54,7 +47,6 @@ def _device_to_response(device) -> DeviceResponse: auto_shutdown=device.auto_shutdown, static_color=list(device.static_color) if device.static_color else None, capabilities=sorted(get_device_capabilities(device.device_type)), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), created_at=device.created_at, updated_at=device.updated_at, ) @@ -133,7 +125,6 @@ async def create_device( device_id=device.id, device_url=device.url, led_count=device.led_count, - calibration=device.calibration, device_type=device.device_type, baud_rate=device.baud_rate, auto_shutdown=device.auto_shutdown, @@ -534,105 +525,3 @@ async def set_device_color( return {"color": list(color) if color else None} - -# ===== CALIBRATION ENDPOINTS ===== - -@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) -async def get_calibration( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), -): - """Get calibration configuration for a device.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - return CalibrationSchema(**calibration_to_dict(device.calibration)) - - -@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) -async def update_calibration( - device_id: str, - calibration_data: CalibrationSchema, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Update calibration configuration for a device.""" - try: - # Convert schema to CalibrationConfig - calibration_dict = calibration_data.model_dump() - calibration = calibration_from_dict(calibration_dict) - - # Update in storage - device = store.update_device(device_id, calibration=calibration) - - # Update in manager (also updates active target's cached calibration) - try: - manager.update_calibration(device_id, calibration) - except ValueError: - pass - - return CalibrationSchema(**calibration_to_dict(device.calibration)) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to update calibration: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.put( - "/api/v1/devices/{device_id}/calibration/test", - response_model=CalibrationTestModeResponse, - tags=["Calibration"], -) -async def set_calibration_test_mode( - device_id: str, - body: CalibrationTestModeRequest, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Toggle calibration test mode for specific edges.""" - try: - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - # Validate edge names and colors - valid_edges = {"top", "right", "bottom", "left"} - for edge_name, color in body.edges.items(): - if edge_name not in valid_edges: - raise HTTPException( - status_code=400, - detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}" - ) - if len(color) != 3 or not all(0 <= c <= 255 for c in color): - raise HTTPException( - status_code=400, - detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255." - ) - - await manager.set_test_mode(device_id, body.edges) - - active_edges = list(body.edges.keys()) - logger.info( - f"Test mode {'activated' if active_edges else 'deactivated'} " - f"for device {device_id}: {active_edges}" - ) - - return CalibrationTestModeResponse( - test_mode=len(active_edges) > 0, - active_edges=active_edges, - device_id=device_id, - ) - - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to set test mode: {e}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 2e85985..c1b175f 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -29,7 +29,6 @@ from wled_controller.api.schemas.picture_targets import ( PictureTargetListResponse, PictureTargetResponse, PictureTargetUpdate, - ProcessingSettings as ProcessingSettingsSchema, TargetMetricsResponse, TargetProcessingState, ) @@ -37,7 +36,6 @@ from wled_controller.config import get_config from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.filters import FilterRegistry, ImagePool from wled_controller.core.processing.processor_manager import ProcessorManager -from wled_controller.core.processing.processing_settings import ProcessingSettings from wled_controller.core.capture.screen_capture import ( calculate_average_color, calculate_dominant_color, @@ -61,32 +59,6 @@ logger = get_logger(__name__) router = APIRouter() -def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings: - """Convert schema ProcessingSettings to core ProcessingSettings.""" - return ProcessingSettings( - display_index=schema.display_index, - fps=schema.fps, - interpolation_mode=schema.interpolation_mode, - brightness=schema.brightness, - smoothing=schema.smoothing, - standby_interval=schema.standby_interval, - state_check_interval=schema.state_check_interval, - ) - - -def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchema: - """Convert core ProcessingSettings to schema ProcessingSettings.""" - return ProcessingSettingsSchema( - display_index=settings.display_index, - fps=settings.fps, - interpolation_mode=settings.interpolation_mode, - brightness=settings.brightness, - smoothing=settings.smoothing, - standby_interval=settings.standby_interval, - state_check_interval=settings.state_check_interval, - ) - - def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema: """Convert core KeyColorsSettings to schema.""" return KeyColorsSettingsSchema( @@ -117,8 +89,9 @@ def _target_to_response(target) -> PictureTargetResponse: name=target.name, target_type=target.target_type, device_id=target.device_id, - picture_source_id=target.picture_source_id, - settings=_settings_to_schema(target.settings), + color_strip_source_id=target.color_strip_source_id, + standby_interval=target.standby_interval, + state_check_interval=target.state_check_interval, description=target.description, created_at=target.created_at, updated_at=target.updated_at, @@ -163,8 +136,6 @@ async def create_target( 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 kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None # Create in store @@ -172,8 +143,10 @@ async def create_target( name=data.name, target_type=data.target_type, device_id=data.device_id, + color_strip_source_id=data.color_strip_source_id, + standby_interval=data.standby_interval, + state_check_interval=data.state_check_interval, picture_source_id=data.picture_source_id, - settings=core_settings, key_colors_settings=kc_settings, description=data.description, ) @@ -237,8 +210,6 @@ async def update_target( 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 kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None # Update in store @@ -246,8 +217,10 @@ async def update_target( target_id=target_id, name=data.name, device_id=data.device_id, + color_strip_source_id=data.color_strip_source_id, + standby_interval=data.standby_interval, + state_check_interval=data.state_check_interval, picture_source_id=data.picture_source_id, - settings=core_settings, key_colors_settings=kc_settings, description=data.description, ) @@ -256,8 +229,10 @@ async def update_target( try: target.sync_with_manager( manager, - settings_changed=data.settings is not None or data.key_colors_settings is not None, - source_changed=data.picture_source_id is not None, + settings_changed=(data.standby_interval is not None or + data.state_check_interval is not None or + data.key_colors_settings is not None), + source_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, ) except ValueError: @@ -375,76 +350,6 @@ async def get_target_state( raise HTTPException(status_code=500, detail=str(e)) -@router.get("/api/v1/picture-targets/{target_id}/settings", 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, KeyColorsPictureTarget): - return _kc_settings_to_schema(target.settings) - 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, - interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode, - brightness=settings.brightness if 'brightness' in sent else existing.brightness, - smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing, - standby_interval=settings.standby_interval if 'standby_interval' in sent else existing.standby_interval, - state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval, - ) - - # 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, diff --git a/server/src/wled_controller/api/schemas/__init__.py b/server/src/wled_controller/api/schemas/__init__.py index de74f6c..f8ce30d 100644 --- a/server/src/wled_controller/api/schemas/__init__.py +++ b/server/src/wled_controller/api/schemas/__init__.py @@ -23,12 +23,18 @@ from .devices import ( DeviceStateResponse, DeviceUpdate, ) +from .color_strip_sources import ( + ColorStripSourceCreate, + ColorStripSourceListResponse, + ColorStripSourceResponse, + ColorStripSourceUpdate, + CSSCalibrationTestRequest, +) from .picture_targets import ( PictureTargetCreate, PictureTargetListResponse, PictureTargetResponse, PictureTargetUpdate, - ProcessingSettings, TargetMetricsResponse, TargetProcessingState, ) @@ -89,11 +95,15 @@ __all__ = [ "DeviceResponse", "DeviceStateResponse", "DeviceUpdate", + "ColorStripSourceCreate", + "ColorStripSourceListResponse", + "ColorStripSourceResponse", + "ColorStripSourceUpdate", + "CSSCalibrationTestRequest", "PictureTargetCreate", "PictureTargetListResponse", "PictureTargetResponse", "PictureTargetUpdate", - "ProcessingSettings", "TargetMetricsResponse", "TargetProcessingState", "EngineInfo", diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py new file mode 100644 index 0000000..2bf141f --- /dev/null +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -0,0 +1,80 @@ +"""Color strip source schemas (CRUD).""" + +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + +from wled_controller.api.schemas.devices import Calibration + + +class ColorStripSourceCreate(BaseModel): + """Request to create a color strip source.""" + + name: str = Field(description="Source name", min_length=1, max_length=100) + source_type: Literal["picture"] = Field(default="picture", description="Source type") + picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") + fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) + brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) + saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0) + gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) + smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0) + interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") + calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class ColorStripSourceUpdate(BaseModel): + """Request to update a color strip source.""" + + name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) + picture_source_id: Optional[str] = Field(None, description="Picture source ID") + fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90) + brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) + saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0) + gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) + smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) + interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class ColorStripSourceResponse(BaseModel): + """Color strip source response.""" + + id: str = Field(description="Source ID") + name: str = Field(description="Source name") + source_type: str = Field(description="Source type") + picture_source_id: Optional[str] = Field(None, description="Picture source ID") + fps: Optional[int] = Field(None, description="Target FPS") + brightness: Optional[float] = Field(None, description="Brightness multiplier") + saturation: Optional[float] = Field(None, description="Saturation") + gamma: Optional[float] = Field(None, description="Gamma correction") + smoothing: Optional[float] = Field(None, description="Temporal smoothing") + interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") + calibration: Optional[Calibration] = Field(None, description="LED calibration") + description: Optional[str] = Field(None, description="Description") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + + +class ColorStripSourceListResponse(BaseModel): + """List of color strip sources.""" + + sources: List[ColorStripSourceResponse] = Field(description="List of color strip sources") + count: int = Field(description="Number of sources") + + +class CSSCalibrationTestRequest(BaseModel): + """Request to run a calibration test for a color strip source on a specific device.""" + + device_id: str = Field(description="Device ID to send test pixels to") + edges: dict = Field( + default_factory=lambda: { + "top": [255, 0, 0], + "right": [0, 255, 0], + "bottom": [0, 100, 255], + "left": [255, 255, 0], + }, + description="Map of edge names to RGB colors. Empty dict = exit test mode.", + ) diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index fe626ad..dac9c92 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -104,7 +104,6 @@ class DeviceResponse(BaseModel): auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop") static_color: Optional[List[int]] = Field(None, description="Static idle color [R, G, B]") capabilities: List[str] = Field(default_factory=list, description="Device type capabilities") - calibration: Optional[Calibration] = Field(None, description="Calibration configuration") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index b40b1e7..223f2e8 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -1,26 +1,11 @@ -"""Picture target schemas (CRUD, processing state, settings, metrics).""" +"""Picture target schemas (CRUD, processing state, metrics).""" from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Dict, List, Optional from pydantic import BaseModel, Field -from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL - - -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) - 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) - standby_interval: float = Field(default=1.0, description="Seconds between keepalive sends when screen is static (0.5-5.0)", ge=0.5, le=5.0) - state_check_interval: int = Field( - default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, - description="Seconds between WLED health checks" - ) +DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks class KeyColorRectangleSchema(BaseModel): @@ -65,9 +50,13 @@ class PictureTargetCreate(BaseModel): name: str = Field(description="Target name", min_length=1, max_length=100) target_type: str = Field(default="led", description="Target type (led, key_colors)") + # LED target fields device_id: str = Field(default="", description="LED device ID") - picture_source_id: str = Field(default="", description="Picture source ID") - settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)") + color_strip_source_id: str = Field(default="", description="Color strip source ID") + standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0) + state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) + # KC target fields + picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -76,9 +65,13 @@ 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 (for wled targets)") + # LED target fields + device_id: Optional[str] = Field(None, description="LED device ID") + color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") + standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0) + state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) + # KC target fields + picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -89,10 +82,14 @@ class PictureTargetResponse(BaseModel): 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: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)") - key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (key_colors)") + # LED target fields + device_id: str = Field(default="", description="LED device ID") + color_strip_source_id: str = Field(default="", description="Color strip source ID") + standby_interval: float = Field(default=1.0, description="Keepalive interval (s)") + state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)") + # KC target fields + picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") + key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") description: Optional[str] = Field(None, description="Description") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") @@ -110,21 +107,18 @@ class TargetProcessingState(BaseModel): target_id: str = Field(description="Target ID") device_id: Optional[str] = Field(None, description="Device ID") + color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") processing: bool = Field(description="Whether processing is active") fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)") - fps_target: int = Field(default=0, description="Target FPS") + fps_target: Optional[int] = Field(None, description="Target FPS") frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)") frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby") fps_current: Optional[int] = Field(None, description="Frames sent in the last second") - timing_extract_ms: Optional[float] = Field(None, description="Border extraction time (ms)") - timing_map_leds_ms: Optional[float] = Field(None, description="LED mapping time (ms)") - timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)") timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)") - timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)") timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)") timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)") - display_index: int = Field(default=0, description="Current display index") + display_index: Optional[int] = Field(None, description="Current display index") overlay_active: bool = Field(default=False, description="Whether visualization overlay is active") last_update: Optional[datetime] = Field(None, description="Last successful update") errors: List[str] = Field(default_factory=list, description="Recent errors") @@ -147,7 +141,7 @@ class TargetMetricsResponse(BaseModel): device_id: Optional[str] = Field(None, 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") + fps_target: Optional[int] = Field(None, 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") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index 31a550e..2cbf627 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -33,6 +33,7 @@ class StorageConfig(BaseSettings): picture_sources_file: str = "data/picture_sources.json" picture_targets_file: str = "data/picture_targets.json" pattern_templates_file: str = "data/pattern_templates.json" + color_strip_sources_file: str = "data/color_strip_sources.json" profiles_file: str = "data/profiles.json" diff --git a/server/src/wled_controller/core/processing/__init__.py b/server/src/wled_controller/core/processing/__init__.py index 8408ca2..46afbf6 100644 --- a/server/src/wled_controller/core/processing/__init__.py +++ b/server/src/wled_controller/core/processing/__init__.py @@ -1,10 +1,6 @@ """Target processing pipeline.""" from wled_controller.core.processing.processor_manager import ProcessorManager -from wled_controller.core.processing.processing_settings import ( - DEFAULT_STATE_CHECK_INTERVAL, - ProcessingSettings, -) from wled_controller.core.processing.target_processor import ( DeviceInfo, ProcessingMetrics, @@ -13,10 +9,8 @@ from wled_controller.core.processing.target_processor import ( ) __all__ = [ - "DEFAULT_STATE_CHECK_INTERVAL", "DeviceInfo", "ProcessingMetrics", - "ProcessingSettings", "ProcessorManager", "TargetContext", "TargetProcessor", diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py new file mode 100644 index 0000000..90fec69 --- /dev/null +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -0,0 +1,321 @@ +"""Color strip stream — produces LED color arrays from a source. + +A ColorStripStream is the runtime counterpart of ColorStripSource. +It continuously computes and caches a fresh np.ndarray (led_count, 3) uint8 +by processing frames from a LiveStream. + +Multiple WledTargetProcessors may read from the same ColorStripStream instance +(shared via ColorStripStreamManager reference counting), meaning the CPU-bound +processing — border extraction, pixel mapping, color correction — runs only once +even when multiple devices share the same source configuration. +""" + +import threading +import time +from abc import ABC, abstractmethod +from typing import Optional + +import numpy as np + +from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper +from wled_controller.core.capture.screen_capture import extract_border_pixels +from wled_controller.core.processing.live_stream import LiveStream +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +def _apply_saturation(colors: np.ndarray, saturation: float) -> np.ndarray: + """Adjust saturation via luminance mixing (Rec.601 weights). + + saturation=1.0: no change + saturation=0.0: grayscale + saturation=2.0: double saturation (clipped to 0-255) + """ + gray = ( + colors[:, 0].astype(np.int32) * 299 + + colors[:, 1].astype(np.int32) * 587 + + colors[:, 2].astype(np.int32) * 114 + ) // 1000 + gray = gray[:, np.newaxis] # (N, 1) for broadcast + result = gray + saturation * (colors.astype(np.int32) - gray) + return np.clip(result, 0, 255).astype(np.uint8) + + +def _build_gamma_lut(gamma: float) -> np.ndarray: + """Build a 256-entry uint8 LUT for gamma correction. + + gamma=1.0: identity (no correction) + gamma<1.0: brighter midtones + gamma>1.0: darker midtones + """ + if gamma == 1.0: + return np.arange(256, dtype=np.uint8) + lut = np.array( + [min(255, int(((i / 255.0) ** (1.0 / gamma)) * 255 + 0.5)) for i in range(256)], + dtype=np.uint8, + ) + return lut + + +class ColorStripStream(ABC): + """Abstract base: a runtime source of LED color arrays. + + Produces a continuous stream of np.ndarray (led_count, 3) uint8 values. + Consumers call get_latest_colors() (non-blocking) to read the most recent + computed frame. + """ + + @property + @abstractmethod + def target_fps(self) -> int: + """Target processing rate.""" + + @property + @abstractmethod + def led_count(self) -> int: + """Number of LEDs this stream produces colors for.""" + + @property + def display_index(self) -> Optional[int]: + """Display index of the underlying capture, or None.""" + return None + + @property + def calibration(self) -> Optional[CalibrationConfig]: + """Calibration config, or None if not applicable.""" + return None + + @abstractmethod + def start(self) -> None: + """Start producing colors.""" + + @abstractmethod + def stop(self) -> None: + """Stop producing colors and release resources.""" + + @abstractmethod + def get_latest_colors(self) -> Optional[np.ndarray]: + """Get the most recent LED color array (led_count, 3) uint8, or None.""" + + def get_last_timing(self) -> dict: + """Return per-stage timing from the last processed frame (ms).""" + return {} + + def update_source(self, source) -> None: + """Hot-update processing parameters. No-op by default.""" + + +class PictureColorStripStream(ColorStripStream): + """Color strip stream backed by a LiveStream (picture source). + + Runs a background thread that: + 1. Reads the latest frame from the LiveStream + 2. Extracts border pixels using the calibration's border_width + 3. Maps border pixels to LED colors via PixelMapper + 4. Applies temporal smoothing + 5. Applies saturation correction + 6. Applies gamma correction (LUT-based, O(1) per pixel) + 7. Applies brightness scaling + 8. Caches the result for lock-free consumer reads + + Processing parameters can be hot-updated via update_source() without + restarting the thread (except when the underlying LiveStream changes). + """ + + def __init__(self, live_stream: LiveStream, source): + """ + Args: + live_stream: Acquired LiveStream (lifecycle managed by ColorStripStreamManager) + source: PictureColorStripSource config + """ + from wled_controller.storage.color_strip_source import PictureColorStripSource + + self._live_stream = live_stream + self._fps: int = source.fps + self._smoothing: float = source.smoothing + self._brightness: float = source.brightness + self._saturation: float = source.saturation + self._gamma: float = source.gamma + self._interpolation_mode: str = source.interpolation_mode + self._calibration: CalibrationConfig = source.calibration + self._pixel_mapper = PixelMapper( + self._calibration, interpolation_mode=self._interpolation_mode + ) + self._led_count: int = self._calibration.get_total_leds() + self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma) + + # Thread-safe color cache + self._latest_colors: Optional[np.ndarray] = None + self._colors_lock = threading.Lock() + self._previous_colors: Optional[np.ndarray] = None + + self._running = False + self._thread: Optional[threading.Thread] = None + self._last_timing: dict = {} + + @property + def target_fps(self) -> int: + return self._fps + + @property + def led_count(self) -> int: + return self._led_count + + @property + def display_index(self) -> Optional[int]: + return self._live_stream.display_index + + @property + def calibration(self) -> Optional[CalibrationConfig]: + return self._calibration + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._processing_loop, + name="css-picture-stream", + daemon=True, + ) + self._thread.start() + logger.info( + f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})" + ) + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning("PictureColorStripStream thread did not terminate within 5s") + self._thread = None + self._latest_colors = None + self._previous_colors = None + logger.info("PictureColorStripStream stopped") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._latest_colors + + def get_last_timing(self) -> dict: + return dict(self._last_timing) + + def update_source(self, source) -> None: + """Hot-update processing parameters. Thread-safe for scalar params. + + PixelMapper is rebuilt atomically if calibration or interpolation_mode changed. + """ + from wled_controller.storage.color_strip_source import PictureColorStripSource + + if not isinstance(source, PictureColorStripSource): + return + + self._fps = source.fps + self._smoothing = source.smoothing + self._brightness = source.brightness + self._saturation = source.saturation + + if source.gamma != self._gamma: + self._gamma = source.gamma + self._gamma_lut = _build_gamma_lut(source.gamma) + + if ( + source.interpolation_mode != self._interpolation_mode + or source.calibration != self._calibration + ): + self._interpolation_mode = source.interpolation_mode + self._calibration = source.calibration + self._led_count = source.calibration.get_total_leds() + self._pixel_mapper = PixelMapper( + source.calibration, interpolation_mode=source.interpolation_mode + ) + self._previous_colors = None # Reset smoothing history on calibration change + + logger.info("PictureColorStripStream params updated in-place") + + def _processing_loop(self) -> None: + """Background thread: poll source, process, cache colors.""" + cached_frame = None + + while self._running: + loop_start = time.perf_counter() + fps = self._fps + frame_time = 1.0 / fps if fps > 0 else 1.0 + + try: + frame = self._live_stream.get_latest_frame() + + if frame is None or frame is cached_frame: + elapsed = time.perf_counter() - loop_start + time.sleep(max(frame_time - elapsed, 0.001)) + continue + + cached_frame = frame + + t0 = time.perf_counter() + + calibration = self._calibration + border_pixels = extract_border_pixels(frame, calibration.border_width) + t1 = time.perf_counter() + + led_colors = self._pixel_mapper.map_border_to_leds(border_pixels) + t2 = time.perf_counter() + + # Temporal smoothing + smoothing = self._smoothing + if ( + self._previous_colors is not None + and smoothing > 0 + and len(self._previous_colors) == len(led_colors) + ): + alpha = int(smoothing * 256) + led_colors = ( + (256 - alpha) * led_colors.astype(np.uint16) + + alpha * self._previous_colors.astype(np.uint16) + ) >> 8 + led_colors = led_colors.astype(np.uint8) + t3 = time.perf_counter() + + # Saturation + saturation = self._saturation + if saturation != 1.0: + led_colors = _apply_saturation(led_colors, saturation) + t4 = time.perf_counter() + + # Gamma (LUT lookup — O(1) per pixel) + if self._gamma != 1.0: + led_colors = self._gamma_lut[led_colors] + t5 = time.perf_counter() + + # Brightness + brightness = self._brightness + if brightness != 1.0: + led_colors = np.clip( + led_colors.astype(np.float32) * brightness, 0, 255 + ).astype(np.uint8) + t6 = time.perf_counter() + + self._previous_colors = led_colors + + with self._colors_lock: + self._latest_colors = led_colors + + self._last_timing = { + "extract_ms": (t1 - t0) * 1000, + "map_leds_ms": (t2 - t1) * 1000, + "smooth_ms": (t3 - t2) * 1000, + "saturation_ms": (t4 - t3) * 1000, + "gamma_ms": (t5 - t4) * 1000, + "brightness_ms": (t6 - t5) * 1000, + "total_ms": (t6 - t0) * 1000, + } + + except Exception as e: + logger.error(f"PictureColorStripStream processing error: {e}", exc_info=True) + + elapsed = time.perf_counter() - loop_start + remaining = frame_time - elapsed + if remaining > 0: + time.sleep(remaining) diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py new file mode 100644 index 0000000..3367806 --- /dev/null +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -0,0 +1,192 @@ +"""Shared color strip stream management with reference counting. + +ColorStripStreamManager creates PictureColorStripStream instances from +ColorStripSource configs and shares them across multiple consumers (LED targets). +When multiple targets reference the same ColorStripSource, they share a single +stream instance — processing runs once, not once per target. + +Reference counting ensures streams are cleaned up when the last consumer +releases them. +""" + +from dataclasses import dataclass +from typing import Dict, Optional + +from wled_controller.core.processing.color_strip_stream import ( + ColorStripStream, + PictureColorStripStream, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +@dataclass +class _ColorStripEntry: + """Internal tracking entry for a managed color strip stream.""" + + stream: ColorStripStream + ref_count: int + # ID of the picture source whose LiveStream we acquired (for release) + picture_source_id: str + + +class ColorStripStreamManager: + """Manages shared ColorStripStream instances with reference counting. + + Multiple LED targets using the same ColorStripSource share a single + ColorStripStream. Streams are created on first acquire and cleaned up + when the last consumer releases. + """ + + def __init__(self, color_strip_store, live_stream_manager): + """ + Args: + color_strip_store: ColorStripStore for resolving source configs + live_stream_manager: LiveStreamManager for acquiring picture streams + """ + self._color_strip_store = color_strip_store + self._live_stream_manager = live_stream_manager + self._streams: Dict[str, _ColorStripEntry] = {} + + def acquire(self, css_id: str) -> ColorStripStream: + """Get or create a ColorStripStream for the given ColorStripSource. + + If a stream already exists for this css_id, increments the reference + count and returns the existing instance. + + Otherwise, loads the ColorStripSource config, acquires the underlying + LiveStream, creates a new ColorStripStream, starts it, and stores it + with ref_count=1. + + Args: + css_id: ID of the ColorStripSource config + + Returns: + ColorStripStream instance (shared if already running) + + Raises: + ValueError: If ColorStripSource not found or type unsupported + RuntimeError: If stream creation/start fails + """ + if css_id in self._streams: + entry = self._streams[css_id] + entry.ref_count += 1 + logger.info( + f"Reusing color strip stream for source {css_id} " + f"(ref_count={entry.ref_count})" + ) + return entry.stream + + from wled_controller.storage.color_strip_source import PictureColorStripSource + + source = self._color_strip_store.get_source(css_id) + + if not isinstance(source, PictureColorStripSource): + raise ValueError( + f"Unsupported color strip source type '{source.source_type}' for {css_id}" + ) + + if not source.picture_source_id: + raise ValueError( + f"Color strip source {css_id} has no picture_source_id assigned" + ) + + # Acquire the underlying live stream (ref-counted) + live_stream = self._live_stream_manager.acquire(source.picture_source_id) + + try: + css_stream = PictureColorStripStream(live_stream, source) + css_stream.start() + except Exception as e: + self._live_stream_manager.release(source.picture_source_id) + raise RuntimeError( + f"Failed to start color strip stream for source {css_id}: {e}" + ) from e + + self._streams[css_id] = _ColorStripEntry( + stream=css_stream, + ref_count=1, + picture_source_id=source.picture_source_id, + ) + + logger.info(f"Created color strip stream for source {css_id}") + return css_stream + + def release(self, css_id: str) -> None: + """Release a reference to a ColorStripStream. + + Decrements the reference count. When it reaches 0, stops the stream, + releases the underlying LiveStream, and removes from registry. + + Args: + css_id: ID of the ColorStripSource to release + """ + entry = self._streams.get(css_id) + if not entry: + logger.warning(f"Attempted to release unknown color strip stream: {css_id}") + return + + entry.ref_count -= 1 + logger.debug(f"Released color strip stream {css_id} (ref_count={entry.ref_count})") + + if entry.ref_count <= 0: + try: + entry.stream.stop() + except Exception as e: + logger.error(f"Error stopping color strip stream {css_id}: {e}") + + picture_source_id = entry.picture_source_id + del self._streams[css_id] + logger.info(f"Removed color strip stream for source {css_id}") + + # Release the underlying live stream + self._live_stream_manager.release(picture_source_id) + + def update_source(self, css_id: str, new_source) -> None: + """Hot-update processing params on a running stream. + + If the picture_source_id changed, only updates in-place params; + the underlying LiveStream is not swapped (requires target restart + to take full effect). + + Args: + css_id: ID of the ColorStripSource + new_source: Updated ColorStripSource config + """ + entry = self._streams.get(css_id) + if not entry: + return # Stream not running; config will be used on next acquire + + entry.stream.update_source(new_source) + + # Track picture_source_id change for future reference counting + from wled_controller.storage.color_strip_source import PictureColorStripSource + if isinstance(new_source, PictureColorStripSource): + if new_source.picture_source_id != entry.picture_source_id: + logger.info( + f"CSS {css_id}: picture_source_id changed — " + f"restart target to use new source" + ) + + logger.info(f"Updated running color strip stream {css_id}") + + def release_all(self) -> None: + """Stop and remove all managed color strip streams. Called on shutdown.""" + css_ids = list(self._streams.keys()) + for css_id in css_ids: + entry = self._streams.get(css_id) + if entry: + try: + entry.stream.stop() + except Exception as e: + logger.error(f"Error stopping color strip stream {css_id}: {e}") + self._streams.clear() + logger.info("Released all managed color strip streams") + + def get_active_stream_ids(self) -> list: + """Get list of active stream IDs with ref counts (for diagnostics).""" + return [ + {"id": sid, "ref_count": entry.ref_count} + for sid, entry in self._streams.items() + ] diff --git a/server/src/wled_controller/core/processing/kc_target_processor.py b/server/src/wled_controller/core/processing/kc_target_processor.py index 2232e0f..05fe8df 100644 --- a/server/src/wled_controller/core/processing/kc_target_processor.py +++ b/server/src/wled_controller/core/processing/kc_target_processor.py @@ -93,7 +93,7 @@ class KCTargetProcessor(TargetProcessor): settings, # KeyColorsSettings ctx: TargetContext, ): - super().__init__(target_id, picture_source_id, ctx) + super().__init__(target_id, ctx, picture_source_id) self._settings = settings # Runtime state diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index adf433b..5864861 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -6,10 +6,7 @@ from typing import Dict, List, Optional, Tuple import httpx -from wled_controller.core.capture.calibration import ( - CalibrationConfig, - create_default_calibration, -) +from wled_controller.core.capture.calibration import CalibrationConfig from wled_controller.core.devices.led_client import ( DeviceHealth, check_device_health, @@ -17,11 +14,8 @@ from wled_controller.core.devices.led_client import ( get_provider, ) from wled_controller.core.processing.live_stream_manager import LiveStreamManager +from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager from wled_controller.core.capture.screen_overlay import OverlayManager -from wled_controller.core.processing.processing_settings import ( - DEFAULT_STATE_CHECK_INTERVAL, - ProcessingSettings, -) from wled_controller.core.processing.target_processor import ( DeviceInfo, TargetContext, @@ -33,15 +27,16 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) +DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks + @dataclass class DeviceState: - """State for a registered LED device (health monitoring + calibration).""" + """State for a registered LED device (health monitoring).""" device_id: str device_url: str led_count: int - calibration: CalibrationConfig device_type: str = "wled" baud_rate: Optional[int] = None health: DeviceHealth = field(default_factory=DeviceHealth) @@ -55,6 +50,8 @@ class DeviceState: # 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) + # Calibration used for the current test (from the CSS being tested) + test_calibration: Optional[CalibrationConfig] = None # Tracked power state for serial devices (no hardware query) power_on: bool = True @@ -62,11 +59,11 @@ class DeviceState: class ProcessorManager: """Manages devices and delegates target processing to TargetProcessor instances. - Devices are registered for health monitoring and calibration. + Devices are registered for health monitoring. Targets are registered for processing via polymorphic TargetProcessor subclasses. """ - def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None): + def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None): """Initialize processor manager.""" self._devices: Dict[str, DeviceState] = {} self._processors: Dict[str, TargetProcessor] = {} @@ -78,9 +75,14 @@ class ProcessorManager: self._pp_template_store = pp_template_store self._pattern_template_store = pattern_template_store self._device_store = device_store + self._color_strip_store = color_strip_store self._live_stream_manager = LiveStreamManager( picture_source_store, capture_template_store, pp_template_store ) + self._color_strip_stream_manager = ColorStripStreamManager( + color_strip_store=color_strip_store, + live_stream_manager=self._live_stream_manager, + ) self._overlay_manager = OverlayManager() self._event_queues: List[asyncio.Queue] = [] logger.info("Processor manager initialized") @@ -97,6 +99,7 @@ class ProcessorManager: pp_template_store=self._pp_template_store, pattern_template_store=self._pattern_template_store, device_store=self._device_store, + color_strip_stream_manager=self._color_strip_stream_manager, fire_event=self._fire_event, get_device_info=self._get_device_info, ) @@ -110,7 +113,6 @@ class ProcessorManager: device_id=ds.device_id, device_url=ds.device_url, led_count=ds.led_count, - calibration=ds.calibration, device_type=ds.device_type, baud_rate=ds.baud_rate, software_brightness=ds.software_brightness, @@ -144,14 +146,13 @@ class ProcessorManager: self._http_client = httpx.AsyncClient(timeout=5) return self._http_client - # ===== DEVICE MANAGEMENT (health monitoring + calibration) ===== + # ===== DEVICE MANAGEMENT (health monitoring) ===== def add_device( self, device_id: str, device_url: str, led_count: int, - calibration: Optional[CalibrationConfig] = None, device_type: str = "wled", baud_rate: Optional[int] = None, software_brightness: int = 255, @@ -162,14 +163,10 @@ class ProcessorManager: 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 = DeviceState( device_id=device_id, device_url=device_url, led_count=led_count, - calibration=calibration, device_type=device_type, baud_rate=baud_rate, software_brightness=software_brightness, @@ -214,34 +211,8 @@ class ProcessorManager: if baud_rate is not None: ds.baud_rate = baud_rate - def update_calibration(self, device_id: str, calibration: CalibrationConfig): - """Update calibration for a device. - - Also propagates to any target processor using this device. - """ - if device_id not in self._devices: - raise ValueError(f"Device {device_id} not found") - - calibration.validate() - - 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 ({ds.led_count})" - ) - - ds.calibration = calibration - - # Propagate to active processors using this device - for proc in self._processors.values(): - if proc.device_id == device_id: - proc.update_calibration(calibration) - - logger.info(f"Updated calibration for device {device_id}") - def get_device_state(self, device_id: str) -> DeviceState: - """Get device state (for health/calibration info).""" + """Get device state (for health info).""" if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") return self._devices[device_id] @@ -298,8 +269,9 @@ class ProcessorManager: self, target_id: str, device_id: str, - settings: Optional[ProcessingSettings] = None, - picture_source_id: str = "", + color_strip_source_id: str = "", + standby_interval: float = 1.0, + state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, ): """Register a WLED target processor.""" if target_id in self._processors: @@ -310,8 +282,9 @@ class ProcessorManager: proc = WledTargetProcessor( target_id=target_id, device_id=device_id, - settings=settings or ProcessingSettings(), - picture_source_id=picture_source_id, + color_strip_source_id=color_strip_source_id, + standby_interval=standby_interval, + state_check_interval=state_check_interval, ctx=self._build_context(), ) self._processors[target_id] = proc @@ -357,6 +330,11 @@ class ProcessorManager: proc = self._get_processor(target_id) proc.update_source(picture_source_id) + def update_target_color_strip_source(self, target_id: str, color_strip_source_id: str): + """Update the color strip source for a WLED target.""" + proc = self._get_processor(target_id) + proc.update_color_strip_source(color_strip_source_id) + def update_target_device(self, target_id: str, device_id: str): """Update the device for a target.""" proc = self._get_processor(target_id) @@ -499,10 +477,19 @@ class ProcessorManager: proc = self._get_processor(target_id) return proc.get_latest_colors() - # ===== CALIBRATION TEST MODE (on device) ===== + # ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) ===== - async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None: - """Set or clear calibration test mode for a device.""" + async def set_test_mode( + self, + device_id: str, + edges: Dict[str, List[int]], + calibration: Optional[CalibrationConfig] = None, + ) -> None: + """Set or clear calibration test mode for a device. + + When setting test mode, pass the calibration from the CSS being tested. + When clearing (edges={}), calibration is not needed. + """ if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") @@ -513,10 +500,13 @@ class ProcessorManager: ds.test_mode_edges = { edge: tuple(color) for edge, color in edges.items() } + if calibration is not None: + ds.test_calibration = calibration await self._send_test_pixels(device_id) else: ds.test_mode_active = False ds.test_mode_edges = {} + ds.test_calibration = None await self._send_clear_pixels(device_id) await self._close_idle_client(device_id) @@ -558,10 +548,16 @@ class ProcessorManager: 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] + + # Require calibration to know which LEDs map to which edges + if ds.test_calibration is None: + logger.debug(f"No calibration for test mode on {device_id}, skipping LED test") + return + pixels = [(0, 0, 0)] * ds.led_count for edge_name, color in ds.test_mode_edges.items(): - for seg in ds.calibration.segments: + for seg in ds.test_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: @@ -569,8 +565,8 @@ class ProcessorManager: break # Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds) - total_leds = ds.calibration.get_total_leds() - offset = ds.calibration.offset % total_leds if total_leds > 0 else 0 + total_leds = ds.test_calibration.get_total_leds() + offset = ds.test_calibration.offset % total_leds if total_leds > 0 else 0 if offset > 0: pixels = pixels[-offset:] + pixels[:-offset] @@ -701,6 +697,9 @@ class ProcessorManager: for did in list(self._idle_clients): await self._close_idle_client(did) + # Safety net: release all color strip streams + self._color_strip_stream_manager.release_all() + # Safety net: release any remaining managed live streams self._live_stream_manager.release_all() @@ -780,7 +779,7 @@ class ProcessorManager: state.device_type, state.device_url, client, state.health, ) - # Auto-sync LED count (preserve existing calibration) + # Auto-sync LED count reported = state.health.device_led_count if reported and reported != state.led_count and self._device_store: old_count = state.led_count diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index c03cf25..3816fb0 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -17,8 +17,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple if TYPE_CHECKING: - import httpx - from wled_controller.core.capture.calibration import CalibrationConfig + from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager from wled_controller.core.processing.live_stream_manager import LiveStreamManager from wled_controller.core.capture.screen_overlay import OverlayManager from wled_controller.storage import DeviceStore @@ -65,7 +64,6 @@ class DeviceInfo: device_id: str device_url: str led_count: int - calibration: "CalibrationConfig" device_type: str = "wled" baud_rate: Optional[int] = None software_brightness: int = 255 @@ -86,6 +84,7 @@ class TargetContext: pp_template_store: Optional["PostprocessingTemplateStore"] = None pattern_template_store: Optional["PatternTemplateStore"] = None device_store: Optional["DeviceStore"] = None + color_strip_stream_manager: Optional["ColorStripStreamManager"] = None fire_event: Callable[[dict], None] = lambda e: None get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None @@ -100,7 +99,7 @@ class TargetProcessor(ABC): Lifecycle: register → start → (running loop) → stop → unregister """ - def __init__(self, target_id: str, picture_source_id: str, ctx: TargetContext): + def __init__(self, target_id: str, ctx: TargetContext, picture_source_id: str = ""): self._target_id = target_id self._picture_source_id = picture_source_id self._ctx = ctx @@ -161,8 +160,8 @@ class TargetProcessor(ABC): """Update device association. Raises for targets without devices.""" raise ValueError(f"Target {self._target_id} does not support device assignment") - def update_calibration(self, calibration) -> None: - """Update calibration. No-op for targets without devices.""" + def update_color_strip_source(self, color_strip_source_id: str) -> None: + """Update color strip source. No-op for targets that don't use CSS.""" pass # ----- Device / display info (overridden by device-aware subclasses) ----- diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index cc31c9a..8daa9cd 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -1,24 +1,17 @@ -"""WLED/LED target processor — captures screen, maps to LEDs, sends via DDP.""" +"""WLED/LED target processor — gets colors from a ColorStripStream, sends via DDP.""" from __future__ import annotations import asyncio import collections -import concurrent.futures import time from datetime import datetime -from typing import TYPE_CHECKING, Optional +from typing import Optional import numpy as np -from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper from wled_controller.core.devices.led_client import LEDClient, create_led_client -from wled_controller.core.processing.live_stream import LiveStream -from wled_controller.core.processing.processing_settings import ProcessingSettings -from wled_controller.core.capture.screen_capture import ( - extract_border_pixels, - get_available_displays, -) +from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.processing.target_processor import ( DeviceInfo, ProcessingMetrics, @@ -27,92 +20,44 @@ from wled_controller.core.processing.target_processor import ( ) from wled_controller.utils import get_logger -if TYPE_CHECKING: - from wled_controller.core.capture_engines.base import ScreenCapture - logger = get_logger(__name__) -_frame_executor = concurrent.futures.ThreadPoolExecutor( - max_workers=4, thread_name_prefix="frame-proc", -) - - -# --------------------------------------------------------------------------- -# CPU-bound frame processing (runs in dedicated thread-pool executor) -# --------------------------------------------------------------------------- - -def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing, brightness): - """All CPU-bound work for one WLED frame. - - Returns (raw_colors, send_colors, timing_ms). - raw_colors: unscaled array for smoothing history. - send_colors: brightness-scaled array ready for DDP send. - timing_ms: dict with per-stage timing in milliseconds. - """ - t0 = time.perf_counter() - border_pixels = extract_border_pixels(capture, border_width) - t1 = time.perf_counter() - led_colors = pixel_mapper.map_border_to_leds(border_pixels) - t2 = time.perf_counter() - - # Inline numpy smoothing — avoids list↔numpy round-trip - if previous_colors is not None and smoothing > 0 and len(previous_colors) == len(led_colors): - alpha = int(smoothing * 256) - led_colors = ( - (256 - alpha) * led_colors.astype(np.uint16) - + alpha * previous_colors.astype(np.uint16) - ) >> 8 - led_colors = led_colors.astype(np.uint8) - t3 = time.perf_counter() - - # Apply brightness scaling in thread pool (avoids extra array copies on event loop) - if brightness < 255: - send_colors = (led_colors.astype(np.uint16) * brightness >> 8).astype(np.uint8) - else: - send_colors = led_colors - t4 = time.perf_counter() - - timing_ms = { - "extract": (t1 - t0) * 1000, - "map_leds": (t2 - t1) * 1000, - "smooth": (t3 - t2) * 1000, - "total": (t4 - t0) * 1000, - } - return led_colors, send_colors, timing_ms - # --------------------------------------------------------------------------- # WledTargetProcessor # --------------------------------------------------------------------------- class WledTargetProcessor(TargetProcessor): - """Processes screen capture frames and streams LED colors to a WLED/LED device.""" + """Streams LED colors from a ColorStripStream to a WLED/LED device. + + The ColorStripStream handles all capture and color processing. + This processor only applies device software_brightness and sends pixels. + """ def __init__( self, target_id: str, device_id: str, - settings: ProcessingSettings, - picture_source_id: str, + color_strip_source_id: str, + standby_interval: float, + state_check_interval: int, ctx: TargetContext, ): - super().__init__(target_id, picture_source_id, ctx) + super().__init__(target_id, ctx) self._device_id = device_id - self._settings = settings + self._color_strip_source_id = color_strip_source_id + self._standby_interval = standby_interval + self._state_check_interval = state_check_interval # Runtime state (populated on start) self._led_client: Optional[LEDClient] = None - self._pixel_mapper: Optional[PixelMapper] = None - self._live_stream: Optional[LiveStream] = None - self._previous_colors: Optional[np.ndarray] = None + self._color_strip_stream = None self._device_state_before: Optional[dict] = None self._overlay_active = False - # Resolved stream metadata + # Resolved stream metadata (set once stream is acquired) self._resolved_display_index: Optional[int] = None self._resolved_target_fps: Optional[int] = None - self._resolved_engine_type: Optional[str] = None - self._resolved_engine_config: Optional[dict] = None # ----- Properties ----- @@ -120,10 +65,6 @@ class WledTargetProcessor(TargetProcessor): def device_id(self) -> str: return self._device_id - @property - def settings(self) -> ProcessingSettings: - return self._settings - @property def led_client(self) -> Optional[LEDClient]: return self._led_client @@ -139,9 +80,6 @@ class WledTargetProcessor(TargetProcessor): if device_info is None: raise ValueError(f"Device {self._device_id} not registered") - # Resolve stream settings - self._resolve_stream_settings() - # Connect to LED device try: self._led_client = create_led_client( @@ -154,44 +92,42 @@ class WledTargetProcessor(TargetProcessor): f"Target {self._target_id} connected to {device_info.device_type} " f"device ({device_info.led_count} LEDs)" ) - - # Snapshot device state before streaming self._device_state_before = await self._led_client.snapshot_device_state() except Exception as e: logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}") raise RuntimeError(f"Failed to connect to LED device: {e}") - # Acquire live stream + # Acquire color strip stream + css_manager = self._ctx.color_strip_stream_manager + if css_manager is None: + await self._led_client.close() + self._led_client = None + raise RuntimeError("Color strip stream manager not available in context") + + if not self._color_strip_source_id: + await self._led_client.close() + self._led_client = None + raise RuntimeError(f"Target {self._target_id} has no color strip source assigned") + try: - live_stream = await asyncio.to_thread( - self._ctx.live_stream_manager.acquire, self._picture_source_id - ) - self._live_stream = live_stream - if live_stream.display_index is not None: - self._resolved_display_index = live_stream.display_index - self._resolved_target_fps = live_stream.target_fps + stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id) + self._color_strip_stream = stream + self._resolved_display_index = stream.display_index + self._resolved_target_fps = stream.target_fps logger.info( - f"Acquired live stream for target {self._target_id} " - f"(picture_source={self._picture_source_id})" + f"Acquired color strip stream for target {self._target_id} " + f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, " + f"fps={self._resolved_target_fps})" ) except Exception as e: - logger.error(f"Failed to initialize live stream for target {self._target_id}: {e}") + logger.error(f"Failed to acquire color strip stream for target {self._target_id}: {e}") if self._led_client: await self._led_client.close() - raise RuntimeError(f"Failed to initialize live stream: {e}") + self._led_client = None + raise RuntimeError(f"Failed to acquire color strip stream: {e}") - # Initialize pixel mapper from current device calibration - calibration = device_info.calibration - self._pixel_mapper = PixelMapper( - calibration, - interpolation_mode=self._settings.interpolation_mode, - ) - - # Reset metrics + # Reset metrics and start loop self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) - self._previous_colors = None - - # Start processing task self._task = asyncio.create_task(self._processing_loop()) self._is_running = True @@ -224,80 +160,84 @@ class WledTargetProcessor(TargetProcessor): await self._led_client.close() self._led_client = None - # Release live stream - if self._live_stream: - try: - self._ctx.live_stream_manager.release(self._picture_source_id) - except Exception as e: - logger.warning(f"Error releasing live stream: {e}") - self._live_stream = None + # Release color strip stream + if self._color_strip_stream is not None: + css_manager = self._ctx.color_strip_stream_manager + if css_manager and self._color_strip_source_id: + try: + await asyncio.to_thread(css_manager.release, self._color_strip_source_id) + except Exception as e: + logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}") + self._color_strip_stream = None logger.info(f"Stopped processing for target {self._target_id}") self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False}) # ----- Settings ----- - def update_settings(self, settings: ProcessingSettings) -> None: - self._settings = settings - # Recreate pixel mapper if interpolation mode changed - if self._pixel_mapper: - device_info = self._ctx.get_device_info(self._device_id) - if device_info: - self._pixel_mapper = PixelMapper( - device_info.calibration, - interpolation_mode=settings.interpolation_mode, - ) + def update_settings(self, settings: dict) -> None: + """Update target-specific timing settings.""" + if isinstance(settings, dict): + if "standby_interval" in settings: + self._standby_interval = settings["standby_interval"] + if "state_check_interval" in settings: + self._state_check_interval = settings["state_check_interval"] logger.info(f"Updated settings for target {self._target_id}") def update_device(self, device_id: str) -> None: """Update the device this target streams to.""" self._device_id = device_id - def update_calibration(self, calibration: CalibrationConfig) -> None: - """Update the cached calibration + rebuild pixel mapper.""" - if self._pixel_mapper: - self._pixel_mapper = PixelMapper( - calibration, - interpolation_mode=self._settings.interpolation_mode, - ) + def update_color_strip_source(self, color_strip_source_id: str) -> None: + """Hot-swap the color strip source for a running target.""" + if not self._is_running or self._color_strip_source_id == color_strip_source_id: + self._color_strip_source_id = color_strip_source_id + return + + css_manager = self._ctx.color_strip_stream_manager + if css_manager is None: + self._color_strip_source_id = color_strip_source_id + return + + old_id = self._color_strip_source_id + try: + new_stream = css_manager.acquire(color_strip_source_id) + css_manager.release(old_id) + self._color_strip_stream = new_stream + self._resolved_display_index = new_stream.display_index + self._resolved_target_fps = new_stream.target_fps + self._color_strip_source_id = color_strip_source_id + logger.info(f"Swapped color strip source for {self._target_id}: {old_id} → {color_strip_source_id}") + except Exception as e: + logger.error(f"Failed to swap color strip source for {self._target_id}: {e}") def get_display_index(self) -> Optional[int]: - """Display index being captured.""" + """Display index being captured, from the active stream.""" if self._resolved_display_index is not None: return self._resolved_display_index - return self._settings.display_index + if self._color_strip_stream is not None: + return self._color_strip_stream.display_index + return None # ----- State / Metrics ----- def get_state(self) -> dict: metrics = self._metrics - device_info = self._ctx.get_device_info(self._device_id) - - # Include device health info - health_info = {} - if device_info: - # Get full health from the device state (delegate via manager callback) - from wled_controller.core.devices.led_client import DeviceHealth - # We access health through the manager's get_device_health_dict - # For now, return empty — will be populated by manager wrapper - pass + fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None return { "target_id": self._target_id, "device_id": self._device_id, + "color_strip_source_id": self._color_strip_source_id, "processing": self._is_running, "fps_actual": metrics.fps_actual if self._is_running else None, "fps_potential": metrics.fps_potential if self._is_running else None, - "fps_target": self._settings.fps, + "fps_target": fps_target, "frames_skipped": metrics.frames_skipped if self._is_running else None, "frames_keepalive": metrics.frames_keepalive if self._is_running else None, "fps_current": metrics.fps_current if self._is_running else None, - "timing_extract_ms": round(metrics.timing_extract_ms, 1) if self._is_running else None, - "timing_map_leds_ms": round(metrics.timing_map_leds_ms, 1) if self._is_running else None, - "timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if self._is_running else None, "timing_send_ms": round(metrics.timing_send_ms, 1) if self._is_running else None, - "timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None, - "display_index": self._resolved_display_index if self._resolved_display_index is not None else self._settings.display_index, + "display_index": self._resolved_display_index, "overlay_active": self._overlay_active, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], @@ -305,6 +245,7 @@ class WledTargetProcessor(TargetProcessor): def get_metrics(self) -> dict: metrics = self._metrics + fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None uptime_seconds = 0.0 if metrics.start_time and self._is_running: uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds() @@ -314,7 +255,7 @@ class WledTargetProcessor(TargetProcessor): "device_id": self._device_id, "processing": self._is_running, "fps_actual": metrics.fps_actual if self._is_running else None, - "fps_target": self._settings.fps, + "fps_target": fps_target, "uptime_seconds": uptime_seconds, "frames_processed": metrics.frames_processed, "errors_count": metrics.errors_count, @@ -331,11 +272,21 @@ class WledTargetProcessor(TargetProcessor): if self._overlay_active: raise RuntimeError(f"Overlay already active for {self._target_id}") - device_info = self._ctx.get_device_info(self._device_id) - if device_info is None: - raise ValueError(f"Device {self._device_id} not found") + # Calibration comes from the active color strip stream + if self._color_strip_stream is None: + raise ValueError( + f"Cannot start overlay for {self._target_id}: no color strip stream active. " + f"Start processing first." + ) + + calibration = self._color_strip_stream.calibration + display_index = self._resolved_display_index + if display_index is None: + display_index = self._color_strip_stream.display_index + + if display_index is None or display_index < 0: + raise ValueError(f"Invalid display index {display_index} for overlay") - display_index = self._resolved_display_index or self._settings.display_index displays = get_available_displays() if display_index >= len(displays): raise ValueError(f"Invalid display index {display_index}") @@ -344,7 +295,7 @@ class WledTargetProcessor(TargetProcessor): await asyncio.to_thread( self._ctx.overlay_manager.start_overlay, - self._target_id, display_info, device_info.calibration, target_name, + self._target_id, display_info, calibration, target_name, ) self._overlay_active = True @@ -366,106 +317,65 @@ class WledTargetProcessor(TargetProcessor): def is_overlay_active(self) -> bool: return self._overlay_active - # ----- Private: stream settings resolution ----- - - def _resolve_stream_settings(self) -> None: - """Resolve picture source chain to populate resolved_* metadata fields.""" - if not self._picture_source_id or not self._ctx.picture_source_store: - raise ValueError(f"Target {self._target_id} has no picture source assigned") - - from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource - - chain = self._ctx.picture_source_store.resolve_stream_chain(self._picture_source_id) - raw_stream = chain["raw_stream"] - - if isinstance(raw_stream, StaticImagePictureSource): - self._resolved_display_index = -1 - self._resolved_target_fps = 1 - self._resolved_engine_type = None - self._resolved_engine_config = None - elif isinstance(raw_stream, ScreenCapturePictureSource): - self._resolved_display_index = raw_stream.display_index - self._resolved_target_fps = raw_stream.target_fps - - if raw_stream.capture_template_id and self._ctx.capture_template_store: - try: - tpl = self._ctx.capture_template_store.get_template(raw_stream.capture_template_id) - self._resolved_engine_type = tpl.engine_type - self._resolved_engine_config = tpl.engine_config - except ValueError: - logger.warning( - f"Capture template {raw_stream.capture_template_id} not found, " - f"using MSS fallback" - ) - self._resolved_engine_type = "mss" - self._resolved_engine_config = {} - - logger.info( - f"Resolved stream metadata for target {self._target_id}: " - f"display={self._resolved_display_index}, fps={self._resolved_target_fps}, " - f"engine={self._resolved_engine_type}" - ) - # ----- Private: processing loop ----- - async def _processing_loop(self) -> None: - """Main processing loop — capture → extract → map → smooth → send.""" - settings = self._settings - device_info = self._ctx.get_device_info(self._device_id) + @staticmethod + def _apply_brightness(colors: np.ndarray, device_info: Optional[DeviceInfo]) -> np.ndarray: + """Apply device software_brightness if < 255.""" + if device_info and device_info.software_brightness < 255: + return (colors.astype(np.uint16) * device_info.software_brightness >> 8).astype(np.uint8) + return colors - target_fps = settings.fps - smoothing = settings.smoothing - border_width = device_info.calibration.border_width if device_info else 10 - led_brightness = settings.brightness + async def _processing_loop(self) -> None: + """Main processing loop — poll ColorStripStream → apply brightness → send.""" + stream = self._color_strip_stream + target_fps = self._resolved_target_fps or 30 + frame_time = 1.0 / target_fps + standby_interval = self._standby_interval + + fps_samples: collections.deque = collections.deque(maxlen=10) + send_timestamps: collections.deque = collections.deque() + prev_colors = None + last_send_time = 0.0 + prev_frame_time_stamp = time.time() + loop = asyncio.get_running_loop() logger.info( f"Processing loop started for target {self._target_id} " f"(display={self._resolved_display_index}, fps={target_fps})" ) - frame_time = 1.0 / target_fps - standby_interval = settings.standby_interval - fps_samples: collections.deque = collections.deque(maxlen=10) - timing_samples: collections.deque = collections.deque(maxlen=10) - prev_frame_time_stamp = time.time() - prev_capture = None - last_send_time = 0.0 - send_timestamps: collections.deque = collections.deque() - loop = asyncio.get_running_loop() - try: while self._is_running: - now = loop_start = time.time() + loop_start = now = time.time() # Re-fetch device info for runtime changes (test mode, brightness) device_info = self._ctx.get_device_info(self._device_id) - # Skip capture/send while in calibration test mode + # Skip send while in calibration test mode if device_info and device_info.test_mode_active: await asyncio.sleep(frame_time) continue try: - capture = self._live_stream.get_latest_frame() + colors = stream.get_latest_colors() - if capture is None: + if colors is None: if self._metrics.frames_processed == 0: - logger.info(f"Capture returned None for target {self._target_id} (no new frame yet)") + logger.info(f"Stream returned None for target {self._target_id} (no data yet)") await asyncio.sleep(frame_time) continue - # Skip processing + send if the frame hasn't changed - if capture is prev_capture: - if self._previous_colors is not None and (loop_start - last_send_time) >= standby_interval: + if colors is prev_colors: + # Same frame — send keepalive if interval elapsed + if prev_colors is not None and (loop_start - last_send_time) >= standby_interval: if not self._is_running or self._led_client is None: break - brightness_value = int(led_brightness * 255) - if device_info and device_info.software_brightness < 255: - brightness_value = brightness_value * device_info.software_brightness // 255 + send_colors = self._apply_brightness(prev_colors, device_info) if self._led_client.supports_fast_send: - self._led_client.send_pixels_fast(self._previous_colors, brightness=brightness_value) + self._led_client.send_pixels_fast(send_colors) else: - await self._led_client.send_pixels(self._previous_colors, brightness=brightness_value) + await self._led_client.send_pixels(send_colors) now = time.time() last_send_time = now send_timestamps.append(now) @@ -476,22 +386,13 @@ class WledTargetProcessor(TargetProcessor): self._metrics.fps_current = len(send_timestamps) await asyncio.sleep(frame_time) continue - prev_capture = capture - # Compute brightness before thread dispatch - brightness_value = int(led_brightness * 255) - if device_info and device_info.software_brightness < 255: - brightness_value = brightness_value * device_info.software_brightness // 255 + prev_colors = colors - # CPU-bound work in dedicated thread-pool executor - raw_colors, send_colors, frame_timing = await loop.run_in_executor( - _frame_executor, - _process_frame, capture, border_width, - self._pixel_mapper, self._previous_colors, smoothing, - brightness_value, - ) + # Apply device software brightness + send_colors = self._apply_brightness(colors, device_info) - # Send to LED device (brightness already applied in thread) + # Send to LED device if not self._is_running or self._led_client is None: break t_send_start = time.perf_counter() @@ -500,45 +401,30 @@ class WledTargetProcessor(TargetProcessor): else: await self._led_client.send_pixels(send_colors) send_ms = (time.perf_counter() - t_send_start) * 1000 + now = time.time() last_send_time = now send_timestamps.append(now) - # Per-stage timing (rolling average over last 10 frames) - frame_timing["send"] = send_ms - timing_samples.append(frame_timing) - n = len(timing_samples) - self._metrics.timing_extract_ms = sum(s["extract"] for s in timing_samples) / n - self._metrics.timing_map_leds_ms = sum(s["map_leds"] for s in timing_samples) / n - self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n - self._metrics.timing_send_ms = sum(s["send"] for s in timing_samples) / n - self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + send_ms - - # Update metrics + self._metrics.timing_send_ms = send_ms self._metrics.frames_processed += 1 + self._metrics.last_update = datetime.utcnow() + if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0: logger.info( f"Frame {self._metrics.frames_processed} for {self._target_id} " - f"({len(send_colors)} LEDs, bri={brightness_value}) — " - f"extract={frame_timing['extract']:.1f}ms " - f"map={frame_timing['map_leds']:.1f}ms " - f"smooth={frame_timing['smooth']:.1f}ms " - f"send={send_ms:.1f}ms" + f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms" ) - self._metrics.last_update = datetime.utcnow() - self._previous_colors = raw_colors - # Calculate actual FPS (reuse cached 'now' from send timestamp) + # FPS tracking interval = now - prev_frame_time_stamp prev_frame_time_stamp = now fps_samples.append(1.0 / interval if interval > 0 else 0) self._metrics.fps_actual = sum(fps_samples) / len(fps_samples) - # Potential FPS processing_time = now - loop_start self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 - # fps_current: count sends in last 1 second while send_timestamps and send_timestamps[0] < now - 1.0: send_timestamps.popleft() self._metrics.fps_current = len(send_timestamps) diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index dfdc09a..2b9dbd0 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -16,13 +16,13 @@ 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.processing.processor_manager import ProcessorManager -from wled_controller.core.processing.processing_settings import 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.pattern_template_store import PatternTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_target_store import PictureTargetStore +from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.utils import setup_logging, get_logger @@ -41,6 +41,7 @@ pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_te picture_source_store = PictureSourceStore(config.storage.picture_sources_file) picture_target_store = PictureTargetStore(config.storage.picture_targets_file) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file) +color_strip_store = ColorStripStore(config.storage.color_strip_sources_file) profile_store = ProfileStore(config.storage.profiles_file) processor_manager = ProcessorManager( @@ -49,6 +50,7 @@ processor_manager = ProcessorManager( pp_template_store=pp_template_store, pattern_template_store=pattern_template_store, device_store=device_store, + color_strip_store=color_strip_store, ) @@ -69,22 +71,10 @@ def _migrate_devices_to_targets(): 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: + if not legacy_source_id: continue - # Build ProcessingSettings from legacy data - from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL - settings = ProcessingSettings( - display_index=legacy_settings.get("display_index", 0), - fps=legacy_settings.get("fps", 30), - brightness=legacy_settings.get("brightness", 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" @@ -93,8 +83,6 @@ def _migrate_devices_to_targets(): 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 @@ -106,6 +94,65 @@ def _migrate_devices_to_targets(): logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings") +def _migrate_targets_to_color_strips(): + """One-time migration: create ColorStripSources from legacy WledPictureTarget data. + + For each WledPictureTarget that has a legacy _legacy_picture_source_id (from old JSON) + but no color_strip_source_id, create a ColorStripSource and link it. + """ + from wled_controller.storage.wled_picture_target import WledPictureTarget + from wled_controller.core.capture.calibration import create_default_calibration + + migrated = 0 + for target in picture_target_store.get_all_targets(): + if not isinstance(target, WledPictureTarget): + continue + if target.color_strip_source_id: + continue # already migrated + if not target._legacy_picture_source_id: + continue # no legacy source to migrate + + legacy_settings = target._legacy_settings or {} + + # Try to get calibration from device (old location) + device = device_store.get_device(target.device_id) if target.device_id else None + calibration = getattr(device, "_legacy_calibration", None) if device else None + if calibration is None: + calibration = create_default_calibration(0) + + css_name = f"{target.name} Strip" + # Ensure unique name + existing_names = {s.name for s in color_strip_store.get_all_sources()} + if css_name in existing_names: + css_name = f"{target.name} Strip (migrated)" + + try: + css = color_strip_store.create_source( + name=css_name, + source_type="picture", + picture_source_id=target._legacy_picture_source_id, + fps=legacy_settings.get("fps", 30), + brightness=legacy_settings.get("brightness", 1.0), + smoothing=legacy_settings.get("smoothing", 0.3), + interpolation_mode=legacy_settings.get("interpolation_mode", "average"), + calibration=calibration, + ) + + # Update target to reference the new CSS + target.color_strip_source_id = css.id + target.standby_interval = legacy_settings.get("standby_interval", 1.0) + target.state_check_interval = legacy_settings.get("state_check_interval", 30) + picture_target_store._save() + + migrated += 1 + logger.info(f"Migrated target {target.id} -> CSS {css.id} ({css_name})") + except Exception as e: + logger.error(f"Failed to migrate target {target.id} to CSS: {e}") + + if migrated > 0: + logger.info(f"CSS migration complete: created {migrated} color strip source(s) from legacy targets") + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager. @@ -137,6 +184,7 @@ async def lifespan(app: FastAPI): # Run migrations _migrate_devices_to_targets() + _migrate_targets_to_color_strips() # Create profile engine (needs processor_manager) profile_engine = ProfileEngine(profile_store, processor_manager) @@ -148,6 +196,7 @@ async def lifespan(app: FastAPI): pattern_template_store=pattern_template_store, picture_source_store=picture_source_store, picture_target_store=picture_target_store, + color_strip_store=color_strip_store, profile_store=profile_store, profile_engine=profile_engine, ) @@ -160,7 +209,6 @@ async def lifespan(app: FastAPI): device_id=device.id, device_url=device.url, led_count=device.led_count, - calibration=device.calibration, device_type=device.device_type, baud_rate=device.baud_rate, software_brightness=device.software_brightness, diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index dc115b2..8ac3c47 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -87,11 +87,17 @@ import { startTargetOverlay, stopTargetOverlay, deleteTarget, } from './features/targets.js'; +// Layer 5: color-strip sources +import { + showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, +} from './features/color-strips.js'; + // Layer 5: calibration import { showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration, updateOffsetSkipLock, updateCalibrationPreview, setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge, + showCSSCalibration, } from './features/calibration.js'; // Layer 6: tabs @@ -262,6 +268,13 @@ Object.assign(window, { stopTargetOverlay, deleteTarget, + // color-strip sources + showCSSEditor, + closeCSSEditorModal, + forceCSSEditorClose, + saveCSSEditor, + deleteColorStrip, + // calibration showCalibration, closeCalibrationModal, @@ -273,6 +286,7 @@ Object.assign(window, { toggleEdgeInputs, toggleDirection, toggleTestEdge, + showCSSCalibration, // tabs switchTab, diff --git a/server/src/wled_controller/static/js/features/calibration.js b/server/src/wled_controller/static/js/features/calibration.js index b104d49..8653142 100644 --- a/server/src/wled_controller/static/js/features/calibration.js +++ b/server/src/wled_controller/static/js/features/calibration.js @@ -35,8 +35,15 @@ class CalibrationModal extends Modal { onForceClose() { closeTutorial(); - const deviceId = this.$('calibration-device-id').value; - if (deviceId) clearTestMode(deviceId); + if (_isCSS()) { + _clearCSSTestMode(); + document.getElementById('calibration-css-id').value = ''; + const testGroup = document.getElementById('calibration-css-test-group'); + if (testGroup) testGroup.style.display = 'none'; + } else { + const deviceId = this.$('calibration-device-id').value; + if (deviceId) clearTestMode(deviceId); + } if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect(); const error = this.$('calibration-error'); if (error) error.style.display = 'none'; @@ -48,6 +55,33 @@ const calibModal = new CalibrationModal(); let _dragRaf = null; let _previewRaf = null; +/* ── Helpers ──────────────────────────────────────────────────── */ + +function _isCSS() { + return !!(document.getElementById('calibration-css-id')?.value); +} + +function _cssStateKey() { + return `css_${document.getElementById('calibration-css-id').value}`; +} + +async function _clearCSSTestMode() { + const cssId = document.getElementById('calibration-css-id')?.value; + const stateKey = _cssStateKey(); + if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return; + calibrationTestState[stateKey] = new Set(); + const testDeviceId = document.getElementById('calibration-test-device')?.value; + if (!testDeviceId) return; + try { + await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { + method: 'PUT', + body: JSON.stringify({ device_id: testDeviceId, edges: {} }), + }); + } catch (err) { + console.error('Failed to clear CSS test mode:', err); + } +} + /* ── Public API (exported names unchanged) ────────────────────── */ export async function showCalibration(deviceId) { @@ -148,6 +182,92 @@ export async function closeCalibrationModal() { calibModal.close(); } +/* ── CSS Calibration support ──────────────────────────────────── */ + +export async function showCSSCalibration(cssId) { + try { + const [cssResp, devicesResp] = await Promise.all([ + fetchWithAuth(`/color-strip-sources/${cssId}`), + fetchWithAuth('/devices'), + ]); + + if (!cssResp.ok) { showToast('Failed to load color strip source', 'error'); return; } + const source = await cssResp.json(); + const calibration = source.calibration || {}; + + // Set CSS mode — clear device-id, set css-id + document.getElementById('calibration-device-id').value = ''; + document.getElementById('calibration-css-id').value = cssId; + + // Populate device picker for edge test + const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : []; + const testDeviceSelect = document.getElementById('calibration-test-device'); + testDeviceSelect.innerHTML = ''; + devices.forEach(d => { + const opt = document.createElement('option'); + opt.value = d.id; + opt.textContent = d.name; + testDeviceSelect.appendChild(opt); + }); + const testGroup = document.getElementById('calibration-css-test-group'); + testGroup.style.display = devices.length ? '' : 'none'; + + // Populate calibration fields + const preview = document.querySelector('.calibration-preview'); + preview.style.aspectRatio = ''; + document.getElementById('cal-device-led-count-inline').textContent = '—'; + + document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left'; + document.getElementById('cal-layout').value = calibration.layout || 'clockwise'; + document.getElementById('cal-offset').value = calibration.offset || 0; + + document.getElementById('cal-top-leds').value = calibration.leds_top || 0; + document.getElementById('cal-right-leds').value = calibration.leds_right || 0; + document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; + document.getElementById('cal-left-leds').value = calibration.leds_left || 0; + + document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0; + document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0; + updateOffsetSkipLock(); + + document.getElementById('cal-border-width').value = calibration.border_width || 10; + + window.edgeSpans = { + top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, + right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 }, + bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 }, + left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, + }; + + calibrationTestState[_cssStateKey()] = new Set(); + + updateCalibrationPreview(); + + calibModal.snapshot(); + calibModal.open(); + + initSpanDrag(); + requestAnimationFrame(() => renderCalibrationCanvas()); + + if (!window._calibrationResizeObserver) { + window._calibrationResizeObserver = new ResizeObserver(() => { + if (window._calibrationResizeRaf) return; + window._calibrationResizeRaf = requestAnimationFrame(() => { + window._calibrationResizeRaf = null; + updateSpanBars(); + renderCalibrationCanvas(); + }); + }); + } + window._calibrationResizeObserver.observe(preview); + + } catch (error) { + if (error.isAuth) return; + console.error('Failed to load CSS calibration:', error); + showToast('Failed to load calibration', 'error'); + } +} + export function updateOffsetSkipLock() { const offsetEl = document.getElementById('cal-offset'); const skipStartEl = document.getElementById('cal-skip-start'); @@ -165,8 +285,9 @@ export function updateCalibrationPreview() { parseInt(document.getElementById('cal-bottom-leds').value || 0) + parseInt(document.getElementById('cal-left-leds').value || 0); const totalEl = document.querySelector('.preview-screen-total'); - const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); - const mismatch = total !== deviceCount; + const inCSS = _isCSS(); + const deviceCount = inCSS ? null : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); + const mismatch = !inCSS && total !== deviceCount; document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total; if (totalEl) totalEl.classList.toggle('mismatch', mismatch); @@ -186,7 +307,8 @@ export function updateCalibrationPreview() { if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW'; const deviceId = document.getElementById('calibration-device-id').value; - const activeEdges = calibrationTestState[deviceId] || new Set(); + const stateKey = _isCSS() ? _cssStateKey() : deviceId; + const activeEdges = calibrationTestState[stateKey] || new Set(); ['top', 'right', 'bottom', 'left'].forEach(edge => { const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`); @@ -612,9 +734,42 @@ export async function toggleTestEdge(edge) { const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0; if (edgeLeds === 0) return; - const deviceId = document.getElementById('calibration-device-id').value; const error = document.getElementById('calibration-error'); + if (_isCSS()) { + const cssId = document.getElementById('calibration-css-id').value; + const testDeviceId = document.getElementById('calibration-test-device')?.value; + if (!testDeviceId) return; + + const stateKey = _cssStateKey(); + if (!calibrationTestState[stateKey]) calibrationTestState[stateKey] = new Set(); + if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge); + else calibrationTestState[stateKey].add(edge); + + const edges = {}; + calibrationTestState[stateKey].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; }); + updateCalibrationPreview(); + + try { + const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { + method: 'PUT', + body: JSON.stringify({ device_id: testDeviceId, edges }), + }); + if (!response.ok) { + const errorData = await response.json(); + error.textContent = `Test failed: ${errorData.detail}`; + error.style.display = 'block'; + } + } catch (err) { + if (err.isAuth) return; + console.error('Failed to toggle CSS test edge:', err); + error.textContent = 'Failed to toggle test edge'; + error.style.display = 'block'; + } + return; + } + + const deviceId = document.getElementById('calibration-device-id').value; if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set(); if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge); @@ -658,11 +813,16 @@ async function clearTestMode(deviceId) { } export async function saveCalibration() { + const cssMode = _isCSS(); const deviceId = document.getElementById('calibration-device-id').value; - const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent); + const cssId = document.getElementById('calibration-css-id').value; const error = document.getElementById('calibration-error'); - await clearTestMode(deviceId); + if (cssMode) { + await _clearCSSTestMode(); + } else { + await clearTestMode(deviceId); + } updateCalibrationPreview(); const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0); @@ -671,10 +831,13 @@ export async function saveCalibration() { const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0); const total = topLeds + rightLeds + bottomLeds + leftLeds; - if (total !== deviceLedCount) { - error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`; - error.style.display = 'block'; - return; + if (!cssMode) { + const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent); + if (total !== deviceLedCount) { + error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`; + error.style.display = 'block'; + return; + } } const startPosition = document.getElementById('cal-start-position').value; @@ -695,14 +858,26 @@ export async function saveCalibration() { }; try { - const response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { - method: 'PUT', - body: JSON.stringify(calibration) - }); + let response; + if (cssMode) { + response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { + method: 'PUT', + body: JSON.stringify({ calibration }), + }); + } else { + response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { + method: 'PUT', + body: JSON.stringify(calibration), + }); + } if (response.ok) { showToast('Calibration saved', 'success'); calibModal.forceClose(); - window.loadDevices(); + if (cssMode) { + if (window.loadTargetsTab) window.loadTargetsTab(); + } else { + window.loadDevices(); + } } else { const errorData = await response.json(); error.textContent = `Failed to save: ${errorData.detail}`; diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js new file mode 100644 index 0000000..ab8c637 --- /dev/null +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -0,0 +1,218 @@ +/** + * Color Strip Sources — CRUD, card rendering, calibration bridge. + */ + +import { fetchWithAuth, escapeHtml } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { showToast, showConfirm } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; + +class CSSEditorModal extends Modal { + constructor() { + super('css-editor-modal'); + } + + snapshotValues() { + return { + name: document.getElementById('css-editor-name').value, + picture_source: document.getElementById('css-editor-picture-source').value, + fps: document.getElementById('css-editor-fps').value, + interpolation: document.getElementById('css-editor-interpolation').value, + smoothing: document.getElementById('css-editor-smoothing').value, + brightness: document.getElementById('css-editor-brightness').value, + saturation: document.getElementById('css-editor-saturation').value, + gamma: document.getElementById('css-editor-gamma').value, + }; + } +} + +const cssEditorModal = new CSSEditorModal(); + +/* ── Card ─────────────────────────────────────────────────────── */ + +export function createColorStripCard(source, pictureSourceMap) { + const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id]) + ? pictureSourceMap[source.picture_source_id].name + : source.picture_source_id || '—'; + const cal = source.calibration || {}; + const ledCount = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); + + return ` +
+ +
+
+ 🎞️ ${escapeHtml(source.name)} +
+
+
+ ⚡ ${source.fps || 30} fps + ${ledCount ? `💡 ${ledCount}` : ''} + 📺 ${escapeHtml(srcName)} +
+
+ + +
+
+ `; +} + +/* ── Editor open/close ────────────────────────────────────────── */ + +export async function showCSSEditor(cssId = null) { + try { + const sourcesResp = await fetchWithAuth('/picture-sources'); + const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : []; + + const sourceSelect = document.getElementById('css-editor-picture-source'); + sourceSelect.innerHTML = ''; + sources.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.dataset.name = s.name; + const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨'; + opt.textContent = `${typeIcon} ${s.name}`; + sourceSelect.appendChild(opt); + }); + + if (cssId) { + const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`); + if (!resp.ok) throw new Error('Failed to load color strip source'); + const css = await resp.json(); + + document.getElementById('css-editor-id').value = css.id; + document.getElementById('css-editor-name').value = css.name; + sourceSelect.value = css.picture_source_id || ''; + + const fps = css.fps ?? 30; + document.getElementById('css-editor-fps').value = fps; + document.getElementById('css-editor-fps-value').textContent = fps; + + document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average'; + + const smoothing = css.smoothing ?? 0.3; + document.getElementById('css-editor-smoothing').value = smoothing; + document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2); + + const brightness = css.brightness ?? 1.0; + document.getElementById('css-editor-brightness').value = brightness; + document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2); + + const saturation = css.saturation ?? 1.0; + document.getElementById('css-editor-saturation').value = saturation; + document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2); + + const gamma = css.gamma ?? 1.0; + document.getElementById('css-editor-gamma').value = gamma; + document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2); + + document.getElementById('css-editor-title').textContent = t('color_strip.edit'); + } else { + document.getElementById('css-editor-id').value = ''; + document.getElementById('css-editor-name').value = ''; + document.getElementById('css-editor-fps').value = 30; + document.getElementById('css-editor-fps-value').textContent = '30'; + document.getElementById('css-editor-interpolation').value = 'average'; + document.getElementById('css-editor-smoothing').value = 0.3; + document.getElementById('css-editor-smoothing-value').textContent = '0.30'; + document.getElementById('css-editor-brightness').value = 1.0; + document.getElementById('css-editor-brightness-value').textContent = '1.00'; + document.getElementById('css-editor-saturation').value = 1.0; + document.getElementById('css-editor-saturation-value').textContent = '1.00'; + document.getElementById('css-editor-gamma').value = 1.0; + document.getElementById('css-editor-gamma-value').textContent = '1.00'; + document.getElementById('css-editor-title').textContent = t('color_strip.add'); + } + + document.getElementById('css-editor-error').style.display = 'none'; + cssEditorModal.snapshot(); + cssEditorModal.open(); + setTimeout(() => document.getElementById('css-editor-name').focus(), 100); + } catch (error) { + if (error.isAuth) return; + console.error('Failed to open CSS editor:', error); + showToast('Failed to open color strip editor', 'error'); + } +} + +export function closeCSSEditorModal() { cssEditorModal.close(); } +export function forceCSSEditorClose() { cssEditorModal.forceClose(); } +export function isCSSEditorDirty() { return cssEditorModal.isDirty(); } + +/* ── Save ─────────────────────────────────────────────────────── */ + +export async function saveCSSEditor() { + const cssId = document.getElementById('css-editor-id').value; + const name = document.getElementById('css-editor-name').value.trim(); + + if (!name) { + cssEditorModal.showError(t('color_strip.error.name_required')); + return; + } + + const payload = { + name, + picture_source_id: document.getElementById('css-editor-picture-source').value, + fps: parseInt(document.getElementById('css-editor-fps').value) || 30, + interpolation_mode: document.getElementById('css-editor-interpolation').value, + smoothing: parseFloat(document.getElementById('css-editor-smoothing').value), + brightness: parseFloat(document.getElementById('css-editor-brightness').value), + saturation: parseFloat(document.getElementById('css-editor-saturation').value), + gamma: parseFloat(document.getElementById('css-editor-gamma').value), + }; + + try { + let response; + if (cssId) { + response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + } else { + payload.source_type = 'picture'; + response = await fetchWithAuth('/color-strip-sources', { + method: 'POST', + body: JSON.stringify(payload), + }); + } + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Failed to save'); + } + + showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success'); + cssEditorModal.forceClose(); + if (window.loadTargetsTab) await window.loadTargetsTab(); + } catch (error) { + if (error.isAuth) return; + console.error('Error saving CSS:', error); + cssEditorModal.showError(error.message); + } +} + +/* ── Delete ───────────────────────────────────────────────────── */ + +export async function deleteColorStrip(cssId) { + const confirmed = await showConfirm(t('color_strip.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { + method: 'DELETE', + }); + if (response.ok) { + showToast(t('color_strip.deleted'), 'success'); + if (window.loadTargetsTab) await window.loadTargetsTab(); + } else { + const err = await response.json(); + const msg = err.detail || 'Failed to delete'; + const isReferenced = response.status === 409; + showToast(isReferenced ? t('color_strip.delete.referenced') : `Failed: ${msg}`, 'error'); + } + } catch (error) { + if (error.isAuth) return; + showToast('Failed to delete color strip source', 'error'); + } +} diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 86e07f9..7a5e825 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -14,6 +14,7 @@ import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js'; import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; +import { createColorStripCard } from './color-strips.js'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) @@ -30,10 +31,7 @@ class TargetEditorModal extends Modal { return { name: document.getElementById('target-editor-name').value, device: document.getElementById('target-editor-device').value, - source: document.getElementById('target-editor-source').value, - fps: document.getElementById('target-editor-fps').value, - interpolation: document.getElementById('target-editor-interpolation').value, - smoothing: document.getElementById('target-editor-smoothing').value, + css: document.getElementById('target-editor-css').value, standby_interval: document.getElementById('target-editor-standby-interval').value, }; } @@ -47,11 +45,11 @@ function _autoGenerateTargetName() { if (_targetNameManuallyEdited) return; if (document.getElementById('target-editor-id').value) return; const deviceSelect = document.getElementById('target-editor-device'); - const sourceSelect = document.getElementById('target-editor-source'); + const cssSelect = document.getElementById('target-editor-css'); const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; - const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; - if (!deviceName || !sourceName) return; - document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${sourceName}`; + const cssName = cssSelect.selectedOptions[0]?.dataset?.name || ''; + if (!deviceName || !cssName) return; + document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`; } function _updateStandbyVisibility() { @@ -64,14 +62,14 @@ function _updateStandbyVisibility() { export async function showTargetEditor(targetId = null) { try { - // Load devices and sources for dropdowns - const [devicesResp, sourcesResp] = await Promise.all([ + // Load devices and CSS sources for dropdowns + const [devicesResp, cssResp] = await Promise.all([ fetch(`${API_BASE}/devices`, { headers: getHeaders() }), - fetchWithAuth('/picture-sources'), + fetchWithAuth('/color-strip-sources'), ]); const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; - const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : []; + const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : []; set_targetEditorDevices(devices); // Populate device select @@ -87,16 +85,15 @@ export async function showTargetEditor(targetId = null) { deviceSelect.appendChild(opt); }); - // Populate source select - const sourceSelect = document.getElementById('target-editor-source'); - sourceSelect.innerHTML = ''; - sources.forEach(s => { + // Populate color strip source select + const cssSelect = document.getElementById('target-editor-css'); + cssSelect.innerHTML = ''; + cssSources.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.dataset.name = s.name; - const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8'; - opt.textContent = `${typeIcon} ${s.name}`; - sourceSelect.appendChild(opt); + opt.textContent = `🎞️ ${s.name}`; + cssSelect.appendChild(opt); }); if (targetId) { @@ -108,24 +105,14 @@ export async function showTargetEditor(targetId = null) { document.getElementById('target-editor-id').value = target.id; document.getElementById('target-editor-name').value = target.name; deviceSelect.value = target.device_id || ''; - sourceSelect.value = target.picture_source_id || ''; - document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30; - document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30; - document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average'; - document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3; - document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3; - document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0; - document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0; + cssSelect.value = target.color_strip_source_id || ''; + document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0; + document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.edit'); } else { // Creating new target — first option is selected by default document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-name').value = ''; - document.getElementById('target-editor-fps').value = 30; - document.getElementById('target-editor-fps-value').textContent = '30'; - document.getElementById('target-editor-interpolation').value = 'average'; - document.getElementById('target-editor-smoothing').value = 0.3; - document.getElementById('target-editor-smoothing-value').textContent = '0.3'; document.getElementById('target-editor-standby-interval').value = 1.0; document.getElementById('target-editor-standby-interval-value').textContent = '1.0'; document.getElementById('target-editor-title').textContent = t('targets.add'); @@ -135,7 +122,7 @@ export async function showTargetEditor(targetId = null) { _targetNameManuallyEdited = !!targetId; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); }; - sourceSelect.onchange = () => _autoGenerateTargetName(); + cssSelect.onchange = () => _autoGenerateTargetName(); if (!targetId) _autoGenerateTargetName(); // Show/hide standby interval based on selected device capabilities @@ -168,10 +155,7 @@ export async function saveTargetEditor() { const targetId = document.getElementById('target-editor-id').value; const name = document.getElementById('target-editor-name').value.trim(); const deviceId = document.getElementById('target-editor-device').value; - const sourceId = document.getElementById('target-editor-source').value; - const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; - const interpolation = document.getElementById('target-editor-interpolation').value; - const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value); + const cssId = document.getElementById('target-editor-css').value; const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value); if (!name) { @@ -182,13 +166,8 @@ export async function saveTargetEditor() { const payload = { name, device_id: deviceId, - picture_source_id: sourceId, - settings: { - fps: fps, - interpolation_mode: interpolation, - smoothing: smoothing, - standby_interval: standbyInterval, - }, + color_strip_source_id: cssId, + standby_interval: standbyInterval, }; try { @@ -243,10 +222,11 @@ export async function loadTargetsTab() { if (!container) return; try { - // Fetch devices, targets, sources, and pattern templates in parallel - const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([ + // Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel + const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([ fetchWithAuth('/devices'), fetchWithAuth('/picture-targets'), + fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/picture-sources').catch(() => null), fetchWithAuth('/pattern-templates').catch(() => null), ]); @@ -257,10 +237,16 @@ export async function loadTargetsTab() { const targetsData = await targetsResp.json(); const targets = targetsData.targets || []; - let sourceMap = {}; - if (sourcesResp && sourcesResp.ok) { - const srcData = await sourcesResp.json(); - (srcData.streams || []).forEach(s => { sourceMap[s.id] = s; }); + let colorStripSourceMap = {}; + if (cssResp && cssResp.ok) { + const cssData = await cssResp.json(); + (cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; }); + } + + let pictureSourceMap = {}; + if (psResp && psResp.ok) { + const psData = await psResp.json(); + (psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; }); } let patternTemplates = []; @@ -320,7 +306,7 @@ export async function loadTargetsTab() { if (activeSubTab === 'wled') activeSubTab = 'led'; const subTabs = [ - { key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length }, + { key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length }, { key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length }, ]; @@ -331,7 +317,7 @@ export async function loadTargetsTab() { // Use window.createPatternTemplateCard to avoid circular import const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); - // LED panel: devices section + targets section + // LED panel: devices section + color strip sources section + targets section const ledPanel = `
@@ -343,10 +329,19 @@ export async function loadTargetsTab() {
+
+

${t('targets.section.color_strips')}

+
+ ${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')} +
+
+
+
+
+

${t('targets.section.targets')}

- ${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')} + ${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
+
@@ -360,7 +355,7 @@ export async function loadTargetsTab() {

${t('targets.section.key_colors')}

- ${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')} + ${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
+
@@ -422,17 +417,16 @@ export async function loadTargetsTab() { } } -export function createTargetCard(target, deviceMap, sourceMap) { +export function createTargetCard(target, deviceMap, colorStripSourceMap) { const state = target.state || {}; const metrics = target.metrics || {}; - const settings = target.settings || {}; const isProcessing = state.processing || false; const device = deviceMap[target.device_id]; - const source = sourceMap[target.picture_source_id]; + const css = colorStripSourceMap[target.color_strip_source_id]; const deviceName = device ? device.name : (target.device_id || 'No device'); - const sourceName = source ? source.name : (target.picture_source_id || 'No source'); + const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source'); // Health info from target state (forwarded from device) const devOnline = state.device_online || false; @@ -455,8 +449,7 @@ export function createTargetCard(target, deviceMap, sourceMap) {
💡 ${escapeHtml(deviceName)} - ⚡ ${settings.fps || 30} - 📺 ${escapeHtml(sourceName)} + 🎞️ ${escapeHtml(cssName)}
${isProcessing ? ` diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 3115e44..4db2b40 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -343,10 +343,11 @@ "streams.validate_image.valid": "Image accessible", "streams.validate_image.invalid": "Image not accessible", "targets.title": "⚡ Targets", - "targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.", + "targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.", "targets.subtab.wled": "LED", "targets.subtab.led": "LED", "targets.section.devices": "💡 Devices", + "targets.section.color_strips": "🎞️ Color Strip Sources", "targets.section.targets": "⚡ Targets", "targets.add": "Add Target", "targets.edit": "Edit Target", @@ -358,6 +359,8 @@ "targets.device": "Device:", "targets.device.hint": "Select the LED device to send data to", "targets.device.none": "-- Select a device --", + "targets.color_strip_source": "Color Strip Source:", + "targets.color_strip_source.hint": "Color strip source that captures and processes screen pixels into LED colors", "targets.source": "Source:", "targets.source.hint": "Which picture source to capture and process", "targets.source.none": "-- No source assigned --", @@ -380,6 +383,7 @@ "targets.delete.confirm": "Are you sure you want to delete this target?", "targets.error.load": "Failed to load targets", "targets.error.required": "Please fill in all required fields", + "targets.error.name_required": "Please enter a target name", "targets.error.delete": "Failed to delete target", "targets.button.start": "Start", "targets.button.stop": "Stop", @@ -531,5 +535,36 @@ "aria.cancel": "Cancel", "aria.previous": "Previous", "aria.next": "Next", - "aria.hint": "Show hint" + "aria.hint": "Show hint", + + "color_strip.add": "🎞️ Add Color Strip Source", + "color_strip.edit": "🎞️ Edit Color Strip Source", + "color_strip.name": "Name:", + "color_strip.name.placeholder": "Wall Strip", + "color_strip.picture_source": "Picture Source:", + "color_strip.picture_source.hint": "Which screen capture source to use as input for LED color calculation", + "color_strip.fps": "Target FPS:", + "color_strip.fps.hint": "Target frames per second for LED color updates (10-90)", + "color_strip.interpolation": "Color Mode:", + "color_strip.interpolation.hint": "How to calculate LED color from sampled border pixels", + "color_strip.interpolation.average": "Average", + "color_strip.interpolation.median": "Median", + "color_strip.interpolation.dominant": "Dominant", + "color_strip.smoothing": "Smoothing:", + "color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", + "color_strip.brightness": "Brightness:", + "color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.", + "color_strip.saturation": "Saturation:", + "color_strip.saturation.hint": "Color saturation (0=grayscale, 1=unchanged, 2=double saturation)", + "color_strip.gamma": "Gamma:", + "color_strip.gamma.hint": "Gamma correction (1=none, <1=brighter midtones, >1=darker midtones)", + "color_strip.test_device": "Test on Device:", + "color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles", + "color_strip.leds": "LED count", + "color_strip.created": "Color strip source created", + "color_strip.updated": "Color strip source updated", + "color_strip.deleted": "Color strip source deleted", + "color_strip.delete.confirm": "Are you sure you want to delete this color strip source?", + "color_strip.delete.referenced": "Cannot delete: this source is in use by a target", + "color_strip.error.name_required": "Please enter a name" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index dd034ed..d1b6189 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -343,10 +343,11 @@ "streams.validate_image.valid": "Изображение доступно", "streams.validate_image.invalid": "Изображение недоступно", "targets.title": "⚡ Цели", - "targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.", + "targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.", "targets.subtab.wled": "LED", "targets.subtab.led": "LED", "targets.section.devices": "💡 Устройства", + "targets.section.color_strips": "🎞️ Источники цветовых полос", "targets.section.targets": "⚡ Цели", "targets.add": "Добавить Цель", "targets.edit": "Редактировать Цель", @@ -358,6 +359,8 @@ "targets.device": "Устройство:", "targets.device.hint": "Выберите LED устройство для передачи данных", "targets.device.none": "-- Выберите устройство --", + "targets.color_strip_source": "Источник цветовой полосы:", + "targets.color_strip_source.hint": "Источник цветовой полосы, который захватывает и обрабатывает пиксели экрана в цвета светодиодов", "targets.source": "Источник:", "targets.source.hint": "Какой источник изображения захватывать и обрабатывать", "targets.source.none": "-- Источник не назначен --", @@ -380,6 +383,7 @@ "targets.delete.confirm": "Вы уверены, что хотите удалить эту цель?", "targets.error.load": "Не удалось загрузить цели", "targets.error.required": "Пожалуйста, заполните все обязательные поля", + "targets.error.name_required": "Введите название цели", "targets.error.delete": "Не удалось удалить цель", "targets.button.start": "Запустить", "targets.button.stop": "Остановить", @@ -531,5 +535,36 @@ "aria.cancel": "Отмена", "aria.previous": "Назад", "aria.next": "Вперёд", - "aria.hint": "Показать подсказку" + "aria.hint": "Показать подсказку", + + "color_strip.add": "🎞️ Добавить источник цветовой полосы", + "color_strip.edit": "🎞️ Редактировать источник цветовой полосы", + "color_strip.name": "Название:", + "color_strip.name.placeholder": "Настенная полоса", + "color_strip.picture_source": "Источник изображения:", + "color_strip.picture_source.hint": "Источник захвата экрана для расчёта цветов светодиодов", + "color_strip.fps": "Целевой FPS:", + "color_strip.fps.hint": "Целевая частота кадров для обновления цветов светодиодов (10-90)", + "color_strip.interpolation": "Режим цвета:", + "color_strip.interpolation.hint": "Как вычислять цвет светодиода по пикселям рамки", + "color_strip.interpolation.average": "Среднее", + "color_strip.interpolation.median": "Медиана", + "color_strip.interpolation.dominant": "Доминирующий", + "color_strip.smoothing": "Сглаживание:", + "color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.", + "color_strip.brightness": "Яркость:", + "color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.", + "color_strip.saturation": "Насыщенность:", + "color_strip.saturation.hint": "Насыщенность цвета (0=оттенки серого, 1=без изменений, 2=двойная насыщенность)", + "color_strip.gamma": "Гамма:", + "color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, <1=ярче средние тона, >1=темнее средние тона)", + "color_strip.test_device": "Тестировать на устройстве:", + "color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку", + "color_strip.leds": "Количество светодиодов", + "color_strip.created": "Источник цветовой полосы создан", + "color_strip.updated": "Источник цветовой полосы обновлён", + "color_strip.deleted": "Источник цветовой полосы удалён", + "color_strip.delete.confirm": "Удалить этот источник цветовой полосы?", + "color_strip.delete.referenced": "Невозможно удалить: источник используется в цели", + "color_strip.error.name_required": "Введите название" } diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py new file mode 100644 index 0000000..3ef6d37 --- /dev/null +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -0,0 +1,130 @@ +"""Color strip source data model with inheritance-based source types. + +A ColorStripSource produces a stream of LED color arrays (np.ndarray shape (N, 3)) +from some input, encapsulating everything needed to drive a physical LED strip: +calibration, color correction, smoothing, and FPS. + +Current types: + PictureColorStripSource — derives LED colors from a PictureSource (screen capture) + +Future types (not yet implemented): + StaticColorStripSource — constant solid colors + GradientColorStripSource — animated gradient +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from wled_controller.core.capture.calibration import ( + CalibrationConfig, + calibration_from_dict, + calibration_to_dict, + create_default_calibration, +) + + +@dataclass +class ColorStripSource: + """Base class for color strip source configurations.""" + + id: str + name: str + source_type: str # "picture" | future types + created_at: datetime + updated_at: datetime + description: Optional[str] = None + + def to_dict(self) -> dict: + """Convert source to dictionary. Subclasses extend this.""" + return { + "id": self.id, + "name": self.name, + "source_type": self.source_type, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "description": self.description, + # Subclass fields default to None for forward compat + "picture_source_id": None, + "fps": None, + "brightness": None, + "saturation": None, + "gamma": None, + "smoothing": None, + "interpolation_mode": None, + "calibration": None, + } + + @staticmethod + def from_dict(data: dict) -> "ColorStripSource": + """Factory: dispatch to the correct subclass based on source_type.""" + source_type: str = data.get("source_type", "picture") or "picture" + sid: str = data["id"] + name: str = data["name"] + description: str | None = data.get("description") + + raw_created = data.get("created_at") + created_at: datetime = ( + datetime.fromisoformat(raw_created) + if isinstance(raw_created, str) + else raw_created if isinstance(raw_created, datetime) + else datetime.utcnow() + ) + raw_updated = data.get("updated_at") + updated_at: datetime = ( + datetime.fromisoformat(raw_updated) + if isinstance(raw_updated, str) + else raw_updated if isinstance(raw_updated, datetime) + else datetime.utcnow() + ) + + calibration_data = data.get("calibration") + calibration = ( + calibration_from_dict(calibration_data) + if calibration_data + else create_default_calibration(0) + ) + + # Only "picture" type for now; extend with elif branches for future types + return PictureColorStripSource( + id=sid, name=name, source_type=source_type, + created_at=created_at, updated_at=updated_at, description=description, + picture_source_id=data.get("picture_source_id") or "", + fps=data.get("fps") or 30, + brightness=data["brightness"] if data.get("brightness") is not None else 1.0, + saturation=data["saturation"] if data.get("saturation") is not None else 1.0, + gamma=data["gamma"] if data.get("gamma") is not None else 1.0, + smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3, + interpolation_mode=data.get("interpolation_mode") or "average", + calibration=calibration, + ) + + +@dataclass +class PictureColorStripSource(ColorStripSource): + """Color strip source driven by a PictureSource (screen capture / static image). + + Contains everything required to produce LED color arrays from a picture stream: + calibration (LED positions), color correction, smoothing, FPS target. + """ + + picture_source_id: str = "" + fps: int = 30 + brightness: float = 1.0 # color correction multiplier (0.0–2.0; 1.0 = unchanged) + saturation: float = 1.0 # 1.0 = unchanged, 0.0 = grayscale, 2.0 = double saturation + gamma: float = 1.0 # 1.0 = no correction; <1 = brighter, >1 = darker mids + smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full) + interpolation_mode: str = "average" # "average" | "median" | "dominant" + calibration: CalibrationConfig = field(default_factory=lambda: create_default_calibration(0)) + + def to_dict(self) -> dict: + d = super().to_dict() + d["picture_source_id"] = self.picture_source_id + d["fps"] = self.fps + d["brightness"] = self.brightness + d["saturation"] = self.saturation + d["gamma"] = self.gamma + d["smoothing"] = self.smoothing + d["interpolation_mode"] = self.interpolation_mode + d["calibration"] = calibration_to_dict(self.calibration) + return d diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py new file mode 100644 index 0000000..d506b0c --- /dev/null +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -0,0 +1,224 @@ +"""Color strip source storage using JSON files.""" + +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +from wled_controller.core.capture.calibration import calibration_to_dict +from wled_controller.storage.color_strip_source import ( + ColorStripSource, + PictureColorStripSource, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class ColorStripStore: + """Persistent storage for color strip sources.""" + + def __init__(self, file_path: str): + self.file_path = Path(file_path) + self._sources: Dict[str, ColorStripSource] = {} + self._load() + + def _load(self) -> None: + if not self.file_path.exists(): + logger.info("Color strip store file not found — starting empty") + return + + try: + with open(self.file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + sources_data = data.get("color_strip_sources", {}) + loaded = 0 + for source_id, source_dict in sources_data.items(): + try: + source = ColorStripSource.from_dict(source_dict) + self._sources[source_id] = source + loaded += 1 + except Exception as e: + logger.error(f"Failed to load color strip source {source_id}: {e}", exc_info=True) + + if loaded > 0: + logger.info(f"Loaded {loaded} color strip sources from storage") + + except Exception as e: + logger.error(f"Failed to load color strip sources from {self.file_path}: {e}") + raise + + logger.info(f"Color strip store initialized with {len(self._sources)} sources") + + def _save(self) -> None: + try: + self.file_path.parent.mkdir(parents=True, exist_ok=True) + + sources_dict = { + sid: source.to_dict() + for sid, source in self._sources.items() + } + + data = { + "version": "1.0.0", + "color_strip_sources": sources_dict, + } + + with open(self.file_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + except Exception as e: + logger.error(f"Failed to save color strip sources to {self.file_path}: {e}") + raise + + def get_all_sources(self) -> List[ColorStripSource]: + return list(self._sources.values()) + + def get_source(self, source_id: str) -> ColorStripSource: + """Get a color strip source by ID. + + Raises: + ValueError: If source not found + """ + if source_id not in self._sources: + raise ValueError(f"Color strip source not found: {source_id}") + return self._sources[source_id] + + def create_source( + self, + name: str, + source_type: str = "picture", + picture_source_id: str = "", + fps: int = 30, + brightness: float = 1.0, + saturation: float = 1.0, + gamma: float = 1.0, + smoothing: float = 0.3, + interpolation_mode: str = "average", + calibration=None, + description: Optional[str] = None, + ) -> ColorStripSource: + """Create a new color strip source. + + Raises: + ValueError: If validation fails + """ + from wled_controller.core.capture.calibration import create_default_calibration + + if not name or not name.strip(): + raise ValueError("Name is required") + + for source in self._sources.values(): + if source.name == name: + raise ValueError(f"Color strip source with name '{name}' already exists") + + if calibration is None: + calibration = create_default_calibration(0) + + source_id = f"css_{uuid.uuid4().hex[:8]}" + now = datetime.utcnow() + + source = PictureColorStripSource( + id=source_id, + name=name, + source_type=source_type, + created_at=now, + updated_at=now, + description=description, + picture_source_id=picture_source_id, + fps=fps, + brightness=brightness, + saturation=saturation, + gamma=gamma, + smoothing=smoothing, + interpolation_mode=interpolation_mode, + calibration=calibration, + ) + + self._sources[source_id] = source + self._save() + + logger.info(f"Created color strip source: {name} ({source_id}, type={source_type})") + return source + + def update_source( + self, + source_id: str, + name: Optional[str] = None, + picture_source_id: Optional[str] = None, + fps: Optional[int] = None, + brightness: Optional[float] = None, + saturation: Optional[float] = None, + gamma: Optional[float] = None, + smoothing: Optional[float] = None, + interpolation_mode: Optional[str] = None, + calibration=None, + description: Optional[str] = None, + ) -> ColorStripSource: + """Update an existing color strip source. + + Raises: + ValueError: If source not found + """ + if source_id not in self._sources: + raise ValueError(f"Color strip source not found: {source_id}") + + source = self._sources[source_id] + + if name is not None: + for other in self._sources.values(): + if other.id != source_id and other.name == name: + raise ValueError(f"Color strip source with name '{name}' already exists") + source.name = name + + if description is not None: + source.description = description + + if isinstance(source, PictureColorStripSource): + if picture_source_id is not None: + source.picture_source_id = picture_source_id + if fps is not None: + source.fps = fps + if brightness is not None: + source.brightness = brightness + if saturation is not None: + source.saturation = saturation + if gamma is not None: + source.gamma = gamma + if smoothing is not None: + source.smoothing = smoothing + if interpolation_mode is not None: + source.interpolation_mode = interpolation_mode + if calibration is not None: + source.calibration = calibration + + source.updated_at = datetime.utcnow() + self._save() + + logger.info(f"Updated color strip source: {source_id}") + return source + + def delete_source(self, source_id: str) -> None: + """Delete a color strip source. + + Raises: + ValueError: If source not found + """ + if source_id not in self._sources: + raise ValueError(f"Color strip source not found: {source_id}") + + del self._sources[source_id] + self._save() + + logger.info(f"Deleted color strip source: {source_id}") + + def is_referenced_by_target(self, source_id: str, target_store) -> bool: + """Check if this source is referenced by any picture target.""" + from wled_controller.storage.wled_picture_target import WledPictureTarget + + for target in target_store.get_all_targets(): + if isinstance(target, WledPictureTarget) and target.color_strip_source_id == source_id: + return True + return False diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index bfdb1a0..1d4edc3 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -6,12 +6,6 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple -from wled_controller.core.capture.calibration import ( - CalibrationConfig, - calibration_from_dict, - calibration_to_dict, - create_default_calibration, -) from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -20,8 +14,9 @@ logger = get_logger(__name__) class Device: """Represents a WLED device configuration. - A device is a holder of connection state and calibration options. - Processing settings and picture source assignments live on PictureTargets. + A device holds connection state and output settings. + Calibration, processing settings, and picture source assignments + now live on ColorStripSource and WledPictureTarget respectively. """ def __init__( @@ -36,7 +31,6 @@ class Device: software_brightness: int = 255, auto_shutdown: bool = False, static_color: Optional[Tuple[int, int, int]] = None, - calibration: Optional[CalibrationConfig] = None, created_at: Optional[datetime] = None, updated_at: Optional[datetime] = None, ): @@ -50,9 +44,10 @@ class Device: self.software_brightness = software_brightness self.auto_shutdown = auto_shutdown self.static_color = static_color - self.calibration = calibration or create_default_calibration(led_count) self.created_at = created_at or datetime.utcnow() self.updated_at = updated_at or datetime.utcnow() + # Preserved from old JSON for migration — not written back + self._legacy_calibration = None def to_dict(self) -> dict: """Convert device to dictionary.""" @@ -63,7 +58,6 @@ class Device: "led_count": self.led_count, "enabled": self.enabled, "device_type": self.device_type, - "calibration": calibration_to_dict(self.calibration), "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } @@ -81,20 +75,13 @@ class Device: def from_dict(cls, data: dict) -> "Device": """Create device from dictionary. - Backward-compatible: ignores legacy 'settings' and 'picture_source_id' - fields that have been migrated to PictureTarget. + Backward-compatible: reads legacy 'calibration' field and stores it + in _legacy_calibration for migration use only. """ - calibration_data = data.get("calibration") - calibration = ( - calibration_from_dict(calibration_data) - if calibration_data - else create_default_calibration(data["led_count"]) - ) - static_color_raw = data.get("static_color") static_color = tuple(static_color_raw) if static_color_raw else None - return cls( + device = cls( device_id=data["id"], name=data["name"], url=data["url"], @@ -105,11 +92,21 @@ class Device: software_brightness=data.get("software_brightness", 255), auto_shutdown=data.get("auto_shutdown", False), static_color=static_color, - calibration=calibration, created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), ) + # Preserve old calibration for migration (never written back by to_dict) + calibration_data = data.get("calibration") + if calibration_data: + try: + from wled_controller.core.capture.calibration import calibration_from_dict + device._legacy_calibration = calibration_from_dict(calibration_data) + except Exception: + pass + + return device + class DeviceStore: """Persistent storage for WLED devices.""" @@ -129,7 +126,7 @@ class DeviceStore: def load(self): """Load devices from storage file.""" if not self.storage_file.exists(): - logger.info(f"Storage file does not exist, starting with empty store") + logger.info("Storage file does not exist, starting with empty store") return try: @@ -190,7 +187,6 @@ class DeviceStore: led_count: int, device_type: str = "wled", baud_rate: Optional[int] = None, - calibration: Optional[CalibrationConfig] = None, auto_shutdown: bool = False, ) -> Device: """Create a new device.""" @@ -203,7 +199,6 @@ class DeviceStore: led_count=led_count, device_type=device_type, baud_rate=baud_rate, - calibration=calibration, auto_shutdown=auto_shutdown, ) @@ -229,7 +224,6 @@ class DeviceStore: led_count: Optional[int] = None, enabled: Optional[bool] = None, baud_rate: Optional[int] = None, - calibration: Optional[CalibrationConfig] = None, auto_shutdown: Optional[bool] = None, ) -> Device: """Update device.""" @@ -249,13 +243,6 @@ class DeviceStore: device.baud_rate = baud_rate if auto_shutdown is not None: device.auto_shutdown = auto_shutdown - if calibration is not None: - if calibration.get_total_leds() != device.led_count: - raise ValueError( - f"Calibration LED count ({calibration.get_total_leds()}) " - f"does not match device LED count ({device.led_count})" - ) - device.calibration = calibration device.updated_at = datetime.utcnow() self.save() diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index 4eb7af9..7c98531 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -6,7 +6,6 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional -from wled_controller.core.processing.processing_settings import ProcessingSettings from wled_controller.storage.picture_target import PictureTarget from wled_controller.storage.wled_picture_target import WledPictureTarget from wled_controller.storage.key_colors_picture_target import ( @@ -17,6 +16,8 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) +DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds + class PictureTargetStore: """Persistent storage for picture targets.""" @@ -100,19 +101,24 @@ class PictureTargetStore: name: str, target_type: str, device_id: str = "", - picture_source_id: str = "", - settings: Optional[ProcessingSettings] = None, + color_strip_source_id: str = "", + standby_interval: float = 1.0, + state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, + # Legacy params — accepted but ignored for backward compat + picture_source_id: str = "", + settings=None, ) -> PictureTarget: """Create a new picture target. Args: name: Target name - target_type: Target type ("wled", "key_colors") - device_id: WLED device ID (for wled targets) - picture_source_id: Picture source ID - settings: Processing settings (for wled targets) + target_type: Target type ("led", "wled", "key_colors") + device_id: WLED device ID (for led targets) + color_strip_source_id: Color strip source ID (for led targets) + standby_interval: Keepalive interval in seconds (for led targets) + state_check_interval: State check interval in seconds (for led targets) key_colors_settings: Key colors settings (for key_colors targets) description: Optional description @@ -139,8 +145,9 @@ class PictureTargetStore: name=name, target_type="led", device_id=device_id, - picture_source_id=picture_source_id, - settings=settings or ProcessingSettings(), + color_strip_source_id=color_strip_source_id, + standby_interval=standby_interval, + state_check_interval=state_check_interval, description=description, created_at=now, updated_at=now, @@ -170,10 +177,14 @@ class PictureTargetStore: target_id: str, name: Optional[str] = None, device_id: Optional[str] = None, - picture_source_id: Optional[str] = None, - settings: Optional[ProcessingSettings] = None, + color_strip_source_id: Optional[str] = None, + standby_interval: Optional[float] = None, + state_check_interval: Optional[int] = None, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, + # Legacy params — accepted but ignored + picture_source_id: Optional[str] = None, + settings=None, ) -> PictureTarget: """Update a picture target. @@ -194,8 +205,9 @@ class PictureTargetStore: target.update_fields( name=name, device_id=device_id, - picture_source_id=picture_source_id, - settings=settings, + color_strip_source_id=color_strip_source_id, + standby_interval=standby_interval, + state_check_interval=state_check_interval, key_colors_settings=key_colors_settings, description=description, ) @@ -228,9 +240,16 @@ class PictureTargetStore: ] def is_referenced_by_source(self, source_id: str) -> bool: - """Check if any target references a picture source.""" + """Check if any KC target directly references a picture source.""" for target in self._targets.values(): - if target.has_picture_source and target.picture_source_id == source_id: + if isinstance(target, KeyColorsPictureTarget) and target.picture_source_id == source_id: + return True + return False + + def is_referenced_by_color_strip_source(self, css_id: str) -> bool: + """Check if any WLED target references a color strip source.""" + for target in self._targets.values(): + if isinstance(target, WledPictureTarget) and target.color_strip_source_id == css_id: return True return False diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index 5d733d8..01f9f5a 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -1,20 +1,31 @@ -"""LED picture target — streams a picture source to an LED device.""" +"""LED picture target — sends a color strip source to an LED device.""" from dataclasses import dataclass, field from datetime import datetime from typing import Optional -from wled_controller.core.processing.processing_settings import ProcessingSettings from wled_controller.storage.picture_target import PictureTarget +DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds + @dataclass class WledPictureTarget(PictureTarget): - """LED picture target — streams a picture source to an LED device.""" + """LED picture target — pairs an LED device with a ColorStripSource. + + The ColorStripSource encapsulates everything needed to produce LED colors + (calibration, color correction, smoothing, fps). The LED target itself only + holds device-specific timing/keepalive settings. + """ device_id: str = "" - picture_source_id: str = "" - settings: ProcessingSettings = field(default_factory=ProcessingSettings) + color_strip_source_id: str = "" + standby_interval: float = 1.0 # seconds between keepalive sends when screen is static + state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL + + # Legacy fields — populated from old JSON data during migration; not written back + _legacy_picture_source_id: str = field(default="", repr=False, compare=False) + _legacy_settings: Optional[dict] = field(default=None, repr=False, compare=False) def register_with_manager(self, manager) -> None: """Register this WLED target with the processor manager.""" @@ -22,74 +33,72 @@ class WledPictureTarget(PictureTarget): manager.add_target( target_id=self.id, device_id=self.device_id, - settings=self.settings, - picture_source_id=self.picture_source_id, + color_strip_source_id=self.color_strip_source_id, + standby_interval=self.standby_interval, + state_check_interval=self.state_check_interval, ) def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None: """Push changed fields to the processor manager.""" if settings_changed: - manager.update_target_settings(self.id, self.settings) + manager.update_target_settings(self.id, { + "standby_interval": self.standby_interval, + "state_check_interval": self.state_check_interval, + }) if source_changed: - manager.update_target_source(self.id, self.picture_source_id) + manager.update_target_color_strip_source(self.id, self.color_strip_source_id) if device_changed: manager.update_target_device(self.id, self.device_id) - def update_fields(self, *, name=None, device_id=None, picture_source_id=None, - settings=None, key_colors_settings=None, description=None) -> None: + def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, + standby_interval=None, state_check_interval=None, + # Legacy params accepted but ignored to keep base class compat: + picture_source_id=None, settings=None, + key_colors_settings=None, description=None) -> None: """Apply mutable field updates for WLED targets.""" super().update_fields(name=name, description=description) if device_id is not None: self.device_id = device_id - if picture_source_id is not None: - self.picture_source_id = picture_source_id - if settings is not None: - self.settings = settings + if color_strip_source_id is not None: + self.color_strip_source_id = color_strip_source_id + if standby_interval is not None: + self.standby_interval = standby_interval + if state_check_interval is not None: + self.state_check_interval = state_check_interval @property def has_picture_source(self) -> bool: - return True + return bool(self.color_strip_source_id) def to_dict(self) -> dict: """Convert to dictionary.""" d = super().to_dict() d["device_id"] = self.device_id - d["picture_source_id"] = self.picture_source_id - d["settings"] = { - "display_index": self.settings.display_index, - "fps": self.settings.fps, - "brightness": self.settings.brightness, - "smoothing": self.settings.smoothing, - "interpolation_mode": self.settings.interpolation_mode, - "standby_interval": self.settings.standby_interval, - "state_check_interval": self.settings.state_check_interval, - } + d["color_strip_source_id"] = self.color_strip_source_id + d["standby_interval"] = self.standby_interval + d["state_check_interval"] = self.state_check_interval return d @classmethod def from_dict(cls, data: dict) -> "WledPictureTarget": - """Create from dictionary.""" - from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL - - settings_data = data.get("settings", {}) - settings = ProcessingSettings( - display_index=settings_data.get("display_index", 0), - fps=settings_data.get("fps", 30), - brightness=settings_data.get("brightness", 1.0), - smoothing=settings_data.get("smoothing", 0.3), - interpolation_mode=settings_data.get("interpolation_mode", "average"), - standby_interval=settings_data.get("standby_interval", 1.0), - state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), - ) - - return cls( + """Create from dictionary. Reads legacy picture_source_id/settings for migration.""" + obj = cls( id=data["id"], name=data["name"], target_type="led", device_id=data.get("device_id", ""), - picture_source_id=data.get("picture_source_id", ""), - settings=settings, + color_strip_source_id=data.get("color_strip_source_id", ""), + standby_interval=data.get("standby_interval", 1.0), + state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), description=data.get("description"), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), ) + + # Preserve legacy fields for migration — never written back by to_dict() + obj._legacy_picture_source_id = data.get("picture_source_id", "") + settings_data = data.get("settings", {}) + if settings_data: + obj._legacy_settings = settings_data + + return obj diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 0c0f81e..a6b8a80 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -104,6 +104,7 @@ {% include 'modals/calibration.html' %} {% include 'modals/device-settings.html' %} {% include 'modals/target-editor.html' %} + {% include 'modals/css-editor.html' %} {% include 'modals/kc-editor.html' %} {% include 'modals/pattern-template.html' %} {% include 'modals/api-key.html' %} diff --git a/server/src/wled_controller/templates/modals/calibration.html b/server/src/wled_controller/templates/modals/calibration.html index e0adaeb..c083340 100644 --- a/server/src/wled_controller/templates/modals/calibration.html +++ b/server/src/wled_controller/templates/modals/calibration.html @@ -8,6 +8,16 @@