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)
if not target_data:
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:
return NONE_OPTION
sources = self.coordinator.data.get("value_sources") or []
@@ -167,4 +172,7 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
if source_id is None:
_LOGGER.error("Value source not found: %s", option)
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.storage import DeviceStore
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
@@ -43,11 +44,11 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id or "",
fps=target.fps,
brightness=target.brightness.to_dict(),
fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_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,
protocol=target.protocol,
description=target.description,
@@ -62,20 +63,20 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type,
ha_source_id=target.ha_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=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
update_rate=target.update_rate,
ha_transition=target.transition,
color_tolerance=target.color_tolerance,
min_brightness_threshold=target.min_brightness_threshold,
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description,
tags=target.tags,
created_at=target.created_at,
@@ -121,7 +122,7 @@ async def create_target(
entity_id=m.entity_id,
led_start=m.led_start,
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
]
@@ -135,7 +136,7 @@ async def create_target(
target_type=data.target_type,
device_id=data.device_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,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
@@ -245,7 +246,7 @@ async def update_target(
entity_id=m.entity_id,
led_start=m.led_start,
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
]
@@ -256,7 +257,7 @@ async def update_target(
name=data.name,
device_id=data.device_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,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
@@ -287,9 +288,10 @@ async def update_target(
or data.transition is not None
or data.color_tolerance 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,
brightness_vs_changed=data.brightness_value_source_id is not None,
brightness_changed=data.brightness is not None,
)
except ValueError as 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)."""
from datetime import datetime
from typing import Dict, Optional, List
from typing import Any, Dict, Optional, List, Union
from pydantic import BaseModel, Field
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):
"""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')")
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)")
brightness_scale: float = Field(
default=1.0, ge=0.0, le=1.0, description="Brightness multiplier"
brightness_scale: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
@@ -37,8 +51,12 @@ class OutputTargetCreate(BaseModel):
# LED target fields
device_id: str = Field(default="", description="LED device 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")
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
fps: Optional[BindableFloatInput] = Field(
default=30, description="Target send FPS (bindable, 1-90)"
)
keepalive_interval: float = Field(
default=1.0,
description="Keepalive send interval when screen is static (0.5-5.0s)",
@@ -51,11 +69,9 @@ class OutputTargetCreate(BaseModel):
ge=5,
le=600,
)
min_brightness_threshold: int = Field(
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
ge=0,
le=254,
description="Min brightness threshold (0=disabled); below this → off",
description="Min brightness threshold (bindable, 0=disabled); below this → off",
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -72,17 +88,15 @@ class OutputTargetCreate(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
)
update_rate: float = Field(
default=2.0, ge=0.5, le=5.0, description="Service call rate in Hz (for ha_light targets)"
update_rate: Optional[BindableFloatInput] = Field(
default=2.0, description="Service call rate in Hz (bindable, for ha_light targets)"
)
transition: float = Field(
default=0.5, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
transition: Optional[BindableFloatInput] = Field(
default=0.5, description="HA transition seconds (bindable, for ha_light targets)"
)
color_tolerance: int = Field(
color_tolerance: Optional[BindableFloatInput] = Field(
default=5,
ge=0,
le=50,
description="Skip service call if RGB delta < this (for ha_light targets)",
description="RGB delta tolerance (bindable, for ha_light targets)",
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -95,18 +109,16 @@ class OutputTargetUpdate(BaseModel):
# LED target fields
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness_value_source_id: Optional[str] = Field(
None, description="Brightness value source ID"
)
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: Optional[float] = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
)
state_check_interval: Optional[int] = Field(
None, description="Health check interval (5-600s)", ge=5, le=600
)
min_brightness_threshold: Optional[int] = Field(
None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off"
min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: Optional[bool] = Field(
None, description="Auto-reduce FPS when device is unresponsive"
@@ -121,14 +133,14 @@ class OutputTargetUpdate(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
)
update_rate: Optional[float] = Field(
None, ge=0.5, le=5.0, description="Service call rate Hz (for ha_light targets)"
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, for ha_light targets)"
)
transition: Optional[float] = Field(
None, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, for ha_light targets)"
)
color_tolerance: Optional[int] = Field(
None, ge=0, le=50, description="RGB delta tolerance (for ha_light targets)"
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable, for ha_light targets)"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
@@ -143,14 +155,14 @@ class OutputTargetResponse(BaseModel):
# LED target fields
device_id: str = Field(default="", description="LED device 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")
fps: Optional[int] = Field(None, description="Target send FPS")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: int = Field(
default=0, description="Min brightness threshold (0=disabled)"
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -161,8 +173,12 @@ class OutputTargetResponse(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (ha_light)"
)
update_rate: Optional[float] = Field(None, description="Service call rate Hz (ha_light)")
ha_transition: Optional[float] = Field(None, description="HA transition seconds (ha_light)")
update_rate: Optional[BindableFloatInput] = Field(
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)")
description: Optional[str] = Field(None, description="Description")
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.
"""
# Check if any table already has data
for table in ["devices", "output_targets", "color_strip_sources",
"picture_sources", "audio_sources", "scene_presets"]:
for table in [
"devices",
"output_targets",
"color_strip_sources",
"picture_sources",
"audio_sources",
"scene_presets",
]:
if db.table_exists_with_data(table):
logger.info("Demo data already exists — skipping seed")
return
@@ -89,6 +95,7 @@ def seed_demo_data(db: Database) -> None:
# ── Devices ────────────────────────────────────────────────────────
def _build_devices() -> dict:
return {
_DEVICE_IDS["strip"]: {
@@ -126,6 +133,7 @@ def _build_devices() -> dict:
# ── Capture Templates ──────────────────────────────────────────────
def _build_capture_templates() -> dict:
return {
_TPL_ID: {
@@ -143,6 +151,7 @@ def _build_capture_templates() -> dict:
# ── Output Targets ─────────────────────────────────────────────────
def _build_output_targets() -> dict:
return {
_TARGET_IDS["strip"]: {
@@ -151,7 +160,7 @@ def _build_output_targets() -> dict:
"target_type": "led",
"device_id": _DEVICE_IDS["strip"],
"color_strip_source_id": _CSS_IDS["gradient"],
"brightness_value_source_id": "",
"brightness": 1.0,
"fps": 30,
"keepalive_interval": 1.0,
"state_check_interval": 30,
@@ -169,7 +178,7 @@ def _build_output_targets() -> dict:
"target_type": "led",
"device_id": _DEVICE_IDS["matrix"],
"color_strip_source_id": _CSS_IDS["picture"],
"brightness_value_source_id": "",
"brightness": 1.0,
"fps": 30,
"keepalive_interval": 1.0,
"state_check_interval": 30,
@@ -186,6 +195,7 @@ def _build_output_targets() -> dict:
# ── Picture Sources ────────────────────────────────────────────────
def _build_picture_sources() -> dict:
return {
_PS_IDS["main"]: {
@@ -237,6 +247,7 @@ def _build_picture_sources() -> dict:
# ── Color Strip Sources ────────────────────────────────────────────
def _build_color_strip_sources() -> dict:
return {
_CSS_IDS["gradient"]: {
@@ -321,6 +332,7 @@ def _build_color_strip_sources() -> dict:
# ── Audio Sources ──────────────────────────────────────────────────
def _build_audio_sources() -> dict:
return {
_AS_IDS["system"]: {
@@ -356,6 +368,7 @@ def _build_audio_sources() -> dict:
# ── Scene Presets ──────────────────────────────────────────────────
def _build_scene_presets() -> dict:
return {
_SCENE_ID: {
@@ -369,14 +382,14 @@ def _build_scene_presets() -> dict:
"target_id": _TARGET_IDS["strip"],
"running": True,
"color_strip_source_id": _CSS_IDS["gradient"],
"brightness_value_source_id": "",
"brightness": 1.0,
"fps": 30,
},
{
"target_id": _TARGET_IDS["matrix"],
"running": True,
"color_strip_source_id": _CSS_IDS["picture"],
"brightness_value_source_id": "",
"brightness": 1.0,
"fps": 30,
},
],
@@ -325,14 +325,14 @@ class HARuntime:
for s in msg.get("result", []):
eid = s.get("entity_id", "")
if self._matches_filter(eid):
attrs = s.get("attributes", {})
entities.append(
{
"entity_id": eid,
"state": s.get("state", ""),
"friendly_name": s.get("attributes", {}).get(
"friendly_name", eid
),
"friendly_name": attrs.get("friendly_name", eid),
"domain": eid.split(".")[0] if "." in eid else "",
"icon": attrs.get("icon", ""),
}
)
return entities
@@ -44,9 +44,17 @@ class ApiInputColorStripStream(ColorStripStream):
# Parse config
fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
self._fallback_color = (
fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
)
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
# Build initial fallback buffer
@@ -108,7 +116,9 @@ class ApiInputColorStripStream(ColorStripStream):
dst_positions = np.linspace(0, 1, target_count)
result = np.empty((target_count, 3), dtype=np.uint8)
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
def push_colors(self, colors: np.ndarray) -> None:
@@ -125,10 +135,10 @@ class ApiInputColorStripStream(ColorStripStream):
n = len(colors)
if n == self._led_count:
if self._colors.shape == colors.shape:
np.copyto(self._colors, colors, casting='unsafe')
np.copyto(self._colors, colors, casting="unsafe")
else:
self._colors = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._colors, colors, casting='unsafe')
np.copyto(self._colors, colors, casting="unsafe")
else:
self._colors = self._resize(colors, self._led_count)
self._last_push_time = time.monotonic()
@@ -180,8 +190,8 @@ class ApiInputColorStripStream(ColorStripStream):
buf[start:end] = colors[:length]
else:
# Pad with zeros if fewer colors than length
buf[start:start + available] = colors
buf[start + available:end] = 0
buf[start : start + available] = colors
buf[start + available : end] = 0
elif mode == "gradient":
stops = np.array(seg["colors"], dtype=np.float32)
@@ -243,7 +253,9 @@ class ApiInputColorStripStream(ColorStripStream):
if self._thread:
self._thread.join(timeout=5.0)
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
logger.info("ApiInputColorStripStream stopped")
@@ -259,11 +271,21 @@ class ApiInputColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
"""Hot-update fallback_color, timeout, and interpolation from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
if isinstance(source, ApiInputColorStripSource):
fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
self._fallback_color = (
fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
)
_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:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
@@ -274,12 +296,13 @@ class ApiInputColorStripStream(ColorStripStream):
"""Background thread that reverts to fallback on timeout."""
while self._running:
time.sleep(0.5)
if self._timeout <= 0:
timeout = self.resolve("timeout", self._timeout)
if timeout <= 0:
continue
if self._timed_out:
continue
elapsed = time.monotonic() - self._last_push_time
if elapsed >= self._timeout:
if elapsed >= timeout:
with self._lock:
self._colors = self._fallback_array.copy()
self._timed_out = True
@@ -37,7 +37,13 @@ class AudioColorStripStream(ColorStripStream):
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_source_store = audio_source_store
self._audio_template_store = audio_template_store
@@ -80,15 +86,19 @@ class AudioColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None:
self._visualization_mode = getattr(source, "visualization_mode", "spectrum")
self._sensitivity = float(getattr(source, "sensitivity", 1.0))
self._smoothing = float(getattr(source, "smoothing", 0.3))
from wled_controller.storage.bindable import bfloat
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._palette_name = getattr(source, "palette", "rainbow")
self._resolve_palette_lut()
color = getattr(source, "color", None)
self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
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())
self._color_f = np.array(self._color, 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_config = tpl.engine_config
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
except ValueError as e:
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
@@ -157,7 +171,8 @@ class AudioColorStripStream(ColorStripStream):
return
# Acquire shared audio capture stream
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_config=self._audio_engine_config,
)
@@ -184,7 +199,8 @@ class AudioColorStripStream(ColorStripStream):
# Release shared audio capture
if self._audio_stream is not None:
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,
)
self._audio_stream = None
@@ -200,6 +216,7 @@ class AudioColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import AudioColorStripSource
if isinstance(source, AudioColorStripSource):
old_device = self._audio_device_index
old_loopback = self._audio_loopback
@@ -217,10 +234,13 @@ class AudioColorStripStream(ColorStripStream):
)
if self._running and needs_swap:
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_device_index, self._audio_loopback,
self._audio_device_index,
self._audio_loopback,
engine_type=self._audio_engine_type,
engine_config=self._audio_engine_config,
)
@@ -301,7 +321,9 @@ class AudioColorStripStream(ColorStripStream):
self._colors = buf
# 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)
fft_ms = capture_timing.get("fft_ms", 0)
self._last_timing = {
@@ -342,8 +364,8 @@ class AudioColorStripStream(ColorStripStream):
return
spectrum, _ = self._pick_channel(analysis)
sensitivity = self._sensitivity
smoothing = self._smoothing
sensitivity = self.resolve("sensitivity", self._sensitivity)
smoothing = self.resolve("smoothing", self._smoothing)
lut = self._palette_lut
band_x = self._band_x
full_amp = self._full_amp
@@ -355,7 +377,7 @@ class AudioColorStripStream(ColorStripStream):
amplitudes *= sensitivity
np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
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
self._prev_spectrum = amplitudes.copy()
# Mirror: center = bass, edges = treble
@@ -366,7 +388,7 @@ class AudioColorStripStream(ColorStripStream):
amplitudes *= sensitivity
np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
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
self._prev_spectrum = amplitudes.copy()
full_amp[:] = amplitudes
@@ -374,16 +396,16 @@ class AudioColorStripStream(ColorStripStream):
# Map to palette: amplitude → palette index → color
np.multiply(full_amp, 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
# 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
np.copyto(f32_rgb, colors, casting='unsafe')
np.copyto(f32_rgb, colors, casting="unsafe")
f32_rgb *= full_amp[:, np.newaxis]
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 ───────────────────────────────────────────────────
@@ -393,8 +415,10 @@ class AudioColorStripStream(ColorStripStream):
return
_, ch_rms = self._pick_channel(analysis)
rms = ch_rms * self._sensitivity
rms = self._smoothing * self._prev_rms + (1.0 - self._smoothing) * rms
sensitivity = self.resolve("sensitivity", self._sensitivity)
smoothing = self.resolve("smoothing", self._smoothing)
rms = ch_rms * sensitivity
rms = smoothing * self._prev_rms + (1.0 - smoothing) * rms
self._prev_rms = rms
rms = min(1.0, rms)
@@ -406,9 +430,9 @@ class AudioColorStripStream(ColorStripStream):
peak = self._color_peak_f
t = self._vu_gradient[:fill_count]
for ch in range(3):
buf[:fill_count, ch] = np.clip(
base[ch] + (peak[ch] - base[ch]) * t, 0, 255
).astype(np.uint8)
buf[:fill_count, ch] = np.clip(base[ch] + (peak[ch] - base[ch]) * t, 0, 255).astype(
np.uint8
)
# ── Beat Pulse ─────────────────────────────────────────────────
@@ -420,7 +444,9 @@ class AudioColorStripStream(ColorStripStream):
if analysis.beat:
self._pulse_brightness = 1.0
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)
brightness = self._pulse_brightness
@@ -432,7 +458,11 @@ class AudioColorStripStream(ColorStripStream):
base_color = self._palette_lut[palette_idx]
# 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[:, 1] = g
buf[:, 2] = b
@@ -47,9 +47,9 @@ def _noise1d(x: np.ndarray) -> np.ndarray:
# (flicker_amplitude_mul, speed_mul, sigma_mul, warm_bonus)
_CANDLE_PRESETS: dict = {
"default": (1.0, 1.0, 1.0, 0.0),
"taper": (0.5, 1.3, 0.8, 0.0), # tall, steady
"votive": (1.5, 1.0, 0.7, 0.0), # small, flickery
"bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift
"taper": (0.5, 1.3, 0.8, 0.0), # tall, steady
"votive": (1.5, 1.0, 0.7, 0.0), # small, flickery
"bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift
}
_VALID_CANDLE_TYPES = frozenset(_CANDLE_PRESETS)
@@ -86,11 +86,15 @@ class CandlelightColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> 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._intensity = float(getattr(source, "intensity", 1.0))
self._color = (
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._speed = float(getattr(source, "speed", 1.0))
self._wind_strength = float(getattr(source, "wind_strength", 0.0))
self._speed = bfloat(getattr(source, "speed", 1.0), 1.0)
self._wind_strength = bfloat(getattr(source, "wind_strength", 0.0), 0.0)
raw_type = getattr(source, "candle_type", "default")
self._candle_type = raw_type if raw_type in _VALID_CANDLE_TYPES else "default"
_lc = getattr(source, "led_count", 0)
@@ -127,7 +131,9 @@ class CandlelightColorStripStream(ColorStripStream):
daemon=True,
)
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:
self._running = False
@@ -144,6 +150,7 @@ class CandlelightColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import CandlelightColorStripSource
if isinstance(source, CandlelightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
@@ -173,10 +180,10 @@ class CandlelightColorStripStream(ColorStripStream):
time.sleep(0.1)
continue
t = clock.get_time()
speed = clock.speed * self._speed
speed = clock.speed * self.resolve("speed", self._speed)
else:
t = wall_start
speed = self._speed
speed = self.resolve("speed", self._speed)
n = self._led_count
if n != _pool_n:
@@ -210,7 +217,7 @@ class CandlelightColorStripStream(ColorStripStream):
def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None:
"""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))
if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5:
self._last_drip_t = wall_t
@@ -245,21 +252,22 @@ class CandlelightColorStripStream(ColorStripStream):
# ── 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."""
amp_mul, spd_mul, sigma_mul, warm_bonus = _CANDLE_PRESETS[self._candle_type]
eff_speed = speed * 0.35 * spd_mul
intensity = self._intensity
intensity = self.resolve("intensity", self._intensity)
num_candles = self._num_candles
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
# Wind modulation
wind_strength = self._wind_strength
wind_strength = self.resolve("wind_strength", self._wind_strength)
if wind_strength > 0.0:
wind_raw = (
0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t)
+ 0.4 * math.sin(2.0 * math.pi * 0.27 * wall_t + 1.1)
wind_raw = 0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t) + 0.4 * math.sin(
2.0 * math.pi * 0.27 * wall_t + 1.1
)
wind_mod = max(0.0, wind_raw)
else:
@@ -287,7 +295,7 @@ class CandlelightColorStripStream(ColorStripStream):
candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul
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)
@@ -300,7 +308,7 @@ class CandlelightColorStripStream(ColorStripStream):
# Per-LED noise
noise_x = x * 0.3 + t * eff_speed * 5.0
noise = _noise1d(noise_x)
bright[:n] *= (0.85 + 0.30 * noise)
bright[:n] *= 0.85 + 0.30 * noise
# Wax drip factor
bright[:n] *= self._s_drip[:n]
@@ -19,8 +19,13 @@ from typing import Optional
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.storage.bindable import bfloat
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
@@ -105,6 +110,32 @@ class ColorStripStream(ABC):
def update_source(self, source) -> None:
"""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):
"""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._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._calibration: CalibrationConfig = source.calibration
self._pixel_mapper = create_pixel_mapper(
@@ -189,9 +220,7 @@ class PictureColorStripStream(ColorStripStream):
daemon=True,
)
self._thread.start()
logger.info(
f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})"
)
logger.info(f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})")
def stop(self) -> None:
self._running = False
@@ -224,12 +253,15 @@ class PictureColorStripStream(ColorStripStream):
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)):
return
self._smoothing = source.smoothing
self._smoothing = bfloat(source.smoothing, 0.3)
if (
source.interpolation_mode != self._interpolation_mode
@@ -253,9 +285,9 @@ class PictureColorStripStream(ColorStripStream):
# Scratch buffer pool (pre-allocated, resized when LED count changes)
_pool_n = 0
_frame_a = _frame_b = None # double-buffered uint8 output
_frame_a = _frame_b = None # double-buffered uint8 output
_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):
"""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).
"""
nonlocal _u16_a, _u16_b
np.copyto(_u16_a, a, casting='unsafe')
np.copyto(_u16_b, b, casting='unsafe')
_u16_a *= (256 - alpha_b)
np.copyto(_u16_a, a, casting="unsafe")
np.copyto(_u16_b, b, casting="unsafe")
_u16_a *= 256 - alpha_b
_u16_b *= alpha_b
_u16_a += _u16_b
_u16_a >>= 8
np.copyto(out, _u16_a, casting='unsafe')
np.copyto(out, _u16_a, casting="unsafe")
try:
with high_resolution_timer():
@@ -333,15 +365,16 @@ class PictureColorStripStream(ColorStripStream):
led_colors = frame_buf
# Temporal smoothing (pre-allocated uint16 scratch)
smoothing = self._smoothing
smoothing = self.resolve("smoothing", self._smoothing)
if (
self._previous_colors is not None
and smoothing > 0
and len(self._previous_colors) == len(led_colors)
and _u16_a is not None
):
_blend_u16(led_colors, self._previous_colors,
int(smoothing * 256), led_colors)
_blend_u16(
led_colors, self._previous_colors, int(smoothing * 256), led_colors
)
t3 = time.perf_counter()
self._previous_colors = led_colors
@@ -357,7 +390,9 @@ class PictureColorStripStream(ColorStripStream):
}
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
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:
return np.array(cr, dtype=np.float32)
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
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))
t = np.round(t * steps) / steps
a_colors = right_colors[idx] # A's right color
b_colors = left_colors[idx + 1] # B's left color
a_colors = right_colors[idx] # A's right color
b_colors = left_colors[idx + 1] # B's left color
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
return np.clip(result, 0, 255).astype(np.uint8)
@@ -470,7 +507,11 @@ class StaticColorStripStream(ColorStripStream):
self._update_from_source(source)
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
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
@@ -544,6 +585,7 @@ class StaticColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import StaticColorStripSource
if isinstance(source, StaticColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
@@ -602,7 +644,11 @@ class StaticColorStripStream(ColorStripStream):
if atype == "breathing":
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
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
elif atype == "strobe":
@@ -631,7 +677,11 @@ class StaticColorStripStream(ColorStripStream):
else:
factor = math.exp(-5.0 * (phase - 0.1))
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
elif atype == "candle":
@@ -642,7 +692,11 @@ class StaticColorStripStream(ColorStripStream):
flicker += 0.10 * (np.random.random() - 0.5)
factor = max(0.2, min(1.0, base_factor + flicker))
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
elif atype == "rainbow_fade":
@@ -694,12 +748,14 @@ class ColorCycleColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None:
raw = source.colors if isinstance(source.colors, list) else []
default = [
[255, 0, 0], [255, 255, 0], [0, 255, 0],
[0, 255, 255], [0, 0, 255], [255, 0, 255],
[255, 0, 0],
[255, 255, 0],
[0, 255, 0],
[0, 255, 255],
[0, 0, 255],
[255, 0, 255],
]
self._color_list = [
c for c in raw if isinstance(c, list) and len(c) == 3
] or default
self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc > 0 else 1
@@ -742,14 +798,18 @@ class ColorCycleColorStripStream(ColorStripStream):
daemon=True,
)
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:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
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
logger.info("ColorCycleColorStripStream stopped")
@@ -759,6 +819,7 @@ class ColorCycleColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import ColorCycleColorStripSource
if isinstance(source, ColorCycleColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
@@ -929,14 +990,18 @@ class GradientColorStripStream(ColorStripStream):
daemon=True,
)
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:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
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
logger.info("GradientColorStripStream stopped")
@@ -946,6 +1011,7 @@ class GradientColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import GradientColorStripSource
if isinstance(source, GradientColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
@@ -973,9 +1039,9 @@ class GradientColorStripStream(ColorStripStream):
_pool_n = 0
_buf_a = _buf_b = _scratch_u16 = None
_use_a = True
_wave_i = None # cached np.arange for wave animation
_wave_factors = None # float32 scratch for wave sin result
_wave_u16 = None # uint16 scratch for wave int factors
_wave_i = None # cached np.arange for wave animation
_wave_factors = None # float32 scratch for wave sin result
_wave_u16 = None # uint16 scratch for wave int factors
try:
with high_resolution_timer():
@@ -1002,7 +1068,12 @@ class GradientColorStripStream(ColorStripStream):
# Recompute base gradient only when stops, led_count, or easing change
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_n = n
_cached_stops = stops
@@ -1023,18 +1094,28 @@ class GradientColorStripStream(ColorStripStream):
_use_a = not _use_a
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)
_scratch_u16 *= int_f
_scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe')
np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf
elif atype == "gradient_shift":
shift = int(speed * t * 10) % max(n, 1)
if shift > 0:
buf[:n - shift] = base[shift:]
buf[n - shift:] = base[:shift]
buf[: n - shift] = base[shift:]
buf[n - shift :] = base[:shift]
else:
np.copyto(buf, base)
colors = buf
@@ -1049,11 +1130,11 @@ class GradientColorStripStream(ColorStripStream):
_wave_factors += 0.5
np.multiply(_wave_factors, 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)
_scratch_u16 *= _wave_u16[:, None]
_scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe')
np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf
else:
np.copyto(buf, base)
@@ -1083,7 +1164,7 @@ class GradientColorStripStream(ColorStripStream):
np.copyto(_scratch_u16, base)
_scratch_u16 *= int_f
_scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe')
np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf
elif atype == "candle":
@@ -1096,7 +1177,7 @@ class GradientColorStripStream(ColorStripStream):
np.copyto(_scratch_u16, base)
_scratch_u16 *= int_f
_scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe')
np.copyto(buf, _scratch_u16, casting="unsafe")
colors = buf
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_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 *= (1.0 / 6.0)
h_arr *= 1.0 / 6.0
h_arr %= 1.0
# Saturation & Value with clamping
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)
bo = np.empty(n, dtype=np.float32)
for sxt, rv, gv, bv in (
(0, v_arr, tt, p), (1, q, v_arr, p),
(2, p, v_arr, tt), (3, p, q, v_arr),
(4, tt, p, v_arr), (5, v_arr, p, q),
(0, v_arr, tt, p),
(1, q, v_arr, p),
(2, p, v_arr, tt),
(3, p, q, v_arr),
(4, tt, p, v_arr),
(5, v_arr, p, q),
):
m = hi == sxt
ro[m] = rv[m]
@@ -1158,9 +1242,13 @@ class GradientColorStripStream(ColorStripStream):
noise_val = _gradient_noise.noise(
np.array([si * 10.0 + t * speed], dtype=np.float32)
)[0]
new_pos = min(1.0, max(0.0,
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2
))
new_pos = min(
1.0,
max(
0.0,
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2,
),
)
perturbed.append(dict(s, position=new_pos))
buf[:] = _compute_gradient_colors(perturbed, n, easing)
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_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 *= (1.0 / 6.0)
h_arr *= 1.0 / 6.0
h_arr %= 1.0
# S and V — preserve original values (no clamping)
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)
bo = np.empty(n, dtype=np.float32)
for sxt, rv, gv, bv in (
(0, v_arr, tt, p), (1, q, v_arr, p),
(2, p, v_arr, tt), (3, p, q, v_arr),
(4, tt, p, v_arr), (5, v_arr, p, q),
(0, v_arr, tt, p),
(1, q, v_arr, p),
(2, p, v_arr, tt),
(3, p, q, v_arr),
(4, tt, p, v_arr),
(5, v_arr, p, q),
):
m = hi == sxt
ro[m] = rv[m]
@@ -53,12 +53,16 @@ class _ColorStripEntry:
target_fps: Dict[str, int] = None
# Clock ID currently acquired for this stream (for correct release)
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):
if self.picture_source_ids is None:
self.picture_source_ids = []
if self.target_fps is None:
self.target_fps = {}
if self.bound_vs_ids is None:
self.bound_vs_ids = {}
class ColorStripStreamManager:
@@ -143,6 +147,54 @@ class ColorStripStreamManager:
logger.debug("Sync clock release during stream cleanup: %s", e)
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:
"""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)
css_stream.start()
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
self._streams[key] = _ColorStripEntry(
entry = _ColorStripEntry(
stream=css_stream,
ref_count=1,
picture_source_ids=[],
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}")
return css_stream
@@ -261,11 +315,13 @@ class ColorStripStreamManager:
self._live_stream_manager.release(ps_id)
raise RuntimeError(f"Failed to start key_colors stream {css_id}: {e}") from e
self._streams[css_id] = _ColorStripEntry(
entry = _ColorStripEntry(
stream=css_stream,
ref_count=1,
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)")
return css_stream
@@ -314,11 +370,13 @@ class ColorStripStreamManager:
f"Failed to start color strip stream for source {css_id}: {e}"
) from e
self._streams[css_id] = _ColorStripEntry(
entry = _ColorStripEntry(
stream=css_stream,
ref_count=1,
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}")
return css_stream
@@ -353,6 +411,9 @@ class ColorStripStreamManager:
source_id = key.split(":")[0] if ":" in key else key
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
del self._streams[key]
logger.info(f"Removed color strip stream {key}")
@@ -7,6 +7,7 @@ from typing import Dict, List, Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bfloat
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -37,6 +38,7 @@ class CompositeColorStripStream(ColorStripStream):
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
import uuid as _uuid
self._source_id: str = source.id
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
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)
# Pre-resolved blend methods: blend_mode_str -> bound method
self._blend_methods = {
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
}
self._blend_methods = {k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()}
self._default_blend_method = self._blend_normal
# Pre-allocated scratch (rebuilt when LED count changes)
@@ -104,7 +104,8 @@ class CompositeColorStripStream(ColorStripStream):
self._acquire_sub_streams()
self._running = True
self._thread = threading.Thread(
target=self._processing_loop, daemon=True,
target=self._processing_loop,
daemon=True,
name=f"CompositeCSS-{self._source_id[:12]}",
)
self._thread.start()
@@ -162,14 +163,32 @@ class CompositeColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
"""Hot-update: rebuild sub-streams if layer config changed."""
new_layers = list(source.layers)
old_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 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]
old_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 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
@@ -209,9 +228,7 @@ class CompositeColorStripStream(ColorStripStream):
stream.configure(self._led_count)
self._sub_streams[i] = (src_id, consumer_id, stream)
except Exception as e:
logger.warning(
f"Composite layer {i} (source {src_id}) failed to acquire: {e}"
)
logger.warning(f"Composite layer {i} (source {src_id}) failed to acquire: {e}")
# Acquire brightness value stream if configured
vs_id = layer.get("brightness_source_id")
if vs_id and self._value_stream_manager:
@@ -219,9 +236,7 @@ class CompositeColorStripStream(ColorStripStream):
vs = self._value_stream_manager.acquire(vs_id)
self._brightness_streams[i] = (vs_id, vs)
except Exception as e:
logger.warning(
f"Composite layer {i} brightness source {vs_id} failed: {e}"
)
logger.warning(f"Composite layer {i} brightness source {vs_id} failed: {e}")
def _release_sub_streams(self) -> None:
self._sub_streams_version += 1
@@ -272,20 +287,20 @@ class CompositeColorStripStream(ColorStripStream):
# ── Blend operations (integer math, pre-allocated) ──────────
def _blend_normal(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_normal(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Normal blend: out = (bottom * (256-a) + top * a) >> 8"""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
u16a *= (256 - alpha)
u16a *= 256 - alpha
u16b *= alpha
u16a += u16b
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_add(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_add(self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray) -> None:
"""Additive blend: out = min(255, bottom + top * alpha >> 8)"""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
@@ -296,8 +311,9 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(u16a, 0, 255, out=u16a)
np.copyto(out, u16a, casting="unsafe")
def _blend_multiply(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_multiply(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Multiply blend: blended = bottom*top>>8, then lerp with alpha."""
u16a, u16b = self._u16_a, self._u16_b
# blended = (bottom * top) >> 8
@@ -307,14 +323,15 @@ class CompositeColorStripStream(ColorStripStream):
u16a >>= 8
# lerp: result = (bottom * (256-a) + blended * a) >> 8
np.copyto(u16b, bottom, casting="unsafe")
u16b *= (256 - alpha)
u16b *= 256 - alpha
u16a *= alpha
u16a += u16b
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_screen(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_screen(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Screen blend: blended = 255 - (255-bottom)*(255-top)>>8, then lerp."""
u16a, u16b = self._u16_a, self._u16_b
# blended = 255 - ((255 - bottom) * (255 - top)) >> 8
@@ -327,14 +344,15 @@ class CompositeColorStripStream(ColorStripStream):
u16a[:] = 255 - u16a
# lerp: result = (bottom * (256-a) + blended * a) >> 8
np.copyto(u16b, bottom, casting="unsafe")
u16b *= (256 - alpha)
u16b *= 256 - alpha
u16a *= alpha
u16a += u16b
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_override(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Override blend: per-pixel alpha derived from top brightness.
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
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
u16a *= (256 - per_px_alpha)
u16a *= 256 - per_px_alpha
u16b *= per_px_alpha
u16a += u16b
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_overlay(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_overlay(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Overlay blend: multiply darks, screen lights, then lerp with alpha.
if bottom < 128: blended = 2*bottom*top >> 8
@@ -375,14 +394,15 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(blended, 0, 255, out=blended)
# Lerp: result = (bottom * (256-a) + blended * a) >> 8
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
u16a *= 256 - alpha
blended *= alpha
u16a += blended
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_soft_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_soft_light(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Soft light blend (Pegtop formula), then lerp with alpha.
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)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
u16a *= 256 - alpha
blended_u16 = blended.astype(np.uint16)
blended_u16 *= alpha
u16a += blended_u16
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_hard_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_hard_light(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Hard light blend: overlay with top/bottom roles swapped.
if top < 128: blended = 2*bottom*top >> 8
@@ -424,14 +445,15 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(blended, 0, 255, out=blended)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
u16a *= 256 - alpha
blended *= alpha
u16a += blended
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_difference(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_difference(
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
) -> None:
"""Difference blend: |bottom - top|, then lerp with alpha."""
u16a, u16b = self._u16_a, self._u16_b
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)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
u16a *= 256 - alpha
blended *= alpha
u16a += blended
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
def _blend_exclusion(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
def _blend_exclusion(
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."""
u16a, u16b = self._u16_a, self._u16_b
np.copyto(u16a, bottom, casting="unsafe")
@@ -457,7 +480,7 @@ class CompositeColorStripStream(ColorStripStream):
np.clip(blended, 0, 255, out=blended)
# Lerp
np.copyto(u16a, bottom, casting="unsafe")
u16a *= (256 - alpha)
u16a *= 256 - alpha
blended *= alpha
u16a += blended
u16a >>= 8
@@ -525,13 +548,16 @@ class CompositeColorStripStream(ColorStripStream):
# Resolve and cache filters for this layer
try:
from wled_controller.core.filters.registry import FilterRegistry
_resolved = self._cspt_store.resolve_filter_instances(
self._cspt_store.get_template(_layer_tmpl_id).filters
)
_filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options)
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)
logger.info(
@@ -539,7 +565,9 @@ class CompositeColorStripStream(ColorStripStream):
f"from template {_layer_tmpl_id}"
)
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_filters = _layer_cspt_cache[i][1]
if _layer_filters:
@@ -556,7 +584,9 @@ class CompositeColorStripStream(ColorStripStream):
if has_range:
# Clamp range to strip bounds
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
if zone_len <= 0:
continue
@@ -573,7 +603,11 @@ class CompositeColorStripStream(ColorStripStream):
self._resize_cache[rkey] = cached
src_x, dst_x, resized = cached
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
else:
# Full-strip layer: resize to target LED count
@@ -589,13 +623,15 @@ class CompositeColorStripStream(ColorStripStream):
_vs_id, vs = self._brightness_streams[i]
bri = vs.get_value()
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)
if self._need_layer_snapshots:
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)
alpha = int(opacity * 256)
alpha = max(0, min(256, alpha))
@@ -609,20 +645,26 @@ class CompositeColorStripStream(ColorStripStream):
rng = result_buf[eff_start:eff_end]
u16a_rng = self._u16_a[: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
orig_u16a, orig_u16b = self._u16_a, self._u16_b
self._u16_a, self._u16_b = u16a_rng, u16b_rng
blend_fn(rng, colors, alpha, rng)
self._u16_a, self._u16_b = orig_u16a, orig_u16b
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)
if has_result:
with self._colors_lock:
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:
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)
@@ -31,23 +31,23 @@ logger = get_logger(__name__)
#
# Format: (hour, R, G, B)
_DAYLIGHT_CURVE = [
(0.0, 10, 10, 30), # midnight — deep blue
(4.0, 10, 10, 40), # pre-dawn — dark blue
(5.5, 40, 20, 60), # first light — purple hint
(6.0, 255, 100, 30), # sunrise — warm orange
(7.0, 255, 170, 80), # early morning — golden
(8.0, 255, 220, 160), # morning — warm white
(10.0, 255, 245, 230), # mid-morning — neutral warm
(12.0, 240, 248, 255), # noon — cool white / slight blue
(14.0, 255, 250, 240), # afternoon — neutral
(16.0, 255, 230, 180), # late afternoon — warm
(17.5, 255, 180, 100), # pre-sunset — golden
(18.5, 255, 100, 40), # sunset — deep orange
(19.0, 200, 60, 40), # late sunset — red
(19.5, 100, 30, 60), # dusk — purple
(20.0, 40, 20, 60), # twilight — dark purple
(21.0, 15, 15, 45), # night — dark blue
(24.0, 10, 10, 30), # midnight (wrap)
(0.0, 10, 10, 30), # midnight — deep blue
(4.0, 10, 10, 40), # pre-dawn — dark blue
(5.5, 40, 20, 60), # first light — purple hint
(6.0, 255, 100, 30), # sunrise — warm orange
(7.0, 255, 170, 80), # early morning — golden
(8.0, 255, 220, 160), # morning — warm white
(10.0, 255, 245, 230), # mid-morning — neutral warm
(12.0, 240, 248, 255), # noon — cool white / slight blue
(14.0, 255, 250, 240), # afternoon — neutral
(16.0, 255, 230, 180), # late afternoon — warm
(17.5, 255, 180, 100), # pre-sunset — golden
(18.5, 255, 100, 40), # sunset — deep orange
(19.0, 200, 60, 40), # late sunset — red
(19.5, 100, 30, 60), # dusk — purple
(20.0, 40, 20, 60), # twilight — dark purple
(21.0, 15, 15, 45), # night — dark blue
(24.0, 10, 10, 30), # midnight (wrap)
]
# Reference solar times the canonical curve was designed around
@@ -61,9 +61,7 @@ _daylight_lut: Optional[np.ndarray] = None
# ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(
latitude: float, longitude: float, day_of_year: int
) -> tuple:
def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple:
"""Return (sunrise_hour, sunset_hour) in local solar time.
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
for ch in range(3):
lut[minute, ch] = int(
prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5
)
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
return lut
@@ -188,7 +184,9 @@ class DaylightColorStripStream(ColorStripStream):
self._update_from_source(source)
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._latitude = float(getattr(source, "latitude", 50.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:
"""Return a solar-time-aware LUT for the given day (cached)."""
sunrise, sunset = _compute_solar_times(
self._latitude, self._longitude, day_of_year
)
sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year)
sr_key = int(round(sunrise * 60))
ss_key = int(round(sunset * 60))
cache_key = (sr_key, ss_key)
@@ -260,6 +256,7 @@ class DaylightColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import DaylightColorStripSource
if isinstance(source, DaylightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
@@ -292,7 +289,7 @@ class DaylightColorStripStream(ColorStripStream):
speed = clock.speed
else:
t = wall_start
speed = self._speed
speed = self.resolve("speed", self._speed)
n = self._led_count
if n != _pool_n:
@@ -26,17 +26,41 @@ logger = get_logger(__name__)
# Each palette is a list of (position, R, G, B) control points.
# Positions must be monotonically increasing from 0.0 to 1.0.
_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)],
"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)],
"forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
"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)],
"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)],
"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),
],
"forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
"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] = {}
@@ -84,6 +108,7 @@ def _build_palette_lut(name: str, custom_stops: list = None) -> np.ndarray:
# ── 1-D value noise (no external deps) ──────────────────────────────────
class _ValueNoise1D:
"""Simple 1-D value noise with smoothstep interpolation and fractal octaves.
@@ -120,7 +145,7 @@ class _ValueNoise1D:
size = len(self._table)
# xi = floor(x)
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
np.subtract(x, self._frac, out=self._frac)
# t = frac * frac * (3 - 2 * frac) (smoothstep)
@@ -224,7 +249,7 @@ class EffectColorStripStream(ColorStripStream):
self._ball_last_t = 0.0
# Fireworks state
self._fw_particles: list = [] # active particles
self._fw_rockets: list = [] # active rockets
self._fw_rockets: list = [] # active rockets
self._fw_last_launch = 0.0
# Sparkle rain state
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._led_count = _lc if _lc and _lc > 0 else 1
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._resolve_palette_lut()
color = getattr(source, "color", None)
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
self._intensity = float(getattr(source, "intensity", 1.0))
self._scale = float(getattr(source, "scale", 1.0))
from wled_controller.storage.bindable import bfloat
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))
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
@@ -296,7 +325,9 @@ class EffectColorStripStream(ColorStripStream):
daemon=True,
)
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:
self._running = False
@@ -315,6 +346,7 @@ class EffectColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import EffectColorStripSource
if isinstance(source, EffectColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
@@ -411,7 +443,7 @@ class EffectColorStripStream(ColorStripStream):
at the bottom. Heat values are mapped to the palette LUT.
"""
speed = self._effective_speed
intensity = self._intensity
intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut
# (Re)allocate heat array when LED count changes
@@ -449,7 +481,7 @@ class EffectColorStripStream(ColorStripStream):
# Map heat to palette (pre-allocated scratch)
np.multiply(heat, 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]
# ── Meteor ───────────────────────────────────────────────────────
@@ -457,7 +489,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
"""Bright meteor head with exponential-decay trail."""
speed = self._effective_speed
intensity = self._intensity
intensity = self.resolve("intensity", self._intensity)
color = self._color
mirror = self._mirror
@@ -493,13 +525,13 @@ class EffectColorStripStream(ColorStripStream):
r, g, b = color
np.multiply(brightness, r, 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.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.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
# boolean mask allocations and fancy indexing temporaries.
@@ -520,14 +552,14 @@ class EffectColorStripStream(ColorStripStream):
np.multiply(head_br, 255 - ch_base, out=tmp)
tmp += buf[head_sl, ch_idx]
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 ───────────────────────────────────────────────────────
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
"""Overlapping sine waves creating colorful plasma patterns."""
speed = self._effective_speed
scale = self._scale
scale = self.resolve("scale", self._scale)
lut = self._palette_lut
# 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:
"""Smooth scrolling fractal noise mapped to a color palette."""
speed = self._effective_speed
scale = self._scale
scale = self.resolve("scale", self._scale)
lut = self._palette_lut
# Positions from cached arange (avoids per-frame np.arange)
@@ -564,7 +596,7 @@ class EffectColorStripStream(ColorStripStream):
# Map to palette indices using pre-allocated scratch
np.multiply(values, 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]
# ── Aurora ───────────────────────────────────────────────────────
@@ -572,8 +604,8 @@ class EffectColorStripStream(ColorStripStream):
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
"""Layered noise bands simulating aurora borealis."""
speed = self._effective_speed
scale = self._scale
intensity = self._intensity
scale = self.resolve("scale", self._scale)
intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut
# Positions from cached arange
@@ -606,20 +638,20 @@ class EffectColorStripStream(ColorStripStream):
# Map to palette using pre-allocated scratch
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)
self._s_f32_rgb[:] = lut[self._s_i32]
self._s_f32_rgb *= bright[:, np.newaxis]
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 ──────────────────────────────────────────────────────────
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
"""Raindrops falling down the strip with trailing tails."""
speed = self._effective_speed
intensity = self._intensity
scale = self._scale
intensity = self.resolve("intensity", self._intensity)
scale = self.resolve("scale", self._scale)
lut = self._palette_lut
# 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.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)
buf[:] = lut[self._s_i32]
@@ -653,7 +685,7 @@ class EffectColorStripStream(ColorStripStream):
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
"""Multiple comets with curved, pulsing tails."""
speed = self._effective_speed
intensity = self._intensity
intensity = self.resolve("intensity", self._intensity)
color = self._color
mirror = self._mirror
@@ -692,14 +724,14 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_a[:] = buf[:, ch_idx]
self._s_f32_a += self._s_f32_c
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 ─────────────────────────────────────────────────
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
"""Physics-simulated bouncing balls with gravity."""
speed = self._effective_speed
intensity = self._intensity
intensity = self.resolve("intensity", self._intensity)
color = self._color
num_balls = 3
@@ -755,14 +787,14 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_a[:] = buf[:, ch_idx]
self._s_f32_a += self._s_f32_c
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 ─────────────────────────────────────────────────────
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
"""Rockets launch and explode into colorful particle bursts."""
speed = self._effective_speed
intensity = self._intensity
intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut
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:
"""Twinkling star field with smooth fade-in/fade-out."""
speed = self._effective_speed
intensity = self._intensity
intensity = self.resolve("intensity", self._intensity)
lut = self._palette_lut
# Initialize/resize sparkle state
@@ -858,20 +890,20 @@ class EffectColorStripStream(ColorStripStream):
# Map sparkle brightness to palette
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)
self._s_f32_rgb[:] = lut[self._s_i32]
# Apply brightness
self._s_f32_rgb *= state[:, np.newaxis]
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 ─────────────────────────────────────────────────────
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
"""Slow-moving colored blobs that merge and separate."""
speed = self._effective_speed
scale = self._scale
scale = self.resolve("scale", self._scale)
lut = self._palette_lut
# Use noise at very low frequency for blob movement
@@ -903,7 +935,7 @@ class EffectColorStripStream(ColorStripStream):
# Map to palette
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)
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:
"""Two counter-propagating sine waves creating interference patterns."""
speed = self._effective_speed
scale = self._scale
scale = self.resolve("scale", self._scale)
lut = self._palette_lut
# Wave parameters
@@ -934,7 +966,7 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_a += self._s_f32_b
# Range is [-2, 2], map to [0, 255]
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.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]
@@ -27,23 +27,35 @@ class HALightTargetProcessor(TargetProcessor):
target_id: str,
ha_source_id: str,
color_strip_source_id: str = "",
brightness=None,
# legacy compat
brightness_value_source_id: str = "",
light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0,
transition: float = 0.5,
transition=None,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
ctx: Optional[TargetContext] = None,
):
from wled_controller.storage.bindable import BindableFloat, bfloat
super().__init__(target_id, ctx)
self._ha_source_id = ha_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._update_rate = max(0.5, min(5.0, update_rate))
self._transition = transition
self._min_brightness_threshold = min_brightness_threshold
self._color_tolerance = color_tolerance
self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0)))
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._color_tolerance = int(bfloat(color_tolerance, 5.0))
# Runtime state
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}")
# 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:
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:
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
self._value_stream = None
@@ -116,7 +130,7 @@ class HALightTargetProcessor(TargetProcessor):
# Release brightness value stream
if self._value_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._brightness_vs_id)
self._ctx.value_stream_manager.release(self._brightness.source_id)
except Exception:
pass
self._value_stream = None
@@ -138,15 +152,29 @@ class HALightTargetProcessor(TargetProcessor):
logger.info(f"HA light target stopped: {self._target_id}")
def update_settings(self, settings) -> None:
from wled_controller.storage.bindable import BindableFloat, bfloat
if isinstance(settings, dict):
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:
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:
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:
self._color_tolerance = int(settings["color_tolerance"])
self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
if "light_mappings" in settings:
self._light_mappings = settings["light_mappings"]
@@ -273,7 +301,12 @@ class HALightTargetProcessor(TargetProcessor):
brightness = max(r, g, b)
# 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:
brightness = int(brightness * eff_scale)
@@ -299,10 +332,11 @@ class HALightTargetProcessor(TargetProcessor):
# Call light.turn_on
service_data = {
"rgb_color": [r, g, b],
"brightness": min(255, int(brightness * mapping.brightness_scale)),
"brightness": min(255, int(brightness * bs)),
}
if self._transition > 0:
service_data["transition"] = self._transition
transition_val = self._transition.value
if transition_val > 0:
service_data["transition"] = transition_val
await self._ha_runtime.call_service(
domain="light",
@@ -18,6 +18,8 @@ from wled_controller.core.capture.screen_capture import (
calculate_dominant_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.timer import high_resolution_timer
@@ -36,12 +38,8 @@ _CALC_FNS = {
}
class KeyColorsColorStripStream:
"""Streams N colors extracted from screen rectangles.
Implements the same interface as ColorStripStream so it can be used
by any target processor via ColorStripStreamManager.
"""
class KeyColorsColorStripStream(ColorStripStream):
"""Streams N colors extracted from screen rectangles."""
def __init__(
self,
@@ -165,7 +163,7 @@ class KeyColorsColorStripStream:
colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
# Temporal smoothing
smoothing = src.smoothing
smoothing = self.resolve("smoothing", bfloat(src.smoothing, 0.3))
if (
prev_colors_arr is not None
and smoothing > 0
@@ -175,7 +173,7 @@ class KeyColorsColorStripStream:
prev_colors_arr = colors_arr
# Apply brightness
brightness = src.brightness
brightness = self.resolve("brightness", bfloat(src.brightness, 1.0))
if brightness < 1.0:
output = colors_arr * brightness
else:
@@ -18,6 +18,7 @@ from typing import Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bfloat
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -74,16 +75,20 @@ class NotificationColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None:
"""Parse config from source dataclass."""
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._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_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
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
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", {}))
self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
with self._colors_lock:
@@ -135,7 +140,7 @@ class NotificationColorStripStream(ColorStripStream):
# Resolve sound: per-app override > global sound_asset_id
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:
override = self._app_sounds[app_lower]
@@ -164,6 +169,7 @@ class NotificationColorStripStream(ColorStripStream):
try:
from wled_controller.utils.sound_player import play_sound_async
play_sound_async(file_path, volume=volume)
except Exception as e:
logger.error(f"Failed to play notification sound: {e}")
@@ -211,7 +217,9 @@ class NotificationColorStripStream(ColorStripStream):
if self._thread:
self._thread.join(timeout=5.0)
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
logger.info("NotificationColorStripStream stopped")
@@ -222,6 +230,7 @@ class NotificationColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
"""Hot-update config from updated source."""
from wled_controller.storage.color_strip_source import NotificationColorStripSource
if isinstance(source, NotificationColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
@@ -253,7 +262,9 @@ class NotificationColorStripStream(ColorStripStream):
while self._event_queue:
try:
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
except IndexError:
break
@@ -273,7 +284,7 @@ class NotificationColorStripStream(ColorStripStream):
color = self._active_effect["color"]
start_time = self._active_effect["start"]
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)
if progress >= 1.0:
@@ -392,7 +403,9 @@ class NotificationColorStripStream(ColorStripStream):
buf[i, 1] = min(255, int(color[1] * 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.
Creates a gradient from the notification color at center to darker
@@ -424,6 +424,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
brightness=None,
# legacy compat
brightness_value_source_id: str = "",
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
@@ -442,7 +444,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
brightness_value_source_id=brightness_value_source_id,
brightness=brightness,
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
@@ -456,10 +458,12 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
target_id: str,
ha_source_id: str,
color_strip_source_id: str = "",
brightness=None,
# legacy compat
brightness_value_source_id: str = "",
light_mappings=None,
update_rate: float = 2.0,
transition: float = 0.5,
transition=None,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
) -> None:
@@ -473,7 +477,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
target_id=target_id,
ha_source_id=ha_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 [],
update_rate=update_rate,
transition=transition,
@@ -544,10 +548,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
)
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)
if hasattr(proc, "update_brightness_value_source"):
proc.update_brightness_value_source(vs_id)
if hasattr(proc, "update_brightness"):
proc.update_brightness(brightness)
def update_value_source(self, vs_id: str):
"""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.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.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
result = color.astype(np.int16)
result[:, 0] += int(shift) # red
result[:, 2] -= int(shift) # blue
result[:, 0] += int(shift) # red
result[:, 2] -= int(shift) # blue
np.clip(result, 0, 255, out=result)
return result.astype(np.uint8)
@@ -90,8 +91,8 @@ class WeatherColorStripStream(ColorStripStream):
def __init__(self, source, weather_manager: WeatherManager):
self._source_id = source.id
self._weather_source_id: str = source.weather_source_id
self._speed: float = source.speed
self._temperature_influence: float = source.temperature_influence
self._speed: float = bfloat(source.speed, 1.0)
self._temperature_influence: float = bfloat(source.temperature_influence, 0.5)
self._clock_id: Optional[str] = source.clock_id
self._weather_manager = weather_manager
@@ -137,11 +138,14 @@ class WeatherColorStripStream(ColorStripStream):
try:
self._weather_manager.acquire(self._weather_source_id)
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._thread = threading.Thread(
target=self._animate_loop, daemon=True,
target=self._animate_loop,
daemon=True,
name=f"WeatherCSS-{self._source_id[:12]}",
)
self._thread.start()
@@ -158,7 +162,9 @@ class WeatherColorStripStream(ColorStripStream):
try:
self._weather_manager.release(self._weather_source_id)
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}")
@@ -171,8 +177,8 @@ class WeatherColorStripStream(ColorStripStream):
self._led_count = device_led_count
def update_source(self, source) -> None:
self._speed = source.speed
self._temperature_influence = source.temperature_influence
self._speed = bfloat(source.speed, 1.0)
self._temperature_influence = bfloat(source.temperature_influence, 0.5)
self._clock_id = source.clock_id
# If weather source changed, release old + acquire new
@@ -239,7 +245,7 @@ class WeatherColorStripStream(ColorStripStream):
# Compute animation phase
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
for i in range(n):
@@ -248,8 +254,9 @@ class WeatherColorStripStream(ColorStripStream):
buf[i] = (c0 * (1.0 - s) + c1 * s).astype(np.uint8)
# Apply temperature shift
if self._temperature_influence > 0.0:
buf[:] = _apply_temperature_shift(buf, weather.temperature, self._temperature_influence)
temp_inf = self.resolve("temperature_influence", self._temperature_influence)
if temp_inf > 0.0:
buf[:] = _apply_temperature_shift(buf, weather.temperature, temp_inf)
# Thunderstorm flash effect
is_thunderstorm = weather.code in (95, 96, 99)
@@ -11,7 +11,11 @@ from typing import Optional
import httpx
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.processing.target_processor import (
DeviceInfo,
@@ -36,20 +40,29 @@ class WledTargetProcessor(TargetProcessor):
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = 30,
brightness=None,
# legacy compat
brightness_value_source_id: str = "",
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
ctx: TargetContext = None,
):
from wled_controller.storage.bindable import BindableFloat, bfloat
super().__init__(target_id, ctx)
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._state_check_interval = state_check_interval
self._css_id = color_strip_source_id
self._brightness_vs_id = brightness_value_source_id
self._min_brightness_threshold = min_brightness_threshold
# 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 "")
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._adaptive_fps = adaptive_fps
self._protocol = protocol
@@ -105,8 +118,10 @@ class WledTargetProcessor(TargetProcessor):
# Connect to LED device
try:
self._led_client = create_led_client(
device_info.device_type, device_info.device_url,
use_ddp=(self._protocol == "ddp"), led_count=device_info.led_count,
device_info.device_type,
device_info.device_url,
use_ddp=(self._protocol == "ddp"),
led_count=device_info.led_count,
baud_rate=device_info.baud_rate,
send_latency_ms=device_info.send_latency_ms,
rgbw=device_info.rgbw,
@@ -128,7 +143,11 @@ class WledTargetProcessor(TargetProcessor):
# Use client-reported LED count if available (more accurate than stored)
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
if effective_led_count != device_info.led_count:
@@ -142,7 +161,9 @@ class WledTargetProcessor(TargetProcessor):
f"device ({effective_led_count} LEDs)"
)
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:
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}")
@@ -168,9 +189,7 @@ class WledTargetProcessor(TargetProcessor):
self._resolved_display_index = getattr(stream, "display_index", None)
self._css_stream = stream
logger.info(
f"Acquired CSS stream '{self._css_id}' for target {self._target_id}"
)
logger.info(f"Acquired CSS stream '{self._css_id}' for target {self._target_id}")
except Exception as e:
if self._led_client:
await self._led_client.close()
@@ -178,13 +197,13 @@ class WledTargetProcessor(TargetProcessor):
raise RuntimeError(f"Failed to acquire CSS stream: {e}")
# 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:
self._value_stream = self._ctx.value_stream_manager.acquire(
self._brightness_vs_id
self._brightness.source_id
)
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
# Reset metrics and start loop
@@ -193,7 +212,9 @@ class WledTargetProcessor(TargetProcessor):
self._task = asyncio.create_task(self._processing_loop())
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:
if not self._is_running:
@@ -232,27 +253,34 @@ class WledTargetProcessor(TargetProcessor):
css_manager.remove_target_fps(self._css_id, self._target_id)
await asyncio.to_thread(css_manager.release, self._css_id, self._target_id)
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
# Release value stream
if self._value_stream is not None and self._ctx.value_stream_manager:
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:
logger.warning(f"Error releasing value stream: {e}")
self._value_stream = None
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 -----
def update_settings(self, settings: dict) -> None:
"""Update target-specific timing settings."""
from wled_controller.storage.bindable import bfloat
if isinstance(settings, dict):
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
css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._is_running and self._css_id:
@@ -262,7 +290,9 @@ class WledTargetProcessor(TargetProcessor):
if "state_check_interval" in settings:
self._state_check_interval = settings["state_check_interval"]
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:
self._adaptive_fps = settings["adaptive_fps"]
if not self._adaptive_fps:
@@ -286,7 +316,9 @@ class WledTargetProcessor(TargetProcessor):
return
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
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}")
def update_brightness_value_source(self, vs_id: str) -> None:
"""Hot-swap the brightness value source for a running target."""
old_vs_id = self._brightness_vs_id
self._brightness_vs_id = vs_id
vs_mgr = self._ctx.value_stream_manager
"""Legacy: hot-swap brightness value source by ID string."""
from wled_controller.storage.bindable import BindableFloat
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:
return
# Only swap streams if source_id actually changed
if old_vs_id == new_vs_id:
return
# Release old stream
if self._value_stream is not None and old_vs_id:
try:
@@ -329,14 +377,14 @@ class WledTargetProcessor(TargetProcessor):
self._value_stream = None
# Acquire new stream
if vs_id:
if new_vs_id:
try:
self._value_stream = vs_mgr.acquire(vs_id)
self._value_stream = vs_mgr.acquire(new_vs_id)
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
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:
"""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
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_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
if is_audio_source:
extract_ms = map_ms = smooth_ms = None
@@ -390,15 +440,17 @@ class WledTargetProcessor(TargetProcessor):
last_update = metrics.last_update
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
last_update = (
datetime.now(timezone.utc)
if elapsed < 1.0
else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
)
return {
"target_id": self._target_id,
"device_id": self._device_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,
"fps_actual": metrics.fps_actual 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
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
last_update = (
datetime.now(timezone.utc)
if elapsed < 1.0
else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
)
return {
@@ -457,7 +511,9 @@ class WledTargetProcessor(TargetProcessor):
def supports_overlay(self) -> bool:
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:
raise RuntimeError(f"Overlay already active for {self._target_id}")
@@ -484,7 +540,10 @@ class WledTargetProcessor(TargetProcessor):
await asyncio.to_thread(
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
@@ -548,6 +607,7 @@ class WledTargetProcessor(TargetProcessor):
# Check if source is composite with multiple layers
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
stream = self._css_stream
layer_colors = None
if isinstance(stream, CompositeColorStripStream):
@@ -555,15 +615,16 @@ class WledTargetProcessor(TargetProcessor):
if layer_colors and len(layer_colors) > 1:
led_count = len(colors)
header = bytes([brightness, 0xFE, len(layer_colors),
(led_count >> 8) & 0xFF, led_count & 0xFF])
header = bytes(
[brightness, 0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]
)
parts = [header]
for lc in layer_colors:
if len(lc) != led_count:
lc = self._fit_to_device(lc, led_count)
parts.append(lc.tobytes())
parts.append(colors.tobytes())
data = b''.join(parts)
data = b"".join(parts)
else:
data = bytes([brightness]) + colors.tobytes()
@@ -673,7 +734,9 @@ class WledTargetProcessor(TargetProcessor):
prev_frame_time_stamp = time.perf_counter()
asyncio.get_running_loop()
_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 = self._css_stream
@@ -695,19 +758,23 @@ class WledTargetProcessor(TargetProcessor):
_bright_n = _dn
_bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
_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 >>= 8
np.copyto(_bright_out, _bright_u16, casting='unsafe')
np.copyto(_bright_out, _bright_u16, casting="unsafe")
return _bright_out
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
vs = self._value_stream
if vs is not None:
vs_val = vs.get_value()
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
SKIP_REPOLL = 0.005 # 5 ms
@@ -781,7 +848,9 @@ class WledTargetProcessor(TargetProcessor):
if self._effective_fps < target_fps:
step = max(1, target_fps // 8)
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:
logger.info(
f"[ADAPTIVE] {self._target_id} device reachable, "
@@ -796,7 +865,11 @@ class WledTargetProcessor(TargetProcessor):
)
# 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:
_last_probe_time = now
_probe_task = asyncio.create_task(
@@ -850,7 +923,10 @@ class WledTargetProcessor(TargetProcessor):
if self._ctx.device_store:
try:
_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:
_cur_cspt_id = ""
if _cur_cspt_id != _cspt_cached_template_id:
@@ -858,21 +934,30 @@ class WledTargetProcessor(TargetProcessor):
_cspt_filters = []
if _cur_cspt_id and self._ctx.cspt_store:
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(
self._ctx.cspt_store.get_template(_cur_cspt_id).filters
)
_cspt_filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options)
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(
f"CSPT resolved {len(_cspt_filters)} filters for "
f"device {self._device_id} template {_cur_cspt_id}"
)
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 = []
if _cspt_filters:
for _flt in _cspt_filters:
@@ -895,7 +980,10 @@ class WledTargetProcessor(TargetProcessor):
# the last sent frame was also black, skip sending
# (but still send periodic keepalive to hold DDP live mode).
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:
break
send_colors = _cached_brightness(
@@ -907,7 +995,10 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = now
send_timestamps.append(now)
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)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
@@ -916,7 +1007,11 @@ class WledTargetProcessor(TargetProcessor):
continue
# 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
_force_colors = _cached_brightness(
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:
# 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:
break
send_colors = _cached_brightness(
@@ -939,7 +1038,10 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = now
send_timestamps.append(now)
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)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
@@ -977,7 +1079,10 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.frames_processed += 1
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(
f"Frame {self._metrics.frames_processed} for {self._target_id} "
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)
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()
except Exception as e:
self._metrics.errors_count += 1
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
next_frame_time += frame_time
@@ -1016,7 +1125,9 @@ class WledTargetProcessor(TargetProcessor):
jitter = actual_sleep - requested_sleep
_diag_sleep_jitters.append((requested_sleep, actual_sleep))
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:
next_frame_time = time.perf_counter()
@@ -1032,20 +1143,32 @@ class WledTargetProcessor(TargetProcessor):
if iter_end >= _diag_next_report:
_diag_next_report = iter_end + _diag_interval
self._emit_diagnostics(
self._target_id, _diag_sleep_jitters,
_diag_iter_times, _diag_slow_iters,
frame_time, _diag_interval,
self._target_id,
_diag_sleep_jitters,
_diag_iter_times,
_diag_slow_iters,
frame_time,
_diag_interval,
)
except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {self._target_id}")
raise
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.errors_count += 1
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
finally:
# Clean up probe client
@@ -32,13 +32,15 @@ def capture_current_snapshot(
continue
proc = processor_manager.get_processor(t.id)
running = proc.is_running if proc else False
targets.append(TargetSnapshot(
target_id=t.id,
running=running,
color_strip_source_id=getattr(t, "color_strip_source_id", ""),
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
fps=getattr(t, "fps", 30),
))
targets.append(
TargetSnapshot(
target_id=t.id,
running=running,
color_strip_source_id=getattr(t, "color_strip_source_id", ""),
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
fps=getattr(t, "fps", 30),
)
)
return targets
@@ -90,12 +92,16 @@ async def apply_scene_state(
proc = processor_manager.get_processor(ts.target_id)
if proc and proc.is_running:
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
if css_changed:
target.sync_with_manager(processor_manager, settings_changed=False, css_changed=True)
if bvs_changed:
target.sync_with_manager(processor_manager, settings_changed=False, brightness_vs_changed=True)
target.sync_with_manager(
processor_manager, settings_changed=False, css_changed=True
)
if brightness_changed:
target.sync_with_manager(
processor_manager, settings_changed=False, brightness_changed=True
)
if settings_changed:
target.sync_with_manager(processor_manager, settings_changed=True)
except ValueError:
@@ -1109,3 +1109,69 @@ textarea:focus-visible {
text-align: right;
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
{ 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: '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 },
// Automations
{ 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 },
// ── 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) ──
{ 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: '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: '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 },
];
@@ -3,6 +3,7 @@
*/
import ELK from 'elkjs/lib/elk.bundled.js';
import { bindableSourceId } from '../types.ts';
/* ── 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');
}
}
// 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
for (const t of e.outputTargets || []) {
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.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');
// KC target 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.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 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 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);
}
// ── 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 ───────────────────────────────────
export const ICON_AUTOMATION = _svg(P.clipboardList);
@@ -30,6 +30,8 @@ class AudioSourceModal extends Modal {
onForceClose() {
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
if (_asChannelIconSelect) { _asChannelIconSelect.destroy(); _asChannelIconSelect = null; }
if (_asBandIconSelect) { _asBandIconSelect.destroy(); _asBandIconSelect = null; }
}
snapshotValues() {
@@ -58,6 +60,7 @@ let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect: EntitySelect | null = null;
let _asBandParentEntitySelect: EntitySelect | 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>`;
@@ -136,7 +139,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
} else if (editData.source_type === 'mono') {
_loadMultichannelSources(editData.audio_source_id);
(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') {
_loadBandParentSources(editData.audio_source_id);
(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();
} else if (sourceType === 'mono') {
_loadMultichannelSources();
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
_ensureChannelIconSelect();
} else if (sourceType === 'band_extract') {
_loadBandParentSources();
(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) {
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
if (!select) return;
@@ -3,6 +3,7 @@
*/
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 { t } from '../core/i18n.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 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;
// ── Auto-name ──
@@ -732,7 +760,6 @@ function addAutomationConditionRow(condition: any) {
const entityId = data.entity_id || '';
const haState = data.state || '';
const matchMode = data.match_mode || 'exact';
// Build HA source options from cached data
const haOptions = _cachedHASources.map((s: any) =>
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
@@ -748,7 +775,9 @@ function addAutomationConditionRow(condition: any) {
</div>
<div class="condition-field">
<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 class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label>
@@ -763,6 +792,45 @@ function addAutomationConditionRow(condition: any) {
</select>
</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;
}
if (type === 'webhook') {
@@ -878,7 +946,7 @@ function getAutomationEditorConditions() {
conditions.push({
condition_type: 'home_assistant',
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,
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 { IconSelect } from '../core/icon-select.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>`;
@@ -24,6 +25,7 @@ let _compositeSourceEntitySelects: any[] = [];
let _compositeBrightnessEntitySelects: any[] = [];
let _compositeBlendIconSelects: any[] = [];
let _compositeCSPTEntitySelects: any[] = [];
let _compositeOpacityWidgets: BindableScalarWidget[] = [];
/** Return current composite layers array (for dirty-check snapshot). */
export function compositeGetRawLayers() {
@@ -47,6 +49,8 @@ export function compositeDestroyEntitySelects() {
_compositeBlendIconSelects = [];
_compositeCSPTEntitySelects.forEach(es => es.destroy());
_compositeCSPTEntitySelects = [];
_compositeOpacityWidgets.forEach(w => w.destroy());
_compositeOpacityWidgets = [];
}
function _getCompositeBlendItems() {
@@ -140,10 +144,8 @@ export function compositeRenderList() {
<div class="composite-layer-row">
<label class="composite-layer-opacity-label">
<span>${t('color_strip.composite.opacity')}:</span>
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
</label>
<input type="range" class="composite-layer-opacity" data-idx="${i}"
min="0" max="1" step="0.05" value="${layer.opacity}">
<div class="composite-layer-opacity-container" data-idx="${i}"></div>
</div>
<div class="composite-layer-row">
<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
const blendItems = _getCompositeBlendItems();
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);
}
@@ -306,7 +313,6 @@ function _compositeLayersSyncFromDom() {
if (!list) return;
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
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 briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
@@ -317,7 +323,7 @@ function _compositeLayersSyncFromDom() {
for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[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].brightness_source_id = briSrcs[i] ? (briSrcs[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 { EntitySelect } from '../core/entity-palette.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 { BindableScalarWidget } from '../core/bindable-scalar.ts';
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 _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() {
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-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
const dur = 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;
_ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500);
(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';
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-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
_ensureNotificationDurationWidget().setValue(1500);
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
@@ -15,7 +15,7 @@ import {
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.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 ───────────────────────────────────── */
@@ -49,7 +49,7 @@ function _collectPreviewConfig() {
config = {
source_type: 'notification',
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,
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
@@ -3,7 +3,7 @@
*/
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 { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -18,9 +18,11 @@ import {
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts';
import { bindableValue } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { getBaseOrigin } from './settings.ts';
import {
rgbArrayToHex, hexToRgbArray,
@@ -39,6 +41,7 @@ import {
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
loadNotificationState, resetNotificationState, showNotificationEndpoint,
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
} from './color-strips-notification.ts';
// Re-export for app.js window global bindings
@@ -58,8 +61,22 @@ class CSSEditorModal extends Modal {
onForceClose() {
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 (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; }
if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; }
compositeDestroyEntitySelects();
}
@@ -70,7 +87,7 @@ class CSSEditorModal extends Modal {
type,
picture_source: (document.getElementById('css-editor-picture-source') 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,
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
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_palette: (document.getElementById('css-editor-effect-palette') 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_scale: (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value,
effect_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0',
effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0',
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
composite_layers: JSON.stringify(compositeGetRawLayers()),
mapped_zones: JSON.stringify(_mappedZones),
audio_viz: (document.getElementById('css-editor-audio-viz') 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_smoothing: (document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value,
audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
audio_palette: (document.getElementById('css-editor-audio-palette') 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_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_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,
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_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_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,
@@ -107,11 +124,13 @@ class CSSEditorModal extends Modal {
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,
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_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_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),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
@@ -121,6 +140,17 @@ class CSSEditorModal extends Modal {
const cssEditorModal = new CSSEditorModal();
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 ──
let _cssPictureSourceEntitySelect: any = null;
@@ -130,6 +160,8 @@ let _processedInputEntitySelect: any = null;
let _processedTemplateEntitySelect: any = null;
let _kcPictureSourceEntitySelect: any = null;
let _kcInterpolationIconSelect: any = null;
let _kcSmoothingWidget: BindableScalarWidget | null = null;
let _kcBrightnessWidget: BindableScalarWidget | null = null;
// ── Key Colors rectangle editor state ──
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 });
}
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() {
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
if (!sel) return;
@@ -992,13 +1193,8 @@ function _loadAudioState(css: any) {
if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
onAudioVizChange();
const sensitivity = css.sensitivity ?? 1.0;
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = sensitivity;
(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);
_ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0);
_ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3);
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
@@ -1017,10 +1213,8 @@ function _loadAudioState(css: any) {
function _resetAudioState() {
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum';
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0';
(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';
_ensureAudioSensitivityWidget().setValue(1.0);
_ensureAudioSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
@@ -1127,7 +1321,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
},
api_input: (source) => {
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';
return `
<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 }) => {
const useRealTime = source.use_real_time;
const speedVal = (source.speed ?? 1.0).toFixed(1);
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
return `
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
${clockBadge}
@@ -1171,8 +1365,8 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
`;
},
weather: (source, { clockBadge }) => {
const speedVal = (source.speed ?? 1.0).toFixed(1);
const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1);
const speedVal = bindableValue(source.speed, 1.0).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 wsName = ws?.name || '—';
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;
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-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
(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);
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
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';
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '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';
_ensureEffectIntensityWidget().setValue(1.0);
_ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
},
getPayload(name) {
@@ -1443,8 +1633,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name,
effect_type: (document.getElementById('css-editor-effect-type') 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),
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
intensity: _ensureEffectIntensityWidget().getValue(),
scale: _ensureEffectScaleWidget().getValue(),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
};
// Meteor/comet/bouncing_ball use a color picker
@@ -1468,8 +1658,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name,
visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value),
smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value),
sensitivity: _ensureAudioSensitivityWidget().getValue(),
smoothing: _ensureAudioSmoothingWidget().getValue(),
gradient_id: (document.getElementById('css-editor-audio-palette') 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),
@@ -1519,17 +1709,14 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
load(css) {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0;
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1);
_ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(css.id);
},
reset() {
(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;
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0';
_ensureApiInputTimeoutWidget().setValue(5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(null);
@@ -1539,7 +1726,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return {
name,
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,
};
},
@@ -1558,7 +1745,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name,
os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
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,
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
@@ -1602,25 +1789,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
candlelight: {
load(css) {
(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;
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
_ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
(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;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
(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);
_ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0);
_ensureCandlelightWindWidget().setValue(css.wind_strength ?? 0.0);
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
},
reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = '1.0';
_ensureCandlelightIntensityWidget().setValue(1.0);
(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;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.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';
_ensureCandlelightSpeedWidget().setValue(1.0);
_ensureCandlelightWindWidget().setValue(0.0);
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
},
@@ -1628,10 +1809,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return {
name,
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,
speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value),
wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value),
speed: _ensureCandlelightSpeedWidget().getValue(),
wind_strength: _ensureCandlelightWindWidget().getValue(),
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();
_populateWeatherSourceDropdown();
(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;
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
(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);
_ensureWeatherSpeedWidget().setValue(css.speed ?? 1.0);
_ensureWeatherTempInfluenceWidget().setValue(css.temperature_influence ?? 0.5);
},
async reset() {
await weatherSourcesCache.fetch();
_populateWeatherSourceDropdown();
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0';
(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';
_ensureWeatherSpeedWidget().setValue(1.0);
_ensureWeatherTempInfluenceWidget().setValue(0.5);
},
getPayload(name) {
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 {
name,
weather_source_id: wsId,
speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value),
temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value),
speed: _ensureWeatherSpeedWidget().getValue(),
temperature_influence: _ensureWeatherTempInfluenceWidget().getValue(),
};
},
},
@@ -1702,21 +1879,18 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
load(css) {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
_ensureSmoothingWidget().setValue(css.smoothing);
},
reset() {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
_ensureSmoothingWidget().setValue(0.3);
},
getPayload(name) {
return {
name,
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,
};
},
@@ -1726,22 +1900,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
sourceSelect.value = css.picture_source_id || '';
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
_ensureSmoothingWidget().setValue(css.smoothing);
},
reset() {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
_ensureSmoothingWidget().setValue(0.3);
},
getPayload(name) {
return {
name,
picture_source_id: (document.getElementById('css-editor-picture-source') 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,
};
},
@@ -1775,11 +1946,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
columns: 1,
});
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing;
(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);
_ensureKcSmoothingWidget().setValue(css.smoothing ?? 0.3);
_ensureKcBrightnessWidget().setValue(css.brightness ?? 1.0);
// Load rectangles
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
_renderKCRectSummary();
@@ -1810,10 +1978,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
columns: 1,
});
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30';
(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';
_ensureKcSmoothingWidget().setValue(0.3);
_ensureKcBrightnessWidget().setValue(1.0);
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
_renderKCRectSummary();
},
@@ -1832,8 +1998,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
picture_source_id: psId,
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,
smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value),
brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value),
smoothing: _ensureKcSmoothingWidget().getValue(),
brightness: _ensureKcBrightnessWidget().getValue(),
};
},
},
@@ -7,11 +7,13 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.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 { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.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 = (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 _editorCssSources: any[] = [];
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 {
constructor() { super('ha-light-editor-modal'); }
@@ -34,6 +40,10 @@ class HALightEditorModal extends Modal {
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = 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();
}
@@ -42,8 +52,10 @@ class HALightEditorModal extends Modal {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value,
transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value,
update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
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(),
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
};
@@ -77,7 +89,7 @@ function _getEntityItems() {
.map((e: any) => ({
value: e.entity_id,
label: e.friendly_name || e.entity_id,
icon: _icon(P.lightbulb),
icon: getHAEntityIcon(e),
desc: e.state || '',
}));
}
@@ -201,6 +213,60 @@ export function removeHALightMapping(btn: HTMLElement): void {
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 ──
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 || '';
haSelect.value = editData.ha_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);
document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1);
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5);
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
_ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
// 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));
} else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0';
document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0';
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
_ensureUpdateRateWidget().setValue(2.0);
_ensureTransitionWidget().setValue(0.5);
_ensureColorToleranceWidget().setValue(5);
_ensureMinBrightnessThresholdWidget().setValue(0);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
// 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;
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
_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('');
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_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 haSourceId = (document.getElementById('ha-light-editor-ha-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 transitionRaw = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value);
const transition = isNaN(transitionRaw) ? 0.5 : transitionRaw;
const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0;
const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
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;
if (!name) {
@@ -369,10 +436,12 @@ export async function saveHALightEditor(): Promise<void> {
name,
ha_source_id: haSourceId,
color_strip_source_id: cssSourceId,
brightness_value_source_id: brightnessVsId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
color_tolerance: colorTolerance,
min_brightness_threshold: minBrightnessThreshold,
description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
@@ -450,7 +519,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
: '';
// Brightness value source
const bvsId = target.brightness_value_source_id || '';
const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
return wrapCard({
@@ -36,6 +36,8 @@ import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash, updateTabBadge } from './tabs.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
// (pattern-templates.js calls window.loadTargetsTab)
@@ -142,6 +144,8 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
// --- Editor state ---
let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null;
let _fpsWidget: BindableScalarWidget | null = null;
let _thresholdWidget: BindableScalarWidget | null = null;
class TargetEditorModal extends Modal {
constructor() {
@@ -155,8 +159,8 @@ class TargetEditorModal extends Modal {
protocol: (document.getElementById('target-editor-protocol') 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_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value,
fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value,
brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0',
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
@@ -329,6 +333,32 @@ function _ensureProtocolIconSelect() {
_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) {
try {
// 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-name') as HTMLInputElement).value = target.name;
deviceSelect.value = target.device_id || '';
const fps = target.fps ?? 30;
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
_ensureFpsWidget().setValue(target.fps ?? 30);
(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-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
const thresh = 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;
_ensureThresholdWidget().setValue(target.min_brightness_threshold ?? 0);
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
_populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(target.brightness));
} else if (cloneData) {
// Cloning — create mode but pre-filled from clone data
_editorTags = cloneData.tags || [];
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
(document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
deviceSelect.value = cloneData.device_id || '';
const fps = cloneData.fps ?? 30;
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
_ensureFpsWidget().setValue(cloneData.fps ?? 30);
(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-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
const cloneThresh = 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;
_ensureThresholdWidget().setValue(cloneData.min_brightness_threshold ?? 0);
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
_populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness));
} else {
// Creating new target
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
(document.getElementById('target-editor-name') as HTMLInputElement).value = '';
(document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30';
_ensureFpsWidget().setValue(30);
(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-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any;
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0';
_ensureThresholdWidget().setValue(0);
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
@@ -472,6 +492,8 @@ export async function closeTargetEditorModal() {
export function forceCloseTargetEditorModal() {
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
targetEditorModal.forceClose();
}
@@ -486,11 +508,11 @@ export async function saveTargetEditor() {
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 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 protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
@@ -499,7 +521,7 @@ export async function saveTargetEditor() {
name,
device_id: deviceId,
color_strip_source_id: colorStripSourceId,
brightness_value_source_id: brightnessVsId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
min_brightness_threshold: minBrightnessThreshold,
fps,
keepalive_interval: standbyInterval,
@@ -958,7 +980,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
const cssId = target.color_strip_source_id || '';
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
const bvsId = target.brightness_value_source_id || '';
const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
// Determine if overlay is available (picture-based CSS)
@@ -990,11 +1012,11 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
</div>
<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" 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${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>` : ''}
${(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>
${renderTagChips(target.tags)}
<div class="card-content">
+37 -17
View File
@@ -5,6 +5,25 @@
* 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 ────────────────────────────────────────────────────
export type DeviceType =
@@ -59,27 +78,27 @@ export interface OutputTarget {
// LED target fields
device_id?: string;
color_strip_source_id?: string;
brightness_value_source_id?: string;
fps?: number;
brightness?: BindableFloat;
fps?: BindableFloat;
keepalive_interval?: number;
state_check_interval?: number;
min_brightness_threshold?: number;
min_brightness_threshold?: BindableFloat;
adaptive_fps?: boolean;
protocol?: string;
// HA light target fields
ha_source_id?: string;
ha_light_mappings?: HALightMapping[];
update_rate?: number;
ha_transition?: number;
color_tolerance?: number;
update_rate?: BindableFloat;
transition?: BindableFloat;
color_tolerance?: BindableFloat;
}
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: number;
brightness_scale: BindableFloat;
}
// ── Color Strip Source ────────────────────────────────────────
@@ -165,7 +184,7 @@ export interface ColorStripSource {
// Picture
picture_source_id?: string;
smoothing?: number;
smoothing?: BindableFloat;
interpolation_mode?: string;
calibration?: Calibration;
@@ -181,8 +200,8 @@ export interface ColorStripSource {
// Effect
effect_type?: string;
palette?: string;
intensity?: number;
scale?: number;
intensity?: BindableFloat;
scale?: BindableFloat;
mirror?: boolean;
// Composite
@@ -194,16 +213,16 @@ export interface ColorStripSource {
// Audio
visualization_mode?: string;
audio_source_id?: string;
sensitivity?: number;
sensitivity?: BindableFloat;
color_peak?: number[];
// Animation
animation?: AnimationConfig;
speed?: number;
speed?: BindableFloat;
// API Input
fallback_color?: number[];
timeout?: number;
timeout?: BindableFloat;
interpolation?: string;
// Notification
@@ -214,6 +233,7 @@ export interface ColorStripSource {
app_filter_mode?: string;
app_filter_list?: string[];
os_listener?: boolean;
sound_volume?: BindableFloat;
// Daylight
use_real_time?: boolean;
@@ -221,6 +241,7 @@ export interface ColorStripSource {
// Candlelight
num_candles?: number;
wind_strength?: BindableFloat;
// Processed
input_source_id?: string;
@@ -228,12 +249,11 @@ export interface ColorStripSource {
// Weather
weather_source_id?: string;
temperature_influence?: number;
temperature_influence?: BindableFloat;
// Key Colors
rectangles?: KeyColorRectangle[];
brightness?: number;
brightness_value_source_id?: string;
brightness?: BindableFloat;
}
// ── Pattern Template ──────────────────────────────────────────
@@ -370,7 +390,7 @@ export interface TargetSnapshot {
target_id: string;
running: boolean;
color_strip_source_id: string;
brightness_value_source_id: string;
brightness?: BindableFloat;
fps: number;
}
@@ -1,5 +1,7 @@
{
"app.title": "LED Grab",
"bindable.none": "None (static value)",
"bindable.toggle": "Toggle value source binding",
"app.version": "Version:",
"app.api_docs": "API Documentation",
"app.connection_lost": "Server unreachable",
@@ -1331,9 +1333,12 @@
"audio_source.parent.hint": "Multichannel source to extract a channel from",
"audio_source.channel": "Channel:",
"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.desc": "Left channel only",
"audio_source.channel.right": "Right",
"audio_source.channel.right.desc": "Right channel only",
"audio_source.description": "Description (optional):",
"audio_source.description.placeholder": "Describe 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.transition": "Transition:",
"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.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
"ha_light.mappings.add": "Add Mapping",
@@ -1830,6 +1839,9 @@
"automations.condition.home_assistant.state": "State:",
"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.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.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
"graph.title": "Graph",
@@ -1,5 +1,11 @@
{
"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.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен",
@@ -1,5 +1,11 @@
{
"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.api_docs": "API 文档",
"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_to_dict,
)
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.utils import resolve_ref
@@ -151,7 +152,7 @@ def _parse_picture_fields(data: dict) -> dict:
)
return dict(
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",
calibration=calibration,
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:
"""Populate dict with fields common to both picture source types."""
d["fps"] = source.fps
d["smoothing"] = source.smoothing
d["smoothing"] = source.smoothing.to_dict()
d["interpolation_mode"] = source.interpolation_mode
d["calibration"] = calibration_to_dict(source.calibration)
d["led_count"] = source.led_count
@@ -173,7 +174,7 @@ def _apply_picture_update(source, **kwargs) -> None:
if kwargs.get("fps") is not None:
source.fps = kwargs["fps"]
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:
source.interpolation_mode = kwargs["interpolation_mode"]
if kwargs.get("calibration") is not None:
@@ -197,7 +198,7 @@ class PictureColorStripSource(ColorStripSource):
picture_source_id: str = ""
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"
calibration: CalibrationConfig = field(
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -234,7 +235,7 @@ class PictureColorStripSource(ColorStripSource):
tags=None,
picture_source_id="",
fps=30,
smoothing=0.3,
smoothing=None,
interpolation_mode="average",
calibration=None,
led_count=0,
@@ -253,7 +254,7 @@ class PictureColorStripSource(ColorStripSource):
tags=tags or [],
picture_source_id=picture_source_id,
fps=fps,
smoothing=smoothing,
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
interpolation_mode=interpolation_mode,
calibration=calibration,
led_count=led_count,
@@ -281,7 +282,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
return True
fps: int = 30
smoothing: float = 0.3
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
interpolation_mode: str = "average"
calibration: CalibrationConfig = field(
default_factory=lambda: CalibrationConfig(mode="advanced")
@@ -311,7 +312,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
clock_id=None,
tags=None,
fps=30,
smoothing=0.3,
smoothing=None,
interpolation_mode="average",
calibration=None,
led_count=0,
@@ -329,7 +330,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
clock_id=clock_id,
tags=tags or [],
fps=fps,
smoothing=smoothing,
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
interpolation_mode=interpolation_mode,
calibration=calibration,
led_count=led_count,
@@ -593,8 +594,8 @@ class EffectColorStripSource(ColorStripSource):
color: list = field(
default_factory=lambda: [255, 80, 0]
) # [R,G,B] for meteor/comet/bouncing_ball head
intensity: float = 1.0 # effect-specific intensity (0.1-2.0)
scale: float = 1.0 # spatial scale / zoom (0.5-5.0)
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
mirror: bool = False # bounce mode (meteor/comet)
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["gradient_id"] = self.gradient_id
d["color"] = list(self.color)
d["intensity"] = self.intensity
d["scale"] = self.scale
d["intensity"] = self.intensity.to_dict()
d["scale"] = self.scale.to_dict()
d["mirror"] = self.mirror
d["custom_palette"] = self.custom_palette
return d
@@ -621,8 +622,8 @@ class EffectColorStripSource(ColorStripSource):
palette=data.get("palette") or "fire",
gradient_id=data.get("gradient_id"),
color=color,
intensity=float(data.get("intensity") or 1.0),
scale=float(data.get("scale") or 1.0),
intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
mirror=bool(data.get("mirror", False)),
custom_palette=data.get("custom_palette"),
)
@@ -643,8 +644,8 @@ class EffectColorStripSource(ColorStripSource):
palette="fire",
gradient_id=None,
color=None,
intensity=1.0,
scale=1.0,
intensity=None,
scale=None,
mirror=False,
custom_palette=None,
**_kwargs,
@@ -663,8 +664,8 @@ class EffectColorStripSource(ColorStripSource):
palette=palette or "fire",
gradient_id=gradient_id,
color=rgb,
intensity=float(intensity) if intensity else 1.0,
scale=float(scale) if scale else 1.0,
intensity=BindableFloat.from_raw(intensity, default=1.0),
scale=BindableFloat.from_raw(scale, default=1.0),
mirror=bool(mirror),
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:
self.color = color
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:
self.scale = float(kwargs["scale"])
self.scale = self.scale.apply_update(kwargs["scale"])
if kwargs.get("mirror") is not None:
self.mirror = bool(kwargs["mirror"])
if "custom_palette" in kwargs:
@@ -701,8 +702,8 @@ class AudioColorStripSource(ColorStripSource):
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
audio_source_id: str = "" # references a MonoAudioSource
sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
palette: str = "rainbow" # legacy palette name (kept for migration)
gradient_id: Optional[str] = None # references a Gradient entity (preferred)
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["visualization_mode"] = self.visualization_mode
d["audio_source_id"] = self.audio_source_id
d["sensitivity"] = self.sensitivity
d["smoothing"] = self.smoothing
d["sensitivity"] = self.sensitivity.to_dict()
d["smoothing"] = self.smoothing.to_dict()
d["palette"] = self.palette
d["gradient_id"] = self.gradient_id
d["color"] = list(self.color)
@@ -734,8 +735,8 @@ class AudioColorStripSource(ColorStripSource):
source_type="audio",
visualization_mode=data.get("visualization_mode") or "spectrum",
audio_source_id=data.get("audio_source_id") or "",
sensitivity=float(data.get("sensitivity") or 1.0),
smoothing=float(data.get("smoothing") or 0.3),
sensitivity=BindableFloat.from_raw(data.get("sensitivity"), default=1.0),
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
palette=data.get("palette") or "rainbow",
gradient_id=data.get("gradient_id"),
color=color,
@@ -758,8 +759,8 @@ class AudioColorStripSource(ColorStripSource):
tags=None,
visualization_mode="spectrum",
audio_source_id="",
sensitivity=1.0,
smoothing=0.3,
sensitivity=None,
smoothing=None,
palette="rainbow",
gradient_id=None,
color=None,
@@ -781,8 +782,8 @@ class AudioColorStripSource(ColorStripSource):
tags=tags or [],
visualization_mode=visualization_mode or "spectrum",
audio_source_id=audio_source_id or "",
sensitivity=float(sensitivity) if sensitivity else 1.0,
smoothing=float(smoothing) if smoothing else 0.3,
sensitivity=BindableFloat.from_raw(sensitivity, default=1.0),
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
palette=palette or "rainbow",
gradient_id=gradient_id,
color=rgb,
@@ -798,9 +799,9 @@ class AudioColorStripSource(ColorStripSource):
if audio_source_id is not None:
self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id)
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:
self.smoothing = float(kwargs["smoothing"])
self.smoothing = self.smoothing.apply_update(kwargs["smoothing"])
if kwargs.get("palette") is not None:
self.palette = kwargs["palette"]
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]
timeout: float = 5.0 # seconds before reverting to fallback
timeout: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
interpolation: str = "linear" # none | linear | nearest
def to_dict(self) -> dict:
d = super().to_dict()
d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout
d["timeout"] = self.timeout.to_dict()
d["interpolation"] = self.interpolation
return d
@@ -984,7 +985,7 @@ class ApiInputColorStripSource(ColorStripSource):
**common,
source_type="api_input",
fallback_color=fallback_color,
timeout=float(data.get("timeout") or 5.0),
timeout=BindableFloat.from_raw(data.get("timeout"), default=5.0),
interpolation=interpolation,
)
@@ -1017,7 +1018,7 @@ class ApiInputColorStripSource(ColorStripSource):
clock_id=clock_id,
tags=tags or [],
fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0,
timeout=BindableFloat.from_raw(timeout, default=5.0),
interpolation=interp,
)
@@ -1030,7 +1031,7 @@ class ApiInputColorStripSource(ColorStripSource):
):
self.fallback_color = fallback_color
if kwargs.get("timeout") is not None:
self.timeout = float(kwargs["timeout"])
self.timeout = self.timeout.apply_update(kwargs["timeout"])
interpolation = kwargs.get("interpolation")
if interpolation in ("none", "linear", "nearest"):
self.interpolation = interpolation
@@ -1048,14 +1049,14 @@ class NotificationColorStripSource(ColorStripSource):
"""
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
app_colors: dict = field(default_factory=dict) # app name -> hex color
app_filter_mode: str = "off" # off | whitelist | blacklist
app_filter_list: list = field(default_factory=list) # app names for filter
os_listener: bool = False # whether to listen for OS notifications
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(
default_factory=dict
) # app name -> {"sound_asset_id": str|None, "volume": float|None}
@@ -1063,14 +1064,14 @@ class NotificationColorStripSource(ColorStripSource):
def to_dict(self) -> dict:
d = super().to_dict()
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["app_colors"] = dict(self.app_colors)
d["app_filter_mode"] = self.app_filter_mode
d["app_filter_list"] = list(self.app_filter_list)
d["os_listener"] = self.os_listener
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)
return d
@@ -1084,14 +1085,14 @@ class NotificationColorStripSource(ColorStripSource):
**common,
source_type="notification",
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",
app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
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 [],
os_listener=bool(data.get("os_listener", False)),
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 {},
)
@@ -1129,14 +1130,14 @@ class NotificationColorStripSource(ColorStripSource):
clock_id=clock_id,
tags=tags or [],
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",
app_colors=app_colors if isinstance(app_colors, dict) else {},
app_filter_mode=app_filter_mode or "off",
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,
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 {},
)
@@ -1144,7 +1145,7 @@ class NotificationColorStripSource(ColorStripSource):
if kwargs.get("notification_effect") is not None:
self.notification_effect = kwargs["notification_effect"]
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:
self.default_color = kwargs["default_color"]
app_colors = kwargs.get("app_colors")
@@ -1160,7 +1161,7 @@ class NotificationColorStripSource(ColorStripSource):
if "sound_asset_id" in kwargs:
self.sound_asset_id = kwargs["sound_asset_id"]
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")
if app_sounds is not None and isinstance(app_sounds, dict):
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).
"""
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
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
longitude: float = 0.0 # longitude for solar position (-180..180)
def to_dict(self) -> dict:
d = super().to_dict()
d["speed"] = self.speed
d["speed"] = self.speed.to_dict()
d["use_real_time"] = self.use_real_time
d["latitude"] = self.latitude
d["longitude"] = self.longitude
@@ -1199,9 +1200,10 @@ class DaylightColorStripSource(ColorStripSource):
return cls(
**common,
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)),
latitude=float(data.get("latitude") or 50.0),
longitude=float(data.get("longitude") or 0.0),
)
@classmethod
@@ -1231,7 +1233,7 @@ class DaylightColorStripSource(ColorStripSource):
description=description,
clock_id=clock_id,
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,
latitude=float(latitude) if latitude is not None else 50.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:
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:
self.use_real_time = bool(kwargs["use_real_time"])
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]
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
speed: float = 1.0 # flicker speed multiplier
wind_strength: float = 0.0 # wind effect (0.0-2.0)
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
wind_strength: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
candle_type: str = "default" # default | taper | votive | bonfire
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["intensity"] = self.intensity
d["intensity"] = self.intensity.to_dict()
d["num_candles"] = self.num_candles
d["speed"] = self.speed
d["wind_strength"] = self.wind_strength
d["speed"] = self.speed.to_dict()
d["wind_strength"] = self.wind_strength.to_dict()
d["candle_type"] = self.candle_type
return d
@@ -1282,9 +1284,11 @@ class CandlelightColorStripSource(ColorStripSource):
**common,
source_type="candlelight",
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),
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
@@ -1300,7 +1304,7 @@ class CandlelightColorStripSource(ColorStripSource):
clock_id=None,
tags=None,
color=None,
intensity=1.0,
intensity=None,
num_candles=None,
speed=None,
wind_strength=None,
@@ -1318,10 +1322,10 @@ class CandlelightColorStripSource(ColorStripSource):
clock_id=clock_id,
tags=tags or [],
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,
speed=float(speed) if speed is not None else 1.0,
wind_strength=float(wind_strength) if wind_strength is not None else 0.0,
speed=BindableFloat.from_raw(speed, default=1.0),
wind_strength=BindableFloat.from_raw(wind_strength, default=0.0),
candle_type=(
candle_type
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:
self.color = color
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:
self.num_candles = int(kwargs["num_candles"])
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:
self.wind_strength = float(kwargs["wind_strength"])
self.wind_strength = self.wind_strength.apply_update(kwargs["wind_strength"])
ct = kwargs.get("candle_type")
if ct is not None and ct in {"default", "taper", "votive", "bonfire"}:
self.candle_type = ct
@@ -1425,14 +1429,14 @@ class WeatherColorStripSource(ColorStripSource):
"""
weather_source_id: str = "" # reference to WeatherSource entity
speed: float = 1.0 # ambient drift animation speed
temperature_influence: float = 0.5 # 0.0=none, 1.0=full temp hue shift
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
temperature_influence: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
def to_dict(self) -> dict:
d = super().to_dict()
d["weather_source_id"] = self.weather_source_id
d["speed"] = self.speed
d["temperature_influence"] = self.temperature_influence
d["speed"] = self.speed.to_dict()
d["temperature_influence"] = self.temperature_influence.to_dict()
return d
@classmethod
@@ -1442,11 +1446,9 @@ class WeatherColorStripSource(ColorStripSource):
**common,
source_type="weather",
weather_source_id=data.get("weather_source_id", ""),
speed=float(data.get("speed") or 1.0),
temperature_influence=float(
data.get("temperature_influence")
if data.get("temperature_influence") is not None
else 0.5
speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
temperature_influence=BindableFloat.from_raw(
data.get("temperature_influence"), default=0.5
),
)
@@ -1477,10 +1479,8 @@ class WeatherColorStripSource(ColorStripSource):
clock_id=clock_id,
tags=tags or [],
weather_source_id=weather_source_id or "",
speed=float(speed) if speed is not None else 1.0,
temperature_influence=(
float(temperature_influence) if temperature_influence is not None else 0.5
),
speed=BindableFloat.from_raw(speed, default=1.0),
temperature_influence=BindableFloat.from_raw(temperature_influence, default=0.5),
)
def apply_update(self, **kwargs) -> None:
@@ -1489,9 +1489,11 @@ class WeatherColorStripSource(ColorStripSource):
kwargs["weather_source_id"], self.weather_source_id
)
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:
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 = ""
rectangles: List[KeyColorRectangle] = field(default_factory=list)
interpolation_mode: str = "average" # average, median, dominant
smoothing: float = 0.3
brightness: float = 1.0
brightness_value_source_id: str = ""
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
@property
def sharable(self) -> bool:
@@ -1549,24 +1550,28 @@ class KeyColorsColorStripSource(ColorStripSource):
d["picture_source_id"] = self.picture_source_id
d["rectangles"] = [r.to_dict() for r in self.rectangles]
d["interpolation_mode"] = self.interpolation_mode
d["smoothing"] = self.smoothing
d["brightness"] = self.brightness
d["brightness_value_source_id"] = self.brightness_value_source_id
d["smoothing"] = self.smoothing.to_dict()
d["brightness"] = self.brightness.to_dict()
return d
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsColorStripSource":
common = _parse_css_common(data)
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(
**common,
source_type="key_colors",
picture_source_id=data.get("picture_source_id", ""),
rectangles=rects,
interpolation_mode=data.get("interpolation_mode", "average"),
smoothing=float(data.get("smoothing") if data.get("smoothing") is not None else 0.3),
brightness=float(data.get("brightness") if data.get("brightness") is not None else 1.0),
brightness_value_source_id=data.get("brightness_value_source_id", ""),
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
brightness=brightness,
)
@classmethod
@@ -1592,6 +1597,14 @@ class KeyColorsColorStripSource(ColorStripSource):
rects = [
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(
id=id,
name=name,
@@ -1604,9 +1617,8 @@ class KeyColorsColorStripSource(ColorStripSource):
picture_source_id=picture_source_id or "",
rectangles=rects,
interpolation_mode=interpolation_mode or "average",
smoothing=float(smoothing) if smoothing is not None else 0.3,
brightness=float(brightness) if brightness is not None else 1.0,
brightness_value_source_id=brightness_value_source_id or "",
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
brightness=bright,
)
def apply_update(self, **kwargs) -> None:
@@ -1622,12 +1634,16 @@ class KeyColorsColorStripSource(ColorStripSource):
if kwargs.get("interpolation_mode") is not None:
self.interpolation_mode = kwargs["interpolation_mode"]
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:
self.brightness = float(kwargs["brightness"])
self.brightness = self.brightness.apply_update(kwargs["brightness"])
if kwargs.get("brightness_value_source_id") is not None:
self.brightness_value_source_id = resolve_ref(
kwargs["brightness_value_source_id"], self.brightness_value_source_id
# Legacy compat: update just the source_id part
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 typing import List, Optional
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.output_target import OutputTarget
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)
from wled_controller.storage.utils import resolve_ref
@dataclass
@@ -19,14 +16,14 @@ class HALightMapping:
entity_id: str = "" # e.g. "light.living_room"
led_start: int = 0 # start LED index (0-based)
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:
return {
"entity_id": self.entity_id,
"led_start": self.led_start,
"led_end": self.led_end,
"brightness_scale": self.brightness_scale,
"brightness_scale": self.brightness_scale.to_dict(),
}
@classmethod
@@ -35,7 +32,7 @@ class HALightMapping:
entity_id=data.get("entity_id", ""),
led_start=data.get("led_start", 0),
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
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)
update_rate: float = 2.0 # Hz (calls per second, 0.5-5.0)
transition: float = 0.5 # HA transition seconds (smooth fade between colors)
min_brightness_threshold: int = 0 # below this brightness → turn off light
color_tolerance: int = 5 # skip service call if RGB delta < this
update_rate: BindableFloat = field(default_factory=lambda: BindableFloat(2.0))
transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
def register_with_manager(self, manager) -> None:
"""Register this HA light target with the processor manager."""
@@ -59,7 +56,7 @@ class HALightOutputTarget(OutputTarget):
target_id=self.id,
ha_source_id=self.ha_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,
update_rate=self.update_rate,
transition=self.transition,
@@ -82,6 +79,7 @@ class HALightOutputTarget(OutputTarget):
manager.update_target_settings(
self.id,
{
"brightness": self.brightness,
"update_rate": self.update_rate,
"transition": self.transition,
"min_brightness_threshold": self.min_brightness_threshold,
@@ -98,6 +96,8 @@ class HALightOutputTarget(OutputTarget):
name=None,
ha_source_id=None,
color_strip_source_id=None,
brightness=None,
# legacy compat
brightness_value_source_id=None,
light_mappings=None,
update_rate=None,
@@ -111,53 +111,66 @@ class HALightOutputTarget(OutputTarget):
"""Apply mutable field updates."""
super().update_fields(name=name, description=description, tags=tags)
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:
self.color_strip_source_id = _resolve_ref(
self.color_strip_source_id = resolve_ref(
color_strip_source_id, self.color_strip_source_id
)
if brightness_value_source_id is not None:
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 light_mappings is not None:
self.light_mappings = light_mappings
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:
self.transition = max(0.0, min(10.0, float(transition)))
self.transition = self.transition.apply_update(transition)
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:
self.color_tolerance = int(color_tolerance)
self.color_tolerance = self.color_tolerance.apply_update(color_tolerance)
def to_dict(self) -> dict:
d = super().to_dict()
d["ha_source_id"] = self.ha_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["update_rate"] = self.update_rate
d["transition"] = self.transition
d["min_brightness_threshold"] = self.min_brightness_threshold
d["color_tolerance"] = self.color_tolerance
d["update_rate"] = self.update_rate.to_dict()
d["transition"] = self.transition.to_dict()
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
d["color_tolerance"] = self.color_tolerance.to_dict()
return d
@classmethod
def from_dict(cls, data: dict) -> "HALightOutputTarget":
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(
id=data["id"],
name=data["name"],
target_type="ha_light",
ha_source_id=data.get("ha_source_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id", ""),
brightness=brightness,
light_mappings=mappings,
update_rate=data.get("update_rate", 2.0),
transition=data.get("transition", 0.5),
min_brightness_threshold=data.get("min_brightness_threshold", 0),
color_tolerance=data.get("color_tolerance", 5),
update_rate=BindableFloat.from_raw(data.get("update_rate"), default=2.0),
transition=BindableFloat.from_raw(data.get("transition"), default=0.5),
min_brightness_threshold=BindableFloat.from_raw(
data.get("min_brightness_threshold"), default=0.0
),
color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.0),
description=data.get("description"),
tags=data.get("tags", []),
created_at=datetime.fromisoformat(
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional
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.output_target import OutputTarget
from wled_controller.storage.wled_output_target import WledOutputTarget
@@ -39,7 +40,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
target_type: str,
device_id: str = "",
color_strip_source_id: str = "",
brightness_value_source_id: str = "",
brightness=None,
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
@@ -51,8 +52,10 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
ha_source_id: str = "",
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0,
transition: float = 0.5,
transition=None,
color_tolerance: int = 5,
# legacy compat
brightness_value_source_id: str = "",
) -> OutputTarget:
"""Create a new output target.
@@ -70,6 +73,16 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
target_id = f"pt_{uuid.uuid4().hex[:8]}"
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":
target: OutputTarget = WledOutputTarget(
id=target_id,
@@ -77,11 +90,13 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
target_type="led",
device_id=device_id,
color_strip_source_id=color_strip_source_id,
brightness_value_source_id=brightness_value_source_id,
fps=fps,
brightness=bright,
fps=BindableFloat.from_raw(fps, default=30.0),
keepalive_interval=keepalive_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,
protocol=protocol,
description=description,
@@ -89,17 +104,28 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
updated_at=now,
)
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(
id=target_id,
name=name,
target_type="ha_light",
ha_source_id=ha_source_id,
color_strip_source_id=color_strip_source_id,
brightness=bright,
light_mappings=ha_light_mappings or [],
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
update_rate=BindableFloat.from_raw(update_rate, default=2.0),
transition=trans,
min_brightness_threshold=BindableFloat.from_raw(
min_brightness_threshold, default=0.0
),
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0),
description=description,
created_at=now,
updated_at=now,
@@ -117,23 +143,25 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
def update_target(
self,
target_id: str,
name: Optional[str] = None,
device_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None,
brightness_value_source_id: Optional[str] = None,
fps: Optional[int] = None,
keepalive_interval: Optional[float] = None,
state_check_interval: Optional[int] = None,
min_brightness_threshold: Optional[int] = None,
adaptive_fps: Optional[bool] = None,
protocol: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
ha_source_id: Optional[str] = None,
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: Optional[float] = None,
transition: Optional[float] = None,
color_tolerance: Optional[int] = None,
name=None,
device_id=None,
color_strip_source_id=None,
brightness=None,
fps=None,
keepalive_interval=None,
state_check_interval=None,
min_brightness_threshold=None,
adaptive_fps=None,
protocol=None,
description=None,
tags=None,
ha_source_id=None,
ha_light_mappings=None,
update_rate=None,
transition=None,
color_tolerance=None,
# legacy compat
brightness_value_source_id=None,
) -> OutputTarget:
"""Update an output target.
@@ -155,6 +183,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
name=name,
device_id=device_id,
color_strip_source_id=color_strip_source_id,
brightness=brightness,
brightness_value_source_id=brightness_value_source_id,
fps=fps,
keepalive_interval=keepalive_interval,
@@ -1,9 +1,10 @@
"""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 typing import List, Optional
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref
@@ -16,13 +17,13 @@ class WledOutputTarget(OutputTarget):
device_id: str = ""
color_strip_source_id: str = ""
brightness_value_source_id: str = ""
fps: int = 30 # target send FPS (1-90)
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
fps: BindableFloat = field(default_factory=lambda: BindableFloat(30.0))
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
min_brightness_threshold: int = 0 # brightness below this → 0 (disabled when 0)
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
def register_with_manager(self, manager) -> None:
"""Register this WLED target with the processor manager."""
@@ -31,58 +32,84 @@ class WledOutputTarget(OutputTarget):
target_id=self.id,
device_id=self.device_id,
color_strip_source_id=self.color_strip_source_id,
brightness=self.brightness,
fps=self.fps,
keepalive_interval=self.keepalive_interval,
state_check_interval=self.state_check_interval,
brightness_value_source_id=self.brightness_value_source_id,
min_brightness_threshold=self.min_brightness_threshold,
adaptive_fps=self.adaptive_fps,
protocol=self.protocol,
)
def sync_with_manager(self, manager, *, settings_changed: bool,
css_changed: bool = False,
brightness_vs_changed: bool = False) -> None:
"""Push changed fields to the processor manager.
NOTE: device_changed is handled separately in the route because
update_target_device is async (stop swap start cycle).
"""
def sync_with_manager(
self,
manager,
*,
settings_changed: bool,
css_changed: bool = False,
brightness_changed: bool = False,
) -> None:
"""Push changed fields to the processor manager."""
if settings_changed:
manager.update_target_settings(self.id, {
"fps": self.fps,
"keepalive_interval": self.keepalive_interval,
"state_check_interval": self.state_check_interval,
"min_brightness_threshold": self.min_brightness_threshold,
"adaptive_fps": self.adaptive_fps,
})
manager.update_target_settings(
self.id,
{
"fps": self.fps,
"keepalive_interval": self.keepalive_interval,
"state_check_interval": self.state_check_interval,
"min_brightness_threshold": self.min_brightness_threshold,
"adaptive_fps": self.adaptive_fps,
},
)
if css_changed:
manager.update_target_css(self.id, self.color_strip_source_id)
if brightness_vs_changed:
manager.update_target_brightness_vs(self.id, self.brightness_value_source_id)
if brightness_changed:
manager.update_target_brightness(self.id, self.brightness)
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
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:
def update_fields(
self,
*,
name=None,
device_id=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."""
super().update_fields(name=name, description=description, tags=tags)
if device_id is not None:
self.device_id = resolve_ref(device_id, self.device_id)
if color_strip_source_id is not None:
self.color_strip_source_id = resolve_ref(color_strip_source_id, self.color_strip_source_id)
if brightness_value_source_id is not None:
self.brightness_value_source_id = resolve_ref(brightness_value_source_id, self.brightness_value_source_id)
self.color_strip_source_id = resolve_ref(
color_strip_source_id, self.color_strip_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:
self.fps = fps
self.fps = self.fps.apply_update(fps)
if keepalive_interval is not None:
self.keepalive_interval = keepalive_interval
if state_check_interval is not None:
self.state_check_interval = state_check_interval
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:
self.adaptive_fps = adaptive_fps
if protocol is not None:
@@ -97,11 +124,11 @@ class WledOutputTarget(OutputTarget):
d = super().to_dict()
d["device_id"] = self.device_id
d["color_strip_source_id"] = self.color_strip_source_id
d["brightness_value_source_id"] = self.brightness_value_source_id
d["fps"] = self.fps
d["brightness"] = self.brightness.to_dict()
d["fps"] = self.fps.to_dict()
d["keepalive_interval"] = self.keepalive_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["protocol"] = self.protocol
return d
@@ -109,21 +136,33 @@ class WledOutputTarget(OutputTarget):
@classmethod
def from_dict(cls, data: dict) -> "WledOutputTarget":
"""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(
id=data["id"],
name=data["name"],
target_type="led",
device_id=data.get("device_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id") or "",
fps=data.get("fps", 30),
brightness=brightness,
fps=BindableFloat.from_raw(data.get("fps"), default=30.0),
keepalive_interval=data.get("keepalive_interval", 1.0),
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),
protocol=data.get("protocol", "ddp"),
description=data.get("description"),
tags=data.get("tags", []),
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
created_at=datetime.fromisoformat(
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="label-row">
<label for="css-editor-smoothing">
<label>
<span data-i18n="color_strip.smoothing">Smoothing:</span>
<span id="css-editor-smoothing-value">0.30</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="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>
@@ -176,28 +175,24 @@
<div id="css-editor-effect-intensity-group" class="form-group">
<div class="label-row">
<label for="css-editor-effect-intensity">
<label>
<span data-i18n="color_strip.effect.intensity">Intensity:</span>
<span id="css-editor-effect-intensity-val">1.0</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="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"
oninput="document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-effect-intensity-container"></div>
</div>
<div id="css-editor-effect-scale-group" class="form-group" style="display:none">
<div class="label-row">
<label for="css-editor-effect-scale">
<label>
<span data-i18n="color_strip.effect.scale">Scale:</span>
<span id="css-editor-effect-scale-val">1.0</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="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"
oninput="document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-effect-scale-container"></div>
</div>
<div id="css-editor-effect-mirror-group" class="form-group" style="display:none">
@@ -267,28 +262,24 @@
<div class="form-group">
<div class="label-row">
<label for="css-editor-audio-sensitivity">
<label>
<span data-i18n="color_strip.audio.sensitivity">Sensitivity:</span>
<span id="css-editor-audio-sensitivity-val">1.0</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="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"
oninput="document.getElementById('css-editor-audio-sensitivity-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-audio-sensitivity-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-audio-smoothing">
<label>
<span data-i18n="color_strip.audio.smoothing">Smoothing:</span>
<span id="css-editor-audio-smoothing-val">0.30</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="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"
oninput="document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(this.value).toFixed(2)">
<div id="css-editor-audio-smoothing-container"></div>
</div>
<div id="css-editor-audio-palette-group" class="form-group">
@@ -353,15 +344,13 @@
<div class="form-group">
<div class="label-row">
<label for="css-editor-api-input-timeout">
<label>
<span data-i18n="color_strip.api_input.timeout">Timeout (seconds):</span>
<span id="css-editor-api-input-timeout-val">5.0</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="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"
oninput="document.getElementById('css-editor-api-input-timeout-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-api-input-timeout-container"></div>
</div>
<div class="form-group">
@@ -418,15 +407,13 @@
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-duration">
<label>
<span data-i18n="color_strip.notification.duration">Duration (ms):</span>
<span id="css-editor-notification-duration-val">1500</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="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"
oninput="document.getElementById('css-editor-notification-duration-val').textContent = this.value">
<div id="css-editor-notification-duration-container"></div>
</div>
<div class="form-group">
@@ -573,12 +560,11 @@
</div>
<div class="form-group">
<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>
</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>
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-candlelight-intensity-container"></div>
</div>
<div class="form-group">
<div class="label-row">
@@ -590,21 +576,19 @@
</div>
<div class="form-group">
<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>
</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>
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-candlelight-speed-container"></div>
</div>
<div class="form-group">
<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>
</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>
<input type="range" id="css-editor-candlelight-wind" min="0.0" max="2.0" step="0.1" value="0.0"
oninput="document.getElementById('css-editor-candlelight-wind-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-candlelight-wind-container"></div>
</div>
<div class="form-group">
<div class="label-row">
@@ -636,21 +620,19 @@
</div>
<div class="form-group">
<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>
</div>
<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"
oninput="document.getElementById('css-editor-weather-speed-val').textContent = parseFloat(this.value).toFixed(1)">
<div id="css-editor-weather-speed-container"></div>
</div>
<div class="form-group">
<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>
</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>
<input type="range" id="css-editor-weather-temp-influence" min="0" max="1" step="0.05" value="0.5"
oninput="document.getElementById('css-editor-weather-temp-val').textContent = parseFloat(this.value).toFixed(2)">
<div id="css-editor-weather-temp-influence-container"></div>
</div>
</div>
@@ -697,17 +679,15 @@
</div>
<div class="form-group">
<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>
<input type="range" id="css-editor-kc-smoothing" min="0" max="1" step="0.05" value="0.3"
oninput="document.getElementById('css-editor-kc-smoothing-val').textContent = parseFloat(this.value).toFixed(2)">
<div id="css-editor-kc-smoothing-container"></div>
</div>
<div class="form-group">
<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>
<input type="range" id="css-editor-kc-brightness" min="0" max="1" step="0.05" value="1.0"
oninput="document.getElementById('css-editor-kc-brightness-val').textContent = parseFloat(this.value).toFixed(2)">
<div id="css-editor-kc-brightness-container"></div>
</div>
</div>
@@ -39,29 +39,25 @@
<!-- Update Rate -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-update-rate">
<label>
<span data-i18n="ha_light.update_rate">Update Rate:</span>
<span id="ha-light-editor-update-rate-display">2.0</span> Hz
</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.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"
oninput="document.getElementById('ha-light-editor-update-rate-display').textContent = parseFloat(this.value).toFixed(1)">
<div id="ha-light-editor-update-rate-container"></div>
</div>
<!-- Transition -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-transition">
<label>
<span data-i18n="ha_light.transition">Transition:</span>
<span id="ha-light-editor-transition-display">0.5</span>s
</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.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"
oninput="document.getElementById('ha-light-editor-transition-display').textContent = parseFloat(this.value).toFixed(1)">
<div id="ha-light-editor-transition-container"></div>
</div>
<!-- Brightness Value Source -->
@@ -74,6 +70,30 @@
</select>
</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 -->
<div class="form-group">
<div class="label-row">
@@ -47,17 +47,13 @@
<div class="form-group" id="target-editor-fps-group">
<div class="label-row">
<label for="target-editor-fps">
<label>
<span data-i18n="targets.fps">Target FPS:</span>
<span id="target-editor-fps-value">30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<div class="slider-row">
<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>
<div id="target-editor-fps-container"></div>
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div>
@@ -66,14 +62,13 @@
<div class="form-collapse-body">
<div class="form-group" id="target-editor-brightness-threshold-group">
<div class="label-row">
<label for="target-editor-brightness-threshold">
<label>
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
<span id="target-editor-brightness-threshold-value">0</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<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 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")
updated = store.update_target(t.id, fps=60, protocol="drgb")
assert isinstance(updated, WledOutputTarget)
assert updated.fps == 60
assert updated.fps.value == 60.0
assert updated.protocol == "drgb"
def test_update_not_found(self, store):