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
@@ -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"