diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..433618c --- /dev/null +++ b/TODO.md @@ -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 diff --git a/custom_components/wled_screen_controller/select.py b/custom_components/wled_screen_controller/select.py index a108589..6e7f6b0 100644 --- a/custom_components/wled_screen_controller/select.py +++ b/custom_components/wled_screen_controller/select.py @@ -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, + ) diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index c643eef..eb1fb84 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -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) diff --git a/server/src/wled_controller/api/schemas/output_targets.py b/server/src/wled_controller/api/schemas/output_targets.py index fb79726..e466aaf 100644 --- a/server/src/wled_controller/api/schemas/output_targets.py +++ b/server/src/wled_controller/api/schemas/output_targets.py @@ -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") diff --git a/server/src/wled_controller/core/demo_seed.py b/server/src/wled_controller/core/demo_seed.py index 184e486..ef62031 100644 --- a/server/src/wled_controller/core/demo_seed.py +++ b/server/src/wled_controller/core/demo_seed.py @@ -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, }, ], diff --git a/server/src/wled_controller/core/home_assistant/ha_runtime.py b/server/src/wled_controller/core/home_assistant/ha_runtime.py index a9db138..323de43 100644 --- a/server/src/wled_controller/core/home_assistant/ha_runtime.py +++ b/server/src/wled_controller/core/home_assistant/ha_runtime.py @@ -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 diff --git a/server/src/wled_controller/core/processing/api_input_stream.py b/server/src/wled_controller/core/processing/api_input_stream.py index 552b2a6..66598e9 100644 --- a/server/src/wled_controller/core/processing/api_input_stream.py +++ b/server/src/wled_controller/core/processing/api_input_stream.py @@ -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 diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index 0e56f33..c83f86f 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -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 diff --git a/server/src/wled_controller/core/processing/candlelight_stream.py b/server/src/wled_controller/core/processing/candlelight_stream.py index 373eead..09a62aa 100644 --- a/server/src/wled_controller/core/processing/candlelight_stream.py +++ b/server/src/wled_controller/core/processing/candlelight_stream.py @@ -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] diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 7e9a4f6..ba2009d 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -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] diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index ce84409..4b7da27 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -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}") diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index 3510a59..535a4c8 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -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) diff --git a/server/src/wled_controller/core/processing/daylight_stream.py b/server/src/wled_controller/core/processing/daylight_stream.py index c29b635..3f4cc44 100644 --- a/server/src/wled_controller/core/processing/daylight_stream.py +++ b/server/src/wled_controller/core/processing/daylight_stream.py @@ -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: diff --git a/server/src/wled_controller/core/processing/effect_stream.py b/server/src/wled_controller/core/processing/effect_stream.py index 3f28a24..dcc1db6 100644 --- a/server/src/wled_controller/core/processing/effect_stream.py +++ b/server/src/wled_controller/core/processing/effect_stream.py @@ -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] diff --git a/server/src/wled_controller/core/processing/ha_light_target_processor.py b/server/src/wled_controller/core/processing/ha_light_target_processor.py index b6d5541..7abaf9e 100644 --- a/server/src/wled_controller/core/processing/ha_light_target_processor.py +++ b/server/src/wled_controller/core/processing/ha_light_target_processor.py @@ -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", diff --git a/server/src/wled_controller/core/processing/kc_color_strip_stream.py b/server/src/wled_controller/core/processing/kc_color_strip_stream.py index b19e830..86a88a2 100644 --- a/server/src/wled_controller/core/processing/kc_color_strip_stream.py +++ b/server/src/wled_controller/core/processing/kc_color_strip_stream.py @@ -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: diff --git a/server/src/wled_controller/core/processing/notification_stream.py b/server/src/wled_controller/core/processing/notification_stream.py index f93ca1e..8ee18d7 100644 --- a/server/src/wled_controller/core/processing/notification_stream.py +++ b/server/src/wled_controller/core/processing/notification_stream.py @@ -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 diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 7edd5ac..c09ab63 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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.""" diff --git a/server/src/wled_controller/core/processing/weather_stream.py b/server/src/wled_controller/core/processing/weather_stream.py index 553f814..fb8b4d4 100644 --- a/server/src/wled_controller/core/processing/weather_stream.py +++ b/server/src/wled_controller/core/processing/weather_stream.py @@ -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) diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 44ec36e..be16c30 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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 diff --git a/server/src/wled_controller/core/scenes/scene_activator.py b/server/src/wled_controller/core/scenes/scene_activator.py index 25cd338..caaa42a 100644 --- a/server/src/wled_controller/core/scenes/scene_activator.py +++ b/server/src/wled_controller/core/scenes/scene_activator.py @@ -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: diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 17c256f..62f48bf 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -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; +} diff --git a/server/src/wled_controller/static/js/core/bindable-scalar.ts b/server/src/wled_controller/static/js/core/bindable-scalar.ts new file mode 100644 index 0000000..0b8b9c7 --- /dev/null +++ b/server/src/wled_controller/static/js/core/bindable-scalar.ts @@ -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 = ``; + + // Slider row (static mode) + const sliderHtml = `
+ + ${this._format(this._staticValue)} + ${toggleHtml} +
`; + + // VS picker row (bound mode) + const vsHtml = ``; + + 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 = `` + + sources.map(vs => + `` + ).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 = ''; + } +} diff --git a/server/src/wled_controller/static/js/core/graph-connections.ts b/server/src/wled_controller/static/js/core/graph-connections.ts index cab4f08..3c73bf7 100644 --- a/server/src/wled_controller/static/js/core/graph-connections.ts +++ b/server/src/wled_controller/static/js/core/graph-connections.ts @@ -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 }, ]; diff --git a/server/src/wled_controller/static/js/core/graph-layout.ts b/server/src/wled_controller/static/js/core/graph-layout.ts index 347cea2..64511cd 100644 --- a/server/src/wled_controller/static/js/core/graph-layout.ts +++ b/server/src/wled_controller/static/js/core/graph-layout.ts @@ -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'); } } diff --git a/server/src/wled_controller/static/js/core/icon-paths.ts b/server/src/wled_controller/static/js/core/icon-paths.ts index 90131bf..9ba532f 100644 --- a/server/src/wled_controller/static/js/core/icon-paths.ts +++ b/server/src/wled_controller/static/js/core/icon-paths.ts @@ -91,3 +91,9 @@ export const heart = ''; export const home = ''; export const lock = ''; +export const check = ''; +export const code = ''; +export const doorOpen = ''; +export const toggleRight = ''; +export const droplets = ''; +export const fan = ''; diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index f5d90cb..3867960 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -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 = { + '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 = { + 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); diff --git a/server/src/wled_controller/static/js/features/audio-sources.ts b/server/src/wled_controller/static/js/features/audio-sources.ts index 8082d9e..77cfcd5 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.ts +++ b/server/src/wled_controller/static/js/features/audio-sources.ts @@ -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 => `${d}`; @@ -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) => `${d}`; + +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; diff --git a/server/src/wled_controller/static/js/features/automations.ts b/server/src/wled_controller/static/js/features/automations.ts index 780fa88..e5b39f0 100644 --- a/server/src/wled_controller/static/js/features/automations.ts +++ b/server/src/wled_controller/static/js/features/automations.ts @@ -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 { + 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 = `` + + _haConditionEntities.map((e: any) => + `` + ).join(''); + if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) { + entitySelect.innerHTML += ``; + } + } +} + 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) => `` ).join(''); @@ -748,7 +775,9 @@ function addAutomationConditionRow(condition: any) {
- +
@@ -763,6 +792,45 @@ function addAutomationConditionRow(condition: any) {
`; + + // 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', }); diff --git a/server/src/wled_controller/static/js/features/color-strips-composite.ts b/server/src/wled_controller/static/js/features/color-strips-composite.ts index 28aca1c..e9b5f7a 100644 --- a/server/src/wled_controller/static/js/features/color-strips-composite.ts +++ b/server/src/wled_controller/static/js/features/color-strips-composite.ts @@ -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) => `${d}`; @@ -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() {
- +
${ICON_LED} ${escapeHtml(deviceName)} - ${ICON_FPS} ${target.fps || 30} + ${ICON_FPS} ${bindableValue(target.fps, 30)} ${_protocolBadge(device, target)} ${ICON_FILM} ${cssSummary} ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} - ${(target.min_brightness_threshold ?? 0) > 0 ? `${ICON_SUN_DIM} <${target.min_brightness_threshold} → off` : ''} + ${bindableValue(target.min_brightness_threshold, 0) > 0 ? `${ICON_SUN_DIM} <${bindableValue(target.min_brightness_threshold, 0)} → off` : ''}
${renderTagChips(target.tags)}
diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 862a8ac..855144a 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -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; } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index d3a90ac..fb6098e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 4b2fbe9..5c4e268 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Сервер недоступен", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 87705c9..44bcfd9 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "服务器不可达", diff --git a/server/src/wled_controller/storage/bindable.py b/server/src/wled_controller/storage/bindable.py new file mode 100644 index 0000000..8b20fcd --- /dev/null +++ b/server/src/wled_controller/storage/bindable.py @@ -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 diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 253eebf..55765fe 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -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 + ), ) diff --git a/server/src/wled_controller/storage/ha_light_output_target.py b/server/src/wled_controller/storage/ha_light_output_target.py index 76c7fc3..77e1507 100644 --- a/server/src/wled_controller/storage/ha_light_output_target.py +++ b/server/src/wled_controller/storage/ha_light_output_target.py @@ -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( diff --git a/server/src/wled_controller/storage/output_target_store.py b/server/src/wled_controller/storage/output_target_store.py index 16e9a80..6e297b3 100644 --- a/server/src/wled_controller/storage/output_target_store.py +++ b/server/src/wled_controller/storage/output_target_store.py @@ -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, diff --git a/server/src/wled_controller/storage/wled_output_target.py b/server/src/wled_controller/storage/wled_output_target.py index a218a05..5803b83 100644 --- a/server/src/wled_controller/storage/wled_output_target.py +++ b/server/src/wled_controller/storage/wled_output_target.py @@ -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()) + ), ) diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index ca03521..4f59820 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -67,14 +67,13 @@
-
- +
@@ -176,28 +175,24 @@
-
- +