diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index 4799086..7553238 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -43,6 +43,10 @@ def _to_response(source: ValueSource) -> ValueSourceResponse: mode=d.get("mode"), sensitivity=d.get("sensitivity"), smoothing=d.get("smoothing"), + adaptive_mode=d.get("adaptive_mode"), + schedule=d.get("schedule"), + picture_source_id=d.get("picture_source_id"), + scene_behavior=d.get("scene_behavior"), description=d.get("description"), created_at=source.created_at, updated_at=source.updated_at, @@ -86,6 +90,10 @@ async def create_value_source( sensitivity=data.sensitivity, smoothing=data.smoothing, description=data.description, + adaptive_mode=data.adaptive_mode, + schedule=data.schedule, + picture_source_id=data.picture_source_id, + scene_behavior=data.scene_behavior, ) return _to_response(source) except ValueError as e: @@ -129,6 +137,10 @@ async def update_value_source( sensitivity=data.sensitivity, smoothing=data.smoothing, description=data.description, + adaptive_mode=data.adaptive_mode, + schedule=data.schedule, + picture_source_id=data.picture_source_id, + scene_behavior=data.scene_behavior, ) # Hot-reload running value streams pm.update_value_source(source_id) diff --git a/server/src/wled_controller/api/schemas/value_sources.py b/server/src/wled_controller/api/schemas/value_sources.py index 62b6654..c07ebc9 100644 --- a/server/src/wled_controller/api/schemas/value_sources.py +++ b/server/src/wled_controller/api/schemas/value_sources.py @@ -10,7 +10,7 @@ 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") + source_type: Literal["static", "animated", "audio", "adaptive"] = 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 @@ -23,6 +23,11 @@ class ValueSourceCreate(BaseModel): 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) + # adaptive fields + adaptive_mode: Optional[str] = Field(None, description="Adaptive mode: time_of_day|scene") + schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]") + picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode") + scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -42,6 +47,11 @@ class ValueSourceUpdate(BaseModel): 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) + # adaptive fields + adaptive_mode: Optional[str] = Field(None, description="Adaptive mode: time_of_day|scene") + schedule: Optional[list] = Field(None, description="Time-of-day schedule") + picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode") + scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match") description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -50,7 +60,7 @@ class ValueSourceResponse(BaseModel): id: str = Field(description="Source ID") name: str = Field(description="Source name") - source_type: str = Field(description="Source type: static, animated, or audio") + source_type: str = Field(description="Source type: static, animated, audio, or adaptive") 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") @@ -60,6 +70,10 @@ class ValueSourceResponse(BaseModel): mode: Optional[str] = Field(None, description="Audio mode") sensitivity: Optional[float] = Field(None, description="Gain multiplier") smoothing: Optional[float] = Field(None, description="Temporal smoothing") + adaptive_mode: Optional[str] = Field(None, description="Adaptive mode") + schedule: Optional[list] = Field(None, description="Time-of-day schedule") + picture_source_id: Optional[str] = Field(None, description="Picture source ID") + scene_behavior: Optional[str] = Field(None, description="Scene behavior") description: Optional[str] = Field(None, description="Description") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 94b8f0d..793fa7d 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -95,6 +95,7 @@ class ProcessorManager: value_source_store=value_source_store, audio_capture_manager=self._audio_capture_manager, audio_source_store=audio_source_store, + live_stream_manager=self._live_stream_manager, ) if value_source_store else None self._overlay_manager = OverlayManager() self._event_queues: List[asyncio.Queue] = [] diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index f00f330..051e5d7 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -1,12 +1,14 @@ """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: +on demand via ``get_value()``. Five 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 + 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 + TimeOfDayValueStream — interpolates brightness along a 24h schedule + SceneValueStream — derives brightness from a picture source's frame luminance ValueStreams are cheap (trivial math or single poll), so they compute inline in the caller's processing loop — no background threads required. @@ -19,12 +21,16 @@ from __future__ import annotations import math import time -from typing import TYPE_CHECKING, Dict, Optional +from datetime import datetime +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +import numpy as np from wled_controller.utils import get_logger if TYPE_CHECKING: from wled_controller.core.audio.audio_capture import AudioCaptureManager + from wled_controller.core.processing.live_stream_manager import LiveStreamManager 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 @@ -277,6 +283,214 @@ class AudioValueStream(ValueStream): ) +# --------------------------------------------------------------------------- +# Time of Day +# --------------------------------------------------------------------------- + +_MINUTES_PER_DAY = 1440 # 24 * 60 + + +class TimeOfDayValueStream(ValueStream): + """Interpolates brightness along a 24-hour schedule. + + Schedule is a list of {"time": "HH:MM", "value": 0.0–1.0} dicts. + At runtime, finds the two surrounding points and linearly interpolates. + The schedule wraps around midnight. + """ + + def __init__( + self, + schedule: List[dict], + min_value: float = 0.0, + max_value: float = 1.0, + ): + self._points: List[Tuple[float, float]] = [] # (minutes, value) + self._min = min_value + self._max = max_value + self._parse_schedule(schedule) + + def _parse_schedule(self, schedule: List[dict]) -> None: + """Parse schedule into sorted (minutes, value) tuples.""" + points = [] + for entry in schedule: + t_str = entry.get("time", "00:00") + parts = t_str.split(":") + h = int(parts[0]) + m = int(parts[1]) if len(parts) > 1 else 0 + minutes = h * 60 + m + val = max(0.0, min(1.0, float(entry.get("value", 1.0)))) + points.append((minutes, val)) + points.sort(key=lambda p: p[0]) + self._points = points + + def get_value(self) -> float: + if len(self._points) < 2: + return self._max # fallback: full brightness + + now = datetime.now() + current = now.hour * 60 + now.minute + now.second / 60.0 + + points = self._points + n = len(points) + + # Find the first point whose time is > current + right_idx = 0 + for i, (t, _) in enumerate(points): + if t > current: + right_idx = i + break + else: + right_idx = 0 # all points <= current: wrap to first + + left_idx = (right_idx - 1) % n + t_left, v_left = points[left_idx] + t_right, v_right = points[right_idx] + + # Compute interval length (handling midnight wrap) + if t_right > t_left: + interval = t_right - t_left + elapsed = current - t_left + else: + interval = (_MINUTES_PER_DAY - t_left) + t_right + elapsed = (current - t_left) if current >= t_left else (_MINUTES_PER_DAY - t_left) + current + + frac = elapsed / interval if interval > 0 else 0.0 + raw = v_left + frac * (v_right - v_left) + + # Map to output range + return self._min + max(0.0, min(1.0, raw)) * (self._max - self._min) + + def update_source(self, source: "ValueSource") -> None: + from wled_controller.storage.value_source import AdaptiveValueSource + if isinstance(source, AdaptiveValueSource) and source.adaptive_mode == "time_of_day": + self._parse_schedule(source.schedule) + self._min = source.min_value + self._max = source.max_value + + +# --------------------------------------------------------------------------- +# Scene +# --------------------------------------------------------------------------- + +class SceneValueStream(ValueStream): + """Derives brightness from a picture source's average frame luminance. + + Acquires a LiveStream from LiveStreamManager on start(). On each + get_value() call, reads the latest frame, subsamples, computes mean + luminance (BT.601), applies sensitivity/smoothing/mapping. + + Behaviors: + complement — dark scene → high brightness (ambient backlight visibility) + match — bright scene → high brightness + """ + + def __init__( + self, + picture_source_id: str, + scene_behavior: str = "complement", + sensitivity: float = 1.0, + smoothing: float = 0.3, + min_value: float = 0.0, + max_value: float = 1.0, + live_stream_manager: Optional["LiveStreamManager"] = None, + ): + self._picture_source_id = picture_source_id + self._behavior = scene_behavior + self._sensitivity = sensitivity + self._smoothing = smoothing + self._min = min_value + self._max = max_value + self._live_stream_manager = live_stream_manager + self._live_stream = None + self._prev_value = 0.5 # neutral start + + def start(self) -> None: + if self._live_stream_manager and self._picture_source_id: + try: + self._live_stream = self._live_stream_manager.acquire( + self._picture_source_id + ) + logger.info( + f"SceneValueStream acquired live stream for {self._picture_source_id}" + ) + except Exception as e: + logger.warning(f"SceneValueStream failed to acquire live stream: {e}") + self._live_stream = None + + def stop(self) -> None: + if self._live_stream is not None and self._live_stream_manager: + try: + self._live_stream_manager.release(self._picture_source_id) + except Exception as e: + logger.warning(f"SceneValueStream failed to release live stream: {e}") + self._live_stream = None + self._prev_value = 0.5 + + def get_value(self) -> float: + if self._live_stream is None: + return self._prev_value + + frame = self._live_stream.get_latest_frame() + if frame is None: + return self._prev_value + + # Fast luminance: subsample to ~64x64 via numpy stride (zero-copy view) + img = frame.image + h, w = img.shape[:2] + step_h = max(1, h // 64) + step_w = max(1, w // 64) + sampled = img[::step_h, ::step_w].astype(np.float32) + + # BT.601 weighted luminance, normalized to [0, 1] + luminance = float( + (0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2]).mean() + ) / 255.0 + + # Apply sensitivity + raw = min(1.0, luminance * self._sensitivity) + + # Apply behavior + if self._behavior == "complement": + raw = 1.0 - raw + + # Temporal smoothing (EMA) + smoothed = self._smoothing * self._prev_value + (1.0 - self._smoothing) * raw + self._prev_value = smoothed + + # Map to output range + clamped = max(0.0, min(1.0, smoothed)) + return self._min + clamped * (self._max - self._min) + + def update_source(self, source: "ValueSource") -> None: + from wled_controller.storage.value_source import AdaptiveValueSource + if not isinstance(source, AdaptiveValueSource) or source.adaptive_mode != "scene": + return + + self._behavior = source.scene_behavior + self._sensitivity = source.sensitivity + self._smoothing = source.smoothing + self._min = source.min_value + self._max = source.max_value + + # If picture source changed, swap live streams + if source.picture_source_id != self._picture_source_id: + old_id = self._picture_source_id + self._picture_source_id = source.picture_source_id + if self._live_stream is not None and self._live_stream_manager: + self._live_stream_manager.release(old_id) + try: + self._live_stream = self._live_stream_manager.acquire( + self._picture_source_id + ) + logger.info( + f"SceneValueStream swapped live stream: {old_id} → " + f"{self._picture_source_id}" + ) + except Exception as e: + logger.warning(f"SceneValueStream failed to swap live stream: {e}") + self._live_stream = None + + # --------------------------------------------------------------------------- # Manager # --------------------------------------------------------------------------- @@ -297,10 +511,12 @@ class ValueStreamManager: value_source_store: "ValueSourceStore", audio_capture_manager: Optional["AudioCaptureManager"] = None, audio_source_store: Optional["AudioSourceStore"] = None, + live_stream_manager: Optional["LiveStreamManager"] = None, ): self._value_source_store = value_source_store self._audio_capture_manager = audio_capture_manager self._audio_source_store = audio_source_store + self._live_stream_manager = live_stream_manager self._streams: Dict[str, ValueStream] = {} def acquire(self, vs_id: str, consumer_id: str) -> ValueStream: @@ -359,6 +575,7 @@ class ValueStreamManager: def _create_stream(self, source: "ValueSource") -> ValueStream: """Factory: create the appropriate ValueStream for a ValueSource.""" from wled_controller.storage.value_source import ( + AdaptiveValueSource, AnimatedValueSource, AudioValueSource, StaticValueSource, @@ -385,5 +602,23 @@ class ValueStreamManager: audio_source_store=self._audio_source_store, ) + if isinstance(source, AdaptiveValueSource): + if source.adaptive_mode == "scene": + return SceneValueStream( + picture_source_id=source.picture_source_id, + scene_behavior=source.scene_behavior, + sensitivity=source.sensitivity, + smoothing=source.smoothing, + min_value=source.min_value, + max_value=source.max_value, + live_stream_manager=self._live_stream_manager, + ) + # Default: time_of_day + return TimeOfDayValueStream( + schedule=source.schedule, + min_value=source.min_value, + max_value=source.max_value, + ) + # Fallback return StaticValueStream(value=1.0) diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index e3b8254..920f4c8 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -112,6 +112,7 @@ import { import { showValueSourceModal, closeValueSourceModal, saveValueSource, editValueSource, deleteValueSource, onValueSourceTypeChange, + onAdaptiveModeChange, addSchedulePoint, } from './features/value-sources.js'; // Layer 5: calibration @@ -330,6 +331,8 @@ Object.assign(window, { editValueSource, deleteValueSource, onValueSourceTypeChange, + onAdaptiveModeChange, + addSchedulePoint, // calibration showCalibration, diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js index 33498e3..cecbe35 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -1,15 +1,15 @@ /** - * Value Sources — CRUD for scalar value sources (static, animated, audio). + * Value Sources — CRUD for scalar value sources (static, animated, audio, adaptive). * * 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). + * on LED targets. Four subtypes: static (constant), animated (waveform), + * audio (audio-reactive), adaptive (time-of-day schedule or scene brightness). * * 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 { _cachedValueSources, set_cachedValueSources, _cachedAudioSources, _cachedStreams } from '../core/state.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; @@ -49,6 +49,16 @@ export async function showValueSourceModal(editData) { 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 if (editData.source_type === 'adaptive') { + document.getElementById('value-source-adaptive-mode').value = editData.adaptive_mode || 'time_of_day'; + onAdaptiveModeChange(); + _populateScheduleUI(editData.schedule); + _populatePictureSourceDropdown(editData.picture_source_id || ''); + document.getElementById('value-source-scene-behavior').value = editData.scene_behavior || 'complement'; + _setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0); + _setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3); + _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0); + _setSlider('value-source-adaptive-max-value', editData.max_value ?? 1); } } else { document.getElementById('value-source-name').value = ''; @@ -64,6 +74,15 @@ export async function showValueSourceModal(editData) { document.getElementById('value-source-mode').value = 'rms'; _setSlider('value-source-sensitivity', 1.0); _setSlider('value-source-smoothing', 0.3); + // Adaptive defaults + document.getElementById('value-source-adaptive-mode').value = 'time_of_day'; + _populateScheduleUI([]); + _populatePictureSourceDropdown(''); + document.getElementById('value-source-scene-behavior').value = 'complement'; + _setSlider('value-source-scene-sensitivity', 1.0); + _setSlider('value-source-scene-smoothing', 0.3); + _setSlider('value-source-adaptive-min-value', 0); + _setSlider('value-source-adaptive-max-value', 1); } valueSourceModal.open(); @@ -78,6 +97,7 @@ export function onValueSourceTypeChange() { 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'; + document.getElementById('value-source-adaptive-section').style.display = type === 'adaptive' ? '' : 'none'; // Populate audio dropdown when switching to audio type if (type === 'audio') { @@ -86,6 +106,18 @@ export function onValueSourceTypeChange() { _populateAudioSourceDropdown(''); } } + + // Initialize adaptive sub-sections + if (type === 'adaptive') { + onAdaptiveModeChange(); + _populatePictureSourceDropdown(''); + } +} + +export function onAdaptiveModeChange() { + const mode = document.getElementById('value-source-adaptive-mode').value; + document.getElementById('value-source-tod-section').style.display = mode === 'time_of_day' ? '' : 'none'; + document.getElementById('value-source-scene-section').style.display = mode === 'scene' ? '' : 'none'; } // ── Save ────────────────────────────────────────────────────── @@ -117,6 +149,23 @@ export async function saveValueSource() { 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); + } else if (sourceType === 'adaptive') { + payload.adaptive_mode = document.getElementById('value-source-adaptive-mode').value; + payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value); + payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value); + if (payload.adaptive_mode === 'time_of_day') { + payload.schedule = _getScheduleFromUI(); + if (payload.schedule.length < 2) { + errorEl.textContent = t('value_source.error.schedule_min'); + errorEl.style.display = ''; + return; + } + } else if (payload.adaptive_mode === 'scene') { + payload.picture_source_id = document.getElementById('value-source-picture-source').value; + payload.scene_behavior = document.getElementById('value-source-scene-behavior').value; + payload.sensitivity = parseFloat(document.getElementById('value-source-scene-sensitivity').value); + payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value); + } } try { @@ -175,7 +224,7 @@ export async function deleteValueSource(sourceId) { // ── Card rendering (used by streams.js) ─────────────────────── export function createValueSourceCard(src) { - const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' }; + const typeIcons = { static: '📊', animated: '🔄', audio: '🎵', adaptive: '🌤️' }; const icon = typeIcons[src.source_type] || '🎚️'; let propsHtml = ''; @@ -196,6 +245,23 @@ export function createValueSourceCard(src) { ${escapeHtml(audioName)} ${modeLabel.toUpperCase()} `; + } else if (src.source_type === 'adaptive') { + if (src.adaptive_mode === 'scene') { + const ps = _cachedStreams.find(s => s.id === src.picture_source_id); + const psName = ps ? ps.name : (src.picture_source_id || '-'); + propsHtml = ` + ${t('value_source.adaptive_mode.scene')} + ${escapeHtml(psName)} + ${src.scene_behavior || 'complement'} + `; + } else { + const pts = (src.schedule || []).length; + propsHtml = ` + ${t('value_source.adaptive_mode.time_of_day')} + ${pts} ${t('value_source.schedule.points')} + ${src.min_value ?? 0}–${src.max_value ?? 1} + `; + } } return ` @@ -232,3 +298,52 @@ function _populateAudioSourceDropdown(selectedId) { `` ).join(''); } + +// ── Adaptive helpers ────────────────────────────────────────── + +function _populatePictureSourceDropdown(selectedId) { + const select = document.getElementById('value-source-picture-source'); + if (!select) return; + select.innerHTML = _cachedStreams.map(s => + `` + ).join(''); +} + +export function addSchedulePoint(time = '', value = 1.0) { + const list = document.getElementById('value-source-schedule-list'); + if (!list) return; + const row = document.createElement('div'); + row.className = 'schedule-row'; + row.innerHTML = ` + + + ${value} + + `; + list.appendChild(row); +} + +function _getScheduleFromUI() { + const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row'); + const schedule = []; + rows.forEach(row => { + const time = row.querySelector('.schedule-time').value; + const value = parseFloat(row.querySelector('.schedule-value').value); + if (time) schedule.push({ time, value }); + }); + return schedule; +} + +function _populateScheduleUI(schedule) { + const list = document.getElementById('value-source-schedule-list'); + if (!list) return; + list.innerHTML = ''; + if (!schedule || schedule.length === 0) { + // Default: morning bright, night dim + addSchedulePoint('08:00', 1.0); + addSchedulePoint('22:00', 0.3); + } else { + schedule.forEach(p => addSchedulePoint(p.time, p.value)); + } +} diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index b704aa7..318fa22 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -774,10 +774,11 @@ "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.hint": "Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input. Adaptive adjusts based on time of day or scene brightness.", "value_source.type.static": "Static", "value_source.type.animated": "Animated", "value_source.type.audio": "Audio", + "value_source.type.adaptive": "Adaptive", "value_source.value": "Value:", "value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)", "value_source.waveform": "Waveform:", @@ -803,6 +804,25 @@ "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.adaptive_mode": "Adaptive Mode:", + "value_source.adaptive_mode.hint": "Time of Day adjusts brightness on a daily schedule. Scene analyzes picture brightness in real time.", + "value_source.adaptive_mode.time_of_day": "Time of Day", + "value_source.adaptive_mode.scene": "Scene Brightness", + "value_source.schedule": "Schedule:", + "value_source.schedule.hint": "Define at least 2 time points. Brightness interpolates linearly between them, wrapping at midnight.", + "value_source.schedule.add": "+ Add Point", + "value_source.schedule.points": "points", + "value_source.picture_source": "Picture Source:", + "value_source.picture_source.hint": "The picture source whose frames will be analyzed for average brightness.", + "value_source.scene_behavior": "Behavior:", + "value_source.scene_behavior.hint": "Complement: dark scene = high brightness (ideal for ambient backlight). Match: bright scene = high brightness.", + "value_source.scene_behavior.complement": "Complement (dark → bright)", + "value_source.scene_behavior.match": "Match (bright → bright)", + "value_source.adaptive_min_value": "Min Value:", + "value_source.adaptive_min_value.hint": "Minimum output brightness", + "value_source.adaptive_max_value": "Max Value:", + "value_source.adaptive_max_value.hint": "Maximum output brightness", + "value_source.error.schedule_min": "Schedule requires at least 2 time points", "value_source.description": "Description (optional):", "value_source.description.placeholder": "Describe this value source...", "value_source.description.hint": "Optional notes about this value source", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 12b44c2..5bc23c3 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -774,10 +774,11 @@ "value_source.name.placeholder": "Пульс яркости", "value_source.name.hint": "Описательное имя для этого источника значений", "value_source.type": "Тип:", - "value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук.", + "value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук. Адаптивный подстраивается под время суток или яркость сцены.", "value_source.type.static": "Статический", "value_source.type.animated": "Анимированный", "value_source.type.audio": "Аудио", + "value_source.type.adaptive": "Адаптивный", "value_source.value": "Значение:", "value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)", "value_source.waveform": "Форма волны:", @@ -803,6 +804,25 @@ "value_source.sensitivity.hint": "Множитель усиления аудиосигнала (выше = более реактивный)", "value_source.smoothing": "Сглаживание:", "value_source.smoothing.hint": "Временное сглаживание (0 = мгновенный отклик, 1 = очень плавный/медленный)", + "value_source.adaptive_mode": "Адаптивный режим:", + "value_source.adaptive_mode.hint": "Время суток регулирует яркость по дневному расписанию. Сцена анализирует яркость изображения в реальном времени.", + "value_source.adaptive_mode.time_of_day": "Время суток", + "value_source.adaptive_mode.scene": "Яркость сцены", + "value_source.schedule": "Расписание:", + "value_source.schedule.hint": "Определите минимум 2 временные точки. Яркость линейно интерполируется между ними, с переходом через полночь.", + "value_source.schedule.add": "+ Добавить точку", + "value_source.schedule.points": "точек", + "value_source.picture_source": "Источник изображения:", + "value_source.picture_source.hint": "Источник изображения, кадры которого будут анализироваться на среднюю яркость.", + "value_source.scene_behavior": "Поведение:", + "value_source.scene_behavior.hint": "Дополнение: тёмная сцена = высокая яркость (для фоновой подсветки). Совпадение: яркая сцена = высокая яркость.", + "value_source.scene_behavior.complement": "Дополнение (тёмный → ярко)", + "value_source.scene_behavior.match": "Совпадение (яркий → ярко)", + "value_source.adaptive_min_value": "Мин. значение:", + "value_source.adaptive_min_value.hint": "Минимальная выходная яркость", + "value_source.adaptive_max_value": "Макс. значение:", + "value_source.adaptive_max_value.hint": "Максимальная выходная яркость", + "value_source.error.schedule_min": "Расписание требует минимум 2 временные точки", "value_source.description": "Описание (необязательно):", "value_source.description.placeholder": "Опишите этот источник значений...", "value_source.description.hint": "Необязательные заметки об этом источнике значений", diff --git a/server/src/wled_controller/storage/value_source.py b/server/src/wled_controller/storage/value_source.py index a905097..153c024 100644 --- a/server/src/wled_controller/storage/value_source.py +++ b/server/src/wled_controller/storage/value_source.py @@ -1,15 +1,16 @@ """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: +parameters like brightness. Four types: StaticValueSource — constant float value AnimatedValueSource — periodic waveform (sine, triangle, square, sawtooth) AudioValueSource — audio-reactive scalar (RMS, peak, beat detection) + AdaptiveValueSource — adapts to external conditions (time of day, scene brightness) """ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import Optional +from typing import List, Optional @dataclass @@ -42,6 +43,10 @@ class ValueSource: "mode": None, "sensitivity": None, "smoothing": None, + "adaptive_mode": None, + "schedule": None, + "picture_source_id": None, + "scene_behavior": None, } @staticmethod @@ -87,6 +92,20 @@ class ValueSource: smoothing=float(data.get("smoothing") or 0.3), ) + if source_type == "adaptive": + return AdaptiveValueSource( + id=sid, name=name, source_type="adaptive", + created_at=created_at, updated_at=updated_at, description=description, + adaptive_mode=data.get("adaptive_mode") or "time_of_day", + schedule=data.get("schedule") or [], + picture_source_id=data.get("picture_source_id") or "", + scene_behavior=data.get("scene_behavior") or "complement", + sensitivity=float(data.get("sensitivity") or 1.0), + smoothing=float(data.get("smoothing") or 0.3), + 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, + ) + # Default: "static" type return StaticValueSource( id=sid, name=name, source_type="static", @@ -152,3 +171,34 @@ class AudioValueSource(ValueSource): d["sensitivity"] = self.sensitivity d["smoothing"] = self.smoothing return d + + +@dataclass +class AdaptiveValueSource(ValueSource): + """Value source that adapts to external conditions. + + Two sub-modes: + time_of_day — interpolates brightness along a 24-hour schedule + scene — derives brightness from a picture source's frame luminance + """ + + adaptive_mode: str = "time_of_day" # "time_of_day" | "scene" + schedule: List[dict] = field(default_factory=list) # [{time: "HH:MM", value: 0.0-1.0}] + picture_source_id: str = "" # for scene mode + scene_behavior: str = "complement" # "complement" | "match" + sensitivity: float = 1.0 # gain multiplier (0.1-5.0) + smoothing: float = 0.3 # temporal smoothing (0.0-1.0) + min_value: float = 0.0 # output range min + max_value: float = 1.0 # output range max + + def to_dict(self) -> dict: + d = super().to_dict() + d["adaptive_mode"] = self.adaptive_mode + d["schedule"] = self.schedule + d["picture_source_id"] = self.picture_source_id + d["scene_behavior"] = self.scene_behavior + d["sensitivity"] = self.sensitivity + d["smoothing"] = self.smoothing + d["min_value"] = self.min_value + d["max_value"] = self.max_value + return d diff --git a/server/src/wled_controller/storage/value_source_store.py b/server/src/wled_controller/storage/value_source_store.py index 6db085f..b833010 100644 --- a/server/src/wled_controller/storage/value_source_store.py +++ b/server/src/wled_controller/storage/value_source_store.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Dict, List, Optional from wled_controller.storage.value_source import ( + AdaptiveValueSource, AnimatedValueSource, AudioValueSource, StaticValueSource, @@ -101,11 +102,15 @@ class ValueSourceStore: sensitivity: Optional[float] = None, smoothing: Optional[float] = None, description: Optional[str] = None, + adaptive_mode: Optional[str] = None, + schedule: Optional[list] = None, + picture_source_id: Optional[str] = None, + scene_behavior: Optional[str] = None, ) -> ValueSource: if not name or not name.strip(): raise ValueError("Name is required") - if source_type not in ("static", "animated", "audio"): + if source_type not in ("static", "animated", "audio", "adaptive"): raise ValueError(f"Invalid source type: {source_type}") for source in self._sources.values(): @@ -139,6 +144,23 @@ class ValueSourceStore: sensitivity=sensitivity if sensitivity is not None else 1.0, smoothing=smoothing if smoothing is not None else 0.3, ) + elif source_type == "adaptive": + am = adaptive_mode or "time_of_day" + schedule_data = schedule or [] + if am == "time_of_day" and len(schedule_data) < 2: + raise ValueError("Time of day schedule requires at least 2 points") + source = AdaptiveValueSource( + id=sid, name=name, source_type="adaptive", + created_at=now, updated_at=now, description=description, + adaptive_mode=am, + schedule=schedule_data, + picture_source_id=picture_source_id or "", + scene_behavior=scene_behavior or "complement", + sensitivity=sensitivity if sensitivity is not None else 1.0, + smoothing=smoothing if smoothing is not None else 0.3, + 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, + ) self._sources[sid] = source self._save() @@ -160,6 +182,10 @@ class ValueSourceStore: sensitivity: Optional[float] = None, smoothing: Optional[float] = None, description: Optional[str] = None, + adaptive_mode: Optional[str] = None, + schedule: Optional[list] = None, + picture_source_id: Optional[str] = None, + scene_behavior: Optional[str] = None, ) -> ValueSource: if source_id not in self._sources: raise ValueError(f"Value source not found: {source_id}") @@ -196,6 +222,25 @@ class ValueSourceStore: source.sensitivity = sensitivity if smoothing is not None: source.smoothing = smoothing + elif isinstance(source, AdaptiveValueSource): + if adaptive_mode is not None: + source.adaptive_mode = adaptive_mode + if schedule is not None: + if source.adaptive_mode == "time_of_day" and len(schedule) < 2: + raise ValueError("Time of day schedule requires at least 2 points") + source.schedule = schedule + if picture_source_id is not None: + source.picture_source_id = picture_source_id + if scene_behavior is not None: + source.scene_behavior = scene_behavior + if sensitivity is not None: + source.sensitivity = sensitivity + if smoothing is not None: + source.smoothing = smoothing + if min_value is not None: + source.min_value = min_value + if max_value is not None: + source.max_value = max_value source.updated_at = datetime.utcnow() self._save() diff --git a/server/src/wled_controller/templates/modals/value-source-editor.html b/server/src/wled_controller/templates/modals/value-source-editor.html index b667b8e..dc6a382 100644 --- a/server/src/wled_controller/templates/modals/value-source-editor.html +++ b/server/src/wled_controller/templates/modals/value-source-editor.html @@ -32,6 +32,7 @@ + @@ -160,6 +161,114 @@ + +
+