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:
@@ -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 {
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user