diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 33875cb..266e1fb 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -9,10 +9,8 @@ from .routes.devices import router as devices_router from .routes.templates import router as templates_router from .routes.postprocessing import router as postprocessing_router from .routes.picture_sources import router as picture_sources_router -from .routes.pattern_templates import router as pattern_templates_router from .routes.output_targets import router as output_targets_router from .routes.output_targets_control import router as output_targets_control_router -from .routes.output_targets_keycolors import router as output_targets_keycolors_router from .routes.color_strip_sources import router as color_strip_sources_router from .routes.audio import router as audio_router from .routes.audio_sources import router as audio_sources_router @@ -36,7 +34,6 @@ router.include_router(system_settings_router) router.include_router(devices_router) 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(audio_router) @@ -45,7 +42,6 @@ router.include_router(audio_templates_router) router.include_router(value_sources_router) router.include_router(output_targets_router) router.include_router(output_targets_control_router) -router.include_router(output_targets_keycolors_router) router.include_router(automations_router) router.include_router(scene_presets_router) router.include_router(webhooks_router) diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index ad22616..7cac935 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -11,7 +11,6 @@ from wled_controller.storage.database import Database 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.output_target_store import OutputTargetStore from wled_controller.storage.color_strip_store import ColorStripStore @@ -64,10 +63,6 @@ def get_pp_template_store() -> PostprocessingTemplateStore: return _get("pp_template_store", "Postprocessing template store") -def get_pattern_template_store() -> PatternTemplateStore: - return _get("pattern_template_store", "Pattern template store") - - def get_picture_source_store() -> PictureSourceStore: return _get("picture_source_store", "Picture source store") @@ -188,7 +183,6 @@ def init_dependencies( processor_manager: ProcessorManager, database: Database | None = None, pp_template_store: PostprocessingTemplateStore | None = None, - pattern_template_store: PatternTemplateStore | None = None, picture_source_store: PictureSourceStore | None = None, output_target_store: OutputTargetStore | None = None, color_strip_store: ColorStripStore | None = None, @@ -218,7 +212,6 @@ def init_dependencies( "template_store": template_store, "processor_manager": processor_manager, "pp_template_store": pp_template_store, - "pattern_template_store": pattern_template_store, "picture_source_store": picture_source_store, "output_target_store": output_target_store, "color_strip_store": color_strip_store, diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index c9ec3ad..82f9058 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -12,9 +12,12 @@ from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( fire_entity_event, get_color_strip_store, + get_device_store, get_picture_source_store, get_output_target_store, + get_pp_template_store, get_processor_manager, + get_template_store, ) from wled_controller.api.schemas.color_strip_sources import ( ColorPushRequest, @@ -34,13 +37,25 @@ from wled_controller.core.capture.calibration import ( ) from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.processing.processor_manager import ProcessorManager -from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource +from wled_controller.storage.color_strip_source import ( + AdvancedPictureColorStripSource, + ApiInputColorStripSource, + CompositeColorStripSource, + NotificationColorStripSource, + PictureColorStripSource, +) +from wled_controller.storage import DeviceStore from wled_controller.storage.color_strip_store import ColorStripStore -from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource +from wled_controller.storage.template_store import TemplateStore +from wled_controller.storage.picture_source import ( + ProcessedPictureSource, + ScreenCapturePictureSource, +) from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.utils import get_logger from wled_controller.storage.base_store import EntityNotFoundError + logger = get_logger(__name__) router = APIRouter() @@ -90,14 +105,18 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe ) -def _resolve_display_index(picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0) -> int: +def _resolve_display_index( + picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0 +) -> int: """Resolve display index from a picture source, following processed source chains.""" if not picture_source_id or depth > 5: return 0 try: ps = picture_source_store.get_stream(picture_source_id) except Exception as e: - logger.debug("Failed to resolve display index for picture source %s: %s", picture_source_id, e) + logger.debug( + "Failed to resolve display index for picture source %s: %s", picture_source_id, e + ) return 0 if isinstance(ps, ScreenCapturePictureSource): return ps.display_index @@ -108,7 +127,12 @@ def _resolve_display_index(picture_source_id: str, picture_source_store: Picture # ===== CRUD ENDPOINTS ===== -@router.get("/api/v1/color-strip-sources", response_model=ColorStripSourceListResponse, tags=["Color Strip Sources"]) + +@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), @@ -126,7 +150,9 @@ def _extract_css_kwargs(data) -> dict: Converts nested Pydantic models (calibration, stops, layers, zones, animation) to plain dicts/lists that the store expects. """ - kwargs = data.model_dump(exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"}) + kwargs = data.model_dump( + exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"} + ) # Remove fields that don't map to store kwargs kwargs.pop("source_type", None) @@ -135,13 +161,20 @@ def _extract_css_kwargs(data) -> dict: else: kwargs["calibration"] = None kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None - kwargs["layers"] = [layer.model_dump() for layer in data.layers] if data.layers is not None else None + kwargs["layers"] = ( + [layer.model_dump() for layer in data.layers] if data.layers is not None else None + ) kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None kwargs["animation"] = data.animation.model_dump() if data.animation else None return kwargs -@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201) +@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, @@ -157,7 +190,6 @@ async def create_color_strip_source( except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -165,7 +197,11 @@ async def create_color_strip_source( raise HTTPException(status_code=500, detail="Internal server error") -@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"]) +@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, @@ -180,7 +216,11 @@ async def get_color_strip_source( raise HTTPException(status_code=404, detail=str(e)) -@router.put("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"]) +@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, @@ -209,7 +249,9 @@ async def update_color_strip_source( raise HTTPException(status_code=500, detail="Internal server error") -@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"]) +@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, @@ -224,7 +266,7 @@ async def delete_color_strip_source( raise HTTPException( status_code=409, detail=f"Color strip source is referenced by target(s): {names}. " - "Delete or reassign the target(s) first.", + "Delete or reassign the target(s) first.", ) composite_names = store.get_composites_referencing(source_id) if composite_names: @@ -232,7 +274,7 @@ async def delete_color_strip_source( raise HTTPException( status_code=409, detail=f"Color strip source is used as a layer in composite source(s): {names}. " - "Remove it from the composite(s) first.", + "Remove it from the composite(s) first.", ) mapped_names = store.get_mapped_referencing(source_id) if mapped_names: @@ -240,7 +282,7 @@ async def delete_color_strip_source( raise HTTPException( status_code=409, detail=f"Color strip source is used as a zone in mapped source(s): {names}. " - "Remove it from the mapped source(s) first.", + "Remove it from the mapped source(s) first.", ) processed_names = store.get_processed_referencing(source_id) if processed_names: @@ -248,7 +290,7 @@ async def delete_color_strip_source( raise HTTPException( status_code=409, detail=f"Color strip source is used as input in processed source(s): {names}. " - "Delete or reassign the processed source(s) first.", + "Delete or reassign the processed source(s) first.", ) store.delete_source(source_id) fire_entity_event("color_strip_source", "deleted", source_id) @@ -261,8 +303,360 @@ async def delete_color_strip_source( raise HTTPException(status_code=500, detail="Internal server error") +# ===== KEY COLORS TEST ===== + + +@router.post( + "/api/v1/color-strip-sources/{source_id}/key-colors/test", + tags=["Color Strip Sources"], +) +async def test_key_colors_source( + source_id: str, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + source_store: PictureSourceStore = Depends(get_picture_source_store), + template_store: TemplateStore = Depends(get_template_store), + processor_manager: ProcessorManager = Depends(get_processor_manager), + device_store: DeviceStore = Depends(get_device_store), + pp_template_store=Depends(get_pp_template_store), +): + """Test a key_colors source: capture a frame, extract colors from each rectangle.""" + from wled_controller.storage.color_strip_source import KeyColorsColorStripSource + from wled_controller.core.capture.screen_capture import ( + calculate_average_color, + calculate_dominant_color, + calculate_median_color, + ) + from wled_controller.core.capture_engines import EngineRegistry + from wled_controller.core.filters import FilterRegistry, ImagePool + from wled_controller.storage.picture_source import ( + ScreenCapturePictureSource, + StaticImagePictureSource, + ) + from wled_controller.utils.image_codec import encode_jpeg_data_uri + + stream = None + try: + source = store.get_source(source_id) + if not isinstance(source, KeyColorsColorStripSource): + raise HTTPException(status_code=400, detail="Source is not a key_colors type") + + if not source.rectangles: + raise HTTPException(status_code=400, detail="No screen regions configured") + if not source.picture_source_id: + raise HTTPException(status_code=400, detail="No picture source configured") + + # Resolve picture source and capture a frame + chain = source_store.resolve_stream_chain(source.picture_source_id) + raw_stream = chain["raw_stream"] + + from wled_controller.utils.image_codec import load_image_file + + if isinstance(raw_stream, StaticImagePictureSource): + from wled_controller.api.dependencies import get_asset_store as _get_asset_store + + asset_store = _get_asset_store() + image_path = ( + asset_store.get_file_path(raw_stream.image_asset_id) + if raw_stream.image_asset_id + else None + ) + if not image_path: + raise HTTPException(status_code=400, detail="Image asset not found") + image = load_image_file(image_path) + elif isinstance(raw_stream, ScreenCapturePictureSource): + capture_template = template_store.get_template(raw_stream.capture_template_id) + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, detail=f"Engine '{capture_template.engine_type}' not available" + ) + + locked = processor_manager.get_display_lock_info(display_index) + if locked: + try: + device_name = device_store.get_device(locked).name + except Exception: + device_name = locked + raise HTTPException( + status_code=409, + detail=f"Display {display_index} is captured by '{device_name}'. Stop it first.", + ) + + stream = EngineRegistry.create_stream( + capture_template.engine_type, display_index, capture_template.engine_config + ) + stream.initialize() + sc = stream.capture_frame() + if sc is None: + raise RuntimeError("No frame captured") + image = sc.image + else: + raise HTTPException(status_code=400, detail="Unsupported picture source type") + + # Apply postprocessing filters + pp_ids = chain.get("postprocessing_template_ids", []) + if pp_ids and pp_template_store: + pool = ImagePool() + for pp_id in pp_ids: + try: + pp = pp_template_store.get_template(pp_id) + for fi in pp_template_store.resolve_filter_instances(pp.filters): + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(image, pool) + if result is not None: + image = result + except Exception: + pass + + # Extract colors from each rectangle + h, w = image.shape[:2] + calc_fns = { + "average": calculate_average_color, + "median": calculate_median_color, + "dominant": calculate_dominant_color, + } + calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) + + result_rects = [] + for rect in source.rectangles: + px_x = max(0, int(rect.x * w)) + px_y = max(0, int(rect.y * h)) + px_w = max(1, int(rect.width * w)) + px_h = max(1, int(rect.height * h)) + px_x, px_y = min(px_x, w - 1), min(px_y, h - 1) + px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y) + sub_img = image[px_y : px_y + px_h, px_x : px_x + px_w] + r, g, b = calc_fn(sub_img) + result_rects.append( + { + "name": rect.name, + "x": rect.x, + "y": rect.y, + "width": rect.width, + "height": rect.height, + "color": { + "r": int(r), + "g": int(g), + "b": int(b), + "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}", + }, + } + ) + + image_data_uri = encode_jpeg_data_uri(image, quality=90) + + return { + "image": image_data_uri, + "rectangles": result_rects, + "interpolation_mode": source.interpolation_mode, + } + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Key colors test failed: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + finally: + if stream: + try: + stream.stop() + except Exception: + pass + + +@router.websocket("/api/v1/color-strip-sources/{source_id}/key-colors/test/ws") +async def test_key_colors_ws( + websocket: WebSocket, + source_id: str, + token: str = Query(""), + fps: int = Query(3), + preview_width: int = Query(480), +): + """WebSocket for real-time key_colors test preview with frame + rectangle overlay.""" + import json as ws_json + import time as ws_time + from wled_controller.api.auth import verify_ws_token + from wled_controller.storage.color_strip_source import KeyColorsColorStripSource + from wled_controller.core.capture.screen_capture import ( + calculate_average_color, + calculate_dominant_color, + calculate_median_color, + ) + from wled_controller.core.filters import FilterRegistry, ImagePool + from wled_controller.storage.picture_source import ScreenCapturePictureSource + from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down + + if not verify_ws_token(token): + await websocket.close(code=4001, reason="Unauthorized") + return + + store = get_color_strip_store() + source_store = get_picture_source_store() + manager = get_processor_manager() + device_store = get_device_store() + pp_store = get_pp_template_store() + + try: + source = store.get_source(source_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + if not isinstance(source, KeyColorsColorStripSource): + await websocket.close(code=4003, reason="Not a key_colors source") + return + if not source.rectangles: + await websocket.close(code=4003, reason="No regions configured") + return + if not source.picture_source_id: + await websocket.close(code=4003, reason="No picture source configured") + return + + try: + chain = source_store.resolve_stream_chain(source.picture_source_id) + except ValueError as e: + await websocket.close(code=4003, reason=str(e)) + return + + raw_stream = chain["raw_stream"] + if isinstance(raw_stream, ScreenCapturePictureSource): + locked = manager.get_display_lock_info(raw_stream.display_index) + if locked: + try: + name = device_store.get_device(locked).name + except Exception: + name = locked + await websocket.close(code=4003, reason=f"Display captured by '{name}'") + return + + fps = max(1, min(30, fps)) + preview_width = max(120, min(1920, preview_width)) + frame_interval = 1.0 / fps + + calc_fns = { + "average": calculate_average_color, + "median": calculate_median_color, + "dominant": calculate_dominant_color, + } + calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) + + await websocket.accept() + logger.info(f"KC CSS test WS connected for {source_id} (fps={fps})") + + live_stream_mgr = manager._live_stream_manager + live_stream = None + + try: + live_stream = await asyncio.to_thread(live_stream_mgr.acquire, source.picture_source_id) + prev_frame_ref = None + + while True: + loop_start = ws_time.monotonic() + try: + capture = await asyncio.to_thread(live_stream.get_latest_frame) + if capture is None or capture.image is None: + await asyncio.sleep(frame_interval) + continue + if capture is prev_frame_ref: + await asyncio.sleep(frame_interval * 0.5) + continue + prev_frame_ref = capture + + cur_image = capture.image + if not isinstance(cur_image, np.ndarray): + await asyncio.sleep(frame_interval) + continue + + # Apply postprocessing + pp_ids = chain.get("postprocessing_template_ids", []) + if pp_ids and pp_store: + pool = ImagePool() + for pp_id in pp_ids: + try: + pp = pp_store.get_template(pp_id) + for fi in pp_store.resolve_filter_instances(pp.filters): + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(cur_image, pool) + if result is not None: + cur_image = result + except Exception: + pass + + # Re-read source for hot-update support + try: + source = store.get_source(source_id) + calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) + except Exception: + pass + + h, w = cur_image.shape[:2] + result_rects = [] + for rect in source.rectangles: + px_x = max(0, int(rect.x * w)) + px_y = max(0, int(rect.y * h)) + px_w = max(1, int(rect.width * w)) + px_h = max(1, int(rect.height * h)) + px_x, px_y = min(px_x, w - 1), min(px_y, h - 1) + px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y) + sub = cur_image[px_y : px_y + px_h, px_x : px_x + px_w] + r, g, b = calc_fn(sub) + result_rects.append( + { + "name": rect.name, + "x": rect.x, + "y": rect.y, + "width": rect.width, + "height": rect.height, + "color": { + "r": int(r), + "g": int(g), + "b": int(b), + "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}", + }, + } + ) + + frame_to_encode = resize_down(cur_image, preview_width) + frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85) + + await websocket.send_text( + ws_json.dumps( + { + "type": "frame", + "image": frame_uri, + "rectangles": result_rects, + "interpolation_mode": source.interpolation_mode, + } + ) + ) + + except (WebSocketDisconnect, Exception) as inner_e: + if isinstance(inner_e, WebSocketDisconnect): + raise + logger.warning(f"KC CSS test WS frame error: {inner_e}") + + elapsed = ws_time.monotonic() - loop_start + if frame_interval - elapsed > 0: + await asyncio.sleep(frame_interval - elapsed) + + except WebSocketDisconnect: + logger.info(f"KC CSS test WS disconnected for {source_id}") + except Exception as e: + logger.error(f"KC CSS test WS error: {e}", exc_info=True) + finally: + if live_stream is not None: + try: + await asyncio.to_thread(live_stream_mgr.release, source.picture_source_id) + except Exception: + pass + + # ===== CALIBRATION TEST ===== + @router.put( "/api/v1/color-strip-sources/{source_id}/calibration/test", response_model=CalibrationTestModeResponse, @@ -291,7 +685,7 @@ async def test_css_calibration( 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))}" + 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( @@ -304,7 +698,9 @@ async def test_css_calibration( if body.edges: try: source = store.get_source(source_id) - if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): + if not isinstance( + source, (PictureColorStripSource, AdvancedPictureColorStripSource) + ): raise HTTPException( status_code=400, detail="Calibration test is only available for picture color strip sources", @@ -339,6 +735,7 @@ async def test_css_calibration( # ===== OVERLAY VISUALIZATION ===== + @router.post("/api/v1/color-strip-sources/{source_id}/overlay/start", tags=["Color Strip Sources"]) async def start_css_overlay( source_id: str, @@ -351,9 +748,13 @@ async def start_css_overlay( try: source = store.get_source(source_id) if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): - raise HTTPException(status_code=400, detail="Overlay is only supported for picture color strip sources") + raise HTTPException( + status_code=400, detail="Overlay is only supported for picture color strip sources" + ) if not source.calibration: - raise HTTPException(status_code=400, detail="Color strip source has no calibration configured") + raise HTTPException( + status_code=400, detail="Color strip source has no calibration configured" + ) ps_id = getattr(source, "picture_source_id", "") or "" display_index = _resolve_display_index(ps_id, picture_source_store) @@ -404,6 +805,7 @@ async def get_css_overlay_status( # ===== API INPUT: COLOR PUSH ===== + @router.post("/api/v1/color-strip-sources/{source_id}/colors", tags=["Color Strip Sources"]) async def push_colors( source_id: str, @@ -442,7 +844,9 @@ async def push_colors( # Legacy flat colors path colors_array = np.array(body.colors, dtype=np.uint8) if colors_array.ndim != 2 or colors_array.shape[1] != 3: - raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets") + raise HTTPException( + status_code=400, detail="Colors must be an array of [R,G,B] triplets" + ) for stream in streams: if hasattr(stream, "push_colors"): stream.push_colors(colors_array) @@ -495,7 +899,10 @@ async def notify_source( @router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"]) async def os_notification_history(_auth: AuthRequired): """Return recent OS notification capture history (newest first).""" - from wled_controller.core.processing.os_notification_listener import get_os_notification_listener + from wled_controller.core.processing.os_notification_listener import ( + get_os_notification_listener, + ) + listener = get_os_notification_listener() if listener is None: return {"available": False, "history": []} @@ -507,7 +914,15 @@ async def os_notification_history(_auth: AuthRequired): # ── Transient Preview WebSocket ──────────────────────────────────────── -_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight", "notification"} +_PREVIEW_ALLOWED_TYPES = { + "static", + "gradient", + "color_cycle", + "effect", + "daylight", + "candlelight", + "notification", +} @router.websocket("/api/v1/color-strip-sources/preview/ws") @@ -529,6 +944,7 @@ async def preview_color_strip_ws( changed the old stream is replaced; otherwise ``update_source()`` is used. """ from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return @@ -557,6 +973,7 @@ async def preview_color_strip_ws( def _build_source(config: dict): """Build a ColorStripSource from a raw config dict, injecting synthetic id/name.""" from wled_controller.storage.color_strip_source import ColorStripSource + config.setdefault("id", "__preview__") config.setdefault("name", "__preview__") return ColorStripSource.from_dict(config) @@ -564,6 +981,7 @@ async def preview_color_strip_ws( def _create_stream(source): """Instantiate and start the appropriate stream class for *source*.""" from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP + stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) if not stream_cls: raise ValueError(f"Unsupported preview source_type: {source.source_type}") @@ -572,6 +990,7 @@ async def preview_color_strip_ws( if hasattr(s, "set_gradient_store"): try: from wled_controller.api.dependencies import get_gradient_store + s.set_gradient_store(get_gradient_store()) except Exception: pass @@ -626,7 +1045,14 @@ async def preview_color_strip_ws( config = _json.loads(initial_text) source_type = config.get("source_type") if source_type not in _PREVIEW_ALLOWED_TYPES: - await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"})) + await websocket.send_text( + _json.dumps( + { + "type": "error", + "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}", + } + ) + ) await websocket.close(code=4003, reason="Invalid source_type") return source = _build_source(config) @@ -639,7 +1065,9 @@ async def preview_color_strip_ws( return await _send_meta(current_source_type) - logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}") + logger.info( + f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}" + ) # Frame loop ───────────────────────────────────────────────────────── @@ -659,7 +1087,10 @@ async def preview_color_strip_ws( # Handle "fire" command for notification streams if new_config.get("action") == "fire": - from wled_controller.core.processing.notification_stream import NotificationColorStripStream + from wled_controller.core.processing.notification_stream import ( + NotificationColorStripStream, + ) + if isinstance(stream, NotificationColorStripStream): stream.fire( app_name=new_config.get("app", ""), @@ -669,7 +1100,14 @@ async def preview_color_strip_ws( new_type = new_config.get("source_type") if new_type not in _PREVIEW_ALLOWED_TYPES: - await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"})) + await websocket.send_text( + _json.dumps( + { + "type": "error", + "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}", + } + ) + ) continue new_source = _build_source(new_config) if new_type != current_source_type: @@ -692,7 +1130,7 @@ async def preview_color_strip_ws( await websocket.send_bytes(colors.tobytes()) else: # Stream hasn't produced a frame yet — send black - await websocket.send_bytes(b'\x00' * led_count * 3) + await websocket.send_bytes(b"\x00" * led_count * 3) except WebSocketDisconnect: pass @@ -716,6 +1154,7 @@ async def css_api_input_ws( or binary frames (raw RGBRGB... bytes, 3 bytes per LED). """ from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return @@ -746,6 +1185,7 @@ async def css_api_input_ws( if "text" in message: # JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]} import json + try: data = json.loads(message["text"]) except (json.JSONDecodeError, ValueError) as e: @@ -756,6 +1196,7 @@ async def css_api_input_ws( # Segment-based path — validate and push try: from wled_controller.api.schemas.color_strip_sources import SegmentPayload + seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]] except Exception as e: await websocket.send_json({"error": f"Invalid segment: {e}"}) @@ -777,7 +1218,9 @@ async def css_api_input_ws( await websocket.send_json({"error": str(e)}) continue else: - await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"}) + await websocket.send_json( + {"error": "JSON frame must contain 'colors' or 'segments'"} + ) continue elif "bytes" in message: @@ -807,6 +1250,7 @@ async def css_api_input_ws( # ── Test / Preview WebSocket ────────────────────────────────────────── + @router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws") async def test_color_strip_ws( websocket: WebSocket, @@ -821,6 +1265,7 @@ async def test_color_strip_ws( Subsequent messages are binary RGB frames (``led_count * 3`` bytes). """ from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return @@ -868,6 +1313,7 @@ async def test_color_strip_ws( from wled_controller.core.processing.composite_stream import CompositeColorStripStream from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream + is_api_input = isinstance(stream, ApiInputColorStripStream) _last_push_gen = 0 # track api_input push generation to skip unchanged frames @@ -900,13 +1346,19 @@ async def test_color_strip_ws( enabled_layers = [layer for layer in source.layers if layer.get("enabled", True)] layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...] for layer in enabled_layers: - info = {"id": layer["source_id"], "name": layer.get("source_id", "?"), - "is_notification": False, "has_brightness": bool(layer.get("brightness_source_id"))} + info = { + "id": layer["source_id"], + "name": layer.get("source_id", "?"), + "is_notification": False, + "has_brightness": bool(layer.get("brightness_source_id")), + } try: layer_src = store.get_source(layer["source_id"]) info["name"] = layer_src.name info["is_notification"] = isinstance(layer_src, NotificationColorStripSource) - if isinstance(layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)): + if isinstance( + layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource) + ): info["is_picture"] = True if hasattr(layer_src, "calibration") and layer_src.calibration: info["calibration_led_count"] = layer_src.calibration.get_total_leds() @@ -927,7 +1379,7 @@ async def test_color_strip_ws( # For picture sources, grab the live stream for frame preview _frame_live = None - if is_picture and hasattr(stream, 'live_stream'): + if is_picture and hasattr(stream, "live_stream"): _frame_live = stream.live_stream _last_aux_time = 0.0 _AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS @@ -943,15 +1395,17 @@ async def test_color_strip_ws( led_count = composite_colors.shape[0] rgb_size = led_count * 3 # Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb] - header = bytes([0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]) + header = bytes( + [0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF] + ) parts = [header] for lc in layer_colors: if lc is not None and lc.shape[0] == led_count: parts.append(lc.tobytes()) else: - parts.append(b'\x00' * rgb_size) + parts.append(b"\x00" * rgb_size) parts.append(composite_colors.tobytes()) - await websocket.send_bytes(b''.join(parts)) + await websocket.send_bytes(b"".join(parts)) elif composite_colors is not None: await websocket.send_bytes(composite_colors.tobytes()) else: @@ -978,9 +1432,12 @@ async def test_color_strip_ws( try: bri_values = stream.get_layer_brightness() if any(v is not None for v in bri_values): - bri_msg = {"type": "brightness", "values": [ - round(v * 100) if v is not None else None for v in bri_values - ]} + bri_msg = { + "type": "brightness", + "values": [ + round(v * 100) if v is not None else None for v in bri_values + ], + } await websocket.send_text(_json.dumps(bri_msg)) except Exception: pass @@ -992,6 +1449,7 @@ async def test_color_strip_ws( if frame is not None and frame.image is not None: from wled_controller.utils.image_codec import encode_jpeg import cv2 as _cv2 + img = frame.image # Ensure 3-channel RGB (some engines may produce BGRA) if img.ndim == 3 and img.shape[2] == 4: @@ -1000,19 +1458,25 @@ async def test_color_strip_ws( # Send frame dimensions once so client can compute border overlay if not _frame_dims_sent: _frame_dims_sent = True - await websocket.send_text(_json.dumps({ - "type": "frame_dims", - "width": w, - "height": h, - })) + await websocket.send_text( + _json.dumps( + { + "type": "frame_dims", + "width": w, + "height": h, + } + ) + ) # Downscale for bandwidth scale = min(960 / w, 540 / h, 1.0) if scale < 1.0: new_w = max(1, int(w * scale)) new_h = max(1, int(h * scale)) - img = _cv2.resize(img, (new_w, new_h), interpolation=_cv2.INTER_AREA) + img = _cv2.resize( + img, (new_w, new_h), interpolation=_cv2.INTER_AREA + ) # Wire format: [0xFD] [jpeg_bytes] - await websocket.send_bytes(b'\xfd' + encode_jpeg(img, quality=70)) + await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70)) except Exception as e: logger.warning(f"JPEG frame preview error: {e}") diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index a4fa9f2..8e2d0fc 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -12,7 +12,6 @@ from wled_controller.api.dependencies import ( get_processor_manager, ) from wled_controller.api.schemas.output_targets import ( - KeyColorsSettingsSchema, OutputTargetCreate, OutputTargetListResponse, OutputTargetResponse, @@ -21,10 +20,6 @@ from wled_controller.api.schemas.output_targets import ( from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore from wled_controller.storage.wled_output_target import WledOutputTarget -from wled_controller.storage.key_colors_output_target import ( - KeyColorsSettings, - KeyColorsOutputTarget, -) from wled_controller.storage.ha_light_output_target import ( HALightMapping, HALightOutputTarget, @@ -39,30 +34,6 @@ logger = get_logger(__name__) router = APIRouter() -def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema: - """Convert core KeyColorsSettings to schema.""" - return KeyColorsSettingsSchema( - fps=settings.fps, - interpolation_mode=settings.interpolation_mode, - smoothing=settings.smoothing, - pattern_template_id=settings.pattern_template_id, - brightness=settings.brightness, - brightness_value_source_id=settings.brightness_value_source_id, - ) - - -def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings: - """Convert schema KeyColorsSettings to core.""" - return KeyColorsSettings( - fps=schema.fps, - interpolation_mode=schema.interpolation_mode, - smoothing=schema.smoothing, - pattern_template_id=schema.pattern_template_id, - brightness=schema.brightness, - brightness_value_source_id=schema.brightness_value_source_id, - ) - - def _target_to_response(target) -> OutputTargetResponse: """Convert an OutputTarget to OutputTargetResponse.""" if isinstance(target, WledOutputTarget): @@ -84,18 +55,6 @@ def _target_to_response(target) -> OutputTargetResponse: created_at=target.created_at, updated_at=target.updated_at, ) - elif isinstance(target, KeyColorsOutputTarget): - return OutputTargetResponse( - id=target.id, - name=target.name, - target_type=target.target_type, - picture_source_id=target.picture_source_id, - key_colors_settings=_kc_settings_to_schema(target.settings), - description=target.description, - tags=target.tags, - created_at=target.created_at, - updated_at=target.updated_at, - ) elif isinstance(target, HALightOutputTarget): return OutputTargetResponse( id=target.id, @@ -155,9 +114,6 @@ async def create_target( except ValueError: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") - kc_settings = ( - _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None - ) ha_mappings = ( [ HALightMapping( @@ -185,8 +141,6 @@ async def create_target( min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, - picture_source_id=data.picture_source_id, - key_colors_settings=kc_settings, description=data.description, tags=data.tags, ha_source_id=data.ha_source_id, @@ -282,31 +236,18 @@ async def update_target( except ValueError: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") - # Build KC settings with partial-update support: only apply fields that were - # explicitly provided in the request body, merging with the existing settings. - kc_settings = None - if data.key_colors_settings is not None: - incoming = data.key_colors_settings.model_dump(exclude_unset=True) - try: - existing_target = target_store.get_target(target_id) - except ValueError: - existing_target = None - - if isinstance(existing_target, KeyColorsOutputTarget): - ex = existing_target.settings - merged = KeyColorsSettingsSchema( - fps=incoming.get("fps", ex.fps), - interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode), - smoothing=incoming.get("smoothing", ex.smoothing), - pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id), - brightness=incoming.get("brightness", ex.brightness), - brightness_value_source_id=incoming.get( - "brightness_value_source_id", ex.brightness_value_source_id - ), + # Build HA light mappings if provided + ha_mappings = None + if data.ha_light_mappings is not None: + ha_mappings = [ + HALightMapping( + entity_id=m.entity_id, + led_start=m.led_start, + led_end=m.led_end, + brightness_scale=m.brightness_scale, ) - kc_settings = _kc_schema_to_settings(merged) - else: - kc_settings = _kc_schema_to_settings(data.key_colors_settings) + for m in data.ha_light_mappings + ] # Update in store target = target_store.update_target( @@ -321,18 +262,15 @@ async def update_target( min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, - key_colors_settings=kc_settings, description=data.description, tags=data.tags, + ha_source_id=data.ha_source_id, + ha_light_mappings=ha_mappings, + update_rate=data.update_rate, + transition=data.transition, + color_tolerance=data.color_tolerance, ) - # Detect KC brightness VS change (inside key_colors_settings) - kc_brightness_vs_changed = False - if data.key_colors_settings is not None: - kc_incoming = data.key_colors_settings.model_dump(exclude_unset=True) - if "brightness_value_source_id" in kc_incoming: - kc_brightness_vs_changed = True - # Sync processor manager (run in thread — css release/acquire can block) try: await asyncio.to_thread( @@ -344,12 +282,13 @@ async def update_target( or data.state_check_interval is not None or data.min_brightness_threshold is not None or data.adaptive_fps is not None - or data.key_colors_settings is not None + or data.update_rate is not None + or data.transition is not None + or data.color_tolerance is not None + or data.ha_light_mappings is not None ), css_changed=data.color_strip_source_id is not None, - brightness_vs_changed=( - data.brightness_value_source_id is not None or kc_brightness_vs_changed - ), + brightness_vs_changed=data.brightness_value_source_id is not None, ) except ValueError as e: logger.debug("Processor config update skipped for target %s: %s", target_id, e) diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index ef2fefa..738d393 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -26,7 +26,6 @@ from wled_controller.api.dependencies import ( get_ha_manager, get_ha_store, get_output_target_store, - get_pattern_template_store, get_picture_source_store, get_pp_template_store, get_processor_manager, @@ -155,7 +154,6 @@ async def list_all_tags(_: AuthRequired): get_template_store, get_audio_template_store, get_pp_template_store, - get_pattern_template_store, get_asset_store, ] for getter in store_getters: diff --git a/server/src/wled_controller/api/schemas/__init__.py b/server/src/wled_controller/api/schemas/__init__.py index 65ac26f..c8efa06 100644 --- a/server/src/wled_controller/api/schemas/__init__.py +++ b/server/src/wled_controller/api/schemas/__init__.py @@ -61,12 +61,6 @@ from .postprocessing import ( PostprocessingTemplateUpdate, PPTemplateTestRequest, ) -from .pattern_templates import ( - PatternTemplateCreate, - PatternTemplateListResponse, - PatternTemplateResponse, - PatternTemplateUpdate, -) from .picture_sources import ( ImageValidateRequest, ImageValidateResponse, diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index d3ac4fc..f0c167a 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -11,8 +11,12 @@ from wled_controller.api.schemas.devices import Calibration class AppSoundOverride(BaseModel): """Per-application sound override for notification sources.""" - sound_asset_id: Optional[str] = Field(None, description="Asset ID for the sound (None = mute this app)") - volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Volume override (None = use global)") + sound_asset_id: Optional[str] = Field( + None, description="Asset ID for the sound (None = mute this app)" + ) + volume: Optional[float] = Field( + None, ge=0.0, le=1.0, description="Volume override (None = use global)" + ) class AnimationConfig(BaseModel): @@ -26,7 +30,9 @@ class AnimationConfig(BaseModel): class ColorStop(BaseModel): """A single color stop in a gradient.""" - position: float = Field(description="Relative position along the strip (0.0–1.0)", ge=0.0, le=1.0) + position: float = Field( + description="Relative position along the strip (0.0–1.0)", ge=0.0, le=1.0 + ) color: List[int] = Field(description="Primary RGB color [R, G, B] (0–255 each)") color_right: Optional[List[int]] = Field( None, @@ -38,13 +44,21 @@ class CompositeLayer(BaseModel): """A single layer in a composite color strip source.""" source_id: str = Field(description="ID of the layer's color strip source") - blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override") + blend_mode: str = Field( + default="normal", description="Blend mode: normal|add|multiply|screen|override" + ) opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") enabled: bool = Field(default=True, description="Whether this layer is active") - brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness") - processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID") + brightness_source_id: Optional[str] = Field( + None, description="Optional value source ID for dynamic brightness" + ) + processing_template_id: Optional[str] = Field( + None, description="Optional color strip processing template ID" + ) start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)") - end: int = Field(default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)") + end: int = Field( + default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)" + ) reverse: bool = Field(default=False, description="Reverse layer output within its range") @@ -61,74 +75,179 @@ 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", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed", "weather"] = Field(default="picture", description="Source type") + source_type: Literal[ + "picture", + "picture_advanced", + "static", + "gradient", + "color_cycle", + "effect", + "composite", + "mapped", + "audio", + "api_input", + "notification", + "daylight", + "candlelight", + "processed", + "weather", + "key_colors", + ] = Field(default="picture", description="Source type") # picture-type fields picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") - 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)") + 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)" + ) # static-type fields - color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)") + color: Optional[List[int]] = Field( + None, description="Static RGB color [R, G, B] (0-255 each, for static type)" + ) # gradient-type fields stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") # color_cycle-type fields - colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") + colors: Optional[List[List[int]]] = Field( + None, description="List of [R,G,B] colors to cycle (color_cycle type)" + ) # effect-type fields - effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference") - palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'") + effect_type: Optional[str] = Field( + None, + description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference", + ) + palette: Optional[str] = Field( + None, + description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'", + ) intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor/comet)") - custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]") + custom_palette: Optional[List[List[float]]] = Field( + None, description="Custom palette stops [[pos,R,G,B],...]" + ) # gradient entity reference (effect, gradient, audio types) - gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)") + gradient_id: Optional[str] = Field( + None, description="Gradient entity ID (overrides palette/inline stops)" + ) # gradient-type easing - easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic") + easing: Optional[str] = Field( + None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic" + ) # composite-type fields layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") # mapped-type fields zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") # audio-type fields - visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter") - audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)") - sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) - color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]") + visualization_mode: Optional[str] = Field( + None, description="Audio visualization: spectrum|beat_pulse|vu_meter" + ) + audio_source_id: Optional[str] = Field( + None, description="Mono audio source ID (for audio type)" + ) + sensitivity: Optional[float] = Field( + None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0 + ) + color_peak: Optional[List[int]] = Field( + None, description="Peak/high RGB color for VU meter [R,G,B]" + ) # shared - led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) + led_count: int = Field( + default=0, description="Total LED count (0 = auto from calibration / device)", ge=0 + ) description: Optional[str] = Field(None, description="Optional description", max_length=500) - animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") + animation: Optional[AnimationConfig] = Field( + None, description="Procedural animation config (static/gradient only)" + ) # api_input-type fields - fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)") - timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0) - interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)") + fallback_color: Optional[List[int]] = Field( + None, description="Fallback RGB color [R,G,B] when no data received (api_input type)" + ) + timeout: Optional[float] = Field( + None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0 + ) + interpolation: Optional[str] = Field( + None, description="LED count interpolation mode: none|linear|nearest (api_input type)" + ) # notification-type fields - notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep") - duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds") - default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB) for notifications") - app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color (#RRGGBB)") - app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist") + notification_effect: Optional[str] = Field( + None, description="Notification effect: flash|pulse|sweep" + ) + duration_ms: Optional[int] = Field( + None, ge=100, le=10000, description="Effect duration in milliseconds" + ) + default_color: Optional[str] = Field( + None, description="Default hex color (#RRGGBB) for notifications" + ) + app_colors: Optional[Dict[str, str]] = Field( + None, description="Map of app name to hex color (#RRGGBB)" + ) + app_filter_mode: Optional[str] = Field( + None, description="App filter mode: off|whitelist|blacklist" + ) app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") - sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume") - app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides") + sound_volume: Optional[float] = Field( + None, ge=0.0, le=1.0, description="Global notification sound volume" + ) + app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( + None, description="Per-app sound overrides" + ) # daylight-type fields - speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0) - use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle") - latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0) - longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0) + speed: Optional[float] = Field( + None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0 + ) + use_real_time: Optional[bool] = Field( + None, description="Use wall-clock time for daylight cycle" + ) + latitude: Optional[float] = Field( + None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0 + ) + longitude: Optional[float] = Field( + None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0 + ) # candlelight-type fields - num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20) - wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0) - candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire") + num_candles: Optional[int] = Field( + None, description="Number of independent candle sources (1-20)", ge=1, le=20 + ) + wind_strength: Optional[float] = Field( + None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0 + ) + candle_type: Optional[str] = Field( + None, description="Candle type preset: default|taper|votive|bonfire" + ) # processed-type fields - input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)") - processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)") + input_source_id: Optional[str] = Field( + None, description="Input color strip source ID (for processed type)" + ) + processing_template_id: Optional[str] = Field( + None, description="Color strip processing template ID (for processed type)" + ) # weather-type fields - weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)") - temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0) + weather_source_id: Optional[str] = Field( + None, description="Weather source entity ID (for weather type)" + ) + temperature_influence: Optional[float] = Field( + None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0 + ) + # key_colors-type fields + rectangles: Optional[List[dict]] = Field( + None, description="Named screen regions [{name,x,y,width,height}] for key_colors type" + ) + brightness: Optional[float] = Field( + None, description="Static brightness (0.0-1.0)", ge=0.0, le=1.0 + ) + brightness_value_source_id: Optional[str] = Field( + None, description="Dynamic brightness value source ID" + ) # sync clock - clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") + clock_id: Optional[str] = Field( + None, description="Optional sync clock ID for synchronized animation" + ) tags: List[str] = Field(default_factory=list, description="User-defined tags") @@ -138,71 +257,147 @@ class ColorStripSourceUpdate(BaseModel): name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") - 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)") + 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") # static-type fields - color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)") + color: Optional[List[int]] = Field( + None, description="Static RGB color [R, G, B] (0-255 each, for static type)" + ) # gradient-type fields stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") # color_cycle-type fields - colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") + colors: Optional[List[List[int]]] = Field( + None, description="List of [R,G,B] colors to cycle (color_cycle type)" + ) # effect-type fields effect_type: Optional[str] = Field(None, description="Effect algorithm") palette: Optional[str] = Field(None, description="Named palette") intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") - custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]") + custom_palette: Optional[List[List[float]]] = Field( + None, description="Custom palette stops [[pos,R,G,B],...]" + ) # gradient entity reference (effect, gradient, audio types) - gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)") + gradient_id: Optional[str] = Field( + None, description="Gradient entity ID (overrides palette/inline stops)" + ) # gradient-type easing - easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic") + easing: Optional[str] = Field( + None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic" + ) # composite-type fields layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") # mapped-type fields zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") # audio-type fields - visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter") - audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)") - sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) - color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]") + visualization_mode: Optional[str] = Field( + None, description="Audio visualization: spectrum|beat_pulse|vu_meter" + ) + audio_source_id: Optional[str] = Field( + None, description="Mono audio source ID (for audio type)" + ) + sensitivity: Optional[float] = Field( + None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0 + ) + color_peak: Optional[List[int]] = Field( + None, description="Peak/high RGB color for VU meter [R,G,B]" + ) # shared - led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) + led_count: Optional[int] = Field( + None, description="Total LED count (0 = auto from calibration / device)", ge=0 + ) description: Optional[str] = Field(None, description="Optional description", max_length=500) - animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") + animation: Optional[AnimationConfig] = Field( + None, description="Procedural animation config (static/gradient only)" + ) # api_input-type fields - fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") - timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0) - interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)") + fallback_color: Optional[List[int]] = Field( + None, description="Fallback RGB color [R,G,B] (api_input type)" + ) + timeout: Optional[float] = Field( + None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0 + ) + interpolation: Optional[str] = Field( + None, description="LED count interpolation mode: none|linear|nearest (api_input type)" + ) # notification-type fields - notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep") - duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds") + notification_effect: Optional[str] = Field( + None, description="Notification effect: flash|pulse|sweep" + ) + duration_ms: Optional[int] = Field( + None, ge=100, le=10000, description="Effect duration in milliseconds" + ) default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)") app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color") - app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist") + app_filter_mode: Optional[str] = Field( + None, description="App filter mode: off|whitelist|blacklist" + ) app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") - sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume") - app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides") + sound_volume: Optional[float] = Field( + None, ge=0.0, le=1.0, description="Global notification sound volume" + ) + app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( + None, description="Per-app sound overrides" + ) # daylight-type fields - speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0) - use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle") - latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0) - longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0) + speed: Optional[float] = Field( + None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0 + ) + use_real_time: Optional[bool] = Field( + None, description="Use wall-clock time for daylight cycle" + ) + latitude: Optional[float] = Field( + None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0 + ) + longitude: Optional[float] = Field( + None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0 + ) # candlelight-type fields - num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20) - wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0) - candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire") + num_candles: Optional[int] = Field( + None, description="Number of independent candle sources (1-20)", ge=1, le=20 + ) + wind_strength: Optional[float] = Field( + None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0 + ) + candle_type: Optional[str] = Field( + None, description="Candle type preset: default|taper|votive|bonfire" + ) # processed-type fields - input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)") - processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)") + input_source_id: Optional[str] = Field( + None, description="Input color strip source ID (for processed type)" + ) + processing_template_id: Optional[str] = Field( + None, description="Color strip processing template ID (for processed type)" + ) # weather-type fields - weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)") - temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0) + weather_source_id: Optional[str] = Field( + None, description="Weather source entity ID (for weather type)" + ) + temperature_influence: Optional[float] = Field( + None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0 + ) + # key_colors-type fields + rectangles: Optional[List[dict]] = Field( + None, description="Named screen regions for key_colors type" + ) + brightness: Optional[float] = Field( + None, description="Static brightness (0.0-1.0)", ge=0.0, le=1.0 + ) + brightness_value_source_id: Optional[str] = Field( + None, description="Dynamic brightness value source ID" + ) # sync clock - clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") + clock_id: Optional[str] = Field( + None, description="Optional sync clock ID for synchronized animation" + ) tags: Optional[List[str]] = None @@ -222,7 +417,9 @@ class ColorStripSourceResponse(BaseModel): # gradient-type fields stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") # color_cycle-type fields - colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") + colors: Optional[List[List[int]]] = Field( + None, description="List of [R,G,B] colors to cycle (color_cycle type)" + ) # effect-type fields effect_type: Optional[str] = Field(None, description="Effect algorithm") palette: Optional[str] = Field(None, description="Named palette") @@ -245,17 +442,27 @@ class ColorStripSourceResponse(BaseModel): # shared led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") - animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") + animation: Optional[AnimationConfig] = Field( + None, description="Procedural animation config (static/gradient only)" + ) # api_input-type fields - fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") + fallback_color: Optional[List[int]] = Field( + None, description="Fallback RGB color [R,G,B] (api_input type)" + ) timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)") - interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)") + interpolation: Optional[str] = Field( + None, description="LED count interpolation mode: none|linear|nearest (api_input type)" + ) # notification-type fields - notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep") + notification_effect: Optional[str] = Field( + None, description="Notification effect: flash|pulse|sweep" + ) duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds") default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)") app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color") - app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist") + app_filter_mode: Optional[str] = Field( + None, description="App filter mode: off|whitelist|blacklist" + ) app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") @@ -263,7 +470,9 @@ class ColorStripSourceResponse(BaseModel): app_sounds: Optional[Dict[str, dict]] = Field(None, description="Per-app sound overrides") # daylight-type fields speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier") - use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle") + use_real_time: Optional[bool] = Field( + None, description="Use wall-clock time for daylight cycle" + ) latitude: Optional[float] = Field(None, description="Latitude for daylight timing") longitude: Optional[float] = Field(None, description="Longitude for daylight timing") # candlelight-type fields @@ -272,14 +481,30 @@ class ColorStripSourceResponse(BaseModel): candle_type: Optional[str] = Field(None, description="Candle type preset") # processed-type fields input_source_id: Optional[str] = Field(None, description="Input color strip source ID") - processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID") + processing_template_id: Optional[str] = Field( + None, description="Color strip processing template ID" + ) # weather-type fields weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") - temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength") + temperature_influence: Optional[float] = Field( + None, description="Temperature color shift strength" + ) + # key_colors-type fields + rectangles: Optional[List[dict]] = Field( + None, description="Named screen regions for key_colors type" + ) + brightness: Optional[float] = Field(None, description="Static brightness (0.0-1.0)") + brightness_value_source_id: Optional[str] = Field( + None, description="Dynamic brightness value source ID" + ) # sync clock - clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") + clock_id: Optional[str] = Field( + None, description="Optional sync clock ID for synchronized animation" + ) tags: List[str] = Field(default_factory=list, description="User-defined tags") - overlay_active: bool = Field(False, description="Whether the screen overlay is currently active") + overlay_active: bool = Field( + False, description="Whether the screen overlay is currently active" + ) created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") @@ -298,7 +523,9 @@ class SegmentPayload(BaseModel): length: int = Field(ge=1, description="Number of LEDs in segment") mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode") color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]") - colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]") + colors: Optional[List[List[int]]] = Field( + None, description="Colors for per_pixel/gradient [[R,G,B],...]" + ) @model_validator(mode="after") def _validate_mode_fields(self) -> "SegmentPayload": @@ -329,8 +556,12 @@ class ColorPushRequest(BaseModel): At least one must be provided. """ - colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)") - segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates") + colors: Optional[List[List[int]]] = Field( + None, description="LED color array [[R,G,B], ...] (0-255 each)" + ) + segments: Optional[List[SegmentPayload]] = Field( + None, description="Segment-based color updates" + ) @model_validator(mode="after") def _require_colors_or_segments(self) -> "ColorPushRequest": 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 index 5bbd0e3..ce84409 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -69,7 +69,20 @@ class ColorStripStreamManager: keyed by ``{css_id}:{consumer_id}``. """ - def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None, asset_store=None): + def __init__( + self, + color_strip_store, + live_stream_manager, + audio_capture_manager=None, + audio_source_store=None, + audio_template_store=None, + sync_clock_manager=None, + value_stream_manager=None, + cspt_store=None, + gradient_store=None, + weather_manager=None, + asset_store=None, + ): """ Args: color_strip_store: ColorStripStore for resolving source configs @@ -166,17 +179,30 @@ class ColorStripStreamManager: if not source.sharable: if source.source_type == "audio": from wled_controller.core.processing.audio_stream import AudioColorStripStream - css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store) + + css_stream = AudioColorStripStream( + source, + self._audio_capture_manager, + self._audio_source_store, + self._audio_template_store, + ) elif source.source_type == "composite": - from wled_controller.core.processing.composite_stream import CompositeColorStripStream - css_stream = CompositeColorStripStream(source, self, self._value_stream_manager, self._cspt_store) + from wled_controller.core.processing.composite_stream import ( + CompositeColorStripStream, + ) + + css_stream = CompositeColorStripStream( + source, self, self._value_stream_manager, self._cspt_store + ) elif source.source_type == "mapped": from wled_controller.core.processing.mapped_stream import MappedColorStripStream + css_stream = MappedColorStripStream(source, self) elif source.source_type == "processed": css_stream = ProcessedColorStripStream(source, self, self._cspt_store) elif source.source_type == "weather": from wled_controller.core.processing.weather_stream import WeatherColorStripStream + css_stream = WeatherColorStripStream(source, self._weather_manager) else: stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) @@ -196,7 +222,9 @@ class ColorStripStreamManager: css_stream.start() key = f"{css_id}:{consumer_id}" if consumer_id else css_id self._streams[key] = _ColorStripEntry( - stream=css_stream, ref_count=1, picture_source_ids=[], + stream=css_stream, + ref_count=1, + picture_source_ids=[], clock_id=acquired_clock_id, ) logger.info(f"Created {source.source_type} stream {key}") @@ -209,8 +237,44 @@ class ColorStripStreamManager: logger.info(f"Reusing stream {css_id} (ref_count={entry.ref_count})") return entry.stream + # Key Colors: sharable, needs a single LiveStream + from wled_controller.storage.color_strip_source import KeyColorsColorStripSource + + if isinstance(source, KeyColorsColorStripSource): + ps_id = source.picture_source_id + if not ps_id: + raise ValueError(f"Key colors source {css_id} has no picture_source_id assigned") + try: + live_stream = self._live_stream_manager.acquire(ps_id) + except Exception as e: + raise ValueError( + f"Failed to acquire live stream for key_colors {css_id}: {e}" + ) from e + try: + from wled_controller.core.processing.kc_color_strip_stream import ( + KeyColorsColorStripStream, + ) + + css_stream = KeyColorsColorStripStream(live_stream, source) + css_stream.start() + except Exception as e: + self._live_stream_manager.release(ps_id) + raise RuntimeError(f"Failed to start key_colors stream {css_id}: {e}") from e + + self._streams[css_id] = _ColorStripEntry( + stream=css_stream, + ref_count=1, + picture_source_ids=[ps_id], + ) + logger.info(f"Created key_colors stream {css_id} ({len(source.rectangles)} rects)") + return css_stream + # Create new picture stream — needs LiveStream(s) from the capture pipeline - from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource + from wled_controller.storage.color_strip_source import ( + PictureColorStripSource, + AdvancedPictureColorStripSource, + ) + if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): raise ValueError( f"Unsupported sharable source type '{source.source_type}' for {css_id}" @@ -222,9 +286,7 @@ class ColorStripStreamManager: # Simple mode: use the CSS source's single picture_source_id ps_id = getattr(source, "picture_source_id", "") if not ps_id: - raise ValueError( - f"Color strip source {css_id} has no picture_source_id assigned" - ) + raise ValueError(f"Color strip source {css_id} has no picture_source_id assigned") required_ps_ids = [ps_id] # Acquire all required live streams (with rollback on failure) @@ -235,9 +297,7 @@ class ColorStripStreamManager: except Exception as e: for ps_id in acquired: self._live_stream_manager.release(ps_id) - raise ValueError( - f"Failed to acquire live streams for source {css_id}: {e}" - ) from e + raise ValueError(f"Failed to acquire live streams for source {css_id}: {e}") from e # Create stream (single LiveStream for simple, dict for advanced) try: @@ -314,10 +374,7 @@ class ColorStripStreamManager: new_source: Updated ColorStripSource config """ # Find all entries: shared key OR per-consumer keys (css_id:xxx) - matching_keys = [ - k for k in self._streams - if k == css_id or k.startswith(f"{css_id}:") - ] + matching_keys = [k for k in self._streams if k == css_id or k.startswith(f"{css_id}:")] if not matching_keys: return # Stream not running; config will be used on next acquire @@ -347,7 +404,11 @@ class ColorStripStreamManager: self._release_clock(source_id, entry.stream, clock_id=old_clock_id) # Track picture source changes for future reference counting - from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource + from wled_controller.storage.color_strip_source import ( + PictureColorStripSource, + AdvancedPictureColorStripSource, + ) + if isinstance(new_source, (PictureColorStripSource, AdvancedPictureColorStripSource)): new_ps_ids = new_source.calibration.get_required_picture_source_ids() if not new_ps_ids: @@ -424,7 +485,4 @@ class ColorStripStreamManager: 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() - ] + return [{"id": sid, "ref_count": entry.ref_count} for sid, entry in self._streams.items()] diff --git a/server/src/wled_controller/core/processing/ha_light_target_processor.py b/server/src/wled_controller/core/processing/ha_light_target_processor.py index 34021b6..c54863b 100644 --- a/server/src/wled_controller/core/processing/ha_light_target_processor.py +++ b/server/src/wled_controller/core/processing/ha_light_target_processor.py @@ -144,21 +144,33 @@ class HALightTargetProcessor(TargetProcessor): logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}") def get_state(self) -> dict: + uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0 return { "target_id": self._target_id, + "processing": self._is_running, "ha_source_id": self._ha_source_id, "css_id": self._css_id, "is_running": self._is_running, "ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False, "light_count": len(self._light_mappings), "update_rate": self._update_rate, + "fps_actual": self._update_rate if self._is_running else None, + "fps_target": self._update_rate, + "uptime_seconds": uptime, } def get_metrics(self) -> dict: + uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0 return { "target_id": self._target_id, - "uptime": time.monotonic() - self._start_time if self._start_time else 0, - "update_rate": self._update_rate, + "processing": self._is_running, + "fps_actual": self._update_rate if self._is_running else None, + "fps_target": self._update_rate, + "uptime_seconds": uptime, + "frames_processed": 0, + "errors_count": 0, + "last_error": None, + "last_update": None, } async def _processing_loop(self) -> None: diff --git a/server/src/wled_controller/core/processing/kc_color_strip_stream.py b/server/src/wled_controller/core/processing/kc_color_strip_stream.py new file mode 100644 index 0000000..b19e830 --- /dev/null +++ b/server/src/wled_controller/core/processing/kc_color_strip_stream.py @@ -0,0 +1,195 @@ +"""Key Colors color strip stream — extracts dominant colors from screen rectangles. + +Produces an np.ndarray(N, 3) where N = number of rectangles. +Each "LED" is the average/median/dominant color of one screen region. +""" + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING, List, Optional + +import cv2 +import numpy as np + +from wled_controller.core.capture.screen_capture import ( + calculate_average_color, + calculate_dominant_color, + calculate_median_color, +) +from wled_controller.utils import get_logger +from wled_controller.utils.timer import high_resolution_timer + +if TYPE_CHECKING: + from wled_controller.core.processing.live_stream import LiveStream + from wled_controller.storage.color_strip_source import KeyColorsColorStripSource + +logger = get_logger(__name__) + +KC_WORK_SIZE = (160, 90) # (width, height) — small enough for fast color calc + +_CALC_FNS = { + "average": calculate_average_color, + "median": calculate_median_color, + "dominant": calculate_dominant_color, +} + + +class KeyColorsColorStripStream: + """Streams N colors extracted from screen rectangles. + + Implements the same interface as ColorStripStream so it can be used + by any target processor via ColorStripStreamManager. + """ + + def __init__( + self, + live_stream: LiveStream, + source: KeyColorsColorStripSource, + ) -> None: + self._live_stream = live_stream + self._source = source + + # Pre-compute rectangle pixel bounds at KC_WORK_SIZE + kc_w, kc_h = KC_WORK_SIZE + self._rect_names: List[str] = [] + self._rect_bounds: List[tuple] = [] + for rect in source.rectangles: + self._rect_names.append(rect.name) + px_x = max(0, int(rect.x * kc_w)) + px_y = max(0, int(rect.y * kc_h)) + px_w = max(1, int(rect.width * kc_w)) + px_h = max(1, int(rect.height * kc_h)) + px_x = min(px_x, kc_w - 1) + px_y = min(px_y, kc_h - 1) + px_w = min(px_w, kc_w - px_x) + px_h = min(px_h, kc_h - px_y) + self._rect_bounds.append((px_y, px_y + px_h, px_x, px_x + px_w)) + + n = len(source.rectangles) + self._led_count = n + self._latest_colors: Optional[np.ndarray] = None + self._colors_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + + # ── Public interface (matches ColorStripStream) ── + + @property + def led_count(self) -> int: + return self._led_count + + @property + def target_fps(self) -> int: + return self._live_stream.target_fps if self._live_stream else 10 + + @property + def is_animated(self) -> bool: + return True + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._latest_colors + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._processing_loop, daemon=True, name=f"kc-css-{self._source.id[:8]}" + ) + self._thread.start() + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + self._thread = None + self._latest_colors = None + + def update_source(self, source: KeyColorsColorStripSource) -> None: + """Hot-update source config (rectangles, interpolation, smoothing, brightness).""" + self._source = source + # Recompute rectangle bounds + kc_w, kc_h = KC_WORK_SIZE + self._rect_names = [] + self._rect_bounds = [] + for rect in source.rectangles: + self._rect_names.append(rect.name) + px_x = max(0, int(rect.x * kc_w)) + px_y = max(0, int(rect.y * kc_h)) + px_w = max(1, int(rect.width * kc_w)) + px_h = max(1, int(rect.height * kc_h)) + px_x = min(px_x, kc_w - 1) + px_y = min(px_y, kc_h - 1) + px_w = min(px_w, kc_w - px_x) + px_h = min(px_h, kc_h - px_y) + self._rect_bounds.append((px_y, px_y + px_h, px_x, px_x + px_w)) + self._led_count = len(source.rectangles) + + # ── Private: processing loop ── + + def _processing_loop(self) -> None: + """Background thread: capture → extract → smooth → cache.""" + prev_capture = None + prev_colors_arr: Optional[np.ndarray] = None + frame_time = 1.0 / max(1, self.target_fps) + + logger.info(f"KC CSS stream started: {self._source.id} ({len(self._rect_names)} rects)") + + with high_resolution_timer(): + while self._running: + try: + capture = self._live_stream.get_latest_frame() + if capture is None or capture is prev_capture: + time.sleep(frame_time) + continue + prev_capture = capture + + # Read source config (hot-update safe) + src = self._source + calc_fn = _CALC_FNS.get(src.interpolation_mode, calculate_average_color) + + # Downsample + small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA) + + # Extract colors per rectangle + n = len(self._rect_names) + if n == 0: + time.sleep(frame_time) + continue + + colors_arr = np.empty((n, 3), dtype=np.float64) + for i, (y1, y2, x1, x2) in enumerate(self._rect_bounds): + colors_arr[i] = calc_fn(small[y1:y2, x1:x2]) + + # Temporal smoothing + smoothing = src.smoothing + if ( + prev_colors_arr is not None + and smoothing > 0 + and prev_colors_arr.shape == colors_arr.shape + ): + colors_arr = colors_arr * (1 - smoothing) + prev_colors_arr * smoothing + prev_colors_arr = colors_arr + + # Apply brightness + brightness = src.brightness + if brightness < 1.0: + output = colors_arr * brightness + else: + output = colors_arr + + result = np.clip(output, 0, 255).astype(np.uint8) + + with self._colors_lock: + self._latest_colors = result + + time.sleep(frame_time) + + except Exception as e: + logger.error(f"KC CSS stream error: {e}") + time.sleep(0.5) + + logger.info(f"KC CSS stream stopped: {self._source.id}") diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index e19d3fe..914948c 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -21,7 +21,6 @@ from wled_controller.core.processing.target_processor import ( TargetProcessor, ) from wled_controller.core.processing.wled_target_processor import WledTargetProcessor -from wled_controller.core.processing.kc_target_processor import KCTargetProcessor from wled_controller.core.processing.auto_restart import ( AutoRestartMixin, RestartState as _RestartState, @@ -36,7 +35,6 @@ from wled_controller.storage.color_strip_processing_template_store import ( ) from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.gradient_store import GradientStore -from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.template_store import TemplateStore @@ -63,7 +61,6 @@ class ProcessorDependencies: picture_source_store: Optional[PictureSourceStore] = None capture_template_store: Optional[TemplateStore] = None pp_template_store: Optional[PostprocessingTemplateStore] = None - pattern_template_store: Optional[PatternTemplateStore] = None device_store: Optional[DeviceStore] = None color_strip_store: Optional[ColorStripStore] = None audio_source_store: Optional[AudioSourceStore] = None @@ -129,7 +126,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) self._picture_source_store = deps.picture_source_store self._capture_template_store = deps.capture_template_store self._pp_template_store = deps.pp_template_store - self._pattern_template_store = deps.pattern_template_store self._device_store = deps.device_store self._color_strip_store = deps.color_strip_store self._audio_source_store = deps.audio_source_store @@ -202,7 +198,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) picture_source_store=self._picture_source_store, capture_template_store=self._capture_template_store, 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, value_stream_manager=self._value_stream_manager, @@ -456,20 +451,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) self._processors[target_id] = proc logger.info(f"Registered target {target_id} for device {device_id}") - def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None: - """Register a key-colors target processor.""" - if target_id in self._processors: - raise ValueError(f"KC target {target_id} already registered") - - proc = KCTargetProcessor( - target_id=target_id, - picture_source_id=picture_source_id, - settings=settings, - ctx=self._build_context(), - ) - self._processors[target_id] = proc - logger.info(f"Registered KC target: {target_id}") - def add_ha_light_target( self, target_id: str, @@ -795,15 +776,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) proc = self._get_processor(target_id) proc.add_ws_client(ws) - def remove_kc_ws_client(self, target_id: str, ws) -> None: - proc = self._processors.get(target_id) - if proc: - proc.remove_ws_client(ws) - - def get_kc_latest_colors(self, target_id: str) -> Dict[str, Tuple[int, int, int]]: - proc = self._get_processor(target_id) - return proc.get_latest_colors() - def add_led_preview_client(self, target_id: str, ws) -> None: proc = self._get_processor(target_id) proc.add_led_preview_client(ws) diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index e419433..67b8eb0 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from wled_controller.storage.picture_source_store import PictureSourceStore 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.color_strip_processing_template_store import ( ColorStripProcessingTemplateStore, ) @@ -115,7 +114,6 @@ class TargetContext: picture_source_store: Optional["PictureSourceStore"] = None capture_template_store: Optional["TemplateStore"] = None pp_template_store: Optional["PostprocessingTemplateStore"] = None - pattern_template_store: Optional["PatternTemplateStore"] = None device_store: Optional["DeviceStore"] = None color_strip_stream_manager: Optional["ColorStripStreamManager"] = None value_stream_manager: Optional["ValueStreamManager"] = None diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 8552b9f..9ec8d99 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -23,7 +23,6 @@ from wled_controller.core.processing.processor_manager import ( 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.output_target_store import OutputTargetStore from wled_controller.storage.color_strip_store import ColorStripStore @@ -77,7 +76,6 @@ template_store = TemplateStore(db) pp_template_store = PostprocessingTemplateStore(db) picture_source_store = PictureSourceStore(db) output_target_store = OutputTargetStore(db) -pattern_template_store = PatternTemplateStore(db) color_strip_store = ColorStripStore(db) audio_source_store = AudioSourceStore(db) audio_template_store = AudioTemplateStore(db) @@ -105,7 +103,6 @@ processor_manager = ProcessorManager( picture_source_store=picture_source_store, capture_template_store=template_store, pp_template_store=pp_template_store, - pattern_template_store=pattern_template_store, device_store=device_store, color_strip_store=color_strip_store, audio_source_store=audio_source_store, @@ -195,7 +192,6 @@ async def lifespan(app: FastAPI): processor_manager, database=db, pp_template_store=pp_template_store, - pattern_template_store=pattern_template_store, picture_source_store=picture_source_store, output_target_store=output_target_store, color_strip_store=color_strip_store, @@ -237,6 +233,7 @@ async def lifespan(app: FastAPI): logger.info(f"Registered {len(devices)} devices for health monitoring") + # Migrate KC targets → key_colors CSS sources # Register output targets in processor manager targets = output_target_store.get_all_targets() registered_targets = 0 diff --git a/server/src/wled_controller/static/css/advanced-calibration.css b/server/src/wled_controller/static/css/advanced-calibration.css index 7589e22..2d273b9 100644 --- a/server/src/wled_controller/static/css/advanced-calibration.css +++ b/server/src/wled_controller/static/css/advanced-calibration.css @@ -134,6 +134,11 @@ line-height: 1; } +.btn-micro .icon { + width: 12px; + height: 12px; +} + .btn-micro:hover { background: rgba(255, 255, 255, 0.08); border-color: var(--border-color); diff --git a/server/src/wled_controller/static/css/automations.css b/server/src/wled_controller/static/css/automations.css index 45babe5..a5b74d0 100644 --- a/server/src/wled_controller/static/css/automations.css +++ b/server/src/wled_controller/static/css/automations.css @@ -83,8 +83,18 @@ border: none; color: var(--danger-color, #dc3545); cursor: pointer; - font-size: 1rem; padding: 2px 6px; + opacity: 0.7; + transition: opacity 0.15s; +} + +.btn-remove-condition:hover { + opacity: 1; +} + +.btn-remove-condition .icon { + width: 16px; + height: 16px; } .condition-fields { diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 2f86ccc..8d79230 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -453,7 +453,6 @@ body.cs-drag-active .card-drag-handle { background: none; border: none; color: var(--text-muted); - font-size: 1rem; width: 28px; height: 28px; display: flex; @@ -464,6 +463,11 @@ body.cs-drag-active .card-drag-handle { transition: color 0.2s, background 0.2s; } +.card-remove-btn .icon { + width: 16px; + height: 16px; +} + .card-remove-btn:hover { color: var(--danger-color); background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */ diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 9a58810..5199dbd 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -956,11 +956,15 @@ background: none; border: none; cursor: pointer; - font-size: 1.1rem; padding: 0 4px; line-height: 1; } +.btn-icon-inline .icon { + width: 16px; + height: 16px; +} + .btn-danger-text { color: var(--danger-color, #f44336); } @@ -1474,13 +1478,17 @@ } .gradient-stop-remove-btn { - font-size: 0.75rem; padding: 0; width: 26px; height: 26px; flex: 0 0 26px; } +.gradient-stop-remove-btn .icon { + width: 14px; + height: 14px; +} + .gradient-stop-bidir-btn.active { background: var(--primary-color); color: var(--primary-contrast); @@ -1492,6 +1500,89 @@ flex: 1; } +/* ── HA Light Mapping rows ────────────────────────────────── */ + +#ha-light-mappings-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 4px; +} + +.ha-light-mapping-row { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + background: var(--bg-secondary, var(--bg-color)); +} + +.ha-mapping-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.ha-mapping-header .ha-mapping-label { + font-weight: 600; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); +} + +.ha-mapping-header .ha-mapping-label .icon { + width: 16px; + height: 16px; + opacity: 0.7; +} + +.btn-remove-mapping { + background: none; + border: none; + color: var(--danger-color, #dc3545); + cursor: pointer; + padding: 2px 6px; + opacity: 0.7; + transition: opacity 0.15s; +} + +.btn-remove-mapping .icon { + width: 16px; + height: 16px; +} + +.btn-remove-mapping:hover { + opacity: 1; +} + +.ha-mapping-fields { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ha-mapping-field label { + display: block; + font-size: 0.85rem; + margin-bottom: 3px; + color: var(--text-muted); +} + +.ha-mapping-range-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.ha-mapping-range-row label { + display: block; + font-size: 0.85rem; + margin-bottom: 3px; + color: var(--text-muted); +} + /* ── Custom gradient presets list ───────────────────────────── */ .custom-presets-list { @@ -1547,7 +1638,6 @@ } .color-cycle-remove-btn { - font-size: 0.6rem; padding: 0; width: 36px; height: 14px; @@ -1555,6 +1645,11 @@ line-height: 1; } +.color-cycle-remove-btn .icon { + width: 12px; + height: 12px; +} + .color-cycle-add-btn { width: 36px; height: 28px; @@ -1566,31 +1661,79 @@ /* ── Notification per-app overrides (unified color + sound) ──── */ -.notif-override-row { - display: grid; - grid-template-columns: 1fr auto auto auto; - gap: 4px 4px; - align-items: center; - margin-bottom: 6px; - padding-bottom: 6px; - border-bottom: 1px solid var(--border-color); +#notification-app-overrides-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 4px; } -.notif-override-row .notif-override-name, -.notif-override-row .notif-override-sound { +.notif-override-row { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + background: var(--bg-secondary, var(--bg-color)); +} + +.notif-override-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.notif-override-label { + font-weight: 600; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); +} + +.notif-override-label .icon { + width: 16px; + height: 16px; + opacity: 0.7; +} + +.btn-remove-override { + background: none; + border: none; + color: var(--danger-color, #dc3545); + cursor: pointer; + padding: 2px 6px; + opacity: 0.7; + transition: opacity 0.15s; +} + +.btn-remove-override:hover { + opacity: 1; +} + +.btn-remove-override .icon { + width: 16px; + height: 16px; +} + +.notif-override-fields { + display: flex; + flex-direction: column; + gap: 8px; +} + +.notif-override-app-row { + display: flex; + align-items: center; + gap: 6px; +} + +.notif-override-app-row .notif-override-name { + flex: 1; min-width: 0; } -/* Sound select spans the first column, volume spans browse+color columns */ -.notif-override-row .notif-override-sound { - grid-column: 1; -} -.notif-override-row .notif-override-volume { - grid-column: 2 / 4; - width: 100%; -} - -.notif-override-row .notif-override-color { +.notif-override-app-row .notif-override-color { width: 26px; height: 26px; border: 1px solid var(--border-color); @@ -1598,6 +1741,24 @@ padding: 1px; cursor: pointer; background: transparent; + flex-shrink: 0; +} + +.notif-override-sound-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + align-items: center; +} + +.notif-override-sound-row .notif-override-sound, +.notif-override-sound-row .entity-select-trigger { + min-width: 0; + width: 100%; +} + +.notif-override-sound-row .notif-override-volume { + width: 100%; } @@ -1671,7 +1832,7 @@ #composite-layers-list { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; margin-bottom: 8px; } @@ -1679,10 +1840,10 @@ display: flex; flex-direction: column; gap: 4px; - padding: 6px 8px; + padding: 10px; border: 1px solid var(--border-color); - border-radius: 4px; - background: var(--card-bg); + border-radius: 6px; + background: var(--bg-secondary, var(--bg-color)); } .composite-layer-header { @@ -1802,22 +1963,20 @@ .composite-layer-remove-btn { background: none; border: none; - color: var(--text-muted); - font-size: 0.85rem; - width: 26px; - height: 26px; - flex: 0 0 26px; - display: flex; - align-items: center; - justify-content: center; + color: var(--danger-color, #dc3545); cursor: pointer; - border-radius: 4px; - transition: color 0.2s, background 0.2s; + padding: 2px 6px; + opacity: 0.7; + transition: opacity 0.15s; +} + +.composite-layer-remove-btn .icon { + width: 16px; + height: 16px; } .composite-layer-remove-btn:hover { - color: var(--danger-color); - background: color-mix(in srgb, var(--danger-color) 10%, transparent); + opacity: 1; } .composite-layer-range-toggle-label { diff --git a/server/src/wled_controller/static/css/patterns.css b/server/src/wled_controller/static/css/patterns.css index 9fcb2aa..5ecc5b2 100644 --- a/server/src/wled_controller/static/css/patterns.css +++ b/server/src/wled_controller/static/css/patterns.css @@ -348,7 +348,6 @@ background: none; border: none; color: var(--text-muted); - font-size: 0.9rem; cursor: pointer; padding: 2px 4px; border-radius: 4px; @@ -356,6 +355,11 @@ transition: color 0.2s, background 0.2s; } +.pattern-rect-row .pattern-rect-remove-btn .icon { + width: 14px; + height: 14px; +} + .pattern-rect-row .pattern-rect-remove-btn:hover { color: var(--danger-color); background: rgba(244, 67, 54, 0.1); diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index 825b787..d9ecb8c 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -73,20 +73,11 @@ import { renderCSPTModalFilterList, } from './features/streams.ts'; import { - createKCTargetCard, testKCTarget, - showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor, - deleteKCTarget, disconnectAllKCWebSockets, - updateKCBrightnessLabel, saveKCBrightness, - cloneKCTarget, -} from './features/kc-targets.ts'; -import { - createPatternTemplateCard, showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal, - savePatternTemplate, deletePatternTemplate, + savePatternTemplate, renderPatternRectList, selectPatternRect, updatePatternRect, addPatternRect, deleteSelectedPatternRect, removePatternRect, capturePatternBackground, - clonePatternTemplate, } from './features/pattern-templates.ts'; import { loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal, @@ -109,7 +100,7 @@ import { loadTargetsTab, switchTargetSubTab, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, startTargetProcessing, stopTargetProcessing, - stopAllLedTargets, stopAllKCTargets, + stopAllLedTargets, startTargetOverlay, stopTargetOverlay, deleteTarget, cloneTarget, toggleLedPreview, disconnectAllLedPreviewWS, @@ -357,26 +348,11 @@ Object.assign(window, { closeTestAudioTemplateModal, startAudioTemplateTest, - // kc-targets - createKCTargetCard, - testKCTarget, - showKCEditor, - closeKCEditorModal, - forceCloseKCEditorModal, - saveKCEditor, - deleteKCTarget, - disconnectAllKCWebSockets, - updateKCBrightnessLabel, - saveKCBrightness, - cloneKCTarget, - - // pattern-templates - createPatternTemplateCard, + // pattern-templates (canvas editor — used by key_colors CSS source) showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal, savePatternTemplate, - deletePatternTemplate, renderPatternRectList, selectPatternRect, updatePatternRect, @@ -384,7 +360,6 @@ Object.assign(window, { deleteSelectedPatternRect, removePatternRect, capturePatternBackground, - clonePatternTemplate, // automations loadAutomations, @@ -427,7 +402,6 @@ Object.assign(window, { startTargetProcessing, stopTargetProcessing, stopAllLedTargets, - stopAllKCTargets, startTargetOverlay, stopTargetOverlay, deleteTarget, @@ -642,7 +616,6 @@ window.addEventListener('beforeunload', () => { } stopConnectionMonitor(); stopEventsWS(); - disconnectAllKCWebSockets(); disconnectAllLedPreviewWS(); }); diff --git a/server/src/wled_controller/static/js/core/card-colors.ts b/server/src/wled_controller/static/js/core/card-colors.ts index ec99fb0..d514297 100644 --- a/server/src/wled_controller/static/js/core/card-colors.ts +++ b/server/src/wled_controller/static/js/core/card-colors.ts @@ -20,6 +20,7 @@ */ import { createColorPicker, registerColorPicker } from './color-picker.ts'; +import { ICON_TRASH } from './icons.ts'; const STORAGE_KEY = 'cardColors'; const DEFAULT_SWATCH = '#808080'; @@ -115,7 +116,7 @@ export function wrapCard({
${topButtons} - ${removeOnclick ? `` : ''} + ${removeOnclick ? `` : ''}
${content}
diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index 51c90dd..36c1d1c 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -28,6 +28,7 @@ const _colorStripTypeIcons = { candlelight: _svg(P.flame), weather: _svg(P.cloudSun), processed: _svg(P.sparkles), + key_colors: _svg(P.palette), }; const _valueSourceTypeIcons = { static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music), diff --git a/server/src/wled_controller/static/js/features/advanced-calibration.ts b/server/src/wled_controller/static/js/features/advanced-calibration.ts index ca42893..1225fe5 100644 --- a/server/src/wled_controller/static/js/features/advanced-calibration.ts +++ b/server/src/wled_controller/static/js/features/advanced-calibration.ts @@ -11,7 +11,7 @@ import { t } from '../core/i18n.ts'; import { showToast } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { EntitySelect } from '../core/entity-palette.ts'; -import { getPictureSourceIcon } from '../core/icons.ts'; +import { getPictureSourceIcon, ICON_TRASH } from '../core/icons.ts'; import type { Calibration, CalibrationLine, PictureSource } from '../types.ts'; /* ── Types ──────────────────────────────────────────────────── */ @@ -455,7 +455,7 @@ function _renderLineList(): void { - + `; container.appendChild(div); diff --git a/server/src/wled_controller/static/js/features/color-strips-composite.ts b/server/src/wled_controller/static/js/features/color-strips-composite.ts index 8be1803..28aca1c 100644 --- a/server/src/wled_controller/static/js/features/color-strips-composite.ts +++ b/server/src/wled_controller/static/js/features/color-strips-composite.ts @@ -8,7 +8,7 @@ import { _cachedValueSources, _cachedCSPTemplates } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { getColorStripIcon, getValueSourceIcon, - ICON_SPARKLES, + ICON_SPARKLES, ICON_TRASH, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { IconSelect } from '../core/icon-select.ts'; @@ -124,7 +124,7 @@ export function compositeRenderList() { ${canRemove ? `` + onclick="compositeRemoveLayer(${i})" title="${t('common.delete')}">${ICON_TRASH}` : ''}
diff --git a/server/src/wled_controller/static/js/features/color-strips-notification.ts b/server/src/wled_controller/static/js/features/color-strips-notification.ts index 7e1ab3a..e689a3c 100644 --- a/server/src/wled_controller/static/js/features/color-strips-notification.ts +++ b/server/src/wled_controller/static/js/features/color-strips-notification.ts @@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast } from '../core/ui.ts'; import { - ICON_SEARCH, ICON_CLONE, getAssetTypeIcon, + ICON_SEARCH, ICON_CLONE, ICON_TRASH, getAssetTypeIcon, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { IconSelect } from '../core/icon-select.ts'; @@ -112,17 +112,26 @@ function _overridesRenderList() { const volPct = entry.volume ?? 100; return `
- - - - - - +
+ ${_icon(P.bellRing)} #${i + 1} + +
+
+
+ + + +
+
+ + +
+
`; }).join(''); diff --git a/server/src/wled_controller/static/js/features/color-strips-test.ts b/server/src/wled_controller/static/js/features/color-strips-test.ts index f22f533..7059958 100644 --- a/server/src/wled_controller/static/js/features/color-strips-test.ts +++ b/server/src/wled_controller/static/js/features/color-strips-test.ts @@ -6,7 +6,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { colorStripSourcesCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; -import { showToast } from '../core/ui.ts'; +import { showToast, openLightbox, closeLightbox } from '../core/ui.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { getColorStripIcon, @@ -143,15 +143,145 @@ function _populateCssTestSourceSelector(preselectId: any) { export function testColorStrip(sourceId: string) { _cssTestCSPTMode = false; _cssTestCSPTId = null; - // Detect api_input type + // Detect source type const sources = (colorStripSourcesCache.data || []) as any[]; const src = sources.find(s => s.id === sourceId); + + // Key Colors sources use a frame + rectangle overlay test (not the strip WS renderer) + if (src?.source_type === 'key_colors') { + _testKeyColorsSource(sourceId); + return; + } + _cssTestIsApiInput = src?.source_type === 'api_input'; // Populate input source selector with current source preselected _populateCssTestSourceSelector(sourceId); _openTestModal(sourceId); } +let _kcTestWs: WebSocket | null = null; +const _kcTestCanvas = document.createElement('canvas'); +const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff']; + +function _testKeyColorsSource(sourceId: string) { + // Show lightbox with spinner + const lightbox = document.getElementById('image-lightbox')!; + const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null; + const img = document.getElementById('lightbox-image') as HTMLImageElement; + img.src = ''; + if (spinner) spinner.style.display = ''; + document.getElementById('lightbox-stats')!.style.display = 'none'; + lightbox.classList.add('active'); + + // Close any previous WS + if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } + + // Build WS URL + const loc = window.location; + const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; + const apiKey = (window as any).apiKey || localStorage.getItem('wled_api_key') || ''; + const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?token=${encodeURIComponent(apiKey)}&fps=5&preview_width=960`; + + const ws = new WebSocket(wsUrl); + _kcTestWs = ws; + + ws.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.type === 'frame') { + _renderKCTestFrame(data); + } + } catch {} + }; + + ws.onerror = () => { + showToast('Key Colors test connection failed', 'error'); + closeLightbox(); + }; + + ws.onclose = () => { + _kcTestWs = null; + }; + + // Stop WS when lightbox closes + const origClose = (window as any).closeLightbox; + lightbox.onclick = (e) => { + if ((e.target as HTMLElement).closest('.lightbox-content')) return; + if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } + closeLightbox(); + }; +} + +function _renderKCTestFrame(data: any) { + const rects = data.rectangles || []; + const mode = data.interpolation_mode || 'average'; + + // Draw frame + rectangles onto offscreen canvas + const tmpImg = new Image(); + tmpImg.onload = () => { + _kcTestCanvas.width = tmpImg.naturalWidth; + _kcTestCanvas.height = tmpImg.naturalHeight; + const ctx = _kcTestCanvas.getContext('2d')!; + ctx.drawImage(tmpImg, 0, 0); + + rects.forEach((r: any, i: number) => { + const x = r.x * _kcTestCanvas.width; + const y = r.y * _kcTestCanvas.height; + const w = r.width * _kcTestCanvas.width; + const h = r.height * _kcTestCanvas.height; + const borderColor = BORDER_COLORS[i % BORDER_COLORS.length]; + + ctx.fillStyle = r.color.hex + '33'; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = borderColor; + ctx.lineWidth = 3; + ctx.strokeRect(x, y, w, h); + + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px sans-serif'; + ctx.shadowColor = '#000'; + ctx.shadowBlur = 3; + ctx.fillText(r.name, x + 4, y + 18); + ctx.shadowBlur = 0; + + ctx.fillStyle = r.color.hex; + ctx.fillRect(x + w - 24, y + 2, 22, 22); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1; + ctx.strokeRect(x + w - 24, y + 2, 22, 22); + }); + + // Update lightbox image directly (use data URL for full-size display) + const lbImg = document.getElementById('lightbox-image') as HTMLImageElement; + if (lbImg) { + lbImg.src = _kcTestCanvas.toDataURL('image/jpeg', 0.9); + lbImg.style.display = ''; + lbImg.style.maxWidth = '100%'; + lbImg.style.width = '100%'; + } + + // Hide spinner after first frame + const spinner = document.querySelector('#image-lightbox .lightbox-spinner') as HTMLElement | null; + if (spinner) spinner.style.display = 'none'; + + // Update swatches + const statsEl = document.getElementById('lightbox-stats')!; + const swatches = rects.map((r: any) => + `
+ + ${escapeHtml(r.name)} + ${r.color.hex} +
` + ).join(''); + statsEl.innerHTML = ` +
${swatches}
+
Mode: ${mode} | ${rects.length} region${rects.length !== 1 ? 's' : ''}
+ `; + statsEl.style.display = ''; + }; + tmpImg.src = data.image; +} + export async function testCSPT(templateId: string) { _cssTestCSPTMode = true; _cssTestCSPTId = templateId; diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index 4796b67..ca26f40 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -13,7 +13,7 @@ import { ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST, - ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, + ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_TRASH, ICON_PATTERN_TEMPLATE, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; @@ -58,6 +58,8 @@ class CSSEditorModal extends Modal { onForceClose() { if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; } + if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; } + if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; } compositeDestroyEntitySelects(); } @@ -110,6 +112,7 @@ class CSSEditorModal extends Modal { candlelight_speed: (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value, processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value, processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value, + kc_rects: JSON.stringify(_kcEditorRects), tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []), }; } @@ -125,13 +128,79 @@ let _cssAudioSourceEntitySelect: any = null; let _cssClockEntitySelect: any = null; let _processedInputEntitySelect: any = null; let _processedTemplateEntitySelect: any = null; +let _kcPictureSourceEntitySelect: any = null; +let _kcInterpolationIconSelect: any = null; + +// ── Key Colors rectangle editor state ── +let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = []; + +function _renderKCRectSummary(): void { + const el = document.getElementById('css-editor-kc-rect-summary'); + if (!el) return; + if (_kcEditorRects.length === 0) { + el.textContent = t('color_strip.key_colors.no_rects'); + } else { + const names = _kcEditorRects.map(r => r.name).join(', '); + el.textContent = `${_kcEditorRects.length} region${_kcEditorRects.length !== 1 ? 's' : ''}: ${names}`; + } +} + +function _openKCRegionEditor(): void { + // Open the pattern template canvas editor in inline mode + const { showPatternTemplateEditor } = window as any; + if (!showPatternTemplateEditor) return; + showPatternTemplateEditor(null, null, { + rects: _kcEditorRects.map(r => ({ ...r })), + onSave: (rects: any[]) => { + _kcEditorRects = rects; + _renderKCRectSummary(); + }, + }); +} + +(window as any)._openKCRegionEditor = _openKCRegionEditor; + +async function configureKCRegions(sourceId: string): Promise { + // Fetch source to get current rectangles + try { + const resp = await fetchWithAuth(`/color-strip-sources/${sourceId}`); + if (!resp.ok) throw new Error('Failed to load source'); + const source = await resp.json(); + const rects = source.rectangles || []; + + const { showPatternTemplateEditor } = window as any; + if (!showPatternTemplateEditor) return; + showPatternTemplateEditor(null, null, { + rects: rects.map((r: any) => ({ ...r })), + onSave: async (newRects: any[]) => { + // Save rectangles back to the CSS source + try { + const putResp = await fetchWithAuth(`/color-strip-sources/${sourceId}`, { + method: 'PUT', + body: JSON.stringify({ rectangles: newRects }), + }); + if (!putResp.ok) throw new Error('Failed to save'); + showToast(t('color_strip.updated'), 'success'); + colorStripSourcesCache.invalidate(); + if (window.loadPictureSources) await window.loadPictureSources(); + } catch (e: any) { + showToast(e.message, 'error'); + } + }, + }); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} +(window as any).configureKCRegions = configureKCRegions; /* ── Icon-grid type selector ──────────────────────────────────── */ const CSS_TYPE_KEYS = [ 'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped', 'audio', - 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', + 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors', ]; function _buildCSSTypeItems() { @@ -178,6 +247,7 @@ const CSS_SECTION_MAP: Record = { 'candlelight': 'css-editor-candlelight-section', 'weather': 'css-editor-weather-section', 'processed': 'css-editor-processed-section', + 'key_colors': 'css-editor-key-colors-section', }; const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))]; @@ -554,7 +624,7 @@ function _renderCustomPresetList() { title="${t('color_strip.gradient.preset.apply')}">✓ + title="${t('common.delete')}">${ICON_TRASH}
`; }).join(''); } @@ -710,7 +780,7 @@ function _colorCycleRenderList() { ${canRemove ? `` + onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}` : `
`}
`).join('') + `
`; @@ -778,7 +848,7 @@ function _mappedRenderList() {
#${i + 1} + onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">${ICON_TRASH}
@@ -969,7 +1039,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: { const NON_PICTURE_TYPES = new Set([ 'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped', - 'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', + 'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors', ]); const CSS_CARD_RENDERERS: Record = { @@ -1115,6 +1185,20 @@ const CSS_CARD_RENDERERS: Record = { ${clockBadge} `; }, + key_colors: (source, { pictureSourceMap }) => { + const rectCount = (source.rectangles || []).length; + const mode = source.interpolation_mode || 'average'; + const ps = pictureSourceMap && source.picture_source_id ? pictureSourceMap[source.picture_source_id] : null; + const psName = ps?.name || '—'; + const psLink = ps + ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-streams','data-stream-id','${source.picture_source_id}')` + : ''; + return ` + ${ICON_LINK_SOURCE} ${escapeHtml(psName)} + ${ICON_PALETTE} ${rectCount} region${rectCount !== 1 ? 's' : ''} + ${mode} + `; + }, processed: (source) => { const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id); const inputName = inputSrc?.name || source.input_source_id || '—'; @@ -1195,6 +1279,10 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap: const notifHistoryBtn = isNotification ? `` : ''; + const isKeyColors = source.source_type === 'key_colors'; + const regionsBtn = isKeyColors + ? `` + : ''; const testPreviewBtn = ``; return wrapCard({ @@ -1215,7 +1303,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap: actions: ` - ${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`, + ${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`, }); } @@ -1658,6 +1746,97 @@ const _typeHandlers: Record any; reset: (... }; }, }, + key_colors: { + async load(css) { + // Populate and wire picture source EntitySelect + const sourceSelect = document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement; + const sources = await streamsCache.fetch().catch((): any[] => []); + sourceSelect.innerHTML = sources.map((s: any) => + `` + ).join(''); + if (_kcPictureSourceEntitySelect) _kcPictureSourceEntitySelect.destroy(); + _kcPictureSourceEntitySelect = new EntitySelect({ + target: sourceSelect, + getItems: () => sources.map((s: any) => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type })), + placeholder: t('palette.search'), + }); + + // Wire interpolation mode IconSelect + const interpSelect = document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement; + interpSelect.value = css.interpolation_mode || 'average'; + if (_kcInterpolationIconSelect) _kcInterpolationIconSelect.destroy(); + _kcInterpolationIconSelect = new IconSelect({ + target: interpSelect, + items: [ + { value: 'average', icon: `${P.palette}`, label: t('color_strip.key_colors.mode.average'), desc: t('color_strip.key_colors.mode.average.desc') }, + { value: 'median', icon: `${P.palette}`, label: t('color_strip.key_colors.mode.median'), desc: t('color_strip.key_colors.mode.median.desc') }, + { value: 'dominant', icon: `${P.palette}`, label: t('color_strip.key_colors.mode.dominant'), desc: t('color_strip.key_colors.mode.dominant.desc') }, + ], + columns: 1, + }); + + const smoothing = css.smoothing ?? 0.3; + (document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing; + (document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2); + (document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = css.brightness ?? 1.0; + (document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = parseFloat(css.brightness ?? 1.0).toFixed(2); + // Load rectangles + _kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r })); + _renderKCRectSummary(); + }, + async reset() { + const sourceSelect = document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement; + const sources = await streamsCache.fetch().catch((): any[] => []); + sourceSelect.innerHTML = sources.map((s: any) => + `` + ).join(''); + if (_kcPictureSourceEntitySelect) _kcPictureSourceEntitySelect.destroy(); + _kcPictureSourceEntitySelect = new EntitySelect({ + target: sourceSelect, + getItems: () => sources.map((s: any) => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type })), + placeholder: t('palette.search'), + }); + + const interpSelect = document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement; + interpSelect.value = 'average'; + if (_kcInterpolationIconSelect) _kcInterpolationIconSelect.destroy(); + _kcInterpolationIconSelect = new IconSelect({ + target: interpSelect, + items: [ + { value: 'average', icon: `${P.palette}`, label: t('color_strip.key_colors.mode.average'), desc: t('color_strip.key_colors.mode.average.desc') }, + { value: 'median', icon: `${P.palette}`, label: t('color_strip.key_colors.mode.median'), desc: t('color_strip.key_colors.mode.median.desc') }, + { value: 'dominant', icon: `${P.palette}`, label: t('color_strip.key_colors.mode.dominant'), desc: t('color_strip.key_colors.mode.dominant.desc') }, + ], + columns: 1, + }); + + (document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any; + (document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30'; + (document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = 1.0 as any; + (document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = '1.00'; + _kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }]; + _renderKCRectSummary(); + }, + getPayload(name) { + const psId = (document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement).value; + if (!psId) { + cssEditorModal.showError(t('color_strip.key_colors.error.no_source')); + return null; + } + if (_kcEditorRects.length === 0) { + cssEditorModal.showError(t('color_strip.key_colors.error.no_rects')); + return null; + } + return { + name, + picture_source_id: psId, + rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })), + interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value, + smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value), + brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value), + }; + }, + }, }; /* ── Editor open/close ────────────────────────────────────────── */ diff --git a/server/src/wled_controller/static/js/features/css-gradient-editor.ts b/server/src/wled_controller/static/js/features/css-gradient-editor.ts index a135c7c..6d415c5 100644 --- a/server/src/wled_controller/static/js/features/css-gradient-editor.ts +++ b/server/src/wled_controller/static/js/features/css-gradient-editor.ts @@ -6,6 +6,7 @@ */ import { t } from '../core/i18n.ts'; +import { ICON_TRASH } from '../core/icons.ts'; /* ── Types ─────────────────────────────────────────────────────── */ @@ -304,7 +305,7 @@ function _gradientRenderStopList(): void { style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color"> + title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>${ICON_TRASH} `; // Select row on mousedown — CSS-only update so child click events are not interrupted diff --git a/server/src/wled_controller/static/js/features/ha-light-targets.ts b/server/src/wled_controller/static/js/features/ha-light-targets.ts index 1e51bf3..dec6b13 100644 --- a/server/src/wled_controller/static/js/features/ha-light-targets.ts +++ b/server/src/wled_controller/static/js/features/ha-light-targets.ts @@ -2,17 +2,16 @@ * HA Light Targets — editor, cards, CRUD for Home Assistant light output targets. */ -import { _cachedHASources, haSourcesCache, colorStripSourcesCache, outputTargetsCache } from '../core/state.ts'; +import { _cachedHASources, _cachedValueSources, haSourcesCache, colorStripSourcesCache, outputTargetsCache, valueSourcesCache } from '../core/state.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; -import { showToast, showConfirm } from '../core/ui.ts'; -import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH } from '../core/icons.ts'; +import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; +import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; -import { getColorStripIcon } from '../core/icons.ts'; const ICON_HA = `${P.home}`; const _icon = (d: string) => `${d}`; @@ -22,6 +21,7 @@ const _icon = (d: string) => `${d}`; let _haLightTagsInput: TagInput | null = null; let _haSourceEntitySelect: EntitySelect | null = null; let _cssSourceEntitySelect: EntitySelect | null = null; +let _brightnessVsEntitySelect: EntitySelect | null = null; let _mappingEntitySelects: EntitySelect[] = []; let _editorCssSources: any[] = []; let _cachedHAEntities: any[] = []; // fetched from selected HA source @@ -33,6 +33,7 @@ class HALightEditorModal extends Modal { if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; } + if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; } _destroyMappingEntitySelects(); } @@ -207,6 +208,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat const [haSources, cssSources] = await Promise.all([ haSourcesCache.fetch().catch((): any[] => []), colorStripSourcesCache.fetch().catch((): any[] => []), + valueSourcesCache.fetch().catch(() => {}), ]); _editorCssSources = cssSources; @@ -307,6 +309,23 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat placeholder: t('palette.search'), }); + // Brightness value source + const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement; + bvsSelect.innerHTML = `` + + _cachedValueSources.map((vs: any) => + `` + ).join(''); + if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy(); + _brightnessVsEntitySelect = new EntitySelect({ + target: bvsSelect, + getItems: () => _cachedValueSources.map((vs: any) => ({ + value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type, + })), + placeholder: t('palette.search'), + allowNone: true, + noneLabel: t('targets.brightness_vs.none'), + }); + // Tags if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } _haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') }); @@ -343,10 +362,13 @@ export async function saveHALightEditor(): Promise { // Collect mappings const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id); + const brightnessVsId = (document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement).value; + const payload: any = { name, ha_source_id: haSourceId, color_strip_source_id: cssSourceId, + brightness_value_source_id: brightnessVsId, ha_light_mappings: mappings, update_rate: updateRate, transition, @@ -407,13 +429,28 @@ export async function cloneHALightTarget(targetId: string): Promise { // ── Card rendering ── -export function createHALightTargetCard(target: any, haSourceMap: Record = {}, cssSourceMap: Record = {}): string { +export function createHALightTargetCard(target: any, haSourceMap: Record = {}, cssSourceMap: Record = {}, valueSourceMap: Record = {}): string { const haSource = haSourceMap[target.ha_source_id]; const cssSource = cssSourceMap[target.color_strip_source_id]; const haName = haSource ? escapeHtml(haSource.name) : target.ha_source_id || '—'; - const cssName = cssSource ? escapeHtml(cssSource.name) : target.color_strip_source_id || '—'; + const cssId = target.color_strip_source_id; + const cssName = cssSource ? escapeHtml(cssSource.name) : cssId || '—'; const mappingCount = target.ha_light_mappings?.length || 0; const isRunning = target.state?.processing; + const state = target.state || {}; + const metrics = target.metrics || {}; + + // Crosslinks + const haLink = haSource + ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','home_assistant','ha-sources','data-id','${target.ha_source_id}')` + : ''; + const cssLink = cssSource + ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')` + : ''; + + // Brightness value source + const bvsId = target.brightness_value_source_id || ''; + const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null; return wrapCard({ type: 'card', @@ -426,13 +463,32 @@ export function createHALightTargetCard(target: any, haSourceMap: Record${ICON_HA} ${escapeHtml(target.name)}
- ${ICON_HA} ${haName} - ${cssName !== '—' ? `${_icon(P.palette)} ${cssName}` : ''} + ${ICON_HA} ${haName} + ${cssName !== '—' ? `${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}` : ''} ${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''} ${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz + ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''}
${renderTagChips(target.tags || [])} - ${target.description ? `
${escapeHtml(target.description)}
` : ''}`, + ${target.description ? `
${escapeHtml(target.description)}
` : ''} +
+ ${isRunning ? ` +
+
+
${t('targets.fps')}
+
${(state.fps_actual ?? target.update_rate ?? 2).toFixed(1)} Hz
+
+
+
${t('device.metrics.uptime')}
+
${metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---'}
+
+
+
HA
+
${state.ha_connected ? ICON_OK : ICON_WARNING}
+
+
+ ` : ''} +
`, actions: ` + `).join(''); } diff --git a/server/src/wled_controller/static/js/features/scene-presets.ts b/server/src/wled_controller/static/js/features/scene-presets.ts index 4aacf18..dcc70ff 100644 --- a/server/src/wled_controller/static/js/features/scene-presets.ts +++ b/server/src/wled_controller/static/js/features/scene-presets.ts @@ -90,7 +90,7 @@ export function createSceneCard(preset: ScenePreset) { const colorStyle = cardColorStyle(preset.id); return `
- +
${escapeHtml(preset.name)}
@@ -219,7 +219,7 @@ export async function editScenePreset(presetId: string): Promise { const item = document.createElement('div'); item.className = 'scene-target-item'; item.dataset.targetId = tid; - item.innerHTML = `${escapeHtml(tgt.name)}`; + item.innerHTML = `${escapeHtml(tgt.name)}`; targetList.appendChild(item); } _refreshTargetSelect(); @@ -314,7 +314,7 @@ function _addTargetToList(targetId: string, targetName: string): void { const item = document.createElement('div'); item.className = 'scene-target-item'; item.dataset.targetId = targetId; - item.innerHTML = `${ICON_TARGET} ${escapeHtml(targetName)}`; + item.innerHTML = `${ICON_TARGET} ${escapeHtml(targetName)}`; list.appendChild(item); _refreshTargetSelect(); } @@ -433,7 +433,7 @@ export async function cloneScenePreset(presetId: string): Promise { const item = document.createElement('div'); item.className = 'scene-target-item'; item.dataset.targetId = tid; - item.innerHTML = `${escapeHtml(tgt.name)}`; + item.innerHTML = `${escapeHtml(tgt.name)}`; targetList.appendChild(item); } _refreshTargetSelect(); diff --git a/server/src/wled_controller/static/js/features/targets.ts b/server/src/wled_controller/static/js/features/targets.ts index 55dce2d..4b77e99 100644 --- a/server/src/wled_controller/static/js/features/targets.ts +++ b/server/src/wled_controller/static/js/features/targets.ts @@ -6,11 +6,10 @@ import { apiKey, _targetEditorDevices, set_targetEditorDevices, _deviceBrightnessCache, - kcWebSockets, ledPreviewWebSockets, _cachedValueSources, valueSourcesCache, streamsCache, audioSourcesCache, syncClocksCache, - colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache, + colorStripSourcesCache, devicesCache, outputTargetsCache, _cachedHASources, haSourcesCache, } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts'; @@ -19,8 +18,7 @@ import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, import { Modal } from '../core/modal.ts'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts'; import { _splitOpenrgbZone } from './device-discovery.ts'; -import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts'; -import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts'; +import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics } from './ha-light-targets.ts'; import { getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, @@ -83,16 +81,6 @@ async function _bulkDeleteDevices(ids: any) { await loadTargetsTab(); } -async function _bulkDeletePatternTemplates(ids: any) { - const results = await Promise.allSettled(ids.map(id => - fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' }) - )); - const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; - if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); - else showToast(t('targets.deleted'), 'success'); - patternTemplatesCache.invalidate(); - await loadTargetsTab(); -} const _targetBulkActions = [ { key: 'start', labelKey: 'bulk.start', icon: ICON_START, handler: _bulkStartTargets }, @@ -105,11 +93,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices }, ] }); const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: ``, bulkActions: _targetBulkActions }); -const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: ``, bulkActions: _targetBulkActions }); const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions }); -const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [ - { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates }, -] }); // Re-render targets tab when language changes (only if tab is active) document.addEventListener('languageChanged', () => { @@ -582,8 +566,6 @@ export function switchTargetSubTab(tabKey: any) { const _targetSectionMap = { 'led-devices': [csDevices], 'led-targets': [csLedTargets], - 'kc-targets': [csKCTargets], - 'kc-patterns': [csPatternTemplates], }; let _loadTargetsLock = false; @@ -599,11 +581,10 @@ export async function loadTargetsTab() { try { // Fetch all entities via DataCache - const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([ + const [devices, targets, cssArr, psArr, valueSrcArr, asSrcArr] = await Promise.all([ devicesCache.fetch().catch((): any[] => []), outputTargetsCache.fetch().catch((): any[] => []), colorStripSourcesCache.fetch().catch((): any[] => []), - patternTemplatesCache.fetch().catch((): any[] => []), streamsCache.fetch().catch((): any[] => []), valueSourcesCache.fetch().catch((): any[] => []), audioSourcesCache.fetch().catch((): any[] => []), @@ -617,9 +598,6 @@ export async function loadTargetsTab() { let pictureSourceMap = {}; psArr.forEach(s => { pictureSourceMap[s.id] = s; }); - let patternTemplateMap = {}; - patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); - let valueSourceMap = {}; valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; }); @@ -661,7 +639,6 @@ export async function loadTargetsTab() { // Group by type const ledDevices = devicesWithState; const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); - const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light'); // Update tab badge with running target count @@ -679,13 +656,6 @@ export async function loadTargetsTab() { { key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length }, ] }, - { - key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors', - children: [ - { key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length }, - { key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length }, - ] - }, { key: 'ha_light_group', icon: `${P.home}`, titleKey: 'ha_light.section.title', children: [ @@ -694,21 +664,15 @@ export async function loadTargetsTab() { } ]; // Determine which tree leaf is active — migrate old values - const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets']; - const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab - : activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices'; - - // Use window.createPatternTemplateCard to avoid circular import - const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); + const validLeaves = ['led-devices', 'led-targets', 'ha-light-targets']; + const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab : 'led-devices'; // Build items arrays for each section (apply saved drag order) const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) }))); const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) }))); - const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) }))); const haSourceMap: Record = {}; _cachedHASources.forEach(s => { haSourceMap[s.id] = s; }); - const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) }))); - const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) }))); + const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap, valueSourceMap) }))); // Track which target cards were replaced/added (need chart re-init) let changedTargetIds: Set | null = null; @@ -718,17 +682,12 @@ export async function loadTargetsTab() { _targetsTree.updateCounts({ 'led-devices': ledDevices.length, 'led-targets': ledTargets.length, - 'kc-targets': kcTargets.length, - 'kc-patterns': patternTemplates.length, 'ha-light-targets': haLightTargets.length, }); csDevices.reconcile(deviceItems); const ledResult = csLedTargets.reconcile(ledTargetItems); - const kcResult = csKCTargets.reconcile(kcTargetItems); - csPatternTemplates.reconcile(patternItems); csHALightTargets.reconcile(haLightTargetItems); - changedTargetIds = new Set([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]), - ...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]); + changedTargetIds = new Set([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[])]); // Restore LED preview state on replaced cards (panel hidden by default in HTML) for (const id of Array.from(ledResult.replaced) as any[]) { @@ -741,12 +700,10 @@ export async function loadTargetsTab() { const panels = [ { key: 'led-devices', html: csDevices.render(deviceItems) }, { key: 'led-targets', html: csLedTargets.render(ledTargetItems) }, - { key: 'kc-targets', html: csKCTargets.render(kcTargetItems) }, - { key: 'kc-patterns', html: csPatternTemplates.render(patternItems) }, { key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) }, ].map(p => `
${p.html}
`).join(''); container.innerHTML = panels; - CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]); + CardSection.bindAll([csDevices, csLedTargets, csHALightTargets]); initHALightTargetDelegation(container); // Render tree sidebar with expand/collapse buttons @@ -757,18 +714,15 @@ export async function loadTargetsTab() { // Show/hide stop-all buttons based on running state const ledRunning = ledTargets.some(t => t.state && t.state.processing); - const kcRunning = kcTargets.some(t => t.state && t.state.processing); const ledStopBtn = container.querySelector('[data-stop-all="led"]') as HTMLElement | null; - const kcStopBtn = container.querySelector('[data-stop-all="kc"]') as HTMLElement | null; if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } } - if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } } // Patch volatile metrics in-place (avoids full card replacement on polls) for (const tgt of ledTargets) { if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt); } - for (const tgt of kcTargets) { - if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt); + for (const tgt of haLightTargets) { + if (tgt.state && tgt.state.processing) patchHALightTargetMetrics(tgt); } // Attach event listeners and fetch brightness for device cards @@ -806,20 +760,6 @@ export async function loadTargetsTab() { } } - // Manage KC WebSockets: connect for processing, disconnect for stopped - const processingKCIds = new Set(); - kcTargets.forEach(target => { - if (target.state && target.state.processing) { - processingKCIds.add(target.id); - if (!kcWebSockets[target.id]) { - connectKCWebSocket(target.id); - } - } - }); - Object.keys(kcWebSockets).forEach(id => { - if (!processingKCIds.has(id)) disconnectKCWebSocket(id); - }); - // Auto-disconnect LED preview WebSockets for targets that stopped const processingLedIds = new Set(); ledTargets.forEach(target => { @@ -847,7 +787,7 @@ export async function loadTargetsTab() { } // Push FPS samples and create/update charts for running targets - const allTargets = [...ledTargets, ...kcTargets]; + const allTargets = [...ledTargets]; const runningIds = new Set(); const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id); @@ -1168,12 +1108,6 @@ export async function stopAllLedTargets() { await _stopAllByType('led'); } -export async function stopAllKCTargets() { - const confirmed = await showConfirm(t('confirm.stop_all')); - if (!confirmed) return; - await _stopAllByType('key_colors'); -} - async function _stopAllByType(targetType: any) { try { const [allTargets, statesResp] = await Promise.all([ diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index afba00a..e1fe416 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -19,7 +19,7 @@ import { getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK, - ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, + ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH, } from '../core/icons.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; @@ -897,7 +897,7 @@ export function addSchedulePoint(time: string = '', value: number = 1.0) { ${value} - + `; list.appendChild(row); _wireScheduleTimePicker(row); diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 45c42db..eab5751 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -87,7 +87,7 @@ export type CSSSourceType = | 'picture' | 'picture_advanced' | 'static' | 'gradient' | 'color_cycle' | 'effect' | 'composite' | 'mapped' | 'audio' | 'api_input' | 'notification' | 'daylight' - | 'candlelight' | 'processed'; + | 'candlelight' | 'processed' | 'weather' | 'key_colors'; export interface ColorStop { position: number; @@ -228,6 +228,11 @@ export interface ColorStripSource { // Weather weather_source_id?: string; temperature_influence?: number; + + // Key Colors + rectangles?: KeyColorRectangle[]; + brightness?: number; + brightness_value_source_id?: string; } // ── Pattern Template ────────────────────────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index b09d368..ff5b8ab 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1167,6 +1167,23 @@ "color_strip.type.candlelight": "Candlelight", "color_strip.type.candlelight.desc": "Realistic flickering candle simulation", "color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.", + "color_strip.type.key_colors": "Key Colors", + "color_strip.type.key_colors.desc": "Extract colors from screen regions", + "color_strip.key_colors.picture_source": "Picture Source:", + "color_strip.key_colors.interpolation": "Color Mode:", + "color_strip.key_colors.smoothing": "Smoothing:", + "color_strip.key_colors.brightness": "Brightness:", + "color_strip.key_colors.rectangles": "Screen Regions:", + "color_strip.key_colors.no_rects": "No regions defined. Click Configure Regions to add.", + "color_strip.key_colors.configure_regions": "Configure Regions", + "color_strip.key_colors.mode.average": "Average", + "color_strip.key_colors.mode.average.desc": "Mean color of all pixels in the region", + "color_strip.key_colors.mode.median": "Median", + "color_strip.key_colors.mode.median.desc": "Median color (less affected by outliers)", + "color_strip.key_colors.mode.dominant": "Dominant", + "color_strip.key_colors.mode.dominant.desc": "Most frequent color (K-means clustering)", + "color_strip.key_colors.error.no_source": "Picture source is required", + "color_strip.key_colors.error.no_rects": "At least one screen region is required", "color_strip.type.weather": "Weather", "color_strip.type.weather.desc": "Weather-reactive ambient colors", "color_strip.type.weather.hint": "Maps real-time weather conditions to ambient LED colors. Requires a Weather Source entity.", diff --git a/server/src/wled_controller/storage/__init__.py b/server/src/wled_controller/storage/__init__.py index dda83b6..4672657 100644 --- a/server/src/wled_controller/storage/__init__.py +++ b/server/src/wled_controller/storage/__init__.py @@ -1,8 +1,7 @@ """Storage layer for device and configuration persistence.""" from .device_store import DeviceStore -from .pattern_template_store import PatternTemplateStore from .picture_source_store import PictureSourceStore from .postprocessing_template_store import PostprocessingTemplateStore -__all__ = ["DeviceStore", "PatternTemplateStore", "PictureSourceStore", "PostprocessingTemplateStore"] +__all__ = ["DeviceStore", "PictureSourceStore", "PostprocessingTemplateStore"] diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 90c3f60..253eebf 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -70,12 +70,19 @@ class ColorStripSource: } @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description: Optional[str] = None, - clock_id: Optional[str] = None, - tags: Optional[List[str]] = None, - **kwargs) -> "ColorStripSource": + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description: Optional[str] = None, + clock_id: Optional[str] = None, + tags: Optional[List[str]] = None, + **kwargs, + ) -> "ColorStripSource": """Create an instance from keyword arguments. Base implementation — subclasses override to extract type-specific fields. @@ -115,15 +122,13 @@ def _parse_css_common(data: dict) -> dict: created_at = ( datetime.fromisoformat(raw_created) if isinstance(raw_created, str) - else raw_created if isinstance(raw_created, datetime) - else datetime.now(timezone.utc) + else raw_created if isinstance(raw_created, datetime) else datetime.now(timezone.utc) ) raw_updated = data.get("updated_at") updated_at = ( datetime.fromisoformat(raw_updated) if isinstance(raw_updated, str) - else raw_updated if isinstance(raw_updated, datetime) - else datetime.now(timezone.utc) + else raw_updated if isinstance(raw_updated, datetime) else datetime.now(timezone.utc) ) return dict( id=data["id"], @@ -192,12 +197,12 @@ class PictureColorStripSource(ColorStripSource): picture_source_id: str = "" fps: int = 30 - smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full) - interpolation_mode: str = "average" # "average" | "median" | "dominant" + 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: CalibrationConfig(layout="clockwise", start_position="bottom_left") ) - led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration) + led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration) def to_dict(self) -> dict: d = super().to_dict() @@ -216,22 +221,42 @@ class PictureColorStripSource(ColorStripSource): ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - picture_source_id="", fps=30, - smoothing=0.3, - interpolation_mode="average", calibration=None, - led_count=0, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + picture_source_id="", + fps=30, + smoothing=0.3, + interpolation_mode="average", + calibration=None, + led_count=0, + **_kwargs, + ): if calibration is None: calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") return cls( - id=id, name=name, source_type=source_type, - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], - picture_source_id=picture_source_id, fps=fps, - smoothing=smoothing, interpolation_mode=interpolation_mode, - calibration=calibration, led_count=led_count, + id=id, + name=name, + source_type=source_type, + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], + picture_source_id=picture_source_id, + fps=fps, + smoothing=smoothing, + interpolation_mode=interpolation_mode, + calibration=calibration, + led_count=led_count, ) def apply_update(self, **kwargs) -> None: @@ -274,21 +299,40 @@ class AdvancedPictureColorStripSource(ColorStripSource): return cls(**common, source_type="picture_advanced", **pic) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - fps=30, - smoothing=0.3, interpolation_mode="average", - calibration=None, led_count=0, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + fps=30, + smoothing=0.3, + interpolation_mode="average", + calibration=None, + led_count=0, + **_kwargs, + ): if calibration is None: calibration = CalibrationConfig(mode="advanced") return cls( - id=id, name=name, source_type="picture_advanced", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="picture_advanced", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], fps=fps, - smoothing=smoothing, interpolation_mode=interpolation_mode, - calibration=calibration, led_count=led_count, + smoothing=smoothing, + interpolation_mode=interpolation_mode, + calibration=calibration, + led_count=led_count, ) def apply_update(self, **kwargs) -> None: @@ -318,21 +362,40 @@ class StaticColorStripSource(ColorStripSource): common = _parse_css_common(data) color = _validate_rgb(data.get("color"), [255, 255, 255]) return cls( - **common, source_type="static", - color=color, animation=data.get("animation"), + **common, + source_type="static", + color=color, + animation=data.get("animation"), ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - color=None, animation=None, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + color=None, + animation=None, + **_kwargs, + ): rgb = _validate_rgb(color, [255, 255, 255]) return cls( - id=id, name=name, source_type="static", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], - color=rgb, animation=animation, + id=id, + name=name, + source_type="static", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], + color=rgb, + animation=animation, ) def apply_update(self, **kwargs) -> None: @@ -356,10 +419,12 @@ class GradientColorStripSource(ColorStripSource): """ # Each stop: {"position": float, "color": [R,G,B], "color_right": [R,G,B] | null} - stops: list = field(default_factory=lambda: [ - {"position": 0.0, "color": [255, 0, 0]}, - {"position": 1.0, "color": [0, 0, 255]}, - ]) + stops: list = field( + default_factory=lambda: [ + {"position": 0.0, "color": [255, 0, 0]}, + {"position": 1.0, "color": [0, 0, 255]}, + ] + ) animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None easing: str = "linear" # linear | ease_in_out | step | cubic gradient_id: Optional[str] = None # references a Gradient entity; overrides inline stops @@ -377,7 +442,8 @@ class GradientColorStripSource(ColorStripSource): common = _parse_css_common(data) raw_stops = data.get("stops") return cls( - **common, source_type="gradient", + **common, + source_type="gradient", stops=raw_stops if isinstance(raw_stops, list) else [], animation=data.get("animation"), easing=data.get("easing") or "linear", @@ -385,19 +451,40 @@ class GradientColorStripSource(ColorStripSource): ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - stops=None, animation=None, easing=None, - gradient_id=None, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + stops=None, + animation=None, + easing=None, + gradient_id=None, + **_kwargs, + ): return cls( - id=id, name=name, source_type="gradient", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], - stops=stops if isinstance(stops, list) else [ - {"position": 0.0, "color": [255, 0, 0]}, - {"position": 1.0, "color": [0, 0, 255]}, - ], + id=id, + name=name, + source_type="gradient", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], + stops=( + stops + if isinstance(stops, list) + else [ + {"position": 0.0, "color": [255, 0, 0]}, + {"position": 1.0, "color": [0, 0, 255]}, + ] + ), animation=animation, easing=easing if easing in ("linear", "ease_in_out", "step", "cubic") else "linear", gradient_id=gradient_id, @@ -424,10 +511,16 @@ class ColorCycleColorStripSource(ColorStripSource): LED count auto-sizes from the connected device. """ - colors: list = field(default_factory=lambda: [ - [255, 0, 0], [255, 255, 0], [0, 255, 0], - [0, 255, 255], [0, 0, 255], [255, 0, 255], - ]) + colors: list = field( + default_factory=lambda: [ + [255, 0, 0], + [255, 255, 0], + [0, 255, 0], + [0, 255, 255], + [0, 0, 255], + [255, 0, 255], + ] + ) def to_dict(self) -> dict: d = super().to_dict() @@ -439,23 +532,43 @@ class ColorCycleColorStripSource(ColorStripSource): common = _parse_css_common(data) raw_colors = data.get("colors") return cls( - **common, source_type="color_cycle", + **common, + source_type="color_cycle", colors=raw_colors if isinstance(raw_colors, list) else [], ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - colors=None, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + colors=None, + **_kwargs, + ): default_colors = [ - [255, 0, 0], [255, 255, 0], [0, 255, 0], - [0, 255, 255], [0, 0, 255], [255, 0, 255], + [255, 0, 0], + [255, 255, 0], + [0, 255, 0], + [0, 255, 255], + [0, 0, 255], + [255, 0, 255], ] return cls( - id=id, name=name, source_type="color_cycle", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="color_cycle", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors, ) @@ -474,13 +587,15 @@ class EffectColorStripSource(ColorStripSource): LED count auto-sizes from the connected device. """ - effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types - palette: str = "fire" # legacy palette name (kept for migration) + effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types + palette: str = "fire" # legacy palette name (kept for migration) gradient_id: Optional[str] = None # references a Gradient entity (preferred over palette) - color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor/comet/bouncing_ball head - intensity: float = 1.0 # effect-specific intensity (0.1-2.0) - scale: float = 1.0 # spatial scale / zoom (0.5-5.0) - mirror: bool = False # bounce mode (meteor/comet) + color: list = field( + default_factory=lambda: [255, 80, 0] + ) # [R,G,B] for meteor/comet/bouncing_ball head + intensity: float = 1.0 # effect-specific intensity (0.1-2.0) + scale: float = 1.0 # spatial scale / zoom (0.5-5.0) + mirror: bool = False # bounce mode (meteor/comet) custom_palette: Optional[list] = None # legacy [[pos, R, G, B], ...] custom palette stops def to_dict(self) -> dict: @@ -500,7 +615,8 @@ class EffectColorStripSource(ColorStripSource): common = _parse_css_common(data) color = _validate_rgb(data.get("color"), [255, 80, 0]) return cls( - **common, source_type="effect", + **common, + source_type="effect", effect_type=data.get("effect_type") or "fire", palette=data.get("palette") or "fire", gradient_id=data.get("gradient_id"), @@ -512,18 +628,39 @@ class EffectColorStripSource(ColorStripSource): ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - effect_type="fire", palette="fire", gradient_id=None, - color=None, intensity=1.0, scale=1.0, mirror=False, - custom_palette=None, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + effect_type="fire", + palette="fire", + gradient_id=None, + color=None, + intensity=1.0, + scale=1.0, + mirror=False, + custom_palette=None, + **_kwargs, + ): rgb = _validate_rgb(color, [255, 80, 0]) return cls( - id=id, name=name, source_type="effect", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], - effect_type=effect_type or "fire", palette=palette or "fire", + id=id, + name=name, + source_type="effect", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], + effect_type=effect_type or "fire", + palette=palette or "fire", gradient_id=gradient_id, color=rgb, intensity=float(intensity) if intensity else 1.0, @@ -562,16 +699,16 @@ class AudioColorStripSource(ColorStripSource): LED count auto-sizes from the connected device when led_count == 0. """ - visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter - audio_source_id: str = "" # references a MonoAudioSource - sensitivity: float = 1.0 # gain multiplier (0.1-5.0) - smoothing: float = 0.3 # temporal smoothing (0.0-1.0) - palette: str = "rainbow" # legacy palette name (kept for migration) - gradient_id: Optional[str] = None # references a Gradient entity (preferred) - color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter + visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter + audio_source_id: str = "" # references a MonoAudioSource + sensitivity: float = 1.0 # gain multiplier (0.1-5.0) + smoothing: float = 0.3 # temporal smoothing (0.0-1.0) + palette: str = "rainbow" # legacy palette name (kept for migration) + gradient_id: Optional[str] = None # references a Gradient entity (preferred) + color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter color_peak: list = field(default_factory=lambda: [255, 0, 0]) # peak RGB for VU meter - led_count: int = 0 # 0 = use device LED count - mirror: bool = False # mirror spectrum from center outward + led_count: int = 0 # 0 = use device LED count + mirror: bool = False # mirror spectrum from center outward def to_dict(self) -> dict: d = super().to_dict() @@ -593,39 +730,64 @@ class AudioColorStripSource(ColorStripSource): color = _validate_rgb(data.get("color"), [0, 255, 0]) color_peak = _validate_rgb(data.get("color_peak"), [255, 0, 0]) return cls( - **common, source_type="audio", + **common, + source_type="audio", visualization_mode=data.get("visualization_mode") or "spectrum", audio_source_id=data.get("audio_source_id") or "", sensitivity=float(data.get("sensitivity") or 1.0), smoothing=float(data.get("smoothing") or 0.3), palette=data.get("palette") or "rainbow", gradient_id=data.get("gradient_id"), - color=color, color_peak=color_peak, + color=color, + color_peak=color_peak, led_count=data.get("led_count") or 0, mirror=bool(data.get("mirror", False)), ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - visualization_mode="spectrum", audio_source_id="", - sensitivity=1.0, smoothing=0.3, palette="rainbow", - gradient_id=None, color=None, color_peak=None, - led_count=0, mirror=False, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + visualization_mode="spectrum", + audio_source_id="", + sensitivity=1.0, + smoothing=0.3, + palette="rainbow", + gradient_id=None, + color=None, + color_peak=None, + led_count=0, + mirror=False, + **_kwargs, + ): rgb = _validate_rgb(color, [0, 255, 0]) peak = _validate_rgb(color_peak, [255, 0, 0]) return cls( - id=id, name=name, source_type="audio", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="audio", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], visualization_mode=visualization_mode or "spectrum", audio_source_id=audio_source_id or "", sensitivity=float(sensitivity) if sensitivity else 1.0, smoothing=float(smoothing) if smoothing else 0.3, palette=palette or "rainbow", gradient_id=gradient_id, - color=rgb, color_peak=peak, led_count=led_count, + color=rgb, + color_peak=peak, + led_count=led_count, mirror=bool(mirror), ) @@ -680,20 +842,37 @@ class CompositeColorStripSource(ColorStripSource): def from_dict(cls, data: dict) -> "CompositeColorStripSource": common = _parse_css_common(data) return cls( - **common, source_type="composite", + **common, + source_type="composite", layers=data.get("layers") or [], led_count=data.get("led_count") or 0, ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - layers=None, led_count=0, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + layers=None, + led_count=0, + **_kwargs, + ): return cls( - id=id, name=name, source_type="composite", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="composite", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], layers=layers if isinstance(layers, list) else [], led_count=led_count, ) @@ -730,20 +909,37 @@ class MappedColorStripSource(ColorStripSource): def from_dict(cls, data: dict) -> "MappedColorStripSource": common = _parse_css_common(data) return cls( - **common, source_type="mapped", + **common, + source_type="mapped", zones=data.get("zones") or [], led_count=data.get("led_count") or 0, ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - zones=None, led_count=0, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + zones=None, + led_count=0, + **_kwargs, + ): return cls( - id=id, name=name, source_type="mapped", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="mapped", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], zones=zones if isinstance(zones, list) else [], led_count=led_count, ) @@ -767,8 +963,8 @@ class ApiInputColorStripSource(ColorStripSource): """ fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B] - timeout: float = 5.0 # seconds before reverting to fallback - interpolation: str = "linear" # none | linear | nearest + timeout: float = 5.0 # seconds before reverting to fallback + interpolation: str = "linear" # none | linear | nearest def to_dict(self) -> dict: d = super().to_dict() @@ -785,25 +981,41 @@ class ApiInputColorStripSource(ColorStripSource): if interpolation not in ("none", "linear", "nearest"): interpolation = "linear" return cls( - **common, source_type="api_input", + **common, + source_type="api_input", fallback_color=fallback_color, timeout=float(data.get("timeout") or 5.0), interpolation=interpolation, ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - fallback_color=None, timeout=None, - interpolation=None, - **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + fallback_color=None, + timeout=None, + interpolation=None, + **_kwargs, + ): fb = _validate_rgb(fallback_color, [0, 0, 0]) interp = interpolation if interpolation in ("none", "linear", "nearest") else "linear" return cls( - id=id, name=name, source_type="api_input", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="api_input", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], fallback_color=fb, timeout=float(timeout) if timeout is not None else 5.0, interpolation=interp, @@ -811,7 +1023,11 @@ class ApiInputColorStripSource(ColorStripSource): def apply_update(self, **kwargs) -> None: fallback_color = kwargs.get("fallback_color") - if fallback_color is not None and isinstance(fallback_color, list) and len(fallback_color) == 3: + if ( + fallback_color is not None + and isinstance(fallback_color, list) + and len(fallback_color) == 3 + ): self.fallback_color = fallback_color if kwargs.get("timeout") is not None: self.timeout = float(kwargs["timeout"]) @@ -831,16 +1047,18 @@ class NotificationColorStripSource(ColorStripSource): LED count auto-sizes from the connected device when led_count == 0. """ - notification_effect: str = "flash" # flash | pulse | sweep - duration_ms: int = 1500 # effect duration in milliseconds - default_color: str = "#FFFFFF" # hex color for notifications without app match - app_colors: dict = field(default_factory=dict) # app name -> hex color - app_filter_mode: str = "off" # off | whitelist | blacklist + notification_effect: str = "flash" # flash | pulse | sweep + duration_ms: int = 1500 # effect duration in milliseconds + default_color: str = "#FFFFFF" # hex color for notifications without app match + app_colors: dict = field(default_factory=dict) # app name -> hex color + app_filter_mode: str = "off" # off | whitelist | blacklist app_filter_list: list = field(default_factory=list) # app names for filter - os_listener: bool = False # whether to listen for OS notifications - sound_asset_id: Optional[str] = None # global notification sound (asset ID) - sound_volume: float = 1.0 # global volume 0.0-1.0 - app_sounds: dict = field(default_factory=dict) # app name -> {"sound_asset_id": str|None, "volume": float|None} + os_listener: bool = False # whether to listen for OS notifications + sound_asset_id: Optional[str] = None # global notification sound (asset ID) + sound_volume: float = 1.0 # global volume 0.0-1.0 + app_sounds: dict = field( + default_factory=dict + ) # app name -> {"sound_asset_id": str|None, "volume": float|None} def to_dict(self) -> dict: d = super().to_dict() @@ -863,7 +1081,8 @@ class NotificationColorStripSource(ColorStripSource): raw_app_filter_list = data.get("app_filter_list") raw_app_sounds = data.get("app_sounds") return cls( - **common, source_type="notification", + **common, + source_type="notification", notification_effect=data.get("notification_effect") or "flash", duration_ms=int(data.get("duration_ms") or 1500), default_color=data.get("default_color") or "#FFFFFF", @@ -877,19 +1096,38 @@ class NotificationColorStripSource(ColorStripSource): ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - notification_effect=None, duration_ms=None, - default_color=None, app_colors=None, - app_filter_mode=None, app_filter_list=None, - os_listener=None, sound_asset_id=None, - sound_volume=None, app_sounds=None, - **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + notification_effect=None, + duration_ms=None, + default_color=None, + app_colors=None, + app_filter_mode=None, + app_filter_list=None, + os_listener=None, + sound_asset_id=None, + sound_volume=None, + app_sounds=None, + **_kwargs, + ): return cls( - id=id, name=name, source_type="notification", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="notification", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], notification_effect=notification_effect or "flash", duration_ms=int(duration_ms) if duration_ms is not None else 1500, default_color=default_color or "#FFFFFF", @@ -942,10 +1180,10 @@ class DaylightColorStripSource(ColorStripSource): a full 24-hour cycle plays (1.0 = 4 minutes per full cycle). """ - speed: float = 1.0 # cycle speed (ignored when use_real_time) - use_real_time: bool = False # use actual time of day - latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90) - longitude: float = 0.0 # longitude for solar position (-180..180) + speed: float = 1.0 # cycle speed (ignored when use_real_time) + use_real_time: bool = False # use actual time of day + latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90) + longitude: float = 0.0 # longitude for solar position (-180..180) def to_dict(self) -> dict: d = super().to_dict() @@ -959,22 +1197,40 @@ class DaylightColorStripSource(ColorStripSource): def from_dict(cls, data: dict) -> "DaylightColorStripSource": common = _parse_css_common(data) return cls( - **common, source_type="daylight", + **common, + source_type="daylight", speed=float(data.get("speed") or 1.0), use_real_time=bool(data.get("use_real_time", False)), latitude=float(data.get("latitude") or 50.0), ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - speed=None, use_real_time=None, latitude=None, - longitude=None, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + speed=None, + use_real_time=None, + latitude=None, + longitude=None, + **_kwargs, + ): return cls( - id=id, name=name, source_type="daylight", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="daylight", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], speed=float(speed) if speed is not None else 1.0, use_real_time=bool(use_real_time) if use_real_time is not None else False, latitude=float(latitude) if latitude is not None else 50.0, @@ -1002,11 +1258,11 @@ class CandlelightColorStripSource(ColorStripSource): """ color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B] - intensity: float = 1.0 # flicker intensity (0.1-2.0) - num_candles: int = 3 # number of independent candle sources - speed: float = 1.0 # flicker speed multiplier - wind_strength: float = 0.0 # wind effect (0.0-2.0) - candle_type: str = "default" # default | taper | votive | bonfire + intensity: float = 1.0 # flicker intensity (0.1-2.0) + num_candles: int = 3 # number of independent candle sources + speed: float = 1.0 # flicker speed multiplier + wind_strength: float = 0.0 # wind effect (0.0-2.0) + candle_type: str = "default" # default | taper | votive | bonfire def to_dict(self) -> dict: d = super().to_dict() @@ -1023,7 +1279,8 @@ class CandlelightColorStripSource(ColorStripSource): common = _parse_css_common(data) color = _validate_rgb(data.get("color"), [255, 147, 41]) return cls( - **common, source_type="candlelight", + **common, + source_type="candlelight", color=color, intensity=float(data.get("intensity") or 1.0), num_candles=int(data.get("num_candles") or 3), @@ -1031,23 +1288,45 @@ class CandlelightColorStripSource(ColorStripSource): ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - color=None, intensity=1.0, num_candles=None, - speed=None, wind_strength=None, candle_type=None, - **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + color=None, + intensity=1.0, + num_candles=None, + speed=None, + wind_strength=None, + candle_type=None, + **_kwargs, + ): rgb = _validate_rgb(color, [255, 147, 41]) return cls( - id=id, name=name, source_type="candlelight", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="candlelight", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], color=rgb, intensity=float(intensity) if intensity else 1.0, num_candles=int(num_candles) if num_candles is not None else 3, speed=float(speed) if speed is not None else 1.0, wind_strength=float(wind_strength) if wind_strength is not None else 0.0, - candle_type=candle_type if candle_type in {"default", "taper", "votive", "bonfire"} else "default", + candle_type=( + candle_type + if candle_type in {"default", "taper", "votive", "bonfire"} + else "default" + ), ) def apply_update(self, **kwargs) -> None: @@ -1077,8 +1356,8 @@ class ProcessedColorStripSource(ColorStripSource): original. """ - input_source_id: str = "" # ID of the input color strip source - processing_template_id: str = "" # ID of the CSPT to apply + input_source_id: str = "" # ID of the input color strip source + processing_template_id: str = "" # ID of the CSPT to apply def to_dict(self) -> dict: d = super().to_dict() @@ -1090,21 +1369,37 @@ class ProcessedColorStripSource(ColorStripSource): def from_dict(cls, data: dict) -> "ProcessedColorStripSource": common = _parse_css_common(data) return cls( - **common, source_type="processed", + **common, + source_type="processed", input_source_id=data.get("input_source_id") or "", processing_template_id=data.get("processing_template_id") or "", ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - input_source_id="", processing_template_id="", - **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + input_source_id="", + processing_template_id="", + **_kwargs, + ): return cls( - id=id, name=name, source_type="processed", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="processed", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], input_source_id=input_source_id, processing_template_id=processing_template_id, ) @@ -1115,7 +1410,9 @@ class ProcessedColorStripSource(ColorStripSource): self.input_source_id = resolve_ref(input_source_id, self.input_source_id) processing_template_id = kwargs.get("processing_template_id") if processing_template_id is not None: - self.processing_template_id = resolve_ref(processing_template_id, self.processing_template_id) + self.processing_template_id = resolve_ref( + processing_template_id, self.processing_template_id + ) @dataclass @@ -1127,8 +1424,8 @@ class WeatherColorStripSource(ColorStripSource): with temperature-influenced hue shifting. """ - weather_source_id: str = "" # reference to WeatherSource entity - speed: float = 1.0 # ambient drift animation speed + weather_source_id: str = "" # reference to WeatherSource entity + speed: float = 1.0 # ambient drift animation speed temperature_influence: float = 0.5 # 0.0=none, 1.0=full temp hue shift def to_dict(self) -> dict: @@ -1142,36 +1439,198 @@ class WeatherColorStripSource(ColorStripSource): def from_dict(cls, data: dict) -> "WeatherColorStripSource": common = _parse_css_common(data) return cls( - **common, source_type="weather", + **common, + source_type="weather", weather_source_id=data.get("weather_source_id", ""), speed=float(data.get("speed") or 1.0), - temperature_influence=float(data.get("temperature_influence") if data.get("temperature_influence") is not None else 0.5), + temperature_influence=float( + data.get("temperature_influence") + if data.get("temperature_influence") is not None + else 0.5 + ), ) @classmethod - def create_from_kwargs(cls, *, id: str, name: str, source_type: str, - created_at: datetime, updated_at: datetime, - description=None, clock_id=None, tags=None, - weather_source_id=None, speed=None, - temperature_influence=None, **_kwargs): + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + weather_source_id=None, + speed=None, + temperature_influence=None, + **_kwargs, + ): return cls( - id=id, name=name, source_type="weather", - created_at=created_at, updated_at=updated_at, - description=description, clock_id=clock_id, tags=tags or [], + id=id, + name=name, + source_type="weather", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], weather_source_id=weather_source_id or "", speed=float(speed) if speed is not None else 1.0, - temperature_influence=float(temperature_influence) if temperature_influence is not None else 0.5, + temperature_influence=( + float(temperature_influence) if temperature_influence is not None else 0.5 + ), ) def apply_update(self, **kwargs) -> None: if kwargs.get("weather_source_id") is not None: - self.weather_source_id = resolve_ref(kwargs["weather_source_id"], self.weather_source_id) + self.weather_source_id = resolve_ref( + kwargs["weather_source_id"], self.weather_source_id + ) if kwargs.get("speed") is not None: self.speed = float(kwargs["speed"]) if kwargs.get("temperature_influence") is not None: self.temperature_influence = float(kwargs["temperature_influence"]) +# --------------------------------------------------------------------------- +# Key Colors — extracts dominant colors from screen rectangles +# --------------------------------------------------------------------------- + + +@dataclass +class KeyColorRectangle: + """A named rectangle in relative coordinates (0.0 to 1.0).""" + + name: str + x: float + y: float + width: float + height: float + + def to_dict(self) -> dict: + return { + "name": self.name, + "x": self.x, + "y": self.y, + "width": self.width, + "height": self.height, + } + + @classmethod + def from_dict(cls, data: dict) -> "KeyColorRectangle": + return cls( + name=data["name"], + x=float(data.get("x", 0.0)), + y=float(data.get("y", 0.0)), + width=float(data.get("width", 1.0)), + height=float(data.get("height", 1.0)), + ) + + +@dataclass +class KeyColorsColorStripSource(ColorStripSource): + """Extracts one color per named screen rectangle — N rects = N 'LEDs'.""" + + picture_source_id: str = "" + rectangles: List[KeyColorRectangle] = field(default_factory=list) + interpolation_mode: str = "average" # average, median, dominant + smoothing: float = 0.3 + brightness: float = 1.0 + brightness_value_source_id: str = "" + + @property + def sharable(self) -> bool: + return True + + def to_dict(self) -> dict: + d = super().to_dict() + d["picture_source_id"] = self.picture_source_id + d["rectangles"] = [r.to_dict() for r in self.rectangles] + d["interpolation_mode"] = self.interpolation_mode + d["smoothing"] = self.smoothing + d["brightness"] = self.brightness + d["brightness_value_source_id"] = self.brightness_value_source_id + return d + + @classmethod + def from_dict(cls, data: dict) -> "KeyColorsColorStripSource": + common = _parse_css_common(data) + rects = [KeyColorRectangle.from_dict(r) for r in data.get("rectangles", [])] + return cls( + **common, + source_type="key_colors", + picture_source_id=data.get("picture_source_id", ""), + rectangles=rects, + interpolation_mode=data.get("interpolation_mode", "average"), + smoothing=float(data.get("smoothing") if data.get("smoothing") is not None else 0.3), + brightness=float(data.get("brightness") if data.get("brightness") is not None else 1.0), + brightness_value_source_id=data.get("brightness_value_source_id", ""), + ) + + @classmethod + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + picture_source_id=None, + rectangles=None, + interpolation_mode=None, + smoothing=None, + brightness=None, + brightness_value_source_id=None, + **_kwargs, + ): + rects = [ + KeyColorRectangle.from_dict(r) if isinstance(r, dict) else r for r in (rectangles or []) + ] + return cls( + id=id, + name=name, + source_type="key_colors", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], + picture_source_id=picture_source_id or "", + rectangles=rects, + interpolation_mode=interpolation_mode or "average", + smoothing=float(smoothing) if smoothing is not None else 0.3, + brightness=float(brightness) if brightness is not None else 1.0, + brightness_value_source_id=brightness_value_source_id or "", + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("picture_source_id") is not None: + self.picture_source_id = resolve_ref( + kwargs["picture_source_id"], self.picture_source_id + ) + if kwargs.get("rectangles") is not None: + raw = kwargs["rectangles"] + self.rectangles = [ + KeyColorRectangle.from_dict(r) if isinstance(r, dict) else r for r in raw + ] + if kwargs.get("interpolation_mode") is not None: + self.interpolation_mode = kwargs["interpolation_mode"] + if kwargs.get("smoothing") is not None: + self.smoothing = float(kwargs["smoothing"]) + if kwargs.get("brightness") is not None: + self.brightness = float(kwargs["brightness"]) + if kwargs.get("brightness_value_source_id") is not None: + self.brightness_value_source_id = resolve_ref( + kwargs["brightness_value_source_id"], self.brightness_value_source_id + ) + + # -- Source type registry -- # Maps source_type string to its subclass for factory dispatch. _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { @@ -1190,4 +1649,5 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { "candlelight": CandlelightColorStripSource, "processed": ProcessedColorStripSource, "weather": WeatherColorStripSource, + "key_colors": KeyColorsColorStripSource, } diff --git a/server/src/wled_controller/storage/database.py b/server/src/wled_controller/storage/database.py index 3dfe0d4..5b213ed 100644 --- a/server/src/wled_controller/storage/database.py +++ b/server/src/wled_controller/storage/database.py @@ -44,7 +44,6 @@ _ENTITY_TABLES = [ "postprocessing_templates", "picture_sources", "output_targets", - "pattern_templates", "color_strip_sources", "audio_sources", "audio_templates", diff --git a/server/src/wled_controller/storage/output_target.py b/server/src/wled_controller/storage/output_target.py index 6d71eaa..70e0f97 100644 --- a/server/src/wled_controller/storage/output_target.py +++ b/server/src/wled_controller/storage/output_target.py @@ -72,10 +72,6 @@ class OutputTarget: from wled_controller.storage.wled_output_target import WledOutputTarget return WledOutputTarget.from_dict(data) - if target_type == "key_colors": - from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget - - return KeyColorsOutputTarget.from_dict(data) if target_type == "ha_light": from wled_controller.storage.ha_light_output_target import HALightOutputTarget diff --git a/server/src/wled_controller/storage/output_target_store.py b/server/src/wled_controller/storage/output_target_store.py index a2ab022..16e9a80 100644 --- a/server/src/wled_controller/storage/output_target_store.py +++ b/server/src/wled_controller/storage/output_target_store.py @@ -8,10 +8,6 @@ from wled_controller.storage.base_sqlite_store import BaseSqliteStore from wled_controller.storage.database import Database from wled_controller.storage.output_target import OutputTarget from wled_controller.storage.wled_output_target import WledOutputTarget -from wled_controller.storage.key_colors_output_target import ( - KeyColorsSettings, - KeyColorsOutputTarget, -) from wled_controller.storage.ha_light_output_target import ( HALightMapping, HALightOutputTarget, @@ -50,9 +46,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold: int = 0, adaptive_fps: bool = False, protocol: str = "ddp", - key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, - picture_source_id: str = "", tags: Optional[List[str]] = None, ha_source_id: str = "", ha_light_mappings: Optional[List[HALightMapping]] = None, @@ -65,7 +59,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): Raises: ValueError: If validation fails """ - if target_type not in ("led", "key_colors", "ha_light"): + if target_type not in ("led", "ha_light"): raise ValueError(f"Invalid target type: {target_type}") # Check for duplicate name @@ -94,17 +88,6 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): created_at=now, updated_at=now, ) - elif target_type == "key_colors": - target = KeyColorsOutputTarget( - id=target_id, - name=name, - target_type="key_colors", - picture_source_id=picture_source_id, - settings=key_colors_settings or KeyColorsSettings(), - description=description, - created_at=now, - updated_at=now, - ) elif target_type == "ha_light": target = HALightOutputTarget( id=target_id, @@ -144,9 +127,13 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold: Optional[int] = None, adaptive_fps: Optional[bool] = None, protocol: Optional[str] = None, - key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, tags: Optional[List[str]] = None, + ha_source_id: Optional[str] = None, + ha_light_mappings: Optional[List[HALightMapping]] = None, + update_rate: Optional[float] = None, + transition: Optional[float] = None, + color_tolerance: Optional[int] = None, ) -> OutputTarget: """Update an output target. @@ -175,9 +162,13 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold=min_brightness_threshold, adaptive_fps=adaptive_fps, protocol=protocol, - key_colors_settings=key_colors_settings, description=description, tags=tags, + ha_source_id=ha_source_id, + light_mappings=ha_light_mappings, + update_rate=update_rate, + transition=transition, + color_tolerance=color_tolerance, ) target.updated_at = datetime.now(timezone.utc) @@ -194,14 +185,6 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): if isinstance(t, WledOutputTarget) and t.device_id == device_id ] - def get_targets_referencing_source(self, source_id: str) -> List[str]: - """Return names of KC targets that reference a picture source.""" - return [ - target.name - for target in self._items.values() - if isinstance(target, KeyColorsOutputTarget) and target.picture_source_id == source_id - ] - def get_targets_referencing_css(self, css_id: str) -> List[str]: """Return names of targets that reference a color strip source.""" result = [] diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 0080f6a..92d05bc 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -191,7 +191,6 @@ {% include 'modals/gradient-editor.html' %} {% include 'modals/test-css-source.html' %} {% include 'modals/notification-history.html' %} - {% include 'modals/kc-editor.html' %} {% include 'modals/pattern-template.html' %} {% include 'modals/api-key.html' %} {% include 'modals/confirm.html' %} diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 3373a39..ca03521 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -37,6 +37,7 @@ +
@@ -676,6 +677,40 @@
+ + +
diff --git a/server/src/wled_controller/templates/modals/ha-light-editor.html b/server/src/wled_controller/templates/modals/ha-light-editor.html index 41e8611..8c87ec6 100644 --- a/server/src/wled_controller/templates/modals/ha-light-editor.html +++ b/server/src/wled_controller/templates/modals/ha-light-editor.html @@ -64,14 +64,27 @@ oninput="document.getElementById('ha-light-editor-transition-display').textContent = parseFloat(this.value).toFixed(1)">
+ +
+
+ +
+ +
+
- +
- Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color. +
+
diff --git a/server/src/wled_controller/templates/modals/pattern-template.html b/server/src/wled_controller/templates/modals/pattern-template.html index b390e34..14ca5a0 100644 --- a/server/src/wled_controller/templates/modals/pattern-template.html +++ b/server/src/wled_controller/templates/modals/pattern-template.html @@ -9,7 +9,7 @@
-
+
@@ -19,7 +19,7 @@
-
+
diff --git a/server/src/wled_controller/utils/sound_player.py b/server/src/wled_controller/utils/sound_player.py index 879aec9..5f3fec9 100644 --- a/server/src/wled_controller/utils/sound_player.py +++ b/server/src/wled_controller/utils/sound_player.py @@ -19,15 +19,74 @@ logger = get_logger(__name__) # Lock + handle for cancelling previous sound _play_lock = threading.Lock() _current_process: subprocess.Popen | None = None +# Hold reference to SND_MEMORY buffer to prevent GC during async playback +_win_sound_buf: bytes | None = None + + +def _scale_wav_volume(file_path: Path, volume: float) -> bytes | None: + """Read a WAV file and return a volume-scaled WAV as bytes. + + Uses stdlib wave + struct to scale PCM samples in memory. + Returns None on error or if the format is unsupported. + """ + import io + import struct + import wave + + try: + with wave.open(str(file_path), "rb") as wf: + n_channels = wf.getnchannels() + sample_width = wf.getsampwidth() + framerate = wf.getframerate() + n_frames = wf.getnframes() + raw = wf.readframes(n_frames) + except Exception as e: + logger.debug(f"Failed to read WAV for volume scaling: {e}") + return None + + if sample_width not in (1, 2): + return None # Only 8-bit and 16-bit PCM supported + + # Scale samples + if sample_width == 2: + fmt = f"<{len(raw) // 2}h" + samples = struct.unpack(fmt, raw) + scaled = struct.pack(fmt, *(max(-32768, min(32767, int(s * volume))) for s in samples)) + else: + # 8-bit WAV is unsigned, center at 128 + samples = struct.unpack(f"{len(raw)}B", raw) + scaled = struct.pack( + f"{len(raw)}B", + *(max(0, min(255, int((s - 128) * volume + 128))) for s in samples), + ) + + # Write scaled WAV to memory buffer + buf = io.BytesIO() + with wave.open(buf, "wb") as out: + out.setnchannels(n_channels) + out.setsampwidth(sample_width) + out.setframerate(framerate) + out.writeframes(scaled) + return buf.getvalue() def _play_windows(file_path: Path, volume: float) -> None: - """Play a WAV file on Windows using winsound.""" + """Play a WAV file on Windows using winsound with volume scaling.""" import winsound - # winsound doesn't support volume control natively, - # but SND_ASYNC plays non-blocking within this thread + global _win_sound_buf + try: + if volume < 1.0: + wav_data = _scale_wav_volume(file_path, volume) + if wav_data: + # Keep a global reference so GC doesn't free the buffer + # while async playback is still using it + _win_sound_buf = wav_data + winsound.PlaySound(wav_data, winsound.SND_MEMORY | winsound.SND_ASYNC) + return + # Full volume or fallback: play file directly + _win_sound_buf = None winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC) except Exception as e: logger.error(f"winsound playback failed: {e}") @@ -110,6 +169,7 @@ def stop_current_sound() -> None: if sys.platform == "win32": try: import winsound + winsound.PlaySound(None, winsound.SND_PURGE) except Exception as e: logger.debug("Failed to stop winsound playback: %s", e) diff --git a/server/tests/storage/test_output_target_store.py b/server/tests/storage/test_output_target_store.py index bb1a254..3d5c96a 100644 --- a/server/tests/storage/test_output_target_store.py +++ b/server/tests/storage/test_output_target_store.py @@ -5,9 +5,6 @@ import pytest from wled_controller.storage.output_target import OutputTarget from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.wled_output_target import WledOutputTarget -from wled_controller.storage.key_colors_output_target import ( - KeyColorsOutputTarget, -) @pytest.fixture @@ -37,18 +34,17 @@ class TestOutputTargetModel: assert isinstance(target, WledOutputTarget) assert target.device_id == "dev_1" - def test_key_colors_from_dict(self): + def test_key_colors_type_rejected(self): + """key_colors target type removed — from_dict raises ValueError.""" data = { "id": "pt_2", "name": "KC Target", "target_type": "key_colors", - "picture_source_id": "ps_1", - "settings": {}, "created_at": "2025-01-01T00:00:00+00:00", "updated_at": "2025-01-01T00:00:00+00:00", } - target = OutputTarget.from_dict(data) - assert isinstance(target, KeyColorsOutputTarget) + with pytest.raises(ValueError, match="Unknown target type"): + OutputTarget.from_dict(data) def test_unknown_type_raises(self): data = { @@ -82,14 +78,10 @@ class TestOutputTargetStoreCRUD: assert t.name == "LED 1" assert store.count() == 1 - def test_create_key_colors_target(self, store): - t = store.create_target( - name="KC 1", - target_type="key_colors", - picture_source_id="ps_1", - ) - assert isinstance(t, KeyColorsOutputTarget) - assert t.picture_source_id == "ps_1" + def test_create_key_colors_target_rejected(self, store): + """key_colors target type is no longer supported (migrated to CSS source).""" + with pytest.raises(ValueError, match="Invalid target type"): + store.create_target(name="KC 1", target_type="key_colors") def test_create_invalid_type(self, store): with pytest.raises(ValueError, match="Invalid target type"): @@ -194,11 +186,13 @@ class TestOutputTargetQueries: class TestOutputTargetPersistence: def test_persist_and_reload(self, tmp_path): from wled_controller.storage.database import Database + db_path = str(tmp_path / "ot_persist.db") db = Database(db_path) s1 = OutputTargetStore(db) t = s1.create_target( - "Persist", "led", + "Persist", + "led", device_id="dev_1", fps=60, tags=["tv"], diff --git a/server/tests/test_processor_manager.py b/server/tests/test_processor_manager.py index c0e2488..9e1a374 100644 --- a/server/tests/test_processor_manager.py +++ b/server/tests/test_processor_manager.py @@ -2,7 +2,10 @@ import pytest -from wled_controller.core.processing.processor_manager import ProcessorDependencies, ProcessorManager +from wled_controller.core.processing.processor_manager import ( + ProcessorDependencies, + ProcessorManager, +) @pytest.fixture @@ -230,8 +233,6 @@ def test_get_target_metrics(processor_manager): def test_target_type_detection(processor_manager): """Test target type detection via processor instances.""" - from wled_controller.storage.key_colors_output_target import KeyColorsSettings - from wled_controller.core.processing.kc_target_processor import KCTargetProcessor from wled_controller.core.processing.wled_target_processor import WledTargetProcessor processor_manager.add_device( @@ -245,13 +246,6 @@ def test_target_type_detection(processor_manager): device_id="test_device", ) - processor_manager.add_kc_target( - target_id="kc_target", - picture_source_id="src_1", - settings=KeyColorsSettings(), - ) - - assert isinstance(processor_manager._processors["kc_target"], KCTargetProcessor) assert isinstance(processor_manager._processors["wled_target"], WledTargetProcessor)