Add Daylight Cycle value source type

New value source that outputs brightness (0-1) based on the daylight
color LUT, computing BT.601 luminance from the simulated sky color.
Supports real-time wall-clock mode or configurable simulation speed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 11:27:36 +03:00
parent 73562cd525
commit ee40d99067
13 changed files with 271 additions and 12 deletions

View File

@@ -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` **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` **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 0255 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 0255 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` **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 - [ ] `P1` **IconSelect grid overflow & scroll jump** — Expandable icon grid sometimes renders outside the visible viewport; opening it causes the modal/page to jump scroll

View File

@@ -51,6 +51,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
schedule=d.get("schedule"), schedule=d.get("schedule"),
picture_source_id=d.get("picture_source_id"), picture_source_id=d.get("picture_source_id"),
scene_behavior=d.get("scene_behavior"), scene_behavior=d.get("scene_behavior"),
use_real_time=d.get("use_real_time"),
latitude=d.get("latitude"),
description=d.get("description"), description=d.get("description"),
tags=d.get("tags", []), tags=d.get("tags", []),
created_at=source.created_at, created_at=source.created_at,
@@ -99,6 +101,8 @@ async def create_value_source(
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior, scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain, auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("value_source", "created", source.id) fire_entity_event("value_source", "created", source.id)
@@ -148,6 +152,8 @@ async def update_value_source(
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior, scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain, auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags, tags=data.tags,
) )
# Hot-reload running value streams # Hot-reload running value streams

View File

@@ -10,12 +10,12 @@ class ValueSourceCreate(BaseModel):
"""Request to create a value source.""" """Request to create a value source."""
name: str = Field(description="Source name", min_length=1, max_length=100) 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 # static fields
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0) value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields # animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth") 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) 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) max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields # 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}]") 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") 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") 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -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) value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
# animated fields # animated fields
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth") 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) 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) max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
# audio fields # audio fields
@@ -53,6 +56,9 @@ class ValueSourceUpdate(BaseModel):
schedule: Optional[list] = Field(None, description="Time-of-day schedule") schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode") 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") 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
@@ -76,6 +82,8 @@ class ValueSourceResponse(BaseModel):
schedule: Optional[list] = Field(None, description="Time-of-day schedule") schedule: Optional[list] = Field(None, description="Time-of-day schedule")
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior") 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") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")

View File

@@ -1,7 +1,7 @@
"""Value stream — runtime scalar signal generators. """Value stream — runtime scalar signal generators.
A ValueStream wraps a ValueSource config and computes a float (0.01.0) A ValueStream wraps a ValueSource config and computes a float (0.01.0)
on demand via ``get_value()``. Five concrete types: on demand via ``get_value()``. Six concrete types:
StaticValueStream — returns a constant StaticValueStream — returns a constant
AnimatedValueStream — evaluates a periodic waveform (sine/triangle/square/sawtooth) 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 sensitivity and temporal smoothing
TimeOfDayValueStream — interpolates brightness along a 24h schedule (adaptive_time) TimeOfDayValueStream — interpolates brightness along a 24h schedule (adaptive_time)
SceneValueStream — derives brightness from a picture source's frame luminance (adaptive_scene) 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 ValueStreams are cheap (trivial math or single poll), so they compute inline
in the caller's processing loop — no background threads required. in the caller's processing loop — no background threads required.
@@ -541,6 +542,64 @@ class SceneValueStream(ValueStream):
self._live_stream = None 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 # Manager
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -635,6 +694,7 @@ class ValueStreamManager:
AdaptiveValueSource, AdaptiveValueSource,
AnimatedValueSource, AnimatedValueSource,
AudioValueSource, AudioValueSource,
DaylightValueSource,
StaticValueSource, StaticValueSource,
) )
@@ -663,6 +723,15 @@ class ValueStreamManager:
audio_template_store=self._audio_template_store, 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 isinstance(source, AdaptiveValueSource):
if source.source_type == "adaptive_scene": if source.source_type == "adaptive_scene":
return SceneValueStream( return SceneValueStream(

View File

@@ -132,6 +132,7 @@ import {
import { import {
showValueSourceModal, closeValueSourceModal, saveValueSource, showValueSourceModal, closeValueSourceModal, saveValueSource,
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange, editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
onDaylightVSRealTimeChange,
addSchedulePoint, addSchedulePoint,
testValueSource, closeTestValueSourceModal, testValueSource, closeTestValueSourceModal,
} from './features/value-sources.js'; } from './features/value-sources.js';
@@ -409,6 +410,7 @@ Object.assign(window, {
cloneValueSource, cloneValueSource,
deleteValueSource, deleteValueSource,
onValueSourceTypeChange, onValueSourceTypeChange,
onDaylightVSRealTimeChange,
addSchedulePoint, addSchedulePoint,
testValueSource, testValueSource,
closeTestValueSourceModal, closeTestValueSourceModal,

View File

@@ -30,6 +30,7 @@ const _colorStripTypeIcons = {
const _valueSourceTypeIcons = { const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music), static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun), 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 _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2) };
const _deviceTypeIcons = { const _deviceTypeIcons = {

View File

@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.js';
import { import {
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST, 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, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
} from '../core/icons.js'; } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
@@ -64,6 +64,9 @@ class ValueSourceModal extends Modal {
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value, sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value, sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
schedule: JSON.stringify(_getScheduleFromUI()), 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() : []), tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
}; };
} }
@@ -97,7 +100,7 @@ function _autoGenerateVSName() {
/* ── Icon-grid type selector ──────────────────────────────────── */ /* ── 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() { function _buildVSTypeItems() {
return VS_TYPE_KEYS.map(key => ({ 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-scene-smoothing', editData.smoothing ?? 0.3);
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0); _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1); _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 { } else {
document.getElementById('value-source-name').value = ''; 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-scene-smoothing', 0.3);
_setSlider('value-source-adaptive-min-value', 0); _setSlider('value-source-adaptive-min-value', 0);
_setSlider('value-source-adaptive-max-value', 1); _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(); _autoGenerateVSName();
} }
@@ -271,8 +286,9 @@ export function onValueSourceTypeChange() {
if (type === 'audio') _ensureAudioModeIconSelect(); if (type === 'audio') _ensureAudioModeIconSelect();
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none'; 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-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 = 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 // Populate audio dropdown when switching to audio type
if (type === 'audio') { if (type === 'audio') {
@@ -290,6 +306,17 @@ export function onValueSourceTypeChange() {
_autoGenerateVSName(); _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 ────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────
export async function saveValueSource() { export async function saveValueSource() {
@@ -338,6 +365,12 @@ export async function saveValueSource() {
payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value); payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value);
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').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); 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 { try {
@@ -632,6 +665,13 @@ export function createValueSourceCard(src) {
<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span> <span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span> <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`; `;
} else if (src.source_type === 'daylight') {
if (src.use_real_time) {
propsHtml = `<span class="stream-card-prop">${ICON_CLOCK} ${t('value_source.daylight.real_time')}</span>`;
} else {
propsHtml = `<span class="stream-card-prop">${ICON_TIMER} ${t('value_source.daylight.speed_label')} ${src.speed ?? 1.0}x</span>`;
}
propsHtml += `<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}\u2013${src.max_value ?? 1}</span>`;
} else if (src.source_type === 'adaptive_scene') { } else if (src.source_type === 'adaptive_scene') {
const ps = _cachedStreams.find(s => s.id === src.picture_source_id); const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
const psName = ps ? ps.name : (src.picture_source_id || '-'); const psName = ps ? ps.name : (src.picture_source_id || '-');

View File

@@ -1101,6 +1101,17 @@
"value_source.type.adaptive_time.desc": "Adjusts by time of day", "value_source.type.adaptive_time.desc": "Adjusts by time of day",
"value_source.type.adaptive_scene": "Adaptive (Scene)", "value_source.type.adaptive_scene": "Adaptive (Scene)",
"value_source.type.adaptive_scene.desc": "Adjusts by scene content", "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": "Value:",
"value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)", "value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)",
"value_source.waveform": "Waveform:", "value_source.waveform": "Waveform:",

View File

@@ -1101,6 +1101,17 @@
"value_source.type.adaptive_time.desc": "Подстройка по времени суток", "value_source.type.adaptive_time.desc": "Подстройка по времени суток",
"value_source.type.adaptive_scene": "Адаптивный (Сцена)", "value_source.type.adaptive_scene": "Адаптивный (Сцена)",
"value_source.type.adaptive_scene.desc": "Подстройка по содержимому сцены", "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": "Значение:",
"value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)", "value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)",
"value_source.waveform": "Форма волны:", "value_source.waveform": "Форма волны:",

View File

@@ -1101,6 +1101,17 @@
"value_source.type.adaptive_time.desc": "按时间自动调节", "value_source.type.adaptive_time.desc": "按时间自动调节",
"value_source.type.adaptive_scene": "自适应(场景)", "value_source.type.adaptive_scene": "自适应(场景)",
"value_source.type.adaptive_scene.desc": "按场景内容调节", "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": "值:",
"value_source.value.hint": "固定输出值0.0 = 关闭1.0 = 最大亮度)", "value_source.value.hint": "固定输出值0.0 = 关闭1.0 = 最大亮度)",
"value_source.waveform": "波形:", "value_source.waveform": "波形:",

View File

@@ -1,13 +1,14 @@
"""Value source data model with inheritance-based source types. """Value source data model with inheritance-based source types.
A ValueSource produces a scalar float (0.01.0) that can drive target A ValueSource produces a scalar float (0.01.0) that can drive target
parameters like brightness. Five types: parameters like brightness. Six types:
StaticValueSource — constant float value StaticValueSource — constant float value
AnimatedValueSource — periodic waveform (sine, triangle, square, sawtooth) AnimatedValueSource — periodic waveform (sine, triangle, square, sawtooth)
AudioValueSource — audio-reactive scalar (RMS, peak, beat detection) AudioValueSource — audio-reactive scalar (RMS, peak, beat detection)
AdaptiveValueSource — adapts to external conditions: AdaptiveValueSource — adapts to external conditions:
adaptive_time — interpolates brightness along a 24-hour schedule adaptive_time — interpolates brightness along a 24-hour schedule
adaptive_scene — derives brightness from a picture source's frame luminance 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 from dataclasses import dataclass, field
@@ -21,7 +22,7 @@ class ValueSource:
id: str id: str
name: 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 created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
@@ -51,6 +52,8 @@ class ValueSource:
"schedule": None, "schedule": None,
"picture_source_id": None, "picture_source_id": None,
"scene_behavior": None, "scene_behavior": None,
"use_real_time": None,
"latitude": None,
} }
@staticmethod @staticmethod
@@ -121,6 +124,17 @@ class ValueSource:
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0, 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 # Default: "static" type
return StaticValueSource( return StaticValueSource(
id=sid, name=name, source_type="static", id=sid, name=name, source_type="static",
@@ -221,3 +235,27 @@ class AdaptiveValueSource(ValueSource):
d["min_value"] = self.min_value d["min_value"] = self.min_value
d["max_value"] = self.max_value d["max_value"] = self.max_value
return d 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

View File

@@ -9,6 +9,7 @@ from wled_controller.storage.value_source import (
AdaptiveValueSource, AdaptiveValueSource,
AnimatedValueSource, AnimatedValueSource,
AudioValueSource, AudioValueSource,
DaylightValueSource,
StaticValueSource, StaticValueSource,
ValueSource, ValueSource,
) )
@@ -51,9 +52,11 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
picture_source_id: Optional[str] = None, picture_source_id: Optional[str] = None,
scene_behavior: Optional[str] = None, scene_behavior: Optional[str] = None,
auto_gain: Optional[bool] = None, auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
) -> ValueSource: ) -> 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}") raise ValueError(f"Invalid source type: {source_type}")
self._check_name_unique(name) 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, 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, 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._items[sid] = source
self._save() self._save()
@@ -137,6 +150,8 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
picture_source_id: Optional[str] = None, picture_source_id: Optional[str] = None,
scene_behavior: Optional[str] = None, scene_behavior: Optional[str] = None,
auto_gain: Optional[bool] = None, auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
) -> ValueSource: ) -> ValueSource:
source = self.get(source_id) source = self.get(source_id)
@@ -194,6 +209,17 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
source.min_value = min_value source.min_value = min_value
if max_value is not None: if max_value is not None:
source.max_value = max_value 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) source.updated_at = datetime.now(timezone.utc)
self._save() self._save()

View File

@@ -34,6 +34,7 @@
<option value="audio" data-i18n="value_source.type.audio">Audio</option> <option value="audio" data-i18n="value_source.type.audio">Audio</option>
<option value="adaptive_time" data-i18n="value_source.type.adaptive_time">Adaptive (Time of Day)</option> <option value="adaptive_time" data-i18n="value_source.type.adaptive_time">Adaptive (Time of Day)</option>
<option value="adaptive_scene" data-i18n="value_source.type.adaptive_scene">Adaptive (Scene)</option> <option value="adaptive_scene" data-i18n="value_source.type.adaptive_scene">Adaptive (Scene)</option>
<option value="daylight" data-i18n="value_source.type.daylight">Daylight Cycle</option>
</select> </select>
</div> </div>
@@ -235,7 +236,42 @@
</div> </div>
</div> </div>
<!-- Shared adaptive output range (shown for both adaptive types) --> <!-- Daylight fields -->
<div id="value-source-daylight-section" style="display:none">
<div id="value-source-daylight-speed-group" class="form-group">
<div class="label-row">
<label for="value-source-daylight-speed"><span data-i18n="value_source.daylight.speed">Speed:</span> <span id="value-source-daylight-speed-display">1.0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes.</small>
<input type="range" id="value-source-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
oninput="document.getElementById('value-source-daylight-speed-display').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-daylight-real-time" data-i18n="value_source.daylight.use_real_time">Use Real Time:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.use_real_time.hint">When enabled, brightness follows the actual time of day. Speed is ignored.</small>
<label class="toggle-label">
<input type="checkbox" id="value-source-daylight-real-time" onchange="onDaylightVSRealTimeChange()">
<span data-i18n="value_source.daylight.enable_real_time">Follow wall clock</span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-daylight-latitude"><span data-i18n="value_source.daylight.latitude">Latitude:</span> <span id="value-source-daylight-latitude-display">50</span>&deg;</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
<input type="range" id="value-source-daylight-latitude" min="-90" max="90" step="1" value="50"
oninput="document.getElementById('value-source-daylight-latitude-display').textContent = parseInt(this.value)">
</div>
</div>
<!-- Shared adaptive output range (shown for adaptive and daylight types) -->
<div id="value-source-adaptive-range-section" style="display:none"> <div id="value-source-adaptive-range-section" style="display:none">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">