From a79f4bf73c01d3556c3a5f7840d796f0547e3573 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 4 May 2026 14:27:22 +0300 Subject: [PATCH] feat(ha-light): broadcast a single Color Value Source to all entities HALightOutputTarget gains a `source_kind` field with two modes: - `css` (existing): per-mapping LED segments averaged from a ColorStripSource. - `color_vs` (new): one colour from a colour-returning ValueSource pushed to every mapped entity (mapping LED ranges are ignored in this mode). Backend wiring: - Schema/route: add `source_kind` + `color_value_source_id` to create/update/ response payloads, with VS existence + return_type=color validation. - Storage: persist new fields, with defensive `or ""` coalesce so legacy rows written via resolve_ref with None survive the str-typed response schema. - Processor: ha_light_target_processor reworked to drive both source kinds (incl. update_target_settings hot-swap of source mode). New unit tests in tests/core/test_ha_light_target_processor.py and extended store tests. Frontend: - ha-light editor modal: collapsed Color Strip + Color VS into one "Color Source" picker with grouped headers; mappings list shows a mode-aware hint when broadcasting a single colour. - EntityPalette: support non-selectable header rows (with keyboard / filter handling) for grouped source pickers. Bundled UI polish (icon inheritance + cleanup): - Custom card icons now flow into more surfaces: command palette, dashboard target cards, scene-preset target picker, calibration test-device picker, and the LED-target device picker. LED targets inherit their device's icon when none is set on the target itself. - Empty mod-card icon plates render as a dashed "+" placeholder when an icon-picker hook is wired, so the action stays discoverable. - Icon picker: distinct "HA light target" eyebrow label and supports HA-light cards (data-ha-target-id) for channel-colour resolution. - Update banner: "View release" now opens the in-app Update settings tab instead of an external link; uses the sparkles icon. - Color-strip delete: cleaner toast on 409 conflict. --- .../src/ledgrab/api/routes/output_targets.py | 69 ++++- .../src/ledgrab/api/schemas/output_targets.py | 35 ++- .../processing/ha_light_target_processor.py | 283 ++++++++++++------ .../core/processing/processor_manager.py | 4 + server/src/ledgrab/static/css/components.css | 11 + server/src/ledgrab/static/css/dashboard.css | 29 ++ server/src/ledgrab/static/css/layout.css | 96 +++++- server/src/ledgrab/static/js/app.ts | 2 + .../ledgrab/static/js/core/command-palette.ts | 18 +- .../ledgrab/static/js/core/device-icons.ts | 9 + .../ledgrab/static/js/core/entity-palette.ts | 86 ++++-- .../src/ledgrab/static/js/core/icon-select.ts | 2 + server/src/ledgrab/static/js/core/mod-card.ts | 18 +- .../ledgrab/static/js/features/calibration.ts | 3 +- .../static/js/features/color-strips/index.ts | 4 +- .../ledgrab/static/js/features/dashboard.ts | 37 ++- .../static/js/features/ha-light-targets.ts | 219 ++++++++++++-- .../ledgrab/static/js/features/icon-picker.ts | 23 +- .../static/js/features/scene-presets.ts | 23 +- .../src/ledgrab/static/js/features/targets.ts | 4 +- .../src/ledgrab/static/js/features/update.ts | 15 +- server/src/ledgrab/static/js/types.ts | 6 + server/src/ledgrab/static/locales/en.json | 7 + server/src/ledgrab/static/locales/ru.json | 7 + server/src/ledgrab/static/locales/zh.json | 7 + .../ledgrab/storage/ha_light_output_target.py | 54 +++- .../ledgrab/storage/output_target_store.py | 8 + .../templates/modals/ha-light-editor.html | 5 +- .../core/test_ha_light_target_processor.py | 252 ++++++++++++++++ .../tests/storage/test_output_target_store.py | 96 ++++++ 30 files changed, 1239 insertions(+), 193 deletions(-) create mode 100644 server/tests/core/test_ha_light_target_processor.py diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py index dc924e6..1dae68d 100644 --- a/server/src/ledgrab/api/routes/output_targets.py +++ b/server/src/ledgrab/api/routes/output_targets.py @@ -11,6 +11,7 @@ from ledgrab.api.dependencies import ( get_device_store, get_output_target_store, get_processor_manager, + get_value_source_store, ) from ledgrab.api.schemas.output_targets import ( HALightMappingSchema, @@ -30,6 +31,7 @@ from ledgrab.storage.ha_light_output_target import ( HALightOutputTarget, ) from ledgrab.storage.output_target_store import OutputTargetStore +from ledgrab.storage.value_source_store import ValueSourceStore from ledgrab.utils import get_logger from ledgrab.storage.base_store import EntityNotFoundError @@ -68,8 +70,11 @@ def _ha_light_target_to_response( return HALightOutputTargetResponse( id=target.id, name=target.name, - ha_source_id=target.ha_source_id, - color_strip_source_id=target.color_strip_source_id, + ha_source_id=target.ha_source_id or "", + source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css", + # Defensive coalesce — older records stored via resolve_ref may hold None. + color_strip_source_id=target.color_strip_source_id or "", + color_value_source_id=target.color_value_source_id or "", brightness=target.brightness.to_dict(), ha_light_mappings=[ HALightMappingSchema( @@ -94,6 +99,32 @@ def _ha_light_target_to_response( ) +def _validate_color_value_source( + value_source_store: ValueSourceStore, color_value_source_id: str +) -> None: + """Ensure the referenced ValueSource exists and returns colour.""" + if not color_value_source_id: + raise HTTPException( + status_code=400, + detail="color_value_source_id is required when source_kind='color_vs'", + ) + try: + source = value_source_store.get_source(color_value_source_id) + except (ValueError, EntityNotFoundError): + raise HTTPException( + status_code=422, + detail=f"Color value source {color_value_source_id} not found", + ) + if source.to_dict().get("return_type") != "color": + raise HTTPException( + status_code=400, + detail=( + f"Value source {color_value_source_id} does not return colour " + "(return_type must be 'color')" + ), + ) + + def _target_to_response(target) -> OutputTargetResponse: """Convert any OutputTarget to the appropriate typed response.""" if isinstance(target, WledOutputTarget): @@ -124,6 +155,7 @@ async def create_target( target_store: OutputTargetStore = Depends(get_output_target_store), device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), + value_source_store: ValueSourceStore = Depends(get_value_source_store), ): """Create a new output target.""" try: @@ -135,6 +167,15 @@ async def create_target( except ValueError: raise HTTPException(status_code=422, detail=f"Device {device_id} not found") + # Validate color VS reference for HA-light targets in color_vs mode + if ( + getattr(data, "target_type", "") == "ha_light" + and getattr(data, "source_kind", "css") == "color_vs" + ): + _validate_color_value_source( + value_source_store, getattr(data, "color_value_source_id", "") + ) + ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) ha_mappings = ( [ @@ -166,6 +207,8 @@ async def create_target( description=data.description, tags=data.tags, ha_source_id=getattr(data, "ha_source_id", ""), + source_kind=getattr(data, "source_kind", "css"), + color_value_source_id=getattr(data, "color_value_source_id", ""), ha_light_mappings=ha_mappings, update_rate=getattr(data, "update_rate", 2.0), transition=getattr(data, "transition", 0.5), @@ -249,6 +292,7 @@ async def update_target( target_store: OutputTargetStore = Depends(get_output_target_store), device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), + value_source_store: ValueSourceStore = Depends(get_value_source_store), ): """Update a output target.""" try: @@ -260,6 +304,21 @@ async def update_target( except ValueError: raise HTTPException(status_code=422, detail=f"Device {device_id} not found") + # Validate color VS reference for HA-light targets switching into / staying in color_vs + if getattr(data, "target_type", "") == "ha_light": + new_kind = getattr(data, "source_kind", None) + new_color_vs = getattr(data, "color_value_source_id", None) + if new_kind == "color_vs" or (new_kind is None and new_color_vs): + # Determine effective id: payload id if provided, else existing target's id + effective_id = new_color_vs + if effective_id is None: + try: + existing = target_store.get_target(target_id) + effective_id = getattr(existing, "color_value_source_id", "") + except ValueError: + effective_id = "" + _validate_color_value_source(value_source_store, effective_id or "") + # Build HA light mappings if provided ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) ha_mappings = None @@ -292,6 +351,8 @@ async def update_target( icon=data.icon, icon_color=data.icon_color, ha_source_id=getattr(data, "ha_source_id", None), + source_kind=getattr(data, "source_kind", None), + color_value_source_id=getattr(data, "color_value_source_id", None), ha_light_mappings=ha_mappings, update_rate=getattr(data, "update_rate", None), transition=getattr(data, "transition", None), @@ -311,6 +372,8 @@ async def update_target( color_tolerance = getattr(data, "color_tolerance", None) brightness = getattr(data, "brightness", None) stop_action = getattr(data, "stop_action", None) + source_kind = getattr(data, "source_kind", None) + color_value_source_id = getattr(data, "color_value_source_id", None) try: await asyncio.to_thread( @@ -328,6 +391,8 @@ async def update_target( or ha_light_mappings_raw is not None or brightness is not None or stop_action is not None + or source_kind is not None + or color_value_source_id is not None ), css_changed=color_strip_source_id is not None, brightness_changed=brightness is not None, diff --git a/server/src/ledgrab/api/schemas/output_targets.py b/server/src/ledgrab/api/schemas/output_targets.py index 3040219..234789d 100644 --- a/server/src/ledgrab/api/schemas/output_targets.py +++ b/server/src/ledgrab/api/schemas/output_targets.py @@ -83,7 +83,19 @@ class LedOutputTargetResponse(_OutputTargetResponseBase): class HALightOutputTargetResponse(_OutputTargetResponseBase): target_type: Literal["ha_light"] = "ha_light" ha_source_id: str = Field(default="", description="Home Assistant source ID") - color_strip_source_id: str = Field(default="", description="Color strip source ID") + source_kind: Literal["css", "color_vs"] = Field( + default="css", + description="Colour source kind: 'css' (per-mapping LED segments) or " + "'color_vs' (single colour value source applied to all entities).", + ) + color_strip_source_id: str = Field( + default="", description="Color strip source ID (used when source_kind='css')" + ) + color_value_source_id: str = Field( + default="", + description="Colour value source ID (used when source_kind='color_vs'); " + "must reference a value source whose return_type='color'.", + ) brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( None, description="LED-to-light mappings" @@ -173,7 +185,18 @@ class LedOutputTargetCreate(_OutputTargetCreateBase): class HALightOutputTargetCreate(_OutputTargetCreateBase): target_type: Literal["ha_light"] = "ha_light" ha_source_id: str = Field(default="", description="Home Assistant source ID") - color_strip_source_id: str = Field(default="", description="Color strip source ID") + source_kind: Literal["css", "color_vs"] = Field( + default="css", + description="Colour source kind: 'css' (per-mapping LED segments) or " + "'color_vs' (single colour value source applied to all entities).", + ) + color_strip_source_id: str = Field( + default="", description="Color strip source ID (used when source_kind='css')" + ) + color_value_source_id: str = Field( + default="", + description="Colour value source ID (used when source_kind='color_vs').", + ) brightness: Optional[BindableFloatInput] = Field( default=1.0, description="Brightness (bindable)" ) @@ -256,7 +279,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase): class HALightOutputTargetUpdate(_OutputTargetUpdateBase): target_type: Literal["ha_light"] = "ha_light" ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID") + source_kind: Optional[Literal["css", "color_vs"]] = Field( + None, + description="Colour source kind: 'css' or 'color_vs'.", + ) color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") + color_value_source_id: Optional[str] = Field( + None, + description="Colour value source ID (used when source_kind='color_vs').", + ) brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( None, description="LED-to-light mappings" diff --git a/server/src/ledgrab/core/processing/ha_light_target_processor.py b/server/src/ledgrab/core/processing/ha_light_target_processor.py index cd1f9e4..4223c12 100644 --- a/server/src/ledgrab/core/processing/ha_light_target_processor.py +++ b/server/src/ledgrab/core/processing/ha_light_target_processor.py @@ -26,7 +26,9 @@ class HALightTargetProcessor(TargetProcessor): self, target_id: str, ha_source_id: str, + source_kind: str = "css", color_strip_source_id: str = "", + color_value_source_id: str = "", brightness=None, # legacy compat brightness_value_source_id: str = "", @@ -42,7 +44,9 @@ class HALightTargetProcessor(TargetProcessor): super().__init__(target_id, ctx) self._ha_source_id = ha_source_id + self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css" self._css_id = color_strip_source_id + self._color_vs_id = color_value_source_id # Accept BindableFloat or legacy string if brightness is not None and isinstance(brightness, BindableFloat): self._brightness = brightness @@ -63,6 +67,7 @@ class HALightTargetProcessor(TargetProcessor): # Runtime state self._css_stream = None + self._color_stream = None # color-returning ValueStream (source_kind="color_vs") self._ha_runtime = None self._value_stream = None # brightness value source stream self._previous_colors: Dict[str, Tuple[int, int, int]] = {} @@ -81,14 +86,23 @@ class HALightTargetProcessor(TargetProcessor): if self._is_running: return - # Acquire CSS stream - if self._css_id and self._ctx.color_strip_stream_manager: - try: - self._css_stream = self._ctx.color_strip_stream_manager.acquire( - self._css_id, self._target_id - ) - except Exception as e: - logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}") + # Acquire colour source — CSS stream OR colour value stream depending on mode. + if self._source_kind == "color_vs": + if self._color_vs_id and self._ctx.value_stream_manager: + try: + self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id) + except Exception as e: + logger.warning( + f"HA light {self._target_id}: failed to acquire color VS stream: {e}" + ) + else: + if self._css_id and self._ctx.color_strip_stream_manager: + try: + self._css_stream = self._ctx.color_strip_stream_manager.acquire( + self._css_id, self._target_id + ) + except Exception as e: + logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}") # Acquire HA runtime try: @@ -145,6 +159,14 @@ class HALightTargetProcessor(TargetProcessor): pass self._css_stream = None + # Release colour value stream (color_vs mode) + if self._color_stream is not None and self._ctx.value_stream_manager: + try: + self._ctx.value_stream_manager.release(self._color_vs_id) + except Exception: + pass + self._color_stream = None + # Release brightness value stream if self._value_stream is not None and self._ctx.value_stream_manager: try: @@ -200,13 +222,26 @@ class HALightTargetProcessor(TargetProcessor): sa = settings["stop_action"] if sa in ("none", "turn_off", "restore"): self._stop_action = sa + # source_kind / color_value_source_id swap is handled here so that + # toggling modes (or repointing the colour VS) takes effect without + # restarting the target. CSS swaps continue to flow through + # update_css_source(). + new_kind = settings.get("source_kind") + new_color_vs = settings.get("color_value_source_id") + kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind + color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id + if kind_changed or color_vs_changed: + self._swap_color_source( + new_kind if kind_changed else self._source_kind, + new_color_vs if new_color_vs is not None else self._color_vs_id, + ) def update_css_source(self, color_strip_source_id: str) -> None: - """Hot-swap the CSS stream.""" + """Hot-swap the CSS stream (only meaningful when source_kind='css').""" old_id = self._css_id self._css_id = color_strip_source_id - if self._is_running and self._ctx.color_strip_stream_manager: + if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager: try: new_stream = self._ctx.color_strip_stream_manager.acquire( color_strip_source_id, self._target_id @@ -218,6 +253,52 @@ class HALightTargetProcessor(TargetProcessor): except Exception as e: logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}") + def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None: + """Release the previous colour stream and acquire the new one.""" + # Tear down previous stream first to keep ref-counts honest. + if self._is_running: + if self._css_stream and self._ctx.color_strip_stream_manager: + try: + self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id) + except Exception: + pass + self._css_stream = None + if self._color_stream is not None and self._ctx.value_stream_manager: + try: + self._ctx.value_stream_manager.release(self._color_vs_id) + except Exception: + pass + self._color_stream = None + + self._source_kind = new_kind + self._color_vs_id = new_color_vs_id + + # Reset per-entity history so the new source isn't gated by stale values. + self._previous_colors.clear() + self._previous_on.clear() + + if not self._is_running: + return + + if self._source_kind == "color_vs": + if self._color_vs_id and self._ctx.value_stream_manager: + try: + self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id) + except Exception as e: + logger.warning( + f"HA light {self._target_id}: failed to acquire color VS stream: {e}" + ) + else: + if self._css_id and self._ctx.color_strip_stream_manager: + try: + self._css_stream = self._ctx.color_strip_stream_manager.acquire( + self._css_id, self._target_id + ) + except Exception as e: + logger.warning( + f"HA light {self._target_id}: failed to re-acquire CSS stream: {e}" + ) + # ── WebSocket clients ── def add_ws_client(self, ws: Any) -> None: @@ -240,7 +321,9 @@ class HALightTargetProcessor(TargetProcessor): "target_id": self._target_id, "processing": self._is_running, "ha_source_id": self._ha_source_id, + "source_kind": self._source_kind, "css_id": self._css_id, + "color_value_source_id": self._color_vs_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), @@ -267,17 +350,28 @@ class HALightTargetProcessor(TargetProcessor): } async def _processing_loop(self) -> None: - """Main loop: read CSS colors, average per mapping, send to HA lights.""" + """Main loop: read source colour(s) and send to HA lights.""" interval = 1.0 / self._update_rate while self._is_running: try: loop_start = time.monotonic() - if self._css_stream and self._ha_runtime and self._ha_runtime.is_connected: - colors = self._css_stream.get_latest_colors() - if colors is not None and len(colors) > 0: - await self._update_lights(colors) + ha_ready = self._ha_runtime and self._ha_runtime.is_connected + if ha_ready: + if self._source_kind == "color_vs" and self._color_stream is not None: + try: + color = self._color_stream.get_color() + except Exception: + color = None + if isinstance(color, (list, tuple)) and len(color) >= 3: + await self._update_lights_single_color( + int(color[0]), int(color[1]), int(color[2]) + ) + elif self._css_stream is not None: + colors = self._css_stream.get_latest_colors() + if colors is not None and len(colors) > 0: + await self._update_lights(colors) # Sleep for remaining frame time elapsed = time.monotonic() - loop_start @@ -290,99 +384,110 @@ class HALightTargetProcessor(TargetProcessor): logger.error(f"HA light {self._target_id} loop error: {e}") await asyncio.sleep(1.0) - async def _update_lights(self, colors: np.ndarray) -> None: - """Average LED segments and call HA services for changed lights.""" - led_count = len(colors) + def _read_brightness_multiplier(self) -> float: + if self._value_stream is None: + return 1.0 + try: + return float(self._value_stream.get_value()) + except Exception: + return 1.0 - # Get brightness multiplier from value source (1.0 if not configured) - vs_multiplier = 1.0 - if self._value_stream is not None: - try: - vs_multiplier = self._value_stream.get_value() - except Exception: - vs_multiplier = 1.0 + async def _send_entity_color( + self, mapping: HALightMapping, r: int, g: int, b: int, vs_multiplier: float + ) -> None: + """Apply tolerance/threshold gates and push one entity update.""" + entity_id = mapping.entity_id + # Cache for WS preview (always, even if HA call is skipped) + self._latest_entity_colors[entity_id] = (r, g, b) + + # Calculate brightness (0-255) from max channel + brightness = max(r, g, b) + + bs = ( + mapping.brightness_scale.value + if hasattr(mapping.brightness_scale, "value") + else mapping.brightness_scale + ) + eff_scale = bs * vs_multiplier + if eff_scale < 1.0: + brightness = int(brightness * eff_scale) + + should_be_on = ( + brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0 + ) + + prev_color = self._previous_colors.get(entity_id) + was_on = self._previous_on.get(entity_id, True) + + if should_be_on: + new_color = (r, g, b) + if prev_color is not None and was_on: + dr = abs(r - prev_color[0]) + dg = abs(g - prev_color[1]) + db = abs(b - prev_color[2]) + if max(dr, dg, db) < self._color_tolerance: + return # skip — colour hasn't changed enough + + service_data = { + "rgb_color": [r, g, b], + "brightness": min(255, int(brightness * bs)), + } + transition_val = self._transition.value + if transition_val > 0: + service_data["transition"] = transition_val + + await self._ha_runtime.call_service( + domain="light", + service="turn_on", + service_data=service_data, + target={"entity_id": entity_id}, + ) + self._previous_colors[entity_id] = new_color + self._previous_on[entity_id] = True + + elif was_on: + await self._ha_runtime.call_service( + domain="light", + service="turn_off", + service_data={}, + target={"entity_id": entity_id}, + ) + self._previous_on[entity_id] = False + self._previous_colors.pop(entity_id, None) + + async def _update_lights(self, colors: np.ndarray) -> None: + """CSS mode: average each mapping's LED segment and dispatch.""" + led_count = len(colors) + vs_multiplier = self._read_brightness_multiplier() for mapping in self._light_mappings: if not mapping.entity_id: continue - # Resolve LED range start = max(0, mapping.led_start) end = mapping.led_end if mapping.led_end >= 0 else led_count end = min(end, led_count) if start >= end: continue - # Average the LED segment segment = colors[start:end] avg = segment.mean(axis=0).astype(int) - r, g, b = int(avg[0]), int(avg[1]), int(avg[2]) - - # Cache for WS preview (always, even if HA call is skipped) - self._latest_entity_colors[mapping.entity_id] = (r, g, b) - - # Calculate brightness (0-255) from max channel - brightness = max(r, g, b) - - # Apply brightness scale and value source multiplier - bs = ( - mapping.brightness_scale.value - if hasattr(mapping.brightness_scale, "value") - else mapping.brightness_scale - ) - eff_scale = bs * vs_multiplier - if eff_scale < 1.0: - brightness = int(brightness * eff_scale) - - # Check brightness threshold - should_be_on = ( - brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0 + await self._send_entity_color( + mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier ) - entity_id = mapping.entity_id - prev_color = self._previous_colors.get(entity_id) - was_on = self._previous_on.get(entity_id, True) + if self._ws_clients and self._latest_entity_colors: + await self._broadcast_entity_colors() - if should_be_on: - # Check if color changed beyond tolerance - new_color = (r, g, b) - if prev_color is not None and was_on: - dr = abs(r - prev_color[0]) - dg = abs(g - prev_color[1]) - db = abs(b - prev_color[2]) - if max(dr, dg, db) < self._color_tolerance: - continue # skip — color hasn't changed enough + async def _update_lights_single_color(self, r: int, g: int, b: int) -> None: + """color_vs mode: push the same RGB triple to every mapping.""" + vs_multiplier = self._read_brightness_multiplier() - # Call light.turn_on - service_data = { - "rgb_color": [r, g, b], - "brightness": min(255, int(brightness * bs)), - } - transition_val = self._transition.value - if transition_val > 0: - service_data["transition"] = transition_val + for mapping in self._light_mappings: + if not mapping.entity_id: + continue + await self._send_entity_color(mapping, r, g, b, vs_multiplier) - await self._ha_runtime.call_service( - domain="light", - service="turn_on", - service_data=service_data, - target={"entity_id": entity_id}, - ) - self._previous_colors[entity_id] = new_color - self._previous_on[entity_id] = True - - elif was_on: - # Brightness dropped below threshold — turn off - await self._ha_runtime.call_service( - domain="light", - service="turn_off", - service_data={}, - target={"entity_id": entity_id}, - ) - self._previous_on[entity_id] = False - self._previous_colors.pop(entity_id, None) - - # Broadcast colors to WS clients if self._ws_clients and self._latest_entity_colors: await self._broadcast_entity_colors() diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index b5a732f..347a6a7 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -428,7 +428,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) self, target_id: str, ha_source_id: str, + source_kind: str = "css", color_strip_source_id: str = "", + color_value_source_id: str = "", brightness=None, # legacy compat brightness_value_source_id: str = "", @@ -448,7 +450,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) proc = HALightTargetProcessor( target_id=target_id, ha_source_id=ha_source_id, + source_kind=source_kind, color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, brightness=brightness, light_mappings=light_mappings or [], update_rate=update_rate, diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index db21a4b..caa398f 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -1116,6 +1116,17 @@ textarea:focus-visible { max-width: 40%; } +/* Section header rows in EntityPalette (non-selectable, used for grouping). */ +.entity-palette-header { + padding: 6px 14px 2px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + cursor: default; + user-select: none; +} + /* Entity Select trigger (replaces ${entityOptions}${extraOption} - -
+ // Per-source-kind row layout: CSS shows LED ranges; color_vs hides them and + // promotes the brightness scale to a single inline field. + const rangeBlock = _editorSourceKind === 'color_vs' + ? `
+
+ + +
+
` + : `
@@ -191,7 +254,19 @@ export function addHALightMapping(data: any = null): void {
+
`; + + row.innerHTML = ` +
+ ${_icon(P.lightbulb)} #${idx} + +
+
+
+ +
+ ${rangeBlock}
`; list.appendChild(row); @@ -206,6 +281,21 @@ export function addHALightMapping(data: any = null): void { _mappingEntitySelects.push(es); } +/** + * Re-render every mapping row using the current `_editorSourceKind` layout. + * Snapshots existing values (entity, ranges, brightness) so the user does not + * lose data when toggling between CSS and color_vs modes. + */ +function _rerenderMappingsForMode(): void { + const list = document.getElementById('ha-light-mappings-list'); + if (!list) return; + const snapshot = JSON.parse(_getMappingsJSON()); + _destroyMappingEntitySelects(); + list.innerHTML = ''; + snapshot.forEach((m: any) => addHALightMapping(m)); + _setMappingsModeHint(); +} + export function removeHALightMapping(btn: HTMLElement): void { const row = btn.closest('.ha-light-mapping-row'); if (!row) return; @@ -314,6 +404,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat valueSourcesCache.fetch().catch(() => {}), ]); _editorCssSources = cssSources; + _editorColorValueSources = (_cachedValueSources || []).filter(_isColorValueSource); const isEdit = !!targetId; const isClone = !!cloneData; @@ -328,11 +419,15 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat `` ).join(''); - // Populate CSS source dropdown - const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement; - cssSelect.innerHTML = `` + cssSources.map((s: any) => - `` + // Unified Color Source picker — combines CSS sources + colour-returning value sources. + const colorSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement; + const cssOptions = cssSources.map((s: any) => + `` ).join(''); + const colorVsOptions = _editorColorValueSources.map((s: any) => + `` + ).join(''); + colorSelect.innerHTML = `${cssOptions}${colorVsOptions}`; // Clear mappings _destroyMappingEntitySelects(); @@ -358,7 +453,12 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat if (isEdit) (document.getElementById('ha-light-editor-id') as HTMLInputElement).value = editData.id; (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || ''; haSelect.value = editData.ha_source_id || ''; - cssSelect.value = editData.color_strip_source_id || ''; + _editorSourceKind = (editData.source_kind === 'color_vs') ? 'color_vs' : 'css'; + _editorColorVsId = editData.color_value_source_id || ''; + const editCssId = editData.color_strip_source_id || ''; + colorSelect.value = _editorSourceKind === 'color_vs' + ? (_editorColorVsId ? `cvs:${_editorColorVsId}` : '') + : (editCssId ? `css:${editCssId}` : ''); _ensureBrightnessWidget().setValue(editData.brightness ?? 1.0); _ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0); _ensureTransitionWidget().setValue(editData.transition ?? 0.5); @@ -377,6 +477,8 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat mappings.forEach((m: any) => addHALightMapping(m)); } else { (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = ''; + _editorSourceKind = 'css'; + _editorColorVsId = ''; _ensureBrightnessWidget().setValue(1.0); _ensureUpdateRateWidget().setValue(2.0); _ensureTransitionWidget().setValue(0.5); @@ -394,6 +496,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat // Add one empty mapping by default addHALightMapping(); } + _setMappingsModeHint(); // EntitySelects if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } @@ -413,11 +516,26 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; } _cssSourceEntitySelect = new EntitySelect({ - target: cssSelect, - getItems: () => _editorCssSources.map((s: any) => ({ - value: s.id, label: s.name, icon: getColorStripIcon(s.source_type), desc: s.source_type, - })), + target: colorSelect, + getItems: () => _buildColorSourcePickerItems(), placeholder: t('palette.search'), + onChange: (rawValue: string) => { + // Decode "css:" or "cvs:" into source kind + id, then re-render + // mapping rows so the LED-range fields appear/disappear in step. + const newKind: HALightSourceKind = rawValue.startsWith('cvs:') ? 'color_vs' : 'css'; + const id = rawValue.includes(':') ? rawValue.slice(rawValue.indexOf(':') + 1) : ''; + if (newKind === 'color_vs') { + _editorColorVsId = id; + } else { + _editorColorVsId = ''; + } + if (newKind !== _editorSourceKind) { + _editorSourceKind = newKind; + _rerenderMappingsForMode(); + } else { + _setMappingsModeHint(); + } + }, }); // Tags @@ -439,7 +557,14 @@ export async function saveHALightEditor(): Promise { const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value; const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim(); const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value; - const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value; + const colorSourceRaw = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value; + // Decode the unified picker value: "css:" or "cvs:" (or "" for none). + const sourceKind: HALightSourceKind = colorSourceRaw.startsWith('cvs:') ? 'color_vs' : 'css'; + const colorSourceId = colorSourceRaw.includes(':') + ? colorSourceRaw.slice(colorSourceRaw.indexOf(':') + 1) + : ''; + const cssSourceId = sourceKind === 'css' ? colorSourceId : ''; + const colorValueSourceId = sourceKind === 'color_vs' ? colorSourceId : ''; const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0; const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5; const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5; @@ -457,6 +582,10 @@ export async function saveHALightEditor(): Promise { haLightEditorModal.showError(t('ha_light.error.ha_source_required')); return; } + if (sourceKind === 'color_vs' && !colorValueSourceId) { + haLightEditorModal.showError(t('ha_light.error.color_source_required')); + return; + } // Collect mappings const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id); @@ -466,7 +595,9 @@ export async function saveHALightEditor(): Promise { const payload: any = { name, ha_source_id: haSourceId, + source_kind: sourceKind, color_strip_source_id: cssSourceId, + color_value_source_id: colorValueSourceId, brightness, ha_light_mappings: mappings, update_rate: updateRate, @@ -561,7 +692,20 @@ export function createHALightTargetCard(target: any, haSourceMap: Record; /** Optional fallback icon (e.g. LED target → parent device). */ inheritedFrom(id: string): InheritedIcon | null; - /** Display label like "Device" / "LED target". */ - typeLabel(): string; + /** Display label like "Device" / "LED target" / "HA light target". */ + typeLabel(id: string): string; } function _readDevice(id: string): EntityRecord | null { @@ -121,7 +121,13 @@ const _adapters: Record = { fromName: dev?.name ?? deviceId, }; }, - typeLabel: () => t('device.icon.entity.target') || 'LED target', + typeLabel: (id: string) => { + const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id); + if ((tgt as any)?.target_type === 'ha_light') { + return t('device.icon.entity.ha_light_target') || 'HA light target'; + } + return t('device.icon.entity.target') || 'LED target'; + }, }, }; @@ -191,8 +197,13 @@ export function openIconPicker(entityType: EntityType, entityId: string): void { const inherited = adapter.inheritedFrom(entityId); // Resolve channel color from the live card so the preview matches. - const cardAttr = entityType === 'device' ? 'data-device-id' : 'data-target-id'; - const card = document.querySelector(`[${cardAttr}="${CSS.escape(entityId)}"]`) as HTMLElement | null; + // LED-target cards use ``data-target-id``; HA-light-target cards use + // ``data-ha-target-id``. Try the LED selector first and fall back to + // the HA-light one when the entity is a target. + const card = entityType === 'device' + ? document.querySelector(`[data-device-id="${CSS.escape(entityId)}"]`) as HTMLElement | null + : (document.querySelector(`[data-target-id="${CSS.escape(entityId)}"]`) + ?? document.querySelector(`[data-ha-target-id="${CSS.escape(entityId)}"]`)) as HTMLElement | null; const channelColor = card ? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel() : _fallbackChannel(); @@ -288,7 +299,7 @@ function _renderModal(): void { // Header — entity type + name, plus inherited hint when applicable. const adapter = _adapters[_ctx.entityType]; if (eyebrowEl) { - eyebrowEl.textContent = adapter.typeLabel(); + eyebrowEl.textContent = adapter.typeLabel(_ctx.entityId); } if (titleNameEl) titleNameEl.textContent = _ctx.entityName; if (subEl) { diff --git a/server/src/ledgrab/static/js/features/scene-presets.ts b/server/src/ledgrab/static/js/features/scene-presets.ts index 9ecd30f..8a2167c 100644 --- a/server/src/ledgrab/static/js/features/scene-presets.ts +++ b/server/src/ledgrab/static/js/features/scene-presets.ts @@ -11,7 +11,8 @@ import { CardSection } from '../core/card-sections.ts'; import { ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_TRASH, ICON_LINK, } from '../core/icons.ts'; -import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.ts'; +import { renderDeviceIcon } from '../core/device-icons.ts'; +import { scenePresetsCache, outputTargetsCache, automationsCacheObj, devicesCache } from '../core/state.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { wrapCard, cardColorStyle } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; @@ -361,26 +362,38 @@ function _refreshTargetSelect(): void { } } -function _addTargetToList(targetId: string, targetName: string): void { +function _addTargetToList(targetId: string, targetName: string, iconHtml?: string): void { const list = document.getElementById('scene-target-list'); if (!list) return; const item = document.createElement('div'); item.className = 'scene-target-item'; item.dataset.targetId = targetId; - item.innerHTML = `${ICON_TARGET} ${escapeHtml(targetName)}`; + item.innerHTML = `${iconHtml || ICON_TARGET} ${escapeHtml(targetName)}`; list.appendChild(item); _refreshTargetSelect(); } +function _resolveTargetIcon(tgt: any, deviceMap: Record): string { + let icon = renderDeviceIcon(tgt.icon); + if (!icon && tgt.target_type === 'led' && tgt.device_id) { + icon = renderDeviceIcon(deviceMap[tgt.device_id]?.icon); + } + return icon; +} + export async function addSceneTarget(): Promise { const added = _getAddedTargetIds(); const available = _allTargets.filter(t => !added.has(t.id)); if (available.length === 0) return; + const devices = await devicesCache.fetch().catch((): any[] => []); + const deviceMap: Record = {}; + for (const d of devices) deviceMap[d.id] = d; + const items = available.map(t => ({ value: t.id, label: t.name, - icon: ICON_TARGET, + icon: _resolveTargetIcon(t, deviceMap) || ICON_TARGET, })); const picked = await EntityPalette.pick({ @@ -391,7 +404,7 @@ export async function addSceneTarget(): Promise { const tgt = _allTargets.find(t => t.id === picked); if (tgt) { - _addTargetToList(tgt.id, tgt.name); + _addTargetToList(tgt.id, tgt.name, _resolveTargetIcon(tgt, deviceMap)); _autoGenerateScenePresetName(); } } diff --git a/server/src/ledgrab/static/js/features/targets.ts b/server/src/ledgrab/static/js/features/targets.ts index 693f57d..124301d 100644 --- a/server/src/ledgrab/static/js/features/targets.ts +++ b/server/src/ledgrab/static/js/features/targets.ts @@ -32,7 +32,7 @@ import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, ModMenuItemOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; -import { renderDeviceIconSvg } from '../core/device-icons.ts'; +import { renderDeviceIcon, renderDeviceIconSvg } from '../core/device-icons.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { CardSection } from '../core/card-sections.ts'; import { TreeNav } from '../core/tree-nav.ts'; @@ -280,7 +280,7 @@ function _ensureTargetEntitySelects() { getItems: () => _targetEditorDevices.map(d => ({ value: d.id, label: d.name, - icon: getDeviceTypeIcon(d.device_type), + icon: renderDeviceIcon((d as any).icon) || getDeviceTypeIcon(d.device_type), desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''), })), placeholder: t('palette.search'), diff --git a/server/src/ledgrab/static/js/features/update.ts b/server/src/ledgrab/static/js/features/update.ts index 572e81c..03bfd24 100644 --- a/server/src/ledgrab/static/js/features/update.ts +++ b/server/src/ledgrab/static/js/features/update.ts @@ -6,7 +6,7 @@ import { fetchWithAuth } from '../core/api.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; import { IconSelect } from '../core/icon-select.ts'; -import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts'; +import { ICON_SPARKLES, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts'; // ─── State ────────────────────────────────────────────────── @@ -63,7 +63,7 @@ function _setVersionBadgeUpdate(hasUpdate: boolean): void { } } -function switchSettingsTabToUpdate(): void { +export function switchSettingsTabToUpdate(): void { if (typeof (window as any).openSettingsModal === 'function') { (window as any).openSettingsModal(); } @@ -89,6 +89,7 @@ function _showBanner(status: UpdateStatus): void { const versionLabel = release.prerelease ? `${release.version} (${t('update.prerelease')})` : release.version; + const versionChip = `${versionLabel}`; let actions = ''; @@ -99,9 +100,9 @@ function _showBanner(status: UpdateStatus): void { `; } - actions += ` - ${ICON_EXTERNAL_LINK} - `; + actions += ``; actions += `
+ @@ -68,6 +70,7 @@ +