diff --git a/TODO.md b/TODO.md index 0876ef1..8ca9695 100644 --- a/TODO.md +++ b/TODO.md @@ -51,6 +51,6 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default - [ ] `P1` **Review new CSS types (Daylight & Candlelight)** — End-to-end review: create via UI, assign to targets, verify LED rendering, check edge cases (0 candles, extreme latitude, real-time toggle) -- [ ] `P1` **Daylight brightness value source** — New value source type that reports a 0–255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic +- [x] `P1` **Daylight brightness value source** — New value source type that reports a 0–255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic - [ ] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout - [ ] `P1` **IconSelect grid overflow & scroll jump** — Expandable icon grid sometimes renders outside the visible viewport; opening it causes the modal/page to jump scroll diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index 3bfeb25..baf35bc 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -51,6 +51,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse: schedule=d.get("schedule"), picture_source_id=d.get("picture_source_id"), scene_behavior=d.get("scene_behavior"), + use_real_time=d.get("use_real_time"), + latitude=d.get("latitude"), description=d.get("description"), tags=d.get("tags", []), created_at=source.created_at, @@ -99,6 +101,8 @@ async def create_value_source( picture_source_id=data.picture_source_id, scene_behavior=data.scene_behavior, auto_gain=data.auto_gain, + use_real_time=data.use_real_time, + latitude=data.latitude, tags=data.tags, ) fire_entity_event("value_source", "created", source.id) @@ -148,6 +152,8 @@ async def update_value_source( picture_source_id=data.picture_source_id, scene_behavior=data.scene_behavior, auto_gain=data.auto_gain, + use_real_time=data.use_real_time, + latitude=data.latitude, tags=data.tags, ) # Hot-reload running value streams diff --git a/server/src/wled_controller/api/schemas/value_sources.py b/server/src/wled_controller/api/schemas/value_sources.py index 3679825..d5f0705 100644 --- a/server/src/wled_controller/api/schemas/value_sources.py +++ b/server/src/wled_controller/api/schemas/value_sources.py @@ -10,12 +10,12 @@ 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", "adaptive_time", "adaptive_scene"] = Field(description="Source type") + source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = 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) + speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, 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 @@ -28,6 +28,9 @@ class ValueSourceCreate(BaseModel): 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") + # daylight fields + use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation") + latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0) description: Optional[str] = Field(None, description="Optional description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") @@ -40,7 +43,7 @@ class ValueSourceUpdate(BaseModel): 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) + speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, 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 @@ -53,6 +56,9 @@ class ValueSourceUpdate(BaseModel): 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") + # daylight fields + use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation") + latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0) description: Optional[str] = Field(None, description="Optional description", max_length=500) tags: Optional[List[str]] = None @@ -76,6 +82,8 @@ class ValueSourceResponse(BaseModel): 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") + use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") + latitude: Optional[float] = Field(None, description="Geographic latitude") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index 998166e..7d0467b 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -1,7 +1,7 @@ """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()``. Five concrete types: +on demand via ``get_value()``. Six concrete types: StaticValueStream — returns a constant AnimatedValueStream — evaluates a periodic waveform (sine/triangle/square/sawtooth) @@ -9,6 +9,7 @@ on demand via ``get_value()``. Five concrete types: sensitivity and temporal smoothing TimeOfDayValueStream — interpolates brightness along a 24h schedule (adaptive_time) SceneValueStream — derives brightness from a picture source's frame luminance (adaptive_scene) + DaylightValueStream — brightness derived from daylight cycle LUT (real-time or simulated) ValueStreams are cheap (trivial math or single poll), so they compute inline in the caller's processing loop — no background threads required. @@ -541,6 +542,64 @@ class SceneValueStream(ValueStream): self._live_stream = None +# --------------------------------------------------------------------------- +# Daylight +# --------------------------------------------------------------------------- + + +class DaylightValueStream(ValueStream): + """Brightness derived from the daylight color cycle LUT. + + Computes BT.601 luminance from the RGB sky color at the current + simulated (or real-time) minute of day, then maps to [min, max]. + Cheap inline computation — no background thread needed. + """ + + def __init__( + self, + speed: float = 1.0, + use_real_time: bool = False, + latitude: float = 50.0, + min_value: float = 0.0, + max_value: float = 1.0, + ): + from wled_controller.core.processing.daylight_stream import _get_daylight_lut + self._lut = _get_daylight_lut() + self._speed = speed + self._use_real_time = use_real_time + self._latitude = latitude + self._min = min_value + self._max = max_value + self._start_time = time.perf_counter() + + def get_value(self) -> float: + if self._use_real_time: + now = datetime.now() + minute_of_day = now.hour * 60 + now.minute + now.second / 60.0 + else: + t_elapsed = time.perf_counter() - self._start_time + cycle_seconds = 240.0 / max(self._speed, 0.01) + phase = (t_elapsed % cycle_seconds) / cycle_seconds + minute_of_day = phase * 1440.0 + + idx = int(minute_of_day) % 1440 + r, g, b = self._lut[idx] + + # BT.601 luminance → 0..1 + luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0 + + return self._min + luminance * (self._max - self._min) + + def update_source(self, source: "ValueSource") -> None: + from wled_controller.storage.value_source import DaylightValueSource + if isinstance(source, DaylightValueSource): + self._speed = source.speed + self._use_real_time = source.use_real_time + self._latitude = source.latitude + self._min = source.min_value + self._max = source.max_value + + # --------------------------------------------------------------------------- # Manager # --------------------------------------------------------------------------- @@ -635,6 +694,7 @@ class ValueStreamManager: AdaptiveValueSource, AnimatedValueSource, AudioValueSource, + DaylightValueSource, StaticValueSource, ) @@ -663,6 +723,15 @@ class ValueStreamManager: audio_template_store=self._audio_template_store, ) + if isinstance(source, DaylightValueSource): + return DaylightValueStream( + speed=source.speed, + use_real_time=source.use_real_time, + latitude=source.latitude, + min_value=source.min_value, + max_value=source.max_value, + ) + if isinstance(source, AdaptiveValueSource): if source.source_type == "adaptive_scene": return SceneValueStream( diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index d17e430..a4cd73b 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -132,6 +132,7 @@ import { import { showValueSourceModal, closeValueSourceModal, saveValueSource, editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange, + onDaylightVSRealTimeChange, addSchedulePoint, testValueSource, closeTestValueSourceModal, } from './features/value-sources.js'; @@ -409,6 +410,7 @@ Object.assign(window, { cloneValueSource, deleteValueSource, onValueSourceTypeChange, + onDaylightVSRealTimeChange, addSchedulePoint, testValueSource, closeTestValueSourceModal, diff --git a/server/src/wled_controller/static/js/core/icons.js b/server/src/wled_controller/static/js/core/icons.js index 7376826..7350f52 100644 --- a/server/src/wled_controller/static/js/core/icons.js +++ b/server/src/wled_controller/static/js/core/icons.js @@ -30,6 +30,7 @@ const _colorStripTypeIcons = { const _valueSourceTypeIcons = { static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music), adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun), + daylight: _svg(P.sun), }; const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2) }; const _deviceTypeIcons = { 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 6b29202..0a6ea3b 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -18,7 +18,7 @@ import { Modal } from '../core/modal.js'; import { getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, - ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, + ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; @@ -64,6 +64,9 @@ class ValueSourceModal extends Modal { sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value, sceneSmoothing: document.getElementById('value-source-scene-smoothing').value, schedule: JSON.stringify(_getScheduleFromUI()), + daylightSpeed: document.getElementById('value-source-daylight-speed').value, + daylightRealTime: document.getElementById('value-source-daylight-real-time').checked, + daylightLatitude: document.getElementById('value-source-daylight-latitude').value, tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []), }; } @@ -97,7 +100,7 @@ function _autoGenerateVSName() { /* ── Icon-grid type selector ──────────────────────────────────── */ -const VS_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene']; +const VS_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight']; function _buildVSTypeItems() { return VS_TYPE_KEYS.map(key => ({ @@ -213,6 +216,13 @@ export async function showValueSourceModal(editData) { _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 if (editData.source_type === 'daylight') { + _setSlider('value-source-daylight-speed', editData.speed ?? 1.0); + document.getElementById('value-source-daylight-real-time').checked = !!editData.use_real_time; + _setSlider('value-source-daylight-latitude', editData.latitude ?? 50); + _syncDaylightVSSpeedVisibility(); + _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 = ''; @@ -240,6 +250,11 @@ export async function showValueSourceModal(editData) { _setSlider('value-source-scene-smoothing', 0.3); _setSlider('value-source-adaptive-min-value', 0); _setSlider('value-source-adaptive-max-value', 1); + // Daylight defaults + _setSlider('value-source-daylight-speed', 1.0); + document.getElementById('value-source-daylight-real-time').checked = false; + _setSlider('value-source-daylight-latitude', 50); + _syncDaylightVSSpeedVisibility(); _autoGenerateVSName(); } @@ -271,8 +286,9 @@ export function onValueSourceTypeChange() { if (type === 'audio') _ensureAudioModeIconSelect(); document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none'; document.getElementById('value-source-adaptive-scene-section').style.display = type === 'adaptive_scene' ? '' : 'none'; + document.getElementById('value-source-daylight-section').style.display = type === 'daylight' ? '' : 'none'; document.getElementById('value-source-adaptive-range-section').style.display = - (type === 'adaptive_time' || type === 'adaptive_scene') ? '' : 'none'; + (type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none'; // Populate audio dropdown when switching to audio type if (type === 'audio') { @@ -290,6 +306,17 @@ export function onValueSourceTypeChange() { _autoGenerateVSName(); } +// ── Daylight helpers ────────────────────────────────────────── + +export function onDaylightVSRealTimeChange() { + _syncDaylightVSSpeedVisibility(); +} + +function _syncDaylightVSSpeedVisibility() { + const rt = document.getElementById('value-source-daylight-real-time').checked; + document.getElementById('value-source-daylight-speed-group').style.display = rt ? 'none' : ''; +} + // ── Save ────────────────────────────────────────────────────── export async function saveValueSource() { @@ -338,6 +365,12 @@ export async function saveValueSource() { payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').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); + } else if (sourceType === 'daylight') { + payload.speed = parseFloat(document.getElementById('value-source-daylight-speed').value); + payload.use_real_time = document.getElementById('value-source-daylight-real-time').checked; + payload.latitude = parseFloat(document.getElementById('value-source-daylight-latitude').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); } try { @@ -632,6 +665,13 @@ export function createValueSourceCard(src) { ${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')} ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1} `; + } else if (src.source_type === 'daylight') { + if (src.use_real_time) { + propsHtml = `${ICON_CLOCK} ${t('value_source.daylight.real_time')}`; + } else { + propsHtml = `${ICON_TIMER} ${t('value_source.daylight.speed_label')} ${src.speed ?? 1.0}x`; + } + propsHtml += `${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}\u2013${src.max_value ?? 1}`; } else if (src.source_type === 'adaptive_scene') { const ps = _cachedStreams.find(s => s.id === src.picture_source_id); const psName = ps ? ps.name : (src.picture_source_id || '-'); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 8d52ec0..968f749 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1101,6 +1101,17 @@ "value_source.type.adaptive_time.desc": "Adjusts by time of day", "value_source.type.adaptive_scene": "Adaptive (Scene)", "value_source.type.adaptive_scene.desc": "Adjusts by scene content", + "value_source.type.daylight": "Daylight Cycle", + "value_source.type.daylight.desc": "Brightness follows day/night cycle", + "value_source.daylight.speed": "Speed:", + "value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.", + "value_source.daylight.use_real_time": "Use Real Time:", + "value_source.daylight.use_real_time.hint": "When enabled, brightness follows the actual time of day. Speed is ignored.", + "value_source.daylight.enable_real_time": "Follow wall clock", + "value_source.daylight.latitude": "Latitude:", + "value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.", + "value_source.daylight.real_time": "Real-time", + "value_source.daylight.speed_label": "Speed", "value_source.value": "Value:", "value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)", "value_source.waveform": "Waveform:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index ae6d650..f1339c7 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1101,6 +1101,17 @@ "value_source.type.adaptive_time.desc": "Подстройка по времени суток", "value_source.type.adaptive_scene": "Адаптивный (Сцена)", "value_source.type.adaptive_scene.desc": "Подстройка по содержимому сцены", + "value_source.type.daylight": "Дневной цикл", + "value_source.type.daylight.desc": "Яркость следует за циклом дня/ночи", + "value_source.daylight.speed": "Скорость:", + "value_source.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.", + "value_source.daylight.use_real_time": "Реальное время:", + "value_source.daylight.use_real_time.hint": "Яркость следует за реальным временем суток. Скорость игнорируется.", + "value_source.daylight.enable_real_time": "Следовать за часами", + "value_source.daylight.latitude": "Широта:", + "value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.", + "value_source.daylight.real_time": "Реальное время", + "value_source.daylight.speed_label": "Скорость", "value_source.value": "Значение:", "value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)", "value_source.waveform": "Форма волны:", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 5446246..1330ca2 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1101,6 +1101,17 @@ "value_source.type.adaptive_time.desc": "按时间自动调节", "value_source.type.adaptive_scene": "自适应(场景)", "value_source.type.adaptive_scene.desc": "按场景内容调节", + "value_source.type.daylight": "日光周期", + "value_source.type.daylight.desc": "亮度跟随日夜周期", + "value_source.daylight.speed": "速度:", + "value_source.daylight.speed.hint": "周期速度倍率。1.0 = 完整日夜周期约4分钟。", + "value_source.daylight.use_real_time": "使用实时:", + "value_source.daylight.use_real_time.hint": "启用后,亮度跟随实际时间。速度设置将被忽略。", + "value_source.daylight.enable_real_time": "跟随系统时钟", + "value_source.daylight.latitude": "纬度:", + "value_source.daylight.latitude.hint": "地理纬度(-90到90)。影响实时模式下的日出/日落时间。", + "value_source.daylight.real_time": "实时", + "value_source.daylight.speed_label": "速度", "value_source.value": "值:", "value_source.value.hint": "固定输出值(0.0 = 关闭,1.0 = 最大亮度)", "value_source.waveform": "波形:", diff --git a/server/src/wled_controller/storage/value_source.py b/server/src/wled_controller/storage/value_source.py index f97d2f6..32ac705 100644 --- a/server/src/wled_controller/storage/value_source.py +++ b/server/src/wled_controller/storage/value_source.py @@ -1,13 +1,14 @@ """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. Five types: +parameters like brightness. Six 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: adaptive_time — interpolates brightness along a 24-hour schedule adaptive_scene — derives brightness from a picture source's frame luminance + DaylightValueSource — brightness based on simulated or real-time daylight cycle """ from dataclasses import dataclass, field @@ -21,7 +22,7 @@ class ValueSource: id: str name: str - source_type: str # "static" | "animated" | "audio" | "adaptive_time" | "adaptive_scene" + source_type: str # "static" | "animated" | "audio" | "adaptive_time" | "adaptive_scene" | "daylight" created_at: datetime updated_at: datetime description: Optional[str] = None @@ -51,6 +52,8 @@ class ValueSource: "schedule": None, "picture_source_id": None, "scene_behavior": None, + "use_real_time": None, + "latitude": None, } @staticmethod @@ -121,6 +124,17 @@ class ValueSource: max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0, ) + if source_type == "daylight": + return DaylightValueSource( + id=sid, name=name, source_type="daylight", + created_at=created_at, updated_at=updated_at, description=description, tags=tags, + speed=float(data.get("speed") or 1.0), + use_real_time=bool(data.get("use_real_time", False)), + latitude=float(data.get("latitude") or 50.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, + ) + # Default: "static" type return StaticValueSource( id=sid, name=name, source_type="static", @@ -221,3 +235,27 @@ class AdaptiveValueSource(ValueSource): d["min_value"] = self.min_value d["max_value"] = self.max_value return d + + +@dataclass +class DaylightValueSource(ValueSource): + """Value source that outputs brightness based on a daylight cycle. + + Uses the same daylight LUT as the CSS daylight stream to derive a + scalar brightness from the simulated (or real-time) sky color luminance. + """ + + speed: float = 1.0 # simulation speed (ignored when use_real_time) + use_real_time: bool = False # use wall clock instead of simulation + latitude: float = 50.0 # affects sunrise/sunset in real-time mode + 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["speed"] = self.speed + d["use_real_time"] = self.use_real_time + d["latitude"] = self.latitude + 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 fb896f0..e2a290f 100644 --- a/server/src/wled_controller/storage/value_source_store.py +++ b/server/src/wled_controller/storage/value_source_store.py @@ -9,6 +9,7 @@ from wled_controller.storage.value_source import ( AdaptiveValueSource, AnimatedValueSource, AudioValueSource, + DaylightValueSource, StaticValueSource, ValueSource, ) @@ -51,9 +52,11 @@ class ValueSourceStore(BaseJsonStore[ValueSource]): picture_source_id: Optional[str] = None, scene_behavior: Optional[str] = None, auto_gain: Optional[bool] = None, + use_real_time: Optional[bool] = None, + latitude: Optional[float] = None, tags: Optional[List[str]] = None, ) -> ValueSource: - if source_type not in ("static", "animated", "audio", "adaptive_time", "adaptive_scene"): + if source_type not in ("static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"): raise ValueError(f"Invalid source type: {source_type}") self._check_name_unique(name) @@ -112,6 +115,16 @@ class ValueSourceStore(BaseJsonStore[ValueSource]): 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 == "daylight": + source = DaylightValueSource( + id=sid, name=name, source_type="daylight", + created_at=now, updated_at=now, description=description, tags=common_tags, + speed=speed if speed is not None else 1.0, + use_real_time=bool(use_real_time) if use_real_time is not None else False, + latitude=latitude if latitude is not None else 50.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, + ) self._items[sid] = source self._save() @@ -137,6 +150,8 @@ class ValueSourceStore(BaseJsonStore[ValueSource]): picture_source_id: Optional[str] = None, scene_behavior: Optional[str] = None, auto_gain: Optional[bool] = None, + use_real_time: Optional[bool] = None, + latitude: Optional[float] = None, tags: Optional[List[str]] = None, ) -> ValueSource: source = self.get(source_id) @@ -194,6 +209,17 @@ class ValueSourceStore(BaseJsonStore[ValueSource]): source.min_value = min_value if max_value is not None: source.max_value = max_value + elif isinstance(source, DaylightValueSource): + if speed is not None: + source.speed = speed + if use_real_time is not None: + source.use_real_time = use_real_time + if latitude is not None: + source.latitude = latitude + 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.now(timezone.utc) 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 63c5b86..60dc859 100644 --- a/server/src/wled_controller/templates/modals/value-source-editor.html +++ b/server/src/wled_controller/templates/modals/value-source-editor.html @@ -34,6 +34,7 @@ + @@ -235,7 +236,42 @@ - + +
+ +