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

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

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

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

Fixes: daylight longitude, candlelight wind_strength/candle_type from_dict
This commit is contained in:
2026-03-29 00:33:24 +03:00
parent 5f70302263
commit 8a17bb5caa
48 changed files with 2512 additions and 887 deletions
@@ -19,6 +19,7 @@ from wled_controller.api.schemas.output_targets import (
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
@@ -43,11 +44,11 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id or "",
fps=target.fps,
brightness=target.brightness.to_dict(),
fps=target.fps.to_dict(),
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold,
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
@@ -62,20 +63,20 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id or "",
brightness=target.brightness.to_dict(),
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
update_rate=target.update_rate,
ha_transition=target.transition,
color_tolerance=target.color_tolerance,
min_brightness_threshold=target.min_brightness_threshold,
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
description=target.description,
tags=target.tags,
created_at=target.created_at,
@@ -121,7 +122,7 @@ async def create_target(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in data.ha_light_mappings
]
@@ -135,7 +136,7 @@ async def create_target(
target_type=data.target_type,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
@@ -245,7 +246,7 @@ async def update_target(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in data.ha_light_mappings
]
@@ -256,7 +257,7 @@ async def update_target(
name=data.name,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
@@ -287,9 +288,10 @@ async def update_target(
or data.transition is not None
or data.color_tolerance is not None
or data.ha_light_mappings is not None
or data.brightness is not None
),
css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=data.brightness_value_source_id is not None,
brightness_changed=data.brightness is not None,
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
@@ -1,12 +1,26 @@
"""Output target schemas (CRUD, processing state, metrics)."""
from datetime import datetime
from typing import Dict, Optional, List
from typing import Any, Dict, Optional, List, Union
from pydantic import BaseModel, Field
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# ---------------------------------------------------------------------------
# BindableFloat — accepts plain number OR {value, source_id} dict
# ---------------------------------------------------------------------------
BindableFloatInput = Union[float, int, Dict[str, Any]]
"""API input type: a plain number (static) or {"value": float, "source_id": str}."""
class BindableFloatSchema(BaseModel):
"""Response schema for a bindable scalar property."""
value: float = Field(description="Static value (used when source_id is empty)")
source_id: str = Field(default="", description="Value source ID (empty = static)")
class KeyColorRectangleSchema(BaseModel):
"""A named rectangle for key color extraction (relative coords 0.0-1.0)."""
@@ -24,8 +38,8 @@ class HALightMappingSchema(BaseModel):
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: float = Field(
default=1.0, ge=0.0, le=1.0, description="Brightness multiplier"
brightness_scale: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
@@ -37,8 +51,12 @@ class OutputTargetCreate(BaseModel):
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
fps: Optional[BindableFloatInput] = Field(
default=30, description="Target send FPS (bindable, 1-90)"
)
keepalive_interval: float = Field(
default=1.0,
description="Keepalive send interval when screen is static (0.5-5.0s)",
@@ -51,11 +69,9 @@ class OutputTargetCreate(BaseModel):
ge=5,
le=600,
)
min_brightness_threshold: int = Field(
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
ge=0,
le=254,
description="Min brightness threshold (0=disabled); below this → off",
description="Min brightness threshold (bindable, 0=disabled); below this → off",
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -72,17 +88,15 @@ class OutputTargetCreate(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
)
update_rate: float = Field(
default=2.0, ge=0.5, le=5.0, description="Service call rate in Hz (for ha_light targets)"
update_rate: Optional[BindableFloatInput] = Field(
default=2.0, description="Service call rate in Hz (bindable, for ha_light targets)"
)
transition: float = Field(
default=0.5, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
transition: Optional[BindableFloatInput] = Field(
default=0.5, description="HA transition seconds (bindable, for ha_light targets)"
)
color_tolerance: int = Field(
color_tolerance: Optional[BindableFloatInput] = Field(
default=5,
ge=0,
le=50,
description="Skip service call if RGB delta < this (for ha_light targets)",
description="RGB delta tolerance (bindable, for ha_light targets)",
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -95,18 +109,16 @@ class OutputTargetUpdate(BaseModel):
# LED target fields
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness_value_source_id: Optional[str] = Field(
None, description="Brightness value source ID"
)
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: Optional[float] = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
)
state_check_interval: Optional[int] = Field(
None, description="Health check interval (5-600s)", ge=5, le=600
)
min_brightness_threshold: Optional[int] = Field(
None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off"
min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: Optional[bool] = Field(
None, description="Auto-reduce FPS when device is unresponsive"
@@ -121,14 +133,14 @@ class OutputTargetUpdate(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
)
update_rate: Optional[float] = Field(
None, ge=0.5, le=5.0, description="Service call rate Hz (for ha_light targets)"
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, for ha_light targets)"
)
transition: Optional[float] = Field(
None, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, for ha_light targets)"
)
color_tolerance: Optional[int] = Field(
None, ge=0, le=50, description="RGB delta tolerance (for ha_light targets)"
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable, for ha_light targets)"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
@@ -143,14 +155,14 @@ class OutputTargetResponse(BaseModel):
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
fps: Optional[int] = Field(None, description="Target send FPS")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: int = Field(
default=0, description="Min brightness threshold (0=disabled)"
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
@@ -161,8 +173,12 @@ class OutputTargetResponse(BaseModel):
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (ha_light)"
)
update_rate: Optional[float] = Field(None, description="Service call rate Hz (ha_light)")
ha_transition: Optional[float] = Field(None, description="HA transition seconds (ha_light)")
update_rate: Optional[BindableFloatInput] = Field(
None, description="Service call rate Hz (bindable, ha_light)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="HA transition seconds (bindable, ha_light)"
)
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")