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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user