Add value sources for dynamic brightness control on LED targets
Introduces a new Value Source entity that produces a scalar float (0.0-1.0) for dynamic brightness modulation. Three subtypes: Static (constant), Animated (sine/triangle/square/sawtooth waveform), and Audio-reactive (RMS/peak/beat from mono audio source). Value sources can be optionally attached to LED targets to control brightness each frame. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from .routes.picture_targets import router as picture_targets_router
|
||||
from .routes.color_strip_sources import router as color_strip_sources_router
|
||||
from .routes.audio import router as audio_router
|
||||
from .routes.audio_sources import router as audio_sources_router
|
||||
from .routes.value_sources import router as value_sources_router
|
||||
from .routes.profiles import router as profiles_router
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,6 +25,7 @@ router.include_router(picture_sources_router)
|
||||
router.include_router(color_strip_sources_router)
|
||||
router.include_router(audio_router)
|
||||
router.include_router(audio_sources_router)
|
||||
router.include_router(value_sources_router)
|
||||
router.include_router(picture_targets_router)
|
||||
router.include_router(profiles_router)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.profile_store import ProfileStore
|
||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||
|
||||
@@ -21,6 +22,7 @@ _picture_source_store: PictureSourceStore | None = None
|
||||
_picture_target_store: PictureTargetStore | None = None
|
||||
_color_strip_store: ColorStripStore | None = None
|
||||
_audio_source_store: AudioSourceStore | None = None
|
||||
_value_source_store: ValueSourceStore | None = None
|
||||
_processor_manager: ProcessorManager | None = None
|
||||
_profile_store: ProfileStore | None = None
|
||||
_profile_engine: ProfileEngine | None = None
|
||||
@@ -82,6 +84,13 @@ def get_audio_source_store() -> AudioSourceStore:
|
||||
return _audio_source_store
|
||||
|
||||
|
||||
def get_value_source_store() -> ValueSourceStore:
|
||||
"""Get value source store dependency."""
|
||||
if _value_source_store is None:
|
||||
raise RuntimeError("Value source store not initialized")
|
||||
return _value_source_store
|
||||
|
||||
|
||||
def get_processor_manager() -> ProcessorManager:
|
||||
"""Get processor manager dependency."""
|
||||
if _processor_manager is None:
|
||||
@@ -113,13 +122,14 @@ def init_dependencies(
|
||||
picture_target_store: PictureTargetStore | None = None,
|
||||
color_strip_store: ColorStripStore | None = None,
|
||||
audio_source_store: AudioSourceStore | None = None,
|
||||
value_source_store: ValueSourceStore | None = None,
|
||||
profile_store: ProfileStore | None = None,
|
||||
profile_engine: ProfileEngine | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||
global _color_strip_store, _audio_source_store, _profile_store, _profile_engine
|
||||
global _color_strip_store, _audio_source_store, _value_source_store, _profile_store, _profile_engine
|
||||
_device_store = device_store
|
||||
_template_store = template_store
|
||||
_processor_manager = processor_manager
|
||||
@@ -129,5 +139,6 @@ def init_dependencies(
|
||||
_picture_target_store = picture_target_store
|
||||
_color_strip_store = color_strip_store
|
||||
_audio_source_store = audio_source_store
|
||||
_value_source_store = value_source_store
|
||||
_profile_store = profile_store
|
||||
_profile_engine = profile_engine
|
||||
|
||||
@@ -94,6 +94,7 @@ def _target_to_response(target) -> PictureTargetResponse:
|
||||
target_type=target.target_type,
|
||||
device_id=target.device_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
brightness_value_source_id=target.brightness_value_source_id,
|
||||
fps=target.fps,
|
||||
keepalive_interval=target.keepalive_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
@@ -149,6 +150,7 @@ async def create_target(
|
||||
target_type=data.target_type,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness_value_source_id=data.brightness_value_source_id,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
@@ -263,6 +265,7 @@ async def update_target(
|
||||
name=data.name,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness_value_source_id=data.brightness_value_source_id,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
@@ -280,6 +283,7 @@ async def update_target(
|
||||
data.key_colors_settings is not None),
|
||||
css_changed=data.color_strip_source_id is not None,
|
||||
device_changed=data.device_id is not None,
|
||||
brightness_vs_changed=data.brightness_value_source_id is not None,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
161
server/src/wled_controller/api/routes/value_sources.py
Normal file
161
server/src/wled_controller/api/routes/value_sources.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
get_picture_target_store,
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.api.schemas.value_sources import (
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
ValueSourceResponse,
|
||||
ValueSourceUpdate,
|
||||
)
|
||||
from wled_controller.storage.value_source import ValueSource
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
"""Convert a ValueSource to a ValueSourceResponse."""
|
||||
d = source.to_dict()
|
||||
return ValueSourceResponse(
|
||||
id=d["id"],
|
||||
name=d["name"],
|
||||
source_type=d["source_type"],
|
||||
value=d.get("value"),
|
||||
waveform=d.get("waveform"),
|
||||
speed=d.get("speed"),
|
||||
min_value=d.get("min_value"),
|
||||
max_value=d.get("max_value"),
|
||||
audio_source_id=d.get("audio_source_id"),
|
||||
mode=d.get("mode"),
|
||||
sensitivity=d.get("sensitivity"),
|
||||
smoothing=d.get("smoothing"),
|
||||
description=d.get("description"),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
|
||||
async def list_value_sources(
|
||||
_auth: AuthRequired,
|
||||
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, or audio"),
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
):
|
||||
"""List all value sources, optionally filtered by type."""
|
||||
sources = store.get_all_sources()
|
||||
if source_type:
|
||||
sources = [s for s in sources if s.source_type == source_type]
|
||||
return ValueSourceListResponse(
|
||||
sources=[_to_response(s) for s in sources],
|
||||
count=len(sources),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
|
||||
async def create_value_source(
|
||||
data: ValueSourceCreate,
|
||||
_auth: AuthRequired,
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
):
|
||||
"""Create a new value source."""
|
||||
try:
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
value=data.value,
|
||||
waveform=data.waveform,
|
||||
speed=data.speed,
|
||||
min_value=data.min_value,
|
||||
max_value=data.max_value,
|
||||
audio_source_id=data.audio_source_id,
|
||||
mode=data.mode,
|
||||
sensitivity=data.sensitivity,
|
||||
smoothing=data.smoothing,
|
||||
description=data.description,
|
||||
)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
|
||||
async def get_value_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
):
|
||||
"""Get a value source by ID."""
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
|
||||
async def update_value_source(
|
||||
source_id: str,
|
||||
data: ValueSourceUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
pm: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Update an existing value source."""
|
||||
try:
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
value=data.value,
|
||||
waveform=data.waveform,
|
||||
speed=data.speed,
|
||||
min_value=data.min_value,
|
||||
max_value=data.max_value,
|
||||
audio_source_id=data.audio_source_id,
|
||||
mode=data.mode,
|
||||
sensitivity=data.sensitivity,
|
||||
smoothing=data.smoothing,
|
||||
description=data.description,
|
||||
)
|
||||
# Hot-reload running value streams
|
||||
pm.update_value_source(source_id)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/value-sources/{source_id}", tags=["Value Sources"])
|
||||
async def delete_value_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ValueSourceStore = Depends(get_value_source_store),
|
||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||
):
|
||||
"""Delete a value source."""
|
||||
try:
|
||||
# Check if any targets reference this value source
|
||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
||||
for target in target_store.get_all_targets():
|
||||
if isinstance(target, WledPictureTarget):
|
||||
if getattr(target, "brightness_value_source_id", "") == source_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete: referenced by target '{target.name}'"
|
||||
)
|
||||
|
||||
store.delete_source(source_id)
|
||||
return {"status": "deleted", "id": source_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -53,6 +53,7 @@ class PictureTargetCreate(BaseModel):
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
||||
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||
@@ -69,6 +70,7 @@ class PictureTargetUpdate(BaseModel):
|
||||
# LED target fields
|
||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
brightness_value_source_id: Optional[str] = Field(None, description="Brightness value source ID")
|
||||
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
||||
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
||||
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||
@@ -87,6 +89,7 @@ class PictureTargetResponse(BaseModel):
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||
fps: Optional[int] = Field(None, description="Target send FPS")
|
||||
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
||||
|
||||
72
server/src/wled_controller/api/schemas/value_sources.py
Normal file
72
server/src/wled_controller/api/schemas/value_sources.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Value source schemas (CRUD)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ValueSourceCreate(BaseModel):
|
||||
"""Request to create a value source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["static", "animated", "audio"] = Field(description="Source type")
|
||||
# static fields
|
||||
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# animated fields
|
||||
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# audio fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-5.0)", ge=0.1, le=5.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
|
||||
class ValueSourceUpdate(BaseModel):
|
||||
"""Request to update a value source."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
# static fields
|
||||
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# animated fields
|
||||
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# audio fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-5.0)", ge=0.1, le=5.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
|
||||
class ValueSourceResponse(BaseModel):
|
||||
"""Value source response."""
|
||||
|
||||
id: str = Field(description="Source ID")
|
||||
name: str = Field(description="Source name")
|
||||
source_type: str = Field(description="Source type: static, animated, or audio")
|
||||
value: Optional[float] = Field(None, description="Static value")
|
||||
waveform: Optional[str] = Field(None, description="Waveform type")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute")
|
||||
min_value: Optional[float] = Field(None, description="Minimum output")
|
||||
max_value: Optional[float] = Field(None, description="Maximum output")
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class ValueSourceListResponse(BaseModel):
|
||||
"""List of value sources."""
|
||||
|
||||
sources: List[ValueSourceResponse] = Field(description="List of value sources")
|
||||
count: int = Field(description="Number of sources")
|
||||
@@ -35,6 +35,7 @@ class StorageConfig(BaseSettings):
|
||||
pattern_templates_file: str = "data/pattern_templates.json"
|
||||
color_strip_sources_file: str = "data/color_strip_sources.json"
|
||||
audio_sources_file: str = "data/audio_sources.json"
|
||||
value_sources_file: str = "data/value_sources.json"
|
||||
profiles_file: str = "data/profiles.json"
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from wled_controller.core.devices.led_client import (
|
||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
||||
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
from wled_controller.core.processing.value_stream import ValueStreamManager
|
||||
from wled_controller.core.capture.screen_overlay import OverlayManager
|
||||
from wled_controller.core.processing.target_processor import (
|
||||
DeviceInfo,
|
||||
@@ -64,7 +65,7 @@ class ProcessorManager:
|
||||
Targets are registered for processing via polymorphic TargetProcessor subclasses.
|
||||
"""
|
||||
|
||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None):
|
||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None):
|
||||
"""Initialize processor manager."""
|
||||
self._devices: Dict[str, DeviceState] = {}
|
||||
self._processors: Dict[str, TargetProcessor] = {}
|
||||
@@ -78,6 +79,7 @@ class ProcessorManager:
|
||||
self._device_store = device_store
|
||||
self._color_strip_store = color_strip_store
|
||||
self._audio_source_store = audio_source_store
|
||||
self._value_source_store = value_source_store
|
||||
self._live_stream_manager = LiveStreamManager(
|
||||
picture_source_store, capture_template_store, pp_template_store
|
||||
)
|
||||
@@ -88,6 +90,11 @@ class ProcessorManager:
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=audio_source_store,
|
||||
)
|
||||
self._value_stream_manager = ValueStreamManager(
|
||||
value_source_store=value_source_store,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=audio_source_store,
|
||||
) if value_source_store else None
|
||||
self._overlay_manager = OverlayManager()
|
||||
self._event_queues: List[asyncio.Queue] = []
|
||||
logger.info("Processor manager initialized")
|
||||
@@ -105,6 +112,7 @@ class ProcessorManager:
|
||||
pattern_template_store=self._pattern_template_store,
|
||||
device_store=self._device_store,
|
||||
color_strip_stream_manager=self._color_strip_stream_manager,
|
||||
value_stream_manager=self._value_stream_manager,
|
||||
fire_event=self._fire_event,
|
||||
get_device_info=self._get_device_info,
|
||||
)
|
||||
@@ -276,6 +284,7 @@ class ProcessorManager:
|
||||
fps: int = 30,
|
||||
keepalive_interval: float = 1.0,
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||
brightness_value_source_id: str = "",
|
||||
):
|
||||
"""Register a WLED target processor."""
|
||||
if target_id in self._processors:
|
||||
@@ -290,6 +299,7 @@ class ProcessorManager:
|
||||
fps=fps,
|
||||
keepalive_interval=keepalive_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
brightness_value_source_id=brightness_value_source_id,
|
||||
ctx=self._build_context(),
|
||||
)
|
||||
self._processors[target_id] = proc
|
||||
@@ -347,6 +357,17 @@ class ProcessorManager:
|
||||
raise ValueError(f"Device {device_id} not registered")
|
||||
proc.update_device(device_id)
|
||||
|
||||
def update_target_brightness_vs(self, target_id: str, vs_id: str):
|
||||
"""Update the brightness value source for a WLED target."""
|
||||
proc = self._get_processor(target_id)
|
||||
if hasattr(proc, "update_brightness_value_source"):
|
||||
proc.update_brightness_value_source(vs_id)
|
||||
|
||||
def update_value_source(self, vs_id: str):
|
||||
"""Hot-update all running value streams for a given source."""
|
||||
if self._value_stream_manager:
|
||||
self._value_stream_manager.update_source(vs_id)
|
||||
|
||||
async def start_processing(self, target_id: str):
|
||||
"""Start processing for a target (any type)."""
|
||||
proc = self._get_processor(target_id)
|
||||
@@ -719,6 +740,10 @@ class ProcessorManager:
|
||||
# Safety net: release all color strip streams
|
||||
self._color_strip_stream_manager.release_all()
|
||||
|
||||
# Safety net: release all value streams
|
||||
if self._value_stream_manager:
|
||||
self._value_stream_manager.release_all()
|
||||
|
||||
# Safety net: release any remaining managed live streams
|
||||
self._live_stream_manager.release_all()
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
|
||||
if TYPE_CHECKING:
|
||||
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
||||
from wled_controller.core.processing.value_stream import ValueStreamManager
|
||||
from wled_controller.core.capture.screen_overlay import OverlayManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
@@ -85,6 +86,7 @@ class TargetContext:
|
||||
pattern_template_store: Optional["PatternTemplateStore"] = None
|
||||
device_store: Optional["DeviceStore"] = None
|
||||
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
|
||||
value_stream_manager: Optional["ValueStreamManager"] = None
|
||||
fire_event: Callable[[dict], None] = lambda e: None
|
||||
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
||||
|
||||
|
||||
389
server/src/wled_controller/core/processing/value_stream.py
Normal file
389
server/src/wled_controller/core/processing/value_stream.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Value stream — runtime scalar signal generators.
|
||||
|
||||
A ValueStream wraps a ValueSource config and computes a float (0.0–1.0)
|
||||
on demand via ``get_value()``. Three concrete types:
|
||||
|
||||
StaticValueStream — returns a constant
|
||||
AnimatedValueStream — evaluates a periodic waveform (sine/triangle/square/sawtooth)
|
||||
AudioValueStream — polls audio analysis for RMS/peak/beat, applies
|
||||
sensitivity and temporal smoothing
|
||||
|
||||
ValueStreams are cheap (trivial math or single poll), so they compute inline
|
||||
in the caller's processing loop — no background threads required.
|
||||
|
||||
ValueStreamManager owns all running ValueStreams, keyed by
|
||||
``{vs_id}:{consumer_id}``. Processors call acquire/release.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Dict, Optional
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.value_source import ValueSource
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ValueStream:
|
||||
"""Abstract base for runtime value streams."""
|
||||
|
||||
def get_value(self) -> float:
|
||||
"""Return current scalar value (0.0–1.0)."""
|
||||
return 1.0
|
||||
|
||||
def start(self) -> None:
|
||||
"""Acquire resources (if any)."""
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Release resources (if any)."""
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
"""Hot-update parameters from a modified ValueSource config."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class StaticValueStream(ValueStream):
|
||||
"""Returns a constant float."""
|
||||
|
||||
def __init__(self, value: float = 1.0):
|
||||
self._value = max(0.0, min(1.0, value))
|
||||
|
||||
def get_value(self) -> float:
|
||||
return self._value
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import StaticValueSource
|
||||
if isinstance(source, StaticValueSource):
|
||||
self._value = max(0.0, min(1.0, source.value))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Animated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TWO_PI = 2.0 * math.pi
|
||||
|
||||
|
||||
class AnimatedValueStream(ValueStream):
|
||||
"""Evaluates a periodic waveform from wall-clock time.
|
||||
|
||||
Waveforms: sine, triangle, square, sawtooth.
|
||||
Speed is in cycles per minute (cpm). Output is mapped to
|
||||
[min_value, max_value].
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
waveform: str = "sine",
|
||||
speed: float = 10.0,
|
||||
min_value: float = 0.0,
|
||||
max_value: float = 1.0,
|
||||
):
|
||||
self._waveform = waveform
|
||||
self._speed = speed
|
||||
self._min = min_value
|
||||
self._max = max_value
|
||||
self._start_time = time.perf_counter()
|
||||
|
||||
def get_value(self) -> float:
|
||||
elapsed = time.perf_counter() - self._start_time
|
||||
# phase in [0, 1)
|
||||
cycles_per_sec = self._speed / 60.0
|
||||
phase = (elapsed * cycles_per_sec) % 1.0
|
||||
|
||||
# Raw waveform value in [0, 1]
|
||||
wf = self._waveform
|
||||
if wf == "sine":
|
||||
raw = 0.5 + 0.5 * math.sin(_TWO_PI * phase)
|
||||
elif wf == "triangle":
|
||||
raw = 1.0 - abs(2.0 * phase - 1.0)
|
||||
elif wf == "square":
|
||||
raw = 1.0 if phase < 0.5 else 0.0
|
||||
elif wf == "sawtooth":
|
||||
raw = phase
|
||||
else:
|
||||
raw = 0.5 + 0.5 * math.sin(_TWO_PI * phase)
|
||||
|
||||
# Map to [min, max]
|
||||
return self._min + raw * (self._max - self._min)
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import AnimatedValueSource
|
||||
if isinstance(source, AnimatedValueSource):
|
||||
self._waveform = source.waveform
|
||||
self._speed = source.speed
|
||||
self._min = source.min_value
|
||||
self._max = source.max_value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AudioValueStream(ValueStream):
|
||||
"""Polls audio analysis for a scalar value.
|
||||
|
||||
Modes:
|
||||
rms — root-mean-square level (overall volume)
|
||||
peak — peak amplitude
|
||||
beat — 1.0 on beat, decays toward 0.0 between beats
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_source_id: str,
|
||||
mode: str = "rms",
|
||||
sensitivity: float = 1.0,
|
||||
smoothing: float = 0.3,
|
||||
audio_capture_manager: Optional["AudioCaptureManager"] = None,
|
||||
audio_source_store: Optional["AudioSourceStore"] = None,
|
||||
):
|
||||
self._audio_source_id = audio_source_id
|
||||
self._mode = mode
|
||||
self._sensitivity = sensitivity
|
||||
self._smoothing = smoothing
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
|
||||
# Resolved audio device params
|
||||
self._audio_device_index = -1
|
||||
self._audio_loopback = True
|
||||
self._audio_channel = "mono"
|
||||
|
||||
self._audio_stream = None
|
||||
self._prev_value = 0.0
|
||||
self._beat_brightness = 0.0
|
||||
|
||||
self._resolve_audio_source()
|
||||
|
||||
def _resolve_audio_source(self) -> None:
|
||||
"""Resolve mono audio source to device index / channel."""
|
||||
if self._audio_source_id and self._audio_source_store:
|
||||
try:
|
||||
device_index, is_loopback, channel = (
|
||||
self._audio_source_store.resolve_mono_source(self._audio_source_id)
|
||||
)
|
||||
self._audio_device_index = device_index
|
||||
self._audio_loopback = is_loopback
|
||||
self._audio_channel = channel
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
|
||||
|
||||
def start(self) -> None:
|
||||
if self._audio_capture_manager is None:
|
||||
return
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback
|
||||
)
|
||||
logger.info(
|
||||
f"AudioValueStream started (mode={self._mode}, "
|
||||
f"device={self._audio_device_index}, loopback={self._audio_loopback})"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._audio_stream is not None and self._audio_capture_manager is not None:
|
||||
self._audio_capture_manager.release(self._audio_device_index, self._audio_loopback)
|
||||
self._audio_stream = None
|
||||
self._prev_value = 0.0
|
||||
self._beat_brightness = 0.0
|
||||
|
||||
def get_value(self) -> float:
|
||||
if self._audio_stream is None:
|
||||
return 0.0
|
||||
|
||||
analysis = self._audio_stream.get_latest_analysis()
|
||||
if analysis is None:
|
||||
return self._prev_value
|
||||
|
||||
raw = self._extract_raw(analysis)
|
||||
raw = min(1.0, raw * self._sensitivity)
|
||||
|
||||
# Temporal smoothing
|
||||
smoothed = self._smoothing * self._prev_value + (1.0 - self._smoothing) * raw
|
||||
self._prev_value = smoothed
|
||||
return max(0.0, min(1.0, smoothed))
|
||||
|
||||
def _extract_raw(self, analysis) -> float:
|
||||
"""Extract raw scalar from audio analysis based on mode."""
|
||||
if self._mode == "peak":
|
||||
return self._pick_peak(analysis)
|
||||
if self._mode == "beat":
|
||||
return self._compute_beat(analysis)
|
||||
# Default: rms
|
||||
return self._pick_rms(analysis)
|
||||
|
||||
def _pick_rms(self, analysis) -> float:
|
||||
if self._audio_channel == "left":
|
||||
return getattr(analysis, "left_rms", 0.0)
|
||||
if self._audio_channel == "right":
|
||||
return getattr(analysis, "right_rms", 0.0)
|
||||
return getattr(analysis, "rms", 0.0)
|
||||
|
||||
def _pick_peak(self, analysis) -> float:
|
||||
if self._audio_channel == "left":
|
||||
return getattr(analysis, "left_peak", 0.0)
|
||||
if self._audio_channel == "right":
|
||||
return getattr(analysis, "right_peak", 0.0)
|
||||
return getattr(analysis, "peak", 0.0)
|
||||
|
||||
def _compute_beat(self, analysis) -> float:
|
||||
if getattr(analysis, "beat", False):
|
||||
self._beat_brightness = 1.0
|
||||
else:
|
||||
decay = 0.05 + 0.15 * (1.0 / max(self._sensitivity, 0.1))
|
||||
self._beat_brightness = max(0.0, self._beat_brightness - decay)
|
||||
return self._beat_brightness
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from wled_controller.storage.value_source import AudioValueSource
|
||||
if not isinstance(source, AudioValueSource):
|
||||
return
|
||||
|
||||
old_source_id = self._audio_source_id
|
||||
self._audio_source_id = source.audio_source_id
|
||||
self._mode = source.mode
|
||||
self._sensitivity = source.sensitivity
|
||||
self._smoothing = source.smoothing
|
||||
|
||||
# If audio source changed, re-resolve and swap capture stream
|
||||
if source.audio_source_id != old_source_id:
|
||||
old_device = self._audio_device_index
|
||||
old_loopback = self._audio_loopback
|
||||
self._resolve_audio_source()
|
||||
if self._audio_stream is not None and self._audio_capture_manager is not None:
|
||||
self._audio_capture_manager.release(old_device, old_loopback)
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback
|
||||
)
|
||||
logger.info(
|
||||
f"AudioValueStream swapped audio device: "
|
||||
f"{old_device}:{old_loopback} → "
|
||||
f"{self._audio_device_index}:{self._audio_loopback}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_key(vs_id: str, consumer_id: str) -> str:
|
||||
return f"{vs_id}:{consumer_id}"
|
||||
|
||||
|
||||
class ValueStreamManager:
|
||||
"""Owns running ValueStream instances, keyed by ``vs_id:consumer_id``.
|
||||
|
||||
Each consumer (target processor) gets its own stream instance —
|
||||
no sharing or ref-counting needed since streams are cheap.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value_source_store: "ValueSourceStore",
|
||||
audio_capture_manager: Optional["AudioCaptureManager"] = None,
|
||||
audio_source_store: Optional["AudioSourceStore"] = None,
|
||||
):
|
||||
self._value_source_store = value_source_store
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._streams: Dict[str, ValueStream] = {}
|
||||
|
||||
def acquire(self, vs_id: str, consumer_id: str) -> ValueStream:
|
||||
"""Create and start a ValueStream for the given ValueSource.
|
||||
|
||||
Args:
|
||||
vs_id: ID of the ValueSource config
|
||||
consumer_id: Unique consumer identifier (target_id)
|
||||
|
||||
Returns:
|
||||
Running ValueStream instance
|
||||
"""
|
||||
key = _make_key(vs_id, consumer_id)
|
||||
if key in self._streams:
|
||||
return self._streams[key]
|
||||
|
||||
source = self._value_source_store.get_source(vs_id)
|
||||
stream = self._create_stream(source)
|
||||
stream.start()
|
||||
self._streams[key] = stream
|
||||
logger.info(f"Acquired value stream {key} (type={source.source_type})")
|
||||
return stream
|
||||
|
||||
def release(self, vs_id: str, consumer_id: str) -> None:
|
||||
"""Stop and remove a ValueStream."""
|
||||
key = _make_key(vs_id, consumer_id)
|
||||
stream = self._streams.pop(key, None)
|
||||
if stream:
|
||||
stream.stop()
|
||||
logger.info(f"Released value stream {key}")
|
||||
|
||||
def update_source(self, vs_id: str) -> None:
|
||||
"""Hot-update all running streams that use the given ValueSource."""
|
||||
try:
|
||||
source = self._value_source_store.get_source(vs_id)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
prefix = f"{vs_id}:"
|
||||
for key, stream in self._streams.items():
|
||||
if key.startswith(prefix):
|
||||
stream.update_source(source)
|
||||
|
||||
logger.debug(f"Updated running value streams for source {vs_id}")
|
||||
|
||||
def release_all(self) -> None:
|
||||
"""Stop and remove all managed streams. Called on shutdown."""
|
||||
for key, stream in self._streams.items():
|
||||
try:
|
||||
stream.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping value stream {key}: {e}")
|
||||
self._streams.clear()
|
||||
logger.info("Released all value streams")
|
||||
|
||||
def _create_stream(self, source: "ValueSource") -> ValueStream:
|
||||
"""Factory: create the appropriate ValueStream for a ValueSource."""
|
||||
from wled_controller.storage.value_source import (
|
||||
AnimatedValueSource,
|
||||
AudioValueSource,
|
||||
StaticValueSource,
|
||||
)
|
||||
|
||||
if isinstance(source, StaticValueSource):
|
||||
return StaticValueStream(value=source.value)
|
||||
|
||||
if isinstance(source, AnimatedValueSource):
|
||||
return AnimatedValueStream(
|
||||
waveform=source.waveform,
|
||||
speed=source.speed,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
if isinstance(source, AudioValueSource):
|
||||
return AudioValueStream(
|
||||
audio_source_id=source.audio_source_id,
|
||||
mode=source.mode,
|
||||
sensitivity=source.sensitivity,
|
||||
smoothing=source.smoothing,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=self._audio_source_store,
|
||||
)
|
||||
|
||||
# Fallback
|
||||
return StaticValueStream(value=1.0)
|
||||
@@ -35,6 +35,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
fps: int = 30,
|
||||
keepalive_interval: float = 1.0,
|
||||
state_check_interval: int = 30,
|
||||
brightness_value_source_id: str = "",
|
||||
ctx: TargetContext = None,
|
||||
):
|
||||
super().__init__(target_id, ctx)
|
||||
@@ -43,10 +44,12 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._keepalive_interval = keepalive_interval
|
||||
self._state_check_interval = state_check_interval
|
||||
self._css_id = color_strip_source_id
|
||||
self._brightness_vs_id = brightness_value_source_id
|
||||
|
||||
# Runtime state (populated on start)
|
||||
self._led_client: Optional[LEDClient] = None
|
||||
self._css_stream: Optional[object] = None # active stream reference
|
||||
self._value_stream = None # active brightness value stream
|
||||
self._device_state_before: Optional[dict] = None
|
||||
self._overlay_active = False
|
||||
self._needs_keepalive = True
|
||||
@@ -122,6 +125,16 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._led_client = None
|
||||
raise RuntimeError(f"Failed to acquire CSS stream: {e}")
|
||||
|
||||
# Acquire value stream for brightness modulation (if configured)
|
||||
if self._brightness_vs_id and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._value_stream = self._ctx.value_stream_manager.acquire(
|
||||
self._brightness_vs_id, self._target_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to acquire value stream {self._brightness_vs_id}: {e}")
|
||||
self._value_stream = None
|
||||
|
||||
# Reset metrics and start loop
|
||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
@@ -167,6 +180,14 @@ class WledTargetProcessor(TargetProcessor):
|
||||
logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}")
|
||||
self._css_stream = None
|
||||
|
||||
# Release value stream
|
||||
if self._value_stream is not None and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._ctx.value_stream_manager.release(self._brightness_vs_id, self._target_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error releasing value stream: {e}")
|
||||
self._value_stream = None
|
||||
|
||||
logger.info(f"Stopped processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
|
||||
|
||||
@@ -228,6 +249,33 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._css_stream = new_stream
|
||||
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
|
||||
|
||||
def update_brightness_value_source(self, vs_id: str) -> None:
|
||||
"""Hot-swap the brightness value source for a running target."""
|
||||
old_vs_id = self._brightness_vs_id
|
||||
self._brightness_vs_id = vs_id
|
||||
vs_mgr = self._ctx.value_stream_manager
|
||||
|
||||
if not self._is_running or vs_mgr is None:
|
||||
return
|
||||
|
||||
# Release old stream
|
||||
if self._value_stream is not None and old_vs_id:
|
||||
try:
|
||||
vs_mgr.release(old_vs_id, self._target_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error releasing old value stream {old_vs_id}: {e}")
|
||||
self._value_stream = None
|
||||
|
||||
# Acquire new stream
|
||||
if vs_id:
|
||||
try:
|
||||
self._value_stream = vs_mgr.acquire(vs_id, self._target_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to acquire value stream {vs_id}: {e}")
|
||||
self._value_stream = None
|
||||
|
||||
logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {vs_id}")
|
||||
|
||||
def get_display_index(self) -> Optional[int]:
|
||||
"""Display index being captured, from the active stream."""
|
||||
if self._resolved_display_index is not None:
|
||||
@@ -261,6 +309,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"target_id": self._target_id,
|
||||
"device_id": self._device_id,
|
||||
"color_strip_source_id": self._css_id,
|
||||
"brightness_value_source_id": self._brightness_vs_id,
|
||||
"processing": self._is_running,
|
||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||
@@ -392,9 +441,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
_bright_out: Optional[np.ndarray] = None
|
||||
_bright_n = 0
|
||||
|
||||
def _cached_brightness(colors_in, dev_info):
|
||||
def _cached_brightness(colors_in, brightness: int):
|
||||
nonlocal _bright_n, _bright_u16, _bright_out
|
||||
if not dev_info or dev_info.software_brightness >= 255:
|
||||
if brightness >= 255:
|
||||
return colors_in
|
||||
_dn = len(colors_in)
|
||||
if _dn != _bright_n:
|
||||
@@ -402,11 +451,20 @@ class WledTargetProcessor(TargetProcessor):
|
||||
_bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
|
||||
_bright_out = np.empty((_dn, 3), dtype=np.uint8)
|
||||
np.copyto(_bright_u16, colors_in, casting='unsafe')
|
||||
_bright_u16 *= dev_info.software_brightness
|
||||
_bright_u16 *= brightness
|
||||
_bright_u16 >>= 8
|
||||
np.copyto(_bright_out, _bright_u16, casting='unsafe')
|
||||
return _bright_out
|
||||
|
||||
def _effective_brightness(dev_info):
|
||||
"""Compute effective brightness = software_brightness * value_stream."""
|
||||
base = dev_info.software_brightness if dev_info else 255
|
||||
vs = self._value_stream
|
||||
if vs is not None:
|
||||
vs_val = vs.get_value()
|
||||
return max(0, min(255, int(base * vs_val)))
|
||||
return base
|
||||
|
||||
SKIP_REPOLL = 0.005 # 5 ms
|
||||
|
||||
# --- Timing diagnostics ---
|
||||
@@ -471,7 +529,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
if not self._is_running or self._led_client is None:
|
||||
break
|
||||
send_colors = _cached_brightness(
|
||||
self._fit_to_device(prev_frame_ref, _total_leds), device_info
|
||||
self._fit_to_device(prev_frame_ref, _total_leds),
|
||||
_effective_brightness(device_info),
|
||||
)
|
||||
if self._led_client.supports_fast_send:
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
@@ -495,7 +554,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
# Fit to device LED count and apply brightness
|
||||
device_colors = self._fit_to_device(frame, _total_leds)
|
||||
send_colors = _cached_brightness(device_colors, device_info)
|
||||
send_colors = _cached_brightness(device_colors, _effective_brightness(device_info))
|
||||
|
||||
# Send to LED device
|
||||
if not self._is_running or self._led_client is None:
|
||||
|
||||
@@ -24,6 +24,7 @@ from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.profile_store import ProfileStore
|
||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||
from wled_controller.utils import setup_logging, get_logger
|
||||
@@ -44,6 +45,7 @@ picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
||||
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
|
||||
color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
|
||||
audio_source_store = AudioSourceStore(config.storage.audio_sources_file)
|
||||
value_source_store = ValueSourceStore(config.storage.value_sources_file)
|
||||
profile_store = ProfileStore(config.storage.profiles_file)
|
||||
|
||||
# Migrate embedded audio config from CSS entities to audio sources
|
||||
@@ -57,6 +59,7 @@ processor_manager = ProcessorManager(
|
||||
device_store=device_store,
|
||||
color_strip_store=color_strip_store,
|
||||
audio_source_store=audio_source_store,
|
||||
value_source_store=value_source_store,
|
||||
)
|
||||
|
||||
|
||||
@@ -101,6 +104,7 @@ async def lifespan(app: FastAPI):
|
||||
picture_target_store=picture_target_store,
|
||||
color_strip_store=color_strip_store,
|
||||
audio_source_store=audio_source_store,
|
||||
value_source_store=value_source_store,
|
||||
profile_store=profile_store,
|
||||
profile_engine=profile_engine,
|
||||
)
|
||||
|
||||
@@ -108,6 +108,12 @@ import {
|
||||
editAudioSource, deleteAudioSource, onAudioSourceTypeChange,
|
||||
} from './features/audio-sources.js';
|
||||
|
||||
// Layer 5: value sources
|
||||
import {
|
||||
showValueSourceModal, closeValueSourceModal, saveValueSource,
|
||||
editValueSource, deleteValueSource, onValueSourceTypeChange,
|
||||
} from './features/value-sources.js';
|
||||
|
||||
// Layer 5: calibration
|
||||
import {
|
||||
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
|
||||
@@ -317,6 +323,14 @@ Object.assign(window, {
|
||||
deleteAudioSource,
|
||||
onAudioSourceTypeChange,
|
||||
|
||||
// value sources
|
||||
showValueSourceModal,
|
||||
closeValueSourceModal,
|
||||
saveValueSource,
|
||||
editValueSource,
|
||||
deleteValueSource,
|
||||
onValueSourceTypeChange,
|
||||
|
||||
// calibration
|
||||
showCalibration,
|
||||
closeCalibrationModal,
|
||||
|
||||
@@ -169,6 +169,10 @@ export const PATTERN_RECT_BORDERS = [
|
||||
export let _cachedAudioSources = [];
|
||||
export function set_cachedAudioSources(v) { _cachedAudioSources = v; }
|
||||
|
||||
// Value sources
|
||||
export let _cachedValueSources = [];
|
||||
export function set_cachedValueSources(v) { _cachedValueSources = v; }
|
||||
|
||||
// Profiles
|
||||
export let _profilesCache = null;
|
||||
export function set_profilesCache(v) { _profilesCache = v; }
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
_currentTestPPTemplateId, set_currentTestPPTemplateId,
|
||||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||||
_cachedAudioSources, set_cachedAudioSources,
|
||||
_cachedValueSources, set_cachedValueSources,
|
||||
apiKey,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
@@ -27,6 +28,7 @@ import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
|
||||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { createValueSourceCard } from './value-sources.js';
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" });
|
||||
@@ -36,6 +38,7 @@ const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postproce
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||
@@ -447,12 +450,13 @@ export async function deleteTemplate(templateId) {
|
||||
|
||||
export async function loadPictureSources() {
|
||||
try {
|
||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp] = await Promise.all([
|
||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([
|
||||
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
|
||||
fetchWithAuth('/postprocessing-templates'),
|
||||
fetchWithAuth('/capture-templates'),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
fetchWithAuth('/audio-sources'),
|
||||
fetchWithAuth('/value-sources'),
|
||||
]);
|
||||
|
||||
if (filtersResp && filtersResp.ok) {
|
||||
@@ -471,6 +475,10 @@ export async function loadPictureSources() {
|
||||
const ad = await audioResp.json();
|
||||
set_cachedAudioSources(ad.sources || []);
|
||||
}
|
||||
if (valueResp && valueResp.ok) {
|
||||
const vd = await valueResp.json();
|
||||
set_cachedValueSources(vd.sources || []);
|
||||
}
|
||||
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
|
||||
const data = await streamsResp.json();
|
||||
set_cachedStreams(data.streams || []);
|
||||
@@ -621,6 +629,7 @@ function renderPictureSourcesList(streams) {
|
||||
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length },
|
||||
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length },
|
||||
{ key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
|
||||
{ key: 'value', icon: '🎚️', titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
];
|
||||
|
||||
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
|
||||
@@ -677,6 +686,8 @@ function renderPictureSourcesList(streams) {
|
||||
panelContent =
|
||||
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
|
||||
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
} else if (tab.key === 'value') {
|
||||
panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
} else {
|
||||
panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||
}
|
||||
@@ -685,7 +696,7 @@ function renderPictureSourcesList(streams) {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = tabBar + panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams, csValueSources]);
|
||||
}
|
||||
|
||||
export function onStreamTypeChange() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
_targetEditorDevices, set_targetEditorDevices,
|
||||
_deviceBrightnessCache,
|
||||
kcWebSockets,
|
||||
_cachedValueSources, set_cachedValueSources,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -112,6 +113,7 @@ class TargetEditorModal extends Modal {
|
||||
name: document.getElementById('target-editor-name').value,
|
||||
device: document.getElementById('target-editor-device').value,
|
||||
css_source: document.getElementById('target-editor-css-source').value,
|
||||
brightness_vs: document.getElementById('target-editor-brightness-vs').value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||
};
|
||||
@@ -177,16 +179,32 @@ function _populateCssDropdown(selectedId = '') {
|
||||
).join('');
|
||||
}
|
||||
|
||||
function _populateBrightnessVsDropdown(selectedId = '') {
|
||||
const select = document.getElementById('target-editor-brightness-vs');
|
||||
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
|
||||
_cachedValueSources.forEach(vs => {
|
||||
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
|
||||
const icon = typeIcons[vs.source_type] || '🎚️';
|
||||
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${icon} ${escapeHtml(vs.name)}</option>`;
|
||||
});
|
||||
select.innerHTML = html;
|
||||
}
|
||||
|
||||
export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
try {
|
||||
// Load devices and CSS sources for dropdowns
|
||||
const [devicesResp, cssResp] = await Promise.all([
|
||||
// Load devices, CSS sources, and value sources for dropdowns
|
||||
const [devicesResp, cssResp, vsResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/color-strip-sources'),
|
||||
fetchWithAuth('/value-sources'),
|
||||
]);
|
||||
|
||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
||||
if (vsResp.ok) {
|
||||
const vsData = await vsResp.json();
|
||||
set_cachedValueSources(vsData.sources || []);
|
||||
}
|
||||
set_targetEditorDevices(devices);
|
||||
_editorCssSources = cssSources;
|
||||
|
||||
@@ -220,6 +238,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
||||
|
||||
_populateCssDropdown(target.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
@@ -233,6 +252,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
|
||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
||||
} else {
|
||||
// Creating new target
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
@@ -244,6 +264,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
|
||||
_populateCssDropdown('');
|
||||
_populateBrightnessVsDropdown('');
|
||||
}
|
||||
|
||||
// Auto-name generation
|
||||
@@ -296,10 +317,13 @@ export async function saveTargetEditor() {
|
||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||
const colorStripSourceId = document.getElementById('target-editor-css-source').value;
|
||||
|
||||
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
color_strip_source_id: colorStripSourceId,
|
||||
brightness_value_source_id: brightnessVsId,
|
||||
fps,
|
||||
keepalive_interval: standbyInterval,
|
||||
};
|
||||
|
||||
234
server/src/wled_controller/static/js/features/value-sources.js
Normal file
234
server/src/wled_controller/static/js/features/value-sources.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Value Sources — CRUD for scalar value sources (static, animated, audio).
|
||||
*
|
||||
* Value sources produce a float 0.0-1.0 used for dynamic brightness control
|
||||
* on LED targets. Three subtypes: static (constant), animated (waveform),
|
||||
* audio (audio-reactive).
|
||||
*
|
||||
* Card rendering is handled by streams.js (Value tab).
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
const valueSourceModal = new Modal('value-source-modal');
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
export async function showValueSourceModal(editData) {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
|
||||
|
||||
document.getElementById('value-source-modal-title').textContent = t(titleKey);
|
||||
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
|
||||
document.getElementById('value-source-error').style.display = 'none';
|
||||
|
||||
const typeSelect = document.getElementById('value-source-type');
|
||||
typeSelect.disabled = isEdit;
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('value-source-name').value = editData.name || '';
|
||||
document.getElementById('value-source-description').value = editData.description || '';
|
||||
typeSelect.value = editData.source_type || 'static';
|
||||
onValueSourceTypeChange();
|
||||
|
||||
if (editData.source_type === 'static') {
|
||||
_setSlider('value-source-value', editData.value ?? 1.0);
|
||||
} else if (editData.source_type === 'animated') {
|
||||
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
|
||||
_setSlider('value-source-speed', editData.speed ?? 10);
|
||||
_setSlider('value-source-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-max-value', editData.max_value ?? 1);
|
||||
} else if (editData.source_type === 'audio') {
|
||||
_populateAudioSourceDropdown(editData.audio_source_id || '');
|
||||
document.getElementById('value-source-mode').value = editData.mode || 'rms';
|
||||
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
|
||||
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
|
||||
}
|
||||
} else {
|
||||
document.getElementById('value-source-name').value = '';
|
||||
document.getElementById('value-source-description').value = '';
|
||||
typeSelect.value = 'static';
|
||||
onValueSourceTypeChange();
|
||||
_setSlider('value-source-value', 1.0);
|
||||
_setSlider('value-source-speed', 10);
|
||||
_setSlider('value-source-min-value', 0);
|
||||
_setSlider('value-source-max-value', 1);
|
||||
document.getElementById('value-source-waveform').value = 'sine';
|
||||
_populateAudioSourceDropdown('');
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
_setSlider('value-source-sensitivity', 1.0);
|
||||
_setSlider('value-source-smoothing', 0.3);
|
||||
}
|
||||
|
||||
valueSourceModal.open();
|
||||
}
|
||||
|
||||
export function closeValueSourceModal() {
|
||||
valueSourceModal.forceClose();
|
||||
}
|
||||
|
||||
export function onValueSourceTypeChange() {
|
||||
const type = document.getElementById('value-source-type').value;
|
||||
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
|
||||
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
|
||||
// Populate audio dropdown when switching to audio type
|
||||
if (type === 'audio') {
|
||||
const select = document.getElementById('value-source-audio-source');
|
||||
if (select && select.options.length === 0) {
|
||||
_populateAudioSourceDropdown('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveValueSource() {
|
||||
const id = document.getElementById('value-source-id').value;
|
||||
const name = document.getElementById('value-source-name').value.trim();
|
||||
const sourceType = document.getElementById('value-source-type').value;
|
||||
const description = document.getElementById('value-source-description').value.trim() || null;
|
||||
const errorEl = document.getElementById('value-source-error');
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('value_source.error.name_required');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description };
|
||||
|
||||
if (sourceType === 'static') {
|
||||
payload.value = parseFloat(document.getElementById('value-source-value').value);
|
||||
} else if (sourceType === 'animated') {
|
||||
payload.waveform = document.getElementById('value-source-waveform').value;
|
||||
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
|
||||
} else if (sourceType === 'audio') {
|
||||
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
|
||||
payload.mode = document.getElementById('value-source-mode').value;
|
||||
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
|
||||
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/value-sources/${id}` : '/value-sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
|
||||
valueSourceModal.forceClose();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────
|
||||
|
||||
export async function editValueSource(sourceId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error('fetch failed');
|
||||
const data = await resp.json();
|
||||
await showValueSourceModal(data);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteValueSource(sourceId) {
|
||||
const confirmed = await showConfirm(t('value_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('value_source.deleted'), 'success');
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card rendering (used by streams.js) ───────────────────────
|
||||
|
||||
export function createValueSourceCard(src) {
|
||||
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
|
||||
const icon = typeIcons[src.source_type] || '🎚️';
|
||||
|
||||
let propsHtml = '';
|
||||
if (src.source_type === 'static') {
|
||||
propsHtml = `<span class="stream-card-prop">${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
|
||||
} else if (src.source_type === 'animated') {
|
||||
const waveLabel = src.waveform || 'sine';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${escapeHtml(waveLabel)}</span>
|
||||
<span class="stream-card-prop">${src.speed ?? 10} cpm</span>
|
||||
<span class="stream-card-prop">${src.min_value ?? 0}–${src.max_value ?? 1}</span>
|
||||
`;
|
||||
} else if (src.source_type === 'audio') {
|
||||
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
|
||||
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
|
||||
const modeLabel = src.mode || 'rms';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${escapeHtml(audioName)}</span>
|
||||
<span class="stream-card-prop">${modeLabel.toUpperCase()}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="template-card" data-id="${src.id}">
|
||||
<button class="card-remove-btn" onclick="deleteValueSource('${src.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">${propsHtml}</div>
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
|
||||
<div class="template-card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">✏️</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function _setSlider(id, value) {
|
||||
const slider = document.getElementById(id);
|
||||
if (slider) {
|
||||
slider.value = value;
|
||||
const display = document.getElementById(id + '-display');
|
||||
if (display) display.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function _populateAudioSourceDropdown(selectedId) {
|
||||
const select = document.getElementById('value-source-audio-source');
|
||||
if (!select) return;
|
||||
const mono = _cachedAudioSources.filter(s => s.source_type === 'mono');
|
||||
select.innerHTML = mono.map(s =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
@@ -760,5 +760,54 @@
|
||||
"audio_source.updated": "Audio source updated",
|
||||
"audio_source.deleted": "Audio source deleted",
|
||||
"audio_source.delete.confirm": "Are you sure you want to delete this audio source?",
|
||||
"audio_source.error.name_required": "Please enter a name"
|
||||
"audio_source.error.name_required": "Please enter a name",
|
||||
|
||||
"streams.group.value": "Value Sources",
|
||||
"value_source.group.title": "🎚️ Value Sources",
|
||||
"value_source.add": "Add Value Source",
|
||||
"value_source.edit": "Edit Value Source",
|
||||
"value_source.name": "Name:",
|
||||
"value_source.name.placeholder": "Brightness Pulse",
|
||||
"value_source.name.hint": "A descriptive name for this value source",
|
||||
"value_source.type": "Type:",
|
||||
"value_source.type.hint": "Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input.",
|
||||
"value_source.type.static": "Static",
|
||||
"value_source.type.animated": "Animated",
|
||||
"value_source.type.audio": "Audio",
|
||||
"value_source.value": "Value:",
|
||||
"value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)",
|
||||
"value_source.waveform": "Waveform:",
|
||||
"value_source.waveform.hint": "Shape of the brightness animation cycle",
|
||||
"value_source.waveform.sine": "Sine",
|
||||
"value_source.waveform.triangle": "Triangle",
|
||||
"value_source.waveform.square": "Square",
|
||||
"value_source.waveform.sawtooth": "Sawtooth",
|
||||
"value_source.speed": "Speed (cpm):",
|
||||
"value_source.speed.hint": "Cycles per minute — how fast the waveform repeats (1 = very slow, 120 = very fast)",
|
||||
"value_source.min_value": "Min Value:",
|
||||
"value_source.min_value.hint": "Minimum output of the waveform cycle",
|
||||
"value_source.max_value": "Max Value:",
|
||||
"value_source.max_value.hint": "Maximum output of the waveform cycle",
|
||||
"value_source.audio_source": "Audio Source:",
|
||||
"value_source.audio_source.hint": "Mono audio source to read audio levels from",
|
||||
"value_source.mode": "Mode:",
|
||||
"value_source.mode.hint": "RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.",
|
||||
"value_source.mode.rms": "RMS (Volume)",
|
||||
"value_source.mode.peak": "Peak",
|
||||
"value_source.mode.beat": "Beat",
|
||||
"value_source.sensitivity": "Sensitivity:",
|
||||
"value_source.sensitivity.hint": "Gain multiplier for the audio signal (higher = more reactive)",
|
||||
"value_source.smoothing": "Smoothing:",
|
||||
"value_source.smoothing.hint": "Temporal smoothing (0 = instant response, 1 = very smooth/slow)",
|
||||
"value_source.description": "Description (optional):",
|
||||
"value_source.description.placeholder": "Describe this value source...",
|
||||
"value_source.description.hint": "Optional notes about this value source",
|
||||
"value_source.created": "Value source created",
|
||||
"value_source.updated": "Value source updated",
|
||||
"value_source.deleted": "Value source deleted",
|
||||
"value_source.delete.confirm": "Are you sure you want to delete this value source?",
|
||||
"value_source.error.name_required": "Please enter a name",
|
||||
"targets.brightness_vs": "Brightness Source:",
|
||||
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
||||
"targets.brightness_vs.none": "None (device brightness)"
|
||||
}
|
||||
|
||||
@@ -760,5 +760,54 @@
|
||||
"audio_source.updated": "Аудиоисточник обновлён",
|
||||
"audio_source.deleted": "Аудиоисточник удалён",
|
||||
"audio_source.delete.confirm": "Удалить этот аудиоисточник?",
|
||||
"audio_source.error.name_required": "Введите название"
|
||||
"audio_source.error.name_required": "Введите название",
|
||||
|
||||
"streams.group.value": "Источники значений",
|
||||
"value_source.group.title": "🎚️ Источники значений",
|
||||
"value_source.add": "Добавить источник значений",
|
||||
"value_source.edit": "Редактировать источник значений",
|
||||
"value_source.name": "Название:",
|
||||
"value_source.name.placeholder": "Пульс яркости",
|
||||
"value_source.name.hint": "Описательное имя для этого источника значений",
|
||||
"value_source.type": "Тип:",
|
||||
"value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук.",
|
||||
"value_source.type.static": "Статический",
|
||||
"value_source.type.animated": "Анимированный",
|
||||
"value_source.type.audio": "Аудио",
|
||||
"value_source.value": "Значение:",
|
||||
"value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)",
|
||||
"value_source.waveform": "Форма волны:",
|
||||
"value_source.waveform.hint": "Форма цикла анимации яркости",
|
||||
"value_source.waveform.sine": "Синус",
|
||||
"value_source.waveform.triangle": "Треугольник",
|
||||
"value_source.waveform.square": "Прямоугольник",
|
||||
"value_source.waveform.sawtooth": "Пила",
|
||||
"value_source.speed": "Скорость (цикл/мин):",
|
||||
"value_source.speed.hint": "Циклов в минуту — как быстро повторяется волна (1 = очень медленно, 120 = очень быстро)",
|
||||
"value_source.min_value": "Мин. значение:",
|
||||
"value_source.min_value.hint": "Минимальный выход цикла волны",
|
||||
"value_source.max_value": "Макс. значение:",
|
||||
"value_source.max_value.hint": "Максимальный выход цикла волны",
|
||||
"value_source.audio_source": "Аудиоисточник:",
|
||||
"value_source.audio_source.hint": "Моно-аудиоисточник для считывания уровня звука",
|
||||
"value_source.mode": "Режим:",
|
||||
"value_source.mode.hint": "RMS измеряет среднюю громкость. Пик отслеживает самые громкие моменты. Бит реагирует на ритм.",
|
||||
"value_source.mode.rms": "RMS (Громкость)",
|
||||
"value_source.mode.peak": "Пик",
|
||||
"value_source.mode.beat": "Бит",
|
||||
"value_source.sensitivity": "Чувствительность:",
|
||||
"value_source.sensitivity.hint": "Множитель усиления аудиосигнала (выше = более реактивный)",
|
||||
"value_source.smoothing": "Сглаживание:",
|
||||
"value_source.smoothing.hint": "Временное сглаживание (0 = мгновенный отклик, 1 = очень плавный/медленный)",
|
||||
"value_source.description": "Описание (необязательно):",
|
||||
"value_source.description.placeholder": "Опишите этот источник значений...",
|
||||
"value_source.description.hint": "Необязательные заметки об этом источнике значений",
|
||||
"value_source.created": "Источник значений создан",
|
||||
"value_source.updated": "Источник значений обновлён",
|
||||
"value_source.deleted": "Источник значений удалён",
|
||||
"value_source.delete.confirm": "Удалить этот источник значений?",
|
||||
"value_source.error.name_required": "Введите название",
|
||||
"targets.brightness_vs": "Источник яркости:",
|
||||
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
||||
"targets.brightness_vs.none": "Нет (яркость устройства)"
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ class PictureTargetStore:
|
||||
target_type: str,
|
||||
device_id: str = "",
|
||||
color_strip_source_id: str = "",
|
||||
brightness_value_source_id: str = "",
|
||||
fps: int = 30,
|
||||
keepalive_interval: float = 1.0,
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||
@@ -132,6 +133,7 @@ class PictureTargetStore:
|
||||
target_type="led",
|
||||
device_id=device_id,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
brightness_value_source_id=brightness_value_source_id,
|
||||
fps=fps,
|
||||
keepalive_interval=keepalive_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
@@ -165,6 +167,7 @@ class PictureTargetStore:
|
||||
name: Optional[str] = None,
|
||||
device_id: Optional[str] = None,
|
||||
color_strip_source_id: Optional[str] = None,
|
||||
brightness_value_source_id: Optional[str] = None,
|
||||
fps: Optional[int] = None,
|
||||
keepalive_interval: Optional[float] = None,
|
||||
state_check_interval: Optional[int] = None,
|
||||
@@ -191,6 +194,7 @@ class PictureTargetStore:
|
||||
name=name,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
brightness_value_source_id=brightness_value_source_id,
|
||||
fps=fps,
|
||||
keepalive_interval=keepalive_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
|
||||
154
server/src/wled_controller/storage/value_source.py
Normal file
154
server/src/wled_controller/storage/value_source.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Value source data model with inheritance-based source types.
|
||||
|
||||
A ValueSource produces a scalar float (0.0–1.0) that can drive target
|
||||
parameters like brightness. Three types:
|
||||
StaticValueSource — constant float value
|
||||
AnimatedValueSource — periodic waveform (sine, triangle, square, sawtooth)
|
||||
AudioValueSource — audio-reactive scalar (RMS, peak, beat detection)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValueSource:
|
||||
"""Base class for value source configurations."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
source_type: str # "static" | "animated" | "audio"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert source to dictionary. Subclasses extend this."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"source_type": self.source_type,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
# Subclass fields default to None for forward compat
|
||||
"value": None,
|
||||
"waveform": None,
|
||||
"speed": None,
|
||||
"min_value": None,
|
||||
"max_value": None,
|
||||
"audio_source_id": None,
|
||||
"mode": None,
|
||||
"sensitivity": None,
|
||||
"smoothing": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "ValueSource":
|
||||
"""Factory: dispatch to the correct subclass based on source_type."""
|
||||
source_type: str = data.get("source_type", "static") or "static"
|
||||
sid: str = data["id"]
|
||||
name: str = data["name"]
|
||||
description: str | None = data.get("description")
|
||||
|
||||
raw_created = data.get("created_at")
|
||||
created_at: datetime = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime)
|
||||
else datetime.utcnow()
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at: datetime = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime)
|
||||
else datetime.utcnow()
|
||||
)
|
||||
|
||||
if source_type == "animated":
|
||||
return AnimatedValueSource(
|
||||
id=sid, name=name, source_type="animated",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
waveform=data.get("waveform") or "sine",
|
||||
speed=float(data.get("speed") or 10.0),
|
||||
min_value=float(data.get("min_value") or 0.0),
|
||||
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
||||
)
|
||||
|
||||
if source_type == "audio":
|
||||
return AudioValueSource(
|
||||
id=sid, name=name, source_type="audio",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
mode=data.get("mode") or "rms",
|
||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
||||
smoothing=float(data.get("smoothing") or 0.3),
|
||||
)
|
||||
|
||||
# Default: "static" type
|
||||
return StaticValueSource(
|
||||
id=sid, name=name, source_type="static",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
value=float(data["value"]) if data.get("value") is not None else 1.0,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StaticValueSource(ValueSource):
|
||||
"""Value source that outputs a constant float.
|
||||
|
||||
Useful as a simple per-target brightness override.
|
||||
"""
|
||||
|
||||
value: float = 1.0 # 0.0–1.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["value"] = self.value
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnimatedValueSource(ValueSource):
|
||||
"""Value source that cycles through a periodic waveform.
|
||||
|
||||
Produces a smooth animation between min_value and max_value
|
||||
at the configured speed (cycles per minute).
|
||||
"""
|
||||
|
||||
waveform: str = "sine" # sine | triangle | square | sawtooth
|
||||
speed: float = 10.0 # cycles per minute (1.0–120.0)
|
||||
min_value: float = 0.0 # minimum output (0.0–1.0)
|
||||
max_value: float = 1.0 # maximum output (0.0–1.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["waveform"] = self.waveform
|
||||
d["speed"] = self.speed
|
||||
d["min_value"] = self.min_value
|
||||
d["max_value"] = self.max_value
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioValueSource(ValueSource):
|
||||
"""Value source driven by audio input.
|
||||
|
||||
Converts audio analysis (RMS level, peak, or beat detection)
|
||||
into a scalar value for brightness modulation.
|
||||
"""
|
||||
|
||||
audio_source_id: str = "" # references a MonoAudioSource
|
||||
mode: str = "rms" # rms | peak | beat
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1–5.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0–1.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["audio_source_id"] = self.audio_source_id
|
||||
d["mode"] = self.mode
|
||||
d["sensitivity"] = self.sensitivity
|
||||
d["smoothing"] = self.smoothing
|
||||
return d
|
||||
213
server/src/wled_controller/storage/value_source_store.py
Normal file
213
server/src/wled_controller/storage/value_source_store.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Value source storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.storage.value_source import (
|
||||
AnimatedValueSource,
|
||||
AudioValueSource,
|
||||
StaticValueSource,
|
||||
ValueSource,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ValueSourceStore:
|
||||
"""Persistent storage for value sources."""
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = Path(file_path)
|
||||
self._sources: Dict[str, ValueSource] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
logger.info("Value source store file not found — starting empty")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
sources_data = data.get("value_sources", {})
|
||||
loaded = 0
|
||||
for source_id, source_dict in sources_data.items():
|
||||
try:
|
||||
source = ValueSource.from_dict(source_dict)
|
||||
self._sources[source_id] = source
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load value source {source_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} value sources from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load value sources from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Value source store initialized with {len(self._sources)} sources")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sources_dict = {
|
||||
sid: source.to_dict()
|
||||
for sid, source in self._sources.items()
|
||||
}
|
||||
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"value_sources": sources_dict,
|
||||
}
|
||||
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save value sources to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
# ── CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_all_sources(self) -> List[ValueSource]:
|
||||
return list(self._sources.values())
|
||||
|
||||
def get_source(self, source_id: str) -> ValueSource:
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Value source not found: {source_id}")
|
||||
return self._sources[source_id]
|
||||
|
||||
def create_source(
|
||||
self,
|
||||
name: str,
|
||||
source_type: str,
|
||||
value: Optional[float] = None,
|
||||
waveform: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
min_value: Optional[float] = None,
|
||||
max_value: Optional[float] = None,
|
||||
audio_source_id: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
sensitivity: Optional[float] = None,
|
||||
smoothing: Optional[float] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> ValueSource:
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Name is required")
|
||||
|
||||
if source_type not in ("static", "animated", "audio"):
|
||||
raise ValueError(f"Invalid source type: {source_type}")
|
||||
|
||||
for source in self._sources.values():
|
||||
if source.name == name:
|
||||
raise ValueError(f"Value source with name '{name}' already exists")
|
||||
|
||||
sid = f"vs_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
|
||||
if source_type == "static":
|
||||
source: ValueSource = StaticValueSource(
|
||||
id=sid, name=name, source_type="static",
|
||||
created_at=now, updated_at=now, description=description,
|
||||
value=value if value is not None else 1.0,
|
||||
)
|
||||
elif source_type == "animated":
|
||||
source = AnimatedValueSource(
|
||||
id=sid, name=name, source_type="animated",
|
||||
created_at=now, updated_at=now, description=description,
|
||||
waveform=waveform or "sine",
|
||||
speed=speed if speed is not None else 10.0,
|
||||
min_value=min_value if min_value is not None else 0.0,
|
||||
max_value=max_value if max_value is not None else 1.0,
|
||||
)
|
||||
elif source_type == "audio":
|
||||
source = AudioValueSource(
|
||||
id=sid, name=name, source_type="audio",
|
||||
created_at=now, updated_at=now, description=description,
|
||||
audio_source_id=audio_source_id or "",
|
||||
mode=mode or "rms",
|
||||
sensitivity=sensitivity if sensitivity is not None else 1.0,
|
||||
smoothing=smoothing if smoothing is not None else 0.3,
|
||||
)
|
||||
|
||||
self._sources[sid] = source
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created value source: {name} ({sid}, type={source_type})")
|
||||
return source
|
||||
|
||||
def update_source(
|
||||
self,
|
||||
source_id: str,
|
||||
name: Optional[str] = None,
|
||||
value: Optional[float] = None,
|
||||
waveform: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
min_value: Optional[float] = None,
|
||||
max_value: Optional[float] = None,
|
||||
audio_source_id: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
sensitivity: Optional[float] = None,
|
||||
smoothing: Optional[float] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> ValueSource:
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Value source not found: {source_id}")
|
||||
|
||||
source = self._sources[source_id]
|
||||
|
||||
if name is not None:
|
||||
for other in self._sources.values():
|
||||
if other.id != source_id and other.name == name:
|
||||
raise ValueError(f"Value source with name '{name}' already exists")
|
||||
source.name = name
|
||||
|
||||
if description is not None:
|
||||
source.description = description
|
||||
|
||||
if isinstance(source, StaticValueSource):
|
||||
if value is not None:
|
||||
source.value = value
|
||||
elif isinstance(source, AnimatedValueSource):
|
||||
if waveform is not None:
|
||||
source.waveform = waveform
|
||||
if speed is not None:
|
||||
source.speed = speed
|
||||
if min_value is not None:
|
||||
source.min_value = min_value
|
||||
if max_value is not None:
|
||||
source.max_value = max_value
|
||||
elif isinstance(source, AudioValueSource):
|
||||
if audio_source_id is not None:
|
||||
source.audio_source_id = audio_source_id
|
||||
if mode is not None:
|
||||
source.mode = mode
|
||||
if sensitivity is not None:
|
||||
source.sensitivity = sensitivity
|
||||
if smoothing is not None:
|
||||
source.smoothing = smoothing
|
||||
|
||||
source.updated_at = datetime.utcnow()
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated value source: {source_id}")
|
||||
return source
|
||||
|
||||
def delete_source(self, source_id: str) -> None:
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Value source not found: {source_id}")
|
||||
|
||||
del self._sources[source_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted value source: {source_id}")
|
||||
@@ -15,6 +15,7 @@ class WledPictureTarget(PictureTarget):
|
||||
|
||||
device_id: str = ""
|
||||
color_strip_source_id: str = ""
|
||||
brightness_value_source_id: str = ""
|
||||
fps: int = 30 # target send FPS (1-90)
|
||||
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||
@@ -29,11 +30,13 @@ class WledPictureTarget(PictureTarget):
|
||||
fps=self.fps,
|
||||
keepalive_interval=self.keepalive_interval,
|
||||
state_check_interval=self.state_check_interval,
|
||||
brightness_value_source_id=self.brightness_value_source_id,
|
||||
)
|
||||
|
||||
def sync_with_manager(self, manager, *, settings_changed: bool,
|
||||
css_changed: bool = False,
|
||||
device_changed: bool = False) -> None:
|
||||
device_changed: bool = False,
|
||||
brightness_vs_changed: bool = False) -> None:
|
||||
"""Push changed fields to the processor manager."""
|
||||
if settings_changed:
|
||||
manager.update_target_settings(self.id, {
|
||||
@@ -45,8 +48,11 @@ class WledPictureTarget(PictureTarget):
|
||||
manager.update_target_css(self.id, self.color_strip_source_id)
|
||||
if device_changed:
|
||||
manager.update_target_device(self.id, self.device_id)
|
||||
if brightness_vs_changed:
|
||||
manager.update_target_brightness_vs(self.id, self.brightness_value_source_id)
|
||||
|
||||
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
|
||||
brightness_value_source_id=None,
|
||||
fps=None, keepalive_interval=None, state_check_interval=None,
|
||||
description=None, **_kwargs) -> None:
|
||||
"""Apply mutable field updates for WLED targets."""
|
||||
@@ -55,6 +61,8 @@ class WledPictureTarget(PictureTarget):
|
||||
self.device_id = device_id
|
||||
if color_strip_source_id is not None:
|
||||
self.color_strip_source_id = color_strip_source_id
|
||||
if brightness_value_source_id is not None:
|
||||
self.brightness_value_source_id = brightness_value_source_id
|
||||
if fps is not None:
|
||||
self.fps = fps
|
||||
if keepalive_interval is not None:
|
||||
@@ -71,6 +79,7 @@ class WledPictureTarget(PictureTarget):
|
||||
d = super().to_dict()
|
||||
d["device_id"] = self.device_id
|
||||
d["color_strip_source_id"] = self.color_strip_source_id
|
||||
d["brightness_value_source_id"] = self.brightness_value_source_id
|
||||
d["fps"] = self.fps
|
||||
d["keepalive_interval"] = self.keepalive_interval
|
||||
d["state_check_interval"] = self.state_check_interval
|
||||
@@ -95,6 +104,7 @@ class WledPictureTarget(PictureTarget):
|
||||
target_type="led",
|
||||
device_id=data.get("device_id", ""),
|
||||
color_strip_source_id=css_id,
|
||||
brightness_value_source_id=data.get("brightness_value_source_id", ""),
|
||||
fps=data.get("fps", 30),
|
||||
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
{% include 'modals/pp-template.html' %}
|
||||
{% include 'modals/profile-editor.html' %}
|
||||
{% include 'modals/audio-source-editor.html' %}
|
||||
{% include 'modals/value-source-editor.html' %}
|
||||
|
||||
{% include 'partials/tutorial-overlay.html' %}
|
||||
{% include 'partials/image-lightbox.html' %}
|
||||
|
||||
@@ -33,6 +33,17 @@
|
||||
<select id="target-editor-css-source"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-brightness-vs" data-i18n="targets.brightness_vs">Brightness Source:</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="targets.brightness_vs.hint">Optional value source that dynamically controls brightness each frame (overrides device brightness)</small>
|
||||
<select id="target-editor-brightness-vs">
|
||||
<option value="" data-i18n="targets.brightness_vs.none">None (device brightness)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-fps-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-fps">
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<!-- Value Source Editor Modal -->
|
||||
<div id="value-source-modal" class="modal" role="dialog" aria-labelledby="value-source-modal-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="value-source-modal-title" data-i18n="value_source.add">Add Value Source</h2>
|
||||
<button class="modal-close-btn" onclick="closeValueSourceModal()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="value-source-form" onsubmit="return false;">
|
||||
<input type="hidden" id="value-source-id">
|
||||
|
||||
<div id="value-source-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-name" data-i18n="value_source.name">Name:</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="value_source.name.hint">A descriptive name for this value source</small>
|
||||
<input type="text" id="value-source-name" data-i18n-placeholder="value_source.name.placeholder" placeholder="Brightness Pulse" required>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-type" data-i18n="value_source.type">Type:</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="value_source.type.hint">Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input.</small>
|
||||
<select id="value-source-type" onchange="onValueSourceTypeChange()">
|
||||
<option value="static" data-i18n="value_source.type.static">Static</option>
|
||||
<option value="animated" data-i18n="value_source.type.animated">Animated</option>
|
||||
<option value="audio" data-i18n="value_source.type.audio">Audio</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Static fields -->
|
||||
<div id="value-source-static-section">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-value" data-i18n="value_source.value">Value:</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="value_source.value.hint">Constant output value (0.0 = off, 1.0 = full brightness)</small>
|
||||
<div class="range-with-value">
|
||||
<input type="range" id="value-source-value" min="0" max="1" step="0.01" value="1.0"
|
||||
oninput="document.getElementById('value-source-value-display').textContent = this.value">
|
||||
<span id="value-source-value-display">1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Animated fields -->
|
||||
<div id="value-source-animated-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-waveform" data-i18n="value_source.waveform">Waveform:</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="value_source.waveform.hint">Shape of the brightness animation cycle</small>
|
||||
<select id="value-source-waveform">
|
||||
<option value="sine" data-i18n="value_source.waveform.sine">Sine</option>
|
||||
<option value="triangle" data-i18n="value_source.waveform.triangle">Triangle</option>
|
||||
<option value="square" data-i18n="value_source.waveform.square">Square</option>
|
||||
<option value="sawtooth" data-i18n="value_source.waveform.sawtooth">Sawtooth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-speed" data-i18n="value_source.speed">Speed (cpm):</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="value_source.speed.hint">Cycles per minute — how fast the waveform repeats (1 = very slow, 120 = very fast)</small>
|
||||
<div class="range-with-value">
|
||||
<input type="range" id="value-source-speed" min="1" max="120" step="1" value="10"
|
||||
oninput="document.getElementById('value-source-speed-display').textContent = this.value">
|
||||
<span id="value-source-speed-display">10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-min-value" data-i18n="value_source.min_value">Min Value:</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="value_source.min_value.hint">Minimum output of the waveform cycle</small>
|
||||
<div class="range-with-value">
|
||||
<input type="range" id="value-source-min-value" min="0" max="1" step="0.01" value="0"
|
||||
oninput="document.getElementById('value-source-min-value-display').textContent = this.value">
|
||||
<span id="value-source-min-value-display">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-max-value" data-i18n="value_source.max_value">Max Value:</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="value_source.max_value.hint">Maximum output of the waveform cycle</small>
|
||||
<div class="range-with-value">
|
||||
<input type="range" id="value-source-max-value" min="0" max="1" step="0.01" value="1"
|
||||
oninput="document.getElementById('value-source-max-value-display').textContent = this.value">
|
||||
<span id="value-source-max-value-display">1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio fields -->
|
||||
<div id="value-source-audio-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-audio-source" data-i18n="value_source.audio_source">Audio Source:</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="value_source.audio_source.hint">Mono audio source to read audio levels from</small>
|
||||
<select id="value-source-audio-source">
|
||||
<!-- populated dynamically with mono audio sources -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-mode" data-i18n="value_source.mode">Mode:</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="value_source.mode.hint">RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.</small>
|
||||
<select id="value-source-mode">
|
||||
<option value="rms" data-i18n="value_source.mode.rms">RMS (Volume)</option>
|
||||
<option value="peak" data-i18n="value_source.mode.peak">Peak</option>
|
||||
<option value="beat" data-i18n="value_source.mode.beat">Beat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-sensitivity" data-i18n="value_source.sensitivity">Sensitivity:</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="value_source.sensitivity.hint">Gain multiplier for the audio signal (higher = more reactive)</small>
|
||||
<div class="range-with-value">
|
||||
<input type="range" id="value-source-sensitivity" min="0.1" max="5" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('value-source-sensitivity-display').textContent = this.value">
|
||||
<span id="value-source-sensitivity-display">1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-smoothing" data-i18n="value_source.smoothing">Smoothing:</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="value_source.smoothing.hint">Temporal smoothing (0 = instant response, 1 = very smooth/slow)</small>
|
||||
<div class="range-with-value">
|
||||
<input type="range" id="value-source-smoothing" min="0" max="1" step="0.05" value="0.3"
|
||||
oninput="document.getElementById('value-source-smoothing-display').textContent = this.value">
|
||||
<span id="value-source-smoothing-display">0.3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-description" data-i18n="value_source.description">Description (optional):</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="value_source.description.hint">Optional notes about this value source</small>
|
||||
<input type="text" id="value-source-description" data-i18n-placeholder="value_source.description.placeholder" placeholder="Describe this value source...">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeValueSourceModal()" data-i18n="settings.button.cancel">× Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveValueSource()" data-i18n="settings.button.save">✓ Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user