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.
This commit is contained in:
2026-05-04 14:27:22 +03:00
parent ced72fc864
commit a79f4bf73c
30 changed files with 1239 additions and 193 deletions
@@ -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,
@@ -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"
@@ -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()
@@ -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,
@@ -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 <select>) */
.entity-select-trigger {
display: flex;
@@ -422,6 +422,35 @@ button.mod-icon:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 60%, transparent);
}
/* Empty / placeholder plate kept visible so the slot is discoverable.
Styled as a dashed outline with a quiet "+" glyph; tints to --ch on
hover so the user sees it light up just like a populated plate. */
.mod-icon.is-empty {
background: transparent;
border-style: dashed;
border-color: var(--lux-line-bold, var(--border-color));
color: var(--lux-ink-mute, var(--text-secondary));
box-shadow: none;
}
.mod-icon.is-empty::before,
.mod-icon.is-empty::after { display: none; }
.mod-icon.is-empty svg {
width: 18px;
height: 18px;
filter: none;
opacity: 0.7;
}
button.mod-icon.is-empty:hover {
transform: none;
border-color: color-mix(in srgb, var(--ch) 60%, var(--lux-line-bold, var(--border-color)));
color: var(--ch);
background: color-mix(in srgb, var(--ch) 5%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 14%, transparent);
}
button.mod-icon.is-empty:hover svg { transform: none; opacity: 1; }
.is-running .mod-icon.is-empty,
.card-running .mod-icon.is-empty { animation: none; }
/* Running cards: the plate breathes with the live indicator. */
.is-running .mod-icon,
.card-running .mod-icon {
+81 -15
View File
@@ -403,38 +403,104 @@ h2 {
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent); }
}
/* ── Update banner ── */
/* Update banner
Channel-signal surface: top accent stripe + hairline border tinted to
--ch-signal so it reads as part of the same Lumenworks language as
toasts, modals, and the bulk toolbar. Version is rendered as an
Orbitron chip same family as the version badge in the header. */
.update-banner {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 6px 16px;
background: var(--bg-secondary);
border-bottom: 2px solid var(--primary-color);
color: var(--text-color);
gap: 14px;
padding: 8px 16px;
background: linear-gradient(180deg,
var(--lux-bg-1, var(--bg-secondary)) 0%,
var(--lux-bg-2, var(--bg-secondary)) 100%);
border-bottom: var(--lux-hairline, 1px) solid color-mix(in srgb,
var(--ch-signal, var(--primary-color)) 35%,
var(--lux-line-bold, var(--border-color)));
color: var(--lux-ink, var(--text-color));
font-size: 0.85rem;
font-weight: 600;
font-weight: var(--weight-semibold, 600);
letter-spacing: 0.01em;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.02),
0 0 24px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
animation: bannerSlideDown 0.3s var(--ease-out);
}
/* Top accent stripe matches .toast::before so the channel language
stays consistent across every floating/notification surface. */
.update-banner::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1.5px;
background: linear-gradient(90deg,
transparent 0%,
var(--ch-signal, var(--primary-color)) 20%,
var(--ch-signal, var(--primary-color)) 80%,
transparent 100%);
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent);
pointer-events: none;
}
.update-banner-text {
color: var(--primary-color);
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
color: var(--lux-ink, var(--text-color));
}
.update-banner-version {
font-family: var(--font-brand, 'Orbitron', sans-serif);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ch-signal, var(--primary-color));
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
border: var(--lux-hairline, 1px) solid color-mix(in srgb,
var(--ch-signal, var(--primary-color)) 45%, transparent);
padding: 2px 8px;
border-radius: var(--lux-r-sm, 3px);
line-height: 1.4;
white-space: nowrap;
}
.update-banner-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.update-banner-action {
padding: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
color: var(--text-secondary);
border: var(--lux-hairline, 1px) solid transparent;
color: var(--lux-ink-dim, var(--text-secondary));
cursor: pointer;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
border-radius: var(--lux-r-sm, var(--radius-sm));
transition: color 0.15s, background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.update-banner-action:hover {
color: var(--primary-color);
background: var(--border-color);
color: var(--ch-signal, var(--primary-color));
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent);
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent);
}
.update-banner-apply {
color: var(--ch-signal, var(--primary-color));
}
/* ── Donation banner ── */
+2
View File
@@ -235,6 +235,7 @@ import {
loadUpdateSettings, saveUpdateSettings, dismissUpdate,
initUpdateSettingsPanel, applyUpdate,
openReleaseNotes, closeReleaseNotes,
switchSettingsTabToUpdate,
} from './features/update.ts';
import {
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
@@ -649,6 +650,7 @@ Object.assign(window, {
applyUpdate,
openReleaseNotes,
closeReleaseNotes,
switchSettingsTabToUpdate,
// donation
dismissDonation,
@@ -10,6 +10,7 @@ import {
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
} from './icons.ts';
import { renderDeviceIcon } from './device-icons.ts';
import { getCardColor } from './card-colors.ts';
import { graphNavigateToNode } from '../features/graph-editor.ts';
import { showToast } from './ui.ts';
@@ -39,15 +40,28 @@ function _buildItems(results: any[], states: any = {}) {
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
const items: any[] = [];
// Map device id → device, used to inherit a device's custom icon onto
// its LED targets when the target itself doesn't override it.
const deviceMap: Record<string, any> = {};
if (Array.isArray(devices)) {
for (const d of devices) deviceMap[d.id] = d;
}
_mapEntities(devices, d => items.push({
name: d.name, detail: d.device_type, group: 'devices', icon: ICON_DEVICE,
name: d.name, detail: d.device_type, group: 'devices',
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
nav: ['targets', 'led-devices', 'led-devices', 'data-device-id', d.id],
}));
_mapEntities(targets, tgt => {
const running = !!states[tgt.id]?.processing;
let resolvedIcon = renderDeviceIcon(tgt.icon);
if (!resolvedIcon && tgt.target_type === 'led' && tgt.device_id) {
resolvedIcon = renderDeviceIcon(deviceMap[tgt.device_id]?.icon);
}
items.push({
name: tgt.name, detail: tgt.target_type, group: 'targets', icon: ICON_TARGET,
name: tgt.name, detail: tgt.target_type, group: 'targets',
icon: resolvedIcon || ICON_TARGET,
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
});
// Action item: toggle start/stop
@@ -124,6 +124,15 @@ export function renderDeviceIconSvg(iconId: string | undefined | null, opts: { s
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${def.paths}</svg>`;
}
/** Render an icon by id as a `.icon`-class SVG that scales with its CSS
* context (matches the shape of `_svg()` in icons.ts). Returns empty
* string if the id is unknown so callers can chain `|| FALLBACK`. */
export function renderDeviceIcon(iconId: string | undefined | null): string {
const def = getDeviceIconDef(iconId);
if (!def) return '';
return `<svg class="icon" viewBox="0 0 24 24">${def.paths}</svg>`;
}
/** All icons as a flat list, ordered by category then by definition order. */
export function allIcons(): DeviceIconDef[] {
return DEVICE_ICONS.slice();
@@ -50,6 +50,25 @@ interface EntityPalettePickOpts {
let _instance: EntityPalette | null = null;
/** Drop leading/trailing/consecutive header rows so the list never shows
* an empty section or a stray divider. */
function _stripDanglingHeaders<T extends IconSelectItem>(items: T[]): T[] {
const result: T[] = [];
let pendingHeader: T | null = null;
for (const it of items) {
if (it.header) {
pendingHeader = it;
continue;
}
if (pendingHeader) {
result.push(pendingHeader);
pendingHeader = null;
}
result.push(it);
}
return result;
}
export class EntityPalette {
_overlay: HTMLDivElement;
_input: HTMLInputElement;
@@ -106,9 +125,8 @@ export class EntityPalette {
const idxStr = target.dataset.idx;
if (idxStr === undefined) return;
const idx = parseInt(idxStr, 10);
if (Number.isFinite(idx) && this._filtered[idx]) {
this._select(this._filtered[idx]);
}
const item = Number.isFinite(idx) ? this._filtered[idx] : null;
if (item && !item.header) this._select(item);
});
}
@@ -142,13 +160,25 @@ export class EntityPalette {
_filter() {
const query = this._input.value.toLowerCase().trim();
const all = this._buildFullList();
this._filtered = query
? all.filter(i => i.label.toLowerCase().includes(query) || (i.desc && i.desc.toLowerCase().includes(query)))
: all;
if (query) {
// Headers are dropped while filtering — they only structure the unfiltered list.
this._filtered = all.filter(i =>
!i.header &&
(i.label.toLowerCase().includes(query) ||
(i.desc && i.desc.toLowerCase().includes(query)))
);
} else {
// Trim leading / trailing / consecutive headers so we never show an empty section
// or a stray divider when the items list shrinks.
this._filtered = _stripDanglingHeaders(all);
}
// Highlight current value, or first item
this._highlightIdx = this._filtered.findIndex(i => i.value === this._currentValue);
if (this._highlightIdx === -1) this._highlightIdx = 0;
// Highlight current value or first selectable item
this._highlightIdx = this._filtered.findIndex(i => i.value === this._currentValue && !i.header);
if (this._highlightIdx === -1) {
this._highlightIdx = this._filtered.findIndex(i => !i.header);
if (this._highlightIdx === -1) this._highlightIdx = 0;
}
this._render();
}
@@ -167,11 +197,19 @@ export class EntityPalette {
reconcileList(
this._list,
this._filtered,
(item, i) => `${i}:${item.value}`,
(item, i) => `${i}:${item.header ? 'h:' : ''}${item.value || item.label}`,
(item, i) => {
const div = document.createElement('div');
div.className = 'entity-palette-item';
div.dataset.idx = String(i);
if (item.header) {
div.className = 'entity-palette-header';
const lbl = document.createElement('span');
lbl.className = 'ep-header-label';
lbl.textContent = item.label;
div.appendChild(lbl);
return div;
}
div.className = 'entity-palette-item';
if (item.icon) {
const ic = document.createElement('span');
ic.className = 'ep-item-icon';
@@ -192,6 +230,7 @@ export class EntityPalette {
},
(el, item, i) => {
el.dataset.idx = String(i);
if (item.header) return;
// Update text content if it shifted
const lbl = el.querySelector('.ep-item-label');
if (lbl && lbl.textContent !== item.label) lbl.textContent = item.label;
@@ -200,11 +239,15 @@ export class EntityPalette {
},
);
// Apply highlight/current classes
// Apply highlight/current classes (headers are never highlighted)
Array.from(this._list.children).forEach((el, i) => {
if (!(el instanceof HTMLElement)) return;
el.classList.toggle('ep-highlight', i === this._highlightIdx);
const item = this._filtered[i];
if (item?.header) {
el.classList.remove('ep-highlight', 'ep-current');
return;
}
el.classList.toggle('ep-highlight', i === this._highlightIdx);
el.classList.toggle('ep-current', item?.value === this._currentValue);
});
@@ -213,20 +256,27 @@ export class EntityPalette {
if (hl) hl.scrollIntoView({ block: 'nearest' });
}
_stepHighlight(dir: 1 | -1) {
// Walk past header rows (which are non-selectable).
const n = this._filtered.length;
let next = this._highlightIdx + dir;
while (next >= 0 && next < n && this._filtered[next]?.header) next += dir;
if (next >= 0 && next < n) this._highlightIdx = next;
}
_onKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1);
this._stepHighlight(1);
this._render();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this._highlightIdx = Math.max(this._highlightIdx - 1, 0);
this._stepHighlight(-1);
this._render();
} else if (e.key === 'Enter') {
e.preventDefault();
if (this._filtered[this._highlightIdx]) {
this._select(this._filtered[this._highlightIdx]);
}
const item = this._filtered[this._highlightIdx];
if (item && !item.header) this._select(item);
} else if (e.key === 'Escape') {
this._cancel();
} else if (e.key === 'Tab') {
@@ -50,6 +50,8 @@ export interface IconSelectItem {
icon: string;
label: string;
desc?: string;
/** Render as a non-selectable section header (used by EntityPalette for grouping). */
header?: boolean;
}
export interface IconSelectOpts {
+13 -5
View File
@@ -14,7 +14,7 @@
import { t } from './i18n.ts';
import { escapeHtml } from './api.ts';
import { ICON_TRASH, ICON_CLONE, ICON_EYE_OFF, ICON_EYE, ICON_KEBAB } from './icons.ts';
import { ICON_TRASH, ICON_CLONE, ICON_EYE_OFF, ICON_EYE, ICON_KEBAB, ICON_PLUS } from './icons.ts';
import { cardColorDot } from './card-colors.ts';
export type LedState = 'on' | 'off' | 'blink' | 'fault';
@@ -278,8 +278,15 @@ function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string):
}
function _iconPlateHtml(head: ModHeadOpts): string {
if (!head.iconHtml) return '';
const styleAttr = head.iconColor
const interactive = !!(head.iconOnclick || head.iconAttrs);
const isEmpty = !head.iconHtml;
// No icon set AND no picker hook → render nothing, head reverts to
// the legacy badge-only layout. With a picker hook we still render
// the slot as a dashed placeholder so the action stays discoverable.
if (isEmpty && !interactive) return '';
const styleAttr = head.iconColor && !isEmpty
? ` style="--ch:${escapeHtml(head.iconColor)};color:${escapeHtml(head.iconColor)}"`
: '';
const onclickAttr = head.iconOnclick ? ` onclick="${head.iconOnclick}"` : '';
@@ -287,10 +294,11 @@ function _iconPlateHtml(head: ModHeadOpts): string {
? ' ' + Object.entries(head.iconAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
const titleAttr = head.iconTitle ? ` title="${escapeHtml(head.iconTitle)}" aria-label="${escapeHtml(head.iconTitle)}"` : '';
const interactive = !!(head.iconOnclick || head.iconAttrs);
const tag = interactive ? 'button' : 'div';
const typeAttr = interactive ? ' type="button"' : '';
return `<${tag} class="mod-icon"${typeAttr}${styleAttr}${onclickAttr}${dataAttrs}${titleAttr}>${head.iconHtml}</${tag}>`;
const classAttr = isEmpty ? 'mod-icon is-empty' : 'mod-icon';
const inner = isEmpty ? ICON_PLUS : head.iconHtml;
return `<${tag} class="${classAttr}"${typeAttr}${styleAttr}${onclickAttr}${dataAttrs}${titleAttr}>${inner}</${tag}>`;
}
export function renderModHead(head: ModHeadOpts): string {
@@ -13,6 +13,7 @@ import { Modal } from '../core/modal.ts';
import { closeTutorial, startCalibrationTutorial } from './tutorials.ts';
import { startCSSOverlay, stopCSSOverlay } from './color-strips/index.ts';
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW, ICON_DEVICE } from '../core/icons.ts';
import { renderDeviceIcon } from '../core/device-icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import type { Calibration } from '../types.ts';
@@ -294,7 +295,7 @@ export async function showCSSCalibration(cssId: any) {
getItems: () => _calTestDeviceList.map((d: any) => ({
value: d.id,
label: d.name,
icon: ICON_DEVICE,
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
desc: d.led_count ? `${d.led_count} LEDs` : '',
})),
placeholder: t('palette.search'),
@@ -1650,8 +1650,8 @@ export async function deleteColorStrip(cssId: string) {
if (window.loadTargetsTab) await window.loadTargetsTab();
} else {
const err = await response.json();
const msg = err.detail || 'Failed to delete';
showToast(response.status === 409 ? t('color_strip.delete.referenced') : `Failed: ${msg}`, 'error');
const msg = err.detail || (response.status === 409 ? t('color_strip.delete.referenced') : 'Failed to delete');
showToast(msg, 'error');
}
} catch (error: any) {
if (error.isAuth) return;
@@ -16,6 +16,7 @@ import {
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
@@ -957,11 +958,41 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
}
}
/** Resolve the effective custom icon for a dashboard target card.
* LED targets inherit from their referenced device when no own icon is
* set; HA-light targets have no inheritance source. Returns the HTML
* for the icon plate (empty string when no icon is available). */
function _dashboardTargetIconPlate(target: any, devicesMap: Record<string, Device>): string {
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const isHALight = target.target_type === 'ha_light';
const ownIconId = (target.icon as string | undefined) || '';
const ownColor = (target.icon_color as string | undefined) || '';
let iconId = ownIconId;
let iconColor = ownColor;
if (!iconId && isLed) {
const device = target.device_id ? devicesMap[target.device_id] : null;
const inheritedId = (device as any)?.icon || '';
if (inheritedId) {
iconId = inheritedId;
iconColor = ownColor || (device as any)?.icon_color || '';
}
}
if (!iconId) return '';
const styleAttr = iconColor
? ` style="--ch:${escapeHtml(iconColor)};color:${escapeHtml(iconColor)}"`
: '';
void isHALight;
return `<div class="mod-icon"${styleAttr}>${renderDeviceIconSvg(iconId, { size: 24 })}</div>`;
}
function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Record<string, Device> = {}, cssSourceMap: Record<string, ColorStripSource> = {}): string {
const state = target.state || {};
const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const isHALight = target.target_type === 'ha_light';
const iconPlate = _dashboardTargetIconPlate(target, devicesMap);
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
const icon = ICON_TARGET;
const typeLabel = isLed ? t('dashboard.type.led') : isHALight ? t('ha_light.section.title') : t('dashboard.type.kc');
const navSubTab = isHALight ? 'ha-light-targets' : 'led-targets';
@@ -1026,7 +1057,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
const cStyle = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link is-running" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="mod-head">
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span>${healthDot}</div>
@@ -1061,7 +1093,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
} else {
const cStyle2 = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="mod-head">
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span></div>
@@ -12,8 +12,9 @@ import { ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_OK, ICON_WARNING, IC
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect } from '../core/icon-select.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, LedState } from '../core/mod-card.ts';
import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, ModMenuItemOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
import { bindableSourceId, bindableValue } from '../types.ts';
@@ -24,18 +25,24 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Modal ──
type HALightSourceKind = 'css' | 'color_vs';
let _haLightTagsInput: TagInput | null = null;
let _haSourceEntitySelect: EntitySelect | null = null;
let _cssSourceEntitySelect: EntitySelect | null = null;
let _brightnessWidget: BindableScalarWidget | null = null;
let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = [];
let _editorColorValueSources: any[] = []; // value sources with return_type='color'
let _cachedHAEntities: any[] = []; // fetched from selected HA source
let _updateRateWidget: BindableScalarWidget | null = null;
let _transitionWidget: BindableScalarWidget | null = null;
let _colorToleranceWidget: BindableScalarWidget | null = null;
let _minBrightnessThresholdWidget: BindableScalarWidget | null = null;
let _stopActionIconSelect: IconSelect | null = null;
// Active mode + selected colour value source id (only used in color_vs mode).
let _editorSourceKind: HALightSourceKind = 'css';
let _editorColorVsId: string = '';
class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); }
@@ -57,7 +64,8 @@ class HALightEditorModal extends Modal {
return {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
color_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
source_kind: _editorSourceKind,
brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0',
update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
@@ -81,16 +89,72 @@ function _getMappingsJSON(): string {
const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row');
const mappings: any[] = [];
rows.forEach(row => {
const ledStartEl = row.querySelector('.ha-mapping-led-start') as HTMLInputElement | null;
const ledEndEl = row.querySelector('.ha-mapping-led-end') as HTMLInputElement | null;
// In color_vs mode the LED range inputs are not rendered; fall back to
// safe defaults so mappings round-trip if the user toggles back to CSS.
mappings.push({
entity_id: (row.querySelector('.ha-mapping-entity') as HTMLSelectElement).value.trim(),
led_start: parseInt((row.querySelector('.ha-mapping-led-start') as HTMLInputElement).value) || 0,
led_end: parseInt((row.querySelector('.ha-mapping-led-end') as HTMLInputElement).value) || -1,
led_start: ledStartEl ? (parseInt(ledStartEl.value) || 0) : 0,
led_end: ledEndEl ? (parseInt(ledEndEl.value) || -1) : -1,
brightness_scale: parseFloat((row.querySelector('.ha-mapping-brightness') as HTMLInputElement).value) || 1.0,
});
});
return JSON.stringify(mappings);
}
function _isColorValueSource(vs: any): boolean {
return !!vs && vs.return_type === 'color';
}
/**
* Build the unified Color Source picker items, split into two sections:
*
* Color strip sources
* Audio bars (audio)
* Living-room screen (picture)
* Color value sources
* Sunrise cycle (animated_color)
*/
function _buildColorSourcePickerItems(): any[] {
const items: any[] = [];
if (_editorCssSources.length > 0) {
items.push({ value: '', label: t('ha_light.color_source.css'), icon: '', header: true });
for (const s of _editorCssSources) {
items.push({
value: `css:${s.id}`,
label: s.name,
icon: getColorStripIcon(s.source_type),
desc: s.source_type,
});
}
}
if (_editorColorValueSources.length > 0) {
items.push({ value: '', label: t('ha_light.color_source.color_vs'), icon: '', header: true });
for (const s of _editorColorValueSources) {
items.push({
value: `cvs:${s.id}`,
label: s.name,
icon: getValueSourceIcon(s.source_type),
desc: s.source_type,
});
}
}
return items;
}
function _setMappingsModeHint(): void {
const hintColorVs = document.getElementById('ha-light-mappings-mode-hint');
const hintCss = document.querySelectorAll('#ha-light-editor-modal small.input-hint[data-i18n="ha_light.mappings.hint"]')[0] as HTMLElement | undefined;
const visibleCss = _editorSourceKind === 'css';
if (hintColorVs) hintColorVs.style.display = visibleCss ? 'none' : '';
// CSS hint is only revealed by the toggle; do not auto-show.
if (hintCss && !visibleCss) hintCss.style.display = 'none';
}
function _getEntityItems() {
return _cachedHAEntities
.filter((e: any) => e.domain === 'light')
@@ -168,17 +232,16 @@ export function addHALightMapping(data: any = null): void {
? `<option value="${escapeHtml(selectedId)}" selected>${escapeHtml(selectedId)}</option>`
: '';
row.innerHTML = `
<div class="ha-mapping-header">
<span class="ha-mapping-label">${_icon(P.lightbulb)} #${idx}</span>
<button type="button" class="btn-remove-mapping" onclick="removeHALightMapping(this)" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="ha-mapping-fields">
<div class="ha-mapping-field">
<label>${t('ha_light.mapping.entity_id')}</label>
<select class="ha-mapping-entity">${entityOptions}${extraOption}</select>
</div>
<div class="ha-mapping-range-row">
// 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'
? `<div class="ha-mapping-range-row">
<div>
<label>${t('ha_light.mapping.brightness')}</label>
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
</div>
</div>`
: `<div class="ha-mapping-range-row">
<div>
<label>${t('ha_light.mapping.led_start')}</label>
<input type="number" class="ha-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
@@ -191,7 +254,19 @@ export function addHALightMapping(data: any = null): void {
<label>${t('ha_light.mapping.brightness')}</label>
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
</div>
</div>`;
row.innerHTML = `
<div class="ha-mapping-header">
<span class="ha-mapping-label">${_icon(P.lightbulb)} #${idx}</span>
<button type="button" class="btn-remove-mapping" onclick="removeHALightMapping(this)" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="ha-mapping-fields">
<div class="ha-mapping-field">
<label>${t('ha_light.mapping.entity_id')}</label>
<select class="ha-mapping-entity">${entityOptions}${extraOption}</select>
</div>
${rangeBlock}
</div>
`;
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
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// Populate CSS source dropdown
const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement;
cssSelect.innerHTML = `<option value="">—</option>` + cssSources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
// 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) =>
`<option value="css:${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`
).join('');
const colorVsOptions = _editorColorValueSources.map((s: any) =>
`<option value="cvs:${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`
).join('');
colorSelect.innerHTML = `<option value="">—</option>${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:<id>" or "cvs:<id>" 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<void> {
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:<id>" or "cvs:<id>" (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<void> {
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<void> {
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<string,
// ── Chips ──
const chips: ModChipOpts[] = [];
if (cssSource) {
const colorVsId: string = target.color_value_source_id || '';
const colorVs = colorVsId && valueSourceMap[colorVsId] ? valueSourceMap[colorVsId] : null;
if (target.source_kind === 'color_vs') {
if (colorVs) {
chips.push({
icon: getValueSourceIcon(colorVs.source_type),
text: colorVs.name,
title: t('ha_light.color_source'),
onclick: `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${colorVsId}')`,
});
} else if (colorVsId) {
chips.push({ icon: _icon(P.palette), text: colorVsId, title: t('ha_light.color_source') });
}
} else if (cssSource) {
chips.push({
icon: getColorStripIcon(cssSource.source_type),
text: cssSource.name,
@@ -652,13 +796,36 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
},
];
// ── Custom icon plate (HA-light targets have no inheritance source) ──
const targetIconId = (target.icon as string | undefined) || '';
const targetIconColor = (target.icon_color as string | undefined) || '';
const iconHtml = targetIconId ? renderDeviceIconSvg(targetIconId, { size: 24 }) : '';
const iconTitle = targetIconId
? (t('device.icon.change') || 'Change icon…')
: (t('device.icon.choose') || 'Choose icon…');
const targetMenuExtraItems: ModMenuItemOpts[] = [
{
label: targetIconId
? (t('device.icon.change') || 'Change icon…')
: (t('device.icon.choose') || 'Choose icon…'),
icon: ICON_EDIT,
dataAttrs: { 'data-icon-picker-trigger': `target:${target.id}` },
},
];
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: target.name,
metaHtml,
leds,
iconHtml,
iconColor: targetIconColor,
iconAttrs: { 'data-icon-picker-trigger': `target:${target.id}` },
iconTitle,
menu: {
extraItems: targetMenuExtraItems,
duplicateOnclick: `cloneHALightTarget('${target.id}')`,
hideOnclick: `toggleCardHidden('ha-light-targets','${target.id}')`,
deleteOnclick: `deleteTarget('${target.id}')`,
@@ -62,8 +62,8 @@ interface EntityTypeAdapter {
reload(): Promise<void>;
/** 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<EntityType, EntityTypeAdapter> = {
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) {
@@ -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 = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
item.innerHTML = `<span>${iconHtml || ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
function _resolveTargetIcon(tgt: any, deviceMap: Record<string, any>): 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<void> {
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<string, any> = {};
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<void> {
const tgt = _allTargets.find(t => t.id === picked);
if (tgt) {
_addTargetToList(tgt.id, tgt.name);
_addTargetToList(tgt.id, tgt.name, _resolveTargetIcon(tgt, deviceMap));
_autoGenerateScenePresetName();
}
}
@@ -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'),
@@ -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 = `<span class="update-banner-version">${versionLabel}</span>`;
let actions = '';
@@ -99,9 +100,9 @@ function _showBanner(status: UpdateStatus): void {
</button>`;
}
actions += `<a href="${status.releases_url}" target="_blank" rel="noopener" class="btn btn-icon update-banner-action" title="${t('update.view_release')}">
${ICON_EXTERNAL_LINK}
</a>`;
actions += `<button class="btn btn-icon update-banner-action" onclick="switchSettingsTabToUpdate()" title="${t('update.view_release')}">
${ICON_SPARKLES}
</button>`;
actions += `<button class="btn btn-icon update-banner-action" onclick="dismissUpdate()" title="${t('update.dismiss')}">
${ICON_X}
@@ -109,9 +110,9 @@ function _showBanner(status: UpdateStatus): void {
banner.innerHTML = `
<span class="update-banner-text">
${t('update.available').replace('{version}', versionLabel)}
${t('update.available').replace('{version}', versionChip)}
</span>
${actions}
<span class="update-banner-actions">${actions}</span>
`;
banner.style.display = 'flex';
}
+6
View File
@@ -129,10 +129,16 @@ export interface LedOutputTarget extends OutputTargetBase {
protocol: string;
}
export type HALightSourceKind = 'css' | 'color_vs';
export interface HALightOutputTarget extends OutputTargetBase {
target_type: 'ha_light';
ha_source_id: string;
/** Which colour source feeds the lights: a CSS (`'css'`) or a colour-returning value source (`'color_vs'`). */
source_kind: HALightSourceKind;
color_strip_source_id: string;
/** Used when `source_kind === 'color_vs'`. References a value source whose `return_type === 'color'`. */
color_value_source_id?: string;
brightness?: BindableFloat;
ha_light_mappings?: HALightMapping[];
update_rate?: BindableFloat;
@@ -584,6 +584,7 @@
"device.icon.cat.ambience": "Ambience",
"device.icon.entity.device": "Device",
"device.icon.entity.target": "LED target",
"device.icon.entity.ha_light_target": "HA light target",
"device.icon.inherited_from": "Inherited from %s",
"device.icon.override_inherited": "Override inherited icon…",
"device.icon.use_inherited": "Use inherited",
@@ -2181,6 +2182,11 @@
"ha_light.name.placeholder": "Living Room Lights",
"ha_light.ha_source": "HA Connection:",
"ha_light.css_source": "Color Strip Source:",
"ha_light.color_source": "Color Source:",
"ha_light.color_source.hint": "Pick a Color Strip Source (per-light LED ranges) or a Color Value Source (one colour broadcast to every light).",
"ha_light.color_source.css": "Color strip",
"ha_light.color_source.color_vs": "Color value source",
"ha_light.mappings.color_vs_hint": "All listed lights will receive the same colour from the selected Color Value Source.",
"ha_light.update_rate": "Update Rate:",
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
"ha_light.transition": "Transition:",
@@ -2199,6 +2205,7 @@
"ha_light.description": "Description (optional):",
"ha_light.error.name_required": "Name is required",
"ha_light.error.ha_source_required": "HA connection is required",
"ha_light.error.color_source_required": "Color value source is required when broadcasting a single colour",
"ha_light.created": "HA light target created",
"ha_light.updated": "HA light target updated",
"ha_light.mapping.select_entity": "Select a light entity...",
@@ -14,6 +14,12 @@
"ha_light.stop_action.turn_off.desc": "Выключить все привязанные лампы",
"ha_light.stop_action.restore": "Восстановить",
"ha_light.stop_action.restore.desc": "Вернуть состояние на момент запуска",
"ha_light.color_source": "Источник цвета:",
"ha_light.color_source.hint": "Выберите Источник полосы цвета (диапазоны LED для каждой лампы) или Источник значения цвета (один цвет на все лампы).",
"ha_light.color_source.css": "Полоса цвета",
"ha_light.color_source.color_vs": "Источник значения цвета",
"ha_light.mappings.color_vs_hint": "Все указанные лампы получат один и тот же цвет от выбранного Источника значения цвета.",
"ha_light.error.color_source_required": "Источник значения цвета обязателен в режиме одного цвета",
"app.version": "Версия:",
"app.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен",
@@ -596,6 +602,7 @@
"device.icon.cat.ambience": "Атмосфера",
"device.icon.entity.device": "Устройство",
"device.icon.entity.target": "LED-цель",
"device.icon.entity.ha_light_target": "HA-светильник",
"device.icon.inherited_from": "Унаследовано от %s",
"device.icon.override_inherited": "Заменить унаследованную иконку…",
"device.icon.use_inherited": "Использовать унаследованную",
@@ -14,6 +14,12 @@
"ha_light.stop_action.turn_off.desc": "关闭所有映射的灯",
"ha_light.stop_action.restore": "恢复",
"ha_light.stop_action.restore.desc": "恢复到启动时捕获的状态",
"ha_light.color_source": "颜色源:",
"ha_light.color_source.hint": "选择颜色条源(每个灯具的 LED 范围)或颜色值源(一种颜色广播到所有灯具)。",
"ha_light.color_source.css": "颜色条",
"ha_light.color_source.color_vs": "颜色值源",
"ha_light.mappings.color_vs_hint": "所有列出的灯具都将接收来自所选颜色值源的相同颜色。",
"ha_light.error.color_source_required": "广播单一颜色时必须选择颜色值源",
"app.version": "版本:",
"app.api_docs": "API 文档",
"app.connection_lost": "服务器不可达",
@@ -596,6 +602,7 @@
"device.icon.cat.ambience": "氛围",
"device.icon.entity.device": "设备",
"device.icon.entity.target": "LED 目标",
"device.icon.entity.ha_light_target": "HA 灯目标",
"device.icon.inherited_from": "继承自 %s",
"device.icon.override_inherited": "覆盖继承的图标…",
"device.icon.use_inherited": "使用继承的",
@@ -37,14 +37,27 @@ class HALightMapping:
VALID_STOP_ACTIONS = ("none", "turn_off", "restore")
VALID_SOURCE_KINDS = ("css", "color_vs")
@dataclass
class HALightOutputTarget(OutputTarget):
"""Output target that casts LED colors to Home Assistant lights via service calls."""
"""Output target that casts LED colors to Home Assistant lights via service calls.
Two source modes are supported:
* ``source_kind="css"`` colours come from a ColorStripSource. Each
``HALightMapping`` averages a slice of the strip (``led_start``/``led_end``)
onto its entity.
* ``source_kind="color_vs"`` a single colour comes from a colour-returning
``ValueSource`` (static, animated, adaptive_time, ). The same colour is
pushed to every mapped entity; ``led_start``/``led_end`` are ignored.
"""
ha_source_id: str = "" # references HomeAssistantSource
color_strip_source_id: str = "" # CSS providing the colors
source_kind: str = "css" # one of VALID_SOURCE_KINDS
color_strip_source_id: str = "" # CSS providing the colors (source_kind="css")
color_value_source_id: str = "" # color-returning ValueSource (source_kind="color_vs")
brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
light_mappings: List[HALightMapping] = field(default_factory=list)
update_rate: BindableFloat = field(default_factory=lambda: BindableFloat(2.0))
@@ -53,13 +66,20 @@ class HALightOutputTarget(OutputTarget):
color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
stop_action: str = "none" # one of VALID_STOP_ACTIONS
def _has_source(self) -> bool:
if self.source_kind == "color_vs":
return bool(self.color_value_source_id)
return bool(self.color_strip_source_id)
def register_with_manager(self, manager) -> None:
"""Register this HA light target with the processor manager."""
if self.ha_source_id and self.light_mappings:
if self.ha_source_id and self.light_mappings and self._has_source():
manager.add_ha_light_target(
target_id=self.id,
ha_source_id=self.ha_source_id,
source_kind=self.source_kind,
color_strip_source_id=self.color_strip_source_id,
color_value_source_id=self.color_value_source_id,
brightness=self.brightness,
light_mappings=self.light_mappings,
update_rate=self.update_rate,
@@ -84,6 +104,8 @@ class HALightOutputTarget(OutputTarget):
manager.update_target_settings(
self.id,
{
"source_kind": self.source_kind,
"color_value_source_id": self.color_value_source_id,
"brightness": self.brightness,
"update_rate": self.update_rate,
"transition": self.transition,
@@ -101,7 +123,9 @@ class HALightOutputTarget(OutputTarget):
*,
name=None,
ha_source_id=None,
source_kind=None,
color_strip_source_id=None,
color_value_source_id=None,
brightness=None,
# legacy compat
brightness_value_source_id=None,
@@ -127,10 +151,15 @@ class HALightOutputTarget(OutputTarget):
)
if ha_source_id is not None:
self.ha_source_id = resolve_ref(ha_source_id, self.ha_source_id)
if source_kind is not None and source_kind in VALID_SOURCE_KINDS:
self.source_kind = source_kind
# color_strip_source_id / color_value_source_id are typed as plain `str`,
# so an empty payload value clears them to "" (not None — which would
# break the response schema's `str` type assertion).
if color_strip_source_id is not None:
self.color_strip_source_id = resolve_ref(
color_strip_source_id, self.color_strip_source_id
)
self.color_strip_source_id = color_strip_source_id
if color_value_source_id is not None:
self.color_value_source_id = color_value_source_id
if brightness is not None:
self.brightness = self.brightness.apply_update(brightness)
elif brightness_value_source_id is not None:
@@ -156,7 +185,9 @@ class HALightOutputTarget(OutputTarget):
def to_dict(self) -> dict:
d = super().to_dict()
d["ha_source_id"] = self.ha_source_id
d["source_kind"] = self.source_kind
d["color_strip_source_id"] = self.color_strip_source_id
d["color_value_source_id"] = self.color_value_source_id
d["brightness"] = self.brightness.to_dict()
d["light_mappings"] = [m.to_dict() for m in self.light_mappings]
d["update_rate"] = self.update_rate.to_dict()
@@ -175,12 +206,19 @@ class HALightOutputTarget(OutputTarget):
legacy_source_id=data.get("brightness_value_source_id", ""),
default=1.0,
)
# source_kind defaults to "css" so legacy rows behave identically
raw_kind = data.get("source_kind", "css")
source_kind = raw_kind if raw_kind in VALID_SOURCE_KINDS else "css"
return cls(
id=data["id"],
name=data["name"],
target_type="ha_light",
ha_source_id=data.get("ha_source_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""),
ha_source_id=data.get("ha_source_id") or "",
source_kind=source_kind,
# `or ""` defends against historical rows where resolve_ref wrote None
# into a str-typed field (now fixed in update_fields).
color_strip_source_id=data.get("color_strip_source_id") or "",
color_value_source_id=data.get("color_value_source_id") or "",
brightness=brightness,
light_mappings=mappings,
update_rate=BindableFloat.from_raw(data.get("update_rate"), default=2.0),
@@ -50,6 +50,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
description: Optional[str] = None,
tags: Optional[List[str]] = None,
ha_source_id: str = "",
source_kind: str = "css",
color_value_source_id: str = "",
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0,
transition=None,
@@ -118,7 +120,9 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
name=name,
target_type="ha_light",
ha_source_id=ha_source_id,
source_kind=source_kind if source_kind in ("css", "color_vs") else "css",
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=bright,
light_mappings=ha_light_mappings or [],
update_rate=BindableFloat.from_raw(update_rate, default=2.0),
@@ -162,6 +166,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
icon=None,
icon_color=None,
ha_source_id=None,
source_kind=None,
color_value_source_id=None,
ha_light_mappings=None,
update_rate=None,
transition=None,
@@ -203,6 +209,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
icon=icon,
icon_color=icon_color,
ha_source_id=ha_source_id,
source_kind=source_kind,
color_value_source_id=color_value_source_id,
light_mappings=ha_light_mappings,
update_rate=update_rate,
transition=transition,
@@ -57,8 +57,10 @@
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-css-source" data-i18n="ha_light.css_source">Color Strip Source:</label>
<label for="ha-light-editor-css-source" data-i18n="ha_light.color_source">Color Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.color_source.hint">Pick a Color Strip Source (per-light LED ranges) or a Color Value Source (one colour broadcast to every light).</small>
<select id="ha-light-editor-css-source"></select>
</div>
@@ -68,6 +70,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.mappings.hint">Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.</small>
<small id="ha-light-mappings-mode-hint" class="input-hint" style="display:none" data-i18n="ha_light.mappings.color_vs_hint">All listed lights will receive the same colour from the selected Color Value Source.</small>
<div id="ha-light-mappings-list"></div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" style="margin-top: 4px;">
+ <span data-i18n="ha_light.mappings.add">Add Mapping</span>
@@ -0,0 +1,252 @@
"""Unit tests for HALightTargetProcessor — covers CSS and color_vs modes."""
import asyncio
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pytest
from ledgrab.core.processing.ha_light_target_processor import HALightTargetProcessor
from ledgrab.core.processing.target_processor import TargetContext
from ledgrab.storage.bindable import BindableFloat
from ledgrab.storage.ha_light_output_target import HALightMapping
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
@dataclass
class _RecordedCall:
domain: str
service: str
service_data: dict
target: dict
class _FakeHARuntime:
is_connected = True
def __init__(self) -> None:
self.calls: List[_RecordedCall] = []
self._states: Dict[str, Any] = {}
async def call_service(self, *, domain, service, service_data, target):
self.calls.append(_RecordedCall(domain, service, service_data, target))
def get_state(self, _entity_id: str):
return self._states.get(_entity_id)
class _FakeHAManager:
def __init__(self, runtime: _FakeHARuntime) -> None:
self._runtime = runtime
self.acquired_for: List[str] = []
self.released_for: List[str] = []
async def acquire(self, source_id: str) -> _FakeHARuntime:
self.acquired_for.append(source_id)
return self._runtime
async def release(self, source_id: str) -> None:
self.released_for.append(source_id)
class _FakeColorStream:
"""A ValueStream that returns a constant RGB triple."""
def __init__(self, color: Tuple[int, int, int]) -> None:
self._color = color
self.get_color_calls = 0
def get_value(self) -> float:
r, g, b = self._color
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
def get_color(self) -> Tuple[int, int, int]:
self.get_color_calls += 1
return self._color
class _FakeCSSStream:
"""A CSS stream that returns a fixed numpy LED frame."""
def __init__(self, frame: np.ndarray) -> None:
self._frame = frame
def get_latest_colors(self) -> np.ndarray:
return self._frame
class _FakeVSManager:
def __init__(self, stream) -> None:
self._stream = stream
self.acquired: List[str] = []
self.released: List[str] = []
def acquire(self, vs_id: str):
self.acquired.append(vs_id)
return self._stream
def release(self, vs_id: str) -> None:
self.released.append(vs_id)
class _FakeCSSManager:
def __init__(self, stream) -> None:
self._stream = stream
self.acquired: List[Tuple[str, str]] = []
self.released: List[Tuple[str, str]] = []
def acquire(self, css_id: str, target_id: str):
self.acquired.append((css_id, target_id))
return self._stream
def release(self, css_id: str, target_id: str) -> None:
self.released.append((css_id, target_id))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ctx(
*,
ha_manager: Optional[_FakeHAManager] = None,
css_manager: Optional[_FakeCSSManager] = None,
vs_manager: Optional[_FakeVSManager] = None,
) -> TargetContext:
return TargetContext(
live_stream_manager=None, # type: ignore[arg-type]
overlay_manager=None, # type: ignore[arg-type]
color_strip_stream_manager=css_manager,
value_stream_manager=vs_manager,
ha_manager=ha_manager,
)
def _mapping(entity_id: str, *, scale: float = 1.0) -> HALightMapping:
return HALightMapping(
entity_id=entity_id,
led_start=0,
led_end=-1,
brightness_scale=BindableFloat(scale),
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_color_vs_mode_broadcasts_same_color_to_all_mappings():
runtime = _FakeHARuntime()
ha_mgr = _FakeHAManager(runtime)
color_stream = _FakeColorStream((128, 64, 200))
vs_mgr = _FakeVSManager(color_stream)
proc = HALightTargetProcessor(
target_id="t_color_vs",
ha_source_id="ha_1",
source_kind="color_vs",
color_value_source_id="vs_purple",
light_mappings=[_mapping("light.a"), _mapping("light.b"), _mapping("light.c")],
color_tolerance=0,
ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=vs_mgr),
)
await proc.start()
# Wait until each entity has been called at least once.
for _ in range(40):
await asyncio.sleep(0.05)
entities = {c.target["entity_id"] for c in runtime.calls}
if {"light.a", "light.b", "light.c"} <= entities:
break
await proc.stop()
assert vs_mgr.acquired == ["vs_purple"], "color VS must be acquired in color_vs mode"
assert vs_mgr.released == ["vs_purple"], "color VS must be released on stop"
# Each entity received the same RGB triple
by_entity: Dict[str, set] = {}
for c in runtime.calls:
eid = c.target["entity_id"]
by_entity.setdefault(eid, set()).add(tuple(c.service_data["rgb_color"]))
assert {"light.a", "light.b", "light.c"} <= set(by_entity.keys())
for colors in by_entity.values():
assert colors == {(128, 64, 200)}
@pytest.mark.asyncio
async def test_css_mode_still_uses_per_segment_average():
runtime = _FakeHARuntime()
ha_mgr = _FakeHAManager(runtime)
# 6-LED frame; light.a covers 0..2 (red), light.b covers 3..5 (blue)
frame = np.array(
[
[255, 0, 0],
[255, 0, 0],
[255, 0, 0],
[0, 0, 255],
[0, 0, 255],
[0, 0, 255],
],
dtype=np.int32,
)
css_mgr = _FakeCSSManager(_FakeCSSStream(frame))
proc = HALightTargetProcessor(
target_id="t_css",
ha_source_id="ha_1",
source_kind="css",
color_strip_source_id="css_1",
light_mappings=[
HALightMapping(
entity_id="light.a",
led_start=0,
led_end=3,
brightness_scale=BindableFloat(1.0),
),
HALightMapping(
entity_id="light.b",
led_start=3,
led_end=6,
brightness_scale=BindableFloat(1.0),
),
],
color_tolerance=0,
ctx=_make_ctx(ha_manager=ha_mgr, css_manager=css_mgr),
)
await proc.start()
for _ in range(40):
await asyncio.sleep(0.05)
if {c.target["entity_id"] for c in runtime.calls} >= {"light.a", "light.b"}:
break
await proc.stop()
by_entity = {c.target["entity_id"]: tuple(c.service_data["rgb_color"]) for c in runtime.calls}
assert by_entity["light.a"] == (255, 0, 0)
assert by_entity["light.b"] == (0, 0, 255)
assert css_mgr.released == [("css_1", "t_css")]
@pytest.mark.asyncio
async def test_get_state_reports_source_kind():
runtime = _FakeHARuntime()
ha_mgr = _FakeHAManager(runtime)
proc = HALightTargetProcessor(
target_id="t_state",
ha_source_id="ha_1",
source_kind="color_vs",
color_value_source_id="vs_x",
light_mappings=[_mapping("light.a")],
ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(_FakeColorStream((0, 0, 0)))),
)
state = proc.get_state()
assert state["source_kind"] == "color_vs"
assert state["color_value_source_id"] == "vs_x"
assert state["css_id"] == ""
@@ -2,6 +2,7 @@
import pytest
from ledgrab.storage.ha_light_output_target import HALightMapping, HALightOutputTarget
from ledgrab.storage.output_target import OutputTarget
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.wled_output_target import WledOutputTarget
@@ -188,3 +189,98 @@ class TestOutputTargetPersistence:
assert isinstance(loaded, WledOutputTarget)
assert loaded.tags == ["tv"]
db.close()
# ---------------------------------------------------------------------------
# HA-light dual-mode source: CSS and color value source
# ---------------------------------------------------------------------------
class TestHALightSourceModes:
def test_legacy_payload_defaults_to_css_kind(self):
"""Records persisted before source_kind existed must load as CSS mode."""
data = {
"id": "pt_legacy",
"name": "Legacy HA light",
"target_type": "ha_light",
"ha_source_id": "ha_1",
"color_strip_source_id": "css_1",
"light_mappings": [],
"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, HALightOutputTarget)
assert target.source_kind == "css"
assert target.color_strip_source_id == "css_1"
assert target.color_value_source_id == ""
def test_invalid_source_kind_falls_back_to_css(self):
data = {
"id": "pt_bad_kind",
"name": "Bad kind",
"target_type": "ha_light",
"ha_source_id": "ha_1",
"source_kind": "garbage",
"color_strip_source_id": "css_1",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
target = OutputTarget.from_dict(data)
assert target.source_kind == "css"
def test_create_ha_light_color_vs(self, store):
target = store.create_target(
"Mood lamp",
"ha_light",
ha_source_id="ha_1",
source_kind="color_vs",
color_value_source_id="vs_static_red",
ha_light_mappings=[HALightMapping(entity_id="light.lamp")],
)
assert isinstance(target, HALightOutputTarget)
assert target.source_kind == "color_vs"
assert target.color_value_source_id == "vs_static_red"
# Round-trip via dict
assert target.to_dict()["source_kind"] == "color_vs"
def test_round_trip_color_vs_persists(self, tmp_path):
from ledgrab.storage.database import Database
db_path = str(tmp_path / "ha_color_vs.db")
db = Database(db_path)
s1 = OutputTargetStore(db)
t = s1.create_target(
"Sunrise",
"ha_light",
ha_source_id="ha_1",
source_kind="color_vs",
color_value_source_id="vs_anim_1",
ha_light_mappings=[HALightMapping(entity_id="light.bed")],
)
s2 = OutputTargetStore(db)
loaded = s2.get_target(t.id)
assert isinstance(loaded, HALightOutputTarget)
assert loaded.source_kind == "color_vs"
assert loaded.color_value_source_id == "vs_anim_1"
db.close()
def test_update_switches_source_kind(self, store):
target = store.create_target(
"Switcher",
"ha_light",
ha_source_id="ha_1",
color_strip_source_id="css_1",
ha_light_mappings=[HALightMapping(entity_id="light.x")],
)
assert target.source_kind == "css"
updated = store.update_target(
target.id,
source_kind="color_vs",
color_value_source_id="vs_color_1",
)
assert isinstance(updated, HALightOutputTarget)
assert updated.source_kind == "color_vs"
assert updated.color_value_source_id == "vs_color_1"
# CSS id is preserved (non-destructive switch)
assert updated.color_strip_source_id == "css_1"