feat: BindableFloat — universal value source binding for all scalar properties
Lint & Test / test (push) Successful in 1m20s

Introduce BindableFloat abstraction that allows any numeric property to be
either a static value or dynamically driven by a ValueSource. Backward-compatible
serialization: plain float when unbound, {value, source_id} dict when bound.

Backend:
- storage/bindable.py — BindableFloat dataclass + bfloat() helper
- 25+ scalar properties converted across all entity types
- Runtime VS acquisition in ColorStripStreamManager for CSS bindings
- All stream hot loops use self.resolve() for live values
- KeyColorsColorStripStream now inherits ColorStripStream

Frontend:
- BindableScalarWidget (slider + VS picker toggle) for all editors
- TypeScript BindableFloat type + helpers
- Graph editor edges for all bindable properties
- Audio source channel IconSelect grid

Fixes: daylight longitude, candlelight wind_strength/candle_type from_dict
This commit is contained in:
2026-03-29 00:33:24 +03:00
parent 5f70302263
commit 8a17bb5caa
48 changed files with 2512 additions and 887 deletions
+38
View File
@@ -0,0 +1,38 @@
# BindableFloat — Universal Value Source Binding
## ALL PHASES COMPLETE
### Phase 1: Core Infrastructure
- [x] `storage/bindable.py` — BindableFloat dataclass + `bfloat()` extraction helper
- [x] WledOutputTarget, HALightOutputTarget, HALightMapping — brightness/transition
- [x] All 15 CSS source types — smoothing, sensitivity, intensity, scale, speed, etc.
- [x] API schemas + routes updated
- [x] output_target_store create/update
- [x] processor_manager add_target / add_ha_light_target
### Phase 2: Runtime Resolution
- [x] WledTargetProcessor — BindableFloat brightness, acquire/release value streams
- [x] HALightTargetProcessor — BindableFloat brightness + transition
- [x] All CSS streams use `bfloat()` to extract static values from BindableFloat properties
- [x] scene_activator — brightness_changed flag
- [x] ColorStripStream base class — `resolve()`, `set_value_stream()`, `remove_value_stream()`
- [x] ColorStripStreamManager — `_bind_value_streams()` / `_release_value_streams()` on acquire/release
- [x] All stream hot loops call `self.resolve(prop, static)` for dynamic runtime binding
- [x] KeyColorsColorStripStream — fixed to inherit from ColorStripStream
### Phase 3: Frontend
- [x] TypeScript BindableFloat type + `bindableValue()` / `bindableSourceId()` helpers
- [x] targets.ts, ha-light-targets.ts, color-strips.ts — save/load/display
- [x] Graph connections — value source edges for ALL bindable CSS properties
- [x] Graph layout — edge creation for CSS + target bindable properties
- [x] custom_components/select.py — HA integration backward compat
### Phase 4: BindableScalarWidget
- [x] `core/bindable-scalar.ts` — reusable widget (slider + VS picker toggle)
- [x] CSS styles (`.bindable-toggle`, `.bindable-slider-row`, `.bindable-vs-row`)
- [x] All 11 CSS editor sliders converted (smoothing, sensitivity, intensity, scale, speed, wind, temp_influence, timeout)
- [x] HTML templates updated with container divs
@@ -141,7 +141,12 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
target_data = self.coordinator.data.get("targets", {}).get(self._target_id) target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data: if not target_data:
return None return None
current_id = target_data["info"].get("brightness_value_source_id", "") # BindableFloat: brightness is either a plain float or {"value": float, "source_id": str}
brightness = target_data["info"].get("brightness", "")
if isinstance(brightness, dict):
current_id = brightness.get("source_id", "")
else:
current_id = target_data["info"].get("brightness_value_source_id", "")
if not current_id: if not current_id:
return NONE_OPTION return NONE_OPTION
sources = self.coordinator.data.get("value_sources") or [] sources = self.coordinator.data.get("value_sources") or []
@@ -167,4 +172,7 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
if source_id is None: if source_id is None:
_LOGGER.error("Value source not found: %s", option) _LOGGER.error("Value source not found: %s", option)
return return
await self.coordinator.update_target(self._target_id, brightness_value_source_id=source_id) await self.coordinator.update_target(
self._target_id,
brightness={"value": 1.0, "source_id": source_id} if source_id else 1.0,
)
@@ -19,6 +19,7 @@ from wled_controller.api.schemas.output_targets import (
) )
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore from wled_controller.storage import DeviceStore
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.ha_light_output_target import ( from wled_controller.storage.ha_light_output_target import (
HALightMapping, HALightMapping,
@@ -43,11 +44,11 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type, target_type=target.target_type,
device_id=target.device_id, device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id, color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id or "", brightness=target.brightness.to_dict(),
fps=target.fps, fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_interval, keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval, state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold, min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps, adaptive_fps=target.adaptive_fps,
protocol=target.protocol, protocol=target.protocol,
description=target.description, description=target.description,
@@ -62,20 +63,20 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type, target_type=target.target_type,
ha_source_id=target.ha_source_id, ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id, color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id or "", brightness=target.brightness.to_dict(),
ha_light_mappings=[ ha_light_mappings=[
HALightMappingSchema( HALightMappingSchema(
entity_id=m.entity_id, entity_id=m.entity_id,
led_start=m.led_start, led_start=m.led_start,
led_end=m.led_end, led_end=m.led_end,
brightness_scale=m.brightness_scale, brightness_scale=m.brightness_scale.to_dict(),
) )
for m in target.light_mappings for m in target.light_mappings
], ],
update_rate=target.update_rate, update_rate=target.update_rate.to_dict(),
ha_transition=target.transition, transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance, color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold, min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
@@ -121,7 +122,7 @@ async def create_target(
entity_id=m.entity_id, entity_id=m.entity_id,
led_start=m.led_start, led_start=m.led_start,
led_end=m.led_end, led_end=m.led_end,
brightness_scale=m.brightness_scale, brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
) )
for m in data.ha_light_mappings for m in data.ha_light_mappings
] ]
@@ -135,7 +136,7 @@ async def create_target(
target_type=data.target_type, target_type=data.target_type,
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id, brightness=data.brightness,
fps=data.fps, fps=data.fps,
keepalive_interval=data.keepalive_interval, keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
@@ -245,7 +246,7 @@ async def update_target(
entity_id=m.entity_id, entity_id=m.entity_id,
led_start=m.led_start, led_start=m.led_start,
led_end=m.led_end, led_end=m.led_end,
brightness_scale=m.brightness_scale, brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
) )
for m in data.ha_light_mappings for m in data.ha_light_mappings
] ]
@@ -256,7 +257,7 @@ async def update_target(
name=data.name, name=data.name,
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id, brightness=data.brightness,
fps=data.fps, fps=data.fps,
keepalive_interval=data.keepalive_interval, keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
@@ -287,9 +288,10 @@ async def update_target(
or data.transition is not None or data.transition is not None
or data.color_tolerance is not None or data.color_tolerance is not None
or data.ha_light_mappings is not None or data.ha_light_mappings is not None
or data.brightness is not None
), ),
css_changed=data.color_strip_source_id is not None, css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=data.brightness_value_source_id is not None, brightness_changed=data.brightness is not None,
) )
except ValueError as e: except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e) logger.debug("Processor config update skipped for target %s: %s", target_id, e)
@@ -1,12 +1,26 @@
"""Output target schemas (CRUD, processing state, metrics).""" """Output target schemas (CRUD, processing state, metrics)."""
from datetime import datetime from datetime import datetime
from typing import Dict, Optional, List from typing import Any, Dict, Optional, List, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# ---------------------------------------------------------------------------
# BindableFloat — accepts plain number OR {value, source_id} dict
# ---------------------------------------------------------------------------
BindableFloatInput = Union[float, int, Dict[str, Any]]
"""API input type: a plain number (static) or {"value": float, "source_id": str}."""
class BindableFloatSchema(BaseModel):
"""Response schema for a bindable scalar property."""
value: float = Field(description="Static value (used when source_id is empty)")
source_id: str = Field(default="", description="Value source ID (empty = static)")
class KeyColorRectangleSchema(BaseModel): class KeyColorRectangleSchema(BaseModel):
"""A named rectangle for key color extraction (relative coords 0.0-1.0).""" """A named rectangle for key color extraction (relative coords 0.0-1.0)."""
@@ -24,8 +38,8 @@ class HALightMappingSchema(BaseModel):
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')") entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)") led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)") led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: float = Field( brightness_scale: Optional[BindableFloatInput] = Field(
default=1.0, ge=0.0, le=1.0, description="Brightness multiplier" default=1.0, description="Brightness multiplier (bindable)"
) )
@@ -37,8 +51,12 @@ class OutputTargetCreate(BaseModel):
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness_value_source_id: str = Field(default="", description="Brightness value source ID") brightness: Optional[BindableFloatInput] = Field(
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)") default=1.0, description="Brightness (bindable)"
)
fps: Optional[BindableFloatInput] = Field(
default=30, description="Target send FPS (bindable, 1-90)"
)
keepalive_interval: float = Field( keepalive_interval: float = Field(
default=1.0, default=1.0,
description="Keepalive send interval when screen is static (0.5-5.0s)", description="Keepalive send interval when screen is static (0.5-5.0s)",
@@ -51,11 +69,9 @@ class OutputTargetCreate(BaseModel):
ge=5, ge=5,
le=600, le=600,
) )
min_brightness_threshold: int = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, default=0,
ge=0, description="Min brightness threshold (bindable, 0=disabled); below this → off",
le=254,
description="Min brightness threshold (0=disabled); below this → off",
) )
adaptive_fps: bool = Field( adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive" default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -72,17 +88,15 @@ class OutputTargetCreate(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)" None, description="LED-to-light mappings (for ha_light targets)"
) )
update_rate: float = Field( update_rate: Optional[BindableFloatInput] = Field(
default=2.0, ge=0.5, le=5.0, description="Service call rate in Hz (for ha_light targets)" default=2.0, description="Service call rate in Hz (bindable, for ha_light targets)"
) )
transition: float = Field( transition: Optional[BindableFloatInput] = Field(
default=0.5, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)" default=0.5, description="HA transition seconds (bindable, for ha_light targets)"
) )
color_tolerance: int = Field( color_tolerance: Optional[BindableFloatInput] = Field(
default=5, default=5,
ge=0, description="RGB delta tolerance (bindable, for ha_light targets)",
le=50,
description="Skip service call if RGB delta < this (for ha_light targets)",
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -95,18 +109,16 @@ class OutputTargetUpdate(BaseModel):
# LED target fields # LED target fields
device_id: Optional[str] = Field(None, description="LED device ID") device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness_value_source_id: Optional[str] = Field( brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
None, description="Brightness value source ID" fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
)
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_interval: Optional[float] = Field( keepalive_interval: Optional[float] = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0 None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
) )
state_check_interval: Optional[int] = Field( state_check_interval: Optional[int] = Field(
None, description="Health check interval (5-600s)", ge=5, le=600 None, description="Health check interval (5-600s)", ge=5, le=600
) )
min_brightness_threshold: Optional[int] = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off" None, description="Min brightness threshold (bindable, 0=disabled)"
) )
adaptive_fps: Optional[bool] = Field( adaptive_fps: Optional[bool] = Field(
None, description="Auto-reduce FPS when device is unresponsive" None, description="Auto-reduce FPS when device is unresponsive"
@@ -121,14 +133,14 @@ class OutputTargetUpdate(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)" None, description="LED-to-light mappings (for ha_light targets)"
) )
update_rate: Optional[float] = Field( update_rate: Optional[BindableFloatInput] = Field(
None, ge=0.5, le=5.0, description="Service call rate Hz (for ha_light targets)" None, description="Service call rate Hz (bindable, for ha_light targets)"
) )
transition: Optional[float] = Field( transition: Optional[BindableFloatInput] = Field(
None, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)" None, description="HA transition seconds (bindable, for ha_light targets)"
) )
color_tolerance: Optional[int] = Field( color_tolerance: Optional[BindableFloatInput] = Field(
None, ge=0, le=50, description="RGB delta tolerance (for ha_light targets)" None, description="RGB delta tolerance (bindable, for ha_light targets)"
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
@@ -143,14 +155,14 @@ class OutputTargetResponse(BaseModel):
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness_value_source_id: str = Field(default="", description="Brightness value source ID") brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[int] = Field(None, description="Target send FPS") fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field( state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)" default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
) )
min_brightness_threshold: int = Field( min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (0=disabled)" default=0, description="Min brightness threshold (bindable, 0=disabled)"
) )
adaptive_fps: bool = Field( adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive" default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -161,8 +173,12 @@ class OutputTargetResponse(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (ha_light)" None, description="LED-to-light mappings (ha_light)"
) )
update_rate: Optional[float] = Field(None, description="Service call rate Hz (ha_light)") update_rate: Optional[BindableFloatInput] = Field(
ha_transition: Optional[float] = Field(None, description="HA transition seconds (ha_light)") None, description="Service call rate Hz (bindable, ha_light)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, ha_light)"
)
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)") color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
+19 -6
View File
@@ -68,8 +68,14 @@ def seed_demo_data(db: Database) -> None:
Must be called BEFORE store constructors run so they load the seeded data. Must be called BEFORE store constructors run so they load the seeded data.
""" """
# Check if any table already has data # Check if any table already has data
for table in ["devices", "output_targets", "color_strip_sources", for table in [
"picture_sources", "audio_sources", "scene_presets"]: "devices",
"output_targets",
"color_strip_sources",
"picture_sources",
"audio_sources",
"scene_presets",
]:
if db.table_exists_with_data(table): if db.table_exists_with_data(table):
logger.info("Demo data already exists — skipping seed") logger.info("Demo data already exists — skipping seed")
return return
@@ -89,6 +95,7 @@ def seed_demo_data(db: Database) -> None:
# ── Devices ──────────────────────────────────────────────────────── # ── Devices ────────────────────────────────────────────────────────
def _build_devices() -> dict: def _build_devices() -> dict:
return { return {
_DEVICE_IDS["strip"]: { _DEVICE_IDS["strip"]: {
@@ -126,6 +133,7 @@ def _build_devices() -> dict:
# ── Capture Templates ────────────────────────────────────────────── # ── Capture Templates ──────────────────────────────────────────────
def _build_capture_templates() -> dict: def _build_capture_templates() -> dict:
return { return {
_TPL_ID: { _TPL_ID: {
@@ -143,6 +151,7 @@ def _build_capture_templates() -> dict:
# ── Output Targets ───────────────────────────────────────────────── # ── Output Targets ─────────────────────────────────────────────────
def _build_output_targets() -> dict: def _build_output_targets() -> dict:
return { return {
_TARGET_IDS["strip"]: { _TARGET_IDS["strip"]: {
@@ -151,7 +160,7 @@ def _build_output_targets() -> dict:
"target_type": "led", "target_type": "led",
"device_id": _DEVICE_IDS["strip"], "device_id": _DEVICE_IDS["strip"],
"color_strip_source_id": _CSS_IDS["gradient"], "color_strip_source_id": _CSS_IDS["gradient"],
"brightness_value_source_id": "", "brightness": 1.0,
"fps": 30, "fps": 30,
"keepalive_interval": 1.0, "keepalive_interval": 1.0,
"state_check_interval": 30, "state_check_interval": 30,
@@ -169,7 +178,7 @@ def _build_output_targets() -> dict:
"target_type": "led", "target_type": "led",
"device_id": _DEVICE_IDS["matrix"], "device_id": _DEVICE_IDS["matrix"],
"color_strip_source_id": _CSS_IDS["picture"], "color_strip_source_id": _CSS_IDS["picture"],
"brightness_value_source_id": "", "brightness": 1.0,
"fps": 30, "fps": 30,
"keepalive_interval": 1.0, "keepalive_interval": 1.0,
"state_check_interval": 30, "state_check_interval": 30,
@@ -186,6 +195,7 @@ def _build_output_targets() -> dict:
# ── Picture Sources ──────────────────────────────────────────────── # ── Picture Sources ────────────────────────────────────────────────
def _build_picture_sources() -> dict: def _build_picture_sources() -> dict:
return { return {
_PS_IDS["main"]: { _PS_IDS["main"]: {
@@ -237,6 +247,7 @@ def _build_picture_sources() -> dict:
# ── Color Strip Sources ──────────────────────────────────────────── # ── Color Strip Sources ────────────────────────────────────────────
def _build_color_strip_sources() -> dict: def _build_color_strip_sources() -> dict:
return { return {
_CSS_IDS["gradient"]: { _CSS_IDS["gradient"]: {
@@ -321,6 +332,7 @@ def _build_color_strip_sources() -> dict:
# ── Audio Sources ────────────────────────────────────────────────── # ── Audio Sources ──────────────────────────────────────────────────
def _build_audio_sources() -> dict: def _build_audio_sources() -> dict:
return { return {
_AS_IDS["system"]: { _AS_IDS["system"]: {
@@ -356,6 +368,7 @@ def _build_audio_sources() -> dict:
# ── Scene Presets ────────────────────────────────────────────────── # ── Scene Presets ──────────────────────────────────────────────────
def _build_scene_presets() -> dict: def _build_scene_presets() -> dict:
return { return {
_SCENE_ID: { _SCENE_ID: {
@@ -369,14 +382,14 @@ def _build_scene_presets() -> dict:
"target_id": _TARGET_IDS["strip"], "target_id": _TARGET_IDS["strip"],
"running": True, "running": True,
"color_strip_source_id": _CSS_IDS["gradient"], "color_strip_source_id": _CSS_IDS["gradient"],
"brightness_value_source_id": "", "brightness": 1.0,
"fps": 30, "fps": 30,
}, },
{ {
"target_id": _TARGET_IDS["matrix"], "target_id": _TARGET_IDS["matrix"],
"running": True, "running": True,
"color_strip_source_id": _CSS_IDS["picture"], "color_strip_source_id": _CSS_IDS["picture"],
"brightness_value_source_id": "", "brightness": 1.0,
"fps": 30, "fps": 30,
}, },
], ],
@@ -325,14 +325,14 @@ class HARuntime:
for s in msg.get("result", []): for s in msg.get("result", []):
eid = s.get("entity_id", "") eid = s.get("entity_id", "")
if self._matches_filter(eid): if self._matches_filter(eid):
attrs = s.get("attributes", {})
entities.append( entities.append(
{ {
"entity_id": eid, "entity_id": eid,
"state": s.get("state", ""), "state": s.get("state", ""),
"friendly_name": s.get("attributes", {}).get( "friendly_name": attrs.get("friendly_name", eid),
"friendly_name", eid
),
"domain": eid.split(".")[0] if "." in eid else "", "domain": eid.split(".")[0] if "." in eid else "",
"icon": attrs.get("icon", ""),
} }
) )
return entities return entities
@@ -44,9 +44,17 @@ class ApiInputColorStripStream(ColorStripStream):
# Parse config # Parse config
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = (
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear" )
from wled_controller.storage.bindable import bfloat
self._timeout = max(0.0, bfloat(source.timeout, 5.0))
self._interpolation = (
source.interpolation
if source.interpolation in ("none", "linear", "nearest")
else "linear"
)
self._led_count = _DEFAULT_LED_COUNT self._led_count = _DEFAULT_LED_COUNT
# Build initial fallback buffer # Build initial fallback buffer
@@ -108,7 +116,9 @@ class ApiInputColorStripStream(ColorStripStream):
dst_positions = np.linspace(0, 1, target_count) dst_positions = np.linspace(0, 1, target_count)
result = np.empty((target_count, 3), dtype=np.uint8) result = np.empty((target_count, 3), dtype=np.uint8)
for ch in range(3): for ch in range(3):
result[:, ch] = np.interp(dst_positions, src_positions, colors[:, ch].astype(np.float32)).astype(np.uint8) result[:, ch] = np.interp(
dst_positions, src_positions, colors[:, ch].astype(np.float32)
).astype(np.uint8)
return result return result
def push_colors(self, colors: np.ndarray) -> None: def push_colors(self, colors: np.ndarray) -> None:
@@ -125,10 +135,10 @@ class ApiInputColorStripStream(ColorStripStream):
n = len(colors) n = len(colors)
if n == self._led_count: if n == self._led_count:
if self._colors.shape == colors.shape: if self._colors.shape == colors.shape:
np.copyto(self._colors, colors, casting='unsafe') np.copyto(self._colors, colors, casting="unsafe")
else: else:
self._colors = np.empty((n, 3), dtype=np.uint8) self._colors = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._colors, colors, casting='unsafe') np.copyto(self._colors, colors, casting="unsafe")
else: else:
self._colors = self._resize(colors, self._led_count) self._colors = self._resize(colors, self._led_count)
self._last_push_time = time.monotonic() self._last_push_time = time.monotonic()
@@ -180,8 +190,8 @@ class ApiInputColorStripStream(ColorStripStream):
buf[start:end] = colors[:length] buf[start:end] = colors[:length]
else: else:
# Pad with zeros if fewer colors than length # Pad with zeros if fewer colors than length
buf[start:start + available] = colors buf[start : start + available] = colors
buf[start + available:end] = 0 buf[start + available : end] = 0
elif mode == "gradient": elif mode == "gradient":
stops = np.array(seg["colors"], dtype=np.float32) stops = np.array(seg["colors"], dtype=np.float32)
@@ -243,7 +253,9 @@ class ApiInputColorStripStream(ColorStripStream):
if self._thread: if self._thread:
self._thread.join(timeout=5.0) self._thread.join(timeout=5.0)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("ApiInputColorStripStream timeout thread did not terminate within 5s") logger.warning(
"ApiInputColorStripStream timeout thread did not terminate within 5s"
)
self._thread = None self._thread = None
logger.info("ApiInputColorStripStream stopped") logger.info("ApiInputColorStripStream stopped")
@@ -259,11 +271,21 @@ class ApiInputColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update fallback_color, timeout, and interpolation from updated source config.""" """Hot-update fallback_color, timeout, and interpolation from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource from wled_controller.storage.color_strip_source import ApiInputColorStripSource
if isinstance(source, ApiInputColorStripSource): if isinstance(source, ApiInputColorStripSource):
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = (
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear" )
_raw_t = source.timeout
self._timeout = max(
0.0, _raw_t.value if hasattr(_raw_t, "value") else float(_raw_t or 5.0)
)
self._interpolation = (
source.interpolation
if source.interpolation in ("none", "linear", "nearest")
else "linear"
)
with self._lock: with self._lock:
self._fallback_array = self._build_fallback(self._led_count) self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out: if self._timed_out:
@@ -274,12 +296,13 @@ class ApiInputColorStripStream(ColorStripStream):
"""Background thread that reverts to fallback on timeout.""" """Background thread that reverts to fallback on timeout."""
while self._running: while self._running:
time.sleep(0.5) time.sleep(0.5)
if self._timeout <= 0: timeout = self.resolve("timeout", self._timeout)
if timeout <= 0:
continue continue
if self._timed_out: if self._timed_out:
continue continue
elapsed = time.monotonic() - self._last_push_time elapsed = time.monotonic() - self._last_push_time
if elapsed >= self._timeout: if elapsed >= timeout:
with self._lock: with self._lock:
self._colors = self._fallback_array.copy() self._colors = self._fallback_array.copy()
self._timed_out = True self._timed_out = True
@@ -37,7 +37,13 @@ class AudioColorStripStream(ColorStripStream):
thread, double-buffered output, configure() for auto-sizing. thread, double-buffered output, configure() for auto-sizing.
""" """
def __init__(self, source, audio_capture_manager: AudioCaptureManager, audio_source_store=None, audio_template_store=None): def __init__(
self,
source,
audio_capture_manager: AudioCaptureManager,
audio_source_store=None,
audio_template_store=None,
):
self._audio_capture_manager = audio_capture_manager self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store self._audio_template_store = audio_template_store
@@ -80,15 +86,19 @@ class AudioColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
self._visualization_mode = getattr(source, "visualization_mode", "spectrum") self._visualization_mode = getattr(source, "visualization_mode", "spectrum")
self._sensitivity = float(getattr(source, "sensitivity", 1.0)) from wled_controller.storage.bindable import bfloat
self._smoothing = float(getattr(source, "smoothing", 0.3))
self._sensitivity = bfloat(getattr(source, "sensitivity", 1.0), 1.0)
self._smoothing = bfloat(getattr(source, "smoothing", 0.3), 0.3)
self._gradient_id = getattr(source, "gradient_id", None) self._gradient_id = getattr(source, "gradient_id", None)
self._palette_name = getattr(source, "palette", "rainbow") self._palette_name = getattr(source, "palette", "rainbow")
self._resolve_palette_lut() self._resolve_palette_lut()
color = getattr(source, "color", None) color = getattr(source, "color", None)
self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0] self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
color_peak = getattr(source, "color_peak", None) color_peak = getattr(source, "color_peak", None)
self._color_peak = color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0] self._color_peak = (
color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0]
)
# Pre-computed float arrays for VU meter (avoid per-frame np.array()) # Pre-computed float arrays for VU meter (avoid per-frame np.array())
self._color_f = np.array(self._color, dtype=np.float32) self._color_f = np.array(self._color, dtype=np.float32)
self._color_peak_f = np.array(self._color_peak, dtype=np.float32) self._color_peak_f = np.array(self._color_peak, dtype=np.float32)
@@ -116,7 +126,11 @@ class AudioColorStripStream(ColorStripStream):
self._audio_engine_type = tpl.engine_type self._audio_engine_type = tpl.engine_type
self._audio_engine_config = tpl.engine_config self._audio_engine_config = tpl.engine_config
except ValueError as e: except ValueError as e:
logger.warning("Audio template %s not found, using default engine: %s", resolved.audio_template_id, e) logger.warning(
"Audio template %s not found, using default engine: %s",
resolved.audio_template_id,
e,
)
pass pass
except ValueError as e: except ValueError as e:
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}") logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
@@ -157,7 +171,8 @@ class AudioColorStripStream(ColorStripStream):
return return
# Acquire shared audio capture stream # Acquire shared audio capture stream
self._audio_stream = self._audio_capture_manager.acquire( self._audio_stream = self._audio_capture_manager.acquire(
self._audio_device_index, self._audio_loopback, self._audio_device_index,
self._audio_loopback,
engine_type=self._audio_engine_type, engine_type=self._audio_engine_type,
engine_config=self._audio_engine_config, engine_config=self._audio_engine_config,
) )
@@ -184,7 +199,8 @@ class AudioColorStripStream(ColorStripStream):
# Release shared audio capture # Release shared audio capture
if self._audio_stream is not None: if self._audio_stream is not None:
self._audio_capture_manager.release( self._audio_capture_manager.release(
self._audio_device_index, self._audio_loopback, self._audio_device_index,
self._audio_loopback,
engine_type=self._audio_engine_type, engine_type=self._audio_engine_type,
) )
self._audio_stream = None self._audio_stream = None
@@ -200,6 +216,7 @@ class AudioColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import AudioColorStripSource from wled_controller.storage.color_strip_source import AudioColorStripSource
if isinstance(source, AudioColorStripSource): if isinstance(source, AudioColorStripSource):
old_device = self._audio_device_index old_device = self._audio_device_index
old_loopback = self._audio_loopback old_loopback = self._audio_loopback
@@ -217,10 +234,13 @@ class AudioColorStripStream(ColorStripStream):
) )
if self._running and needs_swap: if self._running and needs_swap:
self._audio_capture_manager.release( self._audio_capture_manager.release(
old_device, old_loopback, engine_type=old_engine_type, old_device,
old_loopback,
engine_type=old_engine_type,
) )
self._audio_stream = self._audio_capture_manager.acquire( self._audio_stream = self._audio_capture_manager.acquire(
self._audio_device_index, self._audio_loopback, self._audio_device_index,
self._audio_loopback,
engine_type=self._audio_engine_type, engine_type=self._audio_engine_type,
engine_config=self._audio_engine_config, engine_config=self._audio_engine_config,
) )
@@ -301,7 +321,9 @@ class AudioColorStripStream(ColorStripStream):
self._colors = buf self._colors = buf
# Pull capture-side timing and combine with render timing # Pull capture-side timing and combine with render timing
capture_timing = self._audio_stream.get_last_timing() if self._audio_stream else {} capture_timing = (
self._audio_stream.get_last_timing() if self._audio_stream else {}
)
read_ms = capture_timing.get("read_ms", 0) read_ms = capture_timing.get("read_ms", 0)
fft_ms = capture_timing.get("fft_ms", 0) fft_ms = capture_timing.get("fft_ms", 0)
self._last_timing = { self._last_timing = {
@@ -342,8 +364,8 @@ class AudioColorStripStream(ColorStripStream):
return return
spectrum, _ = self._pick_channel(analysis) spectrum, _ = self._pick_channel(analysis)
sensitivity = self._sensitivity sensitivity = self.resolve("sensitivity", self._sensitivity)
smoothing = self._smoothing smoothing = self.resolve("smoothing", self._smoothing)
lut = self._palette_lut lut = self._palette_lut
band_x = self._band_x band_x = self._band_x
full_amp = self._full_amp full_amp = self._full_amp
@@ -355,7 +377,7 @@ class AudioColorStripStream(ColorStripStream):
amplitudes *= sensitivity amplitudes *= sensitivity
np.clip(amplitudes, 0.0, 1.0, out=amplitudes) np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
if self._prev_spectrum is not None and len(self._prev_spectrum) == half: if self._prev_spectrum is not None and len(self._prev_spectrum) == half:
amplitudes *= (1.0 - smoothing) amplitudes *= 1.0 - smoothing
amplitudes += smoothing * self._prev_spectrum amplitudes += smoothing * self._prev_spectrum
self._prev_spectrum = amplitudes.copy() self._prev_spectrum = amplitudes.copy()
# Mirror: center = bass, edges = treble # Mirror: center = bass, edges = treble
@@ -366,7 +388,7 @@ class AudioColorStripStream(ColorStripStream):
amplitudes *= sensitivity amplitudes *= sensitivity
np.clip(amplitudes, 0.0, 1.0, out=amplitudes) np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
if self._prev_spectrum is not None and len(self._prev_spectrum) == n: if self._prev_spectrum is not None and len(self._prev_spectrum) == n:
amplitudes *= (1.0 - smoothing) amplitudes *= 1.0 - smoothing
amplitudes += smoothing * self._prev_spectrum amplitudes += smoothing * self._prev_spectrum
self._prev_spectrum = amplitudes.copy() self._prev_spectrum = amplitudes.copy()
full_amp[:] = amplitudes full_amp[:] = amplitudes
@@ -374,16 +396,16 @@ class AudioColorStripStream(ColorStripStream):
# Map to palette: amplitude → palette index → color # Map to palette: amplitude → palette index → color
np.multiply(full_amp, 255, out=full_amp) np.multiply(full_amp, 255, out=full_amp)
np.clip(full_amp, 0, 255, out=full_amp) np.clip(full_amp, 0, 255, out=full_amp)
np.copyto(indices_buf, full_amp, casting='unsafe') np.copyto(indices_buf, full_amp, casting="unsafe")
colors = lut[indices_buf] # (n, 3) uint8 colors = lut[indices_buf] # (n, 3) uint8
# Scale brightness by amplitude — restore full_amp to [0, 1] # Scale brightness by amplitude — restore full_amp to [0, 1]
full_amp *= (1.0 / 255.0) full_amp *= 1.0 / 255.0
f32_rgb = self._f32_rgb f32_rgb = self._f32_rgb
np.copyto(f32_rgb, colors, casting='unsafe') np.copyto(f32_rgb, colors, casting="unsafe")
f32_rgb *= full_amp[:, np.newaxis] f32_rgb *= full_amp[:, np.newaxis]
np.clip(f32_rgb, 0, 255, out=f32_rgb) np.clip(f32_rgb, 0, 255, out=f32_rgb)
np.copyto(buf, f32_rgb, casting='unsafe') np.copyto(buf, f32_rgb, casting="unsafe")
# ── VU Meter ─────────────────────────────────────────────────── # ── VU Meter ───────────────────────────────────────────────────
@@ -393,8 +415,10 @@ class AudioColorStripStream(ColorStripStream):
return return
_, ch_rms = self._pick_channel(analysis) _, ch_rms = self._pick_channel(analysis)
rms = ch_rms * self._sensitivity sensitivity = self.resolve("sensitivity", self._sensitivity)
rms = self._smoothing * self._prev_rms + (1.0 - self._smoothing) * rms smoothing = self.resolve("smoothing", self._smoothing)
rms = ch_rms * sensitivity
rms = smoothing * self._prev_rms + (1.0 - smoothing) * rms
self._prev_rms = rms self._prev_rms = rms
rms = min(1.0, rms) rms = min(1.0, rms)
@@ -406,9 +430,9 @@ class AudioColorStripStream(ColorStripStream):
peak = self._color_peak_f peak = self._color_peak_f
t = self._vu_gradient[:fill_count] t = self._vu_gradient[:fill_count]
for ch in range(3): for ch in range(3):
buf[:fill_count, ch] = np.clip( buf[:fill_count, ch] = np.clip(base[ch] + (peak[ch] - base[ch]) * t, 0, 255).astype(
base[ch] + (peak[ch] - base[ch]) * t, 0, 255 np.uint8
).astype(np.uint8) )
# ── Beat Pulse ───────────────────────────────────────────────── # ── Beat Pulse ─────────────────────────────────────────────────
@@ -420,7 +444,9 @@ class AudioColorStripStream(ColorStripStream):
if analysis.beat: if analysis.beat:
self._pulse_brightness = 1.0 self._pulse_brightness = 1.0
else: else:
decay_rate = 0.05 + 0.15 * (1.0 / max(self._sensitivity, 0.1)) decay_rate = 0.05 + 0.15 * (
1.0 / max(self.resolve("sensitivity", self._sensitivity), 0.1)
)
self._pulse_brightness = max(0.0, self._pulse_brightness - decay_rate) self._pulse_brightness = max(0.0, self._pulse_brightness - decay_rate)
brightness = self._pulse_brightness brightness = self._pulse_brightness
@@ -432,7 +458,11 @@ class AudioColorStripStream(ColorStripStream):
base_color = self._palette_lut[palette_idx] base_color = self._palette_lut[palette_idx]
# Vectorized fill: scale color by brightness and broadcast to all LEDs # Vectorized fill: scale color by brightness and broadcast to all LEDs
r, g, b = int(base_color[0] * brightness), int(base_color[1] * brightness), int(base_color[2] * brightness) r, g, b = (
int(base_color[0] * brightness),
int(base_color[1] * brightness),
int(base_color[2] * brightness),
)
buf[:, 0] = r buf[:, 0] = r
buf[:, 1] = g buf[:, 1] = g
buf[:, 2] = b buf[:, 2] = b
@@ -47,9 +47,9 @@ def _noise1d(x: np.ndarray) -> np.ndarray:
# (flicker_amplitude_mul, speed_mul, sigma_mul, warm_bonus) # (flicker_amplitude_mul, speed_mul, sigma_mul, warm_bonus)
_CANDLE_PRESETS: dict = { _CANDLE_PRESETS: dict = {
"default": (1.0, 1.0, 1.0, 0.0), "default": (1.0, 1.0, 1.0, 0.0),
"taper": (0.5, 1.3, 0.8, 0.0), # tall, steady "taper": (0.5, 1.3, 0.8, 0.0), # tall, steady
"votive": (1.5, 1.0, 0.7, 0.0), # small, flickery "votive": (1.5, 1.0, 0.7, 0.0), # small, flickery
"bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift "bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift
} }
_VALID_CANDLE_TYPES = frozenset(_CANDLE_PRESETS) _VALID_CANDLE_TYPES = frozenset(_CANDLE_PRESETS)
@@ -86,11 +86,15 @@ class CandlelightColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
raw_color = getattr(source, "color", None) raw_color = getattr(source, "color", None)
self._color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41] self._color = (
self._intensity = float(getattr(source, "intensity", 1.0)) raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
)
from wled_controller.storage.bindable import bfloat
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
self._num_candles = max(1, int(getattr(source, "num_candles", 3))) self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
self._speed = float(getattr(source, "speed", 1.0)) self._speed = bfloat(getattr(source, "speed", 1.0), 1.0)
self._wind_strength = float(getattr(source, "wind_strength", 0.0)) self._wind_strength = bfloat(getattr(source, "wind_strength", 0.0), 0.0)
raw_type = getattr(source, "candle_type", "default") raw_type = getattr(source, "candle_type", "default")
self._candle_type = raw_type if raw_type in _VALID_CANDLE_TYPES else "default" self._candle_type = raw_type if raw_type in _VALID_CANDLE_TYPES else "default"
_lc = getattr(source, "led_count", 0) _lc = getattr(source, "led_count", 0)
@@ -127,7 +131,9 @@ class CandlelightColorStripStream(ColorStripStream):
daemon=True, daemon=True,
) )
self._thread.start() self._thread.start()
logger.info(f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})") logger.info(
f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})"
)
def stop(self) -> None: def stop(self) -> None:
self._running = False self._running = False
@@ -144,6 +150,7 @@ class CandlelightColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import CandlelightColorStripSource from wled_controller.storage.color_strip_source import CandlelightColorStripSource
if isinstance(source, CandlelightColorStripSource): if isinstance(source, CandlelightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
@@ -173,10 +180,10 @@ class CandlelightColorStripStream(ColorStripStream):
time.sleep(0.1) time.sleep(0.1)
continue continue
t = clock.get_time() t = clock.get_time()
speed = clock.speed * self._speed speed = clock.speed * self.resolve("speed", self._speed)
else: else:
t = wall_start t = wall_start
speed = self._speed speed = self.resolve("speed", self._speed)
n = self._led_count n = self._led_count
if n != _pool_n: if n != _pool_n:
@@ -210,7 +217,7 @@ class CandlelightColorStripStream(ColorStripStream):
def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None: def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None:
"""Spawn new wax drip events and advance existing ones.""" """Spawn new wax drip events and advance existing ones."""
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
spawn_interval = max(0.3, 1.0 / max(intensity, 0.01)) spawn_interval = max(0.3, 1.0 / max(intensity, 0.01))
if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5: if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5:
self._last_drip_t = wall_t self._last_drip_t = wall_t
@@ -245,21 +252,22 @@ class CandlelightColorStripStream(ColorStripStream):
# ── Render ────────────────────────────────────────────────────── # ── Render ──────────────────────────────────────────────────────
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float, wall_t: float) -> None: def _render_candlelight(
self, buf: np.ndarray, n: int, t: float, speed: float, wall_t: float
) -> None:
"""Render candle flickering into buf (n, 3) uint8.""" """Render candle flickering into buf (n, 3) uint8."""
amp_mul, spd_mul, sigma_mul, warm_bonus = _CANDLE_PRESETS[self._candle_type] amp_mul, spd_mul, sigma_mul, warm_bonus = _CANDLE_PRESETS[self._candle_type]
eff_speed = speed * 0.35 * spd_mul eff_speed = speed * 0.35 * spd_mul
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
num_candles = self._num_candles num_candles = self._num_candles
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2] base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
# Wind modulation # Wind modulation
wind_strength = self._wind_strength wind_strength = self.resolve("wind_strength", self._wind_strength)
if wind_strength > 0.0: if wind_strength > 0.0:
wind_raw = ( wind_raw = 0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t) + 0.4 * math.sin(
0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t) 2.0 * math.pi * 0.27 * wall_t + 1.1
+ 0.4 * math.sin(2.0 * math.pi * 0.27 * wall_t + 1.1)
) )
wind_mod = max(0.0, wind_raw) wind_mod = max(0.0, wind_raw)
else: else:
@@ -287,7 +295,7 @@ class CandlelightColorStripStream(ColorStripStream):
candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul
if wind_strength > 0.0: if wind_strength > 0.0:
candle_brightness *= (1.0 - wind_strength * wind_mod * 0.4) candle_brightness *= 1.0 - wind_strength * wind_mod * 0.4
candle_brightness = max(0.1, candle_brightness) candle_brightness = max(0.1, candle_brightness)
@@ -300,7 +308,7 @@ class CandlelightColorStripStream(ColorStripStream):
# Per-LED noise # Per-LED noise
noise_x = x * 0.3 + t * eff_speed * 5.0 noise_x = x * 0.3 + t * eff_speed * 5.0
noise = _noise1d(noise_x) noise = _noise1d(noise_x)
bright[:n] *= (0.85 + 0.30 * noise) bright[:n] *= 0.85 + 0.30 * noise
# Wax drip factor # Wax drip factor
bright[:n] *= self._s_drip[:n] bright[:n] *= self._s_drip[:n]
@@ -19,8 +19,13 @@ from typing import Optional
import numpy as np import numpy as np
from wled_controller.core.capture.calibration import CalibrationConfig, AdvancedPixelMapper, create_pixel_mapper from wled_controller.core.capture.calibration import (
CalibrationConfig,
AdvancedPixelMapper,
create_pixel_mapper,
)
from wled_controller.core.capture.screen_capture import extract_border_pixels from wled_controller.core.capture.screen_capture import extract_border_pixels
from wled_controller.storage.bindable import bfloat
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer from wled_controller.utils.timer import high_resolution_timer
@@ -105,6 +110,32 @@ class ColorStripStream(ABC):
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update processing parameters. No-op by default.""" """Hot-update processing parameters. No-op by default."""
# ── BindableFloat value stream resolution ──
_value_streams: dict = None # property_name → ValueStream
def set_value_stream(self, prop: str, stream) -> None:
"""Inject a ValueStream for a bindable property."""
if self._value_streams is None:
self._value_streams = {}
self._value_streams[prop] = stream
def remove_value_stream(self, prop: str) -> None:
"""Remove a ValueStream for a bindable property."""
if self._value_streams:
self._value_streams.pop(prop, None)
def resolve(self, prop: str, static: float) -> float:
"""Resolve a bindable property: ValueStream value if bound, else static."""
if self._value_streams:
vs = self._value_streams.get(prop)
if vs is not None:
try:
return vs.get_value()
except Exception:
pass
return static
class PictureColorStripStream(ColorStripStream): class PictureColorStripStream(ColorStripStream):
"""Color strip stream backed by a LiveStream (picture source). """Color strip stream backed by a LiveStream (picture source).
@@ -138,7 +169,7 @@ class PictureColorStripStream(ColorStripStream):
self._fps: int = 30 # internal capture rate (send FPS is on the target) self._fps: int = 30 # internal capture rate (send FPS is on the target)
self._frame_time: float = 1.0 / 30 self._frame_time: float = 1.0 / 30
self._smoothing: float = source.smoothing self._smoothing: float = bfloat(source.smoothing, 0.3)
self._interpolation_mode: str = source.interpolation_mode self._interpolation_mode: str = source.interpolation_mode
self._calibration: CalibrationConfig = source.calibration self._calibration: CalibrationConfig = source.calibration
self._pixel_mapper = create_pixel_mapper( self._pixel_mapper = create_pixel_mapper(
@@ -189,9 +220,7 @@ class PictureColorStripStream(ColorStripStream):
daemon=True, daemon=True,
) )
self._thread.start() self._thread.start()
logger.info( logger.info(f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})")
f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})"
)
def stop(self) -> None: def stop(self) -> None:
self._running = False self._running = False
@@ -224,12 +253,15 @@ class PictureColorStripStream(ColorStripStream):
PixelMapper is rebuilt atomically if calibration or interpolation_mode changed. PixelMapper is rebuilt atomically if calibration or interpolation_mode changed.
""" """
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource from wled_controller.storage.color_strip_source import (
PictureColorStripSource,
AdvancedPictureColorStripSource,
)
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
return return
self._smoothing = source.smoothing self._smoothing = bfloat(source.smoothing, 0.3)
if ( if (
source.interpolation_mode != self._interpolation_mode source.interpolation_mode != self._interpolation_mode
@@ -253,9 +285,9 @@ class PictureColorStripStream(ColorStripStream):
# Scratch buffer pool (pre-allocated, resized when LED count changes) # Scratch buffer pool (pre-allocated, resized when LED count changes)
_pool_n = 0 _pool_n = 0
_frame_a = _frame_b = None # double-buffered uint8 output _frame_a = _frame_b = None # double-buffered uint8 output
_use_a = True _use_a = True
_u16_a = _u16_b = None # uint16 scratch for smoothing blending _u16_a = _u16_b = None # uint16 scratch for smoothing blending
def _blend_u16(a, b, alpha_b, out): def _blend_u16(a, b, alpha_b, out):
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8. """Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
@@ -263,13 +295,13 @@ class PictureColorStripStream(ColorStripStream):
Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b). Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b).
""" """
nonlocal _u16_a, _u16_b nonlocal _u16_a, _u16_b
np.copyto(_u16_a, a, casting='unsafe') np.copyto(_u16_a, a, casting="unsafe")
np.copyto(_u16_b, b, casting='unsafe') np.copyto(_u16_b, b, casting="unsafe")
_u16_a *= (256 - alpha_b) _u16_a *= 256 - alpha_b
_u16_b *= alpha_b _u16_b *= alpha_b
_u16_a += _u16_b _u16_a += _u16_b
_u16_a >>= 8 _u16_a >>= 8
np.copyto(out, _u16_a, casting='unsafe') np.copyto(out, _u16_a, casting="unsafe")
try: try:
with high_resolution_timer(): with high_resolution_timer():
@@ -333,15 +365,16 @@ class PictureColorStripStream(ColorStripStream):
led_colors = frame_buf led_colors = frame_buf
# Temporal smoothing (pre-allocated uint16 scratch) # Temporal smoothing (pre-allocated uint16 scratch)
smoothing = self._smoothing smoothing = self.resolve("smoothing", self._smoothing)
if ( if (
self._previous_colors is not None self._previous_colors is not None
and smoothing > 0 and smoothing > 0
and len(self._previous_colors) == len(led_colors) and len(self._previous_colors) == len(led_colors)
and _u16_a is not None and _u16_a is not None
): ):
_blend_u16(led_colors, self._previous_colors, _blend_u16(
int(smoothing * 256), led_colors) led_colors, self._previous_colors, int(smoothing * 256), led_colors
)
t3 = time.perf_counter() t3 = time.perf_counter()
self._previous_colors = led_colors self._previous_colors = led_colors
@@ -357,7 +390,9 @@ class PictureColorStripStream(ColorStripStream):
} }
except Exception as e: except Exception as e:
logger.error(f"PictureColorStripStream processing error: {e}", exc_info=True) logger.error(
f"PictureColorStripStream processing error: {e}", exc_info=True
)
elapsed = time.perf_counter() - loop_start elapsed = time.perf_counter() - loop_start
remaining = frame_time - elapsed remaining = frame_time - elapsed
@@ -398,7 +433,9 @@ def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear"
if cr and isinstance(cr, list) and len(cr) == 3: if cr and isinstance(cr, list) and len(cr) == 3:
return np.array(cr, dtype=np.float32) return np.array(cr, dtype=np.float32)
c = stop.get("color", [255, 255, 255]) c = stop.get("color", [255, 255, 255])
return np.array(c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32) return np.array(
c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32
)
# Vectorized: compute all LED positions at once # Vectorized: compute all LED positions at once
positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0]) positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0])
@@ -442,8 +479,8 @@ def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear"
steps = float(max(2, n_stops)) steps = float(max(2, n_stops))
t = np.round(t * steps) / steps t = np.round(t * steps) / steps
a_colors = right_colors[idx] # A's right color a_colors = right_colors[idx] # A's right color
b_colors = left_colors[idx + 1] # B's left color b_colors = left_colors[idx + 1] # B's left color
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors) result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
return np.clip(result, 0, 255).astype(np.uint8) return np.clip(result, 0, 255).astype(np.uint8)
@@ -470,7 +507,11 @@ class StaticColorStripStream(ColorStripStream):
self._update_from_source(source) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
color = source.color if isinstance(source.color, list) and len(source.color) == 3 else [255, 255, 255] color = (
source.color
if isinstance(source.color, list) and len(source.color) == 3
else [255, 255, 255]
)
self._source_color = color # stored separately so configure() can rebuild self._source_color = color # stored separately so configure() can rebuild
_lc = getattr(source, "led_count", 0) _lc = getattr(source, "led_count", 0)
self._auto_size = not _lc self._auto_size = not _lc
@@ -544,6 +585,7 @@ class StaticColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import StaticColorStripSource from wled_controller.storage.color_strip_source import StaticColorStripSource
if isinstance(source, StaticColorStripSource): if isinstance(source, StaticColorStripSource):
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
@@ -602,7 +644,11 @@ class StaticColorStripStream(ColorStripStream):
if atype == "breathing": if atype == "breathing":
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
r, g, b = self._source_color r, g, b = self._source_color
buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor))) buf[:] = (
min(255, int(r * factor)),
min(255, int(g * factor)),
min(255, int(b * factor)),
)
colors = buf colors = buf
elif atype == "strobe": elif atype == "strobe":
@@ -631,7 +677,11 @@ class StaticColorStripStream(ColorStripStream):
else: else:
factor = math.exp(-5.0 * (phase - 0.1)) factor = math.exp(-5.0 * (phase - 0.1))
r, g, b = self._source_color r, g, b = self._source_color
buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor))) buf[:] = (
min(255, int(r * factor)),
min(255, int(g * factor)),
min(255, int(b * factor)),
)
colors = buf colors = buf
elif atype == "candle": elif atype == "candle":
@@ -642,7 +692,11 @@ class StaticColorStripStream(ColorStripStream):
flicker += 0.10 * (np.random.random() - 0.5) flicker += 0.10 * (np.random.random() - 0.5)
factor = max(0.2, min(1.0, base_factor + flicker)) factor = max(0.2, min(1.0, base_factor + flicker))
r, g, b = self._source_color r, g, b = self._source_color
buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor))) buf[:] = (
min(255, int(r * factor)),
min(255, int(g * factor)),
min(255, int(b * factor)),
)
colors = buf colors = buf
elif atype == "rainbow_fade": elif atype == "rainbow_fade":
@@ -694,12 +748,14 @@ class ColorCycleColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
raw = source.colors if isinstance(source.colors, list) else [] raw = source.colors if isinstance(source.colors, list) else []
default = [ default = [
[255, 0, 0], [255, 255, 0], [0, 255, 0], [255, 0, 0],
[0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 0],
[0, 255, 0],
[0, 255, 255],
[0, 0, 255],
[255, 0, 255],
] ]
self._color_list = [ self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
c for c in raw if isinstance(c, list) and len(c) == 3
] or default
_lc = getattr(source, "led_count", 0) _lc = getattr(source, "led_count", 0)
self._auto_size = not _lc self._auto_size = not _lc
self._led_count = _lc if _lc > 0 else 1 self._led_count = _lc if _lc > 0 else 1
@@ -742,14 +798,18 @@ class ColorCycleColorStripStream(ColorStripStream):
daemon=True, daemon=True,
) )
self._thread.start() self._thread.start()
logger.info(f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})") logger.info(
f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
)
def stop(self) -> None: def stop(self) -> None:
self._running = False self._running = False
if self._thread: if self._thread:
self._thread.join(timeout=5.0) self._thread.join(timeout=5.0)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("ColorCycleColorStripStream animate thread did not terminate within 5s") logger.warning(
"ColorCycleColorStripStream animate thread did not terminate within 5s"
)
self._thread = None self._thread = None
logger.info("ColorCycleColorStripStream stopped") logger.info("ColorCycleColorStripStream stopped")
@@ -759,6 +819,7 @@ class ColorCycleColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import ColorCycleColorStripSource from wled_controller.storage.color_strip_source import ColorCycleColorStripSource
if isinstance(source, ColorCycleColorStripSource): if isinstance(source, ColorCycleColorStripSource):
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
@@ -929,14 +990,18 @@ class GradientColorStripStream(ColorStripStream):
daemon=True, daemon=True,
) )
self._thread.start() self._thread.start()
logger.info(f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})") logger.info(
f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})"
)
def stop(self) -> None: def stop(self) -> None:
self._running = False self._running = False
if self._thread: if self._thread:
self._thread.join(timeout=5.0) self._thread.join(timeout=5.0)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("GradientColorStripStream animate thread did not terminate within 5s") logger.warning(
"GradientColorStripStream animate thread did not terminate within 5s"
)
self._thread = None self._thread = None
logger.info("GradientColorStripStream stopped") logger.info("GradientColorStripStream stopped")
@@ -946,6 +1011,7 @@ class GradientColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import GradientColorStripSource from wled_controller.storage.color_strip_source import GradientColorStripSource
if isinstance(source, GradientColorStripSource): if isinstance(source, GradientColorStripSource):
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
@@ -973,9 +1039,9 @@ class GradientColorStripStream(ColorStripStream):
_pool_n = 0 _pool_n = 0
_buf_a = _buf_b = _scratch_u16 = None _buf_a = _buf_b = _scratch_u16 = None
_use_a = True _use_a = True
_wave_i = None # cached np.arange for wave animation _wave_i = None # cached np.arange for wave animation
_wave_factors = None # float32 scratch for wave sin result _wave_factors = None # float32 scratch for wave sin result
_wave_u16 = None # uint16 scratch for wave int factors _wave_u16 = None # uint16 scratch for wave int factors
try: try:
with high_resolution_timer(): with high_resolution_timer():
@@ -1002,7 +1068,12 @@ class GradientColorStripStream(ColorStripStream):
# Recompute base gradient only when stops, led_count, or easing change # Recompute base gradient only when stops, led_count, or easing change
easing = self._easing easing = self._easing
if _cached_base is None or _cached_n != n or _cached_stops is not stops or _cached_easing != easing: if (
_cached_base is None
or _cached_n != n
or _cached_stops is not stops
or _cached_easing != easing
):
_cached_base = _compute_gradient_colors(stops, n, easing) _cached_base = _compute_gradient_colors(stops, n, easing)
_cached_n = n _cached_n = n
_cached_stops = stops _cached_stops = stops
@@ -1023,18 +1094,28 @@ class GradientColorStripStream(ColorStripStream):
_use_a = not _use_a _use_a = not _use_a
if atype == "breathing": if atype == "breathing":
int_f = max(0, min(256, int(0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) * 256))) int_f = max(
0,
min(
256,
int(
0.5
* (1 + math.sin(2 * math.pi * speed * t * 0.5))
* 256
),
),
)
np.copyto(_scratch_u16, base) np.copyto(_scratch_u16, base)
_scratch_u16 *= int_f _scratch_u16 *= int_f
_scratch_u16 >>= 8 _scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe') np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf colors = buf
elif atype == "gradient_shift": elif atype == "gradient_shift":
shift = int(speed * t * 10) % max(n, 1) shift = int(speed * t * 10) % max(n, 1)
if shift > 0: if shift > 0:
buf[:n - shift] = base[shift:] buf[: n - shift] = base[shift:]
buf[n - shift:] = base[:shift] buf[n - shift :] = base[:shift]
else: else:
np.copyto(buf, base) np.copyto(buf, base)
colors = buf colors = buf
@@ -1049,11 +1130,11 @@ class GradientColorStripStream(ColorStripStream):
_wave_factors += 0.5 _wave_factors += 0.5
np.multiply(_wave_factors, 256, out=_wave_factors) np.multiply(_wave_factors, 256, out=_wave_factors)
np.clip(_wave_factors, 0, 256, out=_wave_factors) np.clip(_wave_factors, 0, 256, out=_wave_factors)
np.copyto(_wave_u16, _wave_factors, casting='unsafe') np.copyto(_wave_u16, _wave_factors, casting="unsafe")
np.copyto(_scratch_u16, base) np.copyto(_scratch_u16, base)
_scratch_u16 *= _wave_u16[:, None] _scratch_u16 *= _wave_u16[:, None]
_scratch_u16 >>= 8 _scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe') np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf colors = buf
else: else:
np.copyto(buf, base) np.copyto(buf, base)
@@ -1083,7 +1164,7 @@ class GradientColorStripStream(ColorStripStream):
np.copyto(_scratch_u16, base) np.copyto(_scratch_u16, base)
_scratch_u16 *= int_f _scratch_u16 *= int_f
_scratch_u16 >>= 8 _scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe') np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf colors = buf
elif atype == "candle": elif atype == "candle":
@@ -1096,7 +1177,7 @@ class GradientColorStripStream(ColorStripStream):
np.copyto(_scratch_u16, base) np.copyto(_scratch_u16, base)
_scratch_u16 *= int_f _scratch_u16 *= int_f
_scratch_u16 >>= 8 _scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe') np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf colors = buf
elif atype == "rainbow_fade": elif atype == "rainbow_fade":
@@ -1117,7 +1198,7 @@ class GradientColorStripStream(ColorStripStream):
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0 h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0 h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0 h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
h_arr *= (1.0 / 6.0) h_arr *= 1.0 / 6.0
h_arr %= 1.0 h_arr %= 1.0
# Saturation & Value with clamping # Saturation & Value with clamping
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0)) s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
@@ -1138,9 +1219,12 @@ class GradientColorStripStream(ColorStripStream):
go = np.empty(n, dtype=np.float32) go = np.empty(n, dtype=np.float32)
bo = np.empty(n, dtype=np.float32) bo = np.empty(n, dtype=np.float32)
for sxt, rv, gv, bv in ( for sxt, rv, gv, bv in (
(0, v_arr, tt, p), (1, q, v_arr, p), (0, v_arr, tt, p),
(2, p, v_arr, tt), (3, p, q, v_arr), (1, q, v_arr, p),
(4, tt, p, v_arr), (5, v_arr, p, q), (2, p, v_arr, tt),
(3, p, q, v_arr),
(4, tt, p, v_arr),
(5, v_arr, p, q),
): ):
m = hi == sxt m = hi == sxt
ro[m] = rv[m] ro[m] = rv[m]
@@ -1158,9 +1242,13 @@ class GradientColorStripStream(ColorStripStream):
noise_val = _gradient_noise.noise( noise_val = _gradient_noise.noise(
np.array([si * 10.0 + t * speed], dtype=np.float32) np.array([si * 10.0 + t * speed], dtype=np.float32)
)[0] )[0]
new_pos = min(1.0, max(0.0, new_pos = min(
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2 1.0,
)) max(
0.0,
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2,
),
)
perturbed.append(dict(s, position=new_pos)) perturbed.append(dict(s, position=new_pos))
buf[:] = _compute_gradient_colors(perturbed, n, easing) buf[:] = _compute_gradient_colors(perturbed, n, easing)
colors = buf colors = buf
@@ -1183,7 +1271,7 @@ class GradientColorStripStream(ColorStripStream):
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0 h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0 h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0 h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
h_arr *= (1.0 / 6.0) h_arr *= 1.0 / 6.0
h_arr %= 1.0 h_arr %= 1.0
# S and V — preserve original values (no clamping) # S and V — preserve original values (no clamping)
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0)) s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
@@ -1202,9 +1290,12 @@ class GradientColorStripStream(ColorStripStream):
go = np.empty(n, dtype=np.float32) go = np.empty(n, dtype=np.float32)
bo = np.empty(n, dtype=np.float32) bo = np.empty(n, dtype=np.float32)
for sxt, rv, gv, bv in ( for sxt, rv, gv, bv in (
(0, v_arr, tt, p), (1, q, v_arr, p), (0, v_arr, tt, p),
(2, p, v_arr, tt), (3, p, q, v_arr), (1, q, v_arr, p),
(4, tt, p, v_arr), (5, v_arr, p, q), (2, p, v_arr, tt),
(3, p, q, v_arr),
(4, tt, p, v_arr),
(5, v_arr, p, q),
): ):
m = hi == sxt m = hi == sxt
ro[m] = rv[m] ro[m] = rv[m]
@@ -53,12 +53,16 @@ class _ColorStripEntry:
target_fps: Dict[str, int] = None target_fps: Dict[str, int] = None
# Clock ID currently acquired for this stream (for correct release) # Clock ID currently acquired for this stream (for correct release)
clock_id: Optional[str] = None clock_id: Optional[str] = None
# Value stream IDs acquired for BindableFloat properties (prop → vs_id)
bound_vs_ids: Dict[str, str] = None
def __post_init__(self): def __post_init__(self):
if self.picture_source_ids is None: if self.picture_source_ids is None:
self.picture_source_ids = [] self.picture_source_ids = []
if self.target_fps is None: if self.target_fps is None:
self.target_fps = {} self.target_fps = {}
if self.bound_vs_ids is None:
self.bound_vs_ids = {}
class ColorStripStreamManager: class ColorStripStreamManager:
@@ -143,6 +147,54 @@ class ColorStripStreamManager:
logger.debug("Sync clock release during stream cleanup: %s", e) logger.debug("Sync clock release during stream cleanup: %s", e)
pass # source may have been deleted already pass # source may have been deleted already
# Properties that can be BindableFloat on any CSS source
_BINDABLE_PROPS = (
"smoothing",
"sensitivity",
"intensity",
"scale",
"speed",
"wind_strength",
"temperature_influence",
"sound_volume",
"timeout",
"brightness",
"duration_ms",
)
def _bind_value_streams(
self, css_stream: ColorStripStream, source, entry: _ColorStripEntry
) -> None:
"""Acquire ValueStreams for any bound BindableFloat properties and inject into stream."""
if not self._value_stream_manager:
return
from wled_controller.storage.bindable import BindableFloat
for prop in self._BINDABLE_PROPS:
bf = getattr(source, prop, None)
if isinstance(bf, BindableFloat) and bf.source_id:
try:
vs = self._value_stream_manager.acquire(bf.source_id)
css_stream.set_value_stream(prop, vs)
entry.bound_vs_ids[prop] = bf.source_id
logger.debug("Bound VS %s%s.%s", bf.source_id, source.id, prop)
except Exception as e:
logger.warning(
"Failed to acquire VS %s for %s.%s: %s", bf.source_id, source.id, prop, e
)
def _release_value_streams(self, entry: _ColorStripEntry) -> None:
"""Release all ValueStreams acquired for an entry."""
if not self._value_stream_manager or not entry.bound_vs_ids:
return
for prop, vs_id in entry.bound_vs_ids.items():
try:
self._value_stream_manager.release(vs_id)
entry.stream.remove_value_stream(prop)
except Exception as e:
logger.debug("VS release for %s: %s", vs_id, e)
entry.bound_vs_ids.clear()
def _resolve_key(self, css_id: str, consumer_id: str) -> str: def _resolve_key(self, css_id: str, consumer_id: str) -> str:
"""Resolve internal registry key for a (css_id, consumer_id) pair. """Resolve internal registry key for a (css_id, consumer_id) pair.
@@ -221,12 +273,14 @@ class ColorStripStreamManager:
acquired_clock_id = self._inject_clock(css_stream, source) acquired_clock_id = self._inject_clock(css_stream, source)
css_stream.start() css_stream.start()
key = f"{css_id}:{consumer_id}" if consumer_id else css_id key = f"{css_id}:{consumer_id}" if consumer_id else css_id
self._streams[key] = _ColorStripEntry( entry = _ColorStripEntry(
stream=css_stream, stream=css_stream,
ref_count=1, ref_count=1,
picture_source_ids=[], picture_source_ids=[],
clock_id=acquired_clock_id, clock_id=acquired_clock_id,
) )
self._bind_value_streams(css_stream, source, entry)
self._streams[key] = entry
logger.info(f"Created {source.source_type} stream {key}") logger.info(f"Created {source.source_type} stream {key}")
return css_stream return css_stream
@@ -261,11 +315,13 @@ class ColorStripStreamManager:
self._live_stream_manager.release(ps_id) self._live_stream_manager.release(ps_id)
raise RuntimeError(f"Failed to start key_colors stream {css_id}: {e}") from e raise RuntimeError(f"Failed to start key_colors stream {css_id}: {e}") from e
self._streams[css_id] = _ColorStripEntry( entry = _ColorStripEntry(
stream=css_stream, stream=css_stream,
ref_count=1, ref_count=1,
picture_source_ids=[ps_id], picture_source_ids=[ps_id],
) )
self._bind_value_streams(css_stream, source, entry)
self._streams[css_id] = entry
logger.info(f"Created key_colors stream {css_id} ({len(source.rectangles)} rects)") logger.info(f"Created key_colors stream {css_id} ({len(source.rectangles)} rects)")
return css_stream return css_stream
@@ -314,11 +370,13 @@ class ColorStripStreamManager:
f"Failed to start color strip stream for source {css_id}: {e}" f"Failed to start color strip stream for source {css_id}: {e}"
) from e ) from e
self._streams[css_id] = _ColorStripEntry( entry = _ColorStripEntry(
stream=css_stream, stream=css_stream,
ref_count=1, ref_count=1,
picture_source_ids=list(acquired.keys()), picture_source_ids=list(acquired.keys()),
) )
self._bind_value_streams(css_stream, source, entry)
self._streams[css_id] = entry
logger.info(f"Created picture color strip stream {css_id}") logger.info(f"Created picture color strip stream {css_id}")
return css_stream return css_stream
@@ -353,6 +411,9 @@ class ColorStripStreamManager:
source_id = key.split(":")[0] if ":" in key else key source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream, clock_id=entry.clock_id) self._release_clock(source_id, entry.stream, clock_id=entry.clock_id)
# Release bound value streams
self._release_value_streams(entry)
picture_source_ids = entry.picture_source_ids picture_source_ids = entry.picture_source_ids
del self._streams[key] del self._streams[key]
logger.info(f"Removed color strip stream {key}") logger.info(f"Removed color strip stream {key}")
@@ -7,6 +7,7 @@ from typing import Dict, List, Optional
import numpy as np import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bfloat
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -37,6 +38,7 @@ class CompositeColorStripStream(ColorStripStream):
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None): def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
import uuid as _uuid import uuid as _uuid
self._source_id: str = source.id self._source_id: str = source.id
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
self._layers: List[dict] = list(source.layers) self._layers: List[dict] = list(source.layers)
@@ -67,9 +69,7 @@ class CompositeColorStripStream(ColorStripStream):
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams) self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
# Pre-resolved blend methods: blend_mode_str -> bound method # Pre-resolved blend methods: blend_mode_str -> bound method
self._blend_methods = { self._blend_methods = {k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()}
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
}
self._default_blend_method = self._blend_normal self._default_blend_method = self._blend_normal
# Pre-allocated scratch (rebuilt when LED count changes) # Pre-allocated scratch (rebuilt when LED count changes)
@@ -104,7 +104,8 @@ class CompositeColorStripStream(ColorStripStream):
self._acquire_sub_streams() self._acquire_sub_streams()
self._running = True self._running = True
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._processing_loop, daemon=True, target=self._processing_loop,
daemon=True,
name=f"CompositeCSS-{self._source_id[:12]}", name=f"CompositeCSS-{self._source_id[:12]}",
) )
self._thread.start() self._thread.start()
@@ -162,14 +163,32 @@ class CompositeColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update: rebuild sub-streams if layer config changed.""" """Hot-update: rebuild sub-streams if layer config changed."""
new_layers = list(source.layers) new_layers = list(source.layers)
old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), old_layer_ids = [
layer.get("enabled"), layer.get("brightness_source_id"), (
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False)) layer.get("source_id"),
for layer in self._layers] layer.get("blend_mode"),
new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), layer.get("opacity"),
layer.get("enabled"), layer.get("brightness_source_id"), layer.get("enabled"),
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False)) layer.get("brightness_source_id"),
for layer in new_layers] layer.get("start", 0),
layer.get("end", 0),
layer.get("reverse", False),
)
for layer in self._layers
]
new_layer_ids = [
(
layer.get("source_id"),
layer.get("blend_mode"),
layer.get("opacity"),
layer.get("enabled"),
layer.get("brightness_source_id"),
layer.get("start", 0),
layer.get("end", 0),
layer.get("reverse", False),
)
for layer in new_layers
]
self._layers = new_layers self._layers = new_layers
@@ -209,9 +228,7 @@ class CompositeColorStripStream(ColorStripStream):
stream.configure(self._led_count) stream.configure(self._led_count)
self._sub_streams[i] = (src_id, consumer_id, stream) self._sub_streams[i] = (src_id, consumer_id, stream)
except Exception as e: except Exception as e:
logger.warning( logger.warning(f"Composite layer {i} (source {src_id}) failed to acquire: {e}")
f"Composite layer {i} (source {src_id}) failed to acquire: {e}"
)
# Acquire brightness value stream if configured # Acquire brightness value stream if configured
vs_id = layer.get("brightness_source_id") vs_id = layer.get("brightness_source_id")
if vs_id and self._value_stream_manager: if vs_id and self._value_stream_manager:
@@ -219,9 +236,7 @@ class CompositeColorStripStream(ColorStripStream):
vs = self._value_stream_manager.acquire(vs_id) vs = self._value_stream_manager.acquire(vs_id)
self._brightness_streams[i] = (vs_id, vs) self._brightness_streams[i] = (vs_id, vs)
except Exception as e: except Exception as e:
logger.warning( logger.warning(f"Composite layer {i} brightness source {vs_id} failed: {e}")
f"Composite layer {i} brightness source {vs_id} failed: {e}"
)
def _release_sub_streams(self) -> None: def _release_sub_streams(self) -> None:
self._sub_streams_version += 1 self._sub_streams_version += 1
@@ -272,20 +287,20 @@ class CompositeColorStripStream(ColorStripStream):
# ── Blend operations (integer math, pre-allocated) ────────── # ── Blend operations (integer math, pre-allocated) ──────────
def _blend_normal(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_normal(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Normal blend: out = (bottom * (256-a) + top * a) >> 8""" """Normal blend: out = (bottom * (256-a) + top * a) >> 8"""
u16a, u16b = self._u16_a, self._u16_b u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe") np.copyto(u16b, top, casting="unsafe")
u16a *= (256 - alpha) u16a *= 256 - alpha
u16b *= alpha u16b *= alpha
u16a += u16b u16a += u16b
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_add(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_add(self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray) -> None:
out: np.ndarray) -> None:
"""Additive blend: out = min(255, bottom + top * alpha >> 8)""" """Additive blend: out = min(255, bottom + top * alpha >> 8)"""
u16a, u16b = self._u16_a, self._u16_b u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
@@ -296,8 +311,9 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(u16a, 0, 255, out=u16a) np.clip(u16a, 0, 255, out=u16a)
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_multiply(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_multiply(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Multiply blend: blended = bottom*top>>8, then lerp with alpha.""" """Multiply blend: blended = bottom*top>>8, then lerp with alpha."""
u16a, u16b = self._u16_a, self._u16_b u16a, u16b = self._u16_a, self._u16_b
# blended = (bottom * top) >> 8 # blended = (bottom * top) >> 8
@@ -307,14 +323,15 @@ class CompositeColorStripStream(ColorStripStream):
u16a >>= 8 u16a >>= 8
# lerp: result = (bottom * (256-a) + blended * a) >> 8 # lerp: result = (bottom * (256-a) + blended * a) >> 8
np.copyto(u16b, bottom, casting="unsafe") np.copyto(u16b, bottom, casting="unsafe")
u16b *= (256 - alpha) u16b *= 256 - alpha
u16a *= alpha u16a *= alpha
u16a += u16b u16a += u16b
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_screen(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_screen(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Screen blend: blended = 255 - (255-bottom)*(255-top)>>8, then lerp.""" """Screen blend: blended = 255 - (255-bottom)*(255-top)>>8, then lerp."""
u16a, u16b = self._u16_a, self._u16_b u16a, u16b = self._u16_a, self._u16_b
# blended = 255 - ((255 - bottom) * (255 - top)) >> 8 # blended = 255 - ((255 - bottom) * (255 - top)) >> 8
@@ -327,14 +344,15 @@ class CompositeColorStripStream(ColorStripStream):
u16a[:] = 255 - u16a u16a[:] = 255 - u16a
# lerp: result = (bottom * (256-a) + blended * a) >> 8 # lerp: result = (bottom * (256-a) + blended * a) >> 8
np.copyto(u16b, bottom, casting="unsafe") np.copyto(u16b, bottom, casting="unsafe")
u16b *= (256 - alpha) u16b *= 256 - alpha
u16a *= alpha u16a *= alpha
u16a += u16b u16a += u16b
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_override(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Override blend: per-pixel alpha derived from top brightness. """Override blend: per-pixel alpha derived from top brightness.
Black pixels are fully transparent (bottom shows through), Black pixels are fully transparent (bottom shows through),
@@ -349,14 +367,15 @@ class CompositeColorStripStream(ColorStripStream):
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8 # Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe") np.copyto(u16b, top, casting="unsafe")
u16a *= (256 - per_px_alpha) u16a *= 256 - per_px_alpha
u16b *= per_px_alpha u16b *= per_px_alpha
u16a += u16b u16a += u16b
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_overlay(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_overlay(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Overlay blend: multiply darks, screen lights, then lerp with alpha. """Overlay blend: multiply darks, screen lights, then lerp with alpha.
if bottom < 128: blended = 2*bottom*top >> 8 if bottom < 128: blended = 2*bottom*top >> 8
@@ -375,14 +394,15 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(blended, 0, 255, out=blended) np.clip(blended, 0, 255, out=blended)
# Lerp: result = (bottom * (256-a) + blended * a) >> 8 # Lerp: result = (bottom * (256-a) + blended * a) >> 8
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha) u16a *= 256 - alpha
blended *= alpha blended *= alpha
u16a += blended u16a += blended
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_soft_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_soft_light(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Soft light blend (Pegtop formula), then lerp with alpha. """Soft light blend (Pegtop formula), then lerp with alpha.
blended = (1 - 2*t/255) * b*b/255 + 2*t*b/255 blended = (1 - 2*t/255) * b*b/255 + 2*t*b/255
@@ -399,15 +419,16 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(blended, 0, 255, out=blended) np.clip(blended, 0, 255, out=blended)
# Lerp # Lerp
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha) u16a *= 256 - alpha
blended_u16 = blended.astype(np.uint16) blended_u16 = blended.astype(np.uint16)
blended_u16 *= alpha blended_u16 *= alpha
u16a += blended_u16 u16a += blended_u16
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_hard_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_hard_light(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Hard light blend: overlay with top/bottom roles swapped. """Hard light blend: overlay with top/bottom roles swapped.
if top < 128: blended = 2*bottom*top >> 8 if top < 128: blended = 2*bottom*top >> 8
@@ -424,14 +445,15 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(blended, 0, 255, out=blended) np.clip(blended, 0, 255, out=blended)
# Lerp # Lerp
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha) u16a *= 256 - alpha
blended *= alpha blended *= alpha
u16a += blended u16a += blended
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_difference(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_difference(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Difference blend: |bottom - top|, then lerp with alpha.""" """Difference blend: |bottom - top|, then lerp with alpha."""
u16a, u16b = self._u16_a, self._u16_b u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
@@ -440,14 +462,15 @@ class CompositeColorStripStream(ColorStripStream):
blended = np.abs(u16a.astype(np.int16) - u16b.astype(np.int16)).astype(np.uint16) blended = np.abs(u16a.astype(np.int16) - u16b.astype(np.int16)).astype(np.uint16)
# Lerp # Lerp
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha) u16a *= 256 - alpha
blended *= alpha blended *= alpha
u16a += blended u16a += blended
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_exclusion(self, bottom: np.ndarray, top: np.ndarray, alpha: int, def _blend_exclusion(
out: np.ndarray) -> None: self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Exclusion blend: bottom + top - 2*bottom*top/255, then lerp with alpha.""" """Exclusion blend: bottom + top - 2*bottom*top/255, then lerp with alpha."""
u16a, u16b = self._u16_a, self._u16_b u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
@@ -457,7 +480,7 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(blended, 0, 255, out=blended) np.clip(blended, 0, 255, out=blended)
# Lerp # Lerp
np.copyto(u16a, bottom, casting="unsafe") np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha) u16a *= 256 - alpha
blended *= alpha blended *= alpha
u16a += blended u16a += blended
u16a >>= 8 u16a >>= 8
@@ -525,13 +548,16 @@ class CompositeColorStripStream(ColorStripStream):
# Resolve and cache filters for this layer # Resolve and cache filters for this layer
try: try:
from wled_controller.core.filters.registry import FilterRegistry from wled_controller.core.filters.registry import FilterRegistry
_resolved = self._cspt_store.resolve_filter_instances( _resolved = self._cspt_store.resolve_filter_instances(
self._cspt_store.get_template(_layer_tmpl_id).filters self._cspt_store.get_template(_layer_tmpl_id).filters
) )
_filters = [ _filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options) FilterRegistry.create_instance(fi.filter_id, fi.options)
for fi in _resolved for fi in _resolved
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True) if getattr(
FilterRegistry.get(fi.filter_id), "supports_strip", True
)
] ]
_layer_cspt_cache[i] = (_layer_tmpl_id, _filters) _layer_cspt_cache[i] = (_layer_tmpl_id, _filters)
logger.info( logger.info(
@@ -539,7 +565,9 @@ class CompositeColorStripStream(ColorStripStream):
f"from template {_layer_tmpl_id}" f"from template {_layer_tmpl_id}"
) )
except Exception as e: except Exception as e:
logger.warning(f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}") logger.warning(
f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}"
)
_layer_cspt_cache[i] = (_layer_tmpl_id, []) _layer_cspt_cache[i] = (_layer_tmpl_id, [])
_layer_filters = _layer_cspt_cache[i][1] _layer_filters = _layer_cspt_cache[i][1]
if _layer_filters: if _layer_filters:
@@ -556,7 +584,9 @@ class CompositeColorStripStream(ColorStripStream):
if has_range: if has_range:
# Clamp range to strip bounds # Clamp range to strip bounds
eff_start = max(0, min(layer_start, target_n)) eff_start = max(0, min(layer_start, target_n))
eff_end = max(eff_start, min(layer_end if layer_end > 0 else target_n, target_n)) eff_end = max(
eff_start, min(layer_end if layer_end > 0 else target_n, target_n)
)
zone_len = eff_end - eff_start zone_len = eff_end - eff_start
if zone_len <= 0: if zone_len <= 0:
continue continue
@@ -573,7 +603,11 @@ class CompositeColorStripStream(ColorStripStream):
self._resize_cache[rkey] = cached self._resize_cache[rkey] = cached
src_x, dst_x, resized = cached src_x, dst_x, resized = cached
for ch in range(3): for ch in range(3):
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe") np.copyto(
resized[:, ch],
np.interp(dst_x, src_x, colors[:, ch]),
casting="unsafe",
)
colors = resized colors = resized
else: else:
# Full-strip layer: resize to target LED count # Full-strip layer: resize to target LED count
@@ -589,13 +623,15 @@ class CompositeColorStripStream(ColorStripStream):
_vs_id, vs = self._brightness_streams[i] _vs_id, vs = self._brightness_streams[i]
bri = vs.get_value() bri = vs.get_value()
if bri < 1.0: if bri < 1.0:
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8) colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(
np.uint8
)
# Snapshot layer colors before blending (copy — may alias shared buf) # Snapshot layer colors before blending (copy — may alias shared buf)
if self._need_layer_snapshots: if self._need_layer_snapshots:
layer_snapshots.append(colors.copy()) layer_snapshots.append(colors.copy())
opacity = layer.get("opacity", 1.0) opacity = bfloat(layer.get("opacity", 1.0), 1.0)
blend_mode = layer.get("blend_mode", _BLEND_NORMAL) blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
alpha = int(opacity * 256) alpha = int(opacity * 256)
alpha = max(0, min(256, alpha)) alpha = max(0, min(256, alpha))
@@ -609,20 +645,26 @@ class CompositeColorStripStream(ColorStripStream):
rng = result_buf[eff_start:eff_end] rng = result_buf[eff_start:eff_end]
u16a_rng = self._u16_a[:zone_len] u16a_rng = self._u16_a[:zone_len]
u16b_rng = self._u16_b[:zone_len] u16b_rng = self._u16_b[:zone_len]
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method) blend_fn = self._blend_methods.get(
blend_mode, self._default_blend_method
)
# Temporarily swap scratch buffers for the range size # Temporarily swap scratch buffers for the range size
orig_u16a, orig_u16b = self._u16_a, self._u16_b orig_u16a, orig_u16b = self._u16_a, self._u16_b
self._u16_a, self._u16_b = u16a_rng, u16b_rng self._u16_a, self._u16_b = u16a_rng, u16b_rng
blend_fn(rng, colors, alpha, rng) blend_fn(rng, colors, alpha, rng)
self._u16_a, self._u16_b = orig_u16a, orig_u16b self._u16_a, self._u16_b = orig_u16a, orig_u16b
else: else:
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method) blend_fn = self._blend_methods.get(
blend_mode, self._default_blend_method
)
blend_fn(result_buf, colors, alpha, result_buf) blend_fn(result_buf, colors, alpha, result_buf)
if has_result: if has_result:
with self._colors_lock: with self._colors_lock:
self._latest_colors = result_buf self._latest_colors = result_buf
self._latest_layer_colors = layer_snapshots if len(layer_snapshots) > 1 else None self._latest_layer_colors = (
layer_snapshots if len(layer_snapshots) > 1 else None
)
except Exception as e: except Exception as e:
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True) logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)
@@ -31,23 +31,23 @@ logger = get_logger(__name__)
# #
# Format: (hour, R, G, B) # Format: (hour, R, G, B)
_DAYLIGHT_CURVE = [ _DAYLIGHT_CURVE = [
(0.0, 10, 10, 30), # midnight — deep blue (0.0, 10, 10, 30), # midnight — deep blue
(4.0, 10, 10, 40), # pre-dawn — dark blue (4.0, 10, 10, 40), # pre-dawn — dark blue
(5.5, 40, 20, 60), # first light — purple hint (5.5, 40, 20, 60), # first light — purple hint
(6.0, 255, 100, 30), # sunrise — warm orange (6.0, 255, 100, 30), # sunrise — warm orange
(7.0, 255, 170, 80), # early morning — golden (7.0, 255, 170, 80), # early morning — golden
(8.0, 255, 220, 160), # morning — warm white (8.0, 255, 220, 160), # morning — warm white
(10.0, 255, 245, 230), # mid-morning — neutral warm (10.0, 255, 245, 230), # mid-morning — neutral warm
(12.0, 240, 248, 255), # noon — cool white / slight blue (12.0, 240, 248, 255), # noon — cool white / slight blue
(14.0, 255, 250, 240), # afternoon — neutral (14.0, 255, 250, 240), # afternoon — neutral
(16.0, 255, 230, 180), # late afternoon — warm (16.0, 255, 230, 180), # late afternoon — warm
(17.5, 255, 180, 100), # pre-sunset — golden (17.5, 255, 180, 100), # pre-sunset — golden
(18.5, 255, 100, 40), # sunset — deep orange (18.5, 255, 100, 40), # sunset — deep orange
(19.0, 200, 60, 40), # late sunset — red (19.0, 200, 60, 40), # late sunset — red
(19.5, 100, 30, 60), # dusk — purple (19.5, 100, 30, 60), # dusk — purple
(20.0, 40, 20, 60), # twilight — dark purple (20.0, 40, 20, 60), # twilight — dark purple
(21.0, 15, 15, 45), # night — dark blue (21.0, 15, 15, 45), # night — dark blue
(24.0, 10, 10, 30), # midnight (wrap) (24.0, 10, 10, 30), # midnight (wrap)
] ]
# Reference solar times the canonical curve was designed around # Reference solar times the canonical curve was designed around
@@ -61,9 +61,7 @@ _daylight_lut: Optional[np.ndarray] = None
# ── Solar position helpers ────────────────────────────────────────────── # ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times( def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple:
latitude: float, longitude: float, day_of_year: int
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in local solar time. """Return (sunrise_hour, sunset_hour) in local solar time.
Uses simplified NOAA solar equations: Uses simplified NOAA solar equations:
@@ -148,9 +146,7 @@ def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
t = t * t * (3.0 - 2.0 * t) # smoothstep t = t * t * (3.0 - 2.0 * t) # smoothstep
for ch in range(3): for ch in range(3):
lut[minute, ch] = int( lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5
)
return lut return lut
@@ -188,7 +184,9 @@ class DaylightColorStripStream(ColorStripStream):
self._update_from_source(source) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
self._speed = float(getattr(source, "speed", 1.0)) from wled_controller.storage.bindable import bfloat
self._speed = bfloat(getattr(source, "speed", 1.0), 1.0)
self._use_real_time = bool(getattr(source, "use_real_time", False)) self._use_real_time = bool(getattr(source, "use_real_time", False))
self._latitude = float(getattr(source, "latitude", 50.0)) self._latitude = float(getattr(source, "latitude", 50.0))
self._longitude = float(getattr(source, "longitude", 0.0)) self._longitude = float(getattr(source, "longitude", 0.0))
@@ -201,9 +199,7 @@ class DaylightColorStripStream(ColorStripStream):
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray: def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
"""Return a solar-time-aware LUT for the given day (cached).""" """Return a solar-time-aware LUT for the given day (cached)."""
sunrise, sunset = _compute_solar_times( sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year)
self._latitude, self._longitude, day_of_year
)
sr_key = int(round(sunrise * 60)) sr_key = int(round(sunrise * 60))
ss_key = int(round(sunset * 60)) ss_key = int(round(sunset * 60))
cache_key = (sr_key, ss_key) cache_key = (sr_key, ss_key)
@@ -260,6 +256,7 @@ class DaylightColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import DaylightColorStripSource from wled_controller.storage.color_strip_source import DaylightColorStripSource
if isinstance(source, DaylightColorStripSource): if isinstance(source, DaylightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
@@ -292,7 +289,7 @@ class DaylightColorStripStream(ColorStripStream):
speed = clock.speed speed = clock.speed
else: else:
t = wall_start t = wall_start
speed = self._speed speed = self.resolve("speed", self._speed)
n = self._led_count n = self._led_count
if n != _pool_n: if n != _pool_n:
@@ -26,17 +26,41 @@ logger = get_logger(__name__)
# Each palette is a list of (position, R, G, B) control points. # Each palette is a list of (position, R, G, B) control points.
# Positions must be monotonically increasing from 0.0 to 1.0. # Positions must be monotonically increasing from 0.0 to 1.0.
_PALETTE_DEFS: Dict[str, list] = { _PALETTE_DEFS: Dict[str, list] = {
"fire": [(0, 0, 0, 0), (0.33, 200, 24, 0), (0.66, 255, 160, 0), (1.0, 255, 255, 200)], "fire": [(0, 0, 0, 0), (0.33, 200, 24, 0), (0.66, 255, 160, 0), (1.0, 255, 255, 200)],
"ocean": [(0, 0, 0, 32), (0.33, 0, 16, 128), (0.66, 0, 128, 255), (1.0, 128, 224, 255)], "ocean": [(0, 0, 0, 32), (0.33, 0, 16, 128), (0.66, 0, 128, 255), (1.0, 128, 224, 255)],
"lava": [(0, 0, 0, 0), (0.25, 128, 0, 0), (0.5, 255, 32, 0), (0.75, 255, 160, 0), (1.0, 255, 255, 128)], "lava": [
"forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)], (0, 0, 0, 0),
"rainbow": [(0, 255, 0, 0), (0.17, 255, 255, 0), (0.33, 0, 255, 0), (0.25, 128, 0, 0),
(0.5, 0, 255, 255), (0.67, 0, 0, 255), (0.83, 255, 0, 255), (1.0, 255, 0, 0)], (0.5, 255, 32, 0),
"aurora": [(0, 0, 16, 32), (0.2, 0, 80, 64), (0.4, 0, 200, 100), (0.75, 255, 160, 0),
(0.6, 64, 128, 255), (0.8, 128, 0, 200), (1.0, 0, 16, 32)], (1.0, 255, 255, 128),
"sunset": [(0, 32, 0, 64), (0.25, 128, 0, 128), (0.5, 255, 64, 0), ],
(0.75, 255, 192, 64), (1.0, 255, 255, 192)], "forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
"ice": [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)], "rainbow": [
(0, 255, 0, 0),
(0.17, 255, 255, 0),
(0.33, 0, 255, 0),
(0.5, 0, 255, 255),
(0.67, 0, 0, 255),
(0.83, 255, 0, 255),
(1.0, 255, 0, 0),
],
"aurora": [
(0, 0, 16, 32),
(0.2, 0, 80, 64),
(0.4, 0, 200, 100),
(0.6, 64, 128, 255),
(0.8, 128, 0, 200),
(1.0, 0, 16, 32),
],
"sunset": [
(0, 32, 0, 64),
(0.25, 128, 0, 128),
(0.5, 255, 64, 0),
(0.75, 255, 192, 64),
(1.0, 255, 255, 192),
],
"ice": [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)],
} }
_palette_cache: Dict[str, np.ndarray] = {} _palette_cache: Dict[str, np.ndarray] = {}
@@ -84,6 +108,7 @@ def _build_palette_lut(name: str, custom_stops: list = None) -> np.ndarray:
# ── 1-D value noise (no external deps) ────────────────────────────────── # ── 1-D value noise (no external deps) ──────────────────────────────────
class _ValueNoise1D: class _ValueNoise1D:
"""Simple 1-D value noise with smoothstep interpolation and fractal octaves. """Simple 1-D value noise with smoothstep interpolation and fractal octaves.
@@ -120,7 +145,7 @@ class _ValueNoise1D:
size = len(self._table) size = len(self._table)
# xi = floor(x) # xi = floor(x)
np.floor(x, out=self._frac) np.floor(x, out=self._frac)
np.copyto(self._xi, self._frac, casting='unsafe') np.copyto(self._xi, self._frac, casting="unsafe")
# frac = x - xi # frac = x - xi
np.subtract(x, self._frac, out=self._frac) np.subtract(x, self._frac, out=self._frac)
# t = frac * frac * (3 - 2 * frac) (smoothstep) # t = frac * frac * (3 - 2 * frac) (smoothstep)
@@ -224,7 +249,7 @@ class EffectColorStripStream(ColorStripStream):
self._ball_last_t = 0.0 self._ball_last_t = 0.0
# Fireworks state # Fireworks state
self._fw_particles: list = [] # active particles self._fw_particles: list = [] # active particles
self._fw_rockets: list = [] # active rockets self._fw_rockets: list = [] # active rockets
self._fw_last_launch = 0.0 self._fw_last_launch = 0.0
# Sparkle rain state # Sparkle rain state
self._sparkle_state: Optional[np.ndarray] = None # per-LED brightness 0..1 self._sparkle_state: Optional[np.ndarray] = None # per-LED brightness 0..1
@@ -256,13 +281,17 @@ class EffectColorStripStream(ColorStripStream):
self._auto_size = not _lc self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1 self._led_count = _lc if _lc and _lc > 0 else 1
self._gradient_id = getattr(source, "gradient_id", None) self._gradient_id = getattr(source, "gradient_id", None)
self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(self._effect_type, "fire") self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(
self._effect_type, "fire"
)
self._custom_palette = getattr(source, "custom_palette", None) self._custom_palette = getattr(source, "custom_palette", None)
self._resolve_palette_lut() self._resolve_palette_lut()
color = getattr(source, "color", None) color = getattr(source, "color", None)
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0] self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
self._intensity = float(getattr(source, "intensity", 1.0)) from wled_controller.storage.bindable import bfloat
self._scale = float(getattr(source, "scale", 1.0))
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
self._mirror = bool(getattr(source, "mirror", False)) self._mirror = bool(getattr(source, "mirror", False))
with self._colors_lock: with self._colors_lock:
self._colors: Optional[np.ndarray] = None self._colors: Optional[np.ndarray] = None
@@ -296,7 +325,9 @@ class EffectColorStripStream(ColorStripStream):
daemon=True, daemon=True,
) )
self._thread.start() self._thread.start()
logger.info(f"EffectColorStripStream started (effect={self._effect_type}, leds={self._led_count})") logger.info(
f"EffectColorStripStream started (effect={self._effect_type}, leds={self._led_count})"
)
def stop(self) -> None: def stop(self) -> None:
self._running = False self._running = False
@@ -315,6 +346,7 @@ class EffectColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import EffectColorStripSource from wled_controller.storage.color_strip_source import EffectColorStripSource
if isinstance(source, EffectColorStripSource): if isinstance(source, EffectColorStripSource):
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
@@ -411,7 +443,7 @@ class EffectColorStripStream(ColorStripStream):
at the bottom. Heat values are mapped to the palette LUT. at the bottom. Heat values are mapped to the palette LUT.
""" """
speed = self._effective_speed speed = self._effective_speed
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut lut = self._palette_lut
# (Re)allocate heat array when LED count changes # (Re)allocate heat array when LED count changes
@@ -449,7 +481,7 @@ class EffectColorStripStream(ColorStripStream):
# Map heat to palette (pre-allocated scratch) # Map heat to palette (pre-allocated scratch)
np.multiply(heat, 255, out=self._s_f32_a) np.multiply(heat, 255, out=self._s_f32_a)
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a) np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe') np.copyto(self._s_i32, self._s_f32_a, casting="unsafe")
buf[:] = lut[self._s_i32] buf[:] = lut[self._s_i32]
# ── Meteor ─────────────────────────────────────────────────────── # ── Meteor ───────────────────────────────────────────────────────
@@ -457,7 +489,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None: def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
"""Bright meteor head with exponential-decay trail.""" """Bright meteor head with exponential-decay trail."""
speed = self._effective_speed speed = self._effective_speed
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
color = self._color color = self._color
mirror = self._mirror mirror = self._mirror
@@ -493,13 +525,13 @@ class EffectColorStripStream(ColorStripStream):
r, g, b = color r, g, b = color
np.multiply(brightness, r, out=self._s_f32_c) np.multiply(brightness, r, out=self._s_f32_c)
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c) np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
np.copyto(buf[:, 0], self._s_f32_c, casting='unsafe') np.copyto(buf[:, 0], self._s_f32_c, casting="unsafe")
np.multiply(brightness, g, out=self._s_f32_c) np.multiply(brightness, g, out=self._s_f32_c)
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c) np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
np.copyto(buf[:, 1], self._s_f32_c, casting='unsafe') np.copyto(buf[:, 1], self._s_f32_c, casting="unsafe")
np.multiply(brightness, b, out=self._s_f32_c) np.multiply(brightness, b, out=self._s_f32_c)
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c) np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe') np.copyto(buf[:, 2], self._s_f32_c, casting="unsafe")
# Bright white-ish head (2-3 LEDs) — direct index range to avoid # Bright white-ish head (2-3 LEDs) — direct index range to avoid
# boolean mask allocations and fancy indexing temporaries. # boolean mask allocations and fancy indexing temporaries.
@@ -520,14 +552,14 @@ class EffectColorStripStream(ColorStripStream):
np.multiply(head_br, 255 - ch_base, out=tmp) np.multiply(head_br, 255 - ch_base, out=tmp)
tmp += buf[head_sl, ch_idx] tmp += buf[head_sl, ch_idx]
np.clip(tmp, 0, 255, out=tmp) np.clip(tmp, 0, 255, out=tmp)
np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe') np.copyto(buf[head_sl, ch_idx], tmp, casting="unsafe")
# ── Plasma ─────────────────────────────────────────────────────── # ── Plasma ───────────────────────────────────────────────────────
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None: def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
"""Overlapping sine waves creating colorful plasma patterns.""" """Overlapping sine waves creating colorful plasma patterns."""
speed = self._effective_speed speed = self._effective_speed
scale = self._scale scale = self.resolve("scale", self._scale)
lut = self._palette_lut lut = self._palette_lut
# Cache x array (only changes when n or scale change) # Cache x array (only changes when n or scale change)
@@ -554,7 +586,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None: def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
"""Smooth scrolling fractal noise mapped to a color palette.""" """Smooth scrolling fractal noise mapped to a color palette."""
speed = self._effective_speed speed = self._effective_speed
scale = self._scale scale = self.resolve("scale", self._scale)
lut = self._palette_lut lut = self._palette_lut
# Positions from cached arange (avoids per-frame np.arange) # Positions from cached arange (avoids per-frame np.arange)
@@ -564,7 +596,7 @@ class EffectColorStripStream(ColorStripStream):
# Map to palette indices using pre-allocated scratch # Map to palette indices using pre-allocated scratch
np.multiply(values, 255, out=self._s_f32_b) np.multiply(values, 255, out=self._s_f32_b)
np.clip(self._s_f32_b, 0, 255, out=self._s_f32_b) np.clip(self._s_f32_b, 0, 255, out=self._s_f32_b)
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe') np.copyto(self._s_i32, self._s_f32_b, casting="unsafe")
buf[:] = lut[self._s_i32] buf[:] = lut[self._s_i32]
# ── Aurora ─────────────────────────────────────────────────────── # ── Aurora ───────────────────────────────────────────────────────
@@ -572,8 +604,8 @@ class EffectColorStripStream(ColorStripStream):
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None: def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
"""Layered noise bands simulating aurora borealis.""" """Layered noise bands simulating aurora borealis."""
speed = self._effective_speed speed = self._effective_speed
scale = self._scale scale = self.resolve("scale", self._scale)
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut lut = self._palette_lut
# Positions from cached arange # Positions from cached arange
@@ -606,20 +638,20 @@ class EffectColorStripStream(ColorStripStream):
# Map to palette using pre-allocated scratch # Map to palette using pre-allocated scratch
np.multiply(hue, 255, out=hue) np.multiply(hue, 255, out=hue)
np.copyto(self._s_i32, hue, casting='unsafe') np.copyto(self._s_i32, hue, casting="unsafe")
np.clip(self._s_i32, 0, 255, out=self._s_i32) np.clip(self._s_i32, 0, 255, out=self._s_i32)
self._s_f32_rgb[:] = lut[self._s_i32] self._s_f32_rgb[:] = lut[self._s_i32]
self._s_f32_rgb *= bright[:, np.newaxis] self._s_f32_rgb *= bright[:, np.newaxis]
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb) np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
np.copyto(buf, self._s_f32_rgb, casting='unsafe') np.copyto(buf, self._s_f32_rgb, casting="unsafe")
# ── Rain ────────────────────────────────────────────────────────── # ── Rain ──────────────────────────────────────────────────────────
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None: def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
"""Raindrops falling down the strip with trailing tails.""" """Raindrops falling down the strip with trailing tails."""
speed = self._effective_speed speed = self._effective_speed
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
scale = self._scale scale = self.resolve("scale", self._scale)
lut = self._palette_lut lut = self._palette_lut
# Multiple rain "lanes" at different speeds for depth # Multiple rain "lanes" at different speeds for depth
@@ -644,7 +676,7 @@ class EffectColorStripStream(ColorStripStream):
np.clip(bright, 0.0, 1.0, out=bright) np.clip(bright, 0.0, 1.0, out=bright)
np.multiply(bright, 255, out=self._s_f32_b) np.multiply(bright, 255, out=self._s_f32_b)
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe') np.copyto(self._s_i32, self._s_f32_b, casting="unsafe")
np.clip(self._s_i32, 0, 255, out=self._s_i32) np.clip(self._s_i32, 0, 255, out=self._s_i32)
buf[:] = lut[self._s_i32] buf[:] = lut[self._s_i32]
@@ -653,7 +685,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None: def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
"""Multiple comets with curved, pulsing tails.""" """Multiple comets with curved, pulsing tails."""
speed = self._effective_speed speed = self._effective_speed
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
color = self._color color = self._color
mirror = self._mirror mirror = self._mirror
@@ -692,14 +724,14 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_a[:] = buf[:, ch_idx] self._s_f32_a[:] = buf[:, ch_idx]
self._s_f32_a += self._s_f32_c self._s_f32_a += self._s_f32_c
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a) np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe') np.copyto(buf[:, ch_idx], self._s_f32_a, casting="unsafe")
# ── Bouncing Ball ───────────────────────────────────────────────── # ── Bouncing Ball ─────────────────────────────────────────────────
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None: def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
"""Physics-simulated bouncing balls with gravity.""" """Physics-simulated bouncing balls with gravity."""
speed = self._effective_speed speed = self._effective_speed
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
color = self._color color = self._color
num_balls = 3 num_balls = 3
@@ -755,14 +787,14 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_a[:] = buf[:, ch_idx] self._s_f32_a[:] = buf[:, ch_idx]
self._s_f32_a += self._s_f32_c self._s_f32_a += self._s_f32_c
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a) np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe') np.copyto(buf[:, ch_idx], self._s_f32_a, casting="unsafe")
# ── Fireworks ───────────────────────────────────────────────────── # ── Fireworks ─────────────────────────────────────────────────────
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None: def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
"""Rockets launch and explode into colorful particle bursts.""" """Rockets launch and explode into colorful particle bursts."""
speed = self._effective_speed speed = self._effective_speed
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut lut = self._palette_lut
dt = 1.0 / max(self._fps, 1) dt = 1.0 / max(self._fps, 1)
@@ -835,7 +867,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None: def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
"""Twinkling star field with smooth fade-in/fade-out.""" """Twinkling star field with smooth fade-in/fade-out."""
speed = self._effective_speed speed = self._effective_speed
intensity = self._intensity intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut lut = self._palette_lut
# Initialize/resize sparkle state # Initialize/resize sparkle state
@@ -858,20 +890,20 @@ class EffectColorStripStream(ColorStripStream):
# Map sparkle brightness to palette # Map sparkle brightness to palette
np.multiply(state, 255, out=self._s_f32_a) np.multiply(state, 255, out=self._s_f32_a)
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe') np.copyto(self._s_i32, self._s_f32_a, casting="unsafe")
np.clip(self._s_i32, 0, 255, out=self._s_i32) np.clip(self._s_i32, 0, 255, out=self._s_i32)
self._s_f32_rgb[:] = lut[self._s_i32] self._s_f32_rgb[:] = lut[self._s_i32]
# Apply brightness # Apply brightness
self._s_f32_rgb *= state[:, np.newaxis] self._s_f32_rgb *= state[:, np.newaxis]
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb) np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
np.copyto(buf, self._s_f32_rgb, casting='unsafe') np.copyto(buf, self._s_f32_rgb, casting="unsafe")
# ── Lava Lamp ───────────────────────────────────────────────────── # ── Lava Lamp ─────────────────────────────────────────────────────
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None: def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
"""Slow-moving colored blobs that merge and separate.""" """Slow-moving colored blobs that merge and separate."""
speed = self._effective_speed speed = self._effective_speed
scale = self._scale scale = self.resolve("scale", self._scale)
lut = self._palette_lut lut = self._palette_lut
# Use noise at very low frequency for blob movement # Use noise at very low frequency for blob movement
@@ -903,7 +935,7 @@ class EffectColorStripStream(ColorStripStream):
# Map to palette # Map to palette
np.multiply(combined, 255, out=self._s_f32_b) np.multiply(combined, 255, out=self._s_f32_b)
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe') np.copyto(self._s_i32, self._s_f32_b, casting="unsafe")
np.clip(self._s_i32, 0, 255, out=self._s_i32) np.clip(self._s_i32, 0, 255, out=self._s_i32)
buf[:] = lut[self._s_i32] buf[:] = lut[self._s_i32]
@@ -912,7 +944,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None: def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
"""Two counter-propagating sine waves creating interference patterns.""" """Two counter-propagating sine waves creating interference patterns."""
speed = self._effective_speed speed = self._effective_speed
scale = self._scale scale = self.resolve("scale", self._scale)
lut = self._palette_lut lut = self._palette_lut
# Wave parameters # Wave parameters
@@ -934,7 +966,7 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_a += self._s_f32_b self._s_f32_a += self._s_f32_b
# Range is [-2, 2], map to [0, 255] # Range is [-2, 2], map to [0, 255]
self._s_f32_a += 2.0 self._s_f32_a += 2.0
self._s_f32_a *= (255.0 / 4.0) self._s_f32_a *= 255.0 / 4.0
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a) np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe') np.copyto(self._s_i32, self._s_f32_a, casting="unsafe")
buf[:] = lut[self._s_i32] buf[:] = lut[self._s_i32]
@@ -27,23 +27,35 @@ class HALightTargetProcessor(TargetProcessor):
target_id: str, target_id: str,
ha_source_id: str, ha_source_id: str,
color_strip_source_id: str = "", color_strip_source_id: str = "",
brightness=None,
# legacy compat
brightness_value_source_id: str = "", brightness_value_source_id: str = "",
light_mappings: Optional[List[HALightMapping]] = None, light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0, update_rate: float = 2.0,
transition: float = 0.5, transition=None,
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
color_tolerance: int = 5, color_tolerance: int = 5,
ctx: Optional[TargetContext] = None, ctx: Optional[TargetContext] = None,
): ):
from wled_controller.storage.bindable import BindableFloat, bfloat
super().__init__(target_id, ctx) super().__init__(target_id, ctx)
self._ha_source_id = ha_source_id self._ha_source_id = ha_source_id
self._css_id = color_strip_source_id self._css_id = color_strip_source_id
self._brightness_vs_id = brightness_value_source_id # Accept BindableFloat or legacy string
if brightness is not None and isinstance(brightness, BindableFloat):
self._brightness = brightness
else:
self._brightness = BindableFloat(1.0, source_id=brightness_value_source_id or "")
# Transition as BindableFloat
if transition is not None and isinstance(transition, BindableFloat):
self._transition = transition
else:
self._transition = BindableFloat(float(transition) if transition is not None else 0.5)
self._light_mappings = light_mappings or [] self._light_mappings = light_mappings or []
self._update_rate = max(0.5, min(5.0, update_rate)) self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0)))
self._transition = transition self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._min_brightness_threshold = min_brightness_threshold self._color_tolerance = int(bfloat(color_tolerance, 5.0))
self._color_tolerance = color_tolerance
# Runtime state # Runtime state
self._css_stream = None self._css_stream = None
@@ -83,9 +95,11 @@ class HALightTargetProcessor(TargetProcessor):
logger.warning(f"HA light {self._target_id}: failed to acquire HA runtime: {e}") logger.warning(f"HA light {self._target_id}: failed to acquire HA runtime: {e}")
# Acquire brightness value stream (if configured) # Acquire brightness value stream (if configured)
if self._brightness_vs_id and self._ctx.value_stream_manager: if self._brightness.source_id and self._ctx.value_stream_manager:
try: try:
self._value_stream = self._ctx.value_stream_manager.acquire(self._brightness_vs_id) self._value_stream = self._ctx.value_stream_manager.acquire(
self._brightness.source_id
)
except Exception as e: except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}") logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
self._value_stream = None self._value_stream = None
@@ -116,7 +130,7 @@ class HALightTargetProcessor(TargetProcessor):
# Release brightness value stream # Release brightness value stream
if self._value_stream is not None and self._ctx.value_stream_manager: if self._value_stream is not None and self._ctx.value_stream_manager:
try: try:
self._ctx.value_stream_manager.release(self._brightness_vs_id) self._ctx.value_stream_manager.release(self._brightness.source_id)
except Exception: except Exception:
pass pass
self._value_stream = None self._value_stream = None
@@ -138,15 +152,29 @@ class HALightTargetProcessor(TargetProcessor):
logger.info(f"HA light target stopped: {self._target_id}") logger.info(f"HA light target stopped: {self._target_id}")
def update_settings(self, settings) -> None: def update_settings(self, settings) -> None:
from wled_controller.storage.bindable import BindableFloat, bfloat
if isinstance(settings, dict): if isinstance(settings, dict):
if "update_rate" in settings: if "update_rate" in settings:
self._update_rate = max(0.5, min(5.0, float(settings["update_rate"]))) self._update_rate = max(0.5, min(5.0, bfloat(settings["update_rate"], 2.0)))
if "transition" in settings: if "transition" in settings:
self._transition = float(settings["transition"]) t = settings["transition"]
if isinstance(t, BindableFloat):
self._transition = t
else:
self._transition = self._transition.apply_update(t)
if "brightness" in settings:
b = settings["brightness"]
if isinstance(b, BindableFloat):
self._brightness = b
else:
self._brightness = self._brightness.apply_update(b)
if "min_brightness_threshold" in settings: if "min_brightness_threshold" in settings:
self._min_brightness_threshold = int(settings["min_brightness_threshold"]) self._min_brightness_threshold = int(
bfloat(settings["min_brightness_threshold"], 0.0)
)
if "color_tolerance" in settings: if "color_tolerance" in settings:
self._color_tolerance = int(settings["color_tolerance"]) self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
if "light_mappings" in settings: if "light_mappings" in settings:
self._light_mappings = settings["light_mappings"] self._light_mappings = settings["light_mappings"]
@@ -273,7 +301,12 @@ class HALightTargetProcessor(TargetProcessor):
brightness = max(r, g, b) brightness = max(r, g, b)
# Apply brightness scale and value source multiplier # Apply brightness scale and value source multiplier
eff_scale = mapping.brightness_scale * vs_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: if eff_scale < 1.0:
brightness = int(brightness * eff_scale) brightness = int(brightness * eff_scale)
@@ -299,10 +332,11 @@ class HALightTargetProcessor(TargetProcessor):
# Call light.turn_on # Call light.turn_on
service_data = { service_data = {
"rgb_color": [r, g, b], "rgb_color": [r, g, b],
"brightness": min(255, int(brightness * mapping.brightness_scale)), "brightness": min(255, int(brightness * bs)),
} }
if self._transition > 0: transition_val = self._transition.value
service_data["transition"] = self._transition if transition_val > 0:
service_data["transition"] = transition_val
await self._ha_runtime.call_service( await self._ha_runtime.call_service(
domain="light", domain="light",
@@ -18,6 +18,8 @@ from wled_controller.core.capture.screen_capture import (
calculate_dominant_color, calculate_dominant_color,
calculate_median_color, calculate_median_color,
) )
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bfloat
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer from wled_controller.utils.timer import high_resolution_timer
@@ -36,12 +38,8 @@ _CALC_FNS = {
} }
class KeyColorsColorStripStream: class KeyColorsColorStripStream(ColorStripStream):
"""Streams N colors extracted from screen rectangles. """Streams N colors extracted from screen rectangles."""
Implements the same interface as ColorStripStream so it can be used
by any target processor via ColorStripStreamManager.
"""
def __init__( def __init__(
self, self,
@@ -165,7 +163,7 @@ class KeyColorsColorStripStream:
colors_arr[i] = calc_fn(small[y1:y2, x1:x2]) colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
# Temporal smoothing # Temporal smoothing
smoothing = src.smoothing smoothing = self.resolve("smoothing", bfloat(src.smoothing, 0.3))
if ( if (
prev_colors_arr is not None prev_colors_arr is not None
and smoothing > 0 and smoothing > 0
@@ -175,7 +173,7 @@ class KeyColorsColorStripStream:
prev_colors_arr = colors_arr prev_colors_arr = colors_arr
# Apply brightness # Apply brightness
brightness = src.brightness brightness = self.resolve("brightness", bfloat(src.brightness, 1.0))
if brightness < 1.0: if brightness < 1.0:
output = colors_arr * brightness output = colors_arr * brightness
else: else:
@@ -18,6 +18,7 @@ from typing import Optional
import numpy as np import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bfloat
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -74,16 +75,20 @@ class NotificationColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
"""Parse config from source dataclass.""" """Parse config from source dataclass."""
self._notification_effect = getattr(source, "notification_effect", "flash") self._notification_effect = getattr(source, "notification_effect", "flash")
self._duration_ms = max(100, int(getattr(source, "duration_ms", 1500))) self._duration_ms = max(100, int(bfloat(getattr(source, "duration_ms", 1500), 1500)))
self._default_color = getattr(source, "default_color", "#FFFFFF") self._default_color = getattr(source, "default_color", "#FFFFFF")
self._app_colors = {k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items()} self._app_colors = {
k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items()
}
self._app_filter_mode = getattr(source, "app_filter_mode", "off") self._app_filter_mode = getattr(source, "app_filter_mode", "off")
self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])] self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
self._auto_size = not getattr(source, "led_count", 0) self._auto_size = not getattr(source, "led_count", 0)
self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1 self._led_count = (
getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
)
# Sound config # Sound config
self._sound_asset_id = getattr(source, "sound_asset_id", None) self._sound_asset_id = getattr(source, "sound_asset_id", None)
self._sound_volume = float(getattr(source, "sound_volume", 1.0)) self._sound_volume = bfloat(getattr(source, "sound_volume", 1.0), 1.0)
raw_app_sounds = dict(getattr(source, "app_sounds", {})) raw_app_sounds = dict(getattr(source, "app_sounds", {}))
self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()} self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
with self._colors_lock: with self._colors_lock:
@@ -135,7 +140,7 @@ class NotificationColorStripStream(ColorStripStream):
# Resolve sound: per-app override > global sound_asset_id # Resolve sound: per-app override > global sound_asset_id
sound_asset_id = None sound_asset_id = None
volume = self._sound_volume volume = self.resolve("sound_volume", self._sound_volume)
if app_lower and app_lower in self._app_sounds: if app_lower and app_lower in self._app_sounds:
override = self._app_sounds[app_lower] override = self._app_sounds[app_lower]
@@ -164,6 +169,7 @@ class NotificationColorStripStream(ColorStripStream):
try: try:
from wled_controller.utils.sound_player import play_sound_async from wled_controller.utils.sound_player import play_sound_async
play_sound_async(file_path, volume=volume) play_sound_async(file_path, volume=volume)
except Exception as e: except Exception as e:
logger.error(f"Failed to play notification sound: {e}") logger.error(f"Failed to play notification sound: {e}")
@@ -211,7 +217,9 @@ class NotificationColorStripStream(ColorStripStream):
if self._thread: if self._thread:
self._thread.join(timeout=5.0) self._thread.join(timeout=5.0)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("NotificationColorStripStream render thread did not terminate within 5s") logger.warning(
"NotificationColorStripStream render thread did not terminate within 5s"
)
self._thread = None self._thread = None
logger.info("NotificationColorStripStream stopped") logger.info("NotificationColorStripStream stopped")
@@ -222,6 +230,7 @@ class NotificationColorStripStream(ColorStripStream):
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update config from updated source.""" """Hot-update config from updated source."""
from wled_controller.storage.color_strip_source import NotificationColorStripSource from wled_controller.storage.color_strip_source import NotificationColorStripSource
if isinstance(source, NotificationColorStripSource): if isinstance(source, NotificationColorStripSource):
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
@@ -253,7 +262,9 @@ class NotificationColorStripStream(ColorStripStream):
while self._event_queue: while self._event_queue:
try: try:
event = self._event_queue.popleft() event = self._event_queue.popleft()
if self._active_effect is None or event.get("priority", 0) >= self._active_effect.get("priority", 0): if self._active_effect is None or event.get(
"priority", 0
) >= self._active_effect.get("priority", 0):
self._active_effect = event self._active_effect = event
except IndexError: except IndexError:
break break
@@ -273,7 +284,7 @@ class NotificationColorStripStream(ColorStripStream):
color = self._active_effect["color"] color = self._active_effect["color"]
start_time = self._active_effect["start"] start_time = self._active_effect["start"]
elapsed_ms = (time.monotonic() - start_time) * 1000.0 elapsed_ms = (time.monotonic() - start_time) * 1000.0
duration_ms = self._duration_ms duration_ms = self.resolve("duration_ms", self._duration_ms)
progress = min(elapsed_ms / duration_ms, 1.0) progress = min(elapsed_ms / duration_ms, 1.0)
if progress >= 1.0: if progress >= 1.0:
@@ -392,7 +403,9 @@ class NotificationColorStripStream(ColorStripStream):
buf[i, 1] = min(255, int(color[1] * glow)) buf[i, 1] = min(255, int(color[1] * glow))
buf[i, 2] = min(255, int(color[2] * glow)) buf[i, 2] = min(255, int(color[2] * glow))
def _render_gradient_flash(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None: def _render_gradient_flash(
self, buf: np.ndarray, n: int, color: tuple, progress: float
) -> None:
"""Gradient flash: bright center fades to edges, then all fades out. """Gradient flash: bright center fades to edges, then all fades out.
Creates a gradient from the notification color at center to darker Creates a gradient from the notification color at center to darker
@@ -424,6 +424,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
brightness=None,
# legacy compat
brightness_value_source_id: str = "", brightness_value_source_id: str = "",
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
adaptive_fps: bool = False, adaptive_fps: bool = False,
@@ -442,7 +444,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
brightness_value_source_id=brightness_value_source_id, brightness=brightness,
min_brightness_threshold=min_brightness_threshold, min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps, adaptive_fps=adaptive_fps,
protocol=protocol, protocol=protocol,
@@ -456,10 +458,12 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
target_id: str, target_id: str,
ha_source_id: str, ha_source_id: str,
color_strip_source_id: str = "", color_strip_source_id: str = "",
brightness=None,
# legacy compat
brightness_value_source_id: str = "", brightness_value_source_id: str = "",
light_mappings=None, light_mappings=None,
update_rate: float = 2.0, update_rate: float = 2.0,
transition: float = 0.5, transition=None,
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
color_tolerance: int = 5, color_tolerance: int = 5,
) -> None: ) -> None:
@@ -473,7 +477,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
target_id=target_id, target_id=target_id,
ha_source_id=ha_source_id, ha_source_id=ha_source_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
brightness_value_source_id=brightness_value_source_id, brightness=brightness,
light_mappings=light_mappings or [], light_mappings=light_mappings or [],
update_rate=update_rate, update_rate=update_rate,
transition=transition, transition=transition,
@@ -544,10 +548,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
) )
def update_target_brightness_vs(self, target_id: str, vs_id: str): def update_target_brightness_vs(self, target_id: str, vs_id: str):
"""Update the brightness value source for a WLED target.""" """Legacy: update brightness value source by ID string."""
from wled_controller.storage.bindable import BindableFloat
self.update_target_brightness(target_id, BindableFloat(source_id=vs_id))
def update_target_brightness(self, target_id: str, brightness):
"""Update the brightness binding for a target."""
proc = self._get_processor(target_id) proc = self._get_processor(target_id)
if hasattr(proc, "update_brightness_value_source"): if hasattr(proc, "update_brightness"):
proc.update_brightness_value_source(vs_id) proc.update_brightness(brightness)
def update_value_source(self, vs_id: str): def update_value_source(self, vs_id: str):
"""Hot-update all running value streams for a given source.""" """Hot-update all running value streams for a given source."""
@@ -9,6 +9,7 @@ import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.core.weather.weather_manager import WeatherManager from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.storage.bindable import bfloat
from wled_controller.core.weather.weather_provider import DEFAULT_WEATHER, WeatherData from wled_controller.core.weather.weather_provider import DEFAULT_WEATHER, WeatherData
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -67,8 +68,8 @@ def _apply_temperature_shift(color: np.ndarray, temperature: float, influence: f
shift = t * influence * 30.0 # max ±30 RGB units shift = t * influence * 30.0 # max ±30 RGB units
result = color.astype(np.int16) result = color.astype(np.int16)
result[:, 0] += int(shift) # red result[:, 0] += int(shift) # red
result[:, 2] -= int(shift) # blue result[:, 2] -= int(shift) # blue
np.clip(result, 0, 255, out=result) np.clip(result, 0, 255, out=result)
return result.astype(np.uint8) return result.astype(np.uint8)
@@ -90,8 +91,8 @@ class WeatherColorStripStream(ColorStripStream):
def __init__(self, source, weather_manager: WeatherManager): def __init__(self, source, weather_manager: WeatherManager):
self._source_id = source.id self._source_id = source.id
self._weather_source_id: str = source.weather_source_id self._weather_source_id: str = source.weather_source_id
self._speed: float = source.speed self._speed: float = bfloat(source.speed, 1.0)
self._temperature_influence: float = source.temperature_influence self._temperature_influence: float = bfloat(source.temperature_influence, 0.5)
self._clock_id: Optional[str] = source.clock_id self._clock_id: Optional[str] = source.clock_id
self._weather_manager = weather_manager self._weather_manager = weather_manager
@@ -137,11 +138,14 @@ class WeatherColorStripStream(ColorStripStream):
try: try:
self._weather_manager.acquire(self._weather_source_id) self._weather_manager.acquire(self._weather_source_id)
except Exception as e: except Exception as e:
logger.warning(f"Weather stream {self._source_id}: failed to acquire weather source: {e}") logger.warning(
f"Weather stream {self._source_id}: failed to acquire weather source: {e}"
)
self._running = True self._running = True
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._animate_loop, daemon=True, target=self._animate_loop,
daemon=True,
name=f"WeatherCSS-{self._source_id[:12]}", name=f"WeatherCSS-{self._source_id[:12]}",
) )
self._thread.start() self._thread.start()
@@ -158,7 +162,9 @@ class WeatherColorStripStream(ColorStripStream):
try: try:
self._weather_manager.release(self._weather_source_id) self._weather_manager.release(self._weather_source_id)
except Exception as e: except Exception as e:
logger.warning(f"Weather stream {self._source_id}: failed to release weather source: {e}") logger.warning(
f"Weather stream {self._source_id}: failed to release weather source: {e}"
)
logger.info(f"WeatherColorStripStream stopped: {self._source_id}") logger.info(f"WeatherColorStripStream stopped: {self._source_id}")
@@ -171,8 +177,8 @@ class WeatherColorStripStream(ColorStripStream):
self._led_count = device_led_count self._led_count = device_led_count
def update_source(self, source) -> None: def update_source(self, source) -> None:
self._speed = source.speed self._speed = bfloat(source.speed, 1.0)
self._temperature_influence = source.temperature_influence self._temperature_influence = bfloat(source.temperature_influence, 0.5)
self._clock_id = source.clock_id self._clock_id = source.clock_id
# If weather source changed, release old + acquire new # If weather source changed, release old + acquire new
@@ -239,7 +245,7 @@ class WeatherColorStripStream(ColorStripStream):
# Compute animation phase # Compute animation phase
t = time.perf_counter() - start_time t = time.perf_counter() - start_time
phase = (t * self._speed * 0.1) % 1.0 phase = (t * self.resolve("speed", self._speed) * 0.1) % 1.0
# Generate gradient with drift # Generate gradient with drift
for i in range(n): for i in range(n):
@@ -248,8 +254,9 @@ class WeatherColorStripStream(ColorStripStream):
buf[i] = (c0 * (1.0 - s) + c1 * s).astype(np.uint8) buf[i] = (c0 * (1.0 - s) + c1 * s).astype(np.uint8)
# Apply temperature shift # Apply temperature shift
if self._temperature_influence > 0.0: temp_inf = self.resolve("temperature_influence", self._temperature_influence)
buf[:] = _apply_temperature_shift(buf, weather.temperature, self._temperature_influence) if temp_inf > 0.0:
buf[:] = _apply_temperature_shift(buf, weather.temperature, temp_inf)
# Thunderstorm flash effect # Thunderstorm flash effect
is_thunderstorm = weather.code in (95, 96, 99) is_thunderstorm = weather.code in (95, 96, 99)
@@ -11,7 +11,11 @@ from typing import Optional
import httpx import httpx
import numpy as np import numpy as np
from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities from wled_controller.core.devices.led_client import (
LEDClient,
create_led_client,
get_device_capabilities,
)
from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.target_processor import ( from wled_controller.core.processing.target_processor import (
DeviceInfo, DeviceInfo,
@@ -36,20 +40,29 @@ class WledTargetProcessor(TargetProcessor):
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = 30, state_check_interval: int = 30,
brightness=None,
# legacy compat
brightness_value_source_id: str = "", brightness_value_source_id: str = "",
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
adaptive_fps: bool = False, adaptive_fps: bool = False,
protocol: str = "ddp", protocol: str = "ddp",
ctx: TargetContext = None, ctx: TargetContext = None,
): ):
from wled_controller.storage.bindable import BindableFloat, bfloat
super().__init__(target_id, ctx) super().__init__(target_id, ctx)
self._device_id = device_id self._device_id = device_id
self._target_fps = fps if fps > 0 else 30 _fps = bfloat(fps, 30.0)
self._target_fps = int(_fps) if _fps > 0 else 30
self._keepalive_interval = keepalive_interval self._keepalive_interval = keepalive_interval
self._state_check_interval = state_check_interval self._state_check_interval = state_check_interval
self._css_id = color_strip_source_id self._css_id = color_strip_source_id
self._brightness_vs_id = brightness_value_source_id # Accept BindableFloat or legacy string
self._min_brightness_threshold = min_brightness_threshold if brightness is not None and isinstance(brightness, BindableFloat):
self._brightness = brightness
else:
self._brightness = BindableFloat(1.0, source_id=brightness_value_source_id or "")
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._adaptive_fps = adaptive_fps self._adaptive_fps = adaptive_fps
self._protocol = protocol self._protocol = protocol
@@ -105,8 +118,10 @@ class WledTargetProcessor(TargetProcessor):
# Connect to LED device # Connect to LED device
try: try:
self._led_client = create_led_client( self._led_client = create_led_client(
device_info.device_type, device_info.device_url, device_info.device_type,
use_ddp=(self._protocol == "ddp"), led_count=device_info.led_count, device_info.device_url,
use_ddp=(self._protocol == "ddp"),
led_count=device_info.led_count,
baud_rate=device_info.baud_rate, baud_rate=device_info.baud_rate,
send_latency_ms=device_info.send_latency_ms, send_latency_ms=device_info.send_latency_ms,
rgbw=device_info.rgbw, rgbw=device_info.rgbw,
@@ -128,7 +143,11 @@ class WledTargetProcessor(TargetProcessor):
# Use client-reported LED count if available (more accurate than stored) # Use client-reported LED count if available (more accurate than stored)
client_led_count = self._led_client.device_led_count client_led_count = self._led_client.device_led_count
effective_led_count = client_led_count if client_led_count and client_led_count > 0 else device_info.led_count effective_led_count = (
client_led_count
if client_led_count and client_led_count > 0
else device_info.led_count
)
self._effective_led_count = effective_led_count self._effective_led_count = effective_led_count
if effective_led_count != device_info.led_count: if effective_led_count != device_info.led_count:
@@ -142,7 +161,9 @@ class WledTargetProcessor(TargetProcessor):
f"device ({effective_led_count} LEDs)" f"device ({effective_led_count} LEDs)"
) )
self._device_state_before = await self._led_client.snapshot_device_state() self._device_state_before = await self._led_client.snapshot_device_state()
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type) self._needs_keepalive = "standby_required" in get_device_capabilities(
device_info.device_type
)
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}") logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}") raise RuntimeError(f"Failed to connect to LED device: {e}")
@@ -168,9 +189,7 @@ class WledTargetProcessor(TargetProcessor):
self._resolved_display_index = getattr(stream, "display_index", None) self._resolved_display_index = getattr(stream, "display_index", None)
self._css_stream = stream self._css_stream = stream
logger.info( logger.info(f"Acquired CSS stream '{self._css_id}' for target {self._target_id}")
f"Acquired CSS stream '{self._css_id}' for target {self._target_id}"
)
except Exception as e: except Exception as e:
if self._led_client: if self._led_client:
await self._led_client.close() await self._led_client.close()
@@ -178,13 +197,13 @@ class WledTargetProcessor(TargetProcessor):
raise RuntimeError(f"Failed to acquire CSS stream: {e}") raise RuntimeError(f"Failed to acquire CSS stream: {e}")
# Acquire value stream for brightness modulation (if configured) # Acquire value stream for brightness modulation (if configured)
if self._brightness_vs_id and self._ctx.value_stream_manager: if self._brightness.source_id and self._ctx.value_stream_manager:
try: try:
self._value_stream = self._ctx.value_stream_manager.acquire( self._value_stream = self._ctx.value_stream_manager.acquire(
self._brightness_vs_id self._brightness.source_id
) )
except Exception as e: except Exception as e:
logger.warning(f"Failed to acquire value stream {self._brightness_vs_id}: {e}") logger.warning(f"Failed to acquire value stream {self._brightness.source_id}: {e}")
self._value_stream = None self._value_stream = None
# Reset metrics and start loop # Reset metrics and start loop
@@ -193,7 +212,9 @@ class WledTargetProcessor(TargetProcessor):
self._task = asyncio.create_task(self._processing_loop()) self._task = asyncio.create_task(self._processing_loop())
logger.info(f"Started processing for target {self._target_id}") logger.info(f"Started processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True}) self._ctx.fire_event(
{"type": "state_change", "target_id": self._target_id, "processing": True}
)
async def stop(self) -> None: async def stop(self) -> None:
if not self._is_running: if not self._is_running:
@@ -232,27 +253,34 @@ class WledTargetProcessor(TargetProcessor):
css_manager.remove_target_fps(self._css_id, self._target_id) css_manager.remove_target_fps(self._css_id, self._target_id)
await asyncio.to_thread(css_manager.release, self._css_id, self._target_id) await asyncio.to_thread(css_manager.release, self._css_id, self._target_id)
except Exception as e: except Exception as e:
logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}") logger.warning(
f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}"
)
self._css_stream = None self._css_stream = None
# Release value stream # Release value stream
if self._value_stream is not None and self._ctx.value_stream_manager: if self._value_stream is not None and self._ctx.value_stream_manager:
try: try:
self._ctx.value_stream_manager.release(self._brightness_vs_id) self._ctx.value_stream_manager.release(self._brightness.source_id)
except Exception as e: except Exception as e:
logger.warning(f"Error releasing value stream: {e}") logger.warning(f"Error releasing value stream: {e}")
self._value_stream = None self._value_stream = None
logger.info(f"Stopped processing for target {self._target_id}") logger.info(f"Stopped processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False}) self._ctx.fire_event(
{"type": "state_change", "target_id": self._target_id, "processing": False}
)
# ----- Settings ----- # ----- Settings -----
def update_settings(self, settings: dict) -> None: def update_settings(self, settings: dict) -> None:
"""Update target-specific timing settings.""" """Update target-specific timing settings."""
from wled_controller.storage.bindable import bfloat
if isinstance(settings, dict): if isinstance(settings, dict):
if "fps" in settings: if "fps" in settings:
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30 _fps = bfloat(settings["fps"], 30.0)
self._target_fps = int(_fps) if _fps > 0 else 30
self._effective_fps = self._target_fps # reset adaptive self._effective_fps = self._target_fps # reset adaptive
css_manager = self._ctx.color_strip_stream_manager css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._is_running and self._css_id: if css_manager and self._is_running and self._css_id:
@@ -262,7 +290,9 @@ class WledTargetProcessor(TargetProcessor):
if "state_check_interval" in settings: if "state_check_interval" in settings:
self._state_check_interval = settings["state_check_interval"] self._state_check_interval = settings["state_check_interval"]
if "min_brightness_threshold" in settings: if "min_brightness_threshold" in settings:
self._min_brightness_threshold = settings["min_brightness_threshold"] self._min_brightness_threshold = int(
bfloat(settings["min_brightness_threshold"], 0.0)
)
if "adaptive_fps" in settings: if "adaptive_fps" in settings:
self._adaptive_fps = settings["adaptive_fps"] self._adaptive_fps = settings["adaptive_fps"]
if not self._adaptive_fps: if not self._adaptive_fps:
@@ -286,7 +316,9 @@ class WledTargetProcessor(TargetProcessor):
return return
device_info = self._ctx.get_device_info(self._device_id) device_info = self._ctx.get_device_info(self._device_id)
device_leds = getattr(self, '_effective_led_count', None) or (device_info.led_count if device_info else 0) device_leds = getattr(self, "_effective_led_count", None) or (
device_info.led_count if device_info else 0
)
# Release old stream # Release old stream
if self._css_stream is not None and old_css_id: if self._css_stream is not None and old_css_id:
@@ -312,14 +344,30 @@ class WledTargetProcessor(TargetProcessor):
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}") logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
def update_brightness_value_source(self, vs_id: str) -> None: def update_brightness_value_source(self, vs_id: str) -> None:
"""Hot-swap the brightness value source for a running target.""" """Legacy: hot-swap brightness value source by ID string."""
old_vs_id = self._brightness_vs_id from wled_controller.storage.bindable import BindableFloat
self._brightness_vs_id = vs_id
vs_mgr = self._ctx.value_stream_manager
self.update_brightness(BindableFloat(value=self._brightness.value, source_id=vs_id))
def update_brightness(self, brightness) -> None:
"""Hot-swap the brightness binding for a running target."""
from wled_controller.storage.bindable import BindableFloat
old_vs_id = self._brightness.source_id
if isinstance(brightness, BindableFloat):
self._brightness = brightness
else:
self._brightness = self._brightness.apply_update(brightness)
new_vs_id = self._brightness.source_id
vs_mgr = self._ctx.value_stream_manager
if not self._is_running or vs_mgr is None: if not self._is_running or vs_mgr is None:
return return
# Only swap streams if source_id actually changed
if old_vs_id == new_vs_id:
return
# Release old stream # Release old stream
if self._value_stream is not None and old_vs_id: if self._value_stream is not None and old_vs_id:
try: try:
@@ -329,14 +377,14 @@ class WledTargetProcessor(TargetProcessor):
self._value_stream = None self._value_stream = None
# Acquire new stream # Acquire new stream
if vs_id: if new_vs_id:
try: try:
self._value_stream = vs_mgr.acquire(vs_id) self._value_stream = vs_mgr.acquire(new_vs_id)
except Exception as e: except Exception as e:
logger.warning(f"Failed to acquire value stream {vs_id}: {e}") logger.warning(f"Failed to acquire value stream {new_vs_id}: {e}")
self._value_stream = None self._value_stream = None
logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {vs_id}") logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {new_vs_id}")
async def _probe_device(self, device_url: str, client: httpx.AsyncClient) -> bool: async def _probe_device(self, device_url: str, client: httpx.AsyncClient) -> bool:
"""HTTP liveness probe — lightweight GET to check if device is reachable.""" """HTTP liveness probe — lightweight GET to check if device is reachable."""
@@ -374,7 +422,9 @@ class WledTargetProcessor(TargetProcessor):
is_audio_source = css_timing and "audio_render_ms" in css_timing is_audio_source = css_timing and "audio_render_ms" in css_timing
audio_read_ms = round(css_timing.get("audio_read_ms", 0), 1) if is_audio_source else None audio_read_ms = round(css_timing.get("audio_read_ms", 0), 1) if is_audio_source else None
audio_fft_ms = round(css_timing.get("audio_fft_ms", 0), 1) if is_audio_source else None audio_fft_ms = round(css_timing.get("audio_fft_ms", 0), 1) if is_audio_source else None
audio_render_ms = round(css_timing.get("audio_render_ms", 0), 1) if is_audio_source else None audio_render_ms = (
round(css_timing.get("audio_render_ms", 0), 1) if is_audio_source else None
)
# Suppress picture timing when audio source is active # Suppress picture timing when audio source is active
if is_audio_source: if is_audio_source:
extract_ms = map_ms = smooth_ms = None extract_ms = map_ms = smooth_ms = None
@@ -390,15 +440,17 @@ class WledTargetProcessor(TargetProcessor):
last_update = metrics.last_update last_update = metrics.last_update
if metrics.last_update_mono > 0: if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp( last_update = (
time.time() - elapsed, tz=timezone.utc datetime.now(timezone.utc)
if elapsed < 1.0
else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
) )
return { return {
"target_id": self._target_id, "target_id": self._target_id,
"device_id": self._device_id, "device_id": self._device_id,
"color_strip_source_id": self._css_id, "color_strip_source_id": self._css_id,
"brightness_value_source_id": self._brightness_vs_id, "brightness": self._brightness.to_dict(),
"processing": self._is_running, "processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None, "fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None, "fps_potential": metrics.fps_potential if self._is_running else None,
@@ -435,8 +487,10 @@ class WledTargetProcessor(TargetProcessor):
last_update = metrics.last_update last_update = metrics.last_update
if metrics.last_update_mono > 0: if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp( last_update = (
time.time() - elapsed, tz=timezone.utc datetime.now(timezone.utc)
if elapsed < 1.0
else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
) )
return { return {
@@ -457,7 +511,9 @@ class WledTargetProcessor(TargetProcessor):
def supports_overlay(self) -> bool: def supports_overlay(self) -> bool:
return True return True
async def start_overlay(self, target_name: Optional[str] = None, calibration=None, display_info=None) -> None: async def start_overlay(
self, target_name: Optional[str] = None, calibration=None, display_info=None
) -> None:
if self._overlay_active: if self._overlay_active:
raise RuntimeError(f"Overlay already active for {self._target_id}") raise RuntimeError(f"Overlay already active for {self._target_id}")
@@ -484,7 +540,10 @@ class WledTargetProcessor(TargetProcessor):
await asyncio.to_thread( await asyncio.to_thread(
self._ctx.overlay_manager.start_overlay, self._ctx.overlay_manager.start_overlay,
self._target_id, display_info, calibration, target_name, self._target_id,
display_info,
calibration,
target_name,
) )
self._overlay_active = True self._overlay_active = True
@@ -548,6 +607,7 @@ class WledTargetProcessor(TargetProcessor):
# Check if source is composite with multiple layers # Check if source is composite with multiple layers
from wled_controller.core.processing.composite_stream import CompositeColorStripStream from wled_controller.core.processing.composite_stream import CompositeColorStripStream
stream = self._css_stream stream = self._css_stream
layer_colors = None layer_colors = None
if isinstance(stream, CompositeColorStripStream): if isinstance(stream, CompositeColorStripStream):
@@ -555,15 +615,16 @@ class WledTargetProcessor(TargetProcessor):
if layer_colors and len(layer_colors) > 1: if layer_colors and len(layer_colors) > 1:
led_count = len(colors) led_count = len(colors)
header = bytes([brightness, 0xFE, len(layer_colors), header = bytes(
(led_count >> 8) & 0xFF, led_count & 0xFF]) [brightness, 0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]
)
parts = [header] parts = [header]
for lc in layer_colors: for lc in layer_colors:
if len(lc) != led_count: if len(lc) != led_count:
lc = self._fit_to_device(lc, led_count) lc = self._fit_to_device(lc, led_count)
parts.append(lc.tobytes()) parts.append(lc.tobytes())
parts.append(colors.tobytes()) parts.append(colors.tobytes())
data = b''.join(parts) data = b"".join(parts)
else: else:
data = bytes([brightness]) + colors.tobytes() data = bytes([brightness]) + colors.tobytes()
@@ -673,7 +734,9 @@ class WledTargetProcessor(TargetProcessor):
prev_frame_time_stamp = time.perf_counter() prev_frame_time_stamp = time.perf_counter()
asyncio.get_running_loop() asyncio.get_running_loop()
_init_device_info = self._ctx.get_device_info(self._device_id) _init_device_info = self._ctx.get_device_info(self._device_id)
_total_leds = getattr(self, '_effective_led_count', None) or (_init_device_info.led_count if _init_device_info else 0) _total_leds = getattr(self, "_effective_led_count", None) or (
_init_device_info.led_count if _init_device_info else 0
)
# Stream reference — re-read each tick to detect hot-swaps # Stream reference — re-read each tick to detect hot-swaps
stream = self._css_stream stream = self._css_stream
@@ -695,19 +758,23 @@ class WledTargetProcessor(TargetProcessor):
_bright_n = _dn _bright_n = _dn
_bright_u16 = np.empty((_dn, 3), dtype=np.uint16) _bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
_bright_out = np.empty((_dn, 3), dtype=np.uint8) _bright_out = np.empty((_dn, 3), dtype=np.uint8)
np.copyto(_bright_u16, colors_in, casting='unsafe') np.copyto(_bright_u16, colors_in, casting="unsafe")
_bright_u16 *= brightness _bright_u16 *= brightness
_bright_u16 >>= 8 _bright_u16 >>= 8
np.copyto(_bright_out, _bright_u16, casting='unsafe') np.copyto(_bright_out, _bright_u16, casting="unsafe")
return _bright_out return _bright_out
def _effective_brightness(dev_info): def _effective_brightness(dev_info):
"""Compute effective brightness = software_brightness * value_stream.""" """Compute effective brightness = software_brightness * brightness binding."""
base = dev_info.software_brightness if dev_info else 255 base = dev_info.software_brightness if dev_info else 255
vs = self._value_stream vs = self._value_stream
if vs is not None: if vs is not None:
vs_val = vs.get_value() vs_val = vs.get_value()
return max(0, min(255, int(base * vs_val))) return max(0, min(255, int(base * vs_val)))
# No value stream — use static value from BindableFloat
static_val = self._brightness.value
if static_val < 1.0:
return max(0, min(255, int(base * static_val)))
return base return base
SKIP_REPOLL = 0.005 # 5 ms SKIP_REPOLL = 0.005 # 5 ms
@@ -781,7 +848,9 @@ class WledTargetProcessor(TargetProcessor):
if self._effective_fps < target_fps: if self._effective_fps < target_fps:
step = max(1, target_fps // 8) step = max(1, target_fps // 8)
old_eff = self._effective_fps old_eff = self._effective_fps
self._effective_fps = min(target_fps, self._effective_fps + step) self._effective_fps = min(
target_fps, self._effective_fps + step
)
if old_eff != self._effective_fps: if old_eff != self._effective_fps:
logger.info( logger.info(
f"[ADAPTIVE] {self._target_id} device reachable, " f"[ADAPTIVE] {self._target_id} device reachable, "
@@ -796,7 +865,11 @@ class WledTargetProcessor(TargetProcessor):
) )
# Fire new probe every _probe_interval seconds # Fire new probe every _probe_interval seconds
if _probe_enabled and _probe_task is None and (now - _last_probe_time) >= _probe_interval: if (
_probe_enabled
and _probe_task is None
and (now - _last_probe_time) >= _probe_interval
):
if _probe_client is not None: if _probe_client is not None:
_last_probe_time = now _last_probe_time = now
_probe_task = asyncio.create_task( _probe_task = asyncio.create_task(
@@ -850,7 +923,10 @@ class WledTargetProcessor(TargetProcessor):
if self._ctx.device_store: if self._ctx.device_store:
try: try:
_dev = self._ctx.device_store.get(self._device_id) _dev = self._ctx.device_store.get(self._device_id)
_cur_cspt_id = getattr(_dev, "default_css_processing_template_id", "") or "" _cur_cspt_id = (
getattr(_dev, "default_css_processing_template_id", "")
or ""
)
except Exception: except Exception:
_cur_cspt_id = "" _cur_cspt_id = ""
if _cur_cspt_id != _cspt_cached_template_id: if _cur_cspt_id != _cspt_cached_template_id:
@@ -858,21 +934,30 @@ class WledTargetProcessor(TargetProcessor):
_cspt_filters = [] _cspt_filters = []
if _cur_cspt_id and self._ctx.cspt_store: if _cur_cspt_id and self._ctx.cspt_store:
try: try:
from wled_controller.core.filters.registry import FilterRegistry from wled_controller.core.filters.registry import (
FilterRegistry,
)
_resolved = self._ctx.cspt_store.resolve_filter_instances( _resolved = self._ctx.cspt_store.resolve_filter_instances(
self._ctx.cspt_store.get_template(_cur_cspt_id).filters self._ctx.cspt_store.get_template(_cur_cspt_id).filters
) )
_cspt_filters = [ _cspt_filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options) FilterRegistry.create_instance(fi.filter_id, fi.options)
for fi in _resolved for fi in _resolved
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True) if getattr(
FilterRegistry.get(fi.filter_id),
"supports_strip",
True,
)
] ]
logger.info( logger.info(
f"CSPT resolved {len(_cspt_filters)} filters for " f"CSPT resolved {len(_cspt_filters)} filters for "
f"device {self._device_id} template {_cur_cspt_id}" f"device {self._device_id} template {_cur_cspt_id}"
) )
except Exception as e: except Exception as e:
logger.warning(f"Failed to resolve CSPT {_cur_cspt_id}: {e}") logger.warning(
f"Failed to resolve CSPT {_cur_cspt_id}: {e}"
)
_cspt_filters = [] _cspt_filters = []
if _cspt_filters: if _cspt_filters:
for _flt in _cspt_filters: for _flt in _cspt_filters:
@@ -895,7 +980,10 @@ class WledTargetProcessor(TargetProcessor):
# the last sent frame was also black, skip sending # the last sent frame was also black, skip sending
# (but still send periodic keepalive to hold DDP live mode). # (but still send periodic keepalive to hold DDP live mode).
if cur_brightness <= 1 and _prev_brightness <= 1 and has_any_frame: if cur_brightness <= 1 and _prev_brightness <= 1 and has_any_frame:
if self._needs_keepalive and (loop_start - last_send_time) >= keepalive_interval: if (
self._needs_keepalive
and (loop_start - last_send_time) >= keepalive_interval
):
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
break break
send_colors = _cached_brightness( send_colors = _cached_brightness(
@@ -907,7 +995,10 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = now last_send_time = now
send_timestamps.append(now) send_timestamps.append(now)
self._metrics.frames_keepalive += 1 self._metrics.frames_keepalive += 1
if self._preview_clients and (now - _last_preview_broadcast) >= 0.066: if (
self._preview_clients
and (now - _last_preview_broadcast) >= 0.066
):
await self._broadcast_led_preview(send_colors, cur_brightness) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
@@ -916,7 +1007,11 @@ class WledTargetProcessor(TargetProcessor):
continue continue
# Force-send preview when a new client just connected # Force-send preview when a new client just connected
if self._preview_force_send and self._preview_clients and prev_frame_ref is not None: if (
self._preview_force_send
and self._preview_clients
and prev_frame_ref is not None
):
self._preview_force_send = False self._preview_force_send = False
_force_colors = _cached_brightness( _force_colors = _cached_brightness(
self._fit_to_device(prev_frame_ref, _total_leds), self._fit_to_device(prev_frame_ref, _total_leds),
@@ -927,7 +1022,11 @@ class WledTargetProcessor(TargetProcessor):
if frame is prev_frame_ref and cur_brightness == _prev_brightness: if frame is prev_frame_ref and cur_brightness == _prev_brightness:
# Same frame + same brightness — keepalive or skip # Same frame + same brightness — keepalive or skip
if self._needs_keepalive and has_any_frame and (loop_start - last_send_time) >= keepalive_interval: if (
self._needs_keepalive
and has_any_frame
and (loop_start - last_send_time) >= keepalive_interval
):
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
break break
send_colors = _cached_brightness( send_colors = _cached_brightness(
@@ -939,7 +1038,10 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = now last_send_time = now
send_timestamps.append(now) send_timestamps.append(now)
self._metrics.frames_keepalive += 1 self._metrics.frames_keepalive += 1
if self._preview_clients and (now - _last_preview_broadcast) >= 0.066: if (
self._preview_clients
and (now - _last_preview_broadcast) >= 0.066
):
await self._broadcast_led_preview(send_colors, cur_brightness) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
@@ -977,7 +1079,10 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.frames_processed += 1 self._metrics.frames_processed += 1
self._metrics.last_update_mono = time.monotonic() self._metrics.last_update_mono = time.monotonic()
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0: if (
self._metrics.frames_processed <= 3
or self._metrics.frames_processed % 100 == 0
):
logger.info( logger.info(
f"Frame {self._metrics.frames_processed} for {self._target_id} " f"Frame {self._metrics.frames_processed} for {self._target_id} "
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms" f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
@@ -995,14 +1100,18 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.fps_actual = _fps_sum / len(fps_samples) self._metrics.fps_actual = _fps_sum / len(fps_samples)
processing_time = now - loop_start processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 self._metrics.fps_potential = (
1.0 / processing_time if processing_time > 0 else 0
)
self._metrics.fps_current = _fps_current_from_timestamps() self._metrics.fps_current = _fps_current_from_timestamps()
except Exception as e: except Exception as e:
self._metrics.errors_count += 1 self._metrics.errors_count += 1
self._metrics.last_error = str(e) self._metrics.last_error = str(e)
logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True) logger.error(
f"Processing error for target {self._target_id}: {e}", exc_info=True
)
# Drift-compensating throttle # Drift-compensating throttle
next_frame_time += frame_time next_frame_time += frame_time
@@ -1016,7 +1125,9 @@ class WledTargetProcessor(TargetProcessor):
jitter = actual_sleep - requested_sleep jitter = actual_sleep - requested_sleep
_diag_sleep_jitters.append((requested_sleep, actual_sleep)) _diag_sleep_jitters.append((requested_sleep, actual_sleep))
if jitter > 10.0: if jitter > 10.0:
_diag_slow_iters.append(((t_sleep_end - loop_start) * 1000, "sleep_jitter")) _diag_slow_iters.append(
((t_sleep_end - loop_start) * 1000, "sleep_jitter")
)
elif sleep_time < -frame_time: elif sleep_time < -frame_time:
next_frame_time = time.perf_counter() next_frame_time = time.perf_counter()
@@ -1032,20 +1143,32 @@ class WledTargetProcessor(TargetProcessor):
if iter_end >= _diag_next_report: if iter_end >= _diag_next_report:
_diag_next_report = iter_end + _diag_interval _diag_next_report = iter_end + _diag_interval
self._emit_diagnostics( self._emit_diagnostics(
self._target_id, _diag_sleep_jitters, self._target_id,
_diag_iter_times, _diag_slow_iters, _diag_sleep_jitters,
frame_time, _diag_interval, _diag_iter_times,
_diag_slow_iters,
frame_time,
_diag_interval,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {self._target_id}") logger.info(f"Processing loop cancelled for target {self._target_id}")
raise raise
except Exception as e: except Exception as e:
logger.error(f"Fatal error in processing loop for target {self._target_id}: {e}", exc_info=True) logger.error(
f"Fatal error in processing loop for target {self._target_id}: {e}", exc_info=True
)
self._metrics.last_error = f"FATAL: {e}" self._metrics.last_error = f"FATAL: {e}"
self._metrics.errors_count += 1 self._metrics.errors_count += 1
self._is_running = False self._is_running = False
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False, "crashed": True}) self._ctx.fire_event(
{
"type": "state_change",
"target_id": self._target_id,
"processing": False,
"crashed": True,
}
)
raise raise
finally: finally:
# Clean up probe client # Clean up probe client
@@ -32,13 +32,15 @@ def capture_current_snapshot(
continue continue
proc = processor_manager.get_processor(t.id) proc = processor_manager.get_processor(t.id)
running = proc.is_running if proc else False running = proc.is_running if proc else False
targets.append(TargetSnapshot( targets.append(
target_id=t.id, TargetSnapshot(
running=running, target_id=t.id,
color_strip_source_id=getattr(t, "color_strip_source_id", ""), running=running,
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""), color_strip_source_id=getattr(t, "color_strip_source_id", ""),
fps=getattr(t, "fps", 30), brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
)) fps=getattr(t, "fps", 30),
)
)
return targets return targets
@@ -90,12 +92,16 @@ async def apply_scene_state(
proc = processor_manager.get_processor(ts.target_id) proc = processor_manager.get_processor(ts.target_id)
if proc and proc.is_running: if proc and proc.is_running:
css_changed = "color_strip_source_id" in changed css_changed = "color_strip_source_id" in changed
bvs_changed = "brightness_value_source_id" in changed brightness_changed = "brightness" in changed
settings_changed = "fps" in changed settings_changed = "fps" in changed
if css_changed: if css_changed:
target.sync_with_manager(processor_manager, settings_changed=False, css_changed=True) target.sync_with_manager(
if bvs_changed: processor_manager, settings_changed=False, css_changed=True
target.sync_with_manager(processor_manager, settings_changed=False, brightness_vs_changed=True) )
if brightness_changed:
target.sync_with_manager(
processor_manager, settings_changed=False, brightness_changed=True
)
if settings_changed: if settings_changed:
target.sync_with_manager(processor_manager, settings_changed=True) target.sync_with_manager(processor_manager, settings_changed=True)
except ValueError: except ValueError:
@@ -1109,3 +1109,69 @@ textarea:focus-visible {
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* ── BindableScalarWidget ── */
.bindable-slider-row,
.bindable-vs-row {
display: flex;
align-items: center;
gap: 8px;
}
.bindable-slider-row input[type="range"] {
flex: 1;
min-width: 0;
}
.bindable-vs-row select,
.bindable-vs-row .entity-select-btn {
flex: 1;
min-width: 0;
}
.bindable-value {
min-width: 3.5ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
font-size: 0.85rem;
}
.bindable-toggle {
flex-shrink: 0;
width: 26px;
height: 26px;
padding: 3px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: var(--bg-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.bindable-toggle:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.bindable-toggle--active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--bg-color);
}
.bindable-toggle--active:hover {
opacity: 0.85;
color: var(--bg-color);
}
.bindable-toggle .icon-xs {
width: 16px;
height: 16px;
fill: currentColor;
}
@@ -0,0 +1,211 @@
/**
* BindableScalarWidget — a slider that can optionally bind to a value source.
*
* Renders a slider (range input) with a small toggle button. When toggled to
* "bound" mode, shows an EntitySelect value source picker instead of the slider.
* Emits a BindableFloat value: plain number (static) or {value, source_id} (bound).
*
* Usage:
* const widget = new BindableScalarWidget({
* container: document.getElementById('my-container'),
* label: 'Smoothing',
* min: 0, max: 1, step: 0.05, default: 0.3,
* valueSources: () => cachedValueSources,
* onChange: (bf) => { … },
* });
* widget.setValue({ value: 0.3, source_id: 'vs_abc' });
* const bf = widget.getValue(); // BindableFloat
*/
import type { BindableFloat } from '../types.ts';
import { bindableValue, bindableSourceId } from '../types.ts';
import { EntitySelect } from './entity-palette.ts';
import { getValueSourceIcon } from './icons.ts';
import { t } from './i18n.ts';
export interface BindableScalarOpts {
container: HTMLElement;
label?: string;
min: number;
max: number;
step: number;
default: number;
/** Format the display value (default: 2 decimal places) */
format?: (v: number) => string;
valueSources: () => Array<{ id: string; name: string; source_type: string }>;
onChange?: (value: BindableFloat) => void;
/** HTML id prefix for generated elements */
idPrefix?: string;
/** Label for the "no binding" option (default: generic "None (static value)") */
noneLabel?: string;
}
let _widgetCounter = 0;
export class BindableScalarWidget {
private _container: HTMLElement;
private _opts: BindableScalarOpts;
private _id: string;
private _bound: boolean = false;
private _staticValue: number;
private _sourceId: string = '';
private _entitySelect: EntitySelect | null = null;
// DOM elements
private _sliderRow!: HTMLElement;
private _vsRow!: HTMLElement;
private _slider!: HTMLInputElement;
private _display!: HTMLElement;
private _toggleBtn!: HTMLButtonElement;
private _select!: HTMLSelectElement;
constructor(opts: BindableScalarOpts) {
this._opts = opts;
this._container = opts.container;
this._staticValue = opts.default;
this._id = opts.idPrefix || `bsw-${++_widgetCounter}`;
this._render();
}
private _format(v: number): string {
return this._opts.format ? this._opts.format(v) : v.toFixed(2);
}
private _render(): void {
const { min, max, step } = this._opts;
const id = this._id;
// Toggle button (link icon for binding)
const toggleHtml = `<button type="button" class="bindable-toggle" id="${id}-toggle" title="${t('bindable.toggle')}" aria-label="Toggle value source binding">
<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</button>`;
// Slider row (static mode)
const sliderHtml = `<div class="bindable-slider-row" id="${id}-slider-row">
<input type="range" id="${id}-slider" min="${min}" max="${max}" step="${step}" value="${this._staticValue}">
<span class="bindable-value" id="${id}-display">${this._format(this._staticValue)}</span>
${toggleHtml}
</div>`;
// VS picker row (bound mode)
const vsHtml = `<div class="bindable-vs-row" id="${id}-vs-row" style="display:none">
<select id="${id}-select"></select>
<button type="button" class="bindable-toggle bindable-toggle--active" id="${id}-untoggle" title="${t('bindable.toggle')}" aria-label="Switch to static value">
<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</button>
</div>`;
this._container.innerHTML = sliderHtml + vsHtml;
// Cache DOM refs
this._sliderRow = document.getElementById(`${id}-slider-row`)!;
this._vsRow = document.getElementById(`${id}-vs-row`)!;
this._slider = document.getElementById(`${id}-slider`) as HTMLInputElement;
this._display = document.getElementById(`${id}-display`)!;
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
// Slider input handler
this._slider.addEventListener('input', () => {
this._staticValue = parseFloat(this._slider.value);
this._display.textContent = this._format(this._staticValue);
this._fireChange();
});
// Toggle to bound mode
this._toggleBtn.addEventListener('click', () => this._setMode(true));
document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false));
}
private _setMode(bound: boolean): void {
this._bound = bound;
this._sliderRow.style.display = bound ? 'none' : '';
this._vsRow.style.display = bound ? '' : 'none';
if (bound) {
this._populateVsSelect();
} else {
this._sourceId = '';
if (this._entitySelect) {
this._entitySelect.destroy();
this._entitySelect = null;
}
}
this._fireChange();
}
private _populateVsSelect(): void {
const sources = this._opts.valueSources();
const id = this._id;
this._select.innerHTML = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
sources.map(vs =>
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
).join('');
// Wrap with EntitySelect
if (this._entitySelect) this._entitySelect.destroy();
this._entitySelect = new EntitySelect({
target: this._select,
getItems: () => sources.map(vs => ({
value: vs.id,
label: vs.name,
icon: getValueSourceIcon(vs.source_type),
desc: vs.source_type,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: this._opts.noneLabel || t('bindable.none'),
onChange: (value: string) => {
this._sourceId = value;
this._fireChange();
},
});
}
private _fireChange(): void {
if (this._opts.onChange) {
this._opts.onChange(this.getValue());
}
}
// ── Public API ──
getValue(): BindableFloat {
if (this._bound && this._sourceId) {
return { value: this._staticValue, source_id: this._sourceId };
}
return this._staticValue;
}
setValue(bf: BindableFloat | undefined): void {
this._staticValue = bindableValue(bf, this._opts.default);
this._sourceId = bindableSourceId(bf);
this._bound = !!this._sourceId;
this._slider.value = String(this._staticValue);
this._display.textContent = this._format(this._staticValue);
this._sliderRow.style.display = this._bound ? 'none' : '';
this._vsRow.style.display = this._bound ? '' : 'none';
if (this._bound) {
this._populateVsSelect();
}
}
/** Refresh the VS dropdown after cache updates. */
refresh(): void {
if (this._bound) {
this._populateVsSelect();
}
}
destroy(): void {
if (this._entitySelect) {
this._entitySelect.destroy();
this._entitySelect = null;
}
this._container.innerHTML = '';
}
}
@@ -57,20 +57,34 @@ const CONNECTION_MAP: ConnectionEntry[] = [
// Output targets // Output targets
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, { targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, { targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, { targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true },
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, { targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
// Automations // Automations
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj }, { targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj }, { targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
// ── BindableFloat value source edges (CSS properties) ──
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// HA light target transition binding
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// ── Nested fields (not drag-editable in V1) ── // ── Nested fields (not drag-editable in V1) ──
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true }, { targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
{ targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true }, { targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
{ targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true }, { targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true },
{ targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true }, { targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true },
{ targetKind: 'output_target', field: 'settings.brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'output_target', field: 'settings.brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true }, { targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
]; ];
@@ -3,6 +3,7 @@
*/ */
import ELK from 'elkjs/lib/elk.bundled.js'; import ELK from 'elkjs/lib/elk.bundled.js';
import { bindableSourceId } from '../types.ts';
/* ── Types ────────────────────────────────────────────────────── */ /* ── Types ────────────────────────────────────────────────────── */
@@ -351,18 +352,29 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
if (line.picture_source_id) addEdge(line.picture_source_id, s.id, 'calibration.picture_source_id'); if (line.picture_source_id) addEdge(line.picture_source_id, s.id, 'calibration.picture_source_id');
} }
} }
// BindableFloat value source edges
for (const prop of ['smoothing', 'sensitivity', 'intensity', 'scale', 'speed',
'wind_strength', 'temperature_influence', 'sound_volume', 'timeout', 'brightness'] as const) {
const vsId = bindableSourceId((s as any)[prop]);
if (vsId) addEdge(vsId, s.id, `${prop}.source_id`);
}
} }
// Output target edges // Output target edges
for (const t of e.outputTargets || []) { for (const t of e.outputTargets || []) {
if (t.device_id) addEdge(t.device_id, t.id, 'device_id'); if (t.device_id) addEdge(t.device_id, t.id, 'device_id');
if (t.color_strip_source_id) addEdge(t.color_strip_source_id, t.id, 'color_strip_source_id'); if (t.color_strip_source_id) addEdge(t.color_strip_source_id, t.id, 'color_strip_source_id');
if (t.brightness_value_source_id) addEdge(t.brightness_value_source_id, t.id, 'brightness_value_source_id'); const bvsId = bindableSourceId(t.brightness);
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
const transVsId = bindableSourceId(t.transition);
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id'); if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
// KC target settings // KC target settings
if (t.settings) { if (t.settings) {
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id'); if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
if (t.settings.brightness_value_source_id) addEdge(t.settings.brightness_value_source_id, t.id, 'settings.brightness_value_source_id'); const settingsBvsId = bindableSourceId(t.settings?.brightness);
if (settingsBvsId) addEdge(settingsBvsId, t.id, 'settings.brightness.source_id');
} }
} }
@@ -91,3 +91,9 @@ export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>'; export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'; export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'; export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
export const check = '<path d="M20 6 9 17l-5-5"/>';
export const code = '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>';
export const doorOpen = '<path d="M13 4h3a2 2 0 0 1 2 2v14"/><path d="M2 20h3"/><path d="M13 20h9"/><path d="M10 12v.01"/><path d="M13 4.562v16.157a1 1 0 0 1-1.242.97L5 20V5.562a2 2 0 0 1 1.515-1.94l4-1A2 2 0 0 1 13 4.561Z"/>';
export const toggleRight = '<rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/>';
export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z"/><path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"/>';
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
@@ -91,6 +91,139 @@ export function getAudioEngineIcon(engineType: string): string {
return _audioEngineTypeIcons[engineType] || _svg(P.music); return _audioEngineTypeIcons[engineType] || _svg(P.music);
} }
// ── MDI → Lucide icon mapping (for Home Assistant entities) ──
const _mdiMap: Record<string, string> = {
'mdi:lightbulb': P.lightbulb,
'mdi:lightbulb-group': P.lightbulb,
'mdi:lightbulb-outline': P.lightbulb,
'mdi:led-strip': P.lightbulb,
'mdi:led-strip-variant': P.lightbulb,
'mdi:lamp': P.lightbulb,
'mdi:ceiling-light': P.lightbulb,
'mdi:floor-lamp': P.lightbulb,
'mdi:desk-lamp': P.lightbulb,
'mdi:wall-sconce': P.lightbulb,
'mdi:thermometer': P.thermometer,
'mdi:temperature-celsius': P.thermometer,
'mdi:temperature-fahrenheit': P.thermometer,
'mdi:water-thermometer': P.thermometer,
'mdi:home-thermometer': P.thermometer,
'mdi:monitor': P.monitor,
'mdi:television': P.tv,
'mdi:sun-wireless': P.sun,
'mdi:weather-sunny': P.sun,
'mdi:weather-night': P.moon,
'mdi:moon-waning-crescent': P.moon,
'mdi:lock': P.lock,
'mdi:lock-open': P.lock,
'mdi:wifi': P.wifi,
'mdi:fire': P.flame,
'mdi:power': P.power,
'mdi:power-plug': P.plug,
'mdi:eye': P.eye,
'mdi:home': P.home,
'mdi:home-assistant': P.home,
'mdi:music': P.music,
'mdi:music-note': P.music,
'mdi:camera': P.camera,
'mdi:flash': P.zap,
'mdi:flash-alert': P.zap,
'mdi:bell': P.bellRing,
'mdi:bell-ring': P.bellRing,
'mdi:earth': P.globe,
'mdi:web': P.globe,
'mdi:clock': P.clock,
'mdi:clock-outline': P.clock,
'mdi:timer': P.timer,
'mdi:timer-outline': P.timer,
'mdi:heart': P.heart,
'mdi:heart-pulse': P.heart,
'mdi:motion-sensor': P.activity,
'mdi:run': P.activity,
'mdi:walk': P.activity,
'mdi:door': P.doorOpen,
'mdi:door-open': P.doorOpen,
'mdi:door-closed': P.doorOpen,
'mdi:window-open': P.doorOpen,
'mdi:window-closed': P.doorOpen,
'mdi:toggle-switch': P.toggleRight,
'mdi:toggle-switch-off': P.toggleRight,
'mdi:water': P.droplets,
'mdi:water-percent': P.droplets,
'mdi:humidity': P.droplets,
'mdi:fan': P.fan,
'mdi:fan-speed-1': P.fan,
'mdi:fan-speed-2': P.fan,
'mdi:fan-speed-3': P.fan,
'mdi:star': P.star,
'mdi:battery': P.zap,
'mdi:battery-charging': P.zap,
'mdi:gauge': P.activity,
'mdi:speedometer': P.activity,
'mdi:robot-vacuum': P.settings,
'mdi:cog': P.settings,
};
/** Map an MDI icon name (from HA entity) to a Lucide SVG icon. */
export function getMdiIcon(mdiName: string): string {
if (!mdiName) return _svg(P.listChecks);
const path = _mdiMap[mdiName];
if (path) return _svg(path);
// Fallback: try keyword matching
if (mdiName.includes('light')) return _svg(P.lightbulb);
if (mdiName.includes('sensor')) return _svg(P.activity);
if (mdiName.includes('switch')) return _svg(P.toggleRight);
if (mdiName.includes('door') || mdiName.includes('window')) return _svg(P.doorOpen);
return _svg(P.listChecks);
}
/** Map HA entity domain to an icon (fallback when no MDI icon is available). */
const _domainIcons: Record<string, string> = {
light: P.lightbulb,
switch: P.toggleRight,
sensor: P.activity,
binary_sensor: P.toggleRight,
climate: P.thermometer,
fan: P.fan,
cover: P.doorOpen,
lock: P.lock,
camera: P.camera,
media_player: P.music,
automation: P.refreshCw,
script: P.play,
scene: P.star,
input_boolean: P.toggleRight,
input_number: P.slidersHorizontal,
input_select: P.listChecks,
input_text: P.fileText,
timer: P.timer,
counter: P.hash,
person: P.smartphone,
device_tracker: P.mapPin,
zone: P.mapPin,
sun: P.sun,
weather: P.cloudSun,
update: P.download,
button: P.power,
number: P.slidersHorizontal,
select: P.listChecks,
text: P.fileText,
alarm_control_panel: P.bellRing,
water_heater: P.droplets,
vacuum: P.settings,
humidifier: P.droplets,
};
export function getHAEntityIcon(entity: { icon?: string; domain?: string }): string {
// Prefer explicit MDI icon if set
if (entity.icon) return getMdiIcon(entity.icon);
// Fall back to domain-based icon
const domainPath = _domainIcons[entity.domain || ''];
if (domainPath) return _svg(domainPath);
return _svg(P.listChecks);
}
// ── Entity-kind constants ─────────────────────────────────── // ── Entity-kind constants ───────────────────────────────────
export const ICON_AUTOMATION = _svg(P.clipboardList); export const ICON_AUTOMATION = _svg(P.clipboardList);
@@ -30,6 +30,8 @@ class AudioSourceModal extends Modal {
onForceClose() { onForceClose() {
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; } if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
if (_asChannelIconSelect) { _asChannelIconSelect.destroy(); _asChannelIconSelect = null; }
if (_asBandIconSelect) { _asBandIconSelect.destroy(); _asBandIconSelect = null; }
} }
snapshotValues() { snapshotValues() {
@@ -58,6 +60,7 @@ let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect: EntitySelect | null = null; let _asParentEntitySelect: EntitySelect | null = null;
let _asBandParentEntitySelect: EntitySelect | null = null; let _asBandParentEntitySelect: EntitySelect | null = null;
let _asBandIconSelect: IconSelect | null = null; let _asBandIconSelect: IconSelect | null = null;
let _asChannelIconSelect: IconSelect | null = null;
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -136,7 +139,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
} else if (editData.source_type === 'mono') { } else if (editData.source_type === 'mono') {
_loadMultichannelSources(editData.audio_source_id); _loadMultichannelSources(editData.audio_source_id);
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono'; (document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName(); _ensureChannelIconSelect();
} else if (editData.source_type === 'band_extract') { } else if (editData.source_type === 'band_extract') {
_loadBandParentSources(editData.audio_source_id); _loadBandParentSources(editData.audio_source_id);
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass'; (document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
@@ -155,7 +158,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
await _loadAudioDevices(); await _loadAudioDevices();
} else if (sourceType === 'mono') { } else if (sourceType === 'mono') {
_loadMultichannelSources(); _loadMultichannelSources();
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName(); _ensureChannelIconSelect();
} else if (sourceType === 'band_extract') { } else if (sourceType === 'band_extract') {
_loadBandParentSources(); _loadBandParentSources();
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass'; (document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
@@ -426,6 +429,28 @@ function _ensureBandIconSelect() {
}); });
} }
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureChannelIconSelect() {
const sel = document.getElementById('audio-source-channel') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'mono', icon: _icon(P.headphones), label: t('audio_source.channel.mono'), desc: t('audio_source.channel.mono.desc') },
{ value: 'left', icon: _icon(P.volume2), label: t('audio_source.channel.left'), desc: t('audio_source.channel.left.desc') },
{ value: 'right', icon: _icon(P.volume2), label: t('audio_source.channel.right'), desc: t('audio_source.channel.right.desc') },
];
if (_asChannelIconSelect) {
_asChannelIconSelect.updateItems(items);
return;
}
_asChannelIconSelect = new IconSelect({
target: sel,
items,
columns: 3,
onChange: () => _autoGenerateAudioSourceName(),
});
}
function _loadBandParentSources(selectedId?: any) { function _loadBandParentSources(selectedId?: any) {
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null; const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
if (!select) return; if (!select) return;
@@ -3,6 +3,7 @@
*/ */
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts'; import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
import { getHAEntityIcon } from '../core/icons.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts'; import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
@@ -21,6 +22,33 @@ import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts'; import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts'; import type { Automation } from '../types.ts';
// ── HA condition entity cache ──
let _haConditionEntities: any[] = [];
async function _loadHAEntitiesForCondition(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haConditionEntities = []; return; }
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _haConditionEntities = []; return; }
const data = await resp.json();
_haConditionEntities = data.entities || [];
} catch {
_haConditionEntities = [];
}
// Rebuild entity select options
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
if (entitySelect) {
const currentVal = entitySelect.value;
entitySelect.innerHTML = `<option value="">—</option>` +
_haConditionEntities.map((e: any) =>
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).join('');
if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) {
entitySelect.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
}
}
}
let _automationTagsInput: any = null; let _automationTagsInput: any = null;
// ── Auto-name ── // ── Auto-name ──
@@ -732,7 +760,6 @@ function addAutomationConditionRow(condition: any) {
const entityId = data.entity_id || ''; const entityId = data.entity_id || '';
const haState = data.state || ''; const haState = data.state || '';
const matchMode = data.match_mode || 'exact'; const matchMode = data.match_mode || 'exact';
// Build HA source options from cached data
const haOptions = _cachedHASources.map((s: any) => const haOptions = _cachedHASources.map((s: any) =>
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>` `<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join(''); ).join('');
@@ -748,7 +775,9 @@ function addAutomationConditionRow(condition: any) {
</div> </div>
<div class="condition-field"> <div class="condition-field">
<label>${t('automations.condition.home_assistant.entity_id')}</label> <label>${t('automations.condition.home_assistant.entity_id')}</label>
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door"> <select class="condition-ha-entity-id">
${entityId ? `<option value="${escapeHtml(entityId)}" selected>${escapeHtml(entityId)}</option>` : '<option value="">—</option>'}
</select>
</div> </div>
<div class="condition-field"> <div class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label> <label>${t('automations.condition.home_assistant.state')}</label>
@@ -763,6 +792,45 @@ function addAutomationConditionRow(condition: any) {
</select> </select>
</div> </div>
</div>`; </div>`;
// Wire HA source EntitySelect
const haSrcSelect = container.querySelector('.condition-ha-source-id') as HTMLSelectElement;
new EntitySelect({
target: haSrcSelect,
getItems: () => _cachedHASources.map((s: any) => ({
value: s.id, label: s.name, icon: _icon(P.home),
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
})),
placeholder: t('palette.search'),
onChange: (newId: string) => _loadHAEntitiesForCondition(newId, container),
});
// Wire entity EntitySelect
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
const entityES = new EntitySelect({
target: entitySelect,
getItems: () => _haConditionEntities.map((e: any) => ({
value: e.entity_id, label: e.friendly_name || e.entity_id,
icon: getHAEntityIcon(e), desc: e.state || '',
})),
placeholder: t('ha_light.mapping.search_entity'),
});
// Wire match mode IconSelect
const matchSelect = container.querySelector('.condition-ha-match-mode') as HTMLSelectElement;
new IconSelect({
target: matchSelect,
items: [
{ value: 'exact', icon: _icon(P.check), label: t('automations.condition.mqtt.match_mode.exact'), desc: t('automations.condition.ha.match_mode.exact.desc') },
{ value: 'contains', icon: _icon(P.search), label: t('automations.condition.mqtt.match_mode.contains'), desc: t('automations.condition.ha.match_mode.contains.desc') },
{ value: 'regex', icon: _icon(P.code), label: t('automations.condition.mqtt.match_mode.regex'), desc: t('automations.condition.ha.match_mode.regex.desc') },
],
columns: 1,
});
// Load entities if source is already selected
if (haSourceId) _loadHAEntitiesForCondition(haSourceId, container);
return; return;
} }
if (type === 'webhook') { if (type === 'webhook') {
@@ -878,7 +946,7 @@ function getAutomationEditorConditions() {
conditions.push({ conditions.push({
condition_type: 'home_assistant', condition_type: 'home_assistant',
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value, ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(), entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLSelectElement).value.trim(),
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value, state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact', match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
}); });
@@ -13,6 +13,7 @@ import {
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -24,6 +25,7 @@ let _compositeSourceEntitySelects: any[] = [];
let _compositeBrightnessEntitySelects: any[] = []; let _compositeBrightnessEntitySelects: any[] = [];
let _compositeBlendIconSelects: any[] = []; let _compositeBlendIconSelects: any[] = [];
let _compositeCSPTEntitySelects: any[] = []; let _compositeCSPTEntitySelects: any[] = [];
let _compositeOpacityWidgets: BindableScalarWidget[] = [];
/** Return current composite layers array (for dirty-check snapshot). */ /** Return current composite layers array (for dirty-check snapshot). */
export function compositeGetRawLayers() { export function compositeGetRawLayers() {
@@ -47,6 +49,8 @@ export function compositeDestroyEntitySelects() {
_compositeBlendIconSelects = []; _compositeBlendIconSelects = [];
_compositeCSPTEntitySelects.forEach(es => es.destroy()); _compositeCSPTEntitySelects.forEach(es => es.destroy());
_compositeCSPTEntitySelects = []; _compositeCSPTEntitySelects = [];
_compositeOpacityWidgets.forEach(w => w.destroy());
_compositeOpacityWidgets = [];
} }
function _getCompositeBlendItems() { function _getCompositeBlendItems() {
@@ -140,10 +144,8 @@ export function compositeRenderList() {
<div class="composite-layer-row"> <div class="composite-layer-row">
<label class="composite-layer-opacity-label"> <label class="composite-layer-opacity-label">
<span>${t('color_strip.composite.opacity')}:</span> <span>${t('color_strip.composite.opacity')}:</span>
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
</label> </label>
<input type="range" class="composite-layer-opacity" data-idx="${i}" <div class="composite-layer-opacity-container" data-idx="${i}"></div>
min="0" max="1" step="0.05" value="${layer.opacity}">
</div> </div>
<div class="composite-layer-row"> <div class="composite-layer-row">
<label class="composite-layer-brightness-label"> <label class="composite-layer-brightness-label">
@@ -229,14 +231,6 @@ export function compositeRenderList() {
}); });
}); });
// Wire up live opacity display
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
el.addEventListener('input', () => {
const val = parseFloat(el.value);
(el.closest('.composite-layer-row')!.querySelector('.composite-opacity-val') as HTMLElement).textContent = val.toFixed(2);
});
});
// Attach IconSelect to each layer's blend mode dropdown // Attach IconSelect to each layer's blend mode dropdown
const blendItems = _getCompositeBlendItems(); const blendItems = _getCompositeBlendItems();
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => { list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
@@ -275,6 +269,19 @@ export function compositeRenderList() {
})); }));
}); });
// Create BindableScalarWidget for each layer's opacity
list.querySelectorAll<HTMLElement>('.composite-layer-opacity-container').forEach((container, i) => {
const widget = new BindableScalarWidget({
container,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: `composite-opacity-${i}`,
format: (v) => v.toFixed(2),
valueSources: () => _cachedValueSources,
});
widget.setValue(_compositeLayers[i]?.opacity ?? 1.0);
_compositeOpacityWidgets.push(widget);
});
_initCompositeLayerDrag(list); _initCompositeLayerDrag(list);
} }
@@ -306,7 +313,6 @@ function _compositeLayersSyncFromDom() {
if (!list) return; if (!list) return;
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source'); const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend'); const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
const opacities = list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity');
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled'); const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness'); const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt'); const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
@@ -317,7 +323,7 @@ function _compositeLayersSyncFromDom() {
for (let i = 0; i < srcs.length; i++) { for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[i].value; _compositeLayers[i].source_id = srcs[i].value;
_compositeLayers[i].blend_mode = blends[i].value; _compositeLayers[i].blend_mode = blends[i].value;
_compositeLayers[i].opacity = parseFloat(opacities[i].value); _compositeLayers[i].opacity = _compositeOpacityWidgets[i]?.getValue() ?? _compositeLayers[i].opacity;
_compositeLayers[i].enabled = enableds[i].checked; _compositeLayers[i].enabled = enableds[i].checked;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null; _compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null; _compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
@@ -13,8 +13,9 @@ import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts'; import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { _cachedAssets, assetsCache } from '../core/state.ts'; import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -36,6 +37,32 @@ export function notificationGetRawAppOverrides() {
let _notificationEffectIconSelect: any = null; let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null; let _notificationFilterModeIconSelect: any = null;
let _notificationDurationWidget: BindableScalarWidget | null = null;
function _ensureNotificationDurationWidget(): BindableScalarWidget {
if (!_notificationDurationWidget) {
_notificationDurationWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-notification-duration-container')!,
min: 100, max: 5000, step: 100, default: 1500,
idPrefix: 'css-editor-notification-duration',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _notificationDurationWidget;
}
export function destroyNotificationDurationWidget(): void {
if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; }
}
export function getNotificationDurationValue(): number | { value: number; source_id: string } {
return _notificationDurationWidget ? _notificationDurationWidget.getValue() : 1500;
}
export function getNotificationDurationSnapshot(): string {
return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500';
}
export function ensureNotificationEffectIconSelect() { export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
@@ -346,9 +373,7 @@ export async function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener; (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash'; (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash'); if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
const dur = css.duration_ms ?? 1500; _ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500);
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = dur;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = dur;
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff'; (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off'; (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off'); if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
@@ -380,8 +405,7 @@ export async function resetNotificationState() {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true; (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash'; (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash'); if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any; _ensureNotificationDurationWidget().setValue(1500);
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff'; (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off'; (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off'); if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
@@ -15,7 +15,7 @@ import {
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts'; import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts'; import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts'; import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue } from './color-strips-notification.ts';
/* ── Preview config builder ───────────────────────────────────── */ /* ── Preview config builder ───────────────────────────────────── */
@@ -49,7 +49,7 @@ function _collectPreviewConfig() {
config = { config = {
source_type: 'notification', source_type: 'notification',
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500, duration_ms: getNotificationDurationValue(),
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value, default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList, app_filter_list: filterList,
@@ -3,7 +3,7 @@
*/ */
import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts'; import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
@@ -18,9 +18,11 @@ import {
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts'; import type { ColorStripSource } from '../types.ts';
import { bindableValue } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
import { import {
rgbArrayToHex, hexToRgbArray, rgbArrayToHex, hexToRgbArray,
@@ -39,6 +41,7 @@ import {
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides, notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
loadNotificationState, resetNotificationState, showNotificationEndpoint, loadNotificationState, resetNotificationState, showNotificationEndpoint,
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
} from './color-strips-notification.ts'; } from './color-strips-notification.ts';
// Re-export for app.js window global bindings // Re-export for app.js window global bindings
@@ -58,8 +61,22 @@ class CSSEditorModal extends Modal {
onForceClose() { onForceClose() {
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; } if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
if (_smoothingWidget) { _smoothingWidget.destroy(); _smoothingWidget = null; }
if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; }
if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; }
if (_effectIntensityWidget) { _effectIntensityWidget.destroy(); _effectIntensityWidget = null; }
if (_effectScaleWidget) { _effectScaleWidget.destroy(); _effectScaleWidget = null; }
if (_apiInputTimeoutWidget) { _apiInputTimeoutWidget.destroy(); _apiInputTimeoutWidget = null; }
if (_candlelightIntensityWidget) { _candlelightIntensityWidget.destroy(); _candlelightIntensityWidget = null; }
if (_candlelightSpeedWidget) { _candlelightSpeedWidget.destroy(); _candlelightSpeedWidget = null; }
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
destroyNotificationDurationWidget();
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; } if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; } if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; }
if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; }
compositeDestroyEntitySelects(); compositeDestroyEntitySelects();
} }
@@ -70,7 +87,7 @@ class CSSEditorModal extends Modal {
type, type,
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value, picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value, interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: (document.getElementById('css-editor-smoothing') as HTMLInputElement).value, smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
color: (document.getElementById('css-editor-color') as HTMLInputElement).value, color: (document.getElementById('css-editor-color') as HTMLInputElement).value,
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value, led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]', gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
@@ -79,25 +96,25 @@ class CSSEditorModal extends Modal {
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value, effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value,
effect_intensity: (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value, effect_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0',
effect_scale: (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value, effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0',
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked, effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
composite_layers: JSON.stringify(compositeGetRawLayers()), composite_layers: JSON.stringify(compositeGetRawLayers()),
mapped_zones: JSON.stringify(_mappedZones), mapped_zones: JSON.stringify(_mappedZones),
audio_viz: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value, audio_viz: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value, audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value,
audio_sensitivity: (document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value, audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
audio_smoothing: (document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value, audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value, audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value,
audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value, audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value,
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value, api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value,
api_input_timeout: (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value, api_input_timeout: _apiInputTimeoutWidget ? JSON.stringify(_apiInputTimeoutWidget.getValue()) : '5.0',
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value, api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value,
notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked, notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
notification_duration: (document.getElementById('css-editor-notification-duration') as HTMLInputElement).value, notification_duration: getNotificationDurationSnapshot(),
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value, notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value, notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
@@ -107,11 +124,13 @@ class CSSEditorModal extends Modal {
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value, daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value, candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value,
candlelight_intensity: (document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value, candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value, candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
candlelight_speed: (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value, candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0',
processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value, processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value,
processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value, processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value,
kc_smoothing: _kcSmoothingWidget ? JSON.stringify(_kcSmoothingWidget.getValue()) : '0.3',
kc_brightness: _kcBrightnessWidget ? JSON.stringify(_kcBrightnessWidget.getValue()) : '1.0',
kc_rects: JSON.stringify(_kcEditorRects), kc_rects: JSON.stringify(_kcEditorRects),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []), tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
}; };
@@ -121,6 +140,17 @@ class CSSEditorModal extends Modal {
const cssEditorModal = new CSSEditorModal(); const cssEditorModal = new CSSEditorModal();
let _cssTagsInput: any = null; let _cssTagsInput: any = null;
let _smoothingWidget: BindableScalarWidget | null = null;
let _audioSensitivityWidget: BindableScalarWidget | null = null;
let _audioSmoothingWidget: BindableScalarWidget | null = null;
let _effectIntensityWidget: BindableScalarWidget | null = null;
let _effectScaleWidget: BindableScalarWidget | null = null;
let _apiInputTimeoutWidget: BindableScalarWidget | null = null;
let _candlelightIntensityWidget: BindableScalarWidget | null = null;
let _candlelightSpeedWidget: BindableScalarWidget | null = null;
let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
// ── EntitySelect instances for CSS editor ── // ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect: any = null; let _cssPictureSourceEntitySelect: any = null;
@@ -130,6 +160,8 @@ let _processedInputEntitySelect: any = null;
let _processedTemplateEntitySelect: any = null; let _processedTemplateEntitySelect: any = null;
let _kcPictureSourceEntitySelect: any = null; let _kcPictureSourceEntitySelect: any = null;
let _kcInterpolationIconSelect: any = null; let _kcInterpolationIconSelect: any = null;
let _kcSmoothingWidget: BindableScalarWidget | null = null;
let _kcBrightnessWidget: BindableScalarWidget | null = null;
// ── Key Colors rectangle editor state ── // ── Key Colors rectangle editor state ──
let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = []; let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = [];
@@ -480,6 +512,175 @@ function _ensureInterpolationIconSelect() {
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 }); _interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
} }
function _ensureSmoothingWidget(): BindableScalarWidget {
const container = document.getElementById('css-editor-smoothing-container')!;
if (!_smoothingWidget) {
_smoothingWidget = new BindableScalarWidget({
container,
min: 0, max: 1, step: 0.05, default: 0.3,
idPrefix: 'css-editor-smoothing',
valueSources: () => _cachedValueSources,
});
}
return _smoothingWidget;
}
function _ensureKcSmoothingWidget(): BindableScalarWidget {
if (!_kcSmoothingWidget) {
_kcSmoothingWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-kc-smoothing-container')!,
min: 0, max: 1, step: 0.05, default: 0.3,
idPrefix: 'css-editor-kc-smoothing',
format: (v) => v.toFixed(2),
valueSources: () => _cachedValueSources,
});
}
return _kcSmoothingWidget;
}
function _ensureKcBrightnessWidget(): BindableScalarWidget {
if (!_kcBrightnessWidget) {
_kcBrightnessWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-kc-brightness-container')!,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: 'css-editor-kc-brightness',
format: (v) => v.toFixed(2),
valueSources: () => _cachedValueSources,
});
}
return _kcBrightnessWidget;
}
function _ensureAudioSensitivityWidget(): BindableScalarWidget {
if (!_audioSensitivityWidget) {
_audioSensitivityWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-audio-sensitivity-container')!,
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-audio-sensitivity',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _audioSensitivityWidget;
}
function _ensureAudioSmoothingWidget(): BindableScalarWidget {
if (!_audioSmoothingWidget) {
_audioSmoothingWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-audio-smoothing-container')!,
min: 0.0, max: 1.0, step: 0.05, default: 0.3,
idPrefix: 'css-editor-audio-smoothing',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(2),
});
}
return _audioSmoothingWidget;
}
function _ensureEffectIntensityWidget(): BindableScalarWidget {
if (!_effectIntensityWidget) {
_effectIntensityWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-effect-intensity-container')!,
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-effect-intensity',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _effectIntensityWidget;
}
function _ensureEffectScaleWidget(): BindableScalarWidget {
if (!_effectScaleWidget) {
_effectScaleWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-effect-scale-container')!,
min: 0.5, max: 5.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-effect-scale',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _effectScaleWidget;
}
function _ensureApiInputTimeoutWidget(): BindableScalarWidget {
if (!_apiInputTimeoutWidget) {
_apiInputTimeoutWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-api-input-timeout-container')!,
min: 0, max: 60, step: 0.5, default: 5.0,
idPrefix: 'css-editor-api-input-timeout',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _apiInputTimeoutWidget;
}
function _ensureCandlelightIntensityWidget(): BindableScalarWidget {
if (!_candlelightIntensityWidget) {
_candlelightIntensityWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-candlelight-intensity-container')!,
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-candlelight-intensity',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _candlelightIntensityWidget;
}
function _ensureCandlelightSpeedWidget(): BindableScalarWidget {
if (!_candlelightSpeedWidget) {
_candlelightSpeedWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-candlelight-speed-container')!,
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-candlelight-speed',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _candlelightSpeedWidget;
}
function _ensureCandlelightWindWidget(): BindableScalarWidget {
if (!_candlelightWindWidget) {
_candlelightWindWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-candlelight-wind-container')!,
min: 0.0, max: 2.0, step: 0.1, default: 0.0,
idPrefix: 'css-editor-candlelight-wind',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _candlelightWindWidget;
}
function _ensureWeatherSpeedWidget(): BindableScalarWidget {
if (!_weatherSpeedWidget) {
_weatherSpeedWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-weather-speed-container')!,
min: 0.1, max: 5, step: 0.1, default: 1.0,
idPrefix: 'css-editor-weather-speed',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _weatherSpeedWidget;
}
function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
if (!_weatherTempInfluenceWidget) {
_weatherTempInfluenceWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-weather-temp-influence-container')!,
min: 0, max: 1, step: 0.05, default: 0.5,
idPrefix: 'css-editor-weather-temp-influence',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(2),
});
}
return _weatherTempInfluenceWidget;
}
function _ensureApiInputInterpolationIconSelect() { function _ensureApiInputInterpolationIconSelect() {
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
@@ -992,13 +1193,8 @@ function _loadAudioState(css: any) {
if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum'); if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
onAudioVizChange(); onAudioVizChange();
const sensitivity = css.sensitivity ?? 1.0; _ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0);
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = sensitivity; _ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3);
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = parseFloat(sensitivity).toFixed(1);
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow'; const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
@@ -1017,10 +1213,8 @@ function _loadAudioState(css: any) {
function _resetAudioState() { function _resetAudioState() {
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum'; (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum';
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum'); if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = 1.0 as any; _ensureAudioSensitivityWidget().setValue(1.0);
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0'; _ensureAudioSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = '0.30';
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow'; (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow'); if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00'; (document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
@@ -1127,7 +1321,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
}, },
api_input: (source) => { api_input: (source) => {
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]); const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
const timeoutVal = (source.timeout ?? 5.0).toFixed(1); const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear'; const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
return ` return `
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}"> <span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
@@ -1153,7 +1347,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
}, },
daylight: (source, { clockBadge }) => { daylight: (source, { clockBadge }) => {
const useRealTime = source.use_real_time; const useRealTime = source.use_real_time;
const speedVal = (source.speed ?? 1.0).toFixed(1); const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
return ` return `
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span> <span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
${clockBadge} ${clockBadge}
@@ -1171,8 +1365,8 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
`; `;
}, },
weather: (source, { clockBadge }) => { weather: (source, { clockBadge }) => {
const speedVal = (source.speed ?? 1.0).toFixed(1); const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1); const tempInfl = bindableValue(source.temperature_influence, 0.5).toFixed(1);
const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id); const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id);
const wsName = ws?.name || '—'; const wsName = ws?.name || '—';
const wsLink = ws const wsLink = ws
@@ -1420,10 +1614,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId; (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId); if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]); (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0; _ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1); _ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false; (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
onEffectPaletteChange(); onEffectPaletteChange();
}, },
@@ -1432,10 +1624,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire'; (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire'); if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000'; (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any; _ensureEffectIntensityWidget().setValue(1.0);
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0'; _ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false; (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
}, },
getPayload(name) { getPayload(name) {
@@ -1443,8 +1633,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name, name,
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), intensity: _ensureEffectIntensityWidget().getValue(),
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), scale: _ensureEffectScaleWidget().getValue(),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked, mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
}; };
// Meteor/comet/bouncing_ball use a color picker // Meteor/comet/bouncing_ball use a color picker
@@ -1468,8 +1658,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name, name,
visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value, visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null, audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value), sensitivity: _ensureAudioSensitivityWidget().getValue(),
smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value), smoothing: _ensureAudioSmoothingWidget().getValue(),
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value), color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value), color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
@@ -1519,17 +1709,14 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
load(css) { load(css) {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
rgbArrayToHex(css.fallback_color || [0, 0, 0]); rgbArrayToHex(css.fallback_color || [0, 0, 0]);
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0; _ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0);
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear'; (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
_ensureApiInputInterpolationIconSelect(); _ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(css.id); _showApiInputEndpoints(css.id);
}, },
reset() { reset() {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000'; (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = 5.0 as any; _ensureApiInputTimeoutWidget().setValue(5.0);
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0';
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear'; (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
_ensureApiInputInterpolationIconSelect(); _ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(null); _showApiInputEndpoints(null);
@@ -1539,7 +1726,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return { return {
name, name,
fallback_color: hexToRgbArray(fbHex), fallback_color: hexToRgbArray(fbHex),
timeout: parseFloat((document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value), timeout: _ensureApiInputTimeoutWidget().getValue(),
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value, interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
}; };
}, },
@@ -1558,7 +1745,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name, name,
os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked, os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500, duration_ms: getNotificationDurationValue(),
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value, default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList, app_filter_list: filterList,
@@ -1602,25 +1789,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
candlelight: { candlelight: {
load(css) { load(css) {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]); (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]);
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = css.intensity ?? 1.0; _ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3; (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = css.speed ?? 1.0; _ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0);
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1); _ensureCandlelightWindWidget().setValue(css.wind_strength ?? 0.0);
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = css.wind_strength ?? 0.0;
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = parseFloat(css.wind_strength ?? 0.0).toFixed(1);
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default'; (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default'); if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
}, },
reset() { reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329'; (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = 1.0 as any; _ensureCandlelightIntensityWidget().setValue(1.0);
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any; (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = 1.0 as any; _ensureCandlelightSpeedWidget().setValue(1.0);
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0'; _ensureCandlelightWindWidget().setValue(0.0);
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = 0.0 as any;
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = '0.0';
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default'; (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default'); if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
}, },
@@ -1628,10 +1809,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return { return {
name, name,
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), intensity: _ensureCandlelightIntensityWidget().getValue(),
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value), speed: _ensureCandlelightSpeedWidget().getValue(),
wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value), wind_strength: _ensureCandlelightWindWidget().getValue(),
candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value, candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
}; };
}, },
@@ -1641,19 +1822,15 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
await weatherSourcesCache.fetch(); await weatherSourcesCache.fetch();
_populateWeatherSourceDropdown(); _populateWeatherSourceDropdown();
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || ''; (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0; _ensureWeatherSpeedWidget().setValue(css.speed ?? 1.0);
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1); _ensureWeatherTempInfluenceWidget().setValue(css.temperature_influence ?? 0.5);
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5;
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2);
}, },
async reset() { async reset() {
await weatherSourcesCache.fetch(); await weatherSourcesCache.fetch();
_populateWeatherSourceDropdown(); _populateWeatherSourceDropdown();
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = ''; (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any; _ensureWeatherSpeedWidget().setValue(1.0);
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0'; _ensureWeatherTempInfluenceWidget().setValue(0.5);
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any;
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50';
}, },
getPayload(name) { getPayload(name) {
const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value; const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
@@ -1664,8 +1841,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return { return {
name, name,
weather_source_id: wsId, weather_source_id: wsId,
speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value), speed: _ensureWeatherSpeedWidget().getValue(),
temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value), temperature_influence: _ensureWeatherTempInfluenceWidget().getValue(),
}; };
}, },
}, },
@@ -1702,21 +1879,18 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
load(css) { load(css) {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average'; (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average'); if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3; _ensureSmoothingWidget().setValue(css.smoothing);
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
}, },
reset() { reset() {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average'; (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average'); if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any; _ensureSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
}, },
getPayload(name) { getPayload(name) {
return { return {
name, name,
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value, interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value), smoothing: _ensureSmoothingWidget().getValue(),
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0, led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
}; };
}, },
@@ -1726,22 +1900,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
sourceSelect.value = css.picture_source_id || ''; sourceSelect.value = css.picture_source_id || '';
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average'; (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average'); if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3; _ensureSmoothingWidget().setValue(css.smoothing);
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
}, },
reset() { reset() {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average'; (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average'); if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any; _ensureSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
}, },
getPayload(name) { getPayload(name) {
return { return {
name, name,
picture_source_id: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value, picture_source_id: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value, interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value), smoothing: _ensureSmoothingWidget().getValue(),
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0, led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
}; };
}, },
@@ -1775,11 +1946,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
columns: 1, columns: 1,
}); });
const smoothing = css.smoothing ?? 0.3; _ensureKcSmoothingWidget().setValue(css.smoothing ?? 0.3);
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing; _ensureKcBrightnessWidget().setValue(css.brightness ?? 1.0);
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = css.brightness ?? 1.0;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = parseFloat(css.brightness ?? 1.0).toFixed(2);
// Load rectangles // Load rectangles
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r })); _kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
_renderKCRectSummary(); _renderKCRectSummary();
@@ -1810,10 +1978,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
columns: 1, columns: 1,
}); });
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any; _ensureKcSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30'; _ensureKcBrightnessWidget().setValue(1.0);
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = '1.00';
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }]; _kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
_renderKCRectSummary(); _renderKCRectSummary();
}, },
@@ -1832,8 +1998,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
picture_source_id: psId, picture_source_id: psId,
rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })), rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })),
interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value, interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value,
smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value), smoothing: _ensureKcSmoothingWidget().getValue(),
brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value), brightness: _ensureKcBrightnessWidget().getValue(),
}; };
}, },
}, },
@@ -7,11 +7,13 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon } from '../core/icons.ts'; import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { bindableSourceId, bindableValue } from '../types.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`; const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -25,6 +27,10 @@ let _brightnessVsEntitySelect: EntitySelect | null = null;
let _mappingEntitySelects: EntitySelect[] = []; let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = []; let _editorCssSources: any[] = [];
let _cachedHAEntities: any[] = []; // fetched from selected HA source 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;
class HALightEditorModal extends Modal { class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); } constructor() { super('ha-light-editor-modal'); }
@@ -34,6 +40,10 @@ class HALightEditorModal extends Modal {
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; } if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; } if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; }
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; }
_destroyMappingEntitySelects(); _destroyMappingEntitySelects();
} }
@@ -42,8 +52,10 @@ class HALightEditorModal extends Modal {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value, name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).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, css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value, update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value, transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0',
mappings: _getMappingsJSON(), mappings: _getMappingsJSON(),
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []), tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
}; };
@@ -77,7 +89,7 @@ function _getEntityItems() {
.map((e: any) => ({ .map((e: any) => ({
value: e.entity_id, value: e.entity_id,
label: e.friendly_name || e.entity_id, label: e.friendly_name || e.entity_id,
icon: _icon(P.lightbulb), icon: getHAEntityIcon(e),
desc: e.state || '', desc: e.state || '',
})); }));
} }
@@ -201,6 +213,60 @@ export function removeHALightMapping(btn: HTMLElement): void {
row.remove(); row.remove();
} }
// ── Bindable scalar widgets ──
function _ensureUpdateRateWidget(): BindableScalarWidget {
if (!_updateRateWidget) {
_updateRateWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-update-rate-container')!,
min: 0.5, max: 5.0, step: 0.1, default: 2.0,
idPrefix: 'ha-light-editor-update-rate',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _updateRateWidget;
}
function _ensureTransitionWidget(): BindableScalarWidget {
if (!_transitionWidget) {
_transitionWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-transition-container')!,
min: 0.0, max: 10.0, step: 0.1, default: 0.5,
idPrefix: 'ha-light-editor-transition',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _transitionWidget;
}
function _ensureColorToleranceWidget(): BindableScalarWidget {
if (!_colorToleranceWidget) {
_colorToleranceWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-color-tolerance-container')!,
min: 0, max: 50, step: 1, default: 5,
idPrefix: 'ha-light-editor-color-tolerance',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _colorToleranceWidget;
}
function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget {
if (!_minBrightnessThresholdWidget) {
_minBrightnessThresholdWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-min-brightness-threshold-container')!,
min: 0, max: 254, step: 1, default: 0,
idPrefix: 'ha-light-editor-min-brightness-threshold',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _minBrightnessThresholdWidget;
}
// ── Show / Close ── // ── Show / Close ──
export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> { export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> {
@@ -256,10 +322,10 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || ''; (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
haSelect.value = editData.ha_source_id || ''; haSelect.value = editData.ha_source_id || '';
cssSelect.value = editData.color_strip_source_id || ''; cssSelect.value = editData.color_strip_source_id || '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = String(editData.update_rate ?? 2.0); _ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1); _ensureTransitionWidget().setValue(editData.transition ?? 0.5);
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5); _ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1); _ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || ''; (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
// Fetch entities from the selected HA source before loading mappings // Fetch entities from the selected HA source before loading mappings
@@ -270,10 +336,10 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
mappings.forEach((m: any) => addHALightMapping(m)); mappings.forEach((m: any) => addHALightMapping(m));
} else { } else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = ''; (document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0'; _ensureUpdateRateWidget().setValue(2.0);
document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0'; _ensureTransitionWidget().setValue(0.5);
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5'; _ensureColorToleranceWidget().setValue(5);
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5'; _ensureMinBrightnessThresholdWidget().setValue(0);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = ''; (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
// Fetch entities from the first HA source // Fetch entities from the first HA source
@@ -313,7 +379,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement; const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` + bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
_cachedValueSources.map((vs: any) => _cachedValueSources.map((vs: any) =>
`<option value="${vs.id}" ${vs.id === (editData?.brightness_value_source_id || '') ? 'selected' : ''}>${escapeHtml(vs.name)}</option>` `<option value="${vs.id}" ${vs.id === bindableSourceId(editData?.brightness) ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
).join(''); ).join('');
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy(); if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_brightnessVsEntitySelect = new EntitySelect({ _brightnessVsEntitySelect = new EntitySelect({
@@ -346,9 +412,10 @@ export async function saveHALightEditor(): Promise<void> {
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim(); 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 haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value; const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0; const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0;
const transitionRaw = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value); const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
const transition = isNaN(transitionRaw) ? 0.5 : transitionRaw; const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5;
const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0;
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null; const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
if (!name) { if (!name) {
@@ -369,10 +436,12 @@ export async function saveHALightEditor(): Promise<void> {
name, name,
ha_source_id: haSourceId, ha_source_id: haSourceId,
color_strip_source_id: cssSourceId, color_strip_source_id: cssSourceId,
brightness_value_source_id: brightnessVsId, brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
ha_light_mappings: mappings, ha_light_mappings: mappings,
update_rate: updateRate, update_rate: updateRate,
transition, transition,
color_tolerance: colorTolerance,
min_brightness_threshold: minBrightnessThreshold,
description, description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [], tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
}; };
@@ -450,7 +519,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
: ''; : '';
// Brightness value source // Brightness value source
const bvsId = target.brightness_value_source_id || ''; const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null; const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
return wrapCard({ return wrapCard({
@@ -36,6 +36,8 @@ import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts'; import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash, updateTabBadge } from './tabs.ts'; import { updateSubTabHash, updateTabBadge } from './tabs.ts';
import type { OutputTarget } from '../types.ts'; import type { OutputTarget } from '../types.ts';
import { bindableSourceId, bindableValue } from '../types.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
// createPatternTemplateCard is imported via window.* to avoid circular deps // createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab) // (pattern-templates.js calls window.loadTargetsTab)
@@ -142,6 +144,8 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
// --- Editor state --- // --- Editor state ---
let _editorCssSources: any[] = []; // populated when editor opens let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null; let _targetTagsInput: TagInput | null = null;
let _fpsWidget: BindableScalarWidget | null = null;
let _thresholdWidget: BindableScalarWidget | null = null;
class TargetEditorModal extends Modal { class TargetEditorModal extends Modal {
constructor() { constructor() {
@@ -155,8 +159,8 @@ class TargetEditorModal extends Modal {
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value, protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value, css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value, brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
brightness_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value, brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0',
fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value, fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value, keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked, adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []), tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
@@ -329,6 +333,32 @@ function _ensureProtocolIconSelect() {
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 }); _protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
} }
function _ensureFpsWidget(): BindableScalarWidget {
if (!_fpsWidget) {
_fpsWidget = new BindableScalarWidget({
container: document.getElementById('target-editor-fps-container')!,
min: 1, max: 90, step: 1, default: 30,
idPrefix: 'target-editor-fps',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _fpsWidget;
}
function _ensureThresholdWidget(): BindableScalarWidget {
if (!_thresholdWidget) {
_thresholdWidget = new BindableScalarWidget({
container: document.getElementById('target-editor-brightness-threshold-container')!,
min: 0, max: 254, step: 1, default: 0,
idPrefix: 'target-editor-brightness-threshold',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _thresholdWidget;
}
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) { export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
try { try {
// Load devices, CSS sources, and value sources for dropdowns // Load devices, CSS sources, and value sources for dropdowns
@@ -365,56 +395,46 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-id') as HTMLInputElement).value = target.id; (document.getElementById('target-editor-id') as HTMLInputElement).value = target.id;
(document.getElementById('target-editor-name') as HTMLInputElement).value = target.name; (document.getElementById('target-editor-name') as HTMLInputElement).value = target.name;
deviceSelect.value = target.device_id || ''; deviceSelect.value = target.device_id || '';
const fps = target.fps ?? 30; _ensureFpsWidget().setValue(target.fps ?? 30);
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0; (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0; (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`; (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
const thresh = target.min_brightness_threshold ?? 0; _ensureThresholdWidget().setValue(target.min_brightness_threshold ?? 0);
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = thresh;
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = thresh;
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
_populateCssDropdown(target.color_strip_source_id || ''); _populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(target.brightness_value_source_id || ''); _populateBrightnessVsDropdown(bindableSourceId(target.brightness));
} else if (cloneData) { } else if (cloneData) {
// Cloning — create mode but pre-filled from clone data // Cloning — create mode but pre-filled from clone data
_editorTags = cloneData.tags || []; _editorTags = cloneData.tags || [];
(document.getElementById('target-editor-id') as HTMLInputElement).value = ''; (document.getElementById('target-editor-id') as HTMLInputElement).value = '';
(document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)'; (document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
deviceSelect.value = cloneData.device_id || ''; deviceSelect.value = cloneData.device_id || '';
const fps = cloneData.fps ?? 30; _ensureFpsWidget().setValue(cloneData.fps ?? 30);
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0; (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0; (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`; (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
const cloneThresh = cloneData.min_brightness_threshold ?? 0; _ensureThresholdWidget().setValue(cloneData.min_brightness_threshold ?? 0);
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = cloneThresh;
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = cloneThresh;
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
_populateCssDropdown(cloneData.color_strip_source_id || ''); _populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || ''); _populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness));
} else { } else {
// Creating new target // Creating new target
(document.getElementById('target-editor-id') as HTMLInputElement).value = ''; (document.getElementById('target-editor-id') as HTMLInputElement).value = '';
(document.getElementById('target-editor-name') as HTMLInputElement).value = ''; (document.getElementById('target-editor-name') as HTMLInputElement).value = '';
(document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any; _ensureFpsWidget().setValue(30);
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30';
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any; (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0'; (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0';
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`; (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any; _ensureThresholdWidget().setValue(0);
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0';
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
@@ -472,6 +492,8 @@ export async function closeTargetEditorModal() {
export function forceCloseTargetEditorModal() { export function forceCloseTargetEditorModal() {
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; } if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
targetEditorModal.forceClose(); targetEditorModal.forceClose();
} }
@@ -486,11 +508,11 @@ export async function saveTargetEditor() {
return; return;
} }
const fps = parseInt((document.getElementById('target-editor-fps') as HTMLInputElement).value) || 30; const fps = _fpsWidget ? _fpsWidget.getValue() : 30;
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value; const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;
const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value; const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
const minBrightnessThreshold = parseInt((document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value) || 0; const minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0;
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked; const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value; const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
@@ -499,7 +521,7 @@ export async function saveTargetEditor() {
name, name,
device_id: deviceId, device_id: deviceId,
color_strip_source_id: colorStripSourceId, color_strip_source_id: colorStripSourceId,
brightness_value_source_id: brightnessVsId, brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
min_brightness_threshold: minBrightnessThreshold, min_brightness_threshold: minBrightnessThreshold,
fps, fps,
keepalive_interval: standbyInterval, keepalive_interval: standbyInterval,
@@ -958,7 +980,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
const cssId = target.color_strip_source_id || ''; const cssId = target.color_strip_source_id || '';
const cssSummary = _cssSourceName(cssId, colorStripSourceMap); const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
const bvsId = target.brightness_value_source_id || ''; const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null; const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
// Determine if overlay is available (picture-based CSS) // Determine if overlay is available (picture-based CSS)
@@ -990,11 +1012,11 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
</div> </div>
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span> <span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span> <span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${bindableValue(target.fps, 30)}</span>
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span> <span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span> <span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''} ${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${(target.min_brightness_threshold ?? 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${target.min_brightness_threshold} → off</span>` : ''} ${bindableValue(target.min_brightness_threshold, 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${bindableValue(target.min_brightness_threshold, 0)} → off</span>` : ''}
</div> </div>
${renderTagChips(target.tags)} ${renderTagChips(target.tags)}
<div class="card-content"> <div class="card-content">
+37 -17
View File
@@ -5,6 +5,25 @@
* snake_case to match the JSON payloads no camelCase transformation is done. * snake_case to match the JSON payloads no camelCase transformation is done.
*/ */
// ── Bindable Float ───────────────────────────────────────────
// A scalar that is either a static value (plain number) or bound to a value source (dict).
export type BindableFloat = number | { value: number; source_id: string };
/** Extract the static value from a BindableFloat. */
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
if (b === undefined || b === null) return fallback;
if (typeof b === 'number') return b;
return b.value ?? fallback;
}
/** Extract the source_id from a BindableFloat (empty string = not bound). */
export function bindableSourceId(b: BindableFloat | undefined): string {
if (b === undefined || b === null) return '';
if (typeof b === 'number') return '';
return b.source_id ?? '';
}
// ── Device ──────────────────────────────────────────────────── // ── Device ────────────────────────────────────────────────────
export type DeviceType = export type DeviceType =
@@ -59,27 +78,27 @@ export interface OutputTarget {
// LED target fields // LED target fields
device_id?: string; device_id?: string;
color_strip_source_id?: string; color_strip_source_id?: string;
brightness_value_source_id?: string; brightness?: BindableFloat;
fps?: number; fps?: BindableFloat;
keepalive_interval?: number; keepalive_interval?: number;
state_check_interval?: number; state_check_interval?: number;
min_brightness_threshold?: number; min_brightness_threshold?: BindableFloat;
adaptive_fps?: boolean; adaptive_fps?: boolean;
protocol?: string; protocol?: string;
// HA light target fields // HA light target fields
ha_source_id?: string; ha_source_id?: string;
ha_light_mappings?: HALightMapping[]; ha_light_mappings?: HALightMapping[];
update_rate?: number; update_rate?: BindableFloat;
ha_transition?: number; transition?: BindableFloat;
color_tolerance?: number; color_tolerance?: BindableFloat;
} }
export interface HALightMapping { export interface HALightMapping {
entity_id: string; entity_id: string;
led_start: number; led_start: number;
led_end: number; led_end: number;
brightness_scale: number; brightness_scale: BindableFloat;
} }
// ── Color Strip Source ──────────────────────────────────────── // ── Color Strip Source ────────────────────────────────────────
@@ -165,7 +184,7 @@ export interface ColorStripSource {
// Picture // Picture
picture_source_id?: string; picture_source_id?: string;
smoothing?: number; smoothing?: BindableFloat;
interpolation_mode?: string; interpolation_mode?: string;
calibration?: Calibration; calibration?: Calibration;
@@ -181,8 +200,8 @@ export interface ColorStripSource {
// Effect // Effect
effect_type?: string; effect_type?: string;
palette?: string; palette?: string;
intensity?: number; intensity?: BindableFloat;
scale?: number; scale?: BindableFloat;
mirror?: boolean; mirror?: boolean;
// Composite // Composite
@@ -194,16 +213,16 @@ export interface ColorStripSource {
// Audio // Audio
visualization_mode?: string; visualization_mode?: string;
audio_source_id?: string; audio_source_id?: string;
sensitivity?: number; sensitivity?: BindableFloat;
color_peak?: number[]; color_peak?: number[];
// Animation // Animation
animation?: AnimationConfig; animation?: AnimationConfig;
speed?: number; speed?: BindableFloat;
// API Input // API Input
fallback_color?: number[]; fallback_color?: number[];
timeout?: number; timeout?: BindableFloat;
interpolation?: string; interpolation?: string;
// Notification // Notification
@@ -214,6 +233,7 @@ export interface ColorStripSource {
app_filter_mode?: string; app_filter_mode?: string;
app_filter_list?: string[]; app_filter_list?: string[];
os_listener?: boolean; os_listener?: boolean;
sound_volume?: BindableFloat;
// Daylight // Daylight
use_real_time?: boolean; use_real_time?: boolean;
@@ -221,6 +241,7 @@ export interface ColorStripSource {
// Candlelight // Candlelight
num_candles?: number; num_candles?: number;
wind_strength?: BindableFloat;
// Processed // Processed
input_source_id?: string; input_source_id?: string;
@@ -228,12 +249,11 @@ export interface ColorStripSource {
// Weather // Weather
weather_source_id?: string; weather_source_id?: string;
temperature_influence?: number; temperature_influence?: BindableFloat;
// Key Colors // Key Colors
rectangles?: KeyColorRectangle[]; rectangles?: KeyColorRectangle[];
brightness?: number; brightness?: BindableFloat;
brightness_value_source_id?: string;
} }
// ── Pattern Template ────────────────────────────────────────── // ── Pattern Template ──────────────────────────────────────────
@@ -370,7 +390,7 @@ export interface TargetSnapshot {
target_id: string; target_id: string;
running: boolean; running: boolean;
color_strip_source_id: string; color_strip_source_id: string;
brightness_value_source_id: string; brightness?: BindableFloat;
fps: number; fps: number;
} }
@@ -1,5 +1,7 @@
{ {
"app.title": "LED Grab", "app.title": "LED Grab",
"bindable.none": "None (static value)",
"bindable.toggle": "Toggle value source binding",
"app.version": "Version:", "app.version": "Version:",
"app.api_docs": "API Documentation", "app.api_docs": "API Documentation",
"app.connection_lost": "Server unreachable", "app.connection_lost": "Server unreachable",
@@ -1331,9 +1333,12 @@
"audio_source.parent.hint": "Multichannel source to extract a channel from", "audio_source.parent.hint": "Multichannel source to extract a channel from",
"audio_source.channel": "Channel:", "audio_source.channel": "Channel:",
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source", "audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
"audio_source.channel.mono": "Mono (L+R mix)", "audio_source.channel.mono": "Mono",
"audio_source.channel.mono.desc": "L+R mix",
"audio_source.channel.left": "Left", "audio_source.channel.left": "Left",
"audio_source.channel.left.desc": "Left channel only",
"audio_source.channel.right": "Right", "audio_source.channel.right": "Right",
"audio_source.channel.right.desc": "Right channel only",
"audio_source.description": "Description (optional):", "audio_source.description": "Description (optional):",
"audio_source.description.placeholder": "Describe this audio source...", "audio_source.description.placeholder": "Describe this audio source...",
"audio_source.description.hint": "Optional notes about this audio source", "audio_source.description.hint": "Optional notes about this audio source",
@@ -1808,6 +1813,10 @@
"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.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:", "ha_light.transition": "Transition:",
"ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).", "ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).",
"ha_light.color_tolerance": "Color Tolerance:",
"ha_light.color_tolerance.hint": "Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.",
"ha_light.min_brightness_threshold": "Min Brightness Threshold:",
"ha_light.min_brightness_threshold.hint": "Effective output brightness below this value turns lights off completely (0 = disabled).",
"ha_light.mappings": "Light Mappings:", "ha_light.mappings": "Light Mappings:",
"ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.", "ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
"ha_light.mappings.add": "Add Mapping", "ha_light.mappings.add": "Add Mapping",
@@ -1830,6 +1839,9 @@
"automations.condition.home_assistant.state": "State:", "automations.condition.home_assistant.state": "State:",
"automations.condition.home_assistant.match_mode": "Match Mode:", "automations.condition.home_assistant.match_mode": "Match Mode:",
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state", "automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
"automations.condition.ha.match_mode.exact.desc": "State must match exactly",
"automations.condition.ha.match_mode.contains.desc": "State must contain the text",
"automations.condition.ha.match_mode.regex.desc": "State must match the regex pattern",
"color_strip.clock": "Sync Clock:", "color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.", "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
"graph.title": "Graph", "graph.title": "Graph",
@@ -1,5 +1,11 @@
{ {
"app.title": "LED Grab", "app.title": "LED Grab",
"bindable.none": "Нет (статическое значение)",
"bindable.toggle": "Привязка к источнику значений",
"ha_light.color_tolerance": "Допуск цвета:",
"ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.",
"ha_light.min_brightness_threshold": "Мин. порог яркости:",
"ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).",
"app.version": "Версия:", "app.version": "Версия:",
"app.api_docs": "Документация API", "app.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен", "app.connection_lost": "Сервер недоступен",
@@ -1,5 +1,11 @@
{ {
"app.title": "LED Grab", "app.title": "LED Grab",
"bindable.none": "无(静态值)",
"bindable.toggle": "切换值源绑定",
"ha_light.color_tolerance": "色彩容差:",
"ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。",
"ha_light.min_brightness_threshold": "最低亮度阈值:",
"ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。",
"app.version": "版本:", "app.version": "版本:",
"app.api_docs": "API 文档", "app.api_docs": "API 文档",
"app.connection_lost": "服务器不可达", "app.connection_lost": "服务器不可达",
@@ -0,0 +1,115 @@
"""BindableFloat — a scalar property that can be static or bound to a value source.
Any numeric property (brightness, smoothing, intensity, speed, etc.) can use
BindableFloat instead of a plain float. When source_id is empty the static
value is used; when set, the runtime resolves the current value from the
corresponding ValueStream.
Serialisation is backward-compatible:
unbound plain float (``0.3``)
bound dict (``{"value": 0.3, "source_id": "vs_abc12345"}``)
``from_raw`` accepts both shapes, so old JSON files "just work".
"""
from dataclasses import dataclass
@dataclass
class BindableFloat:
"""A scalar that is either a static value or driven by a ValueSource."""
value: float = 0.0
source_id: str = "" # empty → use static value
# -- serialisation --
def to_dict(self):
"""Serialize: plain float when unbound, dict when bound."""
if not self.source_id:
return self.value
return {"value": self.value, "source_id": self.source_id}
@classmethod
def from_raw(cls, data, *, default: float = 0.0) -> "BindableFloat":
"""Deserialize from either a plain number or a dict.
Also handles the legacy ``brightness_value_source_id`` migration
when called with the *legacy_source_id* helper below.
"""
if data is None:
return cls(value=default)
if isinstance(data, (int, float)):
return cls(value=float(data))
if isinstance(data, dict):
return cls(
value=float(data.get("value", default)),
source_id=data.get("source_id") or "",
)
return cls(value=default)
@classmethod
def from_legacy(
cls,
data,
legacy_source_id: str = "",
*,
default: float = 0.0,
) -> "BindableFloat":
"""Migrate from the old separate-field pattern.
Old format::
{"brightness": 1.0, "brightness_value_source_id": "vs_abc"}
New format::
{"brightness": {"value": 1.0, "source_id": "vs_abc"}}
If *data* is already a dict with ``source_id``, it takes precedence
(new format). Otherwise *legacy_source_id* is folded in.
"""
if isinstance(data, dict) and "source_id" in data:
return cls.from_raw(data, default=default)
value = float(data) if isinstance(data, (int, float)) else default
return cls(value=value, source_id=legacy_source_id or "")
# -- update helpers --
def apply_update(self, raw) -> "BindableFloat":
"""Return a new BindableFloat from an update payload.
Accepts:
plain number sets value, clears source_id
dict sets both
None returns self unchanged
"""
if raw is None:
return self
if isinstance(raw, (int, float)):
return BindableFloat(value=float(raw), source_id=self.source_id)
if isinstance(raw, dict):
return BindableFloat(
value=float(raw.get("value", self.value)),
source_id=raw.get("source_id", self.source_id),
)
return self
@property
def is_bound(self) -> bool:
return bool(self.source_id)
def bfloat(v, default: float = 0.0) -> float:
"""Extract the static float from a value that may be BindableFloat or plain number.
Useful in processing code that reads source model properties which may
be either a plain ``float`` (legacy / unbound) or a ``BindableFloat``.
"""
if isinstance(v, BindableFloat):
return v.value
if isinstance(v, (int, float)):
return float(v)
if hasattr(v, "value"):
return float(v.value)
return default
@@ -26,6 +26,7 @@ from wled_controller.core.capture.calibration import (
calibration_from_dict, calibration_from_dict,
calibration_to_dict, calibration_to_dict,
) )
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.utils import resolve_ref from wled_controller.storage.utils import resolve_ref
@@ -151,7 +152,7 @@ def _parse_picture_fields(data: dict) -> dict:
) )
return dict( return dict(
fps=data.get("fps") or 30, fps=data.get("fps") or 30,
smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3, smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
interpolation_mode=data.get("interpolation_mode") or "average", interpolation_mode=data.get("interpolation_mode") or "average",
calibration=calibration, calibration=calibration,
led_count=data.get("led_count") or 0, led_count=data.get("led_count") or 0,
@@ -161,7 +162,7 @@ def _parse_picture_fields(data: dict) -> dict:
def _picture_base_to_dict(source, d: dict) -> dict: def _picture_base_to_dict(source, d: dict) -> dict:
"""Populate dict with fields common to both picture source types.""" """Populate dict with fields common to both picture source types."""
d["fps"] = source.fps d["fps"] = source.fps
d["smoothing"] = source.smoothing d["smoothing"] = source.smoothing.to_dict()
d["interpolation_mode"] = source.interpolation_mode d["interpolation_mode"] = source.interpolation_mode
d["calibration"] = calibration_to_dict(source.calibration) d["calibration"] = calibration_to_dict(source.calibration)
d["led_count"] = source.led_count d["led_count"] = source.led_count
@@ -173,7 +174,7 @@ def _apply_picture_update(source, **kwargs) -> None:
if kwargs.get("fps") is not None: if kwargs.get("fps") is not None:
source.fps = kwargs["fps"] source.fps = kwargs["fps"]
if kwargs.get("smoothing") is not None: if kwargs.get("smoothing") is not None:
source.smoothing = kwargs["smoothing"] source.smoothing = source.smoothing.apply_update(kwargs["smoothing"])
if kwargs.get("interpolation_mode") is not None: if kwargs.get("interpolation_mode") is not None:
source.interpolation_mode = kwargs["interpolation_mode"] source.interpolation_mode = kwargs["interpolation_mode"]
if kwargs.get("calibration") is not None: if kwargs.get("calibration") is not None:
@@ -197,7 +198,7 @@ class PictureColorStripSource(ColorStripSource):
picture_source_id: str = "" picture_source_id: str = ""
fps: int = 30 fps: int = 30
smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full) smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
interpolation_mode: str = "average" # "average" | "median" | "dominant" interpolation_mode: str = "average" # "average" | "median" | "dominant"
calibration: CalibrationConfig = field( calibration: CalibrationConfig = field(
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left") default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -234,7 +235,7 @@ class PictureColorStripSource(ColorStripSource):
tags=None, tags=None,
picture_source_id="", picture_source_id="",
fps=30, fps=30,
smoothing=0.3, smoothing=None,
interpolation_mode="average", interpolation_mode="average",
calibration=None, calibration=None,
led_count=0, led_count=0,
@@ -253,7 +254,7 @@ class PictureColorStripSource(ColorStripSource):
tags=tags or [], tags=tags or [],
picture_source_id=picture_source_id, picture_source_id=picture_source_id,
fps=fps, fps=fps,
smoothing=smoothing, smoothing=BindableFloat.from_raw(smoothing, default=0.3),
interpolation_mode=interpolation_mode, interpolation_mode=interpolation_mode,
calibration=calibration, calibration=calibration,
led_count=led_count, led_count=led_count,
@@ -281,7 +282,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
return True return True
fps: int = 30 fps: int = 30
smoothing: float = 0.3 smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
interpolation_mode: str = "average" interpolation_mode: str = "average"
calibration: CalibrationConfig = field( calibration: CalibrationConfig = field(
default_factory=lambda: CalibrationConfig(mode="advanced") default_factory=lambda: CalibrationConfig(mode="advanced")
@@ -311,7 +312,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
clock_id=None, clock_id=None,
tags=None, tags=None,
fps=30, fps=30,
smoothing=0.3, smoothing=None,
interpolation_mode="average", interpolation_mode="average",
calibration=None, calibration=None,
led_count=0, led_count=0,
@@ -329,7 +330,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
fps=fps, fps=fps,
smoothing=smoothing, smoothing=BindableFloat.from_raw(smoothing, default=0.3),
interpolation_mode=interpolation_mode, interpolation_mode=interpolation_mode,
calibration=calibration, calibration=calibration,
led_count=led_count, led_count=led_count,
@@ -593,8 +594,8 @@ class EffectColorStripSource(ColorStripSource):
color: list = field( color: list = field(
default_factory=lambda: [255, 80, 0] default_factory=lambda: [255, 80, 0]
) # [R,G,B] for meteor/comet/bouncing_ball head ) # [R,G,B] for meteor/comet/bouncing_ball head
intensity: float = 1.0 # effect-specific intensity (0.1-2.0) intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
scale: float = 1.0 # spatial scale / zoom (0.5-5.0) scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
mirror: bool = False # bounce mode (meteor/comet) mirror: bool = False # bounce mode (meteor/comet)
custom_palette: Optional[list] = None # legacy [[pos, R, G, B], ...] custom palette stops custom_palette: Optional[list] = None # legacy [[pos, R, G, B], ...] custom palette stops
@@ -604,8 +605,8 @@ class EffectColorStripSource(ColorStripSource):
d["palette"] = self.palette d["palette"] = self.palette
d["gradient_id"] = self.gradient_id d["gradient_id"] = self.gradient_id
d["color"] = list(self.color) d["color"] = list(self.color)
d["intensity"] = self.intensity d["intensity"] = self.intensity.to_dict()
d["scale"] = self.scale d["scale"] = self.scale.to_dict()
d["mirror"] = self.mirror d["mirror"] = self.mirror
d["custom_palette"] = self.custom_palette d["custom_palette"] = self.custom_palette
return d return d
@@ -621,8 +622,8 @@ class EffectColorStripSource(ColorStripSource):
palette=data.get("palette") or "fire", palette=data.get("palette") or "fire",
gradient_id=data.get("gradient_id"), gradient_id=data.get("gradient_id"),
color=color, color=color,
intensity=float(data.get("intensity") or 1.0), intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
scale=float(data.get("scale") or 1.0), scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
mirror=bool(data.get("mirror", False)), mirror=bool(data.get("mirror", False)),
custom_palette=data.get("custom_palette"), custom_palette=data.get("custom_palette"),
) )
@@ -643,8 +644,8 @@ class EffectColorStripSource(ColorStripSource):
palette="fire", palette="fire",
gradient_id=None, gradient_id=None,
color=None, color=None,
intensity=1.0, intensity=None,
scale=1.0, scale=None,
mirror=False, mirror=False,
custom_palette=None, custom_palette=None,
**_kwargs, **_kwargs,
@@ -663,8 +664,8 @@ class EffectColorStripSource(ColorStripSource):
palette=palette or "fire", palette=palette or "fire",
gradient_id=gradient_id, gradient_id=gradient_id,
color=rgb, color=rgb,
intensity=float(intensity) if intensity else 1.0, intensity=BindableFloat.from_raw(intensity, default=1.0),
scale=float(scale) if scale else 1.0, scale=BindableFloat.from_raw(scale, default=1.0),
mirror=bool(mirror), mirror=bool(mirror),
custom_palette=custom_palette if isinstance(custom_palette, list) else None, custom_palette=custom_palette if isinstance(custom_palette, list) else None,
) )
@@ -680,9 +681,9 @@ class EffectColorStripSource(ColorStripSource):
if color is not None and isinstance(color, list) and len(color) == 3: if color is not None and isinstance(color, list) and len(color) == 3:
self.color = color self.color = color
if kwargs.get("intensity") is not None: if kwargs.get("intensity") is not None:
self.intensity = float(kwargs["intensity"]) self.intensity = self.intensity.apply_update(kwargs["intensity"])
if kwargs.get("scale") is not None: if kwargs.get("scale") is not None:
self.scale = float(kwargs["scale"]) self.scale = self.scale.apply_update(kwargs["scale"])
if kwargs.get("mirror") is not None: if kwargs.get("mirror") is not None:
self.mirror = bool(kwargs["mirror"]) self.mirror = bool(kwargs["mirror"])
if "custom_palette" in kwargs: if "custom_palette" in kwargs:
@@ -701,8 +702,8 @@ class AudioColorStripSource(ColorStripSource):
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
audio_source_id: str = "" # references a MonoAudioSource audio_source_id: str = "" # references a MonoAudioSource
sensitivity: float = 1.0 # gain multiplier (0.1-5.0) sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
smoothing: float = 0.3 # temporal smoothing (0.0-1.0) smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
palette: str = "rainbow" # legacy palette name (kept for migration) palette: str = "rainbow" # legacy palette name (kept for migration)
gradient_id: Optional[str] = None # references a Gradient entity (preferred) gradient_id: Optional[str] = None # references a Gradient entity (preferred)
color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter
@@ -714,8 +715,8 @@ class AudioColorStripSource(ColorStripSource):
d = super().to_dict() d = super().to_dict()
d["visualization_mode"] = self.visualization_mode d["visualization_mode"] = self.visualization_mode
d["audio_source_id"] = self.audio_source_id d["audio_source_id"] = self.audio_source_id
d["sensitivity"] = self.sensitivity d["sensitivity"] = self.sensitivity.to_dict()
d["smoothing"] = self.smoothing d["smoothing"] = self.smoothing.to_dict()
d["palette"] = self.palette d["palette"] = self.palette
d["gradient_id"] = self.gradient_id d["gradient_id"] = self.gradient_id
d["color"] = list(self.color) d["color"] = list(self.color)
@@ -734,8 +735,8 @@ class AudioColorStripSource(ColorStripSource):
source_type="audio", source_type="audio",
visualization_mode=data.get("visualization_mode") or "spectrum", visualization_mode=data.get("visualization_mode") or "spectrum",
audio_source_id=data.get("audio_source_id") or "", audio_source_id=data.get("audio_source_id") or "",
sensitivity=float(data.get("sensitivity") or 1.0), sensitivity=BindableFloat.from_raw(data.get("sensitivity"), default=1.0),
smoothing=float(data.get("smoothing") or 0.3), smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
palette=data.get("palette") or "rainbow", palette=data.get("palette") or "rainbow",
gradient_id=data.get("gradient_id"), gradient_id=data.get("gradient_id"),
color=color, color=color,
@@ -758,8 +759,8 @@ class AudioColorStripSource(ColorStripSource):
tags=None, tags=None,
visualization_mode="spectrum", visualization_mode="spectrum",
audio_source_id="", audio_source_id="",
sensitivity=1.0, sensitivity=None,
smoothing=0.3, smoothing=None,
palette="rainbow", palette="rainbow",
gradient_id=None, gradient_id=None,
color=None, color=None,
@@ -781,8 +782,8 @@ class AudioColorStripSource(ColorStripSource):
tags=tags or [], tags=tags or [],
visualization_mode=visualization_mode or "spectrum", visualization_mode=visualization_mode or "spectrum",
audio_source_id=audio_source_id or "", audio_source_id=audio_source_id or "",
sensitivity=float(sensitivity) if sensitivity else 1.0, sensitivity=BindableFloat.from_raw(sensitivity, default=1.0),
smoothing=float(smoothing) if smoothing else 0.3, smoothing=BindableFloat.from_raw(smoothing, default=0.3),
palette=palette or "rainbow", palette=palette or "rainbow",
gradient_id=gradient_id, gradient_id=gradient_id,
color=rgb, color=rgb,
@@ -798,9 +799,9 @@ class AudioColorStripSource(ColorStripSource):
if audio_source_id is not None: if audio_source_id is not None:
self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id) self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id)
if kwargs.get("sensitivity") is not None: if kwargs.get("sensitivity") is not None:
self.sensitivity = float(kwargs["sensitivity"]) self.sensitivity = self.sensitivity.apply_update(kwargs["sensitivity"])
if kwargs.get("smoothing") is not None: if kwargs.get("smoothing") is not None:
self.smoothing = float(kwargs["smoothing"]) self.smoothing = self.smoothing.apply_update(kwargs["smoothing"])
if kwargs.get("palette") is not None: if kwargs.get("palette") is not None:
self.palette = kwargs["palette"] self.palette = kwargs["palette"]
if "gradient_id" in kwargs: if "gradient_id" in kwargs:
@@ -963,13 +964,13 @@ class ApiInputColorStripSource(ColorStripSource):
""" """
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B] fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
timeout: float = 5.0 # seconds before reverting to fallback timeout: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
interpolation: str = "linear" # none | linear | nearest interpolation: str = "linear" # none | linear | nearest
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["fallback_color"] = list(self.fallback_color) d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout d["timeout"] = self.timeout.to_dict()
d["interpolation"] = self.interpolation d["interpolation"] = self.interpolation
return d return d
@@ -984,7 +985,7 @@ class ApiInputColorStripSource(ColorStripSource):
**common, **common,
source_type="api_input", source_type="api_input",
fallback_color=fallback_color, fallback_color=fallback_color,
timeout=float(data.get("timeout") or 5.0), timeout=BindableFloat.from_raw(data.get("timeout"), default=5.0),
interpolation=interpolation, interpolation=interpolation,
) )
@@ -1017,7 +1018,7 @@ class ApiInputColorStripSource(ColorStripSource):
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
fallback_color=fb, fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0, timeout=BindableFloat.from_raw(timeout, default=5.0),
interpolation=interp, interpolation=interp,
) )
@@ -1030,7 +1031,7 @@ class ApiInputColorStripSource(ColorStripSource):
): ):
self.fallback_color = fallback_color self.fallback_color = fallback_color
if kwargs.get("timeout") is not None: if kwargs.get("timeout") is not None:
self.timeout = float(kwargs["timeout"]) self.timeout = self.timeout.apply_update(kwargs["timeout"])
interpolation = kwargs.get("interpolation") interpolation = kwargs.get("interpolation")
if interpolation in ("none", "linear", "nearest"): if interpolation in ("none", "linear", "nearest"):
self.interpolation = interpolation self.interpolation = interpolation
@@ -1048,14 +1049,14 @@ class NotificationColorStripSource(ColorStripSource):
""" """
notification_effect: str = "flash" # flash | pulse | sweep notification_effect: str = "flash" # flash | pulse | sweep
duration_ms: int = 1500 # effect duration in milliseconds duration_ms: BindableFloat = field(default_factory=lambda: BindableFloat(1500.0))
default_color: str = "#FFFFFF" # hex color for notifications without app match default_color: str = "#FFFFFF" # hex color for notifications without app match
app_colors: dict = field(default_factory=dict) # app name -> hex color app_colors: dict = field(default_factory=dict) # app name -> hex color
app_filter_mode: str = "off" # off | whitelist | blacklist app_filter_mode: str = "off" # off | whitelist | blacklist
app_filter_list: list = field(default_factory=list) # app names for filter app_filter_list: list = field(default_factory=list) # app names for filter
os_listener: bool = False # whether to listen for OS notifications os_listener: bool = False # whether to listen for OS notifications
sound_asset_id: Optional[str] = None # global notification sound (asset ID) sound_asset_id: Optional[str] = None # global notification sound (asset ID)
sound_volume: float = 1.0 # global volume 0.0-1.0 sound_volume: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
app_sounds: dict = field( app_sounds: dict = field(
default_factory=dict default_factory=dict
) # app name -> {"sound_asset_id": str|None, "volume": float|None} ) # app name -> {"sound_asset_id": str|None, "volume": float|None}
@@ -1063,14 +1064,14 @@ class NotificationColorStripSource(ColorStripSource):
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["notification_effect"] = self.notification_effect d["notification_effect"] = self.notification_effect
d["duration_ms"] = self.duration_ms d["duration_ms"] = self.duration_ms.to_dict()
d["default_color"] = self.default_color d["default_color"] = self.default_color
d["app_colors"] = dict(self.app_colors) d["app_colors"] = dict(self.app_colors)
d["app_filter_mode"] = self.app_filter_mode d["app_filter_mode"] = self.app_filter_mode
d["app_filter_list"] = list(self.app_filter_list) d["app_filter_list"] = list(self.app_filter_list)
d["os_listener"] = self.os_listener d["os_listener"] = self.os_listener
d["sound_asset_id"] = self.sound_asset_id d["sound_asset_id"] = self.sound_asset_id
d["sound_volume"] = self.sound_volume d["sound_volume"] = self.sound_volume.to_dict()
d["app_sounds"] = dict(self.app_sounds) d["app_sounds"] = dict(self.app_sounds)
return d return d
@@ -1084,14 +1085,14 @@ class NotificationColorStripSource(ColorStripSource):
**common, **common,
source_type="notification", source_type="notification",
notification_effect=data.get("notification_effect") or "flash", notification_effect=data.get("notification_effect") or "flash",
duration_ms=int(data.get("duration_ms") or 1500), duration_ms=BindableFloat.from_raw(data.get("duration_ms"), default=1500.0),
default_color=data.get("default_color") or "#FFFFFF", default_color=data.get("default_color") or "#FFFFFF",
app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {}, app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
app_filter_mode=data.get("app_filter_mode") or "off", app_filter_mode=data.get("app_filter_mode") or "off",
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [], app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
os_listener=bool(data.get("os_listener", False)), os_listener=bool(data.get("os_listener", False)),
sound_asset_id=data.get("sound_asset_id"), sound_asset_id=data.get("sound_asset_id"),
sound_volume=float(data.get("sound_volume", 1.0)), sound_volume=BindableFloat.from_raw(data.get("sound_volume"), default=1.0),
app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {}, app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {},
) )
@@ -1129,14 +1130,14 @@ class NotificationColorStripSource(ColorStripSource):
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
notification_effect=notification_effect or "flash", notification_effect=notification_effect or "flash",
duration_ms=int(duration_ms) if duration_ms is not None else 1500, duration_ms=BindableFloat.from_raw(duration_ms, default=1500.0),
default_color=default_color or "#FFFFFF", default_color=default_color or "#FFFFFF",
app_colors=app_colors if isinstance(app_colors, dict) else {}, app_colors=app_colors if isinstance(app_colors, dict) else {},
app_filter_mode=app_filter_mode or "off", app_filter_mode=app_filter_mode or "off",
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [], app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
os_listener=bool(os_listener) if os_listener is not None else False, os_listener=bool(os_listener) if os_listener is not None else False,
sound_asset_id=sound_asset_id, sound_asset_id=sound_asset_id,
sound_volume=float(sound_volume) if sound_volume is not None else 1.0, sound_volume=BindableFloat.from_raw(sound_volume, default=1.0),
app_sounds=app_sounds if isinstance(app_sounds, dict) else {}, app_sounds=app_sounds if isinstance(app_sounds, dict) else {},
) )
@@ -1144,7 +1145,7 @@ class NotificationColorStripSource(ColorStripSource):
if kwargs.get("notification_effect") is not None: if kwargs.get("notification_effect") is not None:
self.notification_effect = kwargs["notification_effect"] self.notification_effect = kwargs["notification_effect"]
if kwargs.get("duration_ms") is not None: if kwargs.get("duration_ms") is not None:
self.duration_ms = int(kwargs["duration_ms"]) self.duration_ms = self.duration_ms.apply_update(kwargs["duration_ms"])
if kwargs.get("default_color") is not None: if kwargs.get("default_color") is not None:
self.default_color = kwargs["default_color"] self.default_color = kwargs["default_color"]
app_colors = kwargs.get("app_colors") app_colors = kwargs.get("app_colors")
@@ -1160,7 +1161,7 @@ class NotificationColorStripSource(ColorStripSource):
if "sound_asset_id" in kwargs: if "sound_asset_id" in kwargs:
self.sound_asset_id = kwargs["sound_asset_id"] self.sound_asset_id = kwargs["sound_asset_id"]
if kwargs.get("sound_volume") is not None: if kwargs.get("sound_volume") is not None:
self.sound_volume = float(kwargs["sound_volume"]) self.sound_volume = self.sound_volume.apply_update(kwargs["sound_volume"])
app_sounds = kwargs.get("app_sounds") app_sounds = kwargs.get("app_sounds")
if app_sounds is not None and isinstance(app_sounds, dict): if app_sounds is not None and isinstance(app_sounds, dict):
self.app_sounds = app_sounds self.app_sounds = app_sounds
@@ -1180,14 +1181,14 @@ class DaylightColorStripSource(ColorStripSource):
a full 24-hour cycle plays (1.0 = 4 minutes per full cycle). a full 24-hour cycle plays (1.0 = 4 minutes per full cycle).
""" """
speed: float = 1.0 # cycle speed (ignored when use_real_time) speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
use_real_time: bool = False # use actual time of day use_real_time: bool = False # use actual time of day
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90) latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
longitude: float = 0.0 # longitude for solar position (-180..180) longitude: float = 0.0 # longitude for solar position (-180..180)
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["speed"] = self.speed d["speed"] = self.speed.to_dict()
d["use_real_time"] = self.use_real_time d["use_real_time"] = self.use_real_time
d["latitude"] = self.latitude d["latitude"] = self.latitude
d["longitude"] = self.longitude d["longitude"] = self.longitude
@@ -1199,9 +1200,10 @@ class DaylightColorStripSource(ColorStripSource):
return cls( return cls(
**common, **common,
source_type="daylight", source_type="daylight",
speed=float(data.get("speed") or 1.0), speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
use_real_time=bool(data.get("use_real_time", False)), use_real_time=bool(data.get("use_real_time", False)),
latitude=float(data.get("latitude") or 50.0), latitude=float(data.get("latitude") or 50.0),
longitude=float(data.get("longitude") or 0.0),
) )
@classmethod @classmethod
@@ -1231,7 +1233,7 @@ class DaylightColorStripSource(ColorStripSource):
description=description, description=description,
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
speed=float(speed) if speed is not None else 1.0, speed=BindableFloat.from_raw(speed, default=1.0),
use_real_time=bool(use_real_time) if use_real_time is not None else False, use_real_time=bool(use_real_time) if use_real_time is not None else False,
latitude=float(latitude) if latitude is not None else 50.0, latitude=float(latitude) if latitude is not None else 50.0,
longitude=float(longitude) if longitude is not None else 0.0, longitude=float(longitude) if longitude is not None else 0.0,
@@ -1239,7 +1241,7 @@ class DaylightColorStripSource(ColorStripSource):
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
if kwargs.get("speed") is not None: if kwargs.get("speed") is not None:
self.speed = float(kwargs["speed"]) self.speed = self.speed.apply_update(kwargs["speed"])
if kwargs.get("use_real_time") is not None: if kwargs.get("use_real_time") is not None:
self.use_real_time = bool(kwargs["use_real_time"]) self.use_real_time = bool(kwargs["use_real_time"])
if kwargs.get("latitude") is not None: if kwargs.get("latitude") is not None:
@@ -1258,19 +1260,19 @@ class CandlelightColorStripSource(ColorStripSource):
""" """
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B] color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
intensity: float = 1.0 # flicker intensity (0.1-2.0) intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
num_candles: int = 3 # number of independent candle sources num_candles: int = 3 # number of independent candle sources
speed: float = 1.0 # flicker speed multiplier speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
wind_strength: float = 0.0 # wind effect (0.0-2.0) wind_strength: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
candle_type: str = "default" # default | taper | votive | bonfire candle_type: str = "default" # default | taper | votive | bonfire
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["color"] = list(self.color) d["color"] = list(self.color)
d["intensity"] = self.intensity d["intensity"] = self.intensity.to_dict()
d["num_candles"] = self.num_candles d["num_candles"] = self.num_candles
d["speed"] = self.speed d["speed"] = self.speed.to_dict()
d["wind_strength"] = self.wind_strength d["wind_strength"] = self.wind_strength.to_dict()
d["candle_type"] = self.candle_type d["candle_type"] = self.candle_type
return d return d
@@ -1282,9 +1284,11 @@ class CandlelightColorStripSource(ColorStripSource):
**common, **common,
source_type="candlelight", source_type="candlelight",
color=color, color=color,
intensity=float(data.get("intensity") or 1.0), intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
num_candles=int(data.get("num_candles") or 3), num_candles=int(data.get("num_candles") or 3),
speed=float(data.get("speed") or 1.0), speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
wind_strength=BindableFloat.from_raw(data.get("wind_strength"), default=0.0),
candle_type=data.get("candle_type") or "default",
) )
@classmethod @classmethod
@@ -1300,7 +1304,7 @@ class CandlelightColorStripSource(ColorStripSource):
clock_id=None, clock_id=None,
tags=None, tags=None,
color=None, color=None,
intensity=1.0, intensity=None,
num_candles=None, num_candles=None,
speed=None, speed=None,
wind_strength=None, wind_strength=None,
@@ -1318,10 +1322,10 @@ class CandlelightColorStripSource(ColorStripSource):
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
color=rgb, color=rgb,
intensity=float(intensity) if intensity else 1.0, intensity=BindableFloat.from_raw(intensity, default=1.0),
num_candles=int(num_candles) if num_candles is not None else 3, num_candles=int(num_candles) if num_candles is not None else 3,
speed=float(speed) if speed is not None else 1.0, speed=BindableFloat.from_raw(speed, default=1.0),
wind_strength=float(wind_strength) if wind_strength is not None else 0.0, wind_strength=BindableFloat.from_raw(wind_strength, default=0.0),
candle_type=( candle_type=(
candle_type candle_type
if candle_type in {"default", "taper", "votive", "bonfire"} if candle_type in {"default", "taper", "votive", "bonfire"}
@@ -1334,13 +1338,13 @@ class CandlelightColorStripSource(ColorStripSource):
if color is not None and isinstance(color, list) and len(color) == 3: if color is not None and isinstance(color, list) and len(color) == 3:
self.color = color self.color = color
if kwargs.get("intensity") is not None: if kwargs.get("intensity") is not None:
self.intensity = float(kwargs["intensity"]) self.intensity = self.intensity.apply_update(kwargs["intensity"])
if kwargs.get("num_candles") is not None: if kwargs.get("num_candles") is not None:
self.num_candles = int(kwargs["num_candles"]) self.num_candles = int(kwargs["num_candles"])
if kwargs.get("speed") is not None: if kwargs.get("speed") is not None:
self.speed = float(kwargs["speed"]) self.speed = self.speed.apply_update(kwargs["speed"])
if kwargs.get("wind_strength") is not None: if kwargs.get("wind_strength") is not None:
self.wind_strength = float(kwargs["wind_strength"]) self.wind_strength = self.wind_strength.apply_update(kwargs["wind_strength"])
ct = kwargs.get("candle_type") ct = kwargs.get("candle_type")
if ct is not None and ct in {"default", "taper", "votive", "bonfire"}: if ct is not None and ct in {"default", "taper", "votive", "bonfire"}:
self.candle_type = ct self.candle_type = ct
@@ -1425,14 +1429,14 @@ class WeatherColorStripSource(ColorStripSource):
""" """
weather_source_id: str = "" # reference to WeatherSource entity weather_source_id: str = "" # reference to WeatherSource entity
speed: float = 1.0 # ambient drift animation speed speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
temperature_influence: float = 0.5 # 0.0=none, 1.0=full temp hue shift temperature_influence: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["weather_source_id"] = self.weather_source_id d["weather_source_id"] = self.weather_source_id
d["speed"] = self.speed d["speed"] = self.speed.to_dict()
d["temperature_influence"] = self.temperature_influence d["temperature_influence"] = self.temperature_influence.to_dict()
return d return d
@classmethod @classmethod
@@ -1442,11 +1446,9 @@ class WeatherColorStripSource(ColorStripSource):
**common, **common,
source_type="weather", source_type="weather",
weather_source_id=data.get("weather_source_id", ""), weather_source_id=data.get("weather_source_id", ""),
speed=float(data.get("speed") or 1.0), speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
temperature_influence=float( temperature_influence=BindableFloat.from_raw(
data.get("temperature_influence") data.get("temperature_influence"), default=0.5
if data.get("temperature_influence") is not None
else 0.5
), ),
) )
@@ -1477,10 +1479,8 @@ class WeatherColorStripSource(ColorStripSource):
clock_id=clock_id, clock_id=clock_id,
tags=tags or [], tags=tags or [],
weather_source_id=weather_source_id or "", weather_source_id=weather_source_id or "",
speed=float(speed) if speed is not None else 1.0, speed=BindableFloat.from_raw(speed, default=1.0),
temperature_influence=( temperature_influence=BindableFloat.from_raw(temperature_influence, default=0.5),
float(temperature_influence) if temperature_influence is not None else 0.5
),
) )
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
@@ -1489,9 +1489,11 @@ class WeatherColorStripSource(ColorStripSource):
kwargs["weather_source_id"], self.weather_source_id kwargs["weather_source_id"], self.weather_source_id
) )
if kwargs.get("speed") is not None: if kwargs.get("speed") is not None:
self.speed = float(kwargs["speed"]) self.speed = self.speed.apply_update(kwargs["speed"])
if kwargs.get("temperature_influence") is not None: if kwargs.get("temperature_influence") is not None:
self.temperature_influence = float(kwargs["temperature_influence"]) self.temperature_influence = self.temperature_influence.apply_update(
kwargs["temperature_influence"]
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1536,9 +1538,8 @@ class KeyColorsColorStripSource(ColorStripSource):
picture_source_id: str = "" picture_source_id: str = ""
rectangles: List[KeyColorRectangle] = field(default_factory=list) rectangles: List[KeyColorRectangle] = field(default_factory=list)
interpolation_mode: str = "average" # average, median, dominant interpolation_mode: str = "average" # average, median, dominant
smoothing: float = 0.3 smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
brightness: float = 1.0 brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
brightness_value_source_id: str = ""
@property @property
def sharable(self) -> bool: def sharable(self) -> bool:
@@ -1549,24 +1550,28 @@ class KeyColorsColorStripSource(ColorStripSource):
d["picture_source_id"] = self.picture_source_id d["picture_source_id"] = self.picture_source_id
d["rectangles"] = [r.to_dict() for r in self.rectangles] d["rectangles"] = [r.to_dict() for r in self.rectangles]
d["interpolation_mode"] = self.interpolation_mode d["interpolation_mode"] = self.interpolation_mode
d["smoothing"] = self.smoothing d["smoothing"] = self.smoothing.to_dict()
d["brightness"] = self.brightness d["brightness"] = self.brightness.to_dict()
d["brightness_value_source_id"] = self.brightness_value_source_id
return d return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "KeyColorsColorStripSource": def from_dict(cls, data: dict) -> "KeyColorsColorStripSource":
common = _parse_css_common(data) common = _parse_css_common(data)
rects = [KeyColorRectangle.from_dict(r) for r in data.get("rectangles", [])] rects = [KeyColorRectangle.from_dict(r) for r in data.get("rectangles", [])]
# Legacy migration: brightness_value_source_id → brightness.source_id
brightness = BindableFloat.from_legacy(
data.get("brightness"),
legacy_source_id=data.get("brightness_value_source_id", ""),
default=1.0,
)
return cls( return cls(
**common, **common,
source_type="key_colors", source_type="key_colors",
picture_source_id=data.get("picture_source_id", ""), picture_source_id=data.get("picture_source_id", ""),
rectangles=rects, rectangles=rects,
interpolation_mode=data.get("interpolation_mode", "average"), interpolation_mode=data.get("interpolation_mode", "average"),
smoothing=float(data.get("smoothing") if data.get("smoothing") is not None else 0.3), smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
brightness=float(data.get("brightness") if data.get("brightness") is not None else 1.0), brightness=brightness,
brightness_value_source_id=data.get("brightness_value_source_id", ""),
) )
@classmethod @classmethod
@@ -1592,6 +1597,14 @@ class KeyColorsColorStripSource(ColorStripSource):
rects = [ rects = [
KeyColorRectangle.from_dict(r) if isinstance(r, dict) else r for r in (rectangles or []) KeyColorRectangle.from_dict(r) if isinstance(r, dict) else r for r in (rectangles or [])
] ]
# Handle legacy brightness_value_source_id kwarg
if brightness_value_source_id and not isinstance(brightness, dict):
bright = BindableFloat(
value=float(brightness) if brightness is not None else 1.0,
source_id=brightness_value_source_id,
)
else:
bright = BindableFloat.from_raw(brightness, default=1.0)
return cls( return cls(
id=id, id=id,
name=name, name=name,
@@ -1604,9 +1617,8 @@ class KeyColorsColorStripSource(ColorStripSource):
picture_source_id=picture_source_id or "", picture_source_id=picture_source_id or "",
rectangles=rects, rectangles=rects,
interpolation_mode=interpolation_mode or "average", interpolation_mode=interpolation_mode or "average",
smoothing=float(smoothing) if smoothing is not None else 0.3, smoothing=BindableFloat.from_raw(smoothing, default=0.3),
brightness=float(brightness) if brightness is not None else 1.0, brightness=bright,
brightness_value_source_id=brightness_value_source_id or "",
) )
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
@@ -1622,12 +1634,16 @@ class KeyColorsColorStripSource(ColorStripSource):
if kwargs.get("interpolation_mode") is not None: if kwargs.get("interpolation_mode") is not None:
self.interpolation_mode = kwargs["interpolation_mode"] self.interpolation_mode = kwargs["interpolation_mode"]
if kwargs.get("smoothing") is not None: if kwargs.get("smoothing") is not None:
self.smoothing = float(kwargs["smoothing"]) self.smoothing = self.smoothing.apply_update(kwargs["smoothing"])
if kwargs.get("brightness") is not None: if kwargs.get("brightness") is not None:
self.brightness = float(kwargs["brightness"]) self.brightness = self.brightness.apply_update(kwargs["brightness"])
if kwargs.get("brightness_value_source_id") is not None: if kwargs.get("brightness_value_source_id") is not None:
self.brightness_value_source_id = resolve_ref( # Legacy compat: update just the source_id part
kwargs["brightness_value_source_id"], self.brightness_value_source_id self.brightness = BindableFloat(
value=self.brightness.value,
source_id=resolve_ref(
kwargs["brightness_value_source_id"], self.brightness.source_id
),
) )
@@ -4,12 +4,9 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.output_target import OutputTarget from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref
def _resolve_ref(new_val: str, old_val: str) -> str:
"""Resolve entity reference: empty string clears, non-empty replaces."""
return "" if new_val == "" else (new_val or old_val)
@dataclass @dataclass
@@ -19,14 +16,14 @@ class HALightMapping:
entity_id: str = "" # e.g. "light.living_room" entity_id: str = "" # e.g. "light.living_room"
led_start: int = 0 # start LED index (0-based) led_start: int = 0 # start LED index (0-based)
led_end: int = -1 # end LED index (-1 = last) led_end: int = -1 # end LED index (-1 = last)
brightness_scale: float = 1.0 # 0.0-1.0 multiplier on brightness brightness_scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"entity_id": self.entity_id, "entity_id": self.entity_id,
"led_start": self.led_start, "led_start": self.led_start,
"led_end": self.led_end, "led_end": self.led_end,
"brightness_scale": self.brightness_scale, "brightness_scale": self.brightness_scale.to_dict(),
} }
@classmethod @classmethod
@@ -35,7 +32,7 @@ class HALightMapping:
entity_id=data.get("entity_id", ""), entity_id=data.get("entity_id", ""),
led_start=data.get("led_start", 0), led_start=data.get("led_start", 0),
led_end=data.get("led_end", -1), led_end=data.get("led_end", -1),
brightness_scale=data.get("brightness_scale", 1.0), brightness_scale=BindableFloat.from_raw(data.get("brightness_scale"), default=1.0),
) )
@@ -45,12 +42,12 @@ class HALightOutputTarget(OutputTarget):
ha_source_id: str = "" # references HomeAssistantSource ha_source_id: str = "" # references HomeAssistantSource
color_strip_source_id: str = "" # CSS providing the colors color_strip_source_id: str = "" # CSS providing the colors
brightness_value_source_id: str = "" # dynamic brightness multiplier brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
light_mappings: List[HALightMapping] = field(default_factory=list) light_mappings: List[HALightMapping] = field(default_factory=list)
update_rate: float = 2.0 # Hz (calls per second, 0.5-5.0) update_rate: BindableFloat = field(default_factory=lambda: BindableFloat(2.0))
transition: float = 0.5 # HA transition seconds (smooth fade between colors) transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
min_brightness_threshold: int = 0 # below this brightness → turn off light min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
color_tolerance: int = 5 # skip service call if RGB delta < this color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
def register_with_manager(self, manager) -> None: def register_with_manager(self, manager) -> None:
"""Register this HA light target with the processor manager.""" """Register this HA light target with the processor manager."""
@@ -59,7 +56,7 @@ class HALightOutputTarget(OutputTarget):
target_id=self.id, target_id=self.id,
ha_source_id=self.ha_source_id, ha_source_id=self.ha_source_id,
color_strip_source_id=self.color_strip_source_id, color_strip_source_id=self.color_strip_source_id,
brightness_value_source_id=self.brightness_value_source_id, brightness=self.brightness,
light_mappings=self.light_mappings, light_mappings=self.light_mappings,
update_rate=self.update_rate, update_rate=self.update_rate,
transition=self.transition, transition=self.transition,
@@ -82,6 +79,7 @@ class HALightOutputTarget(OutputTarget):
manager.update_target_settings( manager.update_target_settings(
self.id, self.id,
{ {
"brightness": self.brightness,
"update_rate": self.update_rate, "update_rate": self.update_rate,
"transition": self.transition, "transition": self.transition,
"min_brightness_threshold": self.min_brightness_threshold, "min_brightness_threshold": self.min_brightness_threshold,
@@ -98,6 +96,8 @@ class HALightOutputTarget(OutputTarget):
name=None, name=None,
ha_source_id=None, ha_source_id=None,
color_strip_source_id=None, color_strip_source_id=None,
brightness=None,
# legacy compat
brightness_value_source_id=None, brightness_value_source_id=None,
light_mappings=None, light_mappings=None,
update_rate=None, update_rate=None,
@@ -111,53 +111,66 @@ class HALightOutputTarget(OutputTarget):
"""Apply mutable field updates.""" """Apply mutable field updates."""
super().update_fields(name=name, description=description, tags=tags) super().update_fields(name=name, description=description, tags=tags)
if ha_source_id is not None: if ha_source_id is not None:
self.ha_source_id = _resolve_ref(ha_source_id, self.ha_source_id) self.ha_source_id = resolve_ref(ha_source_id, self.ha_source_id)
if color_strip_source_id is not None: if color_strip_source_id is not None:
self.color_strip_source_id = _resolve_ref( self.color_strip_source_id = resolve_ref(
color_strip_source_id, self.color_strip_source_id color_strip_source_id, self.color_strip_source_id
) )
if brightness_value_source_id is not None: if brightness is not None:
self.brightness_value_source_id = _resolve_ref( self.brightness = self.brightness.apply_update(brightness)
brightness_value_source_id, self.brightness_value_source_id elif brightness_value_source_id is not None:
self.brightness = BindableFloat(
value=self.brightness.value,
source_id=resolve_ref(brightness_value_source_id, self.brightness.source_id),
) )
if light_mappings is not None: if light_mappings is not None:
self.light_mappings = light_mappings self.light_mappings = light_mappings
if update_rate is not None: if update_rate is not None:
self.update_rate = max(0.5, min(5.0, float(update_rate))) self.update_rate = self.update_rate.apply_update(update_rate)
if transition is not None: if transition is not None:
self.transition = max(0.0, min(10.0, float(transition))) self.transition = self.transition.apply_update(transition)
if min_brightness_threshold is not None: if min_brightness_threshold is not None:
self.min_brightness_threshold = int(min_brightness_threshold) self.min_brightness_threshold = self.min_brightness_threshold.apply_update(
min_brightness_threshold
)
if color_tolerance is not None: if color_tolerance is not None:
self.color_tolerance = int(color_tolerance) self.color_tolerance = self.color_tolerance.apply_update(color_tolerance)
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["ha_source_id"] = self.ha_source_id d["ha_source_id"] = self.ha_source_id
d["color_strip_source_id"] = self.color_strip_source_id d["color_strip_source_id"] = self.color_strip_source_id
d["brightness_value_source_id"] = self.brightness_value_source_id d["brightness"] = self.brightness.to_dict()
d["light_mappings"] = [m.to_dict() for m in self.light_mappings] d["light_mappings"] = [m.to_dict() for m in self.light_mappings]
d["update_rate"] = self.update_rate d["update_rate"] = self.update_rate.to_dict()
d["transition"] = self.transition d["transition"] = self.transition.to_dict()
d["min_brightness_threshold"] = self.min_brightness_threshold d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
d["color_tolerance"] = self.color_tolerance d["color_tolerance"] = self.color_tolerance.to_dict()
return d return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "HALightOutputTarget": def from_dict(cls, data: dict) -> "HALightOutputTarget":
mappings = [HALightMapping.from_dict(m) for m in data.get("light_mappings", [])] mappings = [HALightMapping.from_dict(m) for m in data.get("light_mappings", [])]
# Legacy migration: brightness_value_source_id → brightness.source_id
brightness = BindableFloat.from_legacy(
data.get("brightness"),
legacy_source_id=data.get("brightness_value_source_id", ""),
default=1.0,
)
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
target_type="ha_light", target_type="ha_light",
ha_source_id=data.get("ha_source_id", ""), ha_source_id=data.get("ha_source_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""), color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id", ""), brightness=brightness,
light_mappings=mappings, light_mappings=mappings,
update_rate=data.get("update_rate", 2.0), update_rate=BindableFloat.from_raw(data.get("update_rate"), default=2.0),
transition=data.get("transition", 0.5), transition=BindableFloat.from_raw(data.get("transition"), default=0.5),
min_brightness_threshold=data.get("min_brightness_threshold", 0), min_brightness_threshold=BindableFloat.from_raw(
color_tolerance=data.get("color_tolerance", 5), data.get("min_brightness_threshold"), default=0.0
),
color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.0),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=datetime.fromisoformat( created_at=datetime.fromisoformat(
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from wled_controller.storage.base_sqlite_store import BaseSqliteStore from wled_controller.storage.base_sqlite_store import BaseSqliteStore
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.database import Database from wled_controller.storage.database import Database
from wled_controller.storage.output_target import OutputTarget from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.wled_output_target import WledOutputTarget
@@ -39,7 +40,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
target_type: str, target_type: str,
device_id: str = "", device_id: str = "",
color_strip_source_id: str = "", color_strip_source_id: str = "",
brightness_value_source_id: str = "", brightness=None,
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
@@ -51,8 +52,10 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
ha_source_id: str = "", ha_source_id: str = "",
ha_light_mappings: Optional[List[HALightMapping]] = None, ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0, update_rate: float = 2.0,
transition: float = 0.5, transition=None,
color_tolerance: int = 5, color_tolerance: int = 5,
# legacy compat
brightness_value_source_id: str = "",
) -> OutputTarget: ) -> OutputTarget:
"""Create a new output target. """Create a new output target.
@@ -70,6 +73,16 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
target_id = f"pt_{uuid.uuid4().hex[:8]}" target_id = f"pt_{uuid.uuid4().hex[:8]}"
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Resolve brightness to BindableFloat
if isinstance(brightness, BindableFloat):
bright = brightness
elif brightness is not None:
bright = BindableFloat.from_raw(brightness, default=1.0)
elif brightness_value_source_id:
bright = BindableFloat(1.0, source_id=brightness_value_source_id)
else:
bright = BindableFloat(1.0)
if target_type == "led": if target_type == "led":
target: OutputTarget = WledOutputTarget( target: OutputTarget = WledOutputTarget(
id=target_id, id=target_id,
@@ -77,11 +90,13 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
target_type="led", target_type="led",
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
brightness_value_source_id=brightness_value_source_id, brightness=bright,
fps=fps, fps=BindableFloat.from_raw(fps, default=30.0),
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
min_brightness_threshold=min_brightness_threshold, min_brightness_threshold=BindableFloat.from_raw(
min_brightness_threshold, default=0.0
),
adaptive_fps=adaptive_fps, adaptive_fps=adaptive_fps,
protocol=protocol, protocol=protocol,
description=description, description=description,
@@ -89,17 +104,28 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
updated_at=now, updated_at=now,
) )
elif target_type == "ha_light": elif target_type == "ha_light":
# Resolve transition
if isinstance(transition, BindableFloat):
trans = transition
elif transition is not None:
trans = BindableFloat.from_raw(transition, default=0.5)
else:
trans = BindableFloat(0.5)
target = HALightOutputTarget( target = HALightOutputTarget(
id=target_id, id=target_id,
name=name, name=name,
target_type="ha_light", target_type="ha_light",
ha_source_id=ha_source_id, ha_source_id=ha_source_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
brightness=bright,
light_mappings=ha_light_mappings or [], light_mappings=ha_light_mappings or [],
update_rate=update_rate, update_rate=BindableFloat.from_raw(update_rate, default=2.0),
transition=transition, transition=trans,
min_brightness_threshold=min_brightness_threshold, min_brightness_threshold=BindableFloat.from_raw(
color_tolerance=color_tolerance, min_brightness_threshold, default=0.0
),
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0),
description=description, description=description,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -117,23 +143,25 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
def update_target( def update_target(
self, self,
target_id: str, target_id: str,
name: Optional[str] = None, name=None,
device_id: Optional[str] = None, device_id=None,
color_strip_source_id: Optional[str] = None, color_strip_source_id=None,
brightness_value_source_id: Optional[str] = None, brightness=None,
fps: Optional[int] = None, fps=None,
keepalive_interval: Optional[float] = None, keepalive_interval=None,
state_check_interval: Optional[int] = None, state_check_interval=None,
min_brightness_threshold: Optional[int] = None, min_brightness_threshold=None,
adaptive_fps: Optional[bool] = None, adaptive_fps=None,
protocol: Optional[str] = None, protocol=None,
description: Optional[str] = None, description=None,
tags: Optional[List[str]] = None, tags=None,
ha_source_id: Optional[str] = None, ha_source_id=None,
ha_light_mappings: Optional[List[HALightMapping]] = None, ha_light_mappings=None,
update_rate: Optional[float] = None, update_rate=None,
transition: Optional[float] = None, transition=None,
color_tolerance: Optional[int] = None, color_tolerance=None,
# legacy compat
brightness_value_source_id=None,
) -> OutputTarget: ) -> OutputTarget:
"""Update an output target. """Update an output target.
@@ -155,6 +183,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
name=name, name=name,
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
brightness=brightness,
brightness_value_source_id=brightness_value_source_id, brightness_value_source_id=brightness_value_source_id,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
@@ -1,9 +1,10 @@
"""LED output target — sends color strip sources to an LED device.""" """LED output target — sends color strip sources to an LED device."""
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.output_target import OutputTarget from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref from wled_controller.storage.utils import resolve_ref
@@ -16,13 +17,13 @@ class WledOutputTarget(OutputTarget):
device_id: str = "" device_id: str = ""
color_strip_source_id: str = "" color_strip_source_id: str = ""
brightness_value_source_id: str = "" brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
fps: int = 30 # target send FPS (1-90) fps: BindableFloat = field(default_factory=lambda: BindableFloat(30.0))
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
min_brightness_threshold: int = 0 # brightness below this → 0 (disabled when 0) min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API) protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
def register_with_manager(self, manager) -> None: def register_with_manager(self, manager) -> None:
"""Register this WLED target with the processor manager.""" """Register this WLED target with the processor manager."""
@@ -31,58 +32,84 @@ class WledOutputTarget(OutputTarget):
target_id=self.id, target_id=self.id,
device_id=self.device_id, device_id=self.device_id,
color_strip_source_id=self.color_strip_source_id, color_strip_source_id=self.color_strip_source_id,
brightness=self.brightness,
fps=self.fps, fps=self.fps,
keepalive_interval=self.keepalive_interval, keepalive_interval=self.keepalive_interval,
state_check_interval=self.state_check_interval, state_check_interval=self.state_check_interval,
brightness_value_source_id=self.brightness_value_source_id,
min_brightness_threshold=self.min_brightness_threshold, min_brightness_threshold=self.min_brightness_threshold,
adaptive_fps=self.adaptive_fps, adaptive_fps=self.adaptive_fps,
protocol=self.protocol, protocol=self.protocol,
) )
def sync_with_manager(self, manager, *, settings_changed: bool, def sync_with_manager(
css_changed: bool = False, self,
brightness_vs_changed: bool = False) -> None: manager,
"""Push changed fields to the processor manager. *,
settings_changed: bool,
NOTE: device_changed is handled separately in the route because css_changed: bool = False,
update_target_device is async (stop swap start cycle). brightness_changed: bool = False,
""" ) -> None:
"""Push changed fields to the processor manager."""
if settings_changed: if settings_changed:
manager.update_target_settings(self.id, { manager.update_target_settings(
"fps": self.fps, self.id,
"keepalive_interval": self.keepalive_interval, {
"state_check_interval": self.state_check_interval, "fps": self.fps,
"min_brightness_threshold": self.min_brightness_threshold, "keepalive_interval": self.keepalive_interval,
"adaptive_fps": self.adaptive_fps, "state_check_interval": self.state_check_interval,
}) "min_brightness_threshold": self.min_brightness_threshold,
"adaptive_fps": self.adaptive_fps,
},
)
if css_changed: if css_changed:
manager.update_target_css(self.id, self.color_strip_source_id) manager.update_target_css(self.id, self.color_strip_source_id)
if brightness_vs_changed: if brightness_changed:
manager.update_target_brightness_vs(self.id, self.brightness_value_source_id) manager.update_target_brightness(self.id, self.brightness)
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, def update_fields(
brightness_value_source_id=None, self,
fps=None, keepalive_interval=None, state_check_interval=None, *,
min_brightness_threshold=None, adaptive_fps=None, protocol=None, name=None,
description=None, tags: Optional[List[str]] = None, device_id=None,
**_kwargs) -> None: color_strip_source_id=None,
brightness=None,
# legacy compat
brightness_value_source_id=None,
fps=None,
keepalive_interval=None,
state_check_interval=None,
min_brightness_threshold=None,
adaptive_fps=None,
protocol=None,
description=None,
tags: Optional[List[str]] = None,
**_kwargs,
) -> None:
"""Apply mutable field updates for WLED targets.""" """Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description, tags=tags) super().update_fields(name=name, description=description, tags=tags)
if device_id is not None: if device_id is not None:
self.device_id = resolve_ref(device_id, self.device_id) self.device_id = resolve_ref(device_id, self.device_id)
if color_strip_source_id is not None: 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 = resolve_ref(
if brightness_value_source_id is not None: color_strip_source_id, self.color_strip_source_id
self.brightness_value_source_id = resolve_ref(brightness_value_source_id, self.brightness_value_source_id) )
if brightness is not None:
self.brightness = self.brightness.apply_update(brightness)
elif brightness_value_source_id is not None:
self.brightness = BindableFloat(
value=self.brightness.value,
source_id=resolve_ref(brightness_value_source_id, self.brightness.source_id),
)
if fps is not None: if fps is not None:
self.fps = fps self.fps = self.fps.apply_update(fps)
if keepalive_interval is not None: if keepalive_interval is not None:
self.keepalive_interval = keepalive_interval self.keepalive_interval = keepalive_interval
if state_check_interval is not None: if state_check_interval is not None:
self.state_check_interval = state_check_interval self.state_check_interval = state_check_interval
if min_brightness_threshold is not None: if min_brightness_threshold is not None:
self.min_brightness_threshold = min_brightness_threshold self.min_brightness_threshold = self.min_brightness_threshold.apply_update(
min_brightness_threshold
)
if adaptive_fps is not None: if adaptive_fps is not None:
self.adaptive_fps = adaptive_fps self.adaptive_fps = adaptive_fps
if protocol is not None: if protocol is not None:
@@ -97,11 +124,11 @@ class WledOutputTarget(OutputTarget):
d = super().to_dict() d = super().to_dict()
d["device_id"] = self.device_id d["device_id"] = self.device_id
d["color_strip_source_id"] = self.color_strip_source_id d["color_strip_source_id"] = self.color_strip_source_id
d["brightness_value_source_id"] = self.brightness_value_source_id d["brightness"] = self.brightness.to_dict()
d["fps"] = self.fps d["fps"] = self.fps.to_dict()
d["keepalive_interval"] = self.keepalive_interval d["keepalive_interval"] = self.keepalive_interval
d["state_check_interval"] = self.state_check_interval d["state_check_interval"] = self.state_check_interval
d["min_brightness_threshold"] = self.min_brightness_threshold d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
d["adaptive_fps"] = self.adaptive_fps d["adaptive_fps"] = self.adaptive_fps
d["protocol"] = self.protocol d["protocol"] = self.protocol
return d return d
@@ -109,21 +136,33 @@ class WledOutputTarget(OutputTarget):
@classmethod @classmethod
def from_dict(cls, data: dict) -> "WledOutputTarget": def from_dict(cls, data: dict) -> "WledOutputTarget":
"""Create from dictionary.""" """Create from dictionary."""
# Legacy migration: brightness_value_source_id → brightness.source_id
brightness = BindableFloat.from_legacy(
data.get("brightness"),
legacy_source_id=data.get("brightness_value_source_id", ""),
default=1.0,
)
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
target_type="led", target_type="led",
device_id=data.get("device_id", ""), device_id=data.get("device_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""), color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id") or "", brightness=brightness,
fps=data.get("fps", 30), fps=BindableFloat.from_raw(data.get("fps"), default=30.0),
keepalive_interval=data.get("keepalive_interval", 1.0), keepalive_interval=data.get("keepalive_interval", 1.0),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
min_brightness_threshold=data.get("min_brightness_threshold", 0), min_brightness_threshold=BindableFloat.from_raw(
data.get("min_brightness_threshold"), default=0.0
),
adaptive_fps=data.get("adaptive_fps", False), adaptive_fps=data.get("adaptive_fps", False),
protocol=data.get("protocol", "ddp"), protocol=data.get("protocol", "ddp"),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), created_at=datetime.fromisoformat(
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), data.get("created_at", datetime.now(timezone.utc).isoformat())
),
updated_at=datetime.fromisoformat(
data.get("updated_at", datetime.now(timezone.utc).isoformat())
),
) )
@@ -67,14 +67,13 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-smoothing"> <label>
<span data-i18n="color_strip.smoothing">Smoothing:</span> <span data-i18n="color_strip.smoothing">Smoothing:</span>
<span id="css-editor-smoothing-value">0.30</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)"> <div id="css-editor-smoothing-container"></div>
</div> </div>
</div> </div>
@@ -176,28 +175,24 @@
<div id="css-editor-effect-intensity-group" class="form-group"> <div id="css-editor-effect-intensity-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-effect-intensity"> <label>
<span data-i18n="color_strip.effect.intensity">Intensity:</span> <span data-i18n="color_strip.effect.intensity">Intensity:</span>
<span id="css-editor-effect-intensity-val">1.0</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.intensity.hint">Effect-specific intensity.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.effect.intensity.hint">Effect-specific intensity.</small>
<input type="range" id="css-editor-effect-intensity" min="0.1" max="2.0" step="0.1" value="1.0" <div id="css-editor-effect-intensity-container"></div>
oninput="document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div id="css-editor-effect-scale-group" class="form-group" style="display:none"> <div id="css-editor-effect-scale-group" class="form-group" style="display:none">
<div class="label-row"> <div class="label-row">
<label for="css-editor-effect-scale"> <label>
<span data-i18n="color_strip.effect.scale">Scale:</span> <span data-i18n="color_strip.effect.scale">Scale:</span>
<span id="css-editor-effect-scale-val">1.0</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.scale.hint">Spatial zoom level.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.effect.scale.hint">Spatial zoom level.</small>
<input type="range" id="css-editor-effect-scale" min="0.5" max="5.0" step="0.1" value="1.0" <div id="css-editor-effect-scale-container"></div>
oninput="document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div id="css-editor-effect-mirror-group" class="form-group" style="display:none"> <div id="css-editor-effect-mirror-group" class="form-group" style="display:none">
@@ -267,28 +262,24 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-audio-sensitivity"> <label>
<span data-i18n="color_strip.audio.sensitivity">Sensitivity:</span> <span data-i18n="color_strip.audio.sensitivity">Sensitivity:</span>
<span id="css-editor-audio-sensitivity-val">1.0</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.sensitivity.hint">Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.audio.sensitivity.hint">Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.</small>
<input type="range" id="css-editor-audio-sensitivity" min="0.1" max="5.0" step="0.1" value="1.0" <div id="css-editor-audio-sensitivity-container"></div>
oninput="document.getElementById('css-editor-audio-sensitivity-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-audio-smoothing"> <label>
<span data-i18n="color_strip.audio.smoothing">Smoothing:</span> <span data-i18n="color_strip.audio.smoothing">Smoothing:</span>
<span id="css-editor-audio-smoothing-val">0.30</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.smoothing.hint">Temporal smoothing between frames. Higher values produce smoother but slower-reacting visuals.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.audio.smoothing.hint">Temporal smoothing between frames. Higher values produce smoother but slower-reacting visuals.</small>
<input type="range" id="css-editor-audio-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" <div id="css-editor-audio-smoothing-container"></div>
oninput="document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(this.value).toFixed(2)">
</div> </div>
<div id="css-editor-audio-palette-group" class="form-group"> <div id="css-editor-audio-palette-group" class="form-group">
@@ -353,15 +344,13 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-api-input-timeout"> <label>
<span data-i18n="color_strip.api_input.timeout">Timeout (seconds):</span> <span data-i18n="color_strip.api_input.timeout">Timeout (seconds):</span>
<span id="css-editor-api-input-timeout-val">5.0</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.timeout.hint">How long to wait for new color data before reverting to the fallback color.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.api_input.timeout.hint">How long to wait for new color data before reverting to the fallback color.</small>
<input type="range" id="css-editor-api-input-timeout" min="0" max="60" step="0.5" value="5.0" <div id="css-editor-api-input-timeout-container"></div>
oninput="document.getElementById('css-editor-api-input-timeout-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -418,15 +407,13 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-notification-duration"> <label>
<span data-i18n="color_strip.notification.duration">Duration (ms):</span> <span data-i18n="color_strip.notification.duration">Duration (ms):</span>
<span id="css-editor-notification-duration-val">1500</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.duration.hint">How long the notification effect plays, in milliseconds.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.notification.duration.hint">How long the notification effect plays, in milliseconds.</small>
<input type="range" id="css-editor-notification-duration" min="100" max="10000" step="100" value="1500" <div id="css-editor-notification-duration-container"></div>
oninput="document.getElementById('css-editor-notification-duration-val').textContent = this.value">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -573,12 +560,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-candlelight-intensity"><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span> <span id="css-editor-candlelight-intensity-val">1.0</span></label> <label><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0" <div id="css-editor-candlelight-intensity-container"></div>
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -590,21 +576,19 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-candlelight-speed"><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span> <span id="css-editor-candlelight-speed-val">1.0</span></label> <label><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0" <div id="css-editor-candlelight-speed-container"></div>
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-candlelight-wind"><span data-i18n="color_strip.candlelight.wind">Wind:</span> <span id="css-editor-candlelight-wind-val">0.0</span></label> <label><span data-i18n="color_strip.candlelight.wind">Wind:</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.wind.hint">Wind simulation strength. Higher values create correlated gusts across all candles.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.wind.hint">Wind simulation strength. Higher values create correlated gusts across all candles.</small>
<input type="range" id="css-editor-candlelight-wind" min="0.0" max="2.0" step="0.1" value="0.0" <div id="css-editor-candlelight-wind-container"></div>
oninput="document.getElementById('css-editor-candlelight-wind-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -636,21 +620,19 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-weather-speed"><span data-i18n="color_strip.weather.speed">Animation Speed:</span> <span id="css-editor-weather-speed-val">1.0</span></label> <label><span data-i18n="color_strip.weather.speed">Animation Speed:</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.speed.hint">Speed of the ambient color drift animation</small> <small class="input-hint" style="display:none" data-i18n="color_strip.weather.speed.hint">Speed of the ambient color drift animation</small>
<input type="range" id="css-editor-weather-speed" min="0.1" max="5" step="0.1" value="1.0" <div id="css-editor-weather-speed-container"></div>
oninput="document.getElementById('css-editor-weather-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-weather-temp-influence"><span data-i18n="color_strip.weather.temperature_influence">Temperature Influence:</span> <span id="css-editor-weather-temp-val">0.5</span></label> <label><span data-i18n="color_strip.weather.temperature_influence">Temperature Influence:</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.temperature_influence.hint">How much the current temperature shifts the color palette warm/cool. 0 = pure condition colors, 1 = strong shift.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.weather.temperature_influence.hint">How much the current temperature shifts the color palette warm/cool. 0 = pure condition colors, 1 = strong shift.</small>
<input type="range" id="css-editor-weather-temp-influence" min="0" max="1" step="0.05" value="0.5" <div id="css-editor-weather-temp-influence-container"></div>
oninput="document.getElementById('css-editor-weather-temp-val').textContent = parseFloat(this.value).toFixed(2)">
</div> </div>
</div> </div>
@@ -697,17 +679,15 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-kc-smoothing"><span data-i18n="color_strip.key_colors.smoothing">Smoothing:</span> <span id="css-editor-kc-smoothing-val">0.30</span></label> <label><span data-i18n="color_strip.key_colors.smoothing">Smoothing:</span></label>
</div> </div>
<input type="range" id="css-editor-kc-smoothing" min="0" max="1" step="0.05" value="0.3" <div id="css-editor-kc-smoothing-container"></div>
oninput="document.getElementById('css-editor-kc-smoothing-val').textContent = parseFloat(this.value).toFixed(2)">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-kc-brightness"><span data-i18n="color_strip.key_colors.brightness">Brightness:</span> <span id="css-editor-kc-brightness-val">1.00</span></label> <label><span data-i18n="color_strip.key_colors.brightness">Brightness:</span></label>
</div> </div>
<input type="range" id="css-editor-kc-brightness" min="0" max="1" step="0.05" value="1.0" <div id="css-editor-kc-brightness-container"></div>
oninput="document.getElementById('css-editor-kc-brightness-val').textContent = parseFloat(this.value).toFixed(2)">
</div> </div>
</div> </div>
@@ -39,29 +39,25 @@
<!-- Update Rate --> <!-- Update Rate -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="ha-light-editor-update-rate"> <label>
<span data-i18n="ha_light.update_rate">Update Rate:</span> <span data-i18n="ha_light.update_rate">Update Rate:</span>
<span id="ha-light-editor-update-rate-display">2.0</span> Hz
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="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.</small> <small class="input-hint" style="display:none" data-i18n="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.</small>
<input type="range" id="ha-light-editor-update-rate" min="0.5" max="5.0" step="0.5" value="2.0" <div id="ha-light-editor-update-rate-container"></div>
oninput="document.getElementById('ha-light-editor-update-rate-display').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<!-- Transition --> <!-- Transition -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="ha-light-editor-transition"> <label>
<span data-i18n="ha_light.transition">Transition:</span> <span data-i18n="ha_light.transition">Transition:</span>
<span id="ha-light-editor-transition-display">0.5</span>s
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="ha_light.transition.hint">Smooth fade duration between colors (HA transition parameter). Higher values give smoother but slower transitions.</small> <small class="input-hint" style="display:none" data-i18n="ha_light.transition.hint">Smooth fade duration between colors (HA transition parameter). Higher values give smoother but slower transitions.</small>
<input type="range" id="ha-light-editor-transition" min="0" max="5.0" step="0.1" value="0.5" <div id="ha-light-editor-transition-container"></div>
oninput="document.getElementById('ha-light-editor-transition-display').textContent = parseFloat(this.value).toFixed(1)">
</div> </div>
<!-- Brightness Value Source --> <!-- Brightness Value Source -->
@@ -74,6 +70,30 @@
</select> </select>
</div> </div>
<!-- Color Tolerance -->
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.color_tolerance">Color Tolerance:</span>
</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_tolerance.hint">Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.</small>
<div id="ha-light-editor-color-tolerance-container"></div>
</div>
<!-- Min Brightness Threshold -->
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.min_brightness_threshold">Min Brightness Threshold:</span>
</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.min_brightness_threshold.hint">Effective output brightness below this value turns lights off completely (0 = disabled)</small>
<div id="ha-light-editor-min-brightness-threshold-container"></div>
</div>
<!-- Light Mappings --> <!-- Light Mappings -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -47,17 +47,13 @@
<div class="form-group" id="target-editor-fps-group"> <div class="form-group" id="target-editor-fps-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-fps"> <label>
<span data-i18n="targets.fps">Target FPS:</span> <span data-i18n="targets.fps">Target FPS:</span>
<span id="target-editor-fps-value">30</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth.</small> <small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth.</small>
<div class="slider-row"> <div id="target-editor-fps-container"></div>
<input type="range" id="target-editor-fps" min="1" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
<span class="slider-value">fps</span>
</div>
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small> <small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div> </div>
@@ -66,14 +62,13 @@
<div class="form-collapse-body"> <div class="form-collapse-body">
<div class="form-group" id="target-editor-brightness-threshold-group"> <div class="form-group" id="target-editor-brightness-threshold-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-brightness-threshold"> <label>
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span> <span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
<span id="target-editor-brightness-threshold-value">0</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small> <small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
<input type="range" id="target-editor-brightness-threshold" min="0" max="254" value="0" oninput="document.getElementById('target-editor-brightness-threshold-value').textContent = this.value"> <div id="target-editor-brightness-threshold-container"></div>
</div> </div>
<div class="form-group" id="target-editor-adaptive-fps-group"> <div class="form-group" id="target-editor-adaptive-fps-group">
@@ -105,7 +105,7 @@ class TestOutputTargetUpdate:
t = store.create_target("LED", "led", fps=30, protocol="ddp") t = store.create_target("LED", "led", fps=30, protocol="ddp")
updated = store.update_target(t.id, fps=60, protocol="drgb") updated = store.update_target(t.id, fps=60, protocol="drgb")
assert isinstance(updated, WledOutputTarget) assert isinstance(updated, WledOutputTarget)
assert updated.fps == 60 assert updated.fps.value == 60.0
assert updated.protocol == "drgb" assert updated.protocol == "drgb"
def test_update_not_found(self, store): def test_update_not_found(self, store):