diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 168725c..25c81e5 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -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) diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 60c97e8..76916c4 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -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 diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index c0a81c1..dcfb3f6 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -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 diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py new file mode 100644 index 0000000..4799086 --- /dev/null +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -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)) diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index bd08d3a..09dab7c 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -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)") diff --git a/server/src/wled_controller/api/schemas/value_sources.py b/server/src/wled_controller/api/schemas/value_sources.py new file mode 100644 index 0000000..62b6654 --- /dev/null +++ b/server/src/wled_controller/api/schemas/value_sources.py @@ -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") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index dbea9d3..95595c4 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -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" diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 4888f4b..1384060 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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() diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 5f31ae1..dc74550 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -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 diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py new file mode 100644 index 0000000..f00f330 --- /dev/null +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -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) diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 7895972..91a29ae 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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: diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 448bfa7..db5082f 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -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, ) diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 22fe7ad..e3b8254 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index f9e0b60..fbdfbfa 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -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; } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index c4dedcf..3a0db87 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -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 = `
${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() { diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 2ede722..e5d33f0 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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 = ``; + _cachedValueSources.forEach(vs => { + const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' }; + const icon = typeIcons[vs.source_type] || '🎚️'; + html += ``; + }); + 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, }; diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js new file mode 100644 index 0000000..33498e3 --- /dev/null +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -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 = `${t('value_source.type.static')}: ${src.value ?? 1.0}`; + } else if (src.source_type === 'animated') { + const waveLabel = src.waveform || 'sine'; + propsHtml = ` + ${escapeHtml(waveLabel)} + ${src.speed ?? 10} cpm + ${src.min_value ?? 0}–${src.max_value ?? 1} + `; + } 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 = ` + ${escapeHtml(audioName)} + ${modeLabel.toUpperCase()} + `; + } + + return ` +
+ +
+
${icon} ${escapeHtml(src.name)}
+
+
${propsHtml}
+ ${src.description ? `
${escapeHtml(src.description)}
` : ''} +
+ +
+
+ `; +} + +// ── 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 => + `` + ).join(''); +} diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index ddcf526..f48cbfb 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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)" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 3f43114..7eeb398 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Нет (яркость устройства)" } diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index 3756c38..249aee3 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -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, diff --git a/server/src/wled_controller/storage/value_source.py b/server/src/wled_controller/storage/value_source.py new file mode 100644 index 0000000..a905097 --- /dev/null +++ b/server/src/wled_controller/storage/value_source.py @@ -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 diff --git a/server/src/wled_controller/storage/value_source_store.py b/server/src/wled_controller/storage/value_source_store.py new file mode 100644 index 0000000..6db085f --- /dev/null +++ b/server/src/wled_controller/storage/value_source_store.py @@ -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}") diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index 60a2385..64d1c1a 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -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), diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 37b0d01..8186af9 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -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' %} diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index ea43a7a..7262115 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -33,6 +33,17 @@
+
+
+ + +
+ + +
+