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:
2
TODO.md
2
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
<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>
|
||||
`;
|
||||
} 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') {
|
||||
const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
|
||||
const psName = ps ? ps.name : (src.picture_source_id || '-');
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Форма волны:",
|
||||
|
||||
@@ -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": "波形:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<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_scene" data-i18n="value_source.type.adaptive_scene">Adaptive (Scene)</option>
|
||||
<option value="daylight" data-i18n="value_source.type.daylight">Daylight Cycle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -235,7 +236,42 @@
|
||||
</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>°</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 class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
Reference in New Issue
Block a user