feat: BindableFloat — universal value source binding for all scalar properties
Lint & Test / test (push) Successful in 1m20s
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:
@@ -0,0 +1,38 @@
|
|||||||
|
# BindableFloat — Universal Value Source Binding
|
||||||
|
|
||||||
|
## ALL PHASES COMPLETE
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure
|
||||||
|
|
||||||
|
- [x] `storage/bindable.py` — BindableFloat dataclass + `bfloat()` extraction helper
|
||||||
|
- [x] WledOutputTarget, HALightOutputTarget, HALightMapping — brightness/transition
|
||||||
|
- [x] All 15 CSS source types — smoothing, sensitivity, intensity, scale, speed, etc.
|
||||||
|
- [x] API schemas + routes updated
|
||||||
|
- [x] output_target_store create/update
|
||||||
|
- [x] processor_manager add_target / add_ha_light_target
|
||||||
|
|
||||||
|
### Phase 2: Runtime Resolution
|
||||||
|
|
||||||
|
- [x] WledTargetProcessor — BindableFloat brightness, acquire/release value streams
|
||||||
|
- [x] HALightTargetProcessor — BindableFloat brightness + transition
|
||||||
|
- [x] All CSS streams use `bfloat()` to extract static values from BindableFloat properties
|
||||||
|
- [x] scene_activator — brightness_changed flag
|
||||||
|
- [x] ColorStripStream base class — `resolve()`, `set_value_stream()`, `remove_value_stream()`
|
||||||
|
- [x] ColorStripStreamManager — `_bind_value_streams()` / `_release_value_streams()` on acquire/release
|
||||||
|
- [x] All stream hot loops call `self.resolve(prop, static)` for dynamic runtime binding
|
||||||
|
- [x] KeyColorsColorStripStream — fixed to inherit from ColorStripStream
|
||||||
|
|
||||||
|
### Phase 3: Frontend
|
||||||
|
|
||||||
|
- [x] TypeScript BindableFloat type + `bindableValue()` / `bindableSourceId()` helpers
|
||||||
|
- [x] targets.ts, ha-light-targets.ts, color-strips.ts — save/load/display
|
||||||
|
- [x] Graph connections — value source edges for ALL bindable CSS properties
|
||||||
|
- [x] Graph layout — edge creation for CSS + target bindable properties
|
||||||
|
- [x] custom_components/select.py — HA integration backward compat
|
||||||
|
|
||||||
|
### Phase 4: BindableScalarWidget
|
||||||
|
|
||||||
|
- [x] `core/bindable-scalar.ts` — reusable widget (slider + VS picker toggle)
|
||||||
|
- [x] CSS styles (`.bindable-toggle`, `.bindable-slider-row`, `.bindable-vs-row`)
|
||||||
|
- [x] All 11 CSS editor sliders converted (smoothing, sensitivity, intensity, scale, speed, wind, temp_influence, timeout)
|
||||||
|
- [x] HTML templates updated with container divs
|
||||||
@@ -141,7 +141,12 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
|||||||
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
|
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||||
if not target_data:
|
if not target_data:
|
||||||
return None
|
return None
|
||||||
current_id = target_data["info"].get("brightness_value_source_id", "")
|
# BindableFloat: brightness is either a plain float or {"value": float, "source_id": str}
|
||||||
|
brightness = target_data["info"].get("brightness", "")
|
||||||
|
if isinstance(brightness, dict):
|
||||||
|
current_id = brightness.get("source_id", "")
|
||||||
|
else:
|
||||||
|
current_id = target_data["info"].get("brightness_value_source_id", "")
|
||||||
if not current_id:
|
if not current_id:
|
||||||
return NONE_OPTION
|
return NONE_OPTION
|
||||||
sources = self.coordinator.data.get("value_sources") or []
|
sources = self.coordinator.data.get("value_sources") or []
|
||||||
@@ -167,4 +172,7 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
|||||||
if source_id is None:
|
if source_id is None:
|
||||||
_LOGGER.error("Value source not found: %s", option)
|
_LOGGER.error("Value source not found: %s", option)
|
||||||
return
|
return
|
||||||
await self.coordinator.update_target(self._target_id, brightness_value_source_id=source_id)
|
await self.coordinator.update_target(
|
||||||
|
self._target_id,
|
||||||
|
brightness={"value": 1.0, "source_id": source_id} if source_id else 1.0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from wled_controller.api.schemas.output_targets import (
|
|||||||
)
|
)
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
from wled_controller.storage.wled_output_target import WledOutputTarget
|
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||||
from wled_controller.storage.ha_light_output_target import (
|
from wled_controller.storage.ha_light_output_target import (
|
||||||
HALightMapping,
|
HALightMapping,
|
||||||
@@ -43,11 +44,11 @@ def _target_to_response(target) -> OutputTargetResponse:
|
|||||||
target_type=target.target_type,
|
target_type=target.target_type,
|
||||||
device_id=target.device_id,
|
device_id=target.device_id,
|
||||||
color_strip_source_id=target.color_strip_source_id,
|
color_strip_source_id=target.color_strip_source_id,
|
||||||
brightness_value_source_id=target.brightness_value_source_id or "",
|
brightness=target.brightness.to_dict(),
|
||||||
fps=target.fps,
|
fps=target.fps.to_dict(),
|
||||||
keepalive_interval=target.keepalive_interval,
|
keepalive_interval=target.keepalive_interval,
|
||||||
state_check_interval=target.state_check_interval,
|
state_check_interval=target.state_check_interval,
|
||||||
min_brightness_threshold=target.min_brightness_threshold,
|
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||||
adaptive_fps=target.adaptive_fps,
|
adaptive_fps=target.adaptive_fps,
|
||||||
protocol=target.protocol,
|
protocol=target.protocol,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
@@ -62,20 +63,20 @@ def _target_to_response(target) -> OutputTargetResponse:
|
|||||||
target_type=target.target_type,
|
target_type=target.target_type,
|
||||||
ha_source_id=target.ha_source_id,
|
ha_source_id=target.ha_source_id,
|
||||||
color_strip_source_id=target.color_strip_source_id,
|
color_strip_source_id=target.color_strip_source_id,
|
||||||
brightness_value_source_id=target.brightness_value_source_id or "",
|
brightness=target.brightness.to_dict(),
|
||||||
ha_light_mappings=[
|
ha_light_mappings=[
|
||||||
HALightMappingSchema(
|
HALightMappingSchema(
|
||||||
entity_id=m.entity_id,
|
entity_id=m.entity_id,
|
||||||
led_start=m.led_start,
|
led_start=m.led_start,
|
||||||
led_end=m.led_end,
|
led_end=m.led_end,
|
||||||
brightness_scale=m.brightness_scale,
|
brightness_scale=m.brightness_scale.to_dict(),
|
||||||
)
|
)
|
||||||
for m in target.light_mappings
|
for m in target.light_mappings
|
||||||
],
|
],
|
||||||
update_rate=target.update_rate,
|
update_rate=target.update_rate.to_dict(),
|
||||||
ha_transition=target.transition,
|
transition=target.transition.to_dict(),
|
||||||
color_tolerance=target.color_tolerance,
|
color_tolerance=target.color_tolerance.to_dict(),
|
||||||
min_brightness_threshold=target.min_brightness_threshold,
|
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||||
description=target.description,
|
description=target.description,
|
||||||
tags=target.tags,
|
tags=target.tags,
|
||||||
created_at=target.created_at,
|
created_at=target.created_at,
|
||||||
@@ -121,7 +122,7 @@ async def create_target(
|
|||||||
entity_id=m.entity_id,
|
entity_id=m.entity_id,
|
||||||
led_start=m.led_start,
|
led_start=m.led_start,
|
||||||
led_end=m.led_end,
|
led_end=m.led_end,
|
||||||
brightness_scale=m.brightness_scale,
|
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||||
)
|
)
|
||||||
for m in data.ha_light_mappings
|
for m in data.ha_light_mappings
|
||||||
]
|
]
|
||||||
@@ -135,7 +136,7 @@ async def create_target(
|
|||||||
target_type=data.target_type,
|
target_type=data.target_type,
|
||||||
device_id=data.device_id,
|
device_id=data.device_id,
|
||||||
color_strip_source_id=data.color_strip_source_id,
|
color_strip_source_id=data.color_strip_source_id,
|
||||||
brightness_value_source_id=data.brightness_value_source_id,
|
brightness=data.brightness,
|
||||||
fps=data.fps,
|
fps=data.fps,
|
||||||
keepalive_interval=data.keepalive_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
@@ -245,7 +246,7 @@ async def update_target(
|
|||||||
entity_id=m.entity_id,
|
entity_id=m.entity_id,
|
||||||
led_start=m.led_start,
|
led_start=m.led_start,
|
||||||
led_end=m.led_end,
|
led_end=m.led_end,
|
||||||
brightness_scale=m.brightness_scale,
|
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||||
)
|
)
|
||||||
for m in data.ha_light_mappings
|
for m in data.ha_light_mappings
|
||||||
]
|
]
|
||||||
@@ -256,7 +257,7 @@ async def update_target(
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
device_id=data.device_id,
|
device_id=data.device_id,
|
||||||
color_strip_source_id=data.color_strip_source_id,
|
color_strip_source_id=data.color_strip_source_id,
|
||||||
brightness_value_source_id=data.brightness_value_source_id,
|
brightness=data.brightness,
|
||||||
fps=data.fps,
|
fps=data.fps,
|
||||||
keepalive_interval=data.keepalive_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
@@ -287,9 +288,10 @@ async def update_target(
|
|||||||
or data.transition is not None
|
or data.transition is not None
|
||||||
or data.color_tolerance is not None
|
or data.color_tolerance is not None
|
||||||
or data.ha_light_mappings is not None
|
or data.ha_light_mappings is not None
|
||||||
|
or data.brightness is not None
|
||||||
),
|
),
|
||||||
css_changed=data.color_strip_source_id is not None,
|
css_changed=data.color_strip_source_id is not None,
|
||||||
brightness_vs_changed=data.brightness_value_source_id is not None,
|
brightness_changed=data.brightness is not None,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
"""Output target schemas (CRUD, processing state, metrics)."""
|
"""Output target schemas (CRUD, processing state, metrics)."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Optional, List
|
from typing import Any, Dict, Optional, List, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BindableFloat — accepts plain number OR {value, source_id} dict
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BindableFloatInput = Union[float, int, Dict[str, Any]]
|
||||||
|
"""API input type: a plain number (static) or {"value": float, "source_id": str}."""
|
||||||
|
|
||||||
|
|
||||||
|
class BindableFloatSchema(BaseModel):
|
||||||
|
"""Response schema for a bindable scalar property."""
|
||||||
|
|
||||||
|
value: float = Field(description="Static value (used when source_id is empty)")
|
||||||
|
source_id: str = Field(default="", description="Value source ID (empty = static)")
|
||||||
|
|
||||||
|
|
||||||
class KeyColorRectangleSchema(BaseModel):
|
class KeyColorRectangleSchema(BaseModel):
|
||||||
"""A named rectangle for key color extraction (relative coords 0.0-1.0)."""
|
"""A named rectangle for key color extraction (relative coords 0.0-1.0)."""
|
||||||
@@ -24,8 +38,8 @@ class HALightMappingSchema(BaseModel):
|
|||||||
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
|
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
|
||||||
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
|
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
|
||||||
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
|
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
|
||||||
brightness_scale: float = Field(
|
brightness_scale: Optional[BindableFloatInput] = Field(
|
||||||
default=1.0, ge=0.0, le=1.0, description="Brightness multiplier"
|
default=1.0, description="Brightness multiplier (bindable)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,8 +51,12 @@ class OutputTargetCreate(BaseModel):
|
|||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
brightness: Optional[BindableFloatInput] = Field(
|
||||||
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
default=1.0, description="Brightness (bindable)"
|
||||||
|
)
|
||||||
|
fps: Optional[BindableFloatInput] = Field(
|
||||||
|
default=30, description="Target send FPS (bindable, 1-90)"
|
||||||
|
)
|
||||||
keepalive_interval: float = Field(
|
keepalive_interval: float = Field(
|
||||||
default=1.0,
|
default=1.0,
|
||||||
description="Keepalive send interval when screen is static (0.5-5.0s)",
|
description="Keepalive send interval when screen is static (0.5-5.0s)",
|
||||||
@@ -51,11 +69,9 @@ class OutputTargetCreate(BaseModel):
|
|||||||
ge=5,
|
ge=5,
|
||||||
le=600,
|
le=600,
|
||||||
)
|
)
|
||||||
min_brightness_threshold: int = Field(
|
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||||
default=0,
|
default=0,
|
||||||
ge=0,
|
description="Min brightness threshold (bindable, 0=disabled); below this → off",
|
||||||
le=254,
|
|
||||||
description="Min brightness threshold (0=disabled); below this → off",
|
|
||||||
)
|
)
|
||||||
adaptive_fps: bool = Field(
|
adaptive_fps: bool = Field(
|
||||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||||
@@ -72,17 +88,15 @@ class OutputTargetCreate(BaseModel):
|
|||||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||||
None, description="LED-to-light mappings (for ha_light targets)"
|
None, description="LED-to-light mappings (for ha_light targets)"
|
||||||
)
|
)
|
||||||
update_rate: float = Field(
|
update_rate: Optional[BindableFloatInput] = Field(
|
||||||
default=2.0, ge=0.5, le=5.0, description="Service call rate in Hz (for ha_light targets)"
|
default=2.0, description="Service call rate in Hz (bindable, for ha_light targets)"
|
||||||
)
|
)
|
||||||
transition: float = Field(
|
transition: Optional[BindableFloatInput] = Field(
|
||||||
default=0.5, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
|
default=0.5, description="HA transition seconds (bindable, for ha_light targets)"
|
||||||
)
|
)
|
||||||
color_tolerance: int = Field(
|
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||||
default=5,
|
default=5,
|
||||||
ge=0,
|
description="RGB delta tolerance (bindable, for ha_light targets)",
|
||||||
le=50,
|
|
||||||
description="Skip service call if RGB delta < this (for ha_light targets)",
|
|
||||||
)
|
)
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
@@ -95,18 +109,16 @@ class OutputTargetUpdate(BaseModel):
|
|||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||||
brightness_value_source_id: Optional[str] = Field(
|
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||||
None, description="Brightness value source ID"
|
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
|
||||||
)
|
|
||||||
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
|
||||||
keepalive_interval: Optional[float] = Field(
|
keepalive_interval: Optional[float] = Field(
|
||||||
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
|
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
|
||||||
)
|
)
|
||||||
state_check_interval: Optional[int] = Field(
|
state_check_interval: Optional[int] = Field(
|
||||||
None, description="Health check interval (5-600s)", ge=5, le=600
|
None, description="Health check interval (5-600s)", ge=5, le=600
|
||||||
)
|
)
|
||||||
min_brightness_threshold: Optional[int] = Field(
|
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||||
None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off"
|
None, description="Min brightness threshold (bindable, 0=disabled)"
|
||||||
)
|
)
|
||||||
adaptive_fps: Optional[bool] = Field(
|
adaptive_fps: Optional[bool] = Field(
|
||||||
None, description="Auto-reduce FPS when device is unresponsive"
|
None, description="Auto-reduce FPS when device is unresponsive"
|
||||||
@@ -121,14 +133,14 @@ class OutputTargetUpdate(BaseModel):
|
|||||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||||
None, description="LED-to-light mappings (for ha_light targets)"
|
None, description="LED-to-light mappings (for ha_light targets)"
|
||||||
)
|
)
|
||||||
update_rate: Optional[float] = Field(
|
update_rate: Optional[BindableFloatInput] = Field(
|
||||||
None, ge=0.5, le=5.0, description="Service call rate Hz (for ha_light targets)"
|
None, description="Service call rate Hz (bindable, for ha_light targets)"
|
||||||
)
|
)
|
||||||
transition: Optional[float] = Field(
|
transition: Optional[BindableFloatInput] = Field(
|
||||||
None, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
|
None, description="HA transition seconds (bindable, for ha_light targets)"
|
||||||
)
|
)
|
||||||
color_tolerance: Optional[int] = Field(
|
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||||
None, ge=0, le=50, description="RGB delta tolerance (for ha_light targets)"
|
None, description="RGB delta tolerance (bindable, for ha_light targets)"
|
||||||
)
|
)
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
@@ -143,14 +155,14 @@ class OutputTargetResponse(BaseModel):
|
|||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||||
fps: Optional[int] = Field(None, description="Target send FPS")
|
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
|
||||||
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||||
state_check_interval: int = Field(
|
state_check_interval: int = Field(
|
||||||
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
|
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
|
||||||
)
|
)
|
||||||
min_brightness_threshold: int = Field(
|
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||||
default=0, description="Min brightness threshold (0=disabled)"
|
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||||
)
|
)
|
||||||
adaptive_fps: bool = Field(
|
adaptive_fps: bool = Field(
|
||||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||||
@@ -161,8 +173,12 @@ class OutputTargetResponse(BaseModel):
|
|||||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||||
None, description="LED-to-light mappings (ha_light)"
|
None, description="LED-to-light mappings (ha_light)"
|
||||||
)
|
)
|
||||||
update_rate: Optional[float] = Field(None, description="Service call rate Hz (ha_light)")
|
update_rate: Optional[BindableFloatInput] = Field(
|
||||||
ha_transition: Optional[float] = Field(None, description="HA transition seconds (ha_light)")
|
None, description="Service call rate Hz (bindable, ha_light)"
|
||||||
|
)
|
||||||
|
transition: Optional[BindableFloatInput] = Field(
|
||||||
|
None, description="HA transition seconds (bindable, ha_light)"
|
||||||
|
)
|
||||||
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
|
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
|
||||||
description: Optional[str] = Field(None, description="Description")
|
description: Optional[str] = Field(None, description="Description")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|||||||
@@ -68,8 +68,14 @@ def seed_demo_data(db: Database) -> None:
|
|||||||
Must be called BEFORE store constructors run so they load the seeded data.
|
Must be called BEFORE store constructors run so they load the seeded data.
|
||||||
"""
|
"""
|
||||||
# Check if any table already has data
|
# Check if any table already has data
|
||||||
for table in ["devices", "output_targets", "color_strip_sources",
|
for table in [
|
||||||
"picture_sources", "audio_sources", "scene_presets"]:
|
"devices",
|
||||||
|
"output_targets",
|
||||||
|
"color_strip_sources",
|
||||||
|
"picture_sources",
|
||||||
|
"audio_sources",
|
||||||
|
"scene_presets",
|
||||||
|
]:
|
||||||
if db.table_exists_with_data(table):
|
if db.table_exists_with_data(table):
|
||||||
logger.info("Demo data already exists — skipping seed")
|
logger.info("Demo data already exists — skipping seed")
|
||||||
return
|
return
|
||||||
@@ -89,6 +95,7 @@ def seed_demo_data(db: Database) -> None:
|
|||||||
|
|
||||||
# ── Devices ────────────────────────────────────────────────────────
|
# ── Devices ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_devices() -> dict:
|
def _build_devices() -> dict:
|
||||||
return {
|
return {
|
||||||
_DEVICE_IDS["strip"]: {
|
_DEVICE_IDS["strip"]: {
|
||||||
@@ -126,6 +133,7 @@ def _build_devices() -> dict:
|
|||||||
|
|
||||||
# ── Capture Templates ──────────────────────────────────────────────
|
# ── Capture Templates ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_capture_templates() -> dict:
|
def _build_capture_templates() -> dict:
|
||||||
return {
|
return {
|
||||||
_TPL_ID: {
|
_TPL_ID: {
|
||||||
@@ -143,6 +151,7 @@ def _build_capture_templates() -> dict:
|
|||||||
|
|
||||||
# ── Output Targets ─────────────────────────────────────────────────
|
# ── Output Targets ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_output_targets() -> dict:
|
def _build_output_targets() -> dict:
|
||||||
return {
|
return {
|
||||||
_TARGET_IDS["strip"]: {
|
_TARGET_IDS["strip"]: {
|
||||||
@@ -151,7 +160,7 @@ def _build_output_targets() -> dict:
|
|||||||
"target_type": "led",
|
"target_type": "led",
|
||||||
"device_id": _DEVICE_IDS["strip"],
|
"device_id": _DEVICE_IDS["strip"],
|
||||||
"color_strip_source_id": _CSS_IDS["gradient"],
|
"color_strip_source_id": _CSS_IDS["gradient"],
|
||||||
"brightness_value_source_id": "",
|
"brightness": 1.0,
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"keepalive_interval": 1.0,
|
"keepalive_interval": 1.0,
|
||||||
"state_check_interval": 30,
|
"state_check_interval": 30,
|
||||||
@@ -169,7 +178,7 @@ def _build_output_targets() -> dict:
|
|||||||
"target_type": "led",
|
"target_type": "led",
|
||||||
"device_id": _DEVICE_IDS["matrix"],
|
"device_id": _DEVICE_IDS["matrix"],
|
||||||
"color_strip_source_id": _CSS_IDS["picture"],
|
"color_strip_source_id": _CSS_IDS["picture"],
|
||||||
"brightness_value_source_id": "",
|
"brightness": 1.0,
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"keepalive_interval": 1.0,
|
"keepalive_interval": 1.0,
|
||||||
"state_check_interval": 30,
|
"state_check_interval": 30,
|
||||||
@@ -186,6 +195,7 @@ def _build_output_targets() -> dict:
|
|||||||
|
|
||||||
# ── Picture Sources ────────────────────────────────────────────────
|
# ── Picture Sources ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_picture_sources() -> dict:
|
def _build_picture_sources() -> dict:
|
||||||
return {
|
return {
|
||||||
_PS_IDS["main"]: {
|
_PS_IDS["main"]: {
|
||||||
@@ -237,6 +247,7 @@ def _build_picture_sources() -> dict:
|
|||||||
|
|
||||||
# ── Color Strip Sources ────────────────────────────────────────────
|
# ── Color Strip Sources ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_color_strip_sources() -> dict:
|
def _build_color_strip_sources() -> dict:
|
||||||
return {
|
return {
|
||||||
_CSS_IDS["gradient"]: {
|
_CSS_IDS["gradient"]: {
|
||||||
@@ -321,6 +332,7 @@ def _build_color_strip_sources() -> dict:
|
|||||||
|
|
||||||
# ── Audio Sources ──────────────────────────────────────────────────
|
# ── Audio Sources ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_audio_sources() -> dict:
|
def _build_audio_sources() -> dict:
|
||||||
return {
|
return {
|
||||||
_AS_IDS["system"]: {
|
_AS_IDS["system"]: {
|
||||||
@@ -356,6 +368,7 @@ def _build_audio_sources() -> dict:
|
|||||||
|
|
||||||
# ── Scene Presets ──────────────────────────────────────────────────
|
# ── Scene Presets ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_scene_presets() -> dict:
|
def _build_scene_presets() -> dict:
|
||||||
return {
|
return {
|
||||||
_SCENE_ID: {
|
_SCENE_ID: {
|
||||||
@@ -369,14 +382,14 @@ def _build_scene_presets() -> dict:
|
|||||||
"target_id": _TARGET_IDS["strip"],
|
"target_id": _TARGET_IDS["strip"],
|
||||||
"running": True,
|
"running": True,
|
||||||
"color_strip_source_id": _CSS_IDS["gradient"],
|
"color_strip_source_id": _CSS_IDS["gradient"],
|
||||||
"brightness_value_source_id": "",
|
"brightness": 1.0,
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target_id": _TARGET_IDS["matrix"],
|
"target_id": _TARGET_IDS["matrix"],
|
||||||
"running": True,
|
"running": True,
|
||||||
"color_strip_source_id": _CSS_IDS["picture"],
|
"color_strip_source_id": _CSS_IDS["picture"],
|
||||||
"brightness_value_source_id": "",
|
"brightness": 1.0,
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -325,14 +325,14 @@ class HARuntime:
|
|||||||
for s in msg.get("result", []):
|
for s in msg.get("result", []):
|
||||||
eid = s.get("entity_id", "")
|
eid = s.get("entity_id", "")
|
||||||
if self._matches_filter(eid):
|
if self._matches_filter(eid):
|
||||||
|
attrs = s.get("attributes", {})
|
||||||
entities.append(
|
entities.append(
|
||||||
{
|
{
|
||||||
"entity_id": eid,
|
"entity_id": eid,
|
||||||
"state": s.get("state", ""),
|
"state": s.get("state", ""),
|
||||||
"friendly_name": s.get("attributes", {}).get(
|
"friendly_name": attrs.get("friendly_name", eid),
|
||||||
"friendly_name", eid
|
|
||||||
),
|
|
||||||
"domain": eid.split(".")[0] if "." in eid else "",
|
"domain": eid.split(".")[0] if "." in eid else "",
|
||||||
|
"icon": attrs.get("icon", ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return entities
|
return entities
|
||||||
|
|||||||
@@ -44,9 +44,17 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
fallback = source.fallback_color
|
fallback = source.fallback_color
|
||||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
self._fallback_color = (
|
||||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||||
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
|
)
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
|
|
||||||
|
self._timeout = max(0.0, bfloat(source.timeout, 5.0))
|
||||||
|
self._interpolation = (
|
||||||
|
source.interpolation
|
||||||
|
if source.interpolation in ("none", "linear", "nearest")
|
||||||
|
else "linear"
|
||||||
|
)
|
||||||
self._led_count = _DEFAULT_LED_COUNT
|
self._led_count = _DEFAULT_LED_COUNT
|
||||||
|
|
||||||
# Build initial fallback buffer
|
# Build initial fallback buffer
|
||||||
@@ -108,7 +116,9 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
dst_positions = np.linspace(0, 1, target_count)
|
dst_positions = np.linspace(0, 1, target_count)
|
||||||
result = np.empty((target_count, 3), dtype=np.uint8)
|
result = np.empty((target_count, 3), dtype=np.uint8)
|
||||||
for ch in range(3):
|
for ch in range(3):
|
||||||
result[:, ch] = np.interp(dst_positions, src_positions, colors[:, ch].astype(np.float32)).astype(np.uint8)
|
result[:, ch] = np.interp(
|
||||||
|
dst_positions, src_positions, colors[:, ch].astype(np.float32)
|
||||||
|
).astype(np.uint8)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def push_colors(self, colors: np.ndarray) -> None:
|
def push_colors(self, colors: np.ndarray) -> None:
|
||||||
@@ -125,10 +135,10 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
n = len(colors)
|
n = len(colors)
|
||||||
if n == self._led_count:
|
if n == self._led_count:
|
||||||
if self._colors.shape == colors.shape:
|
if self._colors.shape == colors.shape:
|
||||||
np.copyto(self._colors, colors, casting='unsafe')
|
np.copyto(self._colors, colors, casting="unsafe")
|
||||||
else:
|
else:
|
||||||
self._colors = np.empty((n, 3), dtype=np.uint8)
|
self._colors = np.empty((n, 3), dtype=np.uint8)
|
||||||
np.copyto(self._colors, colors, casting='unsafe')
|
np.copyto(self._colors, colors, casting="unsafe")
|
||||||
else:
|
else:
|
||||||
self._colors = self._resize(colors, self._led_count)
|
self._colors = self._resize(colors, self._led_count)
|
||||||
self._last_push_time = time.monotonic()
|
self._last_push_time = time.monotonic()
|
||||||
@@ -180,8 +190,8 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
buf[start:end] = colors[:length]
|
buf[start:end] = colors[:length]
|
||||||
else:
|
else:
|
||||||
# Pad with zeros if fewer colors than length
|
# Pad with zeros if fewer colors than length
|
||||||
buf[start:start + available] = colors
|
buf[start : start + available] = colors
|
||||||
buf[start + available:end] = 0
|
buf[start + available : end] = 0
|
||||||
|
|
||||||
elif mode == "gradient":
|
elif mode == "gradient":
|
||||||
stops = np.array(seg["colors"], dtype=np.float32)
|
stops = np.array(seg["colors"], dtype=np.float32)
|
||||||
@@ -243,7 +253,9 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=5.0)
|
self._thread.join(timeout=5.0)
|
||||||
if self._thread.is_alive():
|
if self._thread.is_alive():
|
||||||
logger.warning("ApiInputColorStripStream timeout thread did not terminate within 5s")
|
logger.warning(
|
||||||
|
"ApiInputColorStripStream timeout thread did not terminate within 5s"
|
||||||
|
)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
logger.info("ApiInputColorStripStream stopped")
|
logger.info("ApiInputColorStripStream stopped")
|
||||||
|
|
||||||
@@ -259,11 +271,21 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
"""Hot-update fallback_color, timeout, and interpolation from updated source config."""
|
"""Hot-update fallback_color, timeout, and interpolation from updated source config."""
|
||||||
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
||||||
|
|
||||||
if isinstance(source, ApiInputColorStripSource):
|
if isinstance(source, ApiInputColorStripSource):
|
||||||
fallback = source.fallback_color
|
fallback = source.fallback_color
|
||||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
self._fallback_color = (
|
||||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||||
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
|
)
|
||||||
|
_raw_t = source.timeout
|
||||||
|
self._timeout = max(
|
||||||
|
0.0, _raw_t.value if hasattr(_raw_t, "value") else float(_raw_t or 5.0)
|
||||||
|
)
|
||||||
|
self._interpolation = (
|
||||||
|
source.interpolation
|
||||||
|
if source.interpolation in ("none", "linear", "nearest")
|
||||||
|
else "linear"
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._fallback_array = self._build_fallback(self._led_count)
|
self._fallback_array = self._build_fallback(self._led_count)
|
||||||
if self._timed_out:
|
if self._timed_out:
|
||||||
@@ -274,12 +296,13 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
"""Background thread that reverts to fallback on timeout."""
|
"""Background thread that reverts to fallback on timeout."""
|
||||||
while self._running:
|
while self._running:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
if self._timeout <= 0:
|
timeout = self.resolve("timeout", self._timeout)
|
||||||
|
if timeout <= 0:
|
||||||
continue
|
continue
|
||||||
if self._timed_out:
|
if self._timed_out:
|
||||||
continue
|
continue
|
||||||
elapsed = time.monotonic() - self._last_push_time
|
elapsed = time.monotonic() - self._last_push_time
|
||||||
if elapsed >= self._timeout:
|
if elapsed >= timeout:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._colors = self._fallback_array.copy()
|
self._colors = self._fallback_array.copy()
|
||||||
self._timed_out = True
|
self._timed_out = True
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
thread, double-buffered output, configure() for auto-sizing.
|
thread, double-buffered output, configure() for auto-sizing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source, audio_capture_manager: AudioCaptureManager, audio_source_store=None, audio_template_store=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
source,
|
||||||
|
audio_capture_manager: AudioCaptureManager,
|
||||||
|
audio_source_store=None,
|
||||||
|
audio_template_store=None,
|
||||||
|
):
|
||||||
self._audio_capture_manager = audio_capture_manager
|
self._audio_capture_manager = audio_capture_manager
|
||||||
self._audio_source_store = audio_source_store
|
self._audio_source_store = audio_source_store
|
||||||
self._audio_template_store = audio_template_store
|
self._audio_template_store = audio_template_store
|
||||||
@@ -80,15 +86,19 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def _update_from_source(self, source) -> None:
|
def _update_from_source(self, source) -> None:
|
||||||
self._visualization_mode = getattr(source, "visualization_mode", "spectrum")
|
self._visualization_mode = getattr(source, "visualization_mode", "spectrum")
|
||||||
self._sensitivity = float(getattr(source, "sensitivity", 1.0))
|
from wled_controller.storage.bindable import bfloat
|
||||||
self._smoothing = float(getattr(source, "smoothing", 0.3))
|
|
||||||
|
self._sensitivity = bfloat(getattr(source, "sensitivity", 1.0), 1.0)
|
||||||
|
self._smoothing = bfloat(getattr(source, "smoothing", 0.3), 0.3)
|
||||||
self._gradient_id = getattr(source, "gradient_id", None)
|
self._gradient_id = getattr(source, "gradient_id", None)
|
||||||
self._palette_name = getattr(source, "palette", "rainbow")
|
self._palette_name = getattr(source, "palette", "rainbow")
|
||||||
self._resolve_palette_lut()
|
self._resolve_palette_lut()
|
||||||
color = getattr(source, "color", None)
|
color = getattr(source, "color", None)
|
||||||
self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
|
self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
|
||||||
color_peak = getattr(source, "color_peak", None)
|
color_peak = getattr(source, "color_peak", None)
|
||||||
self._color_peak = color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0]
|
self._color_peak = (
|
||||||
|
color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0]
|
||||||
|
)
|
||||||
# Pre-computed float arrays for VU meter (avoid per-frame np.array())
|
# Pre-computed float arrays for VU meter (avoid per-frame np.array())
|
||||||
self._color_f = np.array(self._color, dtype=np.float32)
|
self._color_f = np.array(self._color, dtype=np.float32)
|
||||||
self._color_peak_f = np.array(self._color_peak, dtype=np.float32)
|
self._color_peak_f = np.array(self._color_peak, dtype=np.float32)
|
||||||
@@ -116,7 +126,11 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
self._audio_engine_type = tpl.engine_type
|
self._audio_engine_type = tpl.engine_type
|
||||||
self._audio_engine_config = tpl.engine_config
|
self._audio_engine_config = tpl.engine_config
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning("Audio template %s not found, using default engine: %s", resolved.audio_template_id, e)
|
logger.warning(
|
||||||
|
"Audio template %s not found, using default engine: %s",
|
||||||
|
resolved.audio_template_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
pass
|
pass
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
|
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
|
||||||
@@ -157,7 +171,8 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
return
|
return
|
||||||
# Acquire shared audio capture stream
|
# Acquire shared audio capture stream
|
||||||
self._audio_stream = self._audio_capture_manager.acquire(
|
self._audio_stream = self._audio_capture_manager.acquire(
|
||||||
self._audio_device_index, self._audio_loopback,
|
self._audio_device_index,
|
||||||
|
self._audio_loopback,
|
||||||
engine_type=self._audio_engine_type,
|
engine_type=self._audio_engine_type,
|
||||||
engine_config=self._audio_engine_config,
|
engine_config=self._audio_engine_config,
|
||||||
)
|
)
|
||||||
@@ -184,7 +199,8 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
# Release shared audio capture
|
# Release shared audio capture
|
||||||
if self._audio_stream is not None:
|
if self._audio_stream is not None:
|
||||||
self._audio_capture_manager.release(
|
self._audio_capture_manager.release(
|
||||||
self._audio_device_index, self._audio_loopback,
|
self._audio_device_index,
|
||||||
|
self._audio_loopback,
|
||||||
engine_type=self._audio_engine_type,
|
engine_type=self._audio_engine_type,
|
||||||
)
|
)
|
||||||
self._audio_stream = None
|
self._audio_stream = None
|
||||||
@@ -200,6 +216,7 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import AudioColorStripSource
|
from wled_controller.storage.color_strip_source import AudioColorStripSource
|
||||||
|
|
||||||
if isinstance(source, AudioColorStripSource):
|
if isinstance(source, AudioColorStripSource):
|
||||||
old_device = self._audio_device_index
|
old_device = self._audio_device_index
|
||||||
old_loopback = self._audio_loopback
|
old_loopback = self._audio_loopback
|
||||||
@@ -217,10 +234,13 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
)
|
)
|
||||||
if self._running and needs_swap:
|
if self._running and needs_swap:
|
||||||
self._audio_capture_manager.release(
|
self._audio_capture_manager.release(
|
||||||
old_device, old_loopback, engine_type=old_engine_type,
|
old_device,
|
||||||
|
old_loopback,
|
||||||
|
engine_type=old_engine_type,
|
||||||
)
|
)
|
||||||
self._audio_stream = self._audio_capture_manager.acquire(
|
self._audio_stream = self._audio_capture_manager.acquire(
|
||||||
self._audio_device_index, self._audio_loopback,
|
self._audio_device_index,
|
||||||
|
self._audio_loopback,
|
||||||
engine_type=self._audio_engine_type,
|
engine_type=self._audio_engine_type,
|
||||||
engine_config=self._audio_engine_config,
|
engine_config=self._audio_engine_config,
|
||||||
)
|
)
|
||||||
@@ -301,7 +321,9 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
self._colors = buf
|
self._colors = buf
|
||||||
|
|
||||||
# Pull capture-side timing and combine with render timing
|
# Pull capture-side timing and combine with render timing
|
||||||
capture_timing = self._audio_stream.get_last_timing() if self._audio_stream else {}
|
capture_timing = (
|
||||||
|
self._audio_stream.get_last_timing() if self._audio_stream else {}
|
||||||
|
)
|
||||||
read_ms = capture_timing.get("read_ms", 0)
|
read_ms = capture_timing.get("read_ms", 0)
|
||||||
fft_ms = capture_timing.get("fft_ms", 0)
|
fft_ms = capture_timing.get("fft_ms", 0)
|
||||||
self._last_timing = {
|
self._last_timing = {
|
||||||
@@ -342,8 +364,8 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
return
|
return
|
||||||
|
|
||||||
spectrum, _ = self._pick_channel(analysis)
|
spectrum, _ = self._pick_channel(analysis)
|
||||||
sensitivity = self._sensitivity
|
sensitivity = self.resolve("sensitivity", self._sensitivity)
|
||||||
smoothing = self._smoothing
|
smoothing = self.resolve("smoothing", self._smoothing)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
band_x = self._band_x
|
band_x = self._band_x
|
||||||
full_amp = self._full_amp
|
full_amp = self._full_amp
|
||||||
@@ -355,7 +377,7 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
amplitudes *= sensitivity
|
amplitudes *= sensitivity
|
||||||
np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
|
np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
|
||||||
if self._prev_spectrum is not None and len(self._prev_spectrum) == half:
|
if self._prev_spectrum is not None and len(self._prev_spectrum) == half:
|
||||||
amplitudes *= (1.0 - smoothing)
|
amplitudes *= 1.0 - smoothing
|
||||||
amplitudes += smoothing * self._prev_spectrum
|
amplitudes += smoothing * self._prev_spectrum
|
||||||
self._prev_spectrum = amplitudes.copy()
|
self._prev_spectrum = amplitudes.copy()
|
||||||
# Mirror: center = bass, edges = treble
|
# Mirror: center = bass, edges = treble
|
||||||
@@ -366,7 +388,7 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
amplitudes *= sensitivity
|
amplitudes *= sensitivity
|
||||||
np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
|
np.clip(amplitudes, 0.0, 1.0, out=amplitudes)
|
||||||
if self._prev_spectrum is not None and len(self._prev_spectrum) == n:
|
if self._prev_spectrum is not None and len(self._prev_spectrum) == n:
|
||||||
amplitudes *= (1.0 - smoothing)
|
amplitudes *= 1.0 - smoothing
|
||||||
amplitudes += smoothing * self._prev_spectrum
|
amplitudes += smoothing * self._prev_spectrum
|
||||||
self._prev_spectrum = amplitudes.copy()
|
self._prev_spectrum = amplitudes.copy()
|
||||||
full_amp[:] = amplitudes
|
full_amp[:] = amplitudes
|
||||||
@@ -374,16 +396,16 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
# Map to palette: amplitude → palette index → color
|
# Map to palette: amplitude → palette index → color
|
||||||
np.multiply(full_amp, 255, out=full_amp)
|
np.multiply(full_amp, 255, out=full_amp)
|
||||||
np.clip(full_amp, 0, 255, out=full_amp)
|
np.clip(full_amp, 0, 255, out=full_amp)
|
||||||
np.copyto(indices_buf, full_amp, casting='unsafe')
|
np.copyto(indices_buf, full_amp, casting="unsafe")
|
||||||
colors = lut[indices_buf] # (n, 3) uint8
|
colors = lut[indices_buf] # (n, 3) uint8
|
||||||
|
|
||||||
# Scale brightness by amplitude — restore full_amp to [0, 1]
|
# Scale brightness by amplitude — restore full_amp to [0, 1]
|
||||||
full_amp *= (1.0 / 255.0)
|
full_amp *= 1.0 / 255.0
|
||||||
f32_rgb = self._f32_rgb
|
f32_rgb = self._f32_rgb
|
||||||
np.copyto(f32_rgb, colors, casting='unsafe')
|
np.copyto(f32_rgb, colors, casting="unsafe")
|
||||||
f32_rgb *= full_amp[:, np.newaxis]
|
f32_rgb *= full_amp[:, np.newaxis]
|
||||||
np.clip(f32_rgb, 0, 255, out=f32_rgb)
|
np.clip(f32_rgb, 0, 255, out=f32_rgb)
|
||||||
np.copyto(buf, f32_rgb, casting='unsafe')
|
np.copyto(buf, f32_rgb, casting="unsafe")
|
||||||
|
|
||||||
# ── VU Meter ───────────────────────────────────────────────────
|
# ── VU Meter ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -393,8 +415,10 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
return
|
return
|
||||||
|
|
||||||
_, ch_rms = self._pick_channel(analysis)
|
_, ch_rms = self._pick_channel(analysis)
|
||||||
rms = ch_rms * self._sensitivity
|
sensitivity = self.resolve("sensitivity", self._sensitivity)
|
||||||
rms = self._smoothing * self._prev_rms + (1.0 - self._smoothing) * rms
|
smoothing = self.resolve("smoothing", self._smoothing)
|
||||||
|
rms = ch_rms * sensitivity
|
||||||
|
rms = smoothing * self._prev_rms + (1.0 - smoothing) * rms
|
||||||
self._prev_rms = rms
|
self._prev_rms = rms
|
||||||
rms = min(1.0, rms)
|
rms = min(1.0, rms)
|
||||||
|
|
||||||
@@ -406,9 +430,9 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
peak = self._color_peak_f
|
peak = self._color_peak_f
|
||||||
t = self._vu_gradient[:fill_count]
|
t = self._vu_gradient[:fill_count]
|
||||||
for ch in range(3):
|
for ch in range(3):
|
||||||
buf[:fill_count, ch] = np.clip(
|
buf[:fill_count, ch] = np.clip(base[ch] + (peak[ch] - base[ch]) * t, 0, 255).astype(
|
||||||
base[ch] + (peak[ch] - base[ch]) * t, 0, 255
|
np.uint8
|
||||||
).astype(np.uint8)
|
)
|
||||||
|
|
||||||
# ── Beat Pulse ─────────────────────────────────────────────────
|
# ── Beat Pulse ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -420,7 +444,9 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
if analysis.beat:
|
if analysis.beat:
|
||||||
self._pulse_brightness = 1.0
|
self._pulse_brightness = 1.0
|
||||||
else:
|
else:
|
||||||
decay_rate = 0.05 + 0.15 * (1.0 / max(self._sensitivity, 0.1))
|
decay_rate = 0.05 + 0.15 * (
|
||||||
|
1.0 / max(self.resolve("sensitivity", self._sensitivity), 0.1)
|
||||||
|
)
|
||||||
self._pulse_brightness = max(0.0, self._pulse_brightness - decay_rate)
|
self._pulse_brightness = max(0.0, self._pulse_brightness - decay_rate)
|
||||||
|
|
||||||
brightness = self._pulse_brightness
|
brightness = self._pulse_brightness
|
||||||
@@ -432,7 +458,11 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
base_color = self._palette_lut[palette_idx]
|
base_color = self._palette_lut[palette_idx]
|
||||||
|
|
||||||
# Vectorized fill: scale color by brightness and broadcast to all LEDs
|
# Vectorized fill: scale color by brightness and broadcast to all LEDs
|
||||||
r, g, b = int(base_color[0] * brightness), int(base_color[1] * brightness), int(base_color[2] * brightness)
|
r, g, b = (
|
||||||
|
int(base_color[0] * brightness),
|
||||||
|
int(base_color[1] * brightness),
|
||||||
|
int(base_color[2] * brightness),
|
||||||
|
)
|
||||||
buf[:, 0] = r
|
buf[:, 0] = r
|
||||||
buf[:, 1] = g
|
buf[:, 1] = g
|
||||||
buf[:, 2] = b
|
buf[:, 2] = b
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ def _noise1d(x: np.ndarray) -> np.ndarray:
|
|||||||
# (flicker_amplitude_mul, speed_mul, sigma_mul, warm_bonus)
|
# (flicker_amplitude_mul, speed_mul, sigma_mul, warm_bonus)
|
||||||
_CANDLE_PRESETS: dict = {
|
_CANDLE_PRESETS: dict = {
|
||||||
"default": (1.0, 1.0, 1.0, 0.0),
|
"default": (1.0, 1.0, 1.0, 0.0),
|
||||||
"taper": (0.5, 1.3, 0.8, 0.0), # tall, steady
|
"taper": (0.5, 1.3, 0.8, 0.0), # tall, steady
|
||||||
"votive": (1.5, 1.0, 0.7, 0.0), # small, flickery
|
"votive": (1.5, 1.0, 0.7, 0.0), # small, flickery
|
||||||
"bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift
|
"bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift
|
||||||
}
|
}
|
||||||
|
|
||||||
_VALID_CANDLE_TYPES = frozenset(_CANDLE_PRESETS)
|
_VALID_CANDLE_TYPES = frozenset(_CANDLE_PRESETS)
|
||||||
@@ -86,11 +86,15 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def _update_from_source(self, source) -> None:
|
def _update_from_source(self, source) -> None:
|
||||||
raw_color = getattr(source, "color", None)
|
raw_color = getattr(source, "color", None)
|
||||||
self._color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
|
self._color = (
|
||||||
self._intensity = float(getattr(source, "intensity", 1.0))
|
raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
|
||||||
|
)
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
|
|
||||||
|
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
|
||||||
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
|
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
|
||||||
self._speed = float(getattr(source, "speed", 1.0))
|
self._speed = bfloat(getattr(source, "speed", 1.0), 1.0)
|
||||||
self._wind_strength = float(getattr(source, "wind_strength", 0.0))
|
self._wind_strength = bfloat(getattr(source, "wind_strength", 0.0), 0.0)
|
||||||
raw_type = getattr(source, "candle_type", "default")
|
raw_type = getattr(source, "candle_type", "default")
|
||||||
self._candle_type = raw_type if raw_type in _VALID_CANDLE_TYPES else "default"
|
self._candle_type = raw_type if raw_type in _VALID_CANDLE_TYPES else "default"
|
||||||
_lc = getattr(source, "led_count", 0)
|
_lc = getattr(source, "led_count", 0)
|
||||||
@@ -127,7 +131,9 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
logger.info(f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})")
|
logger.info(
|
||||||
|
f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})"
|
||||||
|
)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -144,6 +150,7 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import CandlelightColorStripSource
|
from wled_controller.storage.color_strip_source import CandlelightColorStripSource
|
||||||
|
|
||||||
if isinstance(source, CandlelightColorStripSource):
|
if isinstance(source, CandlelightColorStripSource):
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
@@ -173,10 +180,10 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
t = clock.get_time()
|
t = clock.get_time()
|
||||||
speed = clock.speed * self._speed
|
speed = clock.speed * self.resolve("speed", self._speed)
|
||||||
else:
|
else:
|
||||||
t = wall_start
|
t = wall_start
|
||||||
speed = self._speed
|
speed = self.resolve("speed", self._speed)
|
||||||
|
|
||||||
n = self._led_count
|
n = self._led_count
|
||||||
if n != _pool_n:
|
if n != _pool_n:
|
||||||
@@ -210,7 +217,7 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None:
|
def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None:
|
||||||
"""Spawn new wax drip events and advance existing ones."""
|
"""Spawn new wax drip events and advance existing ones."""
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
spawn_interval = max(0.3, 1.0 / max(intensity, 0.01))
|
spawn_interval = max(0.3, 1.0 / max(intensity, 0.01))
|
||||||
if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5:
|
if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5:
|
||||||
self._last_drip_t = wall_t
|
self._last_drip_t = wall_t
|
||||||
@@ -245,21 +252,22 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# ── Render ──────────────────────────────────────────────────────
|
# ── Render ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float, wall_t: float) -> None:
|
def _render_candlelight(
|
||||||
|
self, buf: np.ndarray, n: int, t: float, speed: float, wall_t: float
|
||||||
|
) -> None:
|
||||||
"""Render candle flickering into buf (n, 3) uint8."""
|
"""Render candle flickering into buf (n, 3) uint8."""
|
||||||
amp_mul, spd_mul, sigma_mul, warm_bonus = _CANDLE_PRESETS[self._candle_type]
|
amp_mul, spd_mul, sigma_mul, warm_bonus = _CANDLE_PRESETS[self._candle_type]
|
||||||
|
|
||||||
eff_speed = speed * 0.35 * spd_mul
|
eff_speed = speed * 0.35 * spd_mul
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
num_candles = self._num_candles
|
num_candles = self._num_candles
|
||||||
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
|
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
|
||||||
|
|
||||||
# Wind modulation
|
# Wind modulation
|
||||||
wind_strength = self._wind_strength
|
wind_strength = self.resolve("wind_strength", self._wind_strength)
|
||||||
if wind_strength > 0.0:
|
if wind_strength > 0.0:
|
||||||
wind_raw = (
|
wind_raw = 0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t) + 0.4 * math.sin(
|
||||||
0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t)
|
2.0 * math.pi * 0.27 * wall_t + 1.1
|
||||||
+ 0.4 * math.sin(2.0 * math.pi * 0.27 * wall_t + 1.1)
|
|
||||||
)
|
)
|
||||||
wind_mod = max(0.0, wind_raw)
|
wind_mod = max(0.0, wind_raw)
|
||||||
else:
|
else:
|
||||||
@@ -287,7 +295,7 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul
|
candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul
|
||||||
|
|
||||||
if wind_strength > 0.0:
|
if wind_strength > 0.0:
|
||||||
candle_brightness *= (1.0 - wind_strength * wind_mod * 0.4)
|
candle_brightness *= 1.0 - wind_strength * wind_mod * 0.4
|
||||||
|
|
||||||
candle_brightness = max(0.1, candle_brightness)
|
candle_brightness = max(0.1, candle_brightness)
|
||||||
|
|
||||||
@@ -300,7 +308,7 @@ class CandlelightColorStripStream(ColorStripStream):
|
|||||||
# Per-LED noise
|
# Per-LED noise
|
||||||
noise_x = x * 0.3 + t * eff_speed * 5.0
|
noise_x = x * 0.3 + t * eff_speed * 5.0
|
||||||
noise = _noise1d(noise_x)
|
noise = _noise1d(noise_x)
|
||||||
bright[:n] *= (0.85 + 0.30 * noise)
|
bright[:n] *= 0.85 + 0.30 * noise
|
||||||
|
|
||||||
# Wax drip factor
|
# Wax drip factor
|
||||||
bright[:n] *= self._s_drip[:n]
|
bright[:n] *= self._s_drip[:n]
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ from typing import Optional
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.capture.calibration import CalibrationConfig, AdvancedPixelMapper, create_pixel_mapper
|
from wled_controller.core.capture.calibration import (
|
||||||
|
CalibrationConfig,
|
||||||
|
AdvancedPixelMapper,
|
||||||
|
create_pixel_mapper,
|
||||||
|
)
|
||||||
from wled_controller.core.capture.screen_capture import extract_border_pixels
|
from wled_controller.core.capture.screen_capture import extract_border_pixels
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.utils.timer import high_resolution_timer
|
from wled_controller.utils.timer import high_resolution_timer
|
||||||
|
|
||||||
@@ -105,6 +110,32 @@ class ColorStripStream(ABC):
|
|||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
"""Hot-update processing parameters. No-op by default."""
|
"""Hot-update processing parameters. No-op by default."""
|
||||||
|
|
||||||
|
# ── BindableFloat value stream resolution ──
|
||||||
|
|
||||||
|
_value_streams: dict = None # property_name → ValueStream
|
||||||
|
|
||||||
|
def set_value_stream(self, prop: str, stream) -> None:
|
||||||
|
"""Inject a ValueStream for a bindable property."""
|
||||||
|
if self._value_streams is None:
|
||||||
|
self._value_streams = {}
|
||||||
|
self._value_streams[prop] = stream
|
||||||
|
|
||||||
|
def remove_value_stream(self, prop: str) -> None:
|
||||||
|
"""Remove a ValueStream for a bindable property."""
|
||||||
|
if self._value_streams:
|
||||||
|
self._value_streams.pop(prop, None)
|
||||||
|
|
||||||
|
def resolve(self, prop: str, static: float) -> float:
|
||||||
|
"""Resolve a bindable property: ValueStream value if bound, else static."""
|
||||||
|
if self._value_streams:
|
||||||
|
vs = self._value_streams.get(prop)
|
||||||
|
if vs is not None:
|
||||||
|
try:
|
||||||
|
return vs.get_value()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return static
|
||||||
|
|
||||||
|
|
||||||
class PictureColorStripStream(ColorStripStream):
|
class PictureColorStripStream(ColorStripStream):
|
||||||
"""Color strip stream backed by a LiveStream (picture source).
|
"""Color strip stream backed by a LiveStream (picture source).
|
||||||
@@ -138,7 +169,7 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
self._fps: int = 30 # internal capture rate (send FPS is on the target)
|
self._fps: int = 30 # internal capture rate (send FPS is on the target)
|
||||||
self._frame_time: float = 1.0 / 30
|
self._frame_time: float = 1.0 / 30
|
||||||
self._smoothing: float = source.smoothing
|
self._smoothing: float = bfloat(source.smoothing, 0.3)
|
||||||
self._interpolation_mode: str = source.interpolation_mode
|
self._interpolation_mode: str = source.interpolation_mode
|
||||||
self._calibration: CalibrationConfig = source.calibration
|
self._calibration: CalibrationConfig = source.calibration
|
||||||
self._pixel_mapper = create_pixel_mapper(
|
self._pixel_mapper = create_pixel_mapper(
|
||||||
@@ -189,9 +220,7 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
logger.info(
|
logger.info(f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})")
|
||||||
f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -224,12 +253,15 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
PixelMapper is rebuilt atomically if calibration or interpolation_mode changed.
|
PixelMapper is rebuilt atomically if calibration or interpolation_mode changed.
|
||||||
"""
|
"""
|
||||||
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
|
from wled_controller.storage.color_strip_source import (
|
||||||
|
PictureColorStripSource,
|
||||||
|
AdvancedPictureColorStripSource,
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||||
return
|
return
|
||||||
|
|
||||||
self._smoothing = source.smoothing
|
self._smoothing = bfloat(source.smoothing, 0.3)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
source.interpolation_mode != self._interpolation_mode
|
source.interpolation_mode != self._interpolation_mode
|
||||||
@@ -253,9 +285,9 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Scratch buffer pool (pre-allocated, resized when LED count changes)
|
# Scratch buffer pool (pre-allocated, resized when LED count changes)
|
||||||
_pool_n = 0
|
_pool_n = 0
|
||||||
_frame_a = _frame_b = None # double-buffered uint8 output
|
_frame_a = _frame_b = None # double-buffered uint8 output
|
||||||
_use_a = True
|
_use_a = True
|
||||||
_u16_a = _u16_b = None # uint16 scratch for smoothing blending
|
_u16_a = _u16_b = None # uint16 scratch for smoothing blending
|
||||||
|
|
||||||
def _blend_u16(a, b, alpha_b, out):
|
def _blend_u16(a, b, alpha_b, out):
|
||||||
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
|
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
|
||||||
@@ -263,13 +295,13 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b).
|
Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b).
|
||||||
"""
|
"""
|
||||||
nonlocal _u16_a, _u16_b
|
nonlocal _u16_a, _u16_b
|
||||||
np.copyto(_u16_a, a, casting='unsafe')
|
np.copyto(_u16_a, a, casting="unsafe")
|
||||||
np.copyto(_u16_b, b, casting='unsafe')
|
np.copyto(_u16_b, b, casting="unsafe")
|
||||||
_u16_a *= (256 - alpha_b)
|
_u16_a *= 256 - alpha_b
|
||||||
_u16_b *= alpha_b
|
_u16_b *= alpha_b
|
||||||
_u16_a += _u16_b
|
_u16_a += _u16_b
|
||||||
_u16_a >>= 8
|
_u16_a >>= 8
|
||||||
np.copyto(out, _u16_a, casting='unsafe')
|
np.copyto(out, _u16_a, casting="unsafe")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
@@ -333,15 +365,16 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
led_colors = frame_buf
|
led_colors = frame_buf
|
||||||
|
|
||||||
# Temporal smoothing (pre-allocated uint16 scratch)
|
# Temporal smoothing (pre-allocated uint16 scratch)
|
||||||
smoothing = self._smoothing
|
smoothing = self.resolve("smoothing", self._smoothing)
|
||||||
if (
|
if (
|
||||||
self._previous_colors is not None
|
self._previous_colors is not None
|
||||||
and smoothing > 0
|
and smoothing > 0
|
||||||
and len(self._previous_colors) == len(led_colors)
|
and len(self._previous_colors) == len(led_colors)
|
||||||
and _u16_a is not None
|
and _u16_a is not None
|
||||||
):
|
):
|
||||||
_blend_u16(led_colors, self._previous_colors,
|
_blend_u16(
|
||||||
int(smoothing * 256), led_colors)
|
led_colors, self._previous_colors, int(smoothing * 256), led_colors
|
||||||
|
)
|
||||||
t3 = time.perf_counter()
|
t3 = time.perf_counter()
|
||||||
|
|
||||||
self._previous_colors = led_colors
|
self._previous_colors = led_colors
|
||||||
@@ -357,7 +390,9 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"PictureColorStripStream processing error: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"PictureColorStripStream processing error: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
elapsed = time.perf_counter() - loop_start
|
elapsed = time.perf_counter() - loop_start
|
||||||
remaining = frame_time - elapsed
|
remaining = frame_time - elapsed
|
||||||
@@ -398,7 +433,9 @@ def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear"
|
|||||||
if cr and isinstance(cr, list) and len(cr) == 3:
|
if cr and isinstance(cr, list) and len(cr) == 3:
|
||||||
return np.array(cr, dtype=np.float32)
|
return np.array(cr, dtype=np.float32)
|
||||||
c = stop.get("color", [255, 255, 255])
|
c = stop.get("color", [255, 255, 255])
|
||||||
return np.array(c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32)
|
return np.array(
|
||||||
|
c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32
|
||||||
|
)
|
||||||
|
|
||||||
# Vectorized: compute all LED positions at once
|
# Vectorized: compute all LED positions at once
|
||||||
positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0])
|
positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0])
|
||||||
@@ -442,8 +479,8 @@ def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear"
|
|||||||
steps = float(max(2, n_stops))
|
steps = float(max(2, n_stops))
|
||||||
t = np.round(t * steps) / steps
|
t = np.round(t * steps) / steps
|
||||||
|
|
||||||
a_colors = right_colors[idx] # A's right color
|
a_colors = right_colors[idx] # A's right color
|
||||||
b_colors = left_colors[idx + 1] # B's left color
|
b_colors = left_colors[idx + 1] # B's left color
|
||||||
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
|
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
|
||||||
|
|
||||||
return np.clip(result, 0, 255).astype(np.uint8)
|
return np.clip(result, 0, 255).astype(np.uint8)
|
||||||
@@ -470,7 +507,11 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
|
|
||||||
def _update_from_source(self, source) -> None:
|
def _update_from_source(self, source) -> None:
|
||||||
color = source.color if isinstance(source.color, list) and len(source.color) == 3 else [255, 255, 255]
|
color = (
|
||||||
|
source.color
|
||||||
|
if isinstance(source.color, list) and len(source.color) == 3
|
||||||
|
else [255, 255, 255]
|
||||||
|
)
|
||||||
self._source_color = color # stored separately so configure() can rebuild
|
self._source_color = color # stored separately so configure() can rebuild
|
||||||
_lc = getattr(source, "led_count", 0)
|
_lc = getattr(source, "led_count", 0)
|
||||||
self._auto_size = not _lc
|
self._auto_size = not _lc
|
||||||
@@ -544,6 +585,7 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import StaticColorStripSource
|
from wled_controller.storage.color_strip_source import StaticColorStripSource
|
||||||
|
|
||||||
if isinstance(source, StaticColorStripSource):
|
if isinstance(source, StaticColorStripSource):
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
@@ -602,7 +644,11 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
if atype == "breathing":
|
if atype == "breathing":
|
||||||
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
|
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
|
||||||
r, g, b = self._source_color
|
r, g, b = self._source_color
|
||||||
buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor)))
|
buf[:] = (
|
||||||
|
min(255, int(r * factor)),
|
||||||
|
min(255, int(g * factor)),
|
||||||
|
min(255, int(b * factor)),
|
||||||
|
)
|
||||||
colors = buf
|
colors = buf
|
||||||
|
|
||||||
elif atype == "strobe":
|
elif atype == "strobe":
|
||||||
@@ -631,7 +677,11 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
else:
|
else:
|
||||||
factor = math.exp(-5.0 * (phase - 0.1))
|
factor = math.exp(-5.0 * (phase - 0.1))
|
||||||
r, g, b = self._source_color
|
r, g, b = self._source_color
|
||||||
buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor)))
|
buf[:] = (
|
||||||
|
min(255, int(r * factor)),
|
||||||
|
min(255, int(g * factor)),
|
||||||
|
min(255, int(b * factor)),
|
||||||
|
)
|
||||||
colors = buf
|
colors = buf
|
||||||
|
|
||||||
elif atype == "candle":
|
elif atype == "candle":
|
||||||
@@ -642,7 +692,11 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
flicker += 0.10 * (np.random.random() - 0.5)
|
flicker += 0.10 * (np.random.random() - 0.5)
|
||||||
factor = max(0.2, min(1.0, base_factor + flicker))
|
factor = max(0.2, min(1.0, base_factor + flicker))
|
||||||
r, g, b = self._source_color
|
r, g, b = self._source_color
|
||||||
buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor)))
|
buf[:] = (
|
||||||
|
min(255, int(r * factor)),
|
||||||
|
min(255, int(g * factor)),
|
||||||
|
min(255, int(b * factor)),
|
||||||
|
)
|
||||||
colors = buf
|
colors = buf
|
||||||
|
|
||||||
elif atype == "rainbow_fade":
|
elif atype == "rainbow_fade":
|
||||||
@@ -694,12 +748,14 @@ class ColorCycleColorStripStream(ColorStripStream):
|
|||||||
def _update_from_source(self, source) -> None:
|
def _update_from_source(self, source) -> None:
|
||||||
raw = source.colors if isinstance(source.colors, list) else []
|
raw = source.colors if isinstance(source.colors, list) else []
|
||||||
default = [
|
default = [
|
||||||
[255, 0, 0], [255, 255, 0], [0, 255, 0],
|
[255, 0, 0],
|
||||||
[0, 255, 255], [0, 0, 255], [255, 0, 255],
|
[255, 255, 0],
|
||||||
|
[0, 255, 0],
|
||||||
|
[0, 255, 255],
|
||||||
|
[0, 0, 255],
|
||||||
|
[255, 0, 255],
|
||||||
]
|
]
|
||||||
self._color_list = [
|
self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
|
||||||
c for c in raw if isinstance(c, list) and len(c) == 3
|
|
||||||
] or default
|
|
||||||
_lc = getattr(source, "led_count", 0)
|
_lc = getattr(source, "led_count", 0)
|
||||||
self._auto_size = not _lc
|
self._auto_size = not _lc
|
||||||
self._led_count = _lc if _lc > 0 else 1
|
self._led_count = _lc if _lc > 0 else 1
|
||||||
@@ -742,14 +798,18 @@ class ColorCycleColorStripStream(ColorStripStream):
|
|||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
logger.info(f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})")
|
logger.info(
|
||||||
|
f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
|
||||||
|
)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=5.0)
|
self._thread.join(timeout=5.0)
|
||||||
if self._thread.is_alive():
|
if self._thread.is_alive():
|
||||||
logger.warning("ColorCycleColorStripStream animate thread did not terminate within 5s")
|
logger.warning(
|
||||||
|
"ColorCycleColorStripStream animate thread did not terminate within 5s"
|
||||||
|
)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
logger.info("ColorCycleColorStripStream stopped")
|
logger.info("ColorCycleColorStripStream stopped")
|
||||||
|
|
||||||
@@ -759,6 +819,7 @@ class ColorCycleColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import ColorCycleColorStripSource
|
from wled_controller.storage.color_strip_source import ColorCycleColorStripSource
|
||||||
|
|
||||||
if isinstance(source, ColorCycleColorStripSource):
|
if isinstance(source, ColorCycleColorStripSource):
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
@@ -929,14 +990,18 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
logger.info(f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})")
|
logger.info(
|
||||||
|
f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})"
|
||||||
|
)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=5.0)
|
self._thread.join(timeout=5.0)
|
||||||
if self._thread.is_alive():
|
if self._thread.is_alive():
|
||||||
logger.warning("GradientColorStripStream animate thread did not terminate within 5s")
|
logger.warning(
|
||||||
|
"GradientColorStripStream animate thread did not terminate within 5s"
|
||||||
|
)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
logger.info("GradientColorStripStream stopped")
|
logger.info("GradientColorStripStream stopped")
|
||||||
|
|
||||||
@@ -946,6 +1011,7 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import GradientColorStripSource
|
from wled_controller.storage.color_strip_source import GradientColorStripSource
|
||||||
|
|
||||||
if isinstance(source, GradientColorStripSource):
|
if isinstance(source, GradientColorStripSource):
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
@@ -973,9 +1039,9 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
_pool_n = 0
|
_pool_n = 0
|
||||||
_buf_a = _buf_b = _scratch_u16 = None
|
_buf_a = _buf_b = _scratch_u16 = None
|
||||||
_use_a = True
|
_use_a = True
|
||||||
_wave_i = None # cached np.arange for wave animation
|
_wave_i = None # cached np.arange for wave animation
|
||||||
_wave_factors = None # float32 scratch for wave sin result
|
_wave_factors = None # float32 scratch for wave sin result
|
||||||
_wave_u16 = None # uint16 scratch for wave int factors
|
_wave_u16 = None # uint16 scratch for wave int factors
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
@@ -1002,7 +1068,12 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Recompute base gradient only when stops, led_count, or easing change
|
# Recompute base gradient only when stops, led_count, or easing change
|
||||||
easing = self._easing
|
easing = self._easing
|
||||||
if _cached_base is None or _cached_n != n or _cached_stops is not stops or _cached_easing != easing:
|
if (
|
||||||
|
_cached_base is None
|
||||||
|
or _cached_n != n
|
||||||
|
or _cached_stops is not stops
|
||||||
|
or _cached_easing != easing
|
||||||
|
):
|
||||||
_cached_base = _compute_gradient_colors(stops, n, easing)
|
_cached_base = _compute_gradient_colors(stops, n, easing)
|
||||||
_cached_n = n
|
_cached_n = n
|
||||||
_cached_stops = stops
|
_cached_stops = stops
|
||||||
@@ -1023,18 +1094,28 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
_use_a = not _use_a
|
_use_a = not _use_a
|
||||||
|
|
||||||
if atype == "breathing":
|
if atype == "breathing":
|
||||||
int_f = max(0, min(256, int(0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) * 256)))
|
int_f = max(
|
||||||
|
0,
|
||||||
|
min(
|
||||||
|
256,
|
||||||
|
int(
|
||||||
|
0.5
|
||||||
|
* (1 + math.sin(2 * math.pi * speed * t * 0.5))
|
||||||
|
* 256
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
np.copyto(_scratch_u16, base)
|
np.copyto(_scratch_u16, base)
|
||||||
_scratch_u16 *= int_f
|
_scratch_u16 *= int_f
|
||||||
_scratch_u16 >>= 8
|
_scratch_u16 >>= 8
|
||||||
np.copyto(buf, _scratch_u16, casting='unsafe')
|
np.copyto(buf, _scratch_u16, casting="unsafe")
|
||||||
colors = buf
|
colors = buf
|
||||||
|
|
||||||
elif atype == "gradient_shift":
|
elif atype == "gradient_shift":
|
||||||
shift = int(speed * t * 10) % max(n, 1)
|
shift = int(speed * t * 10) % max(n, 1)
|
||||||
if shift > 0:
|
if shift > 0:
|
||||||
buf[:n - shift] = base[shift:]
|
buf[: n - shift] = base[shift:]
|
||||||
buf[n - shift:] = base[:shift]
|
buf[n - shift :] = base[:shift]
|
||||||
else:
|
else:
|
||||||
np.copyto(buf, base)
|
np.copyto(buf, base)
|
||||||
colors = buf
|
colors = buf
|
||||||
@@ -1049,11 +1130,11 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
_wave_factors += 0.5
|
_wave_factors += 0.5
|
||||||
np.multiply(_wave_factors, 256, out=_wave_factors)
|
np.multiply(_wave_factors, 256, out=_wave_factors)
|
||||||
np.clip(_wave_factors, 0, 256, out=_wave_factors)
|
np.clip(_wave_factors, 0, 256, out=_wave_factors)
|
||||||
np.copyto(_wave_u16, _wave_factors, casting='unsafe')
|
np.copyto(_wave_u16, _wave_factors, casting="unsafe")
|
||||||
np.copyto(_scratch_u16, base)
|
np.copyto(_scratch_u16, base)
|
||||||
_scratch_u16 *= _wave_u16[:, None]
|
_scratch_u16 *= _wave_u16[:, None]
|
||||||
_scratch_u16 >>= 8
|
_scratch_u16 >>= 8
|
||||||
np.copyto(buf, _scratch_u16, casting='unsafe')
|
np.copyto(buf, _scratch_u16, casting="unsafe")
|
||||||
colors = buf
|
colors = buf
|
||||||
else:
|
else:
|
||||||
np.copyto(buf, base)
|
np.copyto(buf, base)
|
||||||
@@ -1083,7 +1164,7 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
np.copyto(_scratch_u16, base)
|
np.copyto(_scratch_u16, base)
|
||||||
_scratch_u16 *= int_f
|
_scratch_u16 *= int_f
|
||||||
_scratch_u16 >>= 8
|
_scratch_u16 >>= 8
|
||||||
np.copyto(buf, _scratch_u16, casting='unsafe')
|
np.copyto(buf, _scratch_u16, casting="unsafe")
|
||||||
colors = buf
|
colors = buf
|
||||||
|
|
||||||
elif atype == "candle":
|
elif atype == "candle":
|
||||||
@@ -1096,7 +1177,7 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
np.copyto(_scratch_u16, base)
|
np.copyto(_scratch_u16, base)
|
||||||
_scratch_u16 *= int_f
|
_scratch_u16 *= int_f
|
||||||
_scratch_u16 >>= 8
|
_scratch_u16 >>= 8
|
||||||
np.copyto(buf, _scratch_u16, casting='unsafe')
|
np.copyto(buf, _scratch_u16, casting="unsafe")
|
||||||
colors = buf
|
colors = buf
|
||||||
|
|
||||||
elif atype == "rainbow_fade":
|
elif atype == "rainbow_fade":
|
||||||
@@ -1117,7 +1198,7 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
|
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
|
||||||
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
|
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
|
||||||
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
|
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
|
||||||
h_arr *= (1.0 / 6.0)
|
h_arr *= 1.0 / 6.0
|
||||||
h_arr %= 1.0
|
h_arr %= 1.0
|
||||||
# Saturation & Value with clamping
|
# Saturation & Value with clamping
|
||||||
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
|
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
|
||||||
@@ -1138,9 +1219,12 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
go = np.empty(n, dtype=np.float32)
|
go = np.empty(n, dtype=np.float32)
|
||||||
bo = np.empty(n, dtype=np.float32)
|
bo = np.empty(n, dtype=np.float32)
|
||||||
for sxt, rv, gv, bv in (
|
for sxt, rv, gv, bv in (
|
||||||
(0, v_arr, tt, p), (1, q, v_arr, p),
|
(0, v_arr, tt, p),
|
||||||
(2, p, v_arr, tt), (3, p, q, v_arr),
|
(1, q, v_arr, p),
|
||||||
(4, tt, p, v_arr), (5, v_arr, p, q),
|
(2, p, v_arr, tt),
|
||||||
|
(3, p, q, v_arr),
|
||||||
|
(4, tt, p, v_arr),
|
||||||
|
(5, v_arr, p, q),
|
||||||
):
|
):
|
||||||
m = hi == sxt
|
m = hi == sxt
|
||||||
ro[m] = rv[m]
|
ro[m] = rv[m]
|
||||||
@@ -1158,9 +1242,13 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
noise_val = _gradient_noise.noise(
|
noise_val = _gradient_noise.noise(
|
||||||
np.array([si * 10.0 + t * speed], dtype=np.float32)
|
np.array([si * 10.0 + t * speed], dtype=np.float32)
|
||||||
)[0]
|
)[0]
|
||||||
new_pos = min(1.0, max(0.0,
|
new_pos = min(
|
||||||
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2
|
1.0,
|
||||||
))
|
max(
|
||||||
|
0.0,
|
||||||
|
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2,
|
||||||
|
),
|
||||||
|
)
|
||||||
perturbed.append(dict(s, position=new_pos))
|
perturbed.append(dict(s, position=new_pos))
|
||||||
buf[:] = _compute_gradient_colors(perturbed, n, easing)
|
buf[:] = _compute_gradient_colors(perturbed, n, easing)
|
||||||
colors = buf
|
colors = buf
|
||||||
@@ -1183,7 +1271,7 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
|
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
|
||||||
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
|
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
|
||||||
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
|
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
|
||||||
h_arr *= (1.0 / 6.0)
|
h_arr *= 1.0 / 6.0
|
||||||
h_arr %= 1.0
|
h_arr %= 1.0
|
||||||
# S and V — preserve original values (no clamping)
|
# S and V — preserve original values (no clamping)
|
||||||
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
|
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
|
||||||
@@ -1202,9 +1290,12 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
go = np.empty(n, dtype=np.float32)
|
go = np.empty(n, dtype=np.float32)
|
||||||
bo = np.empty(n, dtype=np.float32)
|
bo = np.empty(n, dtype=np.float32)
|
||||||
for sxt, rv, gv, bv in (
|
for sxt, rv, gv, bv in (
|
||||||
(0, v_arr, tt, p), (1, q, v_arr, p),
|
(0, v_arr, tt, p),
|
||||||
(2, p, v_arr, tt), (3, p, q, v_arr),
|
(1, q, v_arr, p),
|
||||||
(4, tt, p, v_arr), (5, v_arr, p, q),
|
(2, p, v_arr, tt),
|
||||||
|
(3, p, q, v_arr),
|
||||||
|
(4, tt, p, v_arr),
|
||||||
|
(5, v_arr, p, q),
|
||||||
):
|
):
|
||||||
m = hi == sxt
|
m = hi == sxt
|
||||||
ro[m] = rv[m]
|
ro[m] = rv[m]
|
||||||
|
|||||||
@@ -53,12 +53,16 @@ class _ColorStripEntry:
|
|||||||
target_fps: Dict[str, int] = None
|
target_fps: Dict[str, int] = None
|
||||||
# Clock ID currently acquired for this stream (for correct release)
|
# Clock ID currently acquired for this stream (for correct release)
|
||||||
clock_id: Optional[str] = None
|
clock_id: Optional[str] = None
|
||||||
|
# Value stream IDs acquired for BindableFloat properties (prop → vs_id)
|
||||||
|
bound_vs_ids: Dict[str, str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if self.picture_source_ids is None:
|
if self.picture_source_ids is None:
|
||||||
self.picture_source_ids = []
|
self.picture_source_ids = []
|
||||||
if self.target_fps is None:
|
if self.target_fps is None:
|
||||||
self.target_fps = {}
|
self.target_fps = {}
|
||||||
|
if self.bound_vs_ids is None:
|
||||||
|
self.bound_vs_ids = {}
|
||||||
|
|
||||||
|
|
||||||
class ColorStripStreamManager:
|
class ColorStripStreamManager:
|
||||||
@@ -143,6 +147,54 @@ class ColorStripStreamManager:
|
|||||||
logger.debug("Sync clock release during stream cleanup: %s", e)
|
logger.debug("Sync clock release during stream cleanup: %s", e)
|
||||||
pass # source may have been deleted already
|
pass # source may have been deleted already
|
||||||
|
|
||||||
|
# Properties that can be BindableFloat on any CSS source
|
||||||
|
_BINDABLE_PROPS = (
|
||||||
|
"smoothing",
|
||||||
|
"sensitivity",
|
||||||
|
"intensity",
|
||||||
|
"scale",
|
||||||
|
"speed",
|
||||||
|
"wind_strength",
|
||||||
|
"temperature_influence",
|
||||||
|
"sound_volume",
|
||||||
|
"timeout",
|
||||||
|
"brightness",
|
||||||
|
"duration_ms",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _bind_value_streams(
|
||||||
|
self, css_stream: ColorStripStream, source, entry: _ColorStripEntry
|
||||||
|
) -> None:
|
||||||
|
"""Acquire ValueStreams for any bound BindableFloat properties and inject into stream."""
|
||||||
|
if not self._value_stream_manager:
|
||||||
|
return
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
|
|
||||||
|
for prop in self._BINDABLE_PROPS:
|
||||||
|
bf = getattr(source, prop, None)
|
||||||
|
if isinstance(bf, BindableFloat) and bf.source_id:
|
||||||
|
try:
|
||||||
|
vs = self._value_stream_manager.acquire(bf.source_id)
|
||||||
|
css_stream.set_value_stream(prop, vs)
|
||||||
|
entry.bound_vs_ids[prop] = bf.source_id
|
||||||
|
logger.debug("Bound VS %s → %s.%s", bf.source_id, source.id, prop)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to acquire VS %s for %s.%s: %s", bf.source_id, source.id, prop, e
|
||||||
|
)
|
||||||
|
|
||||||
|
def _release_value_streams(self, entry: _ColorStripEntry) -> None:
|
||||||
|
"""Release all ValueStreams acquired for an entry."""
|
||||||
|
if not self._value_stream_manager or not entry.bound_vs_ids:
|
||||||
|
return
|
||||||
|
for prop, vs_id in entry.bound_vs_ids.items():
|
||||||
|
try:
|
||||||
|
self._value_stream_manager.release(vs_id)
|
||||||
|
entry.stream.remove_value_stream(prop)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("VS release for %s: %s", vs_id, e)
|
||||||
|
entry.bound_vs_ids.clear()
|
||||||
|
|
||||||
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
||||||
"""Resolve internal registry key for a (css_id, consumer_id) pair.
|
"""Resolve internal registry key for a (css_id, consumer_id) pair.
|
||||||
|
|
||||||
@@ -221,12 +273,14 @@ class ColorStripStreamManager:
|
|||||||
acquired_clock_id = self._inject_clock(css_stream, source)
|
acquired_clock_id = self._inject_clock(css_stream, source)
|
||||||
css_stream.start()
|
css_stream.start()
|
||||||
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
|
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
|
||||||
self._streams[key] = _ColorStripEntry(
|
entry = _ColorStripEntry(
|
||||||
stream=css_stream,
|
stream=css_stream,
|
||||||
ref_count=1,
|
ref_count=1,
|
||||||
picture_source_ids=[],
|
picture_source_ids=[],
|
||||||
clock_id=acquired_clock_id,
|
clock_id=acquired_clock_id,
|
||||||
)
|
)
|
||||||
|
self._bind_value_streams(css_stream, source, entry)
|
||||||
|
self._streams[key] = entry
|
||||||
logger.info(f"Created {source.source_type} stream {key}")
|
logger.info(f"Created {source.source_type} stream {key}")
|
||||||
return css_stream
|
return css_stream
|
||||||
|
|
||||||
@@ -261,11 +315,13 @@ class ColorStripStreamManager:
|
|||||||
self._live_stream_manager.release(ps_id)
|
self._live_stream_manager.release(ps_id)
|
||||||
raise RuntimeError(f"Failed to start key_colors stream {css_id}: {e}") from e
|
raise RuntimeError(f"Failed to start key_colors stream {css_id}: {e}") from e
|
||||||
|
|
||||||
self._streams[css_id] = _ColorStripEntry(
|
entry = _ColorStripEntry(
|
||||||
stream=css_stream,
|
stream=css_stream,
|
||||||
ref_count=1,
|
ref_count=1,
|
||||||
picture_source_ids=[ps_id],
|
picture_source_ids=[ps_id],
|
||||||
)
|
)
|
||||||
|
self._bind_value_streams(css_stream, source, entry)
|
||||||
|
self._streams[css_id] = entry
|
||||||
logger.info(f"Created key_colors stream {css_id} ({len(source.rectangles)} rects)")
|
logger.info(f"Created key_colors stream {css_id} ({len(source.rectangles)} rects)")
|
||||||
return css_stream
|
return css_stream
|
||||||
|
|
||||||
@@ -314,11 +370,13 @@ class ColorStripStreamManager:
|
|||||||
f"Failed to start color strip stream for source {css_id}: {e}"
|
f"Failed to start color strip stream for source {css_id}: {e}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
self._streams[css_id] = _ColorStripEntry(
|
entry = _ColorStripEntry(
|
||||||
stream=css_stream,
|
stream=css_stream,
|
||||||
ref_count=1,
|
ref_count=1,
|
||||||
picture_source_ids=list(acquired.keys()),
|
picture_source_ids=list(acquired.keys()),
|
||||||
)
|
)
|
||||||
|
self._bind_value_streams(css_stream, source, entry)
|
||||||
|
self._streams[css_id] = entry
|
||||||
|
|
||||||
logger.info(f"Created picture color strip stream {css_id}")
|
logger.info(f"Created picture color strip stream {css_id}")
|
||||||
return css_stream
|
return css_stream
|
||||||
@@ -353,6 +411,9 @@ class ColorStripStreamManager:
|
|||||||
source_id = key.split(":")[0] if ":" in key else key
|
source_id = key.split(":")[0] if ":" in key else key
|
||||||
self._release_clock(source_id, entry.stream, clock_id=entry.clock_id)
|
self._release_clock(source_id, entry.stream, clock_id=entry.clock_id)
|
||||||
|
|
||||||
|
# Release bound value streams
|
||||||
|
self._release_value_streams(entry)
|
||||||
|
|
||||||
picture_source_ids = entry.picture_source_ids
|
picture_source_ids = entry.picture_source_ids
|
||||||
del self._streams[key]
|
del self._streams[key]
|
||||||
logger.info(f"Removed color strip stream {key}")
|
logger.info(f"Removed color strip stream {key}")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Dict, List, Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -37,6 +38,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
|
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
|
||||||
self._source_id: str = source.id
|
self._source_id: str = source.id
|
||||||
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
|
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
|
||||||
self._layers: List[dict] = list(source.layers)
|
self._layers: List[dict] = list(source.layers)
|
||||||
@@ -67,9 +69,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
|
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
|
||||||
|
|
||||||
# Pre-resolved blend methods: blend_mode_str -> bound method
|
# Pre-resolved blend methods: blend_mode_str -> bound method
|
||||||
self._blend_methods = {
|
self._blend_methods = {k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()}
|
||||||
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
|
|
||||||
}
|
|
||||||
self._default_blend_method = self._blend_normal
|
self._default_blend_method = self._blend_normal
|
||||||
|
|
||||||
# Pre-allocated scratch (rebuilt when LED count changes)
|
# Pre-allocated scratch (rebuilt when LED count changes)
|
||||||
@@ -104,7 +104,8 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
self._acquire_sub_streams()
|
self._acquire_sub_streams()
|
||||||
self._running = True
|
self._running = True
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._processing_loop, daemon=True,
|
target=self._processing_loop,
|
||||||
|
daemon=True,
|
||||||
name=f"CompositeCSS-{self._source_id[:12]}",
|
name=f"CompositeCSS-{self._source_id[:12]}",
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
@@ -162,14 +163,32 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
"""Hot-update: rebuild sub-streams if layer config changed."""
|
"""Hot-update: rebuild sub-streams if layer config changed."""
|
||||||
new_layers = list(source.layers)
|
new_layers = list(source.layers)
|
||||||
old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"),
|
old_layer_ids = [
|
||||||
layer.get("enabled"), layer.get("brightness_source_id"),
|
(
|
||||||
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False))
|
layer.get("source_id"),
|
||||||
for layer in self._layers]
|
layer.get("blend_mode"),
|
||||||
new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"),
|
layer.get("opacity"),
|
||||||
layer.get("enabled"), layer.get("brightness_source_id"),
|
layer.get("enabled"),
|
||||||
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False))
|
layer.get("brightness_source_id"),
|
||||||
for layer in new_layers]
|
layer.get("start", 0),
|
||||||
|
layer.get("end", 0),
|
||||||
|
layer.get("reverse", False),
|
||||||
|
)
|
||||||
|
for layer in self._layers
|
||||||
|
]
|
||||||
|
new_layer_ids = [
|
||||||
|
(
|
||||||
|
layer.get("source_id"),
|
||||||
|
layer.get("blend_mode"),
|
||||||
|
layer.get("opacity"),
|
||||||
|
layer.get("enabled"),
|
||||||
|
layer.get("brightness_source_id"),
|
||||||
|
layer.get("start", 0),
|
||||||
|
layer.get("end", 0),
|
||||||
|
layer.get("reverse", False),
|
||||||
|
)
|
||||||
|
for layer in new_layers
|
||||||
|
]
|
||||||
|
|
||||||
self._layers = new_layers
|
self._layers = new_layers
|
||||||
|
|
||||||
@@ -209,9 +228,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
stream.configure(self._led_count)
|
stream.configure(self._led_count)
|
||||||
self._sub_streams[i] = (src_id, consumer_id, stream)
|
self._sub_streams[i] = (src_id, consumer_id, stream)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(f"Composite layer {i} (source {src_id}) failed to acquire: {e}")
|
||||||
f"Composite layer {i} (source {src_id}) failed to acquire: {e}"
|
|
||||||
)
|
|
||||||
# Acquire brightness value stream if configured
|
# Acquire brightness value stream if configured
|
||||||
vs_id = layer.get("brightness_source_id")
|
vs_id = layer.get("brightness_source_id")
|
||||||
if vs_id and self._value_stream_manager:
|
if vs_id and self._value_stream_manager:
|
||||||
@@ -219,9 +236,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
vs = self._value_stream_manager.acquire(vs_id)
|
vs = self._value_stream_manager.acquire(vs_id)
|
||||||
self._brightness_streams[i] = (vs_id, vs)
|
self._brightness_streams[i] = (vs_id, vs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(f"Composite layer {i} brightness source {vs_id} failed: {e}")
|
||||||
f"Composite layer {i} brightness source {vs_id} failed: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _release_sub_streams(self) -> None:
|
def _release_sub_streams(self) -> None:
|
||||||
self._sub_streams_version += 1
|
self._sub_streams_version += 1
|
||||||
@@ -272,20 +287,20 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# ── Blend operations (integer math, pre-allocated) ──────────
|
# ── Blend operations (integer math, pre-allocated) ──────────
|
||||||
|
|
||||||
def _blend_normal(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_normal(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Normal blend: out = (bottom * (256-a) + top * a) >> 8"""
|
"""Normal blend: out = (bottom * (256-a) + top * a) >> 8"""
|
||||||
u16a, u16b = self._u16_a, self._u16_b
|
u16a, u16b = self._u16_a, self._u16_b
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
np.copyto(u16b, top, casting="unsafe")
|
np.copyto(u16b, top, casting="unsafe")
|
||||||
u16a *= (256 - alpha)
|
u16a *= 256 - alpha
|
||||||
u16b *= alpha
|
u16b *= alpha
|
||||||
u16a += u16b
|
u16a += u16b
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_add(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_add(self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray) -> None:
|
||||||
out: np.ndarray) -> None:
|
|
||||||
"""Additive blend: out = min(255, bottom + top * alpha >> 8)"""
|
"""Additive blend: out = min(255, bottom + top * alpha >> 8)"""
|
||||||
u16a, u16b = self._u16_a, self._u16_b
|
u16a, u16b = self._u16_a, self._u16_b
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
@@ -296,8 +311,9 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
np.clip(u16a, 0, 255, out=u16a)
|
np.clip(u16a, 0, 255, out=u16a)
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_multiply(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_multiply(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Multiply blend: blended = bottom*top>>8, then lerp with alpha."""
|
"""Multiply blend: blended = bottom*top>>8, then lerp with alpha."""
|
||||||
u16a, u16b = self._u16_a, self._u16_b
|
u16a, u16b = self._u16_a, self._u16_b
|
||||||
# blended = (bottom * top) >> 8
|
# blended = (bottom * top) >> 8
|
||||||
@@ -307,14 +323,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
# lerp: result = (bottom * (256-a) + blended * a) >> 8
|
# lerp: result = (bottom * (256-a) + blended * a) >> 8
|
||||||
np.copyto(u16b, bottom, casting="unsafe")
|
np.copyto(u16b, bottom, casting="unsafe")
|
||||||
u16b *= (256 - alpha)
|
u16b *= 256 - alpha
|
||||||
u16a *= alpha
|
u16a *= alpha
|
||||||
u16a += u16b
|
u16a += u16b
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_screen(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_screen(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Screen blend: blended = 255 - (255-bottom)*(255-top)>>8, then lerp."""
|
"""Screen blend: blended = 255 - (255-bottom)*(255-top)>>8, then lerp."""
|
||||||
u16a, u16b = self._u16_a, self._u16_b
|
u16a, u16b = self._u16_a, self._u16_b
|
||||||
# blended = 255 - ((255 - bottom) * (255 - top)) >> 8
|
# blended = 255 - ((255 - bottom) * (255 - top)) >> 8
|
||||||
@@ -327,14 +344,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
u16a[:] = 255 - u16a
|
u16a[:] = 255 - u16a
|
||||||
# lerp: result = (bottom * (256-a) + blended * a) >> 8
|
# lerp: result = (bottom * (256-a) + blended * a) >> 8
|
||||||
np.copyto(u16b, bottom, casting="unsafe")
|
np.copyto(u16b, bottom, casting="unsafe")
|
||||||
u16b *= (256 - alpha)
|
u16b *= 256 - alpha
|
||||||
u16a *= alpha
|
u16a *= alpha
|
||||||
u16a += u16b
|
u16a += u16b
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_override(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Override blend: per-pixel alpha derived from top brightness.
|
"""Override blend: per-pixel alpha derived from top brightness.
|
||||||
|
|
||||||
Black pixels are fully transparent (bottom shows through),
|
Black pixels are fully transparent (bottom shows through),
|
||||||
@@ -349,14 +367,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
|
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
np.copyto(u16b, top, casting="unsafe")
|
np.copyto(u16b, top, casting="unsafe")
|
||||||
u16a *= (256 - per_px_alpha)
|
u16a *= 256 - per_px_alpha
|
||||||
u16b *= per_px_alpha
|
u16b *= per_px_alpha
|
||||||
u16a += u16b
|
u16a += u16b
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_overlay(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_overlay(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Overlay blend: multiply darks, screen lights, then lerp with alpha.
|
"""Overlay blend: multiply darks, screen lights, then lerp with alpha.
|
||||||
|
|
||||||
if bottom < 128: blended = 2*bottom*top >> 8
|
if bottom < 128: blended = 2*bottom*top >> 8
|
||||||
@@ -375,14 +394,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
np.clip(blended, 0, 255, out=blended)
|
np.clip(blended, 0, 255, out=blended)
|
||||||
# Lerp: result = (bottom * (256-a) + blended * a) >> 8
|
# Lerp: result = (bottom * (256-a) + blended * a) >> 8
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
u16a *= (256 - alpha)
|
u16a *= 256 - alpha
|
||||||
blended *= alpha
|
blended *= alpha
|
||||||
u16a += blended
|
u16a += blended
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_soft_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_soft_light(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Soft light blend (Pegtop formula), then lerp with alpha.
|
"""Soft light blend (Pegtop formula), then lerp with alpha.
|
||||||
|
|
||||||
blended = (1 - 2*t/255) * b*b/255 + 2*t*b/255
|
blended = (1 - 2*t/255) * b*b/255 + 2*t*b/255
|
||||||
@@ -399,15 +419,16 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
np.clip(blended, 0, 255, out=blended)
|
np.clip(blended, 0, 255, out=blended)
|
||||||
# Lerp
|
# Lerp
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
u16a *= (256 - alpha)
|
u16a *= 256 - alpha
|
||||||
blended_u16 = blended.astype(np.uint16)
|
blended_u16 = blended.astype(np.uint16)
|
||||||
blended_u16 *= alpha
|
blended_u16 *= alpha
|
||||||
u16a += blended_u16
|
u16a += blended_u16
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_hard_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_hard_light(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Hard light blend: overlay with top/bottom roles swapped.
|
"""Hard light blend: overlay with top/bottom roles swapped.
|
||||||
|
|
||||||
if top < 128: blended = 2*bottom*top >> 8
|
if top < 128: blended = 2*bottom*top >> 8
|
||||||
@@ -424,14 +445,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
np.clip(blended, 0, 255, out=blended)
|
np.clip(blended, 0, 255, out=blended)
|
||||||
# Lerp
|
# Lerp
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
u16a *= (256 - alpha)
|
u16a *= 256 - alpha
|
||||||
blended *= alpha
|
blended *= alpha
|
||||||
u16a += blended
|
u16a += blended
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_difference(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_difference(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Difference blend: |bottom - top|, then lerp with alpha."""
|
"""Difference blend: |bottom - top|, then lerp with alpha."""
|
||||||
u16a, u16b = self._u16_a, self._u16_b
|
u16a, u16b = self._u16_a, self._u16_b
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
@@ -440,14 +462,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
blended = np.abs(u16a.astype(np.int16) - u16b.astype(np.int16)).astype(np.uint16)
|
blended = np.abs(u16a.astype(np.int16) - u16b.astype(np.int16)).astype(np.uint16)
|
||||||
# Lerp
|
# Lerp
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
u16a *= (256 - alpha)
|
u16a *= 256 - alpha
|
||||||
blended *= alpha
|
blended *= alpha
|
||||||
u16a += blended
|
u16a += blended
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
np.copyto(out, u16a, casting="unsafe")
|
np.copyto(out, u16a, casting="unsafe")
|
||||||
|
|
||||||
def _blend_exclusion(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
def _blend_exclusion(
|
||||||
out: np.ndarray) -> None:
|
self, bottom: np.ndarray, top: np.ndarray, alpha: int, out: np.ndarray
|
||||||
|
) -> None:
|
||||||
"""Exclusion blend: bottom + top - 2*bottom*top/255, then lerp with alpha."""
|
"""Exclusion blend: bottom + top - 2*bottom*top/255, then lerp with alpha."""
|
||||||
u16a, u16b = self._u16_a, self._u16_b
|
u16a, u16b = self._u16_a, self._u16_b
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
@@ -457,7 +480,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
np.clip(blended, 0, 255, out=blended)
|
np.clip(blended, 0, 255, out=blended)
|
||||||
# Lerp
|
# Lerp
|
||||||
np.copyto(u16a, bottom, casting="unsafe")
|
np.copyto(u16a, bottom, casting="unsafe")
|
||||||
u16a *= (256 - alpha)
|
u16a *= 256 - alpha
|
||||||
blended *= alpha
|
blended *= alpha
|
||||||
u16a += blended
|
u16a += blended
|
||||||
u16a >>= 8
|
u16a >>= 8
|
||||||
@@ -525,13 +548,16 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
# Resolve and cache filters for this layer
|
# Resolve and cache filters for this layer
|
||||||
try:
|
try:
|
||||||
from wled_controller.core.filters.registry import FilterRegistry
|
from wled_controller.core.filters.registry import FilterRegistry
|
||||||
|
|
||||||
_resolved = self._cspt_store.resolve_filter_instances(
|
_resolved = self._cspt_store.resolve_filter_instances(
|
||||||
self._cspt_store.get_template(_layer_tmpl_id).filters
|
self._cspt_store.get_template(_layer_tmpl_id).filters
|
||||||
)
|
)
|
||||||
_filters = [
|
_filters = [
|
||||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
for fi in _resolved
|
for fi in _resolved
|
||||||
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
|
if getattr(
|
||||||
|
FilterRegistry.get(fi.filter_id), "supports_strip", True
|
||||||
|
)
|
||||||
]
|
]
|
||||||
_layer_cspt_cache[i] = (_layer_tmpl_id, _filters)
|
_layer_cspt_cache[i] = (_layer_tmpl_id, _filters)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -539,7 +565,9 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
f"from template {_layer_tmpl_id}"
|
f"from template {_layer_tmpl_id}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}")
|
logger.warning(
|
||||||
|
f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}"
|
||||||
|
)
|
||||||
_layer_cspt_cache[i] = (_layer_tmpl_id, [])
|
_layer_cspt_cache[i] = (_layer_tmpl_id, [])
|
||||||
_layer_filters = _layer_cspt_cache[i][1]
|
_layer_filters = _layer_cspt_cache[i][1]
|
||||||
if _layer_filters:
|
if _layer_filters:
|
||||||
@@ -556,7 +584,9 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
if has_range:
|
if has_range:
|
||||||
# Clamp range to strip bounds
|
# Clamp range to strip bounds
|
||||||
eff_start = max(0, min(layer_start, target_n))
|
eff_start = max(0, min(layer_start, target_n))
|
||||||
eff_end = max(eff_start, min(layer_end if layer_end > 0 else target_n, target_n))
|
eff_end = max(
|
||||||
|
eff_start, min(layer_end if layer_end > 0 else target_n, target_n)
|
||||||
|
)
|
||||||
zone_len = eff_end - eff_start
|
zone_len = eff_end - eff_start
|
||||||
if zone_len <= 0:
|
if zone_len <= 0:
|
||||||
continue
|
continue
|
||||||
@@ -573,7 +603,11 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
self._resize_cache[rkey] = cached
|
self._resize_cache[rkey] = cached
|
||||||
src_x, dst_x, resized = cached
|
src_x, dst_x, resized = cached
|
||||||
for ch in range(3):
|
for ch in range(3):
|
||||||
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
|
np.copyto(
|
||||||
|
resized[:, ch],
|
||||||
|
np.interp(dst_x, src_x, colors[:, ch]),
|
||||||
|
casting="unsafe",
|
||||||
|
)
|
||||||
colors = resized
|
colors = resized
|
||||||
else:
|
else:
|
||||||
# Full-strip layer: resize to target LED count
|
# Full-strip layer: resize to target LED count
|
||||||
@@ -589,13 +623,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
_vs_id, vs = self._brightness_streams[i]
|
_vs_id, vs = self._brightness_streams[i]
|
||||||
bri = vs.get_value()
|
bri = vs.get_value()
|
||||||
if bri < 1.0:
|
if bri < 1.0:
|
||||||
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
|
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(
|
||||||
|
np.uint8
|
||||||
|
)
|
||||||
|
|
||||||
# Snapshot layer colors before blending (copy — may alias shared buf)
|
# Snapshot layer colors before blending (copy — may alias shared buf)
|
||||||
if self._need_layer_snapshots:
|
if self._need_layer_snapshots:
|
||||||
layer_snapshots.append(colors.copy())
|
layer_snapshots.append(colors.copy())
|
||||||
|
|
||||||
opacity = layer.get("opacity", 1.0)
|
opacity = bfloat(layer.get("opacity", 1.0), 1.0)
|
||||||
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
|
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
|
||||||
alpha = int(opacity * 256)
|
alpha = int(opacity * 256)
|
||||||
alpha = max(0, min(256, alpha))
|
alpha = max(0, min(256, alpha))
|
||||||
@@ -609,20 +645,26 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
rng = result_buf[eff_start:eff_end]
|
rng = result_buf[eff_start:eff_end]
|
||||||
u16a_rng = self._u16_a[:zone_len]
|
u16a_rng = self._u16_a[:zone_len]
|
||||||
u16b_rng = self._u16_b[:zone_len]
|
u16b_rng = self._u16_b[:zone_len]
|
||||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
blend_fn = self._blend_methods.get(
|
||||||
|
blend_mode, self._default_blend_method
|
||||||
|
)
|
||||||
# Temporarily swap scratch buffers for the range size
|
# Temporarily swap scratch buffers for the range size
|
||||||
orig_u16a, orig_u16b = self._u16_a, self._u16_b
|
orig_u16a, orig_u16b = self._u16_a, self._u16_b
|
||||||
self._u16_a, self._u16_b = u16a_rng, u16b_rng
|
self._u16_a, self._u16_b = u16a_rng, u16b_rng
|
||||||
blend_fn(rng, colors, alpha, rng)
|
blend_fn(rng, colors, alpha, rng)
|
||||||
self._u16_a, self._u16_b = orig_u16a, orig_u16b
|
self._u16_a, self._u16_b = orig_u16a, orig_u16b
|
||||||
else:
|
else:
|
||||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
blend_fn = self._blend_methods.get(
|
||||||
|
blend_mode, self._default_blend_method
|
||||||
|
)
|
||||||
blend_fn(result_buf, colors, alpha, result_buf)
|
blend_fn(result_buf, colors, alpha, result_buf)
|
||||||
|
|
||||||
if has_result:
|
if has_result:
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._latest_colors = result_buf
|
self._latest_colors = result_buf
|
||||||
self._latest_layer_colors = layer_snapshots if len(layer_snapshots) > 1 else None
|
self._latest_layer_colors = (
|
||||||
|
layer_snapshots if len(layer_snapshots) > 1 else None
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)
|
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -31,23 +31,23 @@ logger = get_logger(__name__)
|
|||||||
#
|
#
|
||||||
# Format: (hour, R, G, B)
|
# Format: (hour, R, G, B)
|
||||||
_DAYLIGHT_CURVE = [
|
_DAYLIGHT_CURVE = [
|
||||||
(0.0, 10, 10, 30), # midnight — deep blue
|
(0.0, 10, 10, 30), # midnight — deep blue
|
||||||
(4.0, 10, 10, 40), # pre-dawn — dark blue
|
(4.0, 10, 10, 40), # pre-dawn — dark blue
|
||||||
(5.5, 40, 20, 60), # first light — purple hint
|
(5.5, 40, 20, 60), # first light — purple hint
|
||||||
(6.0, 255, 100, 30), # sunrise — warm orange
|
(6.0, 255, 100, 30), # sunrise — warm orange
|
||||||
(7.0, 255, 170, 80), # early morning — golden
|
(7.0, 255, 170, 80), # early morning — golden
|
||||||
(8.0, 255, 220, 160), # morning — warm white
|
(8.0, 255, 220, 160), # morning — warm white
|
||||||
(10.0, 255, 245, 230), # mid-morning — neutral warm
|
(10.0, 255, 245, 230), # mid-morning — neutral warm
|
||||||
(12.0, 240, 248, 255), # noon — cool white / slight blue
|
(12.0, 240, 248, 255), # noon — cool white / slight blue
|
||||||
(14.0, 255, 250, 240), # afternoon — neutral
|
(14.0, 255, 250, 240), # afternoon — neutral
|
||||||
(16.0, 255, 230, 180), # late afternoon — warm
|
(16.0, 255, 230, 180), # late afternoon — warm
|
||||||
(17.5, 255, 180, 100), # pre-sunset — golden
|
(17.5, 255, 180, 100), # pre-sunset — golden
|
||||||
(18.5, 255, 100, 40), # sunset — deep orange
|
(18.5, 255, 100, 40), # sunset — deep orange
|
||||||
(19.0, 200, 60, 40), # late sunset — red
|
(19.0, 200, 60, 40), # late sunset — red
|
||||||
(19.5, 100, 30, 60), # dusk — purple
|
(19.5, 100, 30, 60), # dusk — purple
|
||||||
(20.0, 40, 20, 60), # twilight — dark purple
|
(20.0, 40, 20, 60), # twilight — dark purple
|
||||||
(21.0, 15, 15, 45), # night — dark blue
|
(21.0, 15, 15, 45), # night — dark blue
|
||||||
(24.0, 10, 10, 30), # midnight (wrap)
|
(24.0, 10, 10, 30), # midnight (wrap)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Reference solar times the canonical curve was designed around
|
# Reference solar times the canonical curve was designed around
|
||||||
@@ -61,9 +61,7 @@ _daylight_lut: Optional[np.ndarray] = None
|
|||||||
# ── Solar position helpers ──────────────────────────────────────────────
|
# ── Solar position helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _compute_solar_times(
|
def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple:
|
||||||
latitude: float, longitude: float, day_of_year: int
|
|
||||||
) -> tuple:
|
|
||||||
"""Return (sunrise_hour, sunset_hour) in local solar time.
|
"""Return (sunrise_hour, sunset_hour) in local solar time.
|
||||||
|
|
||||||
Uses simplified NOAA solar equations:
|
Uses simplified NOAA solar equations:
|
||||||
@@ -148,9 +146,7 @@ def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
|
|||||||
t = t * t * (3.0 - 2.0 * t) # smoothstep
|
t = t * t * (3.0 - 2.0 * t) # smoothstep
|
||||||
|
|
||||||
for ch in range(3):
|
for ch in range(3):
|
||||||
lut[minute, ch] = int(
|
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
|
||||||
prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5
|
|
||||||
)
|
|
||||||
|
|
||||||
return lut
|
return lut
|
||||||
|
|
||||||
@@ -188,7 +184,9 @@ class DaylightColorStripStream(ColorStripStream):
|
|||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
|
|
||||||
def _update_from_source(self, source) -> None:
|
def _update_from_source(self, source) -> None:
|
||||||
self._speed = float(getattr(source, "speed", 1.0))
|
from wled_controller.storage.bindable import bfloat
|
||||||
|
|
||||||
|
self._speed = bfloat(getattr(source, "speed", 1.0), 1.0)
|
||||||
self._use_real_time = bool(getattr(source, "use_real_time", False))
|
self._use_real_time = bool(getattr(source, "use_real_time", False))
|
||||||
self._latitude = float(getattr(source, "latitude", 50.0))
|
self._latitude = float(getattr(source, "latitude", 50.0))
|
||||||
self._longitude = float(getattr(source, "longitude", 0.0))
|
self._longitude = float(getattr(source, "longitude", 0.0))
|
||||||
@@ -201,9 +199,7 @@ class DaylightColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
|
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
|
||||||
"""Return a solar-time-aware LUT for the given day (cached)."""
|
"""Return a solar-time-aware LUT for the given day (cached)."""
|
||||||
sunrise, sunset = _compute_solar_times(
|
sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year)
|
||||||
self._latitude, self._longitude, day_of_year
|
|
||||||
)
|
|
||||||
sr_key = int(round(sunrise * 60))
|
sr_key = int(round(sunrise * 60))
|
||||||
ss_key = int(round(sunset * 60))
|
ss_key = int(round(sunset * 60))
|
||||||
cache_key = (sr_key, ss_key)
|
cache_key = (sr_key, ss_key)
|
||||||
@@ -260,6 +256,7 @@ class DaylightColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import DaylightColorStripSource
|
from wled_controller.storage.color_strip_source import DaylightColorStripSource
|
||||||
|
|
||||||
if isinstance(source, DaylightColorStripSource):
|
if isinstance(source, DaylightColorStripSource):
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
@@ -292,7 +289,7 @@ class DaylightColorStripStream(ColorStripStream):
|
|||||||
speed = clock.speed
|
speed = clock.speed
|
||||||
else:
|
else:
|
||||||
t = wall_start
|
t = wall_start
|
||||||
speed = self._speed
|
speed = self.resolve("speed", self._speed)
|
||||||
|
|
||||||
n = self._led_count
|
n = self._led_count
|
||||||
if n != _pool_n:
|
if n != _pool_n:
|
||||||
|
|||||||
@@ -26,17 +26,41 @@ logger = get_logger(__name__)
|
|||||||
# Each palette is a list of (position, R, G, B) control points.
|
# Each palette is a list of (position, R, G, B) control points.
|
||||||
# Positions must be monotonically increasing from 0.0 to 1.0.
|
# Positions must be monotonically increasing from 0.0 to 1.0.
|
||||||
_PALETTE_DEFS: Dict[str, list] = {
|
_PALETTE_DEFS: Dict[str, list] = {
|
||||||
"fire": [(0, 0, 0, 0), (0.33, 200, 24, 0), (0.66, 255, 160, 0), (1.0, 255, 255, 200)],
|
"fire": [(0, 0, 0, 0), (0.33, 200, 24, 0), (0.66, 255, 160, 0), (1.0, 255, 255, 200)],
|
||||||
"ocean": [(0, 0, 0, 32), (0.33, 0, 16, 128), (0.66, 0, 128, 255), (1.0, 128, 224, 255)],
|
"ocean": [(0, 0, 0, 32), (0.33, 0, 16, 128), (0.66, 0, 128, 255), (1.0, 128, 224, 255)],
|
||||||
"lava": [(0, 0, 0, 0), (0.25, 128, 0, 0), (0.5, 255, 32, 0), (0.75, 255, 160, 0), (1.0, 255, 255, 128)],
|
"lava": [
|
||||||
"forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
|
(0, 0, 0, 0),
|
||||||
"rainbow": [(0, 255, 0, 0), (0.17, 255, 255, 0), (0.33, 0, 255, 0),
|
(0.25, 128, 0, 0),
|
||||||
(0.5, 0, 255, 255), (0.67, 0, 0, 255), (0.83, 255, 0, 255), (1.0, 255, 0, 0)],
|
(0.5, 255, 32, 0),
|
||||||
"aurora": [(0, 0, 16, 32), (0.2, 0, 80, 64), (0.4, 0, 200, 100),
|
(0.75, 255, 160, 0),
|
||||||
(0.6, 64, 128, 255), (0.8, 128, 0, 200), (1.0, 0, 16, 32)],
|
(1.0, 255, 255, 128),
|
||||||
"sunset": [(0, 32, 0, 64), (0.25, 128, 0, 128), (0.5, 255, 64, 0),
|
],
|
||||||
(0.75, 255, 192, 64), (1.0, 255, 255, 192)],
|
"forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
|
||||||
"ice": [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)],
|
"rainbow": [
|
||||||
|
(0, 255, 0, 0),
|
||||||
|
(0.17, 255, 255, 0),
|
||||||
|
(0.33, 0, 255, 0),
|
||||||
|
(0.5, 0, 255, 255),
|
||||||
|
(0.67, 0, 0, 255),
|
||||||
|
(0.83, 255, 0, 255),
|
||||||
|
(1.0, 255, 0, 0),
|
||||||
|
],
|
||||||
|
"aurora": [
|
||||||
|
(0, 0, 16, 32),
|
||||||
|
(0.2, 0, 80, 64),
|
||||||
|
(0.4, 0, 200, 100),
|
||||||
|
(0.6, 64, 128, 255),
|
||||||
|
(0.8, 128, 0, 200),
|
||||||
|
(1.0, 0, 16, 32),
|
||||||
|
],
|
||||||
|
"sunset": [
|
||||||
|
(0, 32, 0, 64),
|
||||||
|
(0.25, 128, 0, 128),
|
||||||
|
(0.5, 255, 64, 0),
|
||||||
|
(0.75, 255, 192, 64),
|
||||||
|
(1.0, 255, 255, 192),
|
||||||
|
],
|
||||||
|
"ice": [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)],
|
||||||
}
|
}
|
||||||
|
|
||||||
_palette_cache: Dict[str, np.ndarray] = {}
|
_palette_cache: Dict[str, np.ndarray] = {}
|
||||||
@@ -84,6 +108,7 @@ def _build_palette_lut(name: str, custom_stops: list = None) -> np.ndarray:
|
|||||||
|
|
||||||
# ── 1-D value noise (no external deps) ──────────────────────────────────
|
# ── 1-D value noise (no external deps) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class _ValueNoise1D:
|
class _ValueNoise1D:
|
||||||
"""Simple 1-D value noise with smoothstep interpolation and fractal octaves.
|
"""Simple 1-D value noise with smoothstep interpolation and fractal octaves.
|
||||||
|
|
||||||
@@ -120,7 +145,7 @@ class _ValueNoise1D:
|
|||||||
size = len(self._table)
|
size = len(self._table)
|
||||||
# xi = floor(x)
|
# xi = floor(x)
|
||||||
np.floor(x, out=self._frac)
|
np.floor(x, out=self._frac)
|
||||||
np.copyto(self._xi, self._frac, casting='unsafe')
|
np.copyto(self._xi, self._frac, casting="unsafe")
|
||||||
# frac = x - xi
|
# frac = x - xi
|
||||||
np.subtract(x, self._frac, out=self._frac)
|
np.subtract(x, self._frac, out=self._frac)
|
||||||
# t = frac * frac * (3 - 2 * frac) (smoothstep)
|
# t = frac * frac * (3 - 2 * frac) (smoothstep)
|
||||||
@@ -224,7 +249,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
self._ball_last_t = 0.0
|
self._ball_last_t = 0.0
|
||||||
# Fireworks state
|
# Fireworks state
|
||||||
self._fw_particles: list = [] # active particles
|
self._fw_particles: list = [] # active particles
|
||||||
self._fw_rockets: list = [] # active rockets
|
self._fw_rockets: list = [] # active rockets
|
||||||
self._fw_last_launch = 0.0
|
self._fw_last_launch = 0.0
|
||||||
# Sparkle rain state
|
# Sparkle rain state
|
||||||
self._sparkle_state: Optional[np.ndarray] = None # per-LED brightness 0..1
|
self._sparkle_state: Optional[np.ndarray] = None # per-LED brightness 0..1
|
||||||
@@ -256,13 +281,17 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
self._auto_size = not _lc
|
self._auto_size = not _lc
|
||||||
self._led_count = _lc if _lc and _lc > 0 else 1
|
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||||
self._gradient_id = getattr(source, "gradient_id", None)
|
self._gradient_id = getattr(source, "gradient_id", None)
|
||||||
self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(self._effect_type, "fire")
|
self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(
|
||||||
|
self._effect_type, "fire"
|
||||||
|
)
|
||||||
self._custom_palette = getattr(source, "custom_palette", None)
|
self._custom_palette = getattr(source, "custom_palette", None)
|
||||||
self._resolve_palette_lut()
|
self._resolve_palette_lut()
|
||||||
color = getattr(source, "color", None)
|
color = getattr(source, "color", None)
|
||||||
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
|
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
|
||||||
self._intensity = float(getattr(source, "intensity", 1.0))
|
from wled_controller.storage.bindable import bfloat
|
||||||
self._scale = float(getattr(source, "scale", 1.0))
|
|
||||||
|
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
|
||||||
|
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
|
||||||
self._mirror = bool(getattr(source, "mirror", False))
|
self._mirror = bool(getattr(source, "mirror", False))
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._colors: Optional[np.ndarray] = None
|
self._colors: Optional[np.ndarray] = None
|
||||||
@@ -296,7 +325,9 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
logger.info(f"EffectColorStripStream started (effect={self._effect_type}, leds={self._led_count})")
|
logger.info(
|
||||||
|
f"EffectColorStripStream started (effect={self._effect_type}, leds={self._led_count})"
|
||||||
|
)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -315,6 +346,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import EffectColorStripSource
|
from wled_controller.storage.color_strip_source import EffectColorStripSource
|
||||||
|
|
||||||
if isinstance(source, EffectColorStripSource):
|
if isinstance(source, EffectColorStripSource):
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
@@ -411,7 +443,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
at the bottom. Heat values are mapped to the palette LUT.
|
at the bottom. Heat values are mapped to the palette LUT.
|
||||||
"""
|
"""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# (Re)allocate heat array when LED count changes
|
# (Re)allocate heat array when LED count changes
|
||||||
@@ -449,7 +481,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
# Map heat to palette (pre-allocated scratch)
|
# Map heat to palette (pre-allocated scratch)
|
||||||
np.multiply(heat, 255, out=self._s_f32_a)
|
np.multiply(heat, 255, out=self._s_f32_a)
|
||||||
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
||||||
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
|
np.copyto(self._s_i32, self._s_f32_a, casting="unsafe")
|
||||||
buf[:] = lut[self._s_i32]
|
buf[:] = lut[self._s_i32]
|
||||||
|
|
||||||
# ── Meteor ───────────────────────────────────────────────────────
|
# ── Meteor ───────────────────────────────────────────────────────
|
||||||
@@ -457,7 +489,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Bright meteor head with exponential-decay trail."""
|
"""Bright meteor head with exponential-decay trail."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
color = self._color
|
color = self._color
|
||||||
mirror = self._mirror
|
mirror = self._mirror
|
||||||
|
|
||||||
@@ -493,13 +525,13 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
r, g, b = color
|
r, g, b = color
|
||||||
np.multiply(brightness, r, out=self._s_f32_c)
|
np.multiply(brightness, r, out=self._s_f32_c)
|
||||||
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
||||||
np.copyto(buf[:, 0], self._s_f32_c, casting='unsafe')
|
np.copyto(buf[:, 0], self._s_f32_c, casting="unsafe")
|
||||||
np.multiply(brightness, g, out=self._s_f32_c)
|
np.multiply(brightness, g, out=self._s_f32_c)
|
||||||
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
||||||
np.copyto(buf[:, 1], self._s_f32_c, casting='unsafe')
|
np.copyto(buf[:, 1], self._s_f32_c, casting="unsafe")
|
||||||
np.multiply(brightness, b, out=self._s_f32_c)
|
np.multiply(brightness, b, out=self._s_f32_c)
|
||||||
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
||||||
np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe')
|
np.copyto(buf[:, 2], self._s_f32_c, casting="unsafe")
|
||||||
|
|
||||||
# Bright white-ish head (2-3 LEDs) — direct index range to avoid
|
# Bright white-ish head (2-3 LEDs) — direct index range to avoid
|
||||||
# boolean mask allocations and fancy indexing temporaries.
|
# boolean mask allocations and fancy indexing temporaries.
|
||||||
@@ -520,14 +552,14 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
np.multiply(head_br, 255 - ch_base, out=tmp)
|
np.multiply(head_br, 255 - ch_base, out=tmp)
|
||||||
tmp += buf[head_sl, ch_idx]
|
tmp += buf[head_sl, ch_idx]
|
||||||
np.clip(tmp, 0, 255, out=tmp)
|
np.clip(tmp, 0, 255, out=tmp)
|
||||||
np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe')
|
np.copyto(buf[head_sl, ch_idx], tmp, casting="unsafe")
|
||||||
|
|
||||||
# ── Plasma ───────────────────────────────────────────────────────
|
# ── Plasma ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Overlapping sine waves creating colorful plasma patterns."""
|
"""Overlapping sine waves creating colorful plasma patterns."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
scale = self._scale
|
scale = self.resolve("scale", self._scale)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# Cache x array (only changes when n or scale change)
|
# Cache x array (only changes when n or scale change)
|
||||||
@@ -554,7 +586,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Smooth scrolling fractal noise mapped to a color palette."""
|
"""Smooth scrolling fractal noise mapped to a color palette."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
scale = self._scale
|
scale = self.resolve("scale", self._scale)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# Positions from cached arange (avoids per-frame np.arange)
|
# Positions from cached arange (avoids per-frame np.arange)
|
||||||
@@ -564,7 +596,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
# Map to palette indices using pre-allocated scratch
|
# Map to palette indices using pre-allocated scratch
|
||||||
np.multiply(values, 255, out=self._s_f32_b)
|
np.multiply(values, 255, out=self._s_f32_b)
|
||||||
np.clip(self._s_f32_b, 0, 255, out=self._s_f32_b)
|
np.clip(self._s_f32_b, 0, 255, out=self._s_f32_b)
|
||||||
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
|
np.copyto(self._s_i32, self._s_f32_b, casting="unsafe")
|
||||||
buf[:] = lut[self._s_i32]
|
buf[:] = lut[self._s_i32]
|
||||||
|
|
||||||
# ── Aurora ───────────────────────────────────────────────────────
|
# ── Aurora ───────────────────────────────────────────────────────
|
||||||
@@ -572,8 +604,8 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Layered noise bands simulating aurora borealis."""
|
"""Layered noise bands simulating aurora borealis."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
scale = self._scale
|
scale = self.resolve("scale", self._scale)
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# Positions from cached arange
|
# Positions from cached arange
|
||||||
@@ -606,20 +638,20 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Map to palette using pre-allocated scratch
|
# Map to palette using pre-allocated scratch
|
||||||
np.multiply(hue, 255, out=hue)
|
np.multiply(hue, 255, out=hue)
|
||||||
np.copyto(self._s_i32, hue, casting='unsafe')
|
np.copyto(self._s_i32, hue, casting="unsafe")
|
||||||
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
||||||
self._s_f32_rgb[:] = lut[self._s_i32]
|
self._s_f32_rgb[:] = lut[self._s_i32]
|
||||||
self._s_f32_rgb *= bright[:, np.newaxis]
|
self._s_f32_rgb *= bright[:, np.newaxis]
|
||||||
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
|
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
|
||||||
np.copyto(buf, self._s_f32_rgb, casting='unsafe')
|
np.copyto(buf, self._s_f32_rgb, casting="unsafe")
|
||||||
|
|
||||||
# ── Rain ──────────────────────────────────────────────────────────
|
# ── Rain ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Raindrops falling down the strip with trailing tails."""
|
"""Raindrops falling down the strip with trailing tails."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
scale = self._scale
|
scale = self.resolve("scale", self._scale)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# Multiple rain "lanes" at different speeds for depth
|
# Multiple rain "lanes" at different speeds for depth
|
||||||
@@ -644,7 +676,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
np.clip(bright, 0.0, 1.0, out=bright)
|
np.clip(bright, 0.0, 1.0, out=bright)
|
||||||
np.multiply(bright, 255, out=self._s_f32_b)
|
np.multiply(bright, 255, out=self._s_f32_b)
|
||||||
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
|
np.copyto(self._s_i32, self._s_f32_b, casting="unsafe")
|
||||||
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
||||||
buf[:] = lut[self._s_i32]
|
buf[:] = lut[self._s_i32]
|
||||||
|
|
||||||
@@ -653,7 +685,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Multiple comets with curved, pulsing tails."""
|
"""Multiple comets with curved, pulsing tails."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
color = self._color
|
color = self._color
|
||||||
mirror = self._mirror
|
mirror = self._mirror
|
||||||
|
|
||||||
@@ -692,14 +724,14 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
self._s_f32_a[:] = buf[:, ch_idx]
|
self._s_f32_a[:] = buf[:, ch_idx]
|
||||||
self._s_f32_a += self._s_f32_c
|
self._s_f32_a += self._s_f32_c
|
||||||
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
||||||
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')
|
np.copyto(buf[:, ch_idx], self._s_f32_a, casting="unsafe")
|
||||||
|
|
||||||
# ── Bouncing Ball ─────────────────────────────────────────────────
|
# ── Bouncing Ball ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Physics-simulated bouncing balls with gravity."""
|
"""Physics-simulated bouncing balls with gravity."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
color = self._color
|
color = self._color
|
||||||
|
|
||||||
num_balls = 3
|
num_balls = 3
|
||||||
@@ -755,14 +787,14 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
self._s_f32_a[:] = buf[:, ch_idx]
|
self._s_f32_a[:] = buf[:, ch_idx]
|
||||||
self._s_f32_a += self._s_f32_c
|
self._s_f32_a += self._s_f32_c
|
||||||
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
||||||
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')
|
np.copyto(buf[:, ch_idx], self._s_f32_a, casting="unsafe")
|
||||||
|
|
||||||
# ── Fireworks ─────────────────────────────────────────────────────
|
# ── Fireworks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Rockets launch and explode into colorful particle bursts."""
|
"""Rockets launch and explode into colorful particle bursts."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
dt = 1.0 / max(self._fps, 1)
|
dt = 1.0 / max(self._fps, 1)
|
||||||
@@ -835,7 +867,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Twinkling star field with smooth fade-in/fade-out."""
|
"""Twinkling star field with smooth fade-in/fade-out."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
intensity = self._intensity
|
intensity = self.resolve("intensity", self._intensity)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# Initialize/resize sparkle state
|
# Initialize/resize sparkle state
|
||||||
@@ -858,20 +890,20 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Map sparkle brightness to palette
|
# Map sparkle brightness to palette
|
||||||
np.multiply(state, 255, out=self._s_f32_a)
|
np.multiply(state, 255, out=self._s_f32_a)
|
||||||
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
|
np.copyto(self._s_i32, self._s_f32_a, casting="unsafe")
|
||||||
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
||||||
self._s_f32_rgb[:] = lut[self._s_i32]
|
self._s_f32_rgb[:] = lut[self._s_i32]
|
||||||
# Apply brightness
|
# Apply brightness
|
||||||
self._s_f32_rgb *= state[:, np.newaxis]
|
self._s_f32_rgb *= state[:, np.newaxis]
|
||||||
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
|
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
|
||||||
np.copyto(buf, self._s_f32_rgb, casting='unsafe')
|
np.copyto(buf, self._s_f32_rgb, casting="unsafe")
|
||||||
|
|
||||||
# ── Lava Lamp ─────────────────────────────────────────────────────
|
# ── Lava Lamp ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Slow-moving colored blobs that merge and separate."""
|
"""Slow-moving colored blobs that merge and separate."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
scale = self._scale
|
scale = self.resolve("scale", self._scale)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# Use noise at very low frequency for blob movement
|
# Use noise at very low frequency for blob movement
|
||||||
@@ -903,7 +935,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Map to palette
|
# Map to palette
|
||||||
np.multiply(combined, 255, out=self._s_f32_b)
|
np.multiply(combined, 255, out=self._s_f32_b)
|
||||||
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
|
np.copyto(self._s_i32, self._s_f32_b, casting="unsafe")
|
||||||
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
||||||
buf[:] = lut[self._s_i32]
|
buf[:] = lut[self._s_i32]
|
||||||
|
|
||||||
@@ -912,7 +944,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
|
def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||||
"""Two counter-propagating sine waves creating interference patterns."""
|
"""Two counter-propagating sine waves creating interference patterns."""
|
||||||
speed = self._effective_speed
|
speed = self._effective_speed
|
||||||
scale = self._scale
|
scale = self.resolve("scale", self._scale)
|
||||||
lut = self._palette_lut
|
lut = self._palette_lut
|
||||||
|
|
||||||
# Wave parameters
|
# Wave parameters
|
||||||
@@ -934,7 +966,7 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
self._s_f32_a += self._s_f32_b
|
self._s_f32_a += self._s_f32_b
|
||||||
# Range is [-2, 2], map to [0, 255]
|
# Range is [-2, 2], map to [0, 255]
|
||||||
self._s_f32_a += 2.0
|
self._s_f32_a += 2.0
|
||||||
self._s_f32_a *= (255.0 / 4.0)
|
self._s_f32_a *= 255.0 / 4.0
|
||||||
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
||||||
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
|
np.copyto(self._s_i32, self._s_f32_a, casting="unsafe")
|
||||||
buf[:] = lut[self._s_i32]
|
buf[:] = lut[self._s_i32]
|
||||||
|
|||||||
@@ -27,23 +27,35 @@ class HALightTargetProcessor(TargetProcessor):
|
|||||||
target_id: str,
|
target_id: str,
|
||||||
ha_source_id: str,
|
ha_source_id: str,
|
||||||
color_strip_source_id: str = "",
|
color_strip_source_id: str = "",
|
||||||
|
brightness=None,
|
||||||
|
# legacy compat
|
||||||
brightness_value_source_id: str = "",
|
brightness_value_source_id: str = "",
|
||||||
light_mappings: Optional[List[HALightMapping]] = None,
|
light_mappings: Optional[List[HALightMapping]] = None,
|
||||||
update_rate: float = 2.0,
|
update_rate: float = 2.0,
|
||||||
transition: float = 0.5,
|
transition=None,
|
||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
color_tolerance: int = 5,
|
color_tolerance: int = 5,
|
||||||
ctx: Optional[TargetContext] = None,
|
ctx: Optional[TargetContext] = None,
|
||||||
):
|
):
|
||||||
|
from wled_controller.storage.bindable import BindableFloat, bfloat
|
||||||
|
|
||||||
super().__init__(target_id, ctx)
|
super().__init__(target_id, ctx)
|
||||||
self._ha_source_id = ha_source_id
|
self._ha_source_id = ha_source_id
|
||||||
self._css_id = color_strip_source_id
|
self._css_id = color_strip_source_id
|
||||||
self._brightness_vs_id = brightness_value_source_id
|
# Accept BindableFloat or legacy string
|
||||||
|
if brightness is not None and isinstance(brightness, BindableFloat):
|
||||||
|
self._brightness = brightness
|
||||||
|
else:
|
||||||
|
self._brightness = BindableFloat(1.0, source_id=brightness_value_source_id or "")
|
||||||
|
# Transition as BindableFloat
|
||||||
|
if transition is not None and isinstance(transition, BindableFloat):
|
||||||
|
self._transition = transition
|
||||||
|
else:
|
||||||
|
self._transition = BindableFloat(float(transition) if transition is not None else 0.5)
|
||||||
self._light_mappings = light_mappings or []
|
self._light_mappings = light_mappings or []
|
||||||
self._update_rate = max(0.5, min(5.0, update_rate))
|
self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0)))
|
||||||
self._transition = transition
|
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
||||||
self._min_brightness_threshold = min_brightness_threshold
|
self._color_tolerance = int(bfloat(color_tolerance, 5.0))
|
||||||
self._color_tolerance = color_tolerance
|
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self._css_stream = None
|
self._css_stream = None
|
||||||
@@ -83,9 +95,11 @@ class HALightTargetProcessor(TargetProcessor):
|
|||||||
logger.warning(f"HA light {self._target_id}: failed to acquire HA runtime: {e}")
|
logger.warning(f"HA light {self._target_id}: failed to acquire HA runtime: {e}")
|
||||||
|
|
||||||
# Acquire brightness value stream (if configured)
|
# Acquire brightness value stream (if configured)
|
||||||
if self._brightness_vs_id and self._ctx.value_stream_manager:
|
if self._brightness.source_id and self._ctx.value_stream_manager:
|
||||||
try:
|
try:
|
||||||
self._value_stream = self._ctx.value_stream_manager.acquire(self._brightness_vs_id)
|
self._value_stream = self._ctx.value_stream_manager.acquire(
|
||||||
|
self._brightness.source_id
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
|
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
|
||||||
self._value_stream = None
|
self._value_stream = None
|
||||||
@@ -116,7 +130,7 @@ class HALightTargetProcessor(TargetProcessor):
|
|||||||
# Release brightness value stream
|
# Release brightness value stream
|
||||||
if self._value_stream is not None and self._ctx.value_stream_manager:
|
if self._value_stream is not None and self._ctx.value_stream_manager:
|
||||||
try:
|
try:
|
||||||
self._ctx.value_stream_manager.release(self._brightness_vs_id)
|
self._ctx.value_stream_manager.release(self._brightness.source_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._value_stream = None
|
self._value_stream = None
|
||||||
@@ -138,15 +152,29 @@ class HALightTargetProcessor(TargetProcessor):
|
|||||||
logger.info(f"HA light target stopped: {self._target_id}")
|
logger.info(f"HA light target stopped: {self._target_id}")
|
||||||
|
|
||||||
def update_settings(self, settings) -> None:
|
def update_settings(self, settings) -> None:
|
||||||
|
from wled_controller.storage.bindable import BindableFloat, bfloat
|
||||||
|
|
||||||
if isinstance(settings, dict):
|
if isinstance(settings, dict):
|
||||||
if "update_rate" in settings:
|
if "update_rate" in settings:
|
||||||
self._update_rate = max(0.5, min(5.0, float(settings["update_rate"])))
|
self._update_rate = max(0.5, min(5.0, bfloat(settings["update_rate"], 2.0)))
|
||||||
if "transition" in settings:
|
if "transition" in settings:
|
||||||
self._transition = float(settings["transition"])
|
t = settings["transition"]
|
||||||
|
if isinstance(t, BindableFloat):
|
||||||
|
self._transition = t
|
||||||
|
else:
|
||||||
|
self._transition = self._transition.apply_update(t)
|
||||||
|
if "brightness" in settings:
|
||||||
|
b = settings["brightness"]
|
||||||
|
if isinstance(b, BindableFloat):
|
||||||
|
self._brightness = b
|
||||||
|
else:
|
||||||
|
self._brightness = self._brightness.apply_update(b)
|
||||||
if "min_brightness_threshold" in settings:
|
if "min_brightness_threshold" in settings:
|
||||||
self._min_brightness_threshold = int(settings["min_brightness_threshold"])
|
self._min_brightness_threshold = int(
|
||||||
|
bfloat(settings["min_brightness_threshold"], 0.0)
|
||||||
|
)
|
||||||
if "color_tolerance" in settings:
|
if "color_tolerance" in settings:
|
||||||
self._color_tolerance = int(settings["color_tolerance"])
|
self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
|
||||||
if "light_mappings" in settings:
|
if "light_mappings" in settings:
|
||||||
self._light_mappings = settings["light_mappings"]
|
self._light_mappings = settings["light_mappings"]
|
||||||
|
|
||||||
@@ -273,7 +301,12 @@ class HALightTargetProcessor(TargetProcessor):
|
|||||||
brightness = max(r, g, b)
|
brightness = max(r, g, b)
|
||||||
|
|
||||||
# Apply brightness scale and value source multiplier
|
# Apply brightness scale and value source multiplier
|
||||||
eff_scale = mapping.brightness_scale * vs_multiplier
|
bs = (
|
||||||
|
mapping.brightness_scale.value
|
||||||
|
if hasattr(mapping.brightness_scale, "value")
|
||||||
|
else mapping.brightness_scale
|
||||||
|
)
|
||||||
|
eff_scale = bs * vs_multiplier
|
||||||
if eff_scale < 1.0:
|
if eff_scale < 1.0:
|
||||||
brightness = int(brightness * eff_scale)
|
brightness = int(brightness * eff_scale)
|
||||||
|
|
||||||
@@ -299,10 +332,11 @@ class HALightTargetProcessor(TargetProcessor):
|
|||||||
# Call light.turn_on
|
# Call light.turn_on
|
||||||
service_data = {
|
service_data = {
|
||||||
"rgb_color": [r, g, b],
|
"rgb_color": [r, g, b],
|
||||||
"brightness": min(255, int(brightness * mapping.brightness_scale)),
|
"brightness": min(255, int(brightness * bs)),
|
||||||
}
|
}
|
||||||
if self._transition > 0:
|
transition_val = self._transition.value
|
||||||
service_data["transition"] = self._transition
|
if transition_val > 0:
|
||||||
|
service_data["transition"] = transition_val
|
||||||
|
|
||||||
await self._ha_runtime.call_service(
|
await self._ha_runtime.call_service(
|
||||||
domain="light",
|
domain="light",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from wled_controller.core.capture.screen_capture import (
|
|||||||
calculate_dominant_color,
|
calculate_dominant_color,
|
||||||
calculate_median_color,
|
calculate_median_color,
|
||||||
)
|
)
|
||||||
|
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.utils.timer import high_resolution_timer
|
from wled_controller.utils.timer import high_resolution_timer
|
||||||
|
|
||||||
@@ -36,12 +38,8 @@ _CALC_FNS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class KeyColorsColorStripStream:
|
class KeyColorsColorStripStream(ColorStripStream):
|
||||||
"""Streams N colors extracted from screen rectangles.
|
"""Streams N colors extracted from screen rectangles."""
|
||||||
|
|
||||||
Implements the same interface as ColorStripStream so it can be used
|
|
||||||
by any target processor via ColorStripStreamManager.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -165,7 +163,7 @@ class KeyColorsColorStripStream:
|
|||||||
colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
|
colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
|
||||||
|
|
||||||
# Temporal smoothing
|
# Temporal smoothing
|
||||||
smoothing = src.smoothing
|
smoothing = self.resolve("smoothing", bfloat(src.smoothing, 0.3))
|
||||||
if (
|
if (
|
||||||
prev_colors_arr is not None
|
prev_colors_arr is not None
|
||||||
and smoothing > 0
|
and smoothing > 0
|
||||||
@@ -175,7 +173,7 @@ class KeyColorsColorStripStream:
|
|||||||
prev_colors_arr = colors_arr
|
prev_colors_arr = colors_arr
|
||||||
|
|
||||||
# Apply brightness
|
# Apply brightness
|
||||||
brightness = src.brightness
|
brightness = self.resolve("brightness", bfloat(src.brightness, 1.0))
|
||||||
if brightness < 1.0:
|
if brightness < 1.0:
|
||||||
output = colors_arr * brightness
|
output = colors_arr * brightness
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from typing import Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -74,16 +75,20 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
def _update_from_source(self, source) -> None:
|
def _update_from_source(self, source) -> None:
|
||||||
"""Parse config from source dataclass."""
|
"""Parse config from source dataclass."""
|
||||||
self._notification_effect = getattr(source, "notification_effect", "flash")
|
self._notification_effect = getattr(source, "notification_effect", "flash")
|
||||||
self._duration_ms = max(100, int(getattr(source, "duration_ms", 1500)))
|
self._duration_ms = max(100, int(bfloat(getattr(source, "duration_ms", 1500), 1500)))
|
||||||
self._default_color = getattr(source, "default_color", "#FFFFFF")
|
self._default_color = getattr(source, "default_color", "#FFFFFF")
|
||||||
self._app_colors = {k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items()}
|
self._app_colors = {
|
||||||
|
k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items()
|
||||||
|
}
|
||||||
self._app_filter_mode = getattr(source, "app_filter_mode", "off")
|
self._app_filter_mode = getattr(source, "app_filter_mode", "off")
|
||||||
self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
|
self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
|
||||||
self._auto_size = not getattr(source, "led_count", 0)
|
self._auto_size = not getattr(source, "led_count", 0)
|
||||||
self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
|
self._led_count = (
|
||||||
|
getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
|
||||||
|
)
|
||||||
# Sound config
|
# Sound config
|
||||||
self._sound_asset_id = getattr(source, "sound_asset_id", None)
|
self._sound_asset_id = getattr(source, "sound_asset_id", None)
|
||||||
self._sound_volume = float(getattr(source, "sound_volume", 1.0))
|
self._sound_volume = bfloat(getattr(source, "sound_volume", 1.0), 1.0)
|
||||||
raw_app_sounds = dict(getattr(source, "app_sounds", {}))
|
raw_app_sounds = dict(getattr(source, "app_sounds", {}))
|
||||||
self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
|
self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
@@ -135,7 +140,7 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Resolve sound: per-app override > global sound_asset_id
|
# Resolve sound: per-app override > global sound_asset_id
|
||||||
sound_asset_id = None
|
sound_asset_id = None
|
||||||
volume = self._sound_volume
|
volume = self.resolve("sound_volume", self._sound_volume)
|
||||||
|
|
||||||
if app_lower and app_lower in self._app_sounds:
|
if app_lower and app_lower in self._app_sounds:
|
||||||
override = self._app_sounds[app_lower]
|
override = self._app_sounds[app_lower]
|
||||||
@@ -164,6 +169,7 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from wled_controller.utils.sound_player import play_sound_async
|
from wled_controller.utils.sound_player import play_sound_async
|
||||||
|
|
||||||
play_sound_async(file_path, volume=volume)
|
play_sound_async(file_path, volume=volume)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to play notification sound: {e}")
|
logger.error(f"Failed to play notification sound: {e}")
|
||||||
@@ -211,7 +217,9 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=5.0)
|
self._thread.join(timeout=5.0)
|
||||||
if self._thread.is_alive():
|
if self._thread.is_alive():
|
||||||
logger.warning("NotificationColorStripStream render thread did not terminate within 5s")
|
logger.warning(
|
||||||
|
"NotificationColorStripStream render thread did not terminate within 5s"
|
||||||
|
)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
logger.info("NotificationColorStripStream stopped")
|
logger.info("NotificationColorStripStream stopped")
|
||||||
|
|
||||||
@@ -222,6 +230,7 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
"""Hot-update config from updated source."""
|
"""Hot-update config from updated source."""
|
||||||
from wled_controller.storage.color_strip_source import NotificationColorStripSource
|
from wled_controller.storage.color_strip_source import NotificationColorStripSource
|
||||||
|
|
||||||
if isinstance(source, NotificationColorStripSource):
|
if isinstance(source, NotificationColorStripSource):
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
@@ -253,7 +262,9 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
while self._event_queue:
|
while self._event_queue:
|
||||||
try:
|
try:
|
||||||
event = self._event_queue.popleft()
|
event = self._event_queue.popleft()
|
||||||
if self._active_effect is None or event.get("priority", 0) >= self._active_effect.get("priority", 0):
|
if self._active_effect is None or event.get(
|
||||||
|
"priority", 0
|
||||||
|
) >= self._active_effect.get("priority", 0):
|
||||||
self._active_effect = event
|
self._active_effect = event
|
||||||
except IndexError:
|
except IndexError:
|
||||||
break
|
break
|
||||||
@@ -273,7 +284,7 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
color = self._active_effect["color"]
|
color = self._active_effect["color"]
|
||||||
start_time = self._active_effect["start"]
|
start_time = self._active_effect["start"]
|
||||||
elapsed_ms = (time.monotonic() - start_time) * 1000.0
|
elapsed_ms = (time.monotonic() - start_time) * 1000.0
|
||||||
duration_ms = self._duration_ms
|
duration_ms = self.resolve("duration_ms", self._duration_ms)
|
||||||
progress = min(elapsed_ms / duration_ms, 1.0)
|
progress = min(elapsed_ms / duration_ms, 1.0)
|
||||||
|
|
||||||
if progress >= 1.0:
|
if progress >= 1.0:
|
||||||
@@ -392,7 +403,9 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
buf[i, 1] = min(255, int(color[1] * glow))
|
buf[i, 1] = min(255, int(color[1] * glow))
|
||||||
buf[i, 2] = min(255, int(color[2] * glow))
|
buf[i, 2] = min(255, int(color[2] * glow))
|
||||||
|
|
||||||
def _render_gradient_flash(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
|
def _render_gradient_flash(
|
||||||
|
self, buf: np.ndarray, n: int, color: tuple, progress: float
|
||||||
|
) -> None:
|
||||||
"""Gradient flash: bright center fades to edges, then all fades out.
|
"""Gradient flash: bright center fades to edges, then all fades out.
|
||||||
|
|
||||||
Creates a gradient from the notification color at center to darker
|
Creates a gradient from the notification color at center to darker
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
fps: int = 30,
|
fps: int = 30,
|
||||||
keepalive_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
|
brightness=None,
|
||||||
|
# legacy compat
|
||||||
brightness_value_source_id: str = "",
|
brightness_value_source_id: str = "",
|
||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
adaptive_fps: bool = False,
|
adaptive_fps: bool = False,
|
||||||
@@ -442,7 +444,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
fps=fps,
|
fps=fps,
|
||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
brightness_value_source_id=brightness_value_source_id,
|
brightness=brightness,
|
||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
adaptive_fps=adaptive_fps,
|
adaptive_fps=adaptive_fps,
|
||||||
protocol=protocol,
|
protocol=protocol,
|
||||||
@@ -456,10 +458,12 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
target_id: str,
|
target_id: str,
|
||||||
ha_source_id: str,
|
ha_source_id: str,
|
||||||
color_strip_source_id: str = "",
|
color_strip_source_id: str = "",
|
||||||
|
brightness=None,
|
||||||
|
# legacy compat
|
||||||
brightness_value_source_id: str = "",
|
brightness_value_source_id: str = "",
|
||||||
light_mappings=None,
|
light_mappings=None,
|
||||||
update_rate: float = 2.0,
|
update_rate: float = 2.0,
|
||||||
transition: float = 0.5,
|
transition=None,
|
||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
color_tolerance: int = 5,
|
color_tolerance: int = 5,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -473,7 +477,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
target_id=target_id,
|
target_id=target_id,
|
||||||
ha_source_id=ha_source_id,
|
ha_source_id=ha_source_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
brightness_value_source_id=brightness_value_source_id,
|
brightness=brightness,
|
||||||
light_mappings=light_mappings or [],
|
light_mappings=light_mappings or [],
|
||||||
update_rate=update_rate,
|
update_rate=update_rate,
|
||||||
transition=transition,
|
transition=transition,
|
||||||
@@ -544,10 +548,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update_target_brightness_vs(self, target_id: str, vs_id: str):
|
def update_target_brightness_vs(self, target_id: str, vs_id: str):
|
||||||
"""Update the brightness value source for a WLED target."""
|
"""Legacy: update brightness value source by ID string."""
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
|
|
||||||
|
self.update_target_brightness(target_id, BindableFloat(source_id=vs_id))
|
||||||
|
|
||||||
|
def update_target_brightness(self, target_id: str, brightness):
|
||||||
|
"""Update the brightness binding for a target."""
|
||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
if hasattr(proc, "update_brightness_value_source"):
|
if hasattr(proc, "update_brightness"):
|
||||||
proc.update_brightness_value_source(vs_id)
|
proc.update_brightness(brightness)
|
||||||
|
|
||||||
def update_value_source(self, vs_id: str):
|
def update_value_source(self, vs_id: str):
|
||||||
"""Hot-update all running value streams for a given source."""
|
"""Hot-update all running value streams for a given source."""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import numpy as np
|
|||||||
|
|
||||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
from wled_controller.core.weather.weather_provider import DEFAULT_WEATHER, WeatherData
|
from wled_controller.core.weather.weather_provider import DEFAULT_WEATHER, WeatherData
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
@@ -67,8 +68,8 @@ def _apply_temperature_shift(color: np.ndarray, temperature: float, influence: f
|
|||||||
shift = t * influence * 30.0 # max ±30 RGB units
|
shift = t * influence * 30.0 # max ±30 RGB units
|
||||||
|
|
||||||
result = color.astype(np.int16)
|
result = color.astype(np.int16)
|
||||||
result[:, 0] += int(shift) # red
|
result[:, 0] += int(shift) # red
|
||||||
result[:, 2] -= int(shift) # blue
|
result[:, 2] -= int(shift) # blue
|
||||||
np.clip(result, 0, 255, out=result)
|
np.clip(result, 0, 255, out=result)
|
||||||
return result.astype(np.uint8)
|
return result.astype(np.uint8)
|
||||||
|
|
||||||
@@ -90,8 +91,8 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
def __init__(self, source, weather_manager: WeatherManager):
|
def __init__(self, source, weather_manager: WeatherManager):
|
||||||
self._source_id = source.id
|
self._source_id = source.id
|
||||||
self._weather_source_id: str = source.weather_source_id
|
self._weather_source_id: str = source.weather_source_id
|
||||||
self._speed: float = source.speed
|
self._speed: float = bfloat(source.speed, 1.0)
|
||||||
self._temperature_influence: float = source.temperature_influence
|
self._temperature_influence: float = bfloat(source.temperature_influence, 0.5)
|
||||||
self._clock_id: Optional[str] = source.clock_id
|
self._clock_id: Optional[str] = source.clock_id
|
||||||
self._weather_manager = weather_manager
|
self._weather_manager = weather_manager
|
||||||
|
|
||||||
@@ -137,11 +138,14 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
try:
|
try:
|
||||||
self._weather_manager.acquire(self._weather_source_id)
|
self._weather_manager.acquire(self._weather_source_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Weather stream {self._source_id}: failed to acquire weather source: {e}")
|
logger.warning(
|
||||||
|
f"Weather stream {self._source_id}: failed to acquire weather source: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._animate_loop, daemon=True,
|
target=self._animate_loop,
|
||||||
|
daemon=True,
|
||||||
name=f"WeatherCSS-{self._source_id[:12]}",
|
name=f"WeatherCSS-{self._source_id[:12]}",
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
@@ -158,7 +162,9 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
try:
|
try:
|
||||||
self._weather_manager.release(self._weather_source_id)
|
self._weather_manager.release(self._weather_source_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Weather stream {self._source_id}: failed to release weather source: {e}")
|
logger.warning(
|
||||||
|
f"Weather stream {self._source_id}: failed to release weather source: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"WeatherColorStripStream stopped: {self._source_id}")
|
logger.info(f"WeatherColorStripStream stopped: {self._source_id}")
|
||||||
|
|
||||||
@@ -171,8 +177,8 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
self._led_count = device_led_count
|
self._led_count = device_led_count
|
||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
self._speed = source.speed
|
self._speed = bfloat(source.speed, 1.0)
|
||||||
self._temperature_influence = source.temperature_influence
|
self._temperature_influence = bfloat(source.temperature_influence, 0.5)
|
||||||
self._clock_id = source.clock_id
|
self._clock_id = source.clock_id
|
||||||
|
|
||||||
# If weather source changed, release old + acquire new
|
# If weather source changed, release old + acquire new
|
||||||
@@ -239,7 +245,7 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Compute animation phase
|
# Compute animation phase
|
||||||
t = time.perf_counter() - start_time
|
t = time.perf_counter() - start_time
|
||||||
phase = (t * self._speed * 0.1) % 1.0
|
phase = (t * self.resolve("speed", self._speed) * 0.1) % 1.0
|
||||||
|
|
||||||
# Generate gradient with drift
|
# Generate gradient with drift
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
@@ -248,8 +254,9 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
buf[i] = (c0 * (1.0 - s) + c1 * s).astype(np.uint8)
|
buf[i] = (c0 * (1.0 - s) + c1 * s).astype(np.uint8)
|
||||||
|
|
||||||
# Apply temperature shift
|
# Apply temperature shift
|
||||||
if self._temperature_influence > 0.0:
|
temp_inf = self.resolve("temperature_influence", self._temperature_influence)
|
||||||
buf[:] = _apply_temperature_shift(buf, weather.temperature, self._temperature_influence)
|
if temp_inf > 0.0:
|
||||||
|
buf[:] = _apply_temperature_shift(buf, weather.temperature, temp_inf)
|
||||||
|
|
||||||
# Thunderstorm flash effect
|
# Thunderstorm flash effect
|
||||||
is_thunderstorm = weather.code in (95, 96, 99)
|
is_thunderstorm = weather.code in (95, 96, 99)
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities
|
from wled_controller.core.devices.led_client import (
|
||||||
|
LEDClient,
|
||||||
|
create_led_client,
|
||||||
|
get_device_capabilities,
|
||||||
|
)
|
||||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||||
from wled_controller.core.processing.target_processor import (
|
from wled_controller.core.processing.target_processor import (
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
@@ -36,20 +40,29 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
fps: int = 30,
|
fps: int = 30,
|
||||||
keepalive_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = 30,
|
state_check_interval: int = 30,
|
||||||
|
brightness=None,
|
||||||
|
# legacy compat
|
||||||
brightness_value_source_id: str = "",
|
brightness_value_source_id: str = "",
|
||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
adaptive_fps: bool = False,
|
adaptive_fps: bool = False,
|
||||||
protocol: str = "ddp",
|
protocol: str = "ddp",
|
||||||
ctx: TargetContext = None,
|
ctx: TargetContext = None,
|
||||||
):
|
):
|
||||||
|
from wled_controller.storage.bindable import BindableFloat, bfloat
|
||||||
|
|
||||||
super().__init__(target_id, ctx)
|
super().__init__(target_id, ctx)
|
||||||
self._device_id = device_id
|
self._device_id = device_id
|
||||||
self._target_fps = fps if fps > 0 else 30
|
_fps = bfloat(fps, 30.0)
|
||||||
|
self._target_fps = int(_fps) if _fps > 0 else 30
|
||||||
self._keepalive_interval = keepalive_interval
|
self._keepalive_interval = keepalive_interval
|
||||||
self._state_check_interval = state_check_interval
|
self._state_check_interval = state_check_interval
|
||||||
self._css_id = color_strip_source_id
|
self._css_id = color_strip_source_id
|
||||||
self._brightness_vs_id = brightness_value_source_id
|
# Accept BindableFloat or legacy string
|
||||||
self._min_brightness_threshold = min_brightness_threshold
|
if brightness is not None and isinstance(brightness, BindableFloat):
|
||||||
|
self._brightness = brightness
|
||||||
|
else:
|
||||||
|
self._brightness = BindableFloat(1.0, source_id=brightness_value_source_id or "")
|
||||||
|
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
||||||
self._adaptive_fps = adaptive_fps
|
self._adaptive_fps = adaptive_fps
|
||||||
self._protocol = protocol
|
self._protocol = protocol
|
||||||
|
|
||||||
@@ -105,8 +118,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
# Connect to LED device
|
# Connect to LED device
|
||||||
try:
|
try:
|
||||||
self._led_client = create_led_client(
|
self._led_client = create_led_client(
|
||||||
device_info.device_type, device_info.device_url,
|
device_info.device_type,
|
||||||
use_ddp=(self._protocol == "ddp"), led_count=device_info.led_count,
|
device_info.device_url,
|
||||||
|
use_ddp=(self._protocol == "ddp"),
|
||||||
|
led_count=device_info.led_count,
|
||||||
baud_rate=device_info.baud_rate,
|
baud_rate=device_info.baud_rate,
|
||||||
send_latency_ms=device_info.send_latency_ms,
|
send_latency_ms=device_info.send_latency_ms,
|
||||||
rgbw=device_info.rgbw,
|
rgbw=device_info.rgbw,
|
||||||
@@ -128,7 +143,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
# Use client-reported LED count if available (more accurate than stored)
|
# Use client-reported LED count if available (more accurate than stored)
|
||||||
client_led_count = self._led_client.device_led_count
|
client_led_count = self._led_client.device_led_count
|
||||||
effective_led_count = client_led_count if client_led_count and client_led_count > 0 else device_info.led_count
|
effective_led_count = (
|
||||||
|
client_led_count
|
||||||
|
if client_led_count and client_led_count > 0
|
||||||
|
else device_info.led_count
|
||||||
|
)
|
||||||
self._effective_led_count = effective_led_count
|
self._effective_led_count = effective_led_count
|
||||||
|
|
||||||
if effective_led_count != device_info.led_count:
|
if effective_led_count != device_info.led_count:
|
||||||
@@ -142,7 +161,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
f"device ({effective_led_count} LEDs)"
|
f"device ({effective_led_count} LEDs)"
|
||||||
)
|
)
|
||||||
self._device_state_before = await self._led_client.snapshot_device_state()
|
self._device_state_before = await self._led_client.snapshot_device_state()
|
||||||
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type)
|
self._needs_keepalive = "standby_required" in get_device_capabilities(
|
||||||
|
device_info.device_type
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
|
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
|
||||||
raise RuntimeError(f"Failed to connect to LED device: {e}")
|
raise RuntimeError(f"Failed to connect to LED device: {e}")
|
||||||
@@ -168,9 +189,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._resolved_display_index = getattr(stream, "display_index", None)
|
self._resolved_display_index = getattr(stream, "display_index", None)
|
||||||
self._css_stream = stream
|
self._css_stream = stream
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Acquired CSS stream '{self._css_id}' for target {self._target_id}")
|
||||||
f"Acquired CSS stream '{self._css_id}' for target {self._target_id}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._led_client:
|
if self._led_client:
|
||||||
await self._led_client.close()
|
await self._led_client.close()
|
||||||
@@ -178,13 +197,13 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
raise RuntimeError(f"Failed to acquire CSS stream: {e}")
|
raise RuntimeError(f"Failed to acquire CSS stream: {e}")
|
||||||
|
|
||||||
# Acquire value stream for brightness modulation (if configured)
|
# Acquire value stream for brightness modulation (if configured)
|
||||||
if self._brightness_vs_id and self._ctx.value_stream_manager:
|
if self._brightness.source_id and self._ctx.value_stream_manager:
|
||||||
try:
|
try:
|
||||||
self._value_stream = self._ctx.value_stream_manager.acquire(
|
self._value_stream = self._ctx.value_stream_manager.acquire(
|
||||||
self._brightness_vs_id
|
self._brightness.source_id
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to acquire value stream {self._brightness_vs_id}: {e}")
|
logger.warning(f"Failed to acquire value stream {self._brightness.source_id}: {e}")
|
||||||
self._value_stream = None
|
self._value_stream = None
|
||||||
|
|
||||||
# Reset metrics and start loop
|
# Reset metrics and start loop
|
||||||
@@ -193,7 +212,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._task = asyncio.create_task(self._processing_loop())
|
self._task = asyncio.create_task(self._processing_loop())
|
||||||
|
|
||||||
logger.info(f"Started processing for target {self._target_id}")
|
logger.info(f"Started processing for target {self._target_id}")
|
||||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
|
self._ctx.fire_event(
|
||||||
|
{"type": "state_change", "target_id": self._target_id, "processing": True}
|
||||||
|
)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
if not self._is_running:
|
if not self._is_running:
|
||||||
@@ -232,27 +253,34 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
css_manager.remove_target_fps(self._css_id, self._target_id)
|
css_manager.remove_target_fps(self._css_id, self._target_id)
|
||||||
await asyncio.to_thread(css_manager.release, self._css_id, self._target_id)
|
await asyncio.to_thread(css_manager.release, self._css_id, self._target_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}")
|
logger.warning(
|
||||||
|
f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}"
|
||||||
|
)
|
||||||
self._css_stream = None
|
self._css_stream = None
|
||||||
|
|
||||||
# Release value stream
|
# Release value stream
|
||||||
if self._value_stream is not None and self._ctx.value_stream_manager:
|
if self._value_stream is not None and self._ctx.value_stream_manager:
|
||||||
try:
|
try:
|
||||||
self._ctx.value_stream_manager.release(self._brightness_vs_id)
|
self._ctx.value_stream_manager.release(self._brightness.source_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error releasing value stream: {e}")
|
logger.warning(f"Error releasing value stream: {e}")
|
||||||
self._value_stream = None
|
self._value_stream = None
|
||||||
|
|
||||||
logger.info(f"Stopped processing for target {self._target_id}")
|
logger.info(f"Stopped processing for target {self._target_id}")
|
||||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
|
self._ctx.fire_event(
|
||||||
|
{"type": "state_change", "target_id": self._target_id, "processing": False}
|
||||||
|
)
|
||||||
|
|
||||||
# ----- Settings -----
|
# ----- Settings -----
|
||||||
|
|
||||||
def update_settings(self, settings: dict) -> None:
|
def update_settings(self, settings: dict) -> None:
|
||||||
"""Update target-specific timing settings."""
|
"""Update target-specific timing settings."""
|
||||||
|
from wled_controller.storage.bindable import bfloat
|
||||||
|
|
||||||
if isinstance(settings, dict):
|
if isinstance(settings, dict):
|
||||||
if "fps" in settings:
|
if "fps" in settings:
|
||||||
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
|
_fps = bfloat(settings["fps"], 30.0)
|
||||||
|
self._target_fps = int(_fps) if _fps > 0 else 30
|
||||||
self._effective_fps = self._target_fps # reset adaptive
|
self._effective_fps = self._target_fps # reset adaptive
|
||||||
css_manager = self._ctx.color_strip_stream_manager
|
css_manager = self._ctx.color_strip_stream_manager
|
||||||
if css_manager and self._is_running and self._css_id:
|
if css_manager and self._is_running and self._css_id:
|
||||||
@@ -262,7 +290,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if "state_check_interval" in settings:
|
if "state_check_interval" in settings:
|
||||||
self._state_check_interval = settings["state_check_interval"]
|
self._state_check_interval = settings["state_check_interval"]
|
||||||
if "min_brightness_threshold" in settings:
|
if "min_brightness_threshold" in settings:
|
||||||
self._min_brightness_threshold = settings["min_brightness_threshold"]
|
self._min_brightness_threshold = int(
|
||||||
|
bfloat(settings["min_brightness_threshold"], 0.0)
|
||||||
|
)
|
||||||
if "adaptive_fps" in settings:
|
if "adaptive_fps" in settings:
|
||||||
self._adaptive_fps = settings["adaptive_fps"]
|
self._adaptive_fps = settings["adaptive_fps"]
|
||||||
if not self._adaptive_fps:
|
if not self._adaptive_fps:
|
||||||
@@ -286,7 +316,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
return
|
return
|
||||||
|
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
device_info = self._ctx.get_device_info(self._device_id)
|
||||||
device_leds = getattr(self, '_effective_led_count', None) or (device_info.led_count if device_info else 0)
|
device_leds = getattr(self, "_effective_led_count", None) or (
|
||||||
|
device_info.led_count if device_info else 0
|
||||||
|
)
|
||||||
|
|
||||||
# Release old stream
|
# Release old stream
|
||||||
if self._css_stream is not None and old_css_id:
|
if self._css_stream is not None and old_css_id:
|
||||||
@@ -312,14 +344,30 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
|
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
|
||||||
|
|
||||||
def update_brightness_value_source(self, vs_id: str) -> None:
|
def update_brightness_value_source(self, vs_id: str) -> None:
|
||||||
"""Hot-swap the brightness value source for a running target."""
|
"""Legacy: hot-swap brightness value source by ID string."""
|
||||||
old_vs_id = self._brightness_vs_id
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
self._brightness_vs_id = vs_id
|
|
||||||
vs_mgr = self._ctx.value_stream_manager
|
|
||||||
|
|
||||||
|
self.update_brightness(BindableFloat(value=self._brightness.value, source_id=vs_id))
|
||||||
|
|
||||||
|
def update_brightness(self, brightness) -> None:
|
||||||
|
"""Hot-swap the brightness binding for a running target."""
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
|
|
||||||
|
old_vs_id = self._brightness.source_id
|
||||||
|
if isinstance(brightness, BindableFloat):
|
||||||
|
self._brightness = brightness
|
||||||
|
else:
|
||||||
|
self._brightness = self._brightness.apply_update(brightness)
|
||||||
|
new_vs_id = self._brightness.source_id
|
||||||
|
|
||||||
|
vs_mgr = self._ctx.value_stream_manager
|
||||||
if not self._is_running or vs_mgr is None:
|
if not self._is_running or vs_mgr is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only swap streams if source_id actually changed
|
||||||
|
if old_vs_id == new_vs_id:
|
||||||
|
return
|
||||||
|
|
||||||
# Release old stream
|
# Release old stream
|
||||||
if self._value_stream is not None and old_vs_id:
|
if self._value_stream is not None and old_vs_id:
|
||||||
try:
|
try:
|
||||||
@@ -329,14 +377,14 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._value_stream = None
|
self._value_stream = None
|
||||||
|
|
||||||
# Acquire new stream
|
# Acquire new stream
|
||||||
if vs_id:
|
if new_vs_id:
|
||||||
try:
|
try:
|
||||||
self._value_stream = vs_mgr.acquire(vs_id)
|
self._value_stream = vs_mgr.acquire(new_vs_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to acquire value stream {vs_id}: {e}")
|
logger.warning(f"Failed to acquire value stream {new_vs_id}: {e}")
|
||||||
self._value_stream = None
|
self._value_stream = None
|
||||||
|
|
||||||
logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {vs_id}")
|
logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {new_vs_id}")
|
||||||
|
|
||||||
async def _probe_device(self, device_url: str, client: httpx.AsyncClient) -> bool:
|
async def _probe_device(self, device_url: str, client: httpx.AsyncClient) -> bool:
|
||||||
"""HTTP liveness probe — lightweight GET to check if device is reachable."""
|
"""HTTP liveness probe — lightweight GET to check if device is reachable."""
|
||||||
@@ -374,7 +422,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
is_audio_source = css_timing and "audio_render_ms" in css_timing
|
is_audio_source = css_timing and "audio_render_ms" in css_timing
|
||||||
audio_read_ms = round(css_timing.get("audio_read_ms", 0), 1) if is_audio_source else None
|
audio_read_ms = round(css_timing.get("audio_read_ms", 0), 1) if is_audio_source else None
|
||||||
audio_fft_ms = round(css_timing.get("audio_fft_ms", 0), 1) if is_audio_source else None
|
audio_fft_ms = round(css_timing.get("audio_fft_ms", 0), 1) if is_audio_source else None
|
||||||
audio_render_ms = round(css_timing.get("audio_render_ms", 0), 1) if is_audio_source else None
|
audio_render_ms = (
|
||||||
|
round(css_timing.get("audio_render_ms", 0), 1) if is_audio_source else None
|
||||||
|
)
|
||||||
# Suppress picture timing when audio source is active
|
# Suppress picture timing when audio source is active
|
||||||
if is_audio_source:
|
if is_audio_source:
|
||||||
extract_ms = map_ms = smooth_ms = None
|
extract_ms = map_ms = smooth_ms = None
|
||||||
@@ -390,15 +440,17 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
last_update = metrics.last_update
|
last_update = metrics.last_update
|
||||||
if metrics.last_update_mono > 0:
|
if metrics.last_update_mono > 0:
|
||||||
elapsed = time.monotonic() - metrics.last_update_mono
|
elapsed = time.monotonic() - metrics.last_update_mono
|
||||||
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
|
last_update = (
|
||||||
time.time() - elapsed, tz=timezone.utc
|
datetime.now(timezone.utc)
|
||||||
|
if elapsed < 1.0
|
||||||
|
else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"target_id": self._target_id,
|
"target_id": self._target_id,
|
||||||
"device_id": self._device_id,
|
"device_id": self._device_id,
|
||||||
"color_strip_source_id": self._css_id,
|
"color_strip_source_id": self._css_id,
|
||||||
"brightness_value_source_id": self._brightness_vs_id,
|
"brightness": self._brightness.to_dict(),
|
||||||
"processing": self._is_running,
|
"processing": self._is_running,
|
||||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||||
@@ -435,8 +487,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
last_update = metrics.last_update
|
last_update = metrics.last_update
|
||||||
if metrics.last_update_mono > 0:
|
if metrics.last_update_mono > 0:
|
||||||
elapsed = time.monotonic() - metrics.last_update_mono
|
elapsed = time.monotonic() - metrics.last_update_mono
|
||||||
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
|
last_update = (
|
||||||
time.time() - elapsed, tz=timezone.utc
|
datetime.now(timezone.utc)
|
||||||
|
if elapsed < 1.0
|
||||||
|
else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -457,7 +511,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
def supports_overlay(self) -> bool:
|
def supports_overlay(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def start_overlay(self, target_name: Optional[str] = None, calibration=None, display_info=None) -> None:
|
async def start_overlay(
|
||||||
|
self, target_name: Optional[str] = None, calibration=None, display_info=None
|
||||||
|
) -> None:
|
||||||
if self._overlay_active:
|
if self._overlay_active:
|
||||||
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
||||||
|
|
||||||
@@ -484,7 +540,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._ctx.overlay_manager.start_overlay,
|
self._ctx.overlay_manager.start_overlay,
|
||||||
self._target_id, display_info, calibration, target_name,
|
self._target_id,
|
||||||
|
display_info,
|
||||||
|
calibration,
|
||||||
|
target_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._overlay_active = True
|
self._overlay_active = True
|
||||||
@@ -548,6 +607,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
# Check if source is composite with multiple layers
|
# Check if source is composite with multiple layers
|
||||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||||
|
|
||||||
stream = self._css_stream
|
stream = self._css_stream
|
||||||
layer_colors = None
|
layer_colors = None
|
||||||
if isinstance(stream, CompositeColorStripStream):
|
if isinstance(stream, CompositeColorStripStream):
|
||||||
@@ -555,15 +615,16 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
if layer_colors and len(layer_colors) > 1:
|
if layer_colors and len(layer_colors) > 1:
|
||||||
led_count = len(colors)
|
led_count = len(colors)
|
||||||
header = bytes([brightness, 0xFE, len(layer_colors),
|
header = bytes(
|
||||||
(led_count >> 8) & 0xFF, led_count & 0xFF])
|
[brightness, 0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]
|
||||||
|
)
|
||||||
parts = [header]
|
parts = [header]
|
||||||
for lc in layer_colors:
|
for lc in layer_colors:
|
||||||
if len(lc) != led_count:
|
if len(lc) != led_count:
|
||||||
lc = self._fit_to_device(lc, led_count)
|
lc = self._fit_to_device(lc, led_count)
|
||||||
parts.append(lc.tobytes())
|
parts.append(lc.tobytes())
|
||||||
parts.append(colors.tobytes())
|
parts.append(colors.tobytes())
|
||||||
data = b''.join(parts)
|
data = b"".join(parts)
|
||||||
else:
|
else:
|
||||||
data = bytes([brightness]) + colors.tobytes()
|
data = bytes([brightness]) + colors.tobytes()
|
||||||
|
|
||||||
@@ -673,7 +734,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
prev_frame_time_stamp = time.perf_counter()
|
prev_frame_time_stamp = time.perf_counter()
|
||||||
asyncio.get_running_loop()
|
asyncio.get_running_loop()
|
||||||
_init_device_info = self._ctx.get_device_info(self._device_id)
|
_init_device_info = self._ctx.get_device_info(self._device_id)
|
||||||
_total_leds = getattr(self, '_effective_led_count', None) or (_init_device_info.led_count if _init_device_info else 0)
|
_total_leds = getattr(self, "_effective_led_count", None) or (
|
||||||
|
_init_device_info.led_count if _init_device_info else 0
|
||||||
|
)
|
||||||
|
|
||||||
# Stream reference — re-read each tick to detect hot-swaps
|
# Stream reference — re-read each tick to detect hot-swaps
|
||||||
stream = self._css_stream
|
stream = self._css_stream
|
||||||
@@ -695,19 +758,23 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
_bright_n = _dn
|
_bright_n = _dn
|
||||||
_bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
|
_bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
|
||||||
_bright_out = np.empty((_dn, 3), dtype=np.uint8)
|
_bright_out = np.empty((_dn, 3), dtype=np.uint8)
|
||||||
np.copyto(_bright_u16, colors_in, casting='unsafe')
|
np.copyto(_bright_u16, colors_in, casting="unsafe")
|
||||||
_bright_u16 *= brightness
|
_bright_u16 *= brightness
|
||||||
_bright_u16 >>= 8
|
_bright_u16 >>= 8
|
||||||
np.copyto(_bright_out, _bright_u16, casting='unsafe')
|
np.copyto(_bright_out, _bright_u16, casting="unsafe")
|
||||||
return _bright_out
|
return _bright_out
|
||||||
|
|
||||||
def _effective_brightness(dev_info):
|
def _effective_brightness(dev_info):
|
||||||
"""Compute effective brightness = software_brightness * value_stream."""
|
"""Compute effective brightness = software_brightness * brightness binding."""
|
||||||
base = dev_info.software_brightness if dev_info else 255
|
base = dev_info.software_brightness if dev_info else 255
|
||||||
vs = self._value_stream
|
vs = self._value_stream
|
||||||
if vs is not None:
|
if vs is not None:
|
||||||
vs_val = vs.get_value()
|
vs_val = vs.get_value()
|
||||||
return max(0, min(255, int(base * vs_val)))
|
return max(0, min(255, int(base * vs_val)))
|
||||||
|
# No value stream — use static value from BindableFloat
|
||||||
|
static_val = self._brightness.value
|
||||||
|
if static_val < 1.0:
|
||||||
|
return max(0, min(255, int(base * static_val)))
|
||||||
return base
|
return base
|
||||||
|
|
||||||
SKIP_REPOLL = 0.005 # 5 ms
|
SKIP_REPOLL = 0.005 # 5 ms
|
||||||
@@ -781,7 +848,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if self._effective_fps < target_fps:
|
if self._effective_fps < target_fps:
|
||||||
step = max(1, target_fps // 8)
|
step = max(1, target_fps // 8)
|
||||||
old_eff = self._effective_fps
|
old_eff = self._effective_fps
|
||||||
self._effective_fps = min(target_fps, self._effective_fps + step)
|
self._effective_fps = min(
|
||||||
|
target_fps, self._effective_fps + step
|
||||||
|
)
|
||||||
if old_eff != self._effective_fps:
|
if old_eff != self._effective_fps:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[ADAPTIVE] {self._target_id} device reachable, "
|
f"[ADAPTIVE] {self._target_id} device reachable, "
|
||||||
@@ -796,7 +865,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fire new probe every _probe_interval seconds
|
# Fire new probe every _probe_interval seconds
|
||||||
if _probe_enabled and _probe_task is None and (now - _last_probe_time) >= _probe_interval:
|
if (
|
||||||
|
_probe_enabled
|
||||||
|
and _probe_task is None
|
||||||
|
and (now - _last_probe_time) >= _probe_interval
|
||||||
|
):
|
||||||
if _probe_client is not None:
|
if _probe_client is not None:
|
||||||
_last_probe_time = now
|
_last_probe_time = now
|
||||||
_probe_task = asyncio.create_task(
|
_probe_task = asyncio.create_task(
|
||||||
@@ -850,7 +923,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if self._ctx.device_store:
|
if self._ctx.device_store:
|
||||||
try:
|
try:
|
||||||
_dev = self._ctx.device_store.get(self._device_id)
|
_dev = self._ctx.device_store.get(self._device_id)
|
||||||
_cur_cspt_id = getattr(_dev, "default_css_processing_template_id", "") or ""
|
_cur_cspt_id = (
|
||||||
|
getattr(_dev, "default_css_processing_template_id", "")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
_cur_cspt_id = ""
|
_cur_cspt_id = ""
|
||||||
if _cur_cspt_id != _cspt_cached_template_id:
|
if _cur_cspt_id != _cspt_cached_template_id:
|
||||||
@@ -858,21 +934,30 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
_cspt_filters = []
|
_cspt_filters = []
|
||||||
if _cur_cspt_id and self._ctx.cspt_store:
|
if _cur_cspt_id and self._ctx.cspt_store:
|
||||||
try:
|
try:
|
||||||
from wled_controller.core.filters.registry import FilterRegistry
|
from wled_controller.core.filters.registry import (
|
||||||
|
FilterRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
_resolved = self._ctx.cspt_store.resolve_filter_instances(
|
_resolved = self._ctx.cspt_store.resolve_filter_instances(
|
||||||
self._ctx.cspt_store.get_template(_cur_cspt_id).filters
|
self._ctx.cspt_store.get_template(_cur_cspt_id).filters
|
||||||
)
|
)
|
||||||
_cspt_filters = [
|
_cspt_filters = [
|
||||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
for fi in _resolved
|
for fi in _resolved
|
||||||
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
|
if getattr(
|
||||||
|
FilterRegistry.get(fi.filter_id),
|
||||||
|
"supports_strip",
|
||||||
|
True,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
logger.info(
|
logger.info(
|
||||||
f"CSPT resolved {len(_cspt_filters)} filters for "
|
f"CSPT resolved {len(_cspt_filters)} filters for "
|
||||||
f"device {self._device_id} template {_cur_cspt_id}"
|
f"device {self._device_id} template {_cur_cspt_id}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to resolve CSPT {_cur_cspt_id}: {e}")
|
logger.warning(
|
||||||
|
f"Failed to resolve CSPT {_cur_cspt_id}: {e}"
|
||||||
|
)
|
||||||
_cspt_filters = []
|
_cspt_filters = []
|
||||||
if _cspt_filters:
|
if _cspt_filters:
|
||||||
for _flt in _cspt_filters:
|
for _flt in _cspt_filters:
|
||||||
@@ -895,7 +980,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
# the last sent frame was also black, skip sending
|
# the last sent frame was also black, skip sending
|
||||||
# (but still send periodic keepalive to hold DDP live mode).
|
# (but still send periodic keepalive to hold DDP live mode).
|
||||||
if cur_brightness <= 1 and _prev_brightness <= 1 and has_any_frame:
|
if cur_brightness <= 1 and _prev_brightness <= 1 and has_any_frame:
|
||||||
if self._needs_keepalive and (loop_start - last_send_time) >= keepalive_interval:
|
if (
|
||||||
|
self._needs_keepalive
|
||||||
|
and (loop_start - last_send_time) >= keepalive_interval
|
||||||
|
):
|
||||||
if not self._is_running or self._led_client is None:
|
if not self._is_running or self._led_client is None:
|
||||||
break
|
break
|
||||||
send_colors = _cached_brightness(
|
send_colors = _cached_brightness(
|
||||||
@@ -907,7 +995,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(now)
|
send_timestamps.append(now)
|
||||||
self._metrics.frames_keepalive += 1
|
self._metrics.frames_keepalive += 1
|
||||||
if self._preview_clients and (now - _last_preview_broadcast) >= 0.066:
|
if (
|
||||||
|
self._preview_clients
|
||||||
|
and (now - _last_preview_broadcast) >= 0.066
|
||||||
|
):
|
||||||
await self._broadcast_led_preview(send_colors, cur_brightness)
|
await self._broadcast_led_preview(send_colors, cur_brightness)
|
||||||
_last_preview_broadcast = now
|
_last_preview_broadcast = now
|
||||||
self._metrics.frames_skipped += 1
|
self._metrics.frames_skipped += 1
|
||||||
@@ -916,7 +1007,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Force-send preview when a new client just connected
|
# Force-send preview when a new client just connected
|
||||||
if self._preview_force_send and self._preview_clients and prev_frame_ref is not None:
|
if (
|
||||||
|
self._preview_force_send
|
||||||
|
and self._preview_clients
|
||||||
|
and prev_frame_ref is not None
|
||||||
|
):
|
||||||
self._preview_force_send = False
|
self._preview_force_send = False
|
||||||
_force_colors = _cached_brightness(
|
_force_colors = _cached_brightness(
|
||||||
self._fit_to_device(prev_frame_ref, _total_leds),
|
self._fit_to_device(prev_frame_ref, _total_leds),
|
||||||
@@ -927,7 +1022,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
if frame is prev_frame_ref and cur_brightness == _prev_brightness:
|
if frame is prev_frame_ref and cur_brightness == _prev_brightness:
|
||||||
# Same frame + same brightness — keepalive or skip
|
# Same frame + same brightness — keepalive or skip
|
||||||
if self._needs_keepalive and has_any_frame and (loop_start - last_send_time) >= keepalive_interval:
|
if (
|
||||||
|
self._needs_keepalive
|
||||||
|
and has_any_frame
|
||||||
|
and (loop_start - last_send_time) >= keepalive_interval
|
||||||
|
):
|
||||||
if not self._is_running or self._led_client is None:
|
if not self._is_running or self._led_client is None:
|
||||||
break
|
break
|
||||||
send_colors = _cached_brightness(
|
send_colors = _cached_brightness(
|
||||||
@@ -939,7 +1038,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(now)
|
send_timestamps.append(now)
|
||||||
self._metrics.frames_keepalive += 1
|
self._metrics.frames_keepalive += 1
|
||||||
if self._preview_clients and (now - _last_preview_broadcast) >= 0.066:
|
if (
|
||||||
|
self._preview_clients
|
||||||
|
and (now - _last_preview_broadcast) >= 0.066
|
||||||
|
):
|
||||||
await self._broadcast_led_preview(send_colors, cur_brightness)
|
await self._broadcast_led_preview(send_colors, cur_brightness)
|
||||||
_last_preview_broadcast = now
|
_last_preview_broadcast = now
|
||||||
self._metrics.frames_skipped += 1
|
self._metrics.frames_skipped += 1
|
||||||
@@ -977,7 +1079,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._metrics.frames_processed += 1
|
self._metrics.frames_processed += 1
|
||||||
self._metrics.last_update_mono = time.monotonic()
|
self._metrics.last_update_mono = time.monotonic()
|
||||||
|
|
||||||
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
|
if (
|
||||||
|
self._metrics.frames_processed <= 3
|
||||||
|
or self._metrics.frames_processed % 100 == 0
|
||||||
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Frame {self._metrics.frames_processed} for {self._target_id} "
|
f"Frame {self._metrics.frames_processed} for {self._target_id} "
|
||||||
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
|
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
|
||||||
@@ -995,14 +1100,18 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._metrics.fps_actual = _fps_sum / len(fps_samples)
|
self._metrics.fps_actual = _fps_sum / len(fps_samples)
|
||||||
|
|
||||||
processing_time = now - loop_start
|
processing_time = now - loop_start
|
||||||
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
|
self._metrics.fps_potential = (
|
||||||
|
1.0 / processing_time if processing_time > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._metrics.errors_count += 1
|
self._metrics.errors_count += 1
|
||||||
self._metrics.last_error = str(e)
|
self._metrics.last_error = str(e)
|
||||||
logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"Processing error for target {self._target_id}: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
# Drift-compensating throttle
|
# Drift-compensating throttle
|
||||||
next_frame_time += frame_time
|
next_frame_time += frame_time
|
||||||
@@ -1016,7 +1125,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
jitter = actual_sleep - requested_sleep
|
jitter = actual_sleep - requested_sleep
|
||||||
_diag_sleep_jitters.append((requested_sleep, actual_sleep))
|
_diag_sleep_jitters.append((requested_sleep, actual_sleep))
|
||||||
if jitter > 10.0:
|
if jitter > 10.0:
|
||||||
_diag_slow_iters.append(((t_sleep_end - loop_start) * 1000, "sleep_jitter"))
|
_diag_slow_iters.append(
|
||||||
|
((t_sleep_end - loop_start) * 1000, "sleep_jitter")
|
||||||
|
)
|
||||||
elif sleep_time < -frame_time:
|
elif sleep_time < -frame_time:
|
||||||
next_frame_time = time.perf_counter()
|
next_frame_time = time.perf_counter()
|
||||||
|
|
||||||
@@ -1032,20 +1143,32 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if iter_end >= _diag_next_report:
|
if iter_end >= _diag_next_report:
|
||||||
_diag_next_report = iter_end + _diag_interval
|
_diag_next_report = iter_end + _diag_interval
|
||||||
self._emit_diagnostics(
|
self._emit_diagnostics(
|
||||||
self._target_id, _diag_sleep_jitters,
|
self._target_id,
|
||||||
_diag_iter_times, _diag_slow_iters,
|
_diag_sleep_jitters,
|
||||||
frame_time, _diag_interval,
|
_diag_iter_times,
|
||||||
|
_diag_slow_iters,
|
||||||
|
frame_time,
|
||||||
|
_diag_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info(f"Processing loop cancelled for target {self._target_id}")
|
logger.info(f"Processing loop cancelled for target {self._target_id}")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fatal error in processing loop for target {self._target_id}: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"Fatal error in processing loop for target {self._target_id}: {e}", exc_info=True
|
||||||
|
)
|
||||||
self._metrics.last_error = f"FATAL: {e}"
|
self._metrics.last_error = f"FATAL: {e}"
|
||||||
self._metrics.errors_count += 1
|
self._metrics.errors_count += 1
|
||||||
self._is_running = False
|
self._is_running = False
|
||||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False, "crashed": True})
|
self._ctx.fire_event(
|
||||||
|
{
|
||||||
|
"type": "state_change",
|
||||||
|
"target_id": self._target_id,
|
||||||
|
"processing": False,
|
||||||
|
"crashed": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Clean up probe client
|
# Clean up probe client
|
||||||
|
|||||||
@@ -32,13 +32,15 @@ def capture_current_snapshot(
|
|||||||
continue
|
continue
|
||||||
proc = processor_manager.get_processor(t.id)
|
proc = processor_manager.get_processor(t.id)
|
||||||
running = proc.is_running if proc else False
|
running = proc.is_running if proc else False
|
||||||
targets.append(TargetSnapshot(
|
targets.append(
|
||||||
target_id=t.id,
|
TargetSnapshot(
|
||||||
running=running,
|
target_id=t.id,
|
||||||
color_strip_source_id=getattr(t, "color_strip_source_id", ""),
|
running=running,
|
||||||
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
|
color_strip_source_id=getattr(t, "color_strip_source_id", ""),
|
||||||
fps=getattr(t, "fps", 30),
|
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
|
||||||
))
|
fps=getattr(t, "fps", 30),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
@@ -90,12 +92,16 @@ async def apply_scene_state(
|
|||||||
proc = processor_manager.get_processor(ts.target_id)
|
proc = processor_manager.get_processor(ts.target_id)
|
||||||
if proc and proc.is_running:
|
if proc and proc.is_running:
|
||||||
css_changed = "color_strip_source_id" in changed
|
css_changed = "color_strip_source_id" in changed
|
||||||
bvs_changed = "brightness_value_source_id" in changed
|
brightness_changed = "brightness" in changed
|
||||||
settings_changed = "fps" in changed
|
settings_changed = "fps" in changed
|
||||||
if css_changed:
|
if css_changed:
|
||||||
target.sync_with_manager(processor_manager, settings_changed=False, css_changed=True)
|
target.sync_with_manager(
|
||||||
if bvs_changed:
|
processor_manager, settings_changed=False, css_changed=True
|
||||||
target.sync_with_manager(processor_manager, settings_changed=False, brightness_vs_changed=True)
|
)
|
||||||
|
if brightness_changed:
|
||||||
|
target.sync_with_manager(
|
||||||
|
processor_manager, settings_changed=False, brightness_changed=True
|
||||||
|
)
|
||||||
if settings_changed:
|
if settings_changed:
|
||||||
target.sync_with_manager(processor_manager, settings_changed=True)
|
target.sync_with_manager(processor_manager, settings_changed=True)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -1109,3 +1109,69 @@ textarea:focus-visible {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── BindableScalarWidget ── */
|
||||||
|
|
||||||
|
.bindable-slider-row,
|
||||||
|
.bindable-vs-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-slider-row input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-vs-row select,
|
||||||
|
.bindable-vs-row .entity-select-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-value {
|
||||||
|
min-width: 3.5ch;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-toggle:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-toggle--active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-toggle--active:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bindable-toggle .icon-xs {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* BindableScalarWidget — a slider that can optionally bind to a value source.
|
||||||
|
*
|
||||||
|
* Renders a slider (range input) with a small toggle button. When toggled to
|
||||||
|
* "bound" mode, shows an EntitySelect value source picker instead of the slider.
|
||||||
|
* Emits a BindableFloat value: plain number (static) or {value, source_id} (bound).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const widget = new BindableScalarWidget({
|
||||||
|
* container: document.getElementById('my-container'),
|
||||||
|
* label: 'Smoothing',
|
||||||
|
* min: 0, max: 1, step: 0.05, default: 0.3,
|
||||||
|
* valueSources: () => cachedValueSources,
|
||||||
|
* onChange: (bf) => { … },
|
||||||
|
* });
|
||||||
|
* widget.setValue({ value: 0.3, source_id: 'vs_abc' });
|
||||||
|
* const bf = widget.getValue(); // BindableFloat
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BindableFloat } from '../types.ts';
|
||||||
|
import { bindableValue, bindableSourceId } from '../types.ts';
|
||||||
|
import { EntitySelect } from './entity-palette.ts';
|
||||||
|
import { getValueSourceIcon } from './icons.ts';
|
||||||
|
import { t } from './i18n.ts';
|
||||||
|
|
||||||
|
export interface BindableScalarOpts {
|
||||||
|
container: HTMLElement;
|
||||||
|
label?: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
default: number;
|
||||||
|
/** Format the display value (default: 2 decimal places) */
|
||||||
|
format?: (v: number) => string;
|
||||||
|
valueSources: () => Array<{ id: string; name: string; source_type: string }>;
|
||||||
|
onChange?: (value: BindableFloat) => void;
|
||||||
|
/** HTML id prefix for generated elements */
|
||||||
|
idPrefix?: string;
|
||||||
|
/** Label for the "no binding" option (default: generic "None (static value)") */
|
||||||
|
noneLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _widgetCounter = 0;
|
||||||
|
|
||||||
|
export class BindableScalarWidget {
|
||||||
|
private _container: HTMLElement;
|
||||||
|
private _opts: BindableScalarOpts;
|
||||||
|
private _id: string;
|
||||||
|
private _bound: boolean = false;
|
||||||
|
private _staticValue: number;
|
||||||
|
private _sourceId: string = '';
|
||||||
|
private _entitySelect: EntitySelect | null = null;
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
private _sliderRow!: HTMLElement;
|
||||||
|
private _vsRow!: HTMLElement;
|
||||||
|
private _slider!: HTMLInputElement;
|
||||||
|
private _display!: HTMLElement;
|
||||||
|
private _toggleBtn!: HTMLButtonElement;
|
||||||
|
private _select!: HTMLSelectElement;
|
||||||
|
|
||||||
|
constructor(opts: BindableScalarOpts) {
|
||||||
|
this._opts = opts;
|
||||||
|
this._container = opts.container;
|
||||||
|
this._staticValue = opts.default;
|
||||||
|
this._id = opts.idPrefix || `bsw-${++_widgetCounter}`;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _format(v: number): string {
|
||||||
|
return this._opts.format ? this._opts.format(v) : v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _render(): void {
|
||||||
|
const { min, max, step } = this._opts;
|
||||||
|
const id = this._id;
|
||||||
|
|
||||||
|
// Toggle button (link icon for binding)
|
||||||
|
const toggleHtml = `<button type="button" class="bindable-toggle" id="${id}-toggle" title="${t('bindable.toggle')}" aria-label="Toggle value source binding">
|
||||||
|
<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
// Slider row (static mode)
|
||||||
|
const sliderHtml = `<div class="bindable-slider-row" id="${id}-slider-row">
|
||||||
|
<input type="range" id="${id}-slider" min="${min}" max="${max}" step="${step}" value="${this._staticValue}">
|
||||||
|
<span class="bindable-value" id="${id}-display">${this._format(this._staticValue)}</span>
|
||||||
|
${toggleHtml}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// VS picker row (bound mode)
|
||||||
|
const vsHtml = `<div class="bindable-vs-row" id="${id}-vs-row" style="display:none">
|
||||||
|
<select id="${id}-select"></select>
|
||||||
|
<button type="button" class="bindable-toggle bindable-toggle--active" id="${id}-untoggle" title="${t('bindable.toggle')}" aria-label="Switch to static value">
|
||||||
|
<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
this._container.innerHTML = sliderHtml + vsHtml;
|
||||||
|
|
||||||
|
// Cache DOM refs
|
||||||
|
this._sliderRow = document.getElementById(`${id}-slider-row`)!;
|
||||||
|
this._vsRow = document.getElementById(`${id}-vs-row`)!;
|
||||||
|
this._slider = document.getElementById(`${id}-slider`) as HTMLInputElement;
|
||||||
|
this._display = document.getElementById(`${id}-display`)!;
|
||||||
|
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
|
||||||
|
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
|
||||||
|
|
||||||
|
// Slider input handler
|
||||||
|
this._slider.addEventListener('input', () => {
|
||||||
|
this._staticValue = parseFloat(this._slider.value);
|
||||||
|
this._display.textContent = this._format(this._staticValue);
|
||||||
|
this._fireChange();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle to bound mode
|
||||||
|
this._toggleBtn.addEventListener('click', () => this._setMode(true));
|
||||||
|
document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setMode(bound: boolean): void {
|
||||||
|
this._bound = bound;
|
||||||
|
this._sliderRow.style.display = bound ? 'none' : '';
|
||||||
|
this._vsRow.style.display = bound ? '' : 'none';
|
||||||
|
|
||||||
|
if (bound) {
|
||||||
|
this._populateVsSelect();
|
||||||
|
} else {
|
||||||
|
this._sourceId = '';
|
||||||
|
if (this._entitySelect) {
|
||||||
|
this._entitySelect.destroy();
|
||||||
|
this._entitySelect = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._fireChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _populateVsSelect(): void {
|
||||||
|
const sources = this._opts.valueSources();
|
||||||
|
const id = this._id;
|
||||||
|
|
||||||
|
this._select.innerHTML = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
|
||||||
|
sources.map(vs =>
|
||||||
|
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Wrap with EntitySelect
|
||||||
|
if (this._entitySelect) this._entitySelect.destroy();
|
||||||
|
this._entitySelect = new EntitySelect({
|
||||||
|
target: this._select,
|
||||||
|
getItems: () => sources.map(vs => ({
|
||||||
|
value: vs.id,
|
||||||
|
label: vs.name,
|
||||||
|
icon: getValueSourceIcon(vs.source_type),
|
||||||
|
desc: vs.source_type,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
allowNone: true,
|
||||||
|
noneLabel: this._opts.noneLabel || t('bindable.none'),
|
||||||
|
onChange: (value: string) => {
|
||||||
|
this._sourceId = value;
|
||||||
|
this._fireChange();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _fireChange(): void {
|
||||||
|
if (this._opts.onChange) {
|
||||||
|
this._opts.onChange(this.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──
|
||||||
|
|
||||||
|
getValue(): BindableFloat {
|
||||||
|
if (this._bound && this._sourceId) {
|
||||||
|
return { value: this._staticValue, source_id: this._sourceId };
|
||||||
|
}
|
||||||
|
return this._staticValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(bf: BindableFloat | undefined): void {
|
||||||
|
this._staticValue = bindableValue(bf, this._opts.default);
|
||||||
|
this._sourceId = bindableSourceId(bf);
|
||||||
|
this._bound = !!this._sourceId;
|
||||||
|
|
||||||
|
this._slider.value = String(this._staticValue);
|
||||||
|
this._display.textContent = this._format(this._staticValue);
|
||||||
|
|
||||||
|
this._sliderRow.style.display = this._bound ? 'none' : '';
|
||||||
|
this._vsRow.style.display = this._bound ? '' : 'none';
|
||||||
|
|
||||||
|
if (this._bound) {
|
||||||
|
this._populateVsSelect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh the VS dropdown after cache updates. */
|
||||||
|
refresh(): void {
|
||||||
|
if (this._bound) {
|
||||||
|
this._populateVsSelect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this._entitySelect) {
|
||||||
|
this._entitySelect.destroy();
|
||||||
|
this._entitySelect = null;
|
||||||
|
}
|
||||||
|
this._container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,20 +57,34 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
|||||||
// Output targets
|
// Output targets
|
||||||
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||||
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||||
{ targetKind: 'output_target', field: 'brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true },
|
||||||
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||||
|
|
||||||
// Automations
|
// Automations
|
||||||
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||||
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||||
|
|
||||||
|
// ── BindableFloat value source edges (CSS properties) ──
|
||||||
|
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
// HA light target transition binding
|
||||||
|
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
|
|
||||||
// ── Nested fields (not drag-editable in V1) ──
|
// ── Nested fields (not drag-editable in V1) ──
|
||||||
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
||||||
{ targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
{ targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
{ targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
{ targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
||||||
{ targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true },
|
{ targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true },
|
||||||
{ targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true },
|
{ targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true },
|
||||||
{ targetKind: 'output_target', field: 'settings.brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
{ targetKind: 'output_target', field: 'settings.brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||||
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
|
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import ELK from 'elkjs/lib/elk.bundled.js';
|
import ELK from 'elkjs/lib/elk.bundled.js';
|
||||||
|
import { bindableSourceId } from '../types.ts';
|
||||||
|
|
||||||
/* ── Types ────────────────────────────────────────────────────── */
|
/* ── Types ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -351,18 +352,29 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
|||||||
if (line.picture_source_id) addEdge(line.picture_source_id, s.id, 'calibration.picture_source_id');
|
if (line.picture_source_id) addEdge(line.picture_source_id, s.id, 'calibration.picture_source_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindableFloat value source edges
|
||||||
|
for (const prop of ['smoothing', 'sensitivity', 'intensity', 'scale', 'speed',
|
||||||
|
'wind_strength', 'temperature_influence', 'sound_volume', 'timeout', 'brightness'] as const) {
|
||||||
|
const vsId = bindableSourceId((s as any)[prop]);
|
||||||
|
if (vsId) addEdge(vsId, s.id, `${prop}.source_id`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output target edges
|
// Output target edges
|
||||||
for (const t of e.outputTargets || []) {
|
for (const t of e.outputTargets || []) {
|
||||||
if (t.device_id) addEdge(t.device_id, t.id, 'device_id');
|
if (t.device_id) addEdge(t.device_id, t.id, 'device_id');
|
||||||
if (t.color_strip_source_id) addEdge(t.color_strip_source_id, t.id, 'color_strip_source_id');
|
if (t.color_strip_source_id) addEdge(t.color_strip_source_id, t.id, 'color_strip_source_id');
|
||||||
if (t.brightness_value_source_id) addEdge(t.brightness_value_source_id, t.id, 'brightness_value_source_id');
|
const bvsId = bindableSourceId(t.brightness);
|
||||||
|
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
|
||||||
|
const transVsId = bindableSourceId(t.transition);
|
||||||
|
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
|
||||||
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
|
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
|
||||||
// KC target settings
|
// KC target settings
|
||||||
if (t.settings) {
|
if (t.settings) {
|
||||||
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
||||||
if (t.settings.brightness_value_source_id) addEdge(t.settings.brightness_value_source_id, t.id, 'settings.brightness_value_source_id');
|
const settingsBvsId = bindableSourceId(t.settings?.brightness);
|
||||||
|
if (settingsBvsId) addEdge(settingsBvsId, t.id, 'settings.brightness.source_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,3 +91,9 @@ export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0
|
|||||||
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
|
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
|
||||||
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
|
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
|
||||||
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
|
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
|
||||||
|
export const check = '<path d="M20 6 9 17l-5-5"/>';
|
||||||
|
export const code = '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>';
|
||||||
|
export const doorOpen = '<path d="M13 4h3a2 2 0 0 1 2 2v14"/><path d="M2 20h3"/><path d="M13 20h9"/><path d="M10 12v.01"/><path d="M13 4.562v16.157a1 1 0 0 1-1.242.97L5 20V5.562a2 2 0 0 1 1.515-1.94l4-1A2 2 0 0 1 13 4.561Z"/>';
|
||||||
|
export const toggleRight = '<rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/>';
|
||||||
|
export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z"/><path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"/>';
|
||||||
|
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
|
||||||
|
|||||||
@@ -91,6 +91,139 @@ export function getAudioEngineIcon(engineType: string): string {
|
|||||||
return _audioEngineTypeIcons[engineType] || _svg(P.music);
|
return _audioEngineTypeIcons[engineType] || _svg(P.music);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MDI → Lucide icon mapping (for Home Assistant entities) ──
|
||||||
|
|
||||||
|
const _mdiMap: Record<string, string> = {
|
||||||
|
'mdi:lightbulb': P.lightbulb,
|
||||||
|
'mdi:lightbulb-group': P.lightbulb,
|
||||||
|
'mdi:lightbulb-outline': P.lightbulb,
|
||||||
|
'mdi:led-strip': P.lightbulb,
|
||||||
|
'mdi:led-strip-variant': P.lightbulb,
|
||||||
|
'mdi:lamp': P.lightbulb,
|
||||||
|
'mdi:ceiling-light': P.lightbulb,
|
||||||
|
'mdi:floor-lamp': P.lightbulb,
|
||||||
|
'mdi:desk-lamp': P.lightbulb,
|
||||||
|
'mdi:wall-sconce': P.lightbulb,
|
||||||
|
'mdi:thermometer': P.thermometer,
|
||||||
|
'mdi:temperature-celsius': P.thermometer,
|
||||||
|
'mdi:temperature-fahrenheit': P.thermometer,
|
||||||
|
'mdi:water-thermometer': P.thermometer,
|
||||||
|
'mdi:home-thermometer': P.thermometer,
|
||||||
|
'mdi:monitor': P.monitor,
|
||||||
|
'mdi:television': P.tv,
|
||||||
|
'mdi:sun-wireless': P.sun,
|
||||||
|
'mdi:weather-sunny': P.sun,
|
||||||
|
'mdi:weather-night': P.moon,
|
||||||
|
'mdi:moon-waning-crescent': P.moon,
|
||||||
|
'mdi:lock': P.lock,
|
||||||
|
'mdi:lock-open': P.lock,
|
||||||
|
'mdi:wifi': P.wifi,
|
||||||
|
'mdi:fire': P.flame,
|
||||||
|
'mdi:power': P.power,
|
||||||
|
'mdi:power-plug': P.plug,
|
||||||
|
'mdi:eye': P.eye,
|
||||||
|
'mdi:home': P.home,
|
||||||
|
'mdi:home-assistant': P.home,
|
||||||
|
'mdi:music': P.music,
|
||||||
|
'mdi:music-note': P.music,
|
||||||
|
'mdi:camera': P.camera,
|
||||||
|
'mdi:flash': P.zap,
|
||||||
|
'mdi:flash-alert': P.zap,
|
||||||
|
'mdi:bell': P.bellRing,
|
||||||
|
'mdi:bell-ring': P.bellRing,
|
||||||
|
'mdi:earth': P.globe,
|
||||||
|
'mdi:web': P.globe,
|
||||||
|
'mdi:clock': P.clock,
|
||||||
|
'mdi:clock-outline': P.clock,
|
||||||
|
'mdi:timer': P.timer,
|
||||||
|
'mdi:timer-outline': P.timer,
|
||||||
|
'mdi:heart': P.heart,
|
||||||
|
'mdi:heart-pulse': P.heart,
|
||||||
|
'mdi:motion-sensor': P.activity,
|
||||||
|
'mdi:run': P.activity,
|
||||||
|
'mdi:walk': P.activity,
|
||||||
|
'mdi:door': P.doorOpen,
|
||||||
|
'mdi:door-open': P.doorOpen,
|
||||||
|
'mdi:door-closed': P.doorOpen,
|
||||||
|
'mdi:window-open': P.doorOpen,
|
||||||
|
'mdi:window-closed': P.doorOpen,
|
||||||
|
'mdi:toggle-switch': P.toggleRight,
|
||||||
|
'mdi:toggle-switch-off': P.toggleRight,
|
||||||
|
'mdi:water': P.droplets,
|
||||||
|
'mdi:water-percent': P.droplets,
|
||||||
|
'mdi:humidity': P.droplets,
|
||||||
|
'mdi:fan': P.fan,
|
||||||
|
'mdi:fan-speed-1': P.fan,
|
||||||
|
'mdi:fan-speed-2': P.fan,
|
||||||
|
'mdi:fan-speed-3': P.fan,
|
||||||
|
'mdi:star': P.star,
|
||||||
|
'mdi:battery': P.zap,
|
||||||
|
'mdi:battery-charging': P.zap,
|
||||||
|
'mdi:gauge': P.activity,
|
||||||
|
'mdi:speedometer': P.activity,
|
||||||
|
'mdi:robot-vacuum': P.settings,
|
||||||
|
'mdi:cog': P.settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map an MDI icon name (from HA entity) to a Lucide SVG icon. */
|
||||||
|
export function getMdiIcon(mdiName: string): string {
|
||||||
|
if (!mdiName) return _svg(P.listChecks);
|
||||||
|
const path = _mdiMap[mdiName];
|
||||||
|
if (path) return _svg(path);
|
||||||
|
// Fallback: try keyword matching
|
||||||
|
if (mdiName.includes('light')) return _svg(P.lightbulb);
|
||||||
|
if (mdiName.includes('sensor')) return _svg(P.activity);
|
||||||
|
if (mdiName.includes('switch')) return _svg(P.toggleRight);
|
||||||
|
if (mdiName.includes('door') || mdiName.includes('window')) return _svg(P.doorOpen);
|
||||||
|
return _svg(P.listChecks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map HA entity domain to an icon (fallback when no MDI icon is available). */
|
||||||
|
const _domainIcons: Record<string, string> = {
|
||||||
|
light: P.lightbulb,
|
||||||
|
switch: P.toggleRight,
|
||||||
|
sensor: P.activity,
|
||||||
|
binary_sensor: P.toggleRight,
|
||||||
|
climate: P.thermometer,
|
||||||
|
fan: P.fan,
|
||||||
|
cover: P.doorOpen,
|
||||||
|
lock: P.lock,
|
||||||
|
camera: P.camera,
|
||||||
|
media_player: P.music,
|
||||||
|
automation: P.refreshCw,
|
||||||
|
script: P.play,
|
||||||
|
scene: P.star,
|
||||||
|
input_boolean: P.toggleRight,
|
||||||
|
input_number: P.slidersHorizontal,
|
||||||
|
input_select: P.listChecks,
|
||||||
|
input_text: P.fileText,
|
||||||
|
timer: P.timer,
|
||||||
|
counter: P.hash,
|
||||||
|
person: P.smartphone,
|
||||||
|
device_tracker: P.mapPin,
|
||||||
|
zone: P.mapPin,
|
||||||
|
sun: P.sun,
|
||||||
|
weather: P.cloudSun,
|
||||||
|
update: P.download,
|
||||||
|
button: P.power,
|
||||||
|
number: P.slidersHorizontal,
|
||||||
|
select: P.listChecks,
|
||||||
|
text: P.fileText,
|
||||||
|
alarm_control_panel: P.bellRing,
|
||||||
|
water_heater: P.droplets,
|
||||||
|
vacuum: P.settings,
|
||||||
|
humidifier: P.droplets,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getHAEntityIcon(entity: { icon?: string; domain?: string }): string {
|
||||||
|
// Prefer explicit MDI icon if set
|
||||||
|
if (entity.icon) return getMdiIcon(entity.icon);
|
||||||
|
// Fall back to domain-based icon
|
||||||
|
const domainPath = _domainIcons[entity.domain || ''];
|
||||||
|
if (domainPath) return _svg(domainPath);
|
||||||
|
return _svg(P.listChecks);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Entity-kind constants ───────────────────────────────────
|
// ── Entity-kind constants ───────────────────────────────────
|
||||||
|
|
||||||
export const ICON_AUTOMATION = _svg(P.clipboardList);
|
export const ICON_AUTOMATION = _svg(P.clipboardList);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class AudioSourceModal extends Modal {
|
|||||||
|
|
||||||
onForceClose() {
|
onForceClose() {
|
||||||
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
||||||
|
if (_asChannelIconSelect) { _asChannelIconSelect.destroy(); _asChannelIconSelect = null; }
|
||||||
|
if (_asBandIconSelect) { _asBandIconSelect.destroy(); _asBandIconSelect = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotValues() {
|
snapshotValues() {
|
||||||
@@ -58,6 +60,7 @@ let _asDeviceEntitySelect: EntitySelect | null = null;
|
|||||||
let _asParentEntitySelect: EntitySelect | null = null;
|
let _asParentEntitySelect: EntitySelect | null = null;
|
||||||
let _asBandParentEntitySelect: EntitySelect | null = null;
|
let _asBandParentEntitySelect: EntitySelect | null = null;
|
||||||
let _asBandIconSelect: IconSelect | null = null;
|
let _asBandIconSelect: IconSelect | null = null;
|
||||||
|
let _asChannelIconSelect: IconSelect | null = null;
|
||||||
|
|
||||||
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
|
|||||||
} else if (editData.source_type === 'mono') {
|
} else if (editData.source_type === 'mono') {
|
||||||
_loadMultichannelSources(editData.audio_source_id);
|
_loadMultichannelSources(editData.audio_source_id);
|
||||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
|
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
|
||||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
|
_ensureChannelIconSelect();
|
||||||
} else if (editData.source_type === 'band_extract') {
|
} else if (editData.source_type === 'band_extract') {
|
||||||
_loadBandParentSources(editData.audio_source_id);
|
_loadBandParentSources(editData.audio_source_id);
|
||||||
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
|
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
|
||||||
@@ -155,7 +158,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
|
|||||||
await _loadAudioDevices();
|
await _loadAudioDevices();
|
||||||
} else if (sourceType === 'mono') {
|
} else if (sourceType === 'mono') {
|
||||||
_loadMultichannelSources();
|
_loadMultichannelSources();
|
||||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
|
_ensureChannelIconSelect();
|
||||||
} else if (sourceType === 'band_extract') {
|
} else if (sourceType === 'band_extract') {
|
||||||
_loadBandParentSources();
|
_loadBandParentSources();
|
||||||
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
|
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
|
||||||
@@ -426,6 +429,28 @@ function _ensureBandIconSelect() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
|
function _ensureChannelIconSelect() {
|
||||||
|
const sel = document.getElementById('audio-source-channel') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
const items = [
|
||||||
|
{ value: 'mono', icon: _icon(P.headphones), label: t('audio_source.channel.mono'), desc: t('audio_source.channel.mono.desc') },
|
||||||
|
{ value: 'left', icon: _icon(P.volume2), label: t('audio_source.channel.left'), desc: t('audio_source.channel.left.desc') },
|
||||||
|
{ value: 'right', icon: _icon(P.volume2), label: t('audio_source.channel.right'), desc: t('audio_source.channel.right.desc') },
|
||||||
|
];
|
||||||
|
if (_asChannelIconSelect) {
|
||||||
|
_asChannelIconSelect.updateItems(items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_asChannelIconSelect = new IconSelect({
|
||||||
|
target: sel,
|
||||||
|
items,
|
||||||
|
columns: 3,
|
||||||
|
onChange: () => _autoGenerateAudioSourceName(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function _loadBandParentSources(selectedId?: any) {
|
function _loadBandParentSources(selectedId?: any) {
|
||||||
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
|
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
|
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
|
||||||
|
import { getHAEntityIcon } from '../core/icons.ts';
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||||
@@ -21,6 +22,33 @@ import { TreeNav } from '../core/tree-nav.ts';
|
|||||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
import type { Automation } from '../types.ts';
|
import type { Automation } from '../types.ts';
|
||||||
|
|
||||||
|
// ── HA condition entity cache ──
|
||||||
|
let _haConditionEntities: any[] = [];
|
||||||
|
|
||||||
|
async function _loadHAEntitiesForCondition(haSourceId: string, container: HTMLElement): Promise<void> {
|
||||||
|
if (!haSourceId) { _haConditionEntities = []; return; }
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||||
|
if (!resp.ok) { _haConditionEntities = []; return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
_haConditionEntities = data.entities || [];
|
||||||
|
} catch {
|
||||||
|
_haConditionEntities = [];
|
||||||
|
}
|
||||||
|
// Rebuild entity select options
|
||||||
|
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
|
||||||
|
if (entitySelect) {
|
||||||
|
const currentVal = entitySelect.value;
|
||||||
|
entitySelect.innerHTML = `<option value="">—</option>` +
|
||||||
|
_haConditionEntities.map((e: any) =>
|
||||||
|
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
|
||||||
|
).join('');
|
||||||
|
if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) {
|
||||||
|
entitySelect.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _automationTagsInput: any = null;
|
let _automationTagsInput: any = null;
|
||||||
|
|
||||||
// ── Auto-name ──
|
// ── Auto-name ──
|
||||||
@@ -732,7 +760,6 @@ function addAutomationConditionRow(condition: any) {
|
|||||||
const entityId = data.entity_id || '';
|
const entityId = data.entity_id || '';
|
||||||
const haState = data.state || '';
|
const haState = data.state || '';
|
||||||
const matchMode = data.match_mode || 'exact';
|
const matchMode = data.match_mode || 'exact';
|
||||||
// Build HA source options from cached data
|
|
||||||
const haOptions = _cachedHASources.map((s: any) =>
|
const haOptions = _cachedHASources.map((s: any) =>
|
||||||
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
@@ -748,7 +775,9 @@ function addAutomationConditionRow(condition: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="condition-field">
|
<div class="condition-field">
|
||||||
<label>${t('automations.condition.home_assistant.entity_id')}</label>
|
<label>${t('automations.condition.home_assistant.entity_id')}</label>
|
||||||
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
|
<select class="condition-ha-entity-id">
|
||||||
|
${entityId ? `<option value="${escapeHtml(entityId)}" selected>${escapeHtml(entityId)}</option>` : '<option value="">—</option>'}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="condition-field">
|
<div class="condition-field">
|
||||||
<label>${t('automations.condition.home_assistant.state')}</label>
|
<label>${t('automations.condition.home_assistant.state')}</label>
|
||||||
@@ -763,6 +792,45 @@ function addAutomationConditionRow(condition: any) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// Wire HA source EntitySelect
|
||||||
|
const haSrcSelect = container.querySelector('.condition-ha-source-id') as HTMLSelectElement;
|
||||||
|
new EntitySelect({
|
||||||
|
target: haSrcSelect,
|
||||||
|
getItems: () => _cachedHASources.map((s: any) => ({
|
||||||
|
value: s.id, label: s.name, icon: _icon(P.home),
|
||||||
|
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
onChange: (newId: string) => _loadHAEntitiesForCondition(newId, container),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire entity EntitySelect
|
||||||
|
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
|
||||||
|
const entityES = new EntitySelect({
|
||||||
|
target: entitySelect,
|
||||||
|
getItems: () => _haConditionEntities.map((e: any) => ({
|
||||||
|
value: e.entity_id, label: e.friendly_name || e.entity_id,
|
||||||
|
icon: getHAEntityIcon(e), desc: e.state || '',
|
||||||
|
})),
|
||||||
|
placeholder: t('ha_light.mapping.search_entity'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire match mode IconSelect
|
||||||
|
const matchSelect = container.querySelector('.condition-ha-match-mode') as HTMLSelectElement;
|
||||||
|
new IconSelect({
|
||||||
|
target: matchSelect,
|
||||||
|
items: [
|
||||||
|
{ value: 'exact', icon: _icon(P.check), label: t('automations.condition.mqtt.match_mode.exact'), desc: t('automations.condition.ha.match_mode.exact.desc') },
|
||||||
|
{ value: 'contains', icon: _icon(P.search), label: t('automations.condition.mqtt.match_mode.contains'), desc: t('automations.condition.ha.match_mode.contains.desc') },
|
||||||
|
{ value: 'regex', icon: _icon(P.code), label: t('automations.condition.mqtt.match_mode.regex'), desc: t('automations.condition.ha.match_mode.regex.desc') },
|
||||||
|
],
|
||||||
|
columns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load entities if source is already selected
|
||||||
|
if (haSourceId) _loadHAEntitiesForCondition(haSourceId, container);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'webhook') {
|
if (type === 'webhook') {
|
||||||
@@ -878,7 +946,7 @@ function getAutomationEditorConditions() {
|
|||||||
conditions.push({
|
conditions.push({
|
||||||
condition_type: 'home_assistant',
|
condition_type: 'home_assistant',
|
||||||
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
|
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
|
||||||
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(),
|
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLSelectElement).value.trim(),
|
||||||
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
|
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
|
||||||
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
|
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||||
|
|
||||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ let _compositeSourceEntitySelects: any[] = [];
|
|||||||
let _compositeBrightnessEntitySelects: any[] = [];
|
let _compositeBrightnessEntitySelects: any[] = [];
|
||||||
let _compositeBlendIconSelects: any[] = [];
|
let _compositeBlendIconSelects: any[] = [];
|
||||||
let _compositeCSPTEntitySelects: any[] = [];
|
let _compositeCSPTEntitySelects: any[] = [];
|
||||||
|
let _compositeOpacityWidgets: BindableScalarWidget[] = [];
|
||||||
|
|
||||||
/** Return current composite layers array (for dirty-check snapshot). */
|
/** Return current composite layers array (for dirty-check snapshot). */
|
||||||
export function compositeGetRawLayers() {
|
export function compositeGetRawLayers() {
|
||||||
@@ -47,6 +49,8 @@ export function compositeDestroyEntitySelects() {
|
|||||||
_compositeBlendIconSelects = [];
|
_compositeBlendIconSelects = [];
|
||||||
_compositeCSPTEntitySelects.forEach(es => es.destroy());
|
_compositeCSPTEntitySelects.forEach(es => es.destroy());
|
||||||
_compositeCSPTEntitySelects = [];
|
_compositeCSPTEntitySelects = [];
|
||||||
|
_compositeOpacityWidgets.forEach(w => w.destroy());
|
||||||
|
_compositeOpacityWidgets = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getCompositeBlendItems() {
|
function _getCompositeBlendItems() {
|
||||||
@@ -140,10 +144,8 @@ export function compositeRenderList() {
|
|||||||
<div class="composite-layer-row">
|
<div class="composite-layer-row">
|
||||||
<label class="composite-layer-opacity-label">
|
<label class="composite-layer-opacity-label">
|
||||||
<span>${t('color_strip.composite.opacity')}:</span>
|
<span>${t('color_strip.composite.opacity')}:</span>
|
||||||
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input type="range" class="composite-layer-opacity" data-idx="${i}"
|
<div class="composite-layer-opacity-container" data-idx="${i}"></div>
|
||||||
min="0" max="1" step="0.05" value="${layer.opacity}">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="composite-layer-row">
|
<div class="composite-layer-row">
|
||||||
<label class="composite-layer-brightness-label">
|
<label class="composite-layer-brightness-label">
|
||||||
@@ -229,14 +231,6 @@ export function compositeRenderList() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wire up live opacity display
|
|
||||||
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
|
|
||||||
el.addEventListener('input', () => {
|
|
||||||
const val = parseFloat(el.value);
|
|
||||||
(el.closest('.composite-layer-row')!.querySelector('.composite-opacity-val') as HTMLElement).textContent = val.toFixed(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach IconSelect to each layer's blend mode dropdown
|
// Attach IconSelect to each layer's blend mode dropdown
|
||||||
const blendItems = _getCompositeBlendItems();
|
const blendItems = _getCompositeBlendItems();
|
||||||
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
|
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
|
||||||
@@ -275,6 +269,19 @@ export function compositeRenderList() {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create BindableScalarWidget for each layer's opacity
|
||||||
|
list.querySelectorAll<HTMLElement>('.composite-layer-opacity-container').forEach((container, i) => {
|
||||||
|
const widget = new BindableScalarWidget({
|
||||||
|
container,
|
||||||
|
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||||
|
idPrefix: `composite-opacity-${i}`,
|
||||||
|
format: (v) => v.toFixed(2),
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
});
|
||||||
|
widget.setValue(_compositeLayers[i]?.opacity ?? 1.0);
|
||||||
|
_compositeOpacityWidgets.push(widget);
|
||||||
|
});
|
||||||
|
|
||||||
_initCompositeLayerDrag(list);
|
_initCompositeLayerDrag(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +313,6 @@ function _compositeLayersSyncFromDom() {
|
|||||||
if (!list) return;
|
if (!list) return;
|
||||||
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
|
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
|
||||||
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
|
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
|
||||||
const opacities = list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity');
|
|
||||||
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
|
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
|
||||||
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
|
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
|
||||||
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
|
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
|
||||||
@@ -317,7 +323,7 @@ function _compositeLayersSyncFromDom() {
|
|||||||
for (let i = 0; i < srcs.length; i++) {
|
for (let i = 0; i < srcs.length; i++) {
|
||||||
_compositeLayers[i].source_id = srcs[i].value;
|
_compositeLayers[i].source_id = srcs[i].value;
|
||||||
_compositeLayers[i].blend_mode = blends[i].value;
|
_compositeLayers[i].blend_mode = blends[i].value;
|
||||||
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
|
_compositeLayers[i].opacity = _compositeOpacityWidgets[i]?.getValue() ?? _compositeLayers[i].opacity;
|
||||||
_compositeLayers[i].enabled = enableds[i].checked;
|
_compositeLayers[i].enabled = enableds[i].checked;
|
||||||
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
|
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
|
||||||
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
|
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import * as P from '../core/icon-paths.ts';
|
|||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
||||||
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
|
||||||
import { getBaseOrigin } from './settings.ts';
|
import { getBaseOrigin } from './settings.ts';
|
||||||
|
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||||
|
|
||||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
@@ -36,6 +37,32 @@ export function notificationGetRawAppOverrides() {
|
|||||||
|
|
||||||
let _notificationEffectIconSelect: any = null;
|
let _notificationEffectIconSelect: any = null;
|
||||||
let _notificationFilterModeIconSelect: any = null;
|
let _notificationFilterModeIconSelect: any = null;
|
||||||
|
let _notificationDurationWidget: BindableScalarWidget | null = null;
|
||||||
|
|
||||||
|
function _ensureNotificationDurationWidget(): BindableScalarWidget {
|
||||||
|
if (!_notificationDurationWidget) {
|
||||||
|
_notificationDurationWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-notification-duration-container')!,
|
||||||
|
min: 100, max: 5000, step: 100, default: 1500,
|
||||||
|
idPrefix: 'css-editor-notification-duration',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => String(Math.round(v)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _notificationDurationWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyNotificationDurationWidget(): void {
|
||||||
|
if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationDurationValue(): number | { value: number; source_id: string } {
|
||||||
|
return _notificationDurationWidget ? _notificationDurationWidget.getValue() : 1500;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationDurationSnapshot(): string {
|
||||||
|
return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500';
|
||||||
|
}
|
||||||
|
|
||||||
export function ensureNotificationEffectIconSelect() {
|
export function ensureNotificationEffectIconSelect() {
|
||||||
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
|
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
|
||||||
@@ -346,9 +373,7 @@ export async function loadNotificationState(css: any) {
|
|||||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
|
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
|
||||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
|
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
|
||||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
||||||
const dur = css.duration_ms ?? 1500;
|
_ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500);
|
||||||
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = dur;
|
|
||||||
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = dur;
|
|
||||||
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
|
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
|
||||||
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
|
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
|
||||||
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
|
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
|
||||||
@@ -380,8 +405,7 @@ export async function resetNotificationState() {
|
|||||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
|
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
|
||||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
||||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
||||||
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any;
|
_ensureNotificationDurationWidget().setValue(1500);
|
||||||
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
|
|
||||||
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
|
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
|
||||||
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
|
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
|
||||||
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
|
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
||||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
||||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts';
|
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue } from './color-strips-notification.ts';
|
||||||
|
|
||||||
/* ── Preview config builder ───────────────────────────────────── */
|
/* ── Preview config builder ───────────────────────────────────── */
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ function _collectPreviewConfig() {
|
|||||||
config = {
|
config = {
|
||||||
source_type: 'notification',
|
source_type: 'notification',
|
||||||
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
||||||
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
|
duration_ms: getNotificationDurationValue(),
|
||||||
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||||
app_filter_list: filterList,
|
app_filter_list: filterList,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
|
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { wrapCard } from '../core/card-colors.ts';
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
import type { ColorStripSource } from '../types.ts';
|
import type { ColorStripSource } from '../types.ts';
|
||||||
|
import { bindableValue } from '../types.ts';
|
||||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||||
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
|
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||||
import { getBaseOrigin } from './settings.ts';
|
import { getBaseOrigin } from './settings.ts';
|
||||||
import {
|
import {
|
||||||
rgbArrayToHex, hexToRgbArray,
|
rgbArrayToHex, hexToRgbArray,
|
||||||
@@ -39,6 +41,7 @@ import {
|
|||||||
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
|
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
|
||||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||||
loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
||||||
|
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
|
||||||
} from './color-strips-notification.ts';
|
} from './color-strips-notification.ts';
|
||||||
|
|
||||||
// Re-export for app.js window global bindings
|
// Re-export for app.js window global bindings
|
||||||
@@ -58,8 +61,22 @@ class CSSEditorModal extends Modal {
|
|||||||
|
|
||||||
onForceClose() {
|
onForceClose() {
|
||||||
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
|
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
|
||||||
|
if (_smoothingWidget) { _smoothingWidget.destroy(); _smoothingWidget = null; }
|
||||||
|
if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; }
|
||||||
|
if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; }
|
||||||
|
if (_effectIntensityWidget) { _effectIntensityWidget.destroy(); _effectIntensityWidget = null; }
|
||||||
|
if (_effectScaleWidget) { _effectScaleWidget.destroy(); _effectScaleWidget = null; }
|
||||||
|
if (_apiInputTimeoutWidget) { _apiInputTimeoutWidget.destroy(); _apiInputTimeoutWidget = null; }
|
||||||
|
if (_candlelightIntensityWidget) { _candlelightIntensityWidget.destroy(); _candlelightIntensityWidget = null; }
|
||||||
|
if (_candlelightSpeedWidget) { _candlelightSpeedWidget.destroy(); _candlelightSpeedWidget = null; }
|
||||||
|
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
|
||||||
|
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
|
||||||
|
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
|
||||||
|
destroyNotificationDurationWidget();
|
||||||
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
|
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
|
||||||
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
|
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
|
||||||
|
if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; }
|
||||||
|
if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; }
|
||||||
compositeDestroyEntitySelects();
|
compositeDestroyEntitySelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +87,7 @@ class CSSEditorModal extends Modal {
|
|||||||
type,
|
type,
|
||||||
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
||||||
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||||
smoothing: (document.getElementById('css-editor-smoothing') as HTMLInputElement).value,
|
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
|
||||||
color: (document.getElementById('css-editor-color') as HTMLInputElement).value,
|
color: (document.getElementById('css-editor-color') as HTMLInputElement).value,
|
||||||
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
||||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||||
@@ -79,25 +96,25 @@ class CSSEditorModal extends Modal {
|
|||||||
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
||||||
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
||||||
effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value,
|
effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value,
|
||||||
effect_intensity: (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value,
|
effect_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0',
|
||||||
effect_scale: (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value,
|
effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0',
|
||||||
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
||||||
composite_layers: JSON.stringify(compositeGetRawLayers()),
|
composite_layers: JSON.stringify(compositeGetRawLayers()),
|
||||||
mapped_zones: JSON.stringify(_mappedZones),
|
mapped_zones: JSON.stringify(_mappedZones),
|
||||||
audio_viz: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
|
audio_viz: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
|
||||||
audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value,
|
audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value,
|
||||||
audio_sensitivity: (document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value,
|
audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
|
||||||
audio_smoothing: (document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value,
|
audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
|
||||||
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
||||||
audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value,
|
audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value,
|
||||||
audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value,
|
audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value,
|
||||||
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
|
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
|
||||||
api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value,
|
api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value,
|
||||||
api_input_timeout: (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value,
|
api_input_timeout: _apiInputTimeoutWidget ? JSON.stringify(_apiInputTimeoutWidget.getValue()) : '5.0',
|
||||||
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value,
|
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value,
|
||||||
notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
|
notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
|
||||||
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
||||||
notification_duration: (document.getElementById('css-editor-notification-duration') as HTMLInputElement).value,
|
notification_duration: getNotificationDurationSnapshot(),
|
||||||
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||||
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||||
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
|
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
|
||||||
@@ -107,11 +124,13 @@ class CSSEditorModal extends Modal {
|
|||||||
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
||||||
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
|
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
|
||||||
candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value,
|
candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value,
|
||||||
candlelight_intensity: (document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value,
|
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
|
||||||
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
|
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
|
||||||
candlelight_speed: (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value,
|
candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0',
|
||||||
processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value,
|
processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value,
|
||||||
processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value,
|
processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value,
|
||||||
|
kc_smoothing: _kcSmoothingWidget ? JSON.stringify(_kcSmoothingWidget.getValue()) : '0.3',
|
||||||
|
kc_brightness: _kcBrightnessWidget ? JSON.stringify(_kcBrightnessWidget.getValue()) : '1.0',
|
||||||
kc_rects: JSON.stringify(_kcEditorRects),
|
kc_rects: JSON.stringify(_kcEditorRects),
|
||||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||||
};
|
};
|
||||||
@@ -121,6 +140,17 @@ class CSSEditorModal extends Modal {
|
|||||||
const cssEditorModal = new CSSEditorModal();
|
const cssEditorModal = new CSSEditorModal();
|
||||||
|
|
||||||
let _cssTagsInput: any = null;
|
let _cssTagsInput: any = null;
|
||||||
|
let _smoothingWidget: BindableScalarWidget | null = null;
|
||||||
|
let _audioSensitivityWidget: BindableScalarWidget | null = null;
|
||||||
|
let _audioSmoothingWidget: BindableScalarWidget | null = null;
|
||||||
|
let _effectIntensityWidget: BindableScalarWidget | null = null;
|
||||||
|
let _effectScaleWidget: BindableScalarWidget | null = null;
|
||||||
|
let _apiInputTimeoutWidget: BindableScalarWidget | null = null;
|
||||||
|
let _candlelightIntensityWidget: BindableScalarWidget | null = null;
|
||||||
|
let _candlelightSpeedWidget: BindableScalarWidget | null = null;
|
||||||
|
let _candlelightWindWidget: BindableScalarWidget | null = null;
|
||||||
|
let _weatherSpeedWidget: BindableScalarWidget | null = null;
|
||||||
|
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
|
||||||
|
|
||||||
// ── EntitySelect instances for CSS editor ──
|
// ── EntitySelect instances for CSS editor ──
|
||||||
let _cssPictureSourceEntitySelect: any = null;
|
let _cssPictureSourceEntitySelect: any = null;
|
||||||
@@ -130,6 +160,8 @@ let _processedInputEntitySelect: any = null;
|
|||||||
let _processedTemplateEntitySelect: any = null;
|
let _processedTemplateEntitySelect: any = null;
|
||||||
let _kcPictureSourceEntitySelect: any = null;
|
let _kcPictureSourceEntitySelect: any = null;
|
||||||
let _kcInterpolationIconSelect: any = null;
|
let _kcInterpolationIconSelect: any = null;
|
||||||
|
let _kcSmoothingWidget: BindableScalarWidget | null = null;
|
||||||
|
let _kcBrightnessWidget: BindableScalarWidget | null = null;
|
||||||
|
|
||||||
// ── Key Colors rectangle editor state ──
|
// ── Key Colors rectangle editor state ──
|
||||||
let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = [];
|
let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = [];
|
||||||
@@ -480,6 +512,175 @@ function _ensureInterpolationIconSelect() {
|
|||||||
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _ensureSmoothingWidget(): BindableScalarWidget {
|
||||||
|
const container = document.getElementById('css-editor-smoothing-container')!;
|
||||||
|
if (!_smoothingWidget) {
|
||||||
|
_smoothingWidget = new BindableScalarWidget({
|
||||||
|
container,
|
||||||
|
min: 0, max: 1, step: 0.05, default: 0.3,
|
||||||
|
idPrefix: 'css-editor-smoothing',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _smoothingWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureKcSmoothingWidget(): BindableScalarWidget {
|
||||||
|
if (!_kcSmoothingWidget) {
|
||||||
|
_kcSmoothingWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-kc-smoothing-container')!,
|
||||||
|
min: 0, max: 1, step: 0.05, default: 0.3,
|
||||||
|
idPrefix: 'css-editor-kc-smoothing',
|
||||||
|
format: (v) => v.toFixed(2),
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _kcSmoothingWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureKcBrightnessWidget(): BindableScalarWidget {
|
||||||
|
if (!_kcBrightnessWidget) {
|
||||||
|
_kcBrightnessWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-kc-brightness-container')!,
|
||||||
|
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-kc-brightness',
|
||||||
|
format: (v) => v.toFixed(2),
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _kcBrightnessWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureAudioSensitivityWidget(): BindableScalarWidget {
|
||||||
|
if (!_audioSensitivityWidget) {
|
||||||
|
_audioSensitivityWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-audio-sensitivity-container')!,
|
||||||
|
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-audio-sensitivity',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _audioSensitivityWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureAudioSmoothingWidget(): BindableScalarWidget {
|
||||||
|
if (!_audioSmoothingWidget) {
|
||||||
|
_audioSmoothingWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-audio-smoothing-container')!,
|
||||||
|
min: 0.0, max: 1.0, step: 0.05, default: 0.3,
|
||||||
|
idPrefix: 'css-editor-audio-smoothing',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _audioSmoothingWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureEffectIntensityWidget(): BindableScalarWidget {
|
||||||
|
if (!_effectIntensityWidget) {
|
||||||
|
_effectIntensityWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-effect-intensity-container')!,
|
||||||
|
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-effect-intensity',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _effectIntensityWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureEffectScaleWidget(): BindableScalarWidget {
|
||||||
|
if (!_effectScaleWidget) {
|
||||||
|
_effectScaleWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-effect-scale-container')!,
|
||||||
|
min: 0.5, max: 5.0, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-effect-scale',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _effectScaleWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureApiInputTimeoutWidget(): BindableScalarWidget {
|
||||||
|
if (!_apiInputTimeoutWidget) {
|
||||||
|
_apiInputTimeoutWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-api-input-timeout-container')!,
|
||||||
|
min: 0, max: 60, step: 0.5, default: 5.0,
|
||||||
|
idPrefix: 'css-editor-api-input-timeout',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _apiInputTimeoutWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureCandlelightIntensityWidget(): BindableScalarWidget {
|
||||||
|
if (!_candlelightIntensityWidget) {
|
||||||
|
_candlelightIntensityWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-candlelight-intensity-container')!,
|
||||||
|
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-candlelight-intensity',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _candlelightIntensityWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureCandlelightSpeedWidget(): BindableScalarWidget {
|
||||||
|
if (!_candlelightSpeedWidget) {
|
||||||
|
_candlelightSpeedWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-candlelight-speed-container')!,
|
||||||
|
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-candlelight-speed',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _candlelightSpeedWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureCandlelightWindWidget(): BindableScalarWidget {
|
||||||
|
if (!_candlelightWindWidget) {
|
||||||
|
_candlelightWindWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-candlelight-wind-container')!,
|
||||||
|
min: 0.0, max: 2.0, step: 0.1, default: 0.0,
|
||||||
|
idPrefix: 'css-editor-candlelight-wind',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _candlelightWindWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureWeatherSpeedWidget(): BindableScalarWidget {
|
||||||
|
if (!_weatherSpeedWidget) {
|
||||||
|
_weatherSpeedWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-weather-speed-container')!,
|
||||||
|
min: 0.1, max: 5, step: 0.1, default: 1.0,
|
||||||
|
idPrefix: 'css-editor-weather-speed',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _weatherSpeedWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
|
||||||
|
if (!_weatherTempInfluenceWidget) {
|
||||||
|
_weatherTempInfluenceWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('css-editor-weather-temp-influence-container')!,
|
||||||
|
min: 0, max: 1, step: 0.05, default: 0.5,
|
||||||
|
idPrefix: 'css-editor-weather-temp-influence',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _weatherTempInfluenceWidget;
|
||||||
|
}
|
||||||
|
|
||||||
function _ensureApiInputInterpolationIconSelect() {
|
function _ensureApiInputInterpolationIconSelect() {
|
||||||
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
|
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -992,13 +1193,8 @@ function _loadAudioState(css: any) {
|
|||||||
if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
|
if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
|
||||||
onAudioVizChange();
|
onAudioVizChange();
|
||||||
|
|
||||||
const sensitivity = css.sensitivity ?? 1.0;
|
_ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0);
|
||||||
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = sensitivity;
|
_ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3);
|
||||||
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = parseFloat(sensitivity).toFixed(1);
|
|
||||||
|
|
||||||
const smoothing = css.smoothing ?? 0.3;
|
|
||||||
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = smoothing;
|
|
||||||
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
|
||||||
|
|
||||||
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
|
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
|
||||||
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
|
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
|
||||||
@@ -1017,10 +1213,8 @@ function _loadAudioState(css: any) {
|
|||||||
function _resetAudioState() {
|
function _resetAudioState() {
|
||||||
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum';
|
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum';
|
||||||
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
|
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
|
||||||
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = 1.0 as any;
|
_ensureAudioSensitivityWidget().setValue(1.0);
|
||||||
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0';
|
_ensureAudioSmoothingWidget().setValue(0.3);
|
||||||
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = 0.3 as any;
|
|
||||||
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = '0.30';
|
|
||||||
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
|
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
|
||||||
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
|
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
|
||||||
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
|
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
|
||||||
@@ -1127,7 +1321,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
|||||||
},
|
},
|
||||||
api_input: (source) => {
|
api_input: (source) => {
|
||||||
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
|
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
|
||||||
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
|
const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
|
||||||
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
|
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
|
||||||
return `
|
return `
|
||||||
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
|
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
|
||||||
@@ -1153,7 +1347,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
|||||||
},
|
},
|
||||||
daylight: (source, { clockBadge }) => {
|
daylight: (source, { clockBadge }) => {
|
||||||
const useRealTime = source.use_real_time;
|
const useRealTime = source.use_real_time;
|
||||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
|
||||||
return `
|
return `
|
||||||
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
|
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
|
||||||
${clockBadge}
|
${clockBadge}
|
||||||
@@ -1171,8 +1365,8 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
|||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
weather: (source, { clockBadge }) => {
|
weather: (source, { clockBadge }) => {
|
||||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
|
||||||
const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1);
|
const tempInfl = bindableValue(source.temperature_influence, 0.5).toFixed(1);
|
||||||
const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id);
|
const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id);
|
||||||
const wsName = ws?.name || '—';
|
const wsName = ws?.name || '—';
|
||||||
const wsLink = ws
|
const wsLink = ws
|
||||||
@@ -1420,10 +1614,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
|
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
|
||||||
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
|
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
|
||||||
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
|
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
|
||||||
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
|
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
|
||||||
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
|
||||||
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
|
|
||||||
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
|
|
||||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
|
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
|
||||||
onEffectPaletteChange();
|
onEffectPaletteChange();
|
||||||
},
|
},
|
||||||
@@ -1432,10 +1624,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
|
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
|
||||||
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
|
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
|
||||||
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
|
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
|
||||||
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any;
|
_ensureEffectIntensityWidget().setValue(1.0);
|
||||||
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0';
|
_ensureEffectScaleWidget().setValue(1.0);
|
||||||
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
|
|
||||||
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
|
|
||||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
|
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
|
||||||
},
|
},
|
||||||
getPayload(name) {
|
getPayload(name) {
|
||||||
@@ -1443,8 +1633,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
name,
|
name,
|
||||||
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
||||||
gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
||||||
intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value),
|
intensity: _ensureEffectIntensityWidget().getValue(),
|
||||||
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
|
scale: _ensureEffectScaleWidget().getValue(),
|
||||||
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
||||||
};
|
};
|
||||||
// Meteor/comet/bouncing_ball use a color picker
|
// Meteor/comet/bouncing_ball use a color picker
|
||||||
@@ -1468,8 +1658,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
name,
|
name,
|
||||||
visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
|
visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
|
||||||
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
|
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
|
||||||
sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value),
|
sensitivity: _ensureAudioSensitivityWidget().getValue(),
|
||||||
smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value),
|
smoothing: _ensureAudioSmoothingWidget().getValue(),
|
||||||
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
||||||
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
|
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
|
||||||
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
|
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
|
||||||
@@ -1519,17 +1709,14 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
load(css) {
|
load(css) {
|
||||||
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
|
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
|
||||||
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
|
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
|
||||||
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0;
|
_ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0);
|
||||||
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent =
|
|
||||||
parseFloat(css.timeout ?? 5.0).toFixed(1);
|
|
||||||
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
|
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
|
||||||
_ensureApiInputInterpolationIconSelect();
|
_ensureApiInputInterpolationIconSelect();
|
||||||
_showApiInputEndpoints(css.id);
|
_showApiInputEndpoints(css.id);
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
|
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
|
||||||
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = 5.0 as any;
|
_ensureApiInputTimeoutWidget().setValue(5.0);
|
||||||
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0';
|
|
||||||
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
|
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
|
||||||
_ensureApiInputInterpolationIconSelect();
|
_ensureApiInputInterpolationIconSelect();
|
||||||
_showApiInputEndpoints(null);
|
_showApiInputEndpoints(null);
|
||||||
@@ -1539,7 +1726,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
fallback_color: hexToRgbArray(fbHex),
|
fallback_color: hexToRgbArray(fbHex),
|
||||||
timeout: parseFloat((document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value),
|
timeout: _ensureApiInputTimeoutWidget().getValue(),
|
||||||
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
|
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1558,7 +1745,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
name,
|
name,
|
||||||
os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
|
os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
|
||||||
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
||||||
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
|
duration_ms: getNotificationDurationValue(),
|
||||||
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||||
app_filter_list: filterList,
|
app_filter_list: filterList,
|
||||||
@@ -1602,25 +1789,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
candlelight: {
|
candlelight: {
|
||||||
load(css) {
|
load(css) {
|
||||||
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]);
|
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]);
|
||||||
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
|
_ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
|
||||||
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
|
||||||
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
|
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
|
||||||
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = css.speed ?? 1.0;
|
_ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0);
|
||||||
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
_ensureCandlelightWindWidget().setValue(css.wind_strength ?? 0.0);
|
||||||
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = css.wind_strength ?? 0.0;
|
|
||||||
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = parseFloat(css.wind_strength ?? 0.0).toFixed(1);
|
|
||||||
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
|
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
|
||||||
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
|
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
|
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
|
||||||
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = 1.0 as any;
|
_ensureCandlelightIntensityWidget().setValue(1.0);
|
||||||
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = '1.0';
|
|
||||||
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
|
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
|
||||||
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = 1.0 as any;
|
_ensureCandlelightSpeedWidget().setValue(1.0);
|
||||||
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0';
|
_ensureCandlelightWindWidget().setValue(0.0);
|
||||||
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = 0.0 as any;
|
|
||||||
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = '0.0';
|
|
||||||
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
|
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
|
||||||
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
|
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
|
||||||
},
|
},
|
||||||
@@ -1628,10 +1809,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
|
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
|
||||||
intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value),
|
intensity: _ensureCandlelightIntensityWidget().getValue(),
|
||||||
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
|
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
|
||||||
speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value),
|
speed: _ensureCandlelightSpeedWidget().getValue(),
|
||||||
wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value),
|
wind_strength: _ensureCandlelightWindWidget().getValue(),
|
||||||
candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
|
candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1641,19 +1822,15 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
await weatherSourcesCache.fetch();
|
await weatherSourcesCache.fetch();
|
||||||
_populateWeatherSourceDropdown();
|
_populateWeatherSourceDropdown();
|
||||||
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
|
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
|
||||||
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0;
|
_ensureWeatherSpeedWidget().setValue(css.speed ?? 1.0);
|
||||||
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
_ensureWeatherTempInfluenceWidget().setValue(css.temperature_influence ?? 0.5);
|
||||||
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5;
|
|
||||||
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2);
|
|
||||||
},
|
},
|
||||||
async reset() {
|
async reset() {
|
||||||
await weatherSourcesCache.fetch();
|
await weatherSourcesCache.fetch();
|
||||||
_populateWeatherSourceDropdown();
|
_populateWeatherSourceDropdown();
|
||||||
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
|
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
|
||||||
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any;
|
_ensureWeatherSpeedWidget().setValue(1.0);
|
||||||
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0';
|
_ensureWeatherTempInfluenceWidget().setValue(0.5);
|
||||||
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any;
|
|
||||||
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50';
|
|
||||||
},
|
},
|
||||||
getPayload(name) {
|
getPayload(name) {
|
||||||
const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
|
const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
|
||||||
@@ -1664,8 +1841,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
weather_source_id: wsId,
|
weather_source_id: wsId,
|
||||||
speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value),
|
speed: _ensureWeatherSpeedWidget().getValue(),
|
||||||
temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value),
|
temperature_influence: _ensureWeatherTempInfluenceWidget().getValue(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1702,21 +1879,18 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
load(css) {
|
load(css) {
|
||||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
|
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
|
||||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
|
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
|
||||||
const smoothing = css.smoothing ?? 0.3;
|
_ensureSmoothingWidget().setValue(css.smoothing);
|
||||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
|
|
||||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
|
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
|
||||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
||||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
|
_ensureSmoothingWidget().setValue(0.3);
|
||||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
|
|
||||||
},
|
},
|
||||||
getPayload(name) {
|
getPayload(name) {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||||
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
|
smoothing: _ensureSmoothingWidget().getValue(),
|
||||||
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
|
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1726,22 +1900,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
sourceSelect.value = css.picture_source_id || '';
|
sourceSelect.value = css.picture_source_id || '';
|
||||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
|
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
|
||||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
|
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
|
||||||
const smoothing = css.smoothing ?? 0.3;
|
_ensureSmoothingWidget().setValue(css.smoothing);
|
||||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
|
|
||||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
|
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
|
||||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
||||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
|
_ensureSmoothingWidget().setValue(0.3);
|
||||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
|
|
||||||
},
|
},
|
||||||
getPayload(name) {
|
getPayload(name) {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
picture_source_id: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
picture_source_id: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
||||||
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||||
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
|
smoothing: _ensureSmoothingWidget().getValue(),
|
||||||
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
|
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1775,11 +1946,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
columns: 1,
|
columns: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const smoothing = css.smoothing ?? 0.3;
|
_ensureKcSmoothingWidget().setValue(css.smoothing ?? 0.3);
|
||||||
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing;
|
_ensureKcBrightnessWidget().setValue(css.brightness ?? 1.0);
|
||||||
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
|
||||||
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = css.brightness ?? 1.0;
|
|
||||||
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = parseFloat(css.brightness ?? 1.0).toFixed(2);
|
|
||||||
// Load rectangles
|
// Load rectangles
|
||||||
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
|
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
|
||||||
_renderKCRectSummary();
|
_renderKCRectSummary();
|
||||||
@@ -1810,10 +1978,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
columns: 1,
|
columns: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any;
|
_ensureKcSmoothingWidget().setValue(0.3);
|
||||||
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30';
|
_ensureKcBrightnessWidget().setValue(1.0);
|
||||||
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = 1.0 as any;
|
|
||||||
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = '1.00';
|
|
||||||
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
|
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
|
||||||
_renderKCRectSummary();
|
_renderKCRectSummary();
|
||||||
},
|
},
|
||||||
@@ -1832,8 +1998,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
picture_source_id: psId,
|
picture_source_id: psId,
|
||||||
rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })),
|
rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })),
|
||||||
interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value,
|
interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value,
|
||||||
smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value),
|
smoothing: _ensureKcSmoothingWidget().getValue(),
|
||||||
brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value),
|
brightness: _ensureKcBrightnessWidget().getValue(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
|||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
|
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
|
||||||
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon } from '../core/icons.ts';
|
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { wrapCard } from '../core/card-colors.ts';
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||||
|
import { bindableSourceId, bindableValue } from '../types.ts';
|
||||||
|
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||||
|
|
||||||
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
|
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
|
||||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
@@ -25,6 +27,10 @@ let _brightnessVsEntitySelect: EntitySelect | null = null;
|
|||||||
let _mappingEntitySelects: EntitySelect[] = [];
|
let _mappingEntitySelects: EntitySelect[] = [];
|
||||||
let _editorCssSources: any[] = [];
|
let _editorCssSources: any[] = [];
|
||||||
let _cachedHAEntities: any[] = []; // fetched from selected HA source
|
let _cachedHAEntities: any[] = []; // fetched from selected HA source
|
||||||
|
let _updateRateWidget: BindableScalarWidget | null = null;
|
||||||
|
let _transitionWidget: BindableScalarWidget | null = null;
|
||||||
|
let _colorToleranceWidget: BindableScalarWidget | null = null;
|
||||||
|
let _minBrightnessThresholdWidget: BindableScalarWidget | null = null;
|
||||||
|
|
||||||
class HALightEditorModal extends Modal {
|
class HALightEditorModal extends Modal {
|
||||||
constructor() { super('ha-light-editor-modal'); }
|
constructor() { super('ha-light-editor-modal'); }
|
||||||
@@ -34,6 +40,10 @@ class HALightEditorModal extends Modal {
|
|||||||
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
|
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
|
||||||
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
||||||
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
|
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
|
||||||
|
if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; }
|
||||||
|
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
|
||||||
|
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
|
||||||
|
if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; }
|
||||||
_destroyMappingEntitySelects();
|
_destroyMappingEntitySelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +52,10 @@ class HALightEditorModal extends Modal {
|
|||||||
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
|
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
|
||||||
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
|
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
|
||||||
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
|
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
|
||||||
update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value,
|
update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
|
||||||
transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value,
|
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
|
||||||
|
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
|
||||||
|
min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0',
|
||||||
mappings: _getMappingsJSON(),
|
mappings: _getMappingsJSON(),
|
||||||
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
|
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
|
||||||
};
|
};
|
||||||
@@ -77,7 +89,7 @@ function _getEntityItems() {
|
|||||||
.map((e: any) => ({
|
.map((e: any) => ({
|
||||||
value: e.entity_id,
|
value: e.entity_id,
|
||||||
label: e.friendly_name || e.entity_id,
|
label: e.friendly_name || e.entity_id,
|
||||||
icon: _icon(P.lightbulb),
|
icon: getHAEntityIcon(e),
|
||||||
desc: e.state || '',
|
desc: e.state || '',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -201,6 +213,60 @@ export function removeHALightMapping(btn: HTMLElement): void {
|
|||||||
row.remove();
|
row.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bindable scalar widgets ──
|
||||||
|
|
||||||
|
function _ensureUpdateRateWidget(): BindableScalarWidget {
|
||||||
|
if (!_updateRateWidget) {
|
||||||
|
_updateRateWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('ha-light-editor-update-rate-container')!,
|
||||||
|
min: 0.5, max: 5.0, step: 0.1, default: 2.0,
|
||||||
|
idPrefix: 'ha-light-editor-update-rate',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _updateRateWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureTransitionWidget(): BindableScalarWidget {
|
||||||
|
if (!_transitionWidget) {
|
||||||
|
_transitionWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('ha-light-editor-transition-container')!,
|
||||||
|
min: 0.0, max: 10.0, step: 0.1, default: 0.5,
|
||||||
|
idPrefix: 'ha-light-editor-transition',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => v.toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _transitionWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureColorToleranceWidget(): BindableScalarWidget {
|
||||||
|
if (!_colorToleranceWidget) {
|
||||||
|
_colorToleranceWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('ha-light-editor-color-tolerance-container')!,
|
||||||
|
min: 0, max: 50, step: 1, default: 5,
|
||||||
|
idPrefix: 'ha-light-editor-color-tolerance',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => String(Math.round(v)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _colorToleranceWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget {
|
||||||
|
if (!_minBrightnessThresholdWidget) {
|
||||||
|
_minBrightnessThresholdWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('ha-light-editor-min-brightness-threshold-container')!,
|
||||||
|
min: 0, max: 254, step: 1, default: 0,
|
||||||
|
idPrefix: 'ha-light-editor-min-brightness-threshold',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => String(Math.round(v)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minBrightnessThresholdWidget;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Show / Close ──
|
// ── Show / Close ──
|
||||||
|
|
||||||
export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> {
|
export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> {
|
||||||
@@ -256,10 +322,10 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
|
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
|
||||||
haSelect.value = editData.ha_source_id || '';
|
haSelect.value = editData.ha_source_id || '';
|
||||||
cssSelect.value = editData.color_strip_source_id || '';
|
cssSelect.value = editData.color_strip_source_id || '';
|
||||||
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = String(editData.update_rate ?? 2.0);
|
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
|
||||||
document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1);
|
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
|
||||||
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5);
|
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
|
||||||
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
|
_ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0);
|
||||||
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
|
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
|
||||||
|
|
||||||
// Fetch entities from the selected HA source before loading mappings
|
// Fetch entities from the selected HA source before loading mappings
|
||||||
@@ -270,10 +336,10 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
mappings.forEach((m: any) => addHALightMapping(m));
|
mappings.forEach((m: any) => addHALightMapping(m));
|
||||||
} else {
|
} else {
|
||||||
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
|
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
|
||||||
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0';
|
_ensureUpdateRateWidget().setValue(2.0);
|
||||||
document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0';
|
_ensureTransitionWidget().setValue(0.5);
|
||||||
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
|
_ensureColorToleranceWidget().setValue(5);
|
||||||
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
|
_ensureMinBrightnessThresholdWidget().setValue(0);
|
||||||
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
|
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
|
||||||
|
|
||||||
// Fetch entities from the first HA source
|
// Fetch entities from the first HA source
|
||||||
@@ -313,7 +379,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
|
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
|
||||||
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
|
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
|
||||||
_cachedValueSources.map((vs: any) =>
|
_cachedValueSources.map((vs: any) =>
|
||||||
`<option value="${vs.id}" ${vs.id === (editData?.brightness_value_source_id || '') ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
|
`<option value="${vs.id}" ${vs.id === bindableSourceId(editData?.brightness) ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
||||||
_brightnessVsEntitySelect = new EntitySelect({
|
_brightnessVsEntitySelect = new EntitySelect({
|
||||||
@@ -346,9 +412,10 @@ export async function saveHALightEditor(): Promise<void> {
|
|||||||
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
|
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
|
||||||
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
|
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
|
||||||
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
|
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
|
||||||
const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0;
|
const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0;
|
||||||
const transitionRaw = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value);
|
const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
|
||||||
const transition = isNaN(transitionRaw) ? 0.5 : transitionRaw;
|
const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5;
|
||||||
|
const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0;
|
||||||
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
|
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -369,10 +436,12 @@ export async function saveHALightEditor(): Promise<void> {
|
|||||||
name,
|
name,
|
||||||
ha_source_id: haSourceId,
|
ha_source_id: haSourceId,
|
||||||
color_strip_source_id: cssSourceId,
|
color_strip_source_id: cssSourceId,
|
||||||
brightness_value_source_id: brightnessVsId,
|
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
|
||||||
ha_light_mappings: mappings,
|
ha_light_mappings: mappings,
|
||||||
update_rate: updateRate,
|
update_rate: updateRate,
|
||||||
transition,
|
transition,
|
||||||
|
color_tolerance: colorTolerance,
|
||||||
|
min_brightness_threshold: minBrightnessThreshold,
|
||||||
description,
|
description,
|
||||||
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
|
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
|
||||||
};
|
};
|
||||||
@@ -450,7 +519,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Brightness value source
|
// Brightness value source
|
||||||
const bvsId = target.brightness_value_source_id || '';
|
const bvsId = bindableSourceId(target.brightness);
|
||||||
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
|
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
|
||||||
|
|
||||||
return wrapCard({
|
return wrapCard({
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import { CardSection } from '../core/card-sections.ts';
|
|||||||
import { TreeNav } from '../core/tree-nav.ts';
|
import { TreeNav } from '../core/tree-nav.ts';
|
||||||
import { updateSubTabHash, updateTabBadge } from './tabs.ts';
|
import { updateSubTabHash, updateTabBadge } from './tabs.ts';
|
||||||
import type { OutputTarget } from '../types.ts';
|
import type { OutputTarget } from '../types.ts';
|
||||||
|
import { bindableSourceId, bindableValue } from '../types.ts';
|
||||||
|
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||||
|
|
||||||
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
||||||
// (pattern-templates.js calls window.loadTargetsTab)
|
// (pattern-templates.js calls window.loadTargetsTab)
|
||||||
@@ -142,6 +144,8 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
|
|||||||
// --- Editor state ---
|
// --- Editor state ---
|
||||||
let _editorCssSources: any[] = []; // populated when editor opens
|
let _editorCssSources: any[] = []; // populated when editor opens
|
||||||
let _targetTagsInput: TagInput | null = null;
|
let _targetTagsInput: TagInput | null = null;
|
||||||
|
let _fpsWidget: BindableScalarWidget | null = null;
|
||||||
|
let _thresholdWidget: BindableScalarWidget | null = null;
|
||||||
|
|
||||||
class TargetEditorModal extends Modal {
|
class TargetEditorModal extends Modal {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -155,8 +159,8 @@ class TargetEditorModal extends Modal {
|
|||||||
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
|
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
|
||||||
css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
|
css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
|
||||||
brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
|
brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
|
||||||
brightness_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value,
|
brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0',
|
||||||
fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value,
|
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
|
||||||
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
||||||
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
||||||
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
||||||
@@ -329,6 +333,32 @@ function _ensureProtocolIconSelect() {
|
|||||||
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _ensureFpsWidget(): BindableScalarWidget {
|
||||||
|
if (!_fpsWidget) {
|
||||||
|
_fpsWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('target-editor-fps-container')!,
|
||||||
|
min: 1, max: 90, step: 1, default: 30,
|
||||||
|
idPrefix: 'target-editor-fps',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => String(Math.round(v)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _fpsWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureThresholdWidget(): BindableScalarWidget {
|
||||||
|
if (!_thresholdWidget) {
|
||||||
|
_thresholdWidget = new BindableScalarWidget({
|
||||||
|
container: document.getElementById('target-editor-brightness-threshold-container')!,
|
||||||
|
min: 0, max: 254, step: 1, default: 0,
|
||||||
|
idPrefix: 'target-editor-brightness-threshold',
|
||||||
|
valueSources: () => _cachedValueSources,
|
||||||
|
format: (v) => String(Math.round(v)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _thresholdWidget;
|
||||||
|
}
|
||||||
|
|
||||||
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
|
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
|
||||||
try {
|
try {
|
||||||
// Load devices, CSS sources, and value sources for dropdowns
|
// Load devices, CSS sources, and value sources for dropdowns
|
||||||
@@ -365,56 +395,46 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
|||||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = target.id;
|
(document.getElementById('target-editor-id') as HTMLInputElement).value = target.id;
|
||||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = target.name;
|
(document.getElementById('target-editor-name') as HTMLInputElement).value = target.name;
|
||||||
deviceSelect.value = target.device_id || '';
|
deviceSelect.value = target.device_id || '';
|
||||||
const fps = target.fps ?? 30;
|
_ensureFpsWidget().setValue(target.fps ?? 30);
|
||||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
|
|
||||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
|
|
||||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0;
|
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0;
|
||||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0;
|
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0;
|
||||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
|
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
|
||||||
|
|
||||||
const thresh = target.min_brightness_threshold ?? 0;
|
_ensureThresholdWidget().setValue(target.min_brightness_threshold ?? 0);
|
||||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = thresh;
|
|
||||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = thresh;
|
|
||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
||||||
|
|
||||||
_populateCssDropdown(target.color_strip_source_id || '');
|
_populateCssDropdown(target.color_strip_source_id || '');
|
||||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
_populateBrightnessVsDropdown(bindableSourceId(target.brightness));
|
||||||
} else if (cloneData) {
|
} else if (cloneData) {
|
||||||
// Cloning — create mode but pre-filled from clone data
|
// Cloning — create mode but pre-filled from clone data
|
||||||
_editorTags = cloneData.tags || [];
|
_editorTags = cloneData.tags || [];
|
||||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
||||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
(document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||||
deviceSelect.value = cloneData.device_id || '';
|
deviceSelect.value = cloneData.device_id || '';
|
||||||
const fps = cloneData.fps ?? 30;
|
_ensureFpsWidget().setValue(cloneData.fps ?? 30);
|
||||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
|
|
||||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
|
|
||||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0;
|
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0;
|
||||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0;
|
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0;
|
||||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||||
|
|
||||||
const cloneThresh = cloneData.min_brightness_threshold ?? 0;
|
_ensureThresholdWidget().setValue(cloneData.min_brightness_threshold ?? 0);
|
||||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = cloneThresh;
|
|
||||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = cloneThresh;
|
|
||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
||||||
|
|
||||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||||
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
_populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness));
|
||||||
} else {
|
} else {
|
||||||
// Creating new target
|
// Creating new target
|
||||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
||||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = '';
|
(document.getElementById('target-editor-name') as HTMLInputElement).value = '';
|
||||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any;
|
_ensureFpsWidget().setValue(30);
|
||||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30';
|
|
||||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any;
|
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any;
|
||||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0';
|
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0';
|
||||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||||
|
|
||||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any;
|
_ensureThresholdWidget().setValue(0);
|
||||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0';
|
|
||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
||||||
@@ -472,6 +492,8 @@ export async function closeTargetEditorModal() {
|
|||||||
|
|
||||||
export function forceCloseTargetEditorModal() {
|
export function forceCloseTargetEditorModal() {
|
||||||
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
|
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
|
||||||
|
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
|
||||||
|
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
|
||||||
targetEditorModal.forceClose();
|
targetEditorModal.forceClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,11 +508,11 @@ export async function saveTargetEditor() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fps = parseInt((document.getElementById('target-editor-fps') as HTMLInputElement).value) || 30;
|
const fps = _fpsWidget ? _fpsWidget.getValue() : 30;
|
||||||
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;
|
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;
|
||||||
|
|
||||||
const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
|
const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
|
||||||
const minBrightnessThreshold = parseInt((document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value) || 0;
|
const minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0;
|
||||||
|
|
||||||
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
||||||
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
||||||
@@ -499,7 +521,7 @@ export async function saveTargetEditor() {
|
|||||||
name,
|
name,
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
color_strip_source_id: colorStripSourceId,
|
color_strip_source_id: colorStripSourceId,
|
||||||
brightness_value_source_id: brightnessVsId,
|
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
|
||||||
min_brightness_threshold: minBrightnessThreshold,
|
min_brightness_threshold: minBrightnessThreshold,
|
||||||
fps,
|
fps,
|
||||||
keepalive_interval: standbyInterval,
|
keepalive_interval: standbyInterval,
|
||||||
@@ -958,7 +980,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
|||||||
const cssId = target.color_strip_source_id || '';
|
const cssId = target.color_strip_source_id || '';
|
||||||
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
|
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
|
||||||
|
|
||||||
const bvsId = target.brightness_value_source_id || '';
|
const bvsId = bindableSourceId(target.brightness);
|
||||||
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
|
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
|
||||||
|
|
||||||
// Determine if overlay is available (picture-based CSS)
|
// Determine if overlay is available (picture-based CSS)
|
||||||
@@ -990,11 +1012,11 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
|||||||
</div>
|
</div>
|
||||||
<div class="stream-card-props">
|
<div class="stream-card-props">
|
||||||
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
|
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
|
||||||
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
|
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${bindableValue(target.fps, 30)}</span>
|
||||||
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
|
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
|
||||||
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
||||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||||
${(target.min_brightness_threshold ?? 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</span>` : ''}
|
${bindableValue(target.min_brightness_threshold, 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${bindableValue(target.min_brightness_threshold, 0)} → off</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${renderTagChips(target.tags)}
|
${renderTagChips(target.tags)}
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
|||||||
@@ -5,6 +5,25 @@
|
|||||||
* snake_case to match the JSON payloads — no camelCase transformation is done.
|
* snake_case to match the JSON payloads — no camelCase transformation is done.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── Bindable Float ───────────────────────────────────────────
|
||||||
|
// A scalar that is either a static value (plain number) or bound to a value source (dict).
|
||||||
|
|
||||||
|
export type BindableFloat = number | { value: number; source_id: string };
|
||||||
|
|
||||||
|
/** Extract the static value from a BindableFloat. */
|
||||||
|
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
|
||||||
|
if (b === undefined || b === null) return fallback;
|
||||||
|
if (typeof b === 'number') return b;
|
||||||
|
return b.value ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the source_id from a BindableFloat (empty string = not bound). */
|
||||||
|
export function bindableSourceId(b: BindableFloat | undefined): string {
|
||||||
|
if (b === undefined || b === null) return '';
|
||||||
|
if (typeof b === 'number') return '';
|
||||||
|
return b.source_id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Device ────────────────────────────────────────────────────
|
// ── Device ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type DeviceType =
|
export type DeviceType =
|
||||||
@@ -59,27 +78,27 @@ export interface OutputTarget {
|
|||||||
// LED target fields
|
// LED target fields
|
||||||
device_id?: string;
|
device_id?: string;
|
||||||
color_strip_source_id?: string;
|
color_strip_source_id?: string;
|
||||||
brightness_value_source_id?: string;
|
brightness?: BindableFloat;
|
||||||
fps?: number;
|
fps?: BindableFloat;
|
||||||
keepalive_interval?: number;
|
keepalive_interval?: number;
|
||||||
state_check_interval?: number;
|
state_check_interval?: number;
|
||||||
min_brightness_threshold?: number;
|
min_brightness_threshold?: BindableFloat;
|
||||||
adaptive_fps?: boolean;
|
adaptive_fps?: boolean;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
|
|
||||||
// HA light target fields
|
// HA light target fields
|
||||||
ha_source_id?: string;
|
ha_source_id?: string;
|
||||||
ha_light_mappings?: HALightMapping[];
|
ha_light_mappings?: HALightMapping[];
|
||||||
update_rate?: number;
|
update_rate?: BindableFloat;
|
||||||
ha_transition?: number;
|
transition?: BindableFloat;
|
||||||
color_tolerance?: number;
|
color_tolerance?: BindableFloat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HALightMapping {
|
export interface HALightMapping {
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
led_start: number;
|
led_start: number;
|
||||||
led_end: number;
|
led_end: number;
|
||||||
brightness_scale: number;
|
brightness_scale: BindableFloat;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Color Strip Source ────────────────────────────────────────
|
// ── Color Strip Source ────────────────────────────────────────
|
||||||
@@ -165,7 +184,7 @@ export interface ColorStripSource {
|
|||||||
|
|
||||||
// Picture
|
// Picture
|
||||||
picture_source_id?: string;
|
picture_source_id?: string;
|
||||||
smoothing?: number;
|
smoothing?: BindableFloat;
|
||||||
interpolation_mode?: string;
|
interpolation_mode?: string;
|
||||||
calibration?: Calibration;
|
calibration?: Calibration;
|
||||||
|
|
||||||
@@ -181,8 +200,8 @@ export interface ColorStripSource {
|
|||||||
// Effect
|
// Effect
|
||||||
effect_type?: string;
|
effect_type?: string;
|
||||||
palette?: string;
|
palette?: string;
|
||||||
intensity?: number;
|
intensity?: BindableFloat;
|
||||||
scale?: number;
|
scale?: BindableFloat;
|
||||||
mirror?: boolean;
|
mirror?: boolean;
|
||||||
|
|
||||||
// Composite
|
// Composite
|
||||||
@@ -194,16 +213,16 @@ export interface ColorStripSource {
|
|||||||
// Audio
|
// Audio
|
||||||
visualization_mode?: string;
|
visualization_mode?: string;
|
||||||
audio_source_id?: string;
|
audio_source_id?: string;
|
||||||
sensitivity?: number;
|
sensitivity?: BindableFloat;
|
||||||
color_peak?: number[];
|
color_peak?: number[];
|
||||||
|
|
||||||
// Animation
|
// Animation
|
||||||
animation?: AnimationConfig;
|
animation?: AnimationConfig;
|
||||||
speed?: number;
|
speed?: BindableFloat;
|
||||||
|
|
||||||
// API Input
|
// API Input
|
||||||
fallback_color?: number[];
|
fallback_color?: number[];
|
||||||
timeout?: number;
|
timeout?: BindableFloat;
|
||||||
interpolation?: string;
|
interpolation?: string;
|
||||||
|
|
||||||
// Notification
|
// Notification
|
||||||
@@ -214,6 +233,7 @@ export interface ColorStripSource {
|
|||||||
app_filter_mode?: string;
|
app_filter_mode?: string;
|
||||||
app_filter_list?: string[];
|
app_filter_list?: string[];
|
||||||
os_listener?: boolean;
|
os_listener?: boolean;
|
||||||
|
sound_volume?: BindableFloat;
|
||||||
|
|
||||||
// Daylight
|
// Daylight
|
||||||
use_real_time?: boolean;
|
use_real_time?: boolean;
|
||||||
@@ -221,6 +241,7 @@ export interface ColorStripSource {
|
|||||||
|
|
||||||
// Candlelight
|
// Candlelight
|
||||||
num_candles?: number;
|
num_candles?: number;
|
||||||
|
wind_strength?: BindableFloat;
|
||||||
|
|
||||||
// Processed
|
// Processed
|
||||||
input_source_id?: string;
|
input_source_id?: string;
|
||||||
@@ -228,12 +249,11 @@ export interface ColorStripSource {
|
|||||||
|
|
||||||
// Weather
|
// Weather
|
||||||
weather_source_id?: string;
|
weather_source_id?: string;
|
||||||
temperature_influence?: number;
|
temperature_influence?: BindableFloat;
|
||||||
|
|
||||||
// Key Colors
|
// Key Colors
|
||||||
rectangles?: KeyColorRectangle[];
|
rectangles?: KeyColorRectangle[];
|
||||||
brightness?: number;
|
brightness?: BindableFloat;
|
||||||
brightness_value_source_id?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pattern Template ──────────────────────────────────────────
|
// ── Pattern Template ──────────────────────────────────────────
|
||||||
@@ -370,7 +390,7 @@ export interface TargetSnapshot {
|
|||||||
target_id: string;
|
target_id: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
color_strip_source_id: string;
|
color_strip_source_id: string;
|
||||||
brightness_value_source_id: string;
|
brightness?: BindableFloat;
|
||||||
fps: number;
|
fps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app.title": "LED Grab",
|
"app.title": "LED Grab",
|
||||||
|
"bindable.none": "None (static value)",
|
||||||
|
"bindable.toggle": "Toggle value source binding",
|
||||||
"app.version": "Version:",
|
"app.version": "Version:",
|
||||||
"app.api_docs": "API Documentation",
|
"app.api_docs": "API Documentation",
|
||||||
"app.connection_lost": "Server unreachable",
|
"app.connection_lost": "Server unreachable",
|
||||||
@@ -1331,9 +1333,12 @@
|
|||||||
"audio_source.parent.hint": "Multichannel source to extract a channel from",
|
"audio_source.parent.hint": "Multichannel source to extract a channel from",
|
||||||
"audio_source.channel": "Channel:",
|
"audio_source.channel": "Channel:",
|
||||||
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
|
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
|
||||||
"audio_source.channel.mono": "Mono (L+R mix)",
|
"audio_source.channel.mono": "Mono",
|
||||||
|
"audio_source.channel.mono.desc": "L+R mix",
|
||||||
"audio_source.channel.left": "Left",
|
"audio_source.channel.left": "Left",
|
||||||
|
"audio_source.channel.left.desc": "Left channel only",
|
||||||
"audio_source.channel.right": "Right",
|
"audio_source.channel.right": "Right",
|
||||||
|
"audio_source.channel.right.desc": "Right channel only",
|
||||||
"audio_source.description": "Description (optional):",
|
"audio_source.description": "Description (optional):",
|
||||||
"audio_source.description.placeholder": "Describe this audio source...",
|
"audio_source.description.placeholder": "Describe this audio source...",
|
||||||
"audio_source.description.hint": "Optional notes about this audio source",
|
"audio_source.description.hint": "Optional notes about this audio source",
|
||||||
@@ -1808,6 +1813,10 @@
|
|||||||
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
|
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
|
||||||
"ha_light.transition": "Transition:",
|
"ha_light.transition": "Transition:",
|
||||||
"ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).",
|
"ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).",
|
||||||
|
"ha_light.color_tolerance": "Color Tolerance:",
|
||||||
|
"ha_light.color_tolerance.hint": "Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.",
|
||||||
|
"ha_light.min_brightness_threshold": "Min Brightness Threshold:",
|
||||||
|
"ha_light.min_brightness_threshold.hint": "Effective output brightness below this value turns lights off completely (0 = disabled).",
|
||||||
"ha_light.mappings": "Light Mappings:",
|
"ha_light.mappings": "Light Mappings:",
|
||||||
"ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
|
"ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
|
||||||
"ha_light.mappings.add": "Add Mapping",
|
"ha_light.mappings.add": "Add Mapping",
|
||||||
@@ -1830,6 +1839,9 @@
|
|||||||
"automations.condition.home_assistant.state": "State:",
|
"automations.condition.home_assistant.state": "State:",
|
||||||
"automations.condition.home_assistant.match_mode": "Match Mode:",
|
"automations.condition.home_assistant.match_mode": "Match Mode:",
|
||||||
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
|
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
|
||||||
|
"automations.condition.ha.match_mode.exact.desc": "State must match exactly",
|
||||||
|
"automations.condition.ha.match_mode.contains.desc": "State must contain the text",
|
||||||
|
"automations.condition.ha.match_mode.regex.desc": "State must match the regex pattern",
|
||||||
"color_strip.clock": "Sync Clock:",
|
"color_strip.clock": "Sync Clock:",
|
||||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
||||||
"graph.title": "Graph",
|
"graph.title": "Graph",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"app.title": "LED Grab",
|
"app.title": "LED Grab",
|
||||||
|
"bindable.none": "Нет (статическое значение)",
|
||||||
|
"bindable.toggle": "Привязка к источнику значений",
|
||||||
|
"ha_light.color_tolerance": "Допуск цвета:",
|
||||||
|
"ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.",
|
||||||
|
"ha_light.min_brightness_threshold": "Мин. порог яркости:",
|
||||||
|
"ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).",
|
||||||
"app.version": "Версия:",
|
"app.version": "Версия:",
|
||||||
"app.api_docs": "Документация API",
|
"app.api_docs": "Документация API",
|
||||||
"app.connection_lost": "Сервер недоступен",
|
"app.connection_lost": "Сервер недоступен",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"app.title": "LED Grab",
|
"app.title": "LED Grab",
|
||||||
|
"bindable.none": "无(静态值)",
|
||||||
|
"bindable.toggle": "切换值源绑定",
|
||||||
|
"ha_light.color_tolerance": "色彩容差:",
|
||||||
|
"ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。",
|
||||||
|
"ha_light.min_brightness_threshold": "最低亮度阈值:",
|
||||||
|
"ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。",
|
||||||
"app.version": "版本:",
|
"app.version": "版本:",
|
||||||
"app.api_docs": "API 文档",
|
"app.api_docs": "API 文档",
|
||||||
"app.connection_lost": "服务器不可达",
|
"app.connection_lost": "服务器不可达",
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""BindableFloat — a scalar property that can be static or bound to a value source.
|
||||||
|
|
||||||
|
Any numeric property (brightness, smoothing, intensity, speed, etc.) can use
|
||||||
|
BindableFloat instead of a plain float. When source_id is empty the static
|
||||||
|
value is used; when set, the runtime resolves the current value from the
|
||||||
|
corresponding ValueStream.
|
||||||
|
|
||||||
|
Serialisation is backward-compatible:
|
||||||
|
• unbound → plain float (``0.3``)
|
||||||
|
• bound → dict (``{"value": 0.3, "source_id": "vs_abc12345"}``)
|
||||||
|
|
||||||
|
``from_raw`` accepts both shapes, so old JSON files "just work".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BindableFloat:
|
||||||
|
"""A scalar that is either a static value or driven by a ValueSource."""
|
||||||
|
|
||||||
|
value: float = 0.0
|
||||||
|
source_id: str = "" # empty → use static value
|
||||||
|
|
||||||
|
# -- serialisation --
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Serialize: plain float when unbound, dict when bound."""
|
||||||
|
if not self.source_id:
|
||||||
|
return self.value
|
||||||
|
return {"value": self.value, "source_id": self.source_id}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw(cls, data, *, default: float = 0.0) -> "BindableFloat":
|
||||||
|
"""Deserialize from either a plain number or a dict.
|
||||||
|
|
||||||
|
Also handles the legacy ``brightness_value_source_id`` migration
|
||||||
|
when called with the *legacy_source_id* helper below.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return cls(value=default)
|
||||||
|
if isinstance(data, (int, float)):
|
||||||
|
return cls(value=float(data))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return cls(
|
||||||
|
value=float(data.get("value", default)),
|
||||||
|
source_id=data.get("source_id") or "",
|
||||||
|
)
|
||||||
|
return cls(value=default)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_legacy(
|
||||||
|
cls,
|
||||||
|
data,
|
||||||
|
legacy_source_id: str = "",
|
||||||
|
*,
|
||||||
|
default: float = 0.0,
|
||||||
|
) -> "BindableFloat":
|
||||||
|
"""Migrate from the old separate-field pattern.
|
||||||
|
|
||||||
|
Old format::
|
||||||
|
|
||||||
|
{"brightness": 1.0, "brightness_value_source_id": "vs_abc"}
|
||||||
|
|
||||||
|
New format::
|
||||||
|
|
||||||
|
{"brightness": {"value": 1.0, "source_id": "vs_abc"}}
|
||||||
|
|
||||||
|
If *data* is already a dict with ``source_id``, it takes precedence
|
||||||
|
(new format). Otherwise *legacy_source_id* is folded in.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict) and "source_id" in data:
|
||||||
|
return cls.from_raw(data, default=default)
|
||||||
|
value = float(data) if isinstance(data, (int, float)) else default
|
||||||
|
return cls(value=value, source_id=legacy_source_id or "")
|
||||||
|
|
||||||
|
# -- update helpers --
|
||||||
|
|
||||||
|
def apply_update(self, raw) -> "BindableFloat":
|
||||||
|
"""Return a new BindableFloat from an update payload.
|
||||||
|
|
||||||
|
Accepts:
|
||||||
|
• plain number → sets value, clears source_id
|
||||||
|
• dict → sets both
|
||||||
|
• None → returns self unchanged
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return self
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
return BindableFloat(value=float(raw), source_id=self.source_id)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return BindableFloat(
|
||||||
|
value=float(raw.get("value", self.value)),
|
||||||
|
source_id=raw.get("source_id", self.source_id),
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_bound(self) -> bool:
|
||||||
|
return bool(self.source_id)
|
||||||
|
|
||||||
|
|
||||||
|
def bfloat(v, default: float = 0.0) -> float:
|
||||||
|
"""Extract the static float from a value that may be BindableFloat or plain number.
|
||||||
|
|
||||||
|
Useful in processing code that reads source model properties which may
|
||||||
|
be either a plain ``float`` (legacy / unbound) or a ``BindableFloat``.
|
||||||
|
"""
|
||||||
|
if isinstance(v, BindableFloat):
|
||||||
|
return v.value
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
if hasattr(v, "value"):
|
||||||
|
return float(v.value)
|
||||||
|
return default
|
||||||
@@ -26,6 +26,7 @@ from wled_controller.core.capture.calibration import (
|
|||||||
calibration_from_dict,
|
calibration_from_dict,
|
||||||
calibration_to_dict,
|
calibration_to_dict,
|
||||||
)
|
)
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
from wled_controller.storage.utils import resolve_ref
|
from wled_controller.storage.utils import resolve_ref
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ def _parse_picture_fields(data: dict) -> dict:
|
|||||||
)
|
)
|
||||||
return dict(
|
return dict(
|
||||||
fps=data.get("fps") or 30,
|
fps=data.get("fps") or 30,
|
||||||
smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3,
|
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
|
||||||
interpolation_mode=data.get("interpolation_mode") or "average",
|
interpolation_mode=data.get("interpolation_mode") or "average",
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
led_count=data.get("led_count") or 0,
|
led_count=data.get("led_count") or 0,
|
||||||
@@ -161,7 +162,7 @@ def _parse_picture_fields(data: dict) -> dict:
|
|||||||
def _picture_base_to_dict(source, d: dict) -> dict:
|
def _picture_base_to_dict(source, d: dict) -> dict:
|
||||||
"""Populate dict with fields common to both picture source types."""
|
"""Populate dict with fields common to both picture source types."""
|
||||||
d["fps"] = source.fps
|
d["fps"] = source.fps
|
||||||
d["smoothing"] = source.smoothing
|
d["smoothing"] = source.smoothing.to_dict()
|
||||||
d["interpolation_mode"] = source.interpolation_mode
|
d["interpolation_mode"] = source.interpolation_mode
|
||||||
d["calibration"] = calibration_to_dict(source.calibration)
|
d["calibration"] = calibration_to_dict(source.calibration)
|
||||||
d["led_count"] = source.led_count
|
d["led_count"] = source.led_count
|
||||||
@@ -173,7 +174,7 @@ def _apply_picture_update(source, **kwargs) -> None:
|
|||||||
if kwargs.get("fps") is not None:
|
if kwargs.get("fps") is not None:
|
||||||
source.fps = kwargs["fps"]
|
source.fps = kwargs["fps"]
|
||||||
if kwargs.get("smoothing") is not None:
|
if kwargs.get("smoothing") is not None:
|
||||||
source.smoothing = kwargs["smoothing"]
|
source.smoothing = source.smoothing.apply_update(kwargs["smoothing"])
|
||||||
if kwargs.get("interpolation_mode") is not None:
|
if kwargs.get("interpolation_mode") is not None:
|
||||||
source.interpolation_mode = kwargs["interpolation_mode"]
|
source.interpolation_mode = kwargs["interpolation_mode"]
|
||||||
if kwargs.get("calibration") is not None:
|
if kwargs.get("calibration") is not None:
|
||||||
@@ -197,7 +198,7 @@ class PictureColorStripSource(ColorStripSource):
|
|||||||
|
|
||||||
picture_source_id: str = ""
|
picture_source_id: str = ""
|
||||||
fps: int = 30
|
fps: int = 30
|
||||||
smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full)
|
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
|
||||||
interpolation_mode: str = "average" # "average" | "median" | "dominant"
|
interpolation_mode: str = "average" # "average" | "median" | "dominant"
|
||||||
calibration: CalibrationConfig = field(
|
calibration: CalibrationConfig = field(
|
||||||
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||||
@@ -234,7 +235,7 @@ class PictureColorStripSource(ColorStripSource):
|
|||||||
tags=None,
|
tags=None,
|
||||||
picture_source_id="",
|
picture_source_id="",
|
||||||
fps=30,
|
fps=30,
|
||||||
smoothing=0.3,
|
smoothing=None,
|
||||||
interpolation_mode="average",
|
interpolation_mode="average",
|
||||||
calibration=None,
|
calibration=None,
|
||||||
led_count=0,
|
led_count=0,
|
||||||
@@ -253,7 +254,7 @@ class PictureColorStripSource(ColorStripSource):
|
|||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
picture_source_id=picture_source_id,
|
picture_source_id=picture_source_id,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
smoothing=smoothing,
|
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
|
||||||
interpolation_mode=interpolation_mode,
|
interpolation_mode=interpolation_mode,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
@@ -281,7 +282,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
fps: int = 30
|
fps: int = 30
|
||||||
smoothing: float = 0.3
|
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
|
||||||
interpolation_mode: str = "average"
|
interpolation_mode: str = "average"
|
||||||
calibration: CalibrationConfig = field(
|
calibration: CalibrationConfig = field(
|
||||||
default_factory=lambda: CalibrationConfig(mode="advanced")
|
default_factory=lambda: CalibrationConfig(mode="advanced")
|
||||||
@@ -311,7 +312,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
|
|||||||
clock_id=None,
|
clock_id=None,
|
||||||
tags=None,
|
tags=None,
|
||||||
fps=30,
|
fps=30,
|
||||||
smoothing=0.3,
|
smoothing=None,
|
||||||
interpolation_mode="average",
|
interpolation_mode="average",
|
||||||
calibration=None,
|
calibration=None,
|
||||||
led_count=0,
|
led_count=0,
|
||||||
@@ -329,7 +330,7 @@ class AdvancedPictureColorStripSource(ColorStripSource):
|
|||||||
clock_id=clock_id,
|
clock_id=clock_id,
|
||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
fps=fps,
|
fps=fps,
|
||||||
smoothing=smoothing,
|
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
|
||||||
interpolation_mode=interpolation_mode,
|
interpolation_mode=interpolation_mode,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
@@ -593,8 +594,8 @@ class EffectColorStripSource(ColorStripSource):
|
|||||||
color: list = field(
|
color: list = field(
|
||||||
default_factory=lambda: [255, 80, 0]
|
default_factory=lambda: [255, 80, 0]
|
||||||
) # [R,G,B] for meteor/comet/bouncing_ball head
|
) # [R,G,B] for meteor/comet/bouncing_ball head
|
||||||
intensity: float = 1.0 # effect-specific intensity (0.1-2.0)
|
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
scale: float = 1.0 # spatial scale / zoom (0.5-5.0)
|
scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
mirror: bool = False # bounce mode (meteor/comet)
|
mirror: bool = False # bounce mode (meteor/comet)
|
||||||
custom_palette: Optional[list] = None # legacy [[pos, R, G, B], ...] custom palette stops
|
custom_palette: Optional[list] = None # legacy [[pos, R, G, B], ...] custom palette stops
|
||||||
|
|
||||||
@@ -604,8 +605,8 @@ class EffectColorStripSource(ColorStripSource):
|
|||||||
d["palette"] = self.palette
|
d["palette"] = self.palette
|
||||||
d["gradient_id"] = self.gradient_id
|
d["gradient_id"] = self.gradient_id
|
||||||
d["color"] = list(self.color)
|
d["color"] = list(self.color)
|
||||||
d["intensity"] = self.intensity
|
d["intensity"] = self.intensity.to_dict()
|
||||||
d["scale"] = self.scale
|
d["scale"] = self.scale.to_dict()
|
||||||
d["mirror"] = self.mirror
|
d["mirror"] = self.mirror
|
||||||
d["custom_palette"] = self.custom_palette
|
d["custom_palette"] = self.custom_palette
|
||||||
return d
|
return d
|
||||||
@@ -621,8 +622,8 @@ class EffectColorStripSource(ColorStripSource):
|
|||||||
palette=data.get("palette") or "fire",
|
palette=data.get("palette") or "fire",
|
||||||
gradient_id=data.get("gradient_id"),
|
gradient_id=data.get("gradient_id"),
|
||||||
color=color,
|
color=color,
|
||||||
intensity=float(data.get("intensity") or 1.0),
|
intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
|
||||||
scale=float(data.get("scale") or 1.0),
|
scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
|
||||||
mirror=bool(data.get("mirror", False)),
|
mirror=bool(data.get("mirror", False)),
|
||||||
custom_palette=data.get("custom_palette"),
|
custom_palette=data.get("custom_palette"),
|
||||||
)
|
)
|
||||||
@@ -643,8 +644,8 @@ class EffectColorStripSource(ColorStripSource):
|
|||||||
palette="fire",
|
palette="fire",
|
||||||
gradient_id=None,
|
gradient_id=None,
|
||||||
color=None,
|
color=None,
|
||||||
intensity=1.0,
|
intensity=None,
|
||||||
scale=1.0,
|
scale=None,
|
||||||
mirror=False,
|
mirror=False,
|
||||||
custom_palette=None,
|
custom_palette=None,
|
||||||
**_kwargs,
|
**_kwargs,
|
||||||
@@ -663,8 +664,8 @@ class EffectColorStripSource(ColorStripSource):
|
|||||||
palette=palette or "fire",
|
palette=palette or "fire",
|
||||||
gradient_id=gradient_id,
|
gradient_id=gradient_id,
|
||||||
color=rgb,
|
color=rgb,
|
||||||
intensity=float(intensity) if intensity else 1.0,
|
intensity=BindableFloat.from_raw(intensity, default=1.0),
|
||||||
scale=float(scale) if scale else 1.0,
|
scale=BindableFloat.from_raw(scale, default=1.0),
|
||||||
mirror=bool(mirror),
|
mirror=bool(mirror),
|
||||||
custom_palette=custom_palette if isinstance(custom_palette, list) else None,
|
custom_palette=custom_palette if isinstance(custom_palette, list) else None,
|
||||||
)
|
)
|
||||||
@@ -680,9 +681,9 @@ class EffectColorStripSource(ColorStripSource):
|
|||||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||||
self.color = color
|
self.color = color
|
||||||
if kwargs.get("intensity") is not None:
|
if kwargs.get("intensity") is not None:
|
||||||
self.intensity = float(kwargs["intensity"])
|
self.intensity = self.intensity.apply_update(kwargs["intensity"])
|
||||||
if kwargs.get("scale") is not None:
|
if kwargs.get("scale") is not None:
|
||||||
self.scale = float(kwargs["scale"])
|
self.scale = self.scale.apply_update(kwargs["scale"])
|
||||||
if kwargs.get("mirror") is not None:
|
if kwargs.get("mirror") is not None:
|
||||||
self.mirror = bool(kwargs["mirror"])
|
self.mirror = bool(kwargs["mirror"])
|
||||||
if "custom_palette" in kwargs:
|
if "custom_palette" in kwargs:
|
||||||
@@ -701,8 +702,8 @@ class AudioColorStripSource(ColorStripSource):
|
|||||||
|
|
||||||
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
|
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
|
||||||
audio_source_id: str = "" # references a MonoAudioSource
|
audio_source_id: str = "" # references a MonoAudioSource
|
||||||
sensitivity: float = 1.0 # gain multiplier (0.1-5.0)
|
sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
smoothing: float = 0.3 # temporal smoothing (0.0-1.0)
|
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
|
||||||
palette: str = "rainbow" # legacy palette name (kept for migration)
|
palette: str = "rainbow" # legacy palette name (kept for migration)
|
||||||
gradient_id: Optional[str] = None # references a Gradient entity (preferred)
|
gradient_id: Optional[str] = None # references a Gradient entity (preferred)
|
||||||
color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter
|
color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter
|
||||||
@@ -714,8 +715,8 @@ class AudioColorStripSource(ColorStripSource):
|
|||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["visualization_mode"] = self.visualization_mode
|
d["visualization_mode"] = self.visualization_mode
|
||||||
d["audio_source_id"] = self.audio_source_id
|
d["audio_source_id"] = self.audio_source_id
|
||||||
d["sensitivity"] = self.sensitivity
|
d["sensitivity"] = self.sensitivity.to_dict()
|
||||||
d["smoothing"] = self.smoothing
|
d["smoothing"] = self.smoothing.to_dict()
|
||||||
d["palette"] = self.palette
|
d["palette"] = self.palette
|
||||||
d["gradient_id"] = self.gradient_id
|
d["gradient_id"] = self.gradient_id
|
||||||
d["color"] = list(self.color)
|
d["color"] = list(self.color)
|
||||||
@@ -734,8 +735,8 @@ class AudioColorStripSource(ColorStripSource):
|
|||||||
source_type="audio",
|
source_type="audio",
|
||||||
visualization_mode=data.get("visualization_mode") or "spectrum",
|
visualization_mode=data.get("visualization_mode") or "spectrum",
|
||||||
audio_source_id=data.get("audio_source_id") or "",
|
audio_source_id=data.get("audio_source_id") or "",
|
||||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
sensitivity=BindableFloat.from_raw(data.get("sensitivity"), default=1.0),
|
||||||
smoothing=float(data.get("smoothing") or 0.3),
|
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
|
||||||
palette=data.get("palette") or "rainbow",
|
palette=data.get("palette") or "rainbow",
|
||||||
gradient_id=data.get("gradient_id"),
|
gradient_id=data.get("gradient_id"),
|
||||||
color=color,
|
color=color,
|
||||||
@@ -758,8 +759,8 @@ class AudioColorStripSource(ColorStripSource):
|
|||||||
tags=None,
|
tags=None,
|
||||||
visualization_mode="spectrum",
|
visualization_mode="spectrum",
|
||||||
audio_source_id="",
|
audio_source_id="",
|
||||||
sensitivity=1.0,
|
sensitivity=None,
|
||||||
smoothing=0.3,
|
smoothing=None,
|
||||||
palette="rainbow",
|
palette="rainbow",
|
||||||
gradient_id=None,
|
gradient_id=None,
|
||||||
color=None,
|
color=None,
|
||||||
@@ -781,8 +782,8 @@ class AudioColorStripSource(ColorStripSource):
|
|||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
visualization_mode=visualization_mode or "spectrum",
|
visualization_mode=visualization_mode or "spectrum",
|
||||||
audio_source_id=audio_source_id or "",
|
audio_source_id=audio_source_id or "",
|
||||||
sensitivity=float(sensitivity) if sensitivity else 1.0,
|
sensitivity=BindableFloat.from_raw(sensitivity, default=1.0),
|
||||||
smoothing=float(smoothing) if smoothing else 0.3,
|
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
|
||||||
palette=palette or "rainbow",
|
palette=palette or "rainbow",
|
||||||
gradient_id=gradient_id,
|
gradient_id=gradient_id,
|
||||||
color=rgb,
|
color=rgb,
|
||||||
@@ -798,9 +799,9 @@ class AudioColorStripSource(ColorStripSource):
|
|||||||
if audio_source_id is not None:
|
if audio_source_id is not None:
|
||||||
self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id)
|
self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id)
|
||||||
if kwargs.get("sensitivity") is not None:
|
if kwargs.get("sensitivity") is not None:
|
||||||
self.sensitivity = float(kwargs["sensitivity"])
|
self.sensitivity = self.sensitivity.apply_update(kwargs["sensitivity"])
|
||||||
if kwargs.get("smoothing") is not None:
|
if kwargs.get("smoothing") is not None:
|
||||||
self.smoothing = float(kwargs["smoothing"])
|
self.smoothing = self.smoothing.apply_update(kwargs["smoothing"])
|
||||||
if kwargs.get("palette") is not None:
|
if kwargs.get("palette") is not None:
|
||||||
self.palette = kwargs["palette"]
|
self.palette = kwargs["palette"]
|
||||||
if "gradient_id" in kwargs:
|
if "gradient_id" in kwargs:
|
||||||
@@ -963,13 +964,13 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
|
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
|
||||||
timeout: float = 5.0 # seconds before reverting to fallback
|
timeout: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
|
||||||
interpolation: str = "linear" # none | linear | nearest
|
interpolation: str = "linear" # none | linear | nearest
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["fallback_color"] = list(self.fallback_color)
|
d["fallback_color"] = list(self.fallback_color)
|
||||||
d["timeout"] = self.timeout
|
d["timeout"] = self.timeout.to_dict()
|
||||||
d["interpolation"] = self.interpolation
|
d["interpolation"] = self.interpolation
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@@ -984,7 +985,7 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
**common,
|
**common,
|
||||||
source_type="api_input",
|
source_type="api_input",
|
||||||
fallback_color=fallback_color,
|
fallback_color=fallback_color,
|
||||||
timeout=float(data.get("timeout") or 5.0),
|
timeout=BindableFloat.from_raw(data.get("timeout"), default=5.0),
|
||||||
interpolation=interpolation,
|
interpolation=interpolation,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1017,7 +1018,7 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
clock_id=clock_id,
|
clock_id=clock_id,
|
||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
fallback_color=fb,
|
fallback_color=fb,
|
||||||
timeout=float(timeout) if timeout is not None else 5.0,
|
timeout=BindableFloat.from_raw(timeout, default=5.0),
|
||||||
interpolation=interp,
|
interpolation=interp,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1030,7 +1031,7 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
):
|
):
|
||||||
self.fallback_color = fallback_color
|
self.fallback_color = fallback_color
|
||||||
if kwargs.get("timeout") is not None:
|
if kwargs.get("timeout") is not None:
|
||||||
self.timeout = float(kwargs["timeout"])
|
self.timeout = self.timeout.apply_update(kwargs["timeout"])
|
||||||
interpolation = kwargs.get("interpolation")
|
interpolation = kwargs.get("interpolation")
|
||||||
if interpolation in ("none", "linear", "nearest"):
|
if interpolation in ("none", "linear", "nearest"):
|
||||||
self.interpolation = interpolation
|
self.interpolation = interpolation
|
||||||
@@ -1048,14 +1049,14 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
notification_effect: str = "flash" # flash | pulse | sweep
|
notification_effect: str = "flash" # flash | pulse | sweep
|
||||||
duration_ms: int = 1500 # effect duration in milliseconds
|
duration_ms: BindableFloat = field(default_factory=lambda: BindableFloat(1500.0))
|
||||||
default_color: str = "#FFFFFF" # hex color for notifications without app match
|
default_color: str = "#FFFFFF" # hex color for notifications without app match
|
||||||
app_colors: dict = field(default_factory=dict) # app name -> hex color
|
app_colors: dict = field(default_factory=dict) # app name -> hex color
|
||||||
app_filter_mode: str = "off" # off | whitelist | blacklist
|
app_filter_mode: str = "off" # off | whitelist | blacklist
|
||||||
app_filter_list: list = field(default_factory=list) # app names for filter
|
app_filter_list: list = field(default_factory=list) # app names for filter
|
||||||
os_listener: bool = False # whether to listen for OS notifications
|
os_listener: bool = False # whether to listen for OS notifications
|
||||||
sound_asset_id: Optional[str] = None # global notification sound (asset ID)
|
sound_asset_id: Optional[str] = None # global notification sound (asset ID)
|
||||||
sound_volume: float = 1.0 # global volume 0.0-1.0
|
sound_volume: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
app_sounds: dict = field(
|
app_sounds: dict = field(
|
||||||
default_factory=dict
|
default_factory=dict
|
||||||
) # app name -> {"sound_asset_id": str|None, "volume": float|None}
|
) # app name -> {"sound_asset_id": str|None, "volume": float|None}
|
||||||
@@ -1063,14 +1064,14 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["notification_effect"] = self.notification_effect
|
d["notification_effect"] = self.notification_effect
|
||||||
d["duration_ms"] = self.duration_ms
|
d["duration_ms"] = self.duration_ms.to_dict()
|
||||||
d["default_color"] = self.default_color
|
d["default_color"] = self.default_color
|
||||||
d["app_colors"] = dict(self.app_colors)
|
d["app_colors"] = dict(self.app_colors)
|
||||||
d["app_filter_mode"] = self.app_filter_mode
|
d["app_filter_mode"] = self.app_filter_mode
|
||||||
d["app_filter_list"] = list(self.app_filter_list)
|
d["app_filter_list"] = list(self.app_filter_list)
|
||||||
d["os_listener"] = self.os_listener
|
d["os_listener"] = self.os_listener
|
||||||
d["sound_asset_id"] = self.sound_asset_id
|
d["sound_asset_id"] = self.sound_asset_id
|
||||||
d["sound_volume"] = self.sound_volume
|
d["sound_volume"] = self.sound_volume.to_dict()
|
||||||
d["app_sounds"] = dict(self.app_sounds)
|
d["app_sounds"] = dict(self.app_sounds)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@@ -1084,14 +1085,14 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
**common,
|
**common,
|
||||||
source_type="notification",
|
source_type="notification",
|
||||||
notification_effect=data.get("notification_effect") or "flash",
|
notification_effect=data.get("notification_effect") or "flash",
|
||||||
duration_ms=int(data.get("duration_ms") or 1500),
|
duration_ms=BindableFloat.from_raw(data.get("duration_ms"), default=1500.0),
|
||||||
default_color=data.get("default_color") or "#FFFFFF",
|
default_color=data.get("default_color") or "#FFFFFF",
|
||||||
app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
|
app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
|
||||||
app_filter_mode=data.get("app_filter_mode") or "off",
|
app_filter_mode=data.get("app_filter_mode") or "off",
|
||||||
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
|
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
|
||||||
os_listener=bool(data.get("os_listener", False)),
|
os_listener=bool(data.get("os_listener", False)),
|
||||||
sound_asset_id=data.get("sound_asset_id"),
|
sound_asset_id=data.get("sound_asset_id"),
|
||||||
sound_volume=float(data.get("sound_volume", 1.0)),
|
sound_volume=BindableFloat.from_raw(data.get("sound_volume"), default=1.0),
|
||||||
app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {},
|
app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1129,14 +1130,14 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
clock_id=clock_id,
|
clock_id=clock_id,
|
||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
notification_effect=notification_effect or "flash",
|
notification_effect=notification_effect or "flash",
|
||||||
duration_ms=int(duration_ms) if duration_ms is not None else 1500,
|
duration_ms=BindableFloat.from_raw(duration_ms, default=1500.0),
|
||||||
default_color=default_color or "#FFFFFF",
|
default_color=default_color or "#FFFFFF",
|
||||||
app_colors=app_colors if isinstance(app_colors, dict) else {},
|
app_colors=app_colors if isinstance(app_colors, dict) else {},
|
||||||
app_filter_mode=app_filter_mode or "off",
|
app_filter_mode=app_filter_mode or "off",
|
||||||
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
||||||
os_listener=bool(os_listener) if os_listener is not None else False,
|
os_listener=bool(os_listener) if os_listener is not None else False,
|
||||||
sound_asset_id=sound_asset_id,
|
sound_asset_id=sound_asset_id,
|
||||||
sound_volume=float(sound_volume) if sound_volume is not None else 1.0,
|
sound_volume=BindableFloat.from_raw(sound_volume, default=1.0),
|
||||||
app_sounds=app_sounds if isinstance(app_sounds, dict) else {},
|
app_sounds=app_sounds if isinstance(app_sounds, dict) else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1144,7 +1145,7 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
if kwargs.get("notification_effect") is not None:
|
if kwargs.get("notification_effect") is not None:
|
||||||
self.notification_effect = kwargs["notification_effect"]
|
self.notification_effect = kwargs["notification_effect"]
|
||||||
if kwargs.get("duration_ms") is not None:
|
if kwargs.get("duration_ms") is not None:
|
||||||
self.duration_ms = int(kwargs["duration_ms"])
|
self.duration_ms = self.duration_ms.apply_update(kwargs["duration_ms"])
|
||||||
if kwargs.get("default_color") is not None:
|
if kwargs.get("default_color") is not None:
|
||||||
self.default_color = kwargs["default_color"]
|
self.default_color = kwargs["default_color"]
|
||||||
app_colors = kwargs.get("app_colors")
|
app_colors = kwargs.get("app_colors")
|
||||||
@@ -1160,7 +1161,7 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
if "sound_asset_id" in kwargs:
|
if "sound_asset_id" in kwargs:
|
||||||
self.sound_asset_id = kwargs["sound_asset_id"]
|
self.sound_asset_id = kwargs["sound_asset_id"]
|
||||||
if kwargs.get("sound_volume") is not None:
|
if kwargs.get("sound_volume") is not None:
|
||||||
self.sound_volume = float(kwargs["sound_volume"])
|
self.sound_volume = self.sound_volume.apply_update(kwargs["sound_volume"])
|
||||||
app_sounds = kwargs.get("app_sounds")
|
app_sounds = kwargs.get("app_sounds")
|
||||||
if app_sounds is not None and isinstance(app_sounds, dict):
|
if app_sounds is not None and isinstance(app_sounds, dict):
|
||||||
self.app_sounds = app_sounds
|
self.app_sounds = app_sounds
|
||||||
@@ -1180,14 +1181,14 @@ class DaylightColorStripSource(ColorStripSource):
|
|||||||
a full 24-hour cycle plays (1.0 = 4 minutes per full cycle).
|
a full 24-hour cycle plays (1.0 = 4 minutes per full cycle).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
speed: float = 1.0 # cycle speed (ignored when use_real_time)
|
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
use_real_time: bool = False # use actual time of day
|
use_real_time: bool = False # use actual time of day
|
||||||
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
|
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
|
||||||
longitude: float = 0.0 # longitude for solar position (-180..180)
|
longitude: float = 0.0 # longitude for solar position (-180..180)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["speed"] = self.speed
|
d["speed"] = self.speed.to_dict()
|
||||||
d["use_real_time"] = self.use_real_time
|
d["use_real_time"] = self.use_real_time
|
||||||
d["latitude"] = self.latitude
|
d["latitude"] = self.latitude
|
||||||
d["longitude"] = self.longitude
|
d["longitude"] = self.longitude
|
||||||
@@ -1199,9 +1200,10 @@ class DaylightColorStripSource(ColorStripSource):
|
|||||||
return cls(
|
return cls(
|
||||||
**common,
|
**common,
|
||||||
source_type="daylight",
|
source_type="daylight",
|
||||||
speed=float(data.get("speed") or 1.0),
|
speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
|
||||||
use_real_time=bool(data.get("use_real_time", False)),
|
use_real_time=bool(data.get("use_real_time", False)),
|
||||||
latitude=float(data.get("latitude") or 50.0),
|
latitude=float(data.get("latitude") or 50.0),
|
||||||
|
longitude=float(data.get("longitude") or 0.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1231,7 +1233,7 @@ class DaylightColorStripSource(ColorStripSource):
|
|||||||
description=description,
|
description=description,
|
||||||
clock_id=clock_id,
|
clock_id=clock_id,
|
||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
speed=float(speed) if speed is not None else 1.0,
|
speed=BindableFloat.from_raw(speed, default=1.0),
|
||||||
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
||||||
latitude=float(latitude) if latitude is not None else 50.0,
|
latitude=float(latitude) if latitude is not None else 50.0,
|
||||||
longitude=float(longitude) if longitude is not None else 0.0,
|
longitude=float(longitude) if longitude is not None else 0.0,
|
||||||
@@ -1239,7 +1241,7 @@ class DaylightColorStripSource(ColorStripSource):
|
|||||||
|
|
||||||
def apply_update(self, **kwargs) -> None:
|
def apply_update(self, **kwargs) -> None:
|
||||||
if kwargs.get("speed") is not None:
|
if kwargs.get("speed") is not None:
|
||||||
self.speed = float(kwargs["speed"])
|
self.speed = self.speed.apply_update(kwargs["speed"])
|
||||||
if kwargs.get("use_real_time") is not None:
|
if kwargs.get("use_real_time") is not None:
|
||||||
self.use_real_time = bool(kwargs["use_real_time"])
|
self.use_real_time = bool(kwargs["use_real_time"])
|
||||||
if kwargs.get("latitude") is not None:
|
if kwargs.get("latitude") is not None:
|
||||||
@@ -1258,19 +1260,19 @@ class CandlelightColorStripSource(ColorStripSource):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
|
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
|
||||||
intensity: float = 1.0 # flicker intensity (0.1-2.0)
|
intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
num_candles: int = 3 # number of independent candle sources
|
num_candles: int = 3 # number of independent candle sources
|
||||||
speed: float = 1.0 # flicker speed multiplier
|
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
wind_strength: float = 0.0 # wind effect (0.0-2.0)
|
wind_strength: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
|
||||||
candle_type: str = "default" # default | taper | votive | bonfire
|
candle_type: str = "default" # default | taper | votive | bonfire
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["color"] = list(self.color)
|
d["color"] = list(self.color)
|
||||||
d["intensity"] = self.intensity
|
d["intensity"] = self.intensity.to_dict()
|
||||||
d["num_candles"] = self.num_candles
|
d["num_candles"] = self.num_candles
|
||||||
d["speed"] = self.speed
|
d["speed"] = self.speed.to_dict()
|
||||||
d["wind_strength"] = self.wind_strength
|
d["wind_strength"] = self.wind_strength.to_dict()
|
||||||
d["candle_type"] = self.candle_type
|
d["candle_type"] = self.candle_type
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@@ -1282,9 +1284,11 @@ class CandlelightColorStripSource(ColorStripSource):
|
|||||||
**common,
|
**common,
|
||||||
source_type="candlelight",
|
source_type="candlelight",
|
||||||
color=color,
|
color=color,
|
||||||
intensity=float(data.get("intensity") or 1.0),
|
intensity=BindableFloat.from_raw(data.get("intensity"), default=1.0),
|
||||||
num_candles=int(data.get("num_candles") or 3),
|
num_candles=int(data.get("num_candles") or 3),
|
||||||
speed=float(data.get("speed") or 1.0),
|
speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
|
||||||
|
wind_strength=BindableFloat.from_raw(data.get("wind_strength"), default=0.0),
|
||||||
|
candle_type=data.get("candle_type") or "default",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1300,7 +1304,7 @@ class CandlelightColorStripSource(ColorStripSource):
|
|||||||
clock_id=None,
|
clock_id=None,
|
||||||
tags=None,
|
tags=None,
|
||||||
color=None,
|
color=None,
|
||||||
intensity=1.0,
|
intensity=None,
|
||||||
num_candles=None,
|
num_candles=None,
|
||||||
speed=None,
|
speed=None,
|
||||||
wind_strength=None,
|
wind_strength=None,
|
||||||
@@ -1318,10 +1322,10 @@ class CandlelightColorStripSource(ColorStripSource):
|
|||||||
clock_id=clock_id,
|
clock_id=clock_id,
|
||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
color=rgb,
|
color=rgb,
|
||||||
intensity=float(intensity) if intensity else 1.0,
|
intensity=BindableFloat.from_raw(intensity, default=1.0),
|
||||||
num_candles=int(num_candles) if num_candles is not None else 3,
|
num_candles=int(num_candles) if num_candles is not None else 3,
|
||||||
speed=float(speed) if speed is not None else 1.0,
|
speed=BindableFloat.from_raw(speed, default=1.0),
|
||||||
wind_strength=float(wind_strength) if wind_strength is not None else 0.0,
|
wind_strength=BindableFloat.from_raw(wind_strength, default=0.0),
|
||||||
candle_type=(
|
candle_type=(
|
||||||
candle_type
|
candle_type
|
||||||
if candle_type in {"default", "taper", "votive", "bonfire"}
|
if candle_type in {"default", "taper", "votive", "bonfire"}
|
||||||
@@ -1334,13 +1338,13 @@ class CandlelightColorStripSource(ColorStripSource):
|
|||||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||||
self.color = color
|
self.color = color
|
||||||
if kwargs.get("intensity") is not None:
|
if kwargs.get("intensity") is not None:
|
||||||
self.intensity = float(kwargs["intensity"])
|
self.intensity = self.intensity.apply_update(kwargs["intensity"])
|
||||||
if kwargs.get("num_candles") is not None:
|
if kwargs.get("num_candles") is not None:
|
||||||
self.num_candles = int(kwargs["num_candles"])
|
self.num_candles = int(kwargs["num_candles"])
|
||||||
if kwargs.get("speed") is not None:
|
if kwargs.get("speed") is not None:
|
||||||
self.speed = float(kwargs["speed"])
|
self.speed = self.speed.apply_update(kwargs["speed"])
|
||||||
if kwargs.get("wind_strength") is not None:
|
if kwargs.get("wind_strength") is not None:
|
||||||
self.wind_strength = float(kwargs["wind_strength"])
|
self.wind_strength = self.wind_strength.apply_update(kwargs["wind_strength"])
|
||||||
ct = kwargs.get("candle_type")
|
ct = kwargs.get("candle_type")
|
||||||
if ct is not None and ct in {"default", "taper", "votive", "bonfire"}:
|
if ct is not None and ct in {"default", "taper", "votive", "bonfire"}:
|
||||||
self.candle_type = ct
|
self.candle_type = ct
|
||||||
@@ -1425,14 +1429,14 @@ class WeatherColorStripSource(ColorStripSource):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
weather_source_id: str = "" # reference to WeatherSource entity
|
weather_source_id: str = "" # reference to WeatherSource entity
|
||||||
speed: float = 1.0 # ambient drift animation speed
|
speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
temperature_influence: float = 0.5 # 0.0=none, 1.0=full temp hue shift
|
temperature_influence: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["weather_source_id"] = self.weather_source_id
|
d["weather_source_id"] = self.weather_source_id
|
||||||
d["speed"] = self.speed
|
d["speed"] = self.speed.to_dict()
|
||||||
d["temperature_influence"] = self.temperature_influence
|
d["temperature_influence"] = self.temperature_influence.to_dict()
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1442,11 +1446,9 @@ class WeatherColorStripSource(ColorStripSource):
|
|||||||
**common,
|
**common,
|
||||||
source_type="weather",
|
source_type="weather",
|
||||||
weather_source_id=data.get("weather_source_id", ""),
|
weather_source_id=data.get("weather_source_id", ""),
|
||||||
speed=float(data.get("speed") or 1.0),
|
speed=BindableFloat.from_raw(data.get("speed"), default=1.0),
|
||||||
temperature_influence=float(
|
temperature_influence=BindableFloat.from_raw(
|
||||||
data.get("temperature_influence")
|
data.get("temperature_influence"), default=0.5
|
||||||
if data.get("temperature_influence") is not None
|
|
||||||
else 0.5
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1477,10 +1479,8 @@ class WeatherColorStripSource(ColorStripSource):
|
|||||||
clock_id=clock_id,
|
clock_id=clock_id,
|
||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
weather_source_id=weather_source_id or "",
|
weather_source_id=weather_source_id or "",
|
||||||
speed=float(speed) if speed is not None else 1.0,
|
speed=BindableFloat.from_raw(speed, default=1.0),
|
||||||
temperature_influence=(
|
temperature_influence=BindableFloat.from_raw(temperature_influence, default=0.5),
|
||||||
float(temperature_influence) if temperature_influence is not None else 0.5
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def apply_update(self, **kwargs) -> None:
|
def apply_update(self, **kwargs) -> None:
|
||||||
@@ -1489,9 +1489,11 @@ class WeatherColorStripSource(ColorStripSource):
|
|||||||
kwargs["weather_source_id"], self.weather_source_id
|
kwargs["weather_source_id"], self.weather_source_id
|
||||||
)
|
)
|
||||||
if kwargs.get("speed") is not None:
|
if kwargs.get("speed") is not None:
|
||||||
self.speed = float(kwargs["speed"])
|
self.speed = self.speed.apply_update(kwargs["speed"])
|
||||||
if kwargs.get("temperature_influence") is not None:
|
if kwargs.get("temperature_influence") is not None:
|
||||||
self.temperature_influence = float(kwargs["temperature_influence"])
|
self.temperature_influence = self.temperature_influence.apply_update(
|
||||||
|
kwargs["temperature_influence"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1536,9 +1538,8 @@ class KeyColorsColorStripSource(ColorStripSource):
|
|||||||
picture_source_id: str = ""
|
picture_source_id: str = ""
|
||||||
rectangles: List[KeyColorRectangle] = field(default_factory=list)
|
rectangles: List[KeyColorRectangle] = field(default_factory=list)
|
||||||
interpolation_mode: str = "average" # average, median, dominant
|
interpolation_mode: str = "average" # average, median, dominant
|
||||||
smoothing: float = 0.3
|
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
|
||||||
brightness: float = 1.0
|
brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
brightness_value_source_id: str = ""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sharable(self) -> bool:
|
def sharable(self) -> bool:
|
||||||
@@ -1549,24 +1550,28 @@ class KeyColorsColorStripSource(ColorStripSource):
|
|||||||
d["picture_source_id"] = self.picture_source_id
|
d["picture_source_id"] = self.picture_source_id
|
||||||
d["rectangles"] = [r.to_dict() for r in self.rectangles]
|
d["rectangles"] = [r.to_dict() for r in self.rectangles]
|
||||||
d["interpolation_mode"] = self.interpolation_mode
|
d["interpolation_mode"] = self.interpolation_mode
|
||||||
d["smoothing"] = self.smoothing
|
d["smoothing"] = self.smoothing.to_dict()
|
||||||
d["brightness"] = self.brightness
|
d["brightness"] = self.brightness.to_dict()
|
||||||
d["brightness_value_source_id"] = self.brightness_value_source_id
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "KeyColorsColorStripSource":
|
def from_dict(cls, data: dict) -> "KeyColorsColorStripSource":
|
||||||
common = _parse_css_common(data)
|
common = _parse_css_common(data)
|
||||||
rects = [KeyColorRectangle.from_dict(r) for r in data.get("rectangles", [])]
|
rects = [KeyColorRectangle.from_dict(r) for r in data.get("rectangles", [])]
|
||||||
|
# Legacy migration: brightness_value_source_id → brightness.source_id
|
||||||
|
brightness = BindableFloat.from_legacy(
|
||||||
|
data.get("brightness"),
|
||||||
|
legacy_source_id=data.get("brightness_value_source_id", ""),
|
||||||
|
default=1.0,
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
**common,
|
**common,
|
||||||
source_type="key_colors",
|
source_type="key_colors",
|
||||||
picture_source_id=data.get("picture_source_id", ""),
|
picture_source_id=data.get("picture_source_id", ""),
|
||||||
rectangles=rects,
|
rectangles=rects,
|
||||||
interpolation_mode=data.get("interpolation_mode", "average"),
|
interpolation_mode=data.get("interpolation_mode", "average"),
|
||||||
smoothing=float(data.get("smoothing") if data.get("smoothing") is not None else 0.3),
|
smoothing=BindableFloat.from_raw(data.get("smoothing"), default=0.3),
|
||||||
brightness=float(data.get("brightness") if data.get("brightness") is not None else 1.0),
|
brightness=brightness,
|
||||||
brightness_value_source_id=data.get("brightness_value_source_id", ""),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1592,6 +1597,14 @@ class KeyColorsColorStripSource(ColorStripSource):
|
|||||||
rects = [
|
rects = [
|
||||||
KeyColorRectangle.from_dict(r) if isinstance(r, dict) else r for r in (rectangles or [])
|
KeyColorRectangle.from_dict(r) if isinstance(r, dict) else r for r in (rectangles or [])
|
||||||
]
|
]
|
||||||
|
# Handle legacy brightness_value_source_id kwarg
|
||||||
|
if brightness_value_source_id and not isinstance(brightness, dict):
|
||||||
|
bright = BindableFloat(
|
||||||
|
value=float(brightness) if brightness is not None else 1.0,
|
||||||
|
source_id=brightness_value_source_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bright = BindableFloat.from_raw(brightness, default=1.0)
|
||||||
return cls(
|
return cls(
|
||||||
id=id,
|
id=id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -1604,9 +1617,8 @@ class KeyColorsColorStripSource(ColorStripSource):
|
|||||||
picture_source_id=picture_source_id or "",
|
picture_source_id=picture_source_id or "",
|
||||||
rectangles=rects,
|
rectangles=rects,
|
||||||
interpolation_mode=interpolation_mode or "average",
|
interpolation_mode=interpolation_mode or "average",
|
||||||
smoothing=float(smoothing) if smoothing is not None else 0.3,
|
smoothing=BindableFloat.from_raw(smoothing, default=0.3),
|
||||||
brightness=float(brightness) if brightness is not None else 1.0,
|
brightness=bright,
|
||||||
brightness_value_source_id=brightness_value_source_id or "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def apply_update(self, **kwargs) -> None:
|
def apply_update(self, **kwargs) -> None:
|
||||||
@@ -1622,12 +1634,16 @@ class KeyColorsColorStripSource(ColorStripSource):
|
|||||||
if kwargs.get("interpolation_mode") is not None:
|
if kwargs.get("interpolation_mode") is not None:
|
||||||
self.interpolation_mode = kwargs["interpolation_mode"]
|
self.interpolation_mode = kwargs["interpolation_mode"]
|
||||||
if kwargs.get("smoothing") is not None:
|
if kwargs.get("smoothing") is not None:
|
||||||
self.smoothing = float(kwargs["smoothing"])
|
self.smoothing = self.smoothing.apply_update(kwargs["smoothing"])
|
||||||
if kwargs.get("brightness") is not None:
|
if kwargs.get("brightness") is not None:
|
||||||
self.brightness = float(kwargs["brightness"])
|
self.brightness = self.brightness.apply_update(kwargs["brightness"])
|
||||||
if kwargs.get("brightness_value_source_id") is not None:
|
if kwargs.get("brightness_value_source_id") is not None:
|
||||||
self.brightness_value_source_id = resolve_ref(
|
# Legacy compat: update just the source_id part
|
||||||
kwargs["brightness_value_source_id"], self.brightness_value_source_id
|
self.brightness = BindableFloat(
|
||||||
|
value=self.brightness.value,
|
||||||
|
source_id=resolve_ref(
|
||||||
|
kwargs["brightness_value_source_id"], self.brightness.source_id
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
from wled_controller.storage.output_target import OutputTarget
|
from wled_controller.storage.output_target import OutputTarget
|
||||||
|
from wled_controller.storage.utils import resolve_ref
|
||||||
|
|
||||||
def _resolve_ref(new_val: str, old_val: str) -> str:
|
|
||||||
"""Resolve entity reference: empty string clears, non-empty replaces."""
|
|
||||||
return "" if new_val == "" else (new_val or old_val)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -19,14 +16,14 @@ class HALightMapping:
|
|||||||
entity_id: str = "" # e.g. "light.living_room"
|
entity_id: str = "" # e.g. "light.living_room"
|
||||||
led_start: int = 0 # start LED index (0-based)
|
led_start: int = 0 # start LED index (0-based)
|
||||||
led_end: int = -1 # end LED index (-1 = last)
|
led_end: int = -1 # end LED index (-1 = last)
|
||||||
brightness_scale: float = 1.0 # 0.0-1.0 multiplier on brightness
|
brightness_scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"entity_id": self.entity_id,
|
"entity_id": self.entity_id,
|
||||||
"led_start": self.led_start,
|
"led_start": self.led_start,
|
||||||
"led_end": self.led_end,
|
"led_end": self.led_end,
|
||||||
"brightness_scale": self.brightness_scale,
|
"brightness_scale": self.brightness_scale.to_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -35,7 +32,7 @@ class HALightMapping:
|
|||||||
entity_id=data.get("entity_id", ""),
|
entity_id=data.get("entity_id", ""),
|
||||||
led_start=data.get("led_start", 0),
|
led_start=data.get("led_start", 0),
|
||||||
led_end=data.get("led_end", -1),
|
led_end=data.get("led_end", -1),
|
||||||
brightness_scale=data.get("brightness_scale", 1.0),
|
brightness_scale=BindableFloat.from_raw(data.get("brightness_scale"), default=1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -45,12 +42,12 @@ class HALightOutputTarget(OutputTarget):
|
|||||||
|
|
||||||
ha_source_id: str = "" # references HomeAssistantSource
|
ha_source_id: str = "" # references HomeAssistantSource
|
||||||
color_strip_source_id: str = "" # CSS providing the colors
|
color_strip_source_id: str = "" # CSS providing the colors
|
||||||
brightness_value_source_id: str = "" # dynamic brightness multiplier
|
brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
light_mappings: List[HALightMapping] = field(default_factory=list)
|
light_mappings: List[HALightMapping] = field(default_factory=list)
|
||||||
update_rate: float = 2.0 # Hz (calls per second, 0.5-5.0)
|
update_rate: BindableFloat = field(default_factory=lambda: BindableFloat(2.0))
|
||||||
transition: float = 0.5 # HA transition seconds (smooth fade between colors)
|
transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.5))
|
||||||
min_brightness_threshold: int = 0 # below this brightness → turn off light
|
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
|
||||||
color_tolerance: int = 5 # skip service call if RGB delta < this
|
color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
|
||||||
|
|
||||||
def register_with_manager(self, manager) -> None:
|
def register_with_manager(self, manager) -> None:
|
||||||
"""Register this HA light target with the processor manager."""
|
"""Register this HA light target with the processor manager."""
|
||||||
@@ -59,7 +56,7 @@ class HALightOutputTarget(OutputTarget):
|
|||||||
target_id=self.id,
|
target_id=self.id,
|
||||||
ha_source_id=self.ha_source_id,
|
ha_source_id=self.ha_source_id,
|
||||||
color_strip_source_id=self.color_strip_source_id,
|
color_strip_source_id=self.color_strip_source_id,
|
||||||
brightness_value_source_id=self.brightness_value_source_id,
|
brightness=self.brightness,
|
||||||
light_mappings=self.light_mappings,
|
light_mappings=self.light_mappings,
|
||||||
update_rate=self.update_rate,
|
update_rate=self.update_rate,
|
||||||
transition=self.transition,
|
transition=self.transition,
|
||||||
@@ -82,6 +79,7 @@ class HALightOutputTarget(OutputTarget):
|
|||||||
manager.update_target_settings(
|
manager.update_target_settings(
|
||||||
self.id,
|
self.id,
|
||||||
{
|
{
|
||||||
|
"brightness": self.brightness,
|
||||||
"update_rate": self.update_rate,
|
"update_rate": self.update_rate,
|
||||||
"transition": self.transition,
|
"transition": self.transition,
|
||||||
"min_brightness_threshold": self.min_brightness_threshold,
|
"min_brightness_threshold": self.min_brightness_threshold,
|
||||||
@@ -98,6 +96,8 @@ class HALightOutputTarget(OutputTarget):
|
|||||||
name=None,
|
name=None,
|
||||||
ha_source_id=None,
|
ha_source_id=None,
|
||||||
color_strip_source_id=None,
|
color_strip_source_id=None,
|
||||||
|
brightness=None,
|
||||||
|
# legacy compat
|
||||||
brightness_value_source_id=None,
|
brightness_value_source_id=None,
|
||||||
light_mappings=None,
|
light_mappings=None,
|
||||||
update_rate=None,
|
update_rate=None,
|
||||||
@@ -111,53 +111,66 @@ class HALightOutputTarget(OutputTarget):
|
|||||||
"""Apply mutable field updates."""
|
"""Apply mutable field updates."""
|
||||||
super().update_fields(name=name, description=description, tags=tags)
|
super().update_fields(name=name, description=description, tags=tags)
|
||||||
if ha_source_id is not None:
|
if ha_source_id is not None:
|
||||||
self.ha_source_id = _resolve_ref(ha_source_id, self.ha_source_id)
|
self.ha_source_id = resolve_ref(ha_source_id, self.ha_source_id)
|
||||||
if color_strip_source_id is not None:
|
if color_strip_source_id is not None:
|
||||||
self.color_strip_source_id = _resolve_ref(
|
self.color_strip_source_id = resolve_ref(
|
||||||
color_strip_source_id, self.color_strip_source_id
|
color_strip_source_id, self.color_strip_source_id
|
||||||
)
|
)
|
||||||
if brightness_value_source_id is not None:
|
if brightness is not None:
|
||||||
self.brightness_value_source_id = _resolve_ref(
|
self.brightness = self.brightness.apply_update(brightness)
|
||||||
brightness_value_source_id, self.brightness_value_source_id
|
elif brightness_value_source_id is not None:
|
||||||
|
self.brightness = BindableFloat(
|
||||||
|
value=self.brightness.value,
|
||||||
|
source_id=resolve_ref(brightness_value_source_id, self.brightness.source_id),
|
||||||
)
|
)
|
||||||
if light_mappings is not None:
|
if light_mappings is not None:
|
||||||
self.light_mappings = light_mappings
|
self.light_mappings = light_mappings
|
||||||
if update_rate is not None:
|
if update_rate is not None:
|
||||||
self.update_rate = max(0.5, min(5.0, float(update_rate)))
|
self.update_rate = self.update_rate.apply_update(update_rate)
|
||||||
if transition is not None:
|
if transition is not None:
|
||||||
self.transition = max(0.0, min(10.0, float(transition)))
|
self.transition = self.transition.apply_update(transition)
|
||||||
if min_brightness_threshold is not None:
|
if min_brightness_threshold is not None:
|
||||||
self.min_brightness_threshold = int(min_brightness_threshold)
|
self.min_brightness_threshold = self.min_brightness_threshold.apply_update(
|
||||||
|
min_brightness_threshold
|
||||||
|
)
|
||||||
if color_tolerance is not None:
|
if color_tolerance is not None:
|
||||||
self.color_tolerance = int(color_tolerance)
|
self.color_tolerance = self.color_tolerance.apply_update(color_tolerance)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["ha_source_id"] = self.ha_source_id
|
d["ha_source_id"] = self.ha_source_id
|
||||||
d["color_strip_source_id"] = self.color_strip_source_id
|
d["color_strip_source_id"] = self.color_strip_source_id
|
||||||
d["brightness_value_source_id"] = self.brightness_value_source_id
|
d["brightness"] = self.brightness.to_dict()
|
||||||
d["light_mappings"] = [m.to_dict() for m in self.light_mappings]
|
d["light_mappings"] = [m.to_dict() for m in self.light_mappings]
|
||||||
d["update_rate"] = self.update_rate
|
d["update_rate"] = self.update_rate.to_dict()
|
||||||
d["transition"] = self.transition
|
d["transition"] = self.transition.to_dict()
|
||||||
d["min_brightness_threshold"] = self.min_brightness_threshold
|
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
|
||||||
d["color_tolerance"] = self.color_tolerance
|
d["color_tolerance"] = self.color_tolerance.to_dict()
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "HALightOutputTarget":
|
def from_dict(cls, data: dict) -> "HALightOutputTarget":
|
||||||
mappings = [HALightMapping.from_dict(m) for m in data.get("light_mappings", [])]
|
mappings = [HALightMapping.from_dict(m) for m in data.get("light_mappings", [])]
|
||||||
|
# Legacy migration: brightness_value_source_id → brightness.source_id
|
||||||
|
brightness = BindableFloat.from_legacy(
|
||||||
|
data.get("brightness"),
|
||||||
|
legacy_source_id=data.get("brightness_value_source_id", ""),
|
||||||
|
default=1.0,
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
target_type="ha_light",
|
target_type="ha_light",
|
||||||
ha_source_id=data.get("ha_source_id", ""),
|
ha_source_id=data.get("ha_source_id", ""),
|
||||||
color_strip_source_id=data.get("color_strip_source_id", ""),
|
color_strip_source_id=data.get("color_strip_source_id", ""),
|
||||||
brightness_value_source_id=data.get("brightness_value_source_id", ""),
|
brightness=brightness,
|
||||||
light_mappings=mappings,
|
light_mappings=mappings,
|
||||||
update_rate=data.get("update_rate", 2.0),
|
update_rate=BindableFloat.from_raw(data.get("update_rate"), default=2.0),
|
||||||
transition=data.get("transition", 0.5),
|
transition=BindableFloat.from_raw(data.get("transition"), default=0.5),
|
||||||
min_brightness_threshold=data.get("min_brightness_threshold", 0),
|
min_brightness_threshold=BindableFloat.from_raw(
|
||||||
color_tolerance=data.get("color_tolerance", 5),
|
data.get("min_brightness_threshold"), default=0.0
|
||||||
|
),
|
||||||
|
color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.0),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
tags=data.get("tags", []),
|
tags=data.get("tags", []),
|
||||||
created_at=datetime.fromisoformat(
|
created_at=datetime.fromisoformat(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
|
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
from wled_controller.storage.database import Database
|
from wled_controller.storage.database import Database
|
||||||
from wled_controller.storage.output_target import OutputTarget
|
from wled_controller.storage.output_target import OutputTarget
|
||||||
from wled_controller.storage.wled_output_target import WledOutputTarget
|
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||||
@@ -39,7 +40,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
target_type: str,
|
target_type: str,
|
||||||
device_id: str = "",
|
device_id: str = "",
|
||||||
color_strip_source_id: str = "",
|
color_strip_source_id: str = "",
|
||||||
brightness_value_source_id: str = "",
|
brightness=None,
|
||||||
fps: int = 30,
|
fps: int = 30,
|
||||||
keepalive_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
@@ -51,8 +52,10 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
ha_source_id: str = "",
|
ha_source_id: str = "",
|
||||||
ha_light_mappings: Optional[List[HALightMapping]] = None,
|
ha_light_mappings: Optional[List[HALightMapping]] = None,
|
||||||
update_rate: float = 2.0,
|
update_rate: float = 2.0,
|
||||||
transition: float = 0.5,
|
transition=None,
|
||||||
color_tolerance: int = 5,
|
color_tolerance: int = 5,
|
||||||
|
# legacy compat
|
||||||
|
brightness_value_source_id: str = "",
|
||||||
) -> OutputTarget:
|
) -> OutputTarget:
|
||||||
"""Create a new output target.
|
"""Create a new output target.
|
||||||
|
|
||||||
@@ -70,6 +73,16 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
target_id = f"pt_{uuid.uuid4().hex[:8]}"
|
target_id = f"pt_{uuid.uuid4().hex[:8]}"
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Resolve brightness to BindableFloat
|
||||||
|
if isinstance(brightness, BindableFloat):
|
||||||
|
bright = brightness
|
||||||
|
elif brightness is not None:
|
||||||
|
bright = BindableFloat.from_raw(brightness, default=1.0)
|
||||||
|
elif brightness_value_source_id:
|
||||||
|
bright = BindableFloat(1.0, source_id=brightness_value_source_id)
|
||||||
|
else:
|
||||||
|
bright = BindableFloat(1.0)
|
||||||
|
|
||||||
if target_type == "led":
|
if target_type == "led":
|
||||||
target: OutputTarget = WledOutputTarget(
|
target: OutputTarget = WledOutputTarget(
|
||||||
id=target_id,
|
id=target_id,
|
||||||
@@ -77,11 +90,13 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
target_type="led",
|
target_type="led",
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
brightness_value_source_id=brightness_value_source_id,
|
brightness=bright,
|
||||||
fps=fps,
|
fps=BindableFloat.from_raw(fps, default=30.0),
|
||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=BindableFloat.from_raw(
|
||||||
|
min_brightness_threshold, default=0.0
|
||||||
|
),
|
||||||
adaptive_fps=adaptive_fps,
|
adaptive_fps=adaptive_fps,
|
||||||
protocol=protocol,
|
protocol=protocol,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -89,17 +104,28 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
elif target_type == "ha_light":
|
elif target_type == "ha_light":
|
||||||
|
# Resolve transition
|
||||||
|
if isinstance(transition, BindableFloat):
|
||||||
|
trans = transition
|
||||||
|
elif transition is not None:
|
||||||
|
trans = BindableFloat.from_raw(transition, default=0.5)
|
||||||
|
else:
|
||||||
|
trans = BindableFloat(0.5)
|
||||||
|
|
||||||
target = HALightOutputTarget(
|
target = HALightOutputTarget(
|
||||||
id=target_id,
|
id=target_id,
|
||||||
name=name,
|
name=name,
|
||||||
target_type="ha_light",
|
target_type="ha_light",
|
||||||
ha_source_id=ha_source_id,
|
ha_source_id=ha_source_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
|
brightness=bright,
|
||||||
light_mappings=ha_light_mappings or [],
|
light_mappings=ha_light_mappings or [],
|
||||||
update_rate=update_rate,
|
update_rate=BindableFloat.from_raw(update_rate, default=2.0),
|
||||||
transition=transition,
|
transition=trans,
|
||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=BindableFloat.from_raw(
|
||||||
color_tolerance=color_tolerance,
|
min_brightness_threshold, default=0.0
|
||||||
|
),
|
||||||
|
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0),
|
||||||
description=description,
|
description=description,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -117,23 +143,25 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
def update_target(
|
def update_target(
|
||||||
self,
|
self,
|
||||||
target_id: str,
|
target_id: str,
|
||||||
name: Optional[str] = None,
|
name=None,
|
||||||
device_id: Optional[str] = None,
|
device_id=None,
|
||||||
color_strip_source_id: Optional[str] = None,
|
color_strip_source_id=None,
|
||||||
brightness_value_source_id: Optional[str] = None,
|
brightness=None,
|
||||||
fps: Optional[int] = None,
|
fps=None,
|
||||||
keepalive_interval: Optional[float] = None,
|
keepalive_interval=None,
|
||||||
state_check_interval: Optional[int] = None,
|
state_check_interval=None,
|
||||||
min_brightness_threshold: Optional[int] = None,
|
min_brightness_threshold=None,
|
||||||
adaptive_fps: Optional[bool] = None,
|
adaptive_fps=None,
|
||||||
protocol: Optional[str] = None,
|
protocol=None,
|
||||||
description: Optional[str] = None,
|
description=None,
|
||||||
tags: Optional[List[str]] = None,
|
tags=None,
|
||||||
ha_source_id: Optional[str] = None,
|
ha_source_id=None,
|
||||||
ha_light_mappings: Optional[List[HALightMapping]] = None,
|
ha_light_mappings=None,
|
||||||
update_rate: Optional[float] = None,
|
update_rate=None,
|
||||||
transition: Optional[float] = None,
|
transition=None,
|
||||||
color_tolerance: Optional[int] = None,
|
color_tolerance=None,
|
||||||
|
# legacy compat
|
||||||
|
brightness_value_source_id=None,
|
||||||
) -> OutputTarget:
|
) -> OutputTarget:
|
||||||
"""Update an output target.
|
"""Update an output target.
|
||||||
|
|
||||||
@@ -155,6 +183,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
name=name,
|
name=name,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
|
brightness=brightness,
|
||||||
brightness_value_source_id=brightness_value_source_id,
|
brightness_value_source_id=brightness_value_source_id,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""LED output target — sends color strip sources to an LED device."""
|
"""LED output target — sends color strip sources to an LED device."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from wled_controller.storage.bindable import BindableFloat
|
||||||
from wled_controller.storage.output_target import OutputTarget
|
from wled_controller.storage.output_target import OutputTarget
|
||||||
from wled_controller.storage.utils import resolve_ref
|
from wled_controller.storage.utils import resolve_ref
|
||||||
|
|
||||||
@@ -16,13 +17,13 @@ class WledOutputTarget(OutputTarget):
|
|||||||
|
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
color_strip_source_id: str = ""
|
color_strip_source_id: str = ""
|
||||||
brightness_value_source_id: str = ""
|
brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||||
fps: int = 30 # target send FPS (1-90)
|
fps: BindableFloat = field(default_factory=lambda: BindableFloat(30.0))
|
||||||
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||||
min_brightness_threshold: int = 0 # brightness below this → 0 (disabled when 0)
|
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
|
||||||
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
|
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
|
||||||
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
|
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
|
||||||
|
|
||||||
def register_with_manager(self, manager) -> None:
|
def register_with_manager(self, manager) -> None:
|
||||||
"""Register this WLED target with the processor manager."""
|
"""Register this WLED target with the processor manager."""
|
||||||
@@ -31,58 +32,84 @@ class WledOutputTarget(OutputTarget):
|
|||||||
target_id=self.id,
|
target_id=self.id,
|
||||||
device_id=self.device_id,
|
device_id=self.device_id,
|
||||||
color_strip_source_id=self.color_strip_source_id,
|
color_strip_source_id=self.color_strip_source_id,
|
||||||
|
brightness=self.brightness,
|
||||||
fps=self.fps,
|
fps=self.fps,
|
||||||
keepalive_interval=self.keepalive_interval,
|
keepalive_interval=self.keepalive_interval,
|
||||||
state_check_interval=self.state_check_interval,
|
state_check_interval=self.state_check_interval,
|
||||||
brightness_value_source_id=self.brightness_value_source_id,
|
|
||||||
min_brightness_threshold=self.min_brightness_threshold,
|
min_brightness_threshold=self.min_brightness_threshold,
|
||||||
adaptive_fps=self.adaptive_fps,
|
adaptive_fps=self.adaptive_fps,
|
||||||
protocol=self.protocol,
|
protocol=self.protocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync_with_manager(self, manager, *, settings_changed: bool,
|
def sync_with_manager(
|
||||||
css_changed: bool = False,
|
self,
|
||||||
brightness_vs_changed: bool = False) -> None:
|
manager,
|
||||||
"""Push changed fields to the processor manager.
|
*,
|
||||||
|
settings_changed: bool,
|
||||||
NOTE: device_changed is handled separately in the route because
|
css_changed: bool = False,
|
||||||
update_target_device is async (stop → swap → start cycle).
|
brightness_changed: bool = False,
|
||||||
"""
|
) -> None:
|
||||||
|
"""Push changed fields to the processor manager."""
|
||||||
if settings_changed:
|
if settings_changed:
|
||||||
manager.update_target_settings(self.id, {
|
manager.update_target_settings(
|
||||||
"fps": self.fps,
|
self.id,
|
||||||
"keepalive_interval": self.keepalive_interval,
|
{
|
||||||
"state_check_interval": self.state_check_interval,
|
"fps": self.fps,
|
||||||
"min_brightness_threshold": self.min_brightness_threshold,
|
"keepalive_interval": self.keepalive_interval,
|
||||||
"adaptive_fps": self.adaptive_fps,
|
"state_check_interval": self.state_check_interval,
|
||||||
})
|
"min_brightness_threshold": self.min_brightness_threshold,
|
||||||
|
"adaptive_fps": self.adaptive_fps,
|
||||||
|
},
|
||||||
|
)
|
||||||
if css_changed:
|
if css_changed:
|
||||||
manager.update_target_css(self.id, self.color_strip_source_id)
|
manager.update_target_css(self.id, self.color_strip_source_id)
|
||||||
if brightness_vs_changed:
|
if brightness_changed:
|
||||||
manager.update_target_brightness_vs(self.id, self.brightness_value_source_id)
|
manager.update_target_brightness(self.id, self.brightness)
|
||||||
|
|
||||||
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
|
def update_fields(
|
||||||
brightness_value_source_id=None,
|
self,
|
||||||
fps=None, keepalive_interval=None, state_check_interval=None,
|
*,
|
||||||
min_brightness_threshold=None, adaptive_fps=None, protocol=None,
|
name=None,
|
||||||
description=None, tags: Optional[List[str]] = None,
|
device_id=None,
|
||||||
**_kwargs) -> None:
|
color_strip_source_id=None,
|
||||||
|
brightness=None,
|
||||||
|
# legacy compat
|
||||||
|
brightness_value_source_id=None,
|
||||||
|
fps=None,
|
||||||
|
keepalive_interval=None,
|
||||||
|
state_check_interval=None,
|
||||||
|
min_brightness_threshold=None,
|
||||||
|
adaptive_fps=None,
|
||||||
|
protocol=None,
|
||||||
|
description=None,
|
||||||
|
tags: Optional[List[str]] = None,
|
||||||
|
**_kwargs,
|
||||||
|
) -> None:
|
||||||
"""Apply mutable field updates for WLED targets."""
|
"""Apply mutable field updates for WLED targets."""
|
||||||
super().update_fields(name=name, description=description, tags=tags)
|
super().update_fields(name=name, description=description, tags=tags)
|
||||||
if device_id is not None:
|
if device_id is not None:
|
||||||
self.device_id = resolve_ref(device_id, self.device_id)
|
self.device_id = resolve_ref(device_id, self.device_id)
|
||||||
if color_strip_source_id is not None:
|
if color_strip_source_id is not None:
|
||||||
self.color_strip_source_id = resolve_ref(color_strip_source_id, self.color_strip_source_id)
|
self.color_strip_source_id = resolve_ref(
|
||||||
if brightness_value_source_id is not None:
|
color_strip_source_id, self.color_strip_source_id
|
||||||
self.brightness_value_source_id = resolve_ref(brightness_value_source_id, self.brightness_value_source_id)
|
)
|
||||||
|
if brightness is not None:
|
||||||
|
self.brightness = self.brightness.apply_update(brightness)
|
||||||
|
elif brightness_value_source_id is not None:
|
||||||
|
self.brightness = BindableFloat(
|
||||||
|
value=self.brightness.value,
|
||||||
|
source_id=resolve_ref(brightness_value_source_id, self.brightness.source_id),
|
||||||
|
)
|
||||||
if fps is not None:
|
if fps is not None:
|
||||||
self.fps = fps
|
self.fps = self.fps.apply_update(fps)
|
||||||
if keepalive_interval is not None:
|
if keepalive_interval is not None:
|
||||||
self.keepalive_interval = keepalive_interval
|
self.keepalive_interval = keepalive_interval
|
||||||
if state_check_interval is not None:
|
if state_check_interval is not None:
|
||||||
self.state_check_interval = state_check_interval
|
self.state_check_interval = state_check_interval
|
||||||
if min_brightness_threshold is not None:
|
if min_brightness_threshold is not None:
|
||||||
self.min_brightness_threshold = min_brightness_threshold
|
self.min_brightness_threshold = self.min_brightness_threshold.apply_update(
|
||||||
|
min_brightness_threshold
|
||||||
|
)
|
||||||
if adaptive_fps is not None:
|
if adaptive_fps is not None:
|
||||||
self.adaptive_fps = adaptive_fps
|
self.adaptive_fps = adaptive_fps
|
||||||
if protocol is not None:
|
if protocol is not None:
|
||||||
@@ -97,11 +124,11 @@ class WledOutputTarget(OutputTarget):
|
|||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["device_id"] = self.device_id
|
d["device_id"] = self.device_id
|
||||||
d["color_strip_source_id"] = self.color_strip_source_id
|
d["color_strip_source_id"] = self.color_strip_source_id
|
||||||
d["brightness_value_source_id"] = self.brightness_value_source_id
|
d["brightness"] = self.brightness.to_dict()
|
||||||
d["fps"] = self.fps
|
d["fps"] = self.fps.to_dict()
|
||||||
d["keepalive_interval"] = self.keepalive_interval
|
d["keepalive_interval"] = self.keepalive_interval
|
||||||
d["state_check_interval"] = self.state_check_interval
|
d["state_check_interval"] = self.state_check_interval
|
||||||
d["min_brightness_threshold"] = self.min_brightness_threshold
|
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
|
||||||
d["adaptive_fps"] = self.adaptive_fps
|
d["adaptive_fps"] = self.adaptive_fps
|
||||||
d["protocol"] = self.protocol
|
d["protocol"] = self.protocol
|
||||||
return d
|
return d
|
||||||
@@ -109,21 +136,33 @@ class WledOutputTarget(OutputTarget):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "WledOutputTarget":
|
def from_dict(cls, data: dict) -> "WledOutputTarget":
|
||||||
"""Create from dictionary."""
|
"""Create from dictionary."""
|
||||||
|
# Legacy migration: brightness_value_source_id → brightness.source_id
|
||||||
|
brightness = BindableFloat.from_legacy(
|
||||||
|
data.get("brightness"),
|
||||||
|
legacy_source_id=data.get("brightness_value_source_id", ""),
|
||||||
|
default=1.0,
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
target_type="led",
|
target_type="led",
|
||||||
device_id=data.get("device_id", ""),
|
device_id=data.get("device_id", ""),
|
||||||
color_strip_source_id=data.get("color_strip_source_id", ""),
|
color_strip_source_id=data.get("color_strip_source_id", ""),
|
||||||
brightness_value_source_id=data.get("brightness_value_source_id") or "",
|
brightness=brightness,
|
||||||
fps=data.get("fps", 30),
|
fps=BindableFloat.from_raw(data.get("fps"), default=30.0),
|
||||||
keepalive_interval=data.get("keepalive_interval", 1.0),
|
keepalive_interval=data.get("keepalive_interval", 1.0),
|
||||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||||
min_brightness_threshold=data.get("min_brightness_threshold", 0),
|
min_brightness_threshold=BindableFloat.from_raw(
|
||||||
|
data.get("min_brightness_threshold"), default=0.0
|
||||||
|
),
|
||||||
adaptive_fps=data.get("adaptive_fps", False),
|
adaptive_fps=data.get("adaptive_fps", False),
|
||||||
protocol=data.get("protocol", "ddp"),
|
protocol=data.get("protocol", "ddp"),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
tags=data.get("tags", []),
|
tags=data.get("tags", []),
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
created_at=datetime.fromisoformat(
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
data.get("created_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
),
|
||||||
|
updated_at=datetime.fromisoformat(
|
||||||
|
data.get("updated_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,14 +67,13 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-smoothing">
|
<label>
|
||||||
<span data-i18n="color_strip.smoothing">Smoothing:</span>
|
<span data-i18n="color_strip.smoothing">Smoothing:</span>
|
||||||
<span id="css-editor-smoothing-value">0.30</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
||||||
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)">
|
<div id="css-editor-smoothing-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -176,28 +175,24 @@
|
|||||||
|
|
||||||
<div id="css-editor-effect-intensity-group" class="form-group">
|
<div id="css-editor-effect-intensity-group" class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-effect-intensity">
|
<label>
|
||||||
<span data-i18n="color_strip.effect.intensity">Intensity:</span>
|
<span data-i18n="color_strip.effect.intensity">Intensity:</span>
|
||||||
<span id="css-editor-effect-intensity-val">1.0</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.intensity.hint">Effect-specific intensity.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.intensity.hint">Effect-specific intensity.</small>
|
||||||
<input type="range" id="css-editor-effect-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
|
<div id="css-editor-effect-intensity-container"></div>
|
||||||
oninput="document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="css-editor-effect-scale-group" class="form-group" style="display:none">
|
<div id="css-editor-effect-scale-group" class="form-group" style="display:none">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-effect-scale">
|
<label>
|
||||||
<span data-i18n="color_strip.effect.scale">Scale:</span>
|
<span data-i18n="color_strip.effect.scale">Scale:</span>
|
||||||
<span id="css-editor-effect-scale-val">1.0</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.scale.hint">Spatial zoom level.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.scale.hint">Spatial zoom level.</small>
|
||||||
<input type="range" id="css-editor-effect-scale" min="0.5" max="5.0" step="0.1" value="1.0"
|
<div id="css-editor-effect-scale-container"></div>
|
||||||
oninput="document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="css-editor-effect-mirror-group" class="form-group" style="display:none">
|
<div id="css-editor-effect-mirror-group" class="form-group" style="display:none">
|
||||||
@@ -267,28 +262,24 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-audio-sensitivity">
|
<label>
|
||||||
<span data-i18n="color_strip.audio.sensitivity">Sensitivity:</span>
|
<span data-i18n="color_strip.audio.sensitivity">Sensitivity:</span>
|
||||||
<span id="css-editor-audio-sensitivity-val">1.0</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.sensitivity.hint">Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.sensitivity.hint">Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.</small>
|
||||||
<input type="range" id="css-editor-audio-sensitivity" min="0.1" max="5.0" step="0.1" value="1.0"
|
<div id="css-editor-audio-sensitivity-container"></div>
|
||||||
oninput="document.getElementById('css-editor-audio-sensitivity-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-audio-smoothing">
|
<label>
|
||||||
<span data-i18n="color_strip.audio.smoothing">Smoothing:</span>
|
<span data-i18n="color_strip.audio.smoothing">Smoothing:</span>
|
||||||
<span id="css-editor-audio-smoothing-val">0.30</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.smoothing.hint">Temporal smoothing between frames. Higher values produce smoother but slower-reacting visuals.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.smoothing.hint">Temporal smoothing between frames. Higher values produce smoother but slower-reacting visuals.</small>
|
||||||
<input type="range" id="css-editor-audio-smoothing" min="0.0" max="1.0" step="0.05" value="0.3"
|
<div id="css-editor-audio-smoothing-container"></div>
|
||||||
oninput="document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(this.value).toFixed(2)">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="css-editor-audio-palette-group" class="form-group">
|
<div id="css-editor-audio-palette-group" class="form-group">
|
||||||
@@ -353,15 +344,13 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-api-input-timeout">
|
<label>
|
||||||
<span data-i18n="color_strip.api_input.timeout">Timeout (seconds):</span>
|
<span data-i18n="color_strip.api_input.timeout">Timeout (seconds):</span>
|
||||||
<span id="css-editor-api-input-timeout-val">5.0</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.timeout.hint">How long to wait for new color data before reverting to the fallback color.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.timeout.hint">How long to wait for new color data before reverting to the fallback color.</small>
|
||||||
<input type="range" id="css-editor-api-input-timeout" min="0" max="60" step="0.5" value="5.0"
|
<div id="css-editor-api-input-timeout-container"></div>
|
||||||
oninput="document.getElementById('css-editor-api-input-timeout-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -418,15 +407,13 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-notification-duration">
|
<label>
|
||||||
<span data-i18n="color_strip.notification.duration">Duration (ms):</span>
|
<span data-i18n="color_strip.notification.duration">Duration (ms):</span>
|
||||||
<span id="css-editor-notification-duration-val">1500</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.duration.hint">How long the notification effect plays, in milliseconds.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.duration.hint">How long the notification effect plays, in milliseconds.</small>
|
||||||
<input type="range" id="css-editor-notification-duration" min="100" max="10000" step="100" value="1500"
|
<div id="css-editor-notification-duration-container"></div>
|
||||||
oninput="document.getElementById('css-editor-notification-duration-val').textContent = this.value">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -573,12 +560,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-candlelight-intensity"><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span> <span id="css-editor-candlelight-intensity-val">1.0</span></label>
|
<label><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span></label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
|
||||||
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
|
<div id="css-editor-candlelight-intensity-container"></div>
|
||||||
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
@@ -590,21 +576,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-candlelight-speed"><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span> <span id="css-editor-candlelight-speed-val">1.0</span></label>
|
<label><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span></label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
|
||||||
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
|
<div id="css-editor-candlelight-speed-container"></div>
|
||||||
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-candlelight-wind"><span data-i18n="color_strip.candlelight.wind">Wind:</span> <span id="css-editor-candlelight-wind-val">0.0</span></label>
|
<label><span data-i18n="color_strip.candlelight.wind">Wind:</span></label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.wind.hint">Wind simulation strength. Higher values create correlated gusts across all candles.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.wind.hint">Wind simulation strength. Higher values create correlated gusts across all candles.</small>
|
||||||
<input type="range" id="css-editor-candlelight-wind" min="0.0" max="2.0" step="0.1" value="0.0"
|
<div id="css-editor-candlelight-wind-container"></div>
|
||||||
oninput="document.getElementById('css-editor-candlelight-wind-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
@@ -636,21 +620,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-weather-speed"><span data-i18n="color_strip.weather.speed">Animation Speed:</span> <span id="css-editor-weather-speed-val">1.0</span></label>
|
<label><span data-i18n="color_strip.weather.speed">Animation Speed:</span></label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.speed.hint">Speed of the ambient color drift animation</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.speed.hint">Speed of the ambient color drift animation</small>
|
||||||
<input type="range" id="css-editor-weather-speed" min="0.1" max="5" step="0.1" value="1.0"
|
<div id="css-editor-weather-speed-container"></div>
|
||||||
oninput="document.getElementById('css-editor-weather-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-weather-temp-influence"><span data-i18n="color_strip.weather.temperature_influence">Temperature Influence:</span> <span id="css-editor-weather-temp-val">0.5</span></label>
|
<label><span data-i18n="color_strip.weather.temperature_influence">Temperature Influence:</span></label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.temperature_influence.hint">How much the current temperature shifts the color palette warm/cool. 0 = pure condition colors, 1 = strong shift.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.weather.temperature_influence.hint">How much the current temperature shifts the color palette warm/cool. 0 = pure condition colors, 1 = strong shift.</small>
|
||||||
<input type="range" id="css-editor-weather-temp-influence" min="0" max="1" step="0.05" value="0.5"
|
<div id="css-editor-weather-temp-influence-container"></div>
|
||||||
oninput="document.getElementById('css-editor-weather-temp-val').textContent = parseFloat(this.value).toFixed(2)">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -697,17 +679,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-kc-smoothing"><span data-i18n="color_strip.key_colors.smoothing">Smoothing:</span> <span id="css-editor-kc-smoothing-val">0.30</span></label>
|
<label><span data-i18n="color_strip.key_colors.smoothing">Smoothing:</span></label>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="css-editor-kc-smoothing" min="0" max="1" step="0.05" value="0.3"
|
<div id="css-editor-kc-smoothing-container"></div>
|
||||||
oninput="document.getElementById('css-editor-kc-smoothing-val').textContent = parseFloat(this.value).toFixed(2)">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-kc-brightness"><span data-i18n="color_strip.key_colors.brightness">Brightness:</span> <span id="css-editor-kc-brightness-val">1.00</span></label>
|
<label><span data-i18n="color_strip.key_colors.brightness">Brightness:</span></label>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="css-editor-kc-brightness" min="0" max="1" step="0.05" value="1.0"
|
<div id="css-editor-kc-brightness-container"></div>
|
||||||
oninput="document.getElementById('css-editor-kc-brightness-val').textContent = parseFloat(this.value).toFixed(2)">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -39,29 +39,25 @@
|
|||||||
<!-- Update Rate -->
|
<!-- Update Rate -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="ha-light-editor-update-rate">
|
<label>
|
||||||
<span data-i18n="ha_light.update_rate">Update Rate:</span>
|
<span data-i18n="ha_light.update_rate">Update Rate:</span>
|
||||||
<span id="ha-light-editor-update-rate-display">2.0</span> Hz
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="ha_light.update_rate.hint">How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.</small>
|
<small class="input-hint" style="display:none" data-i18n="ha_light.update_rate.hint">How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.</small>
|
||||||
<input type="range" id="ha-light-editor-update-rate" min="0.5" max="5.0" step="0.5" value="2.0"
|
<div id="ha-light-editor-update-rate-container"></div>
|
||||||
oninput="document.getElementById('ha-light-editor-update-rate-display').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transition -->
|
<!-- Transition -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="ha-light-editor-transition">
|
<label>
|
||||||
<span data-i18n="ha_light.transition">Transition:</span>
|
<span data-i18n="ha_light.transition">Transition:</span>
|
||||||
<span id="ha-light-editor-transition-display">0.5</span>s
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="ha_light.transition.hint">Smooth fade duration between colors (HA transition parameter). Higher values give smoother but slower transitions.</small>
|
<small class="input-hint" style="display:none" data-i18n="ha_light.transition.hint">Smooth fade duration between colors (HA transition parameter). Higher values give smoother but slower transitions.</small>
|
||||||
<input type="range" id="ha-light-editor-transition" min="0" max="5.0" step="0.1" value="0.5"
|
<div id="ha-light-editor-transition-container"></div>
|
||||||
oninput="document.getElementById('ha-light-editor-transition-display').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Brightness Value Source -->
|
<!-- Brightness Value Source -->
|
||||||
@@ -74,6 +70,30 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Tolerance -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label>
|
||||||
|
<span data-i18n="ha_light.color_tolerance">Color Tolerance:</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="ha_light.color_tolerance.hint">Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.</small>
|
||||||
|
<div id="ha-light-editor-color-tolerance-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Min Brightness Threshold -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label>
|
||||||
|
<span data-i18n="ha_light.min_brightness_threshold">Min Brightness Threshold:</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="ha_light.min_brightness_threshold.hint">Effective output brightness below this value turns lights off completely (0 = disabled)</small>
|
||||||
|
<div id="ha-light-editor-min-brightness-threshold-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Light Mappings -->
|
<!-- Light Mappings -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
@@ -47,17 +47,13 @@
|
|||||||
|
|
||||||
<div class="form-group" id="target-editor-fps-group">
|
<div class="form-group" id="target-editor-fps-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-fps">
|
<label>
|
||||||
<span data-i18n="targets.fps">Target FPS:</span>
|
<span data-i18n="targets.fps">Target FPS:</span>
|
||||||
<span id="target-editor-fps-value">30</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth.</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth.</small>
|
||||||
<div class="slider-row">
|
<div id="target-editor-fps-container"></div>
|
||||||
<input type="range" id="target-editor-fps" min="1" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
|
|
||||||
<span class="slider-value">fps</span>
|
|
||||||
</div>
|
|
||||||
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,14 +62,13 @@
|
|||||||
<div class="form-collapse-body">
|
<div class="form-collapse-body">
|
||||||
<div class="form-group" id="target-editor-brightness-threshold-group">
|
<div class="form-group" id="target-editor-brightness-threshold-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-brightness-threshold">
|
<label>
|
||||||
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
|
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
|
||||||
<span id="target-editor-brightness-threshold-value">0</span>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
|
||||||
<input type="range" id="target-editor-brightness-threshold" min="0" max="254" value="0" oninput="document.getElementById('target-editor-brightness-threshold-value').textContent = this.value">
|
<div id="target-editor-brightness-threshold-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="target-editor-adaptive-fps-group">
|
<div class="form-group" id="target-editor-adaptive-fps-group">
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class TestOutputTargetUpdate:
|
|||||||
t = store.create_target("LED", "led", fps=30, protocol="ddp")
|
t = store.create_target("LED", "led", fps=30, protocol="ddp")
|
||||||
updated = store.update_target(t.id, fps=60, protocol="drgb")
|
updated = store.update_target(t.id, fps=60, protocol="drgb")
|
||||||
assert isinstance(updated, WledOutputTarget)
|
assert isinstance(updated, WledOutputTarget)
|
||||||
assert updated.fps == 60
|
assert updated.fps.value == 60.0
|
||||||
assert updated.protocol == "drgb"
|
assert updated.protocol == "drgb"
|
||||||
|
|
||||||
def test_update_not_found(self, store):
|
def test_update_not_found(self, store):
|
||||||
|
|||||||
Reference in New Issue
Block a user