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