Add Daylight Cycle and Candlelight CSS source types

Full-stack implementation of two new color strip source types:
- Daylight: simulates day/night color cycle with real-time or speed-based mode, latitude support
- Candlelight: multi-candle fire simulation with Gaussian falloff, layered-sine flicker, warm color shift

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 11:07:30 +03:00
parent 954e37c2ca
commit 37c80f01af
15 changed files with 882 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_picture_source_store,
get_output_target_store,
@@ -99,6 +100,10 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
app_filter_mode=getattr(source, "app_filter_mode", None),
app_filter_list=getattr(source, "app_filter_list", None),
os_listener=getattr(source, "os_listener", None),
speed=getattr(source, "speed", None),
use_real_time=getattr(source, "use_real_time", None),
latitude=getattr(source, "latitude", None),
num_candles=getattr(source, "num_candles", None),
overlay_active=overlay_active,
tags=getattr(source, 'tags', []),
created_at=source.created_at,
@@ -191,8 +196,13 @@ async def create_color_strip_source(
app_filter_mode=data.app_filter_mode,
app_filter_list=data.app_filter_list,
os_listener=data.os_listener,
speed=data.speed,
use_real_time=data.use_real_time,
latitude=data.latitude,
num_candles=data.num_candles,
tags=data.tags,
)
fire_entity_event("color_strip_source", "created", source.id)
return _css_to_response(source)
except ValueError as e:
@@ -275,6 +285,10 @@ async def update_color_strip_source(
app_filter_mode=data.app_filter_mode,
app_filter_list=data.app_filter_list,
os_listener=data.os_listener,
speed=data.speed,
use_real_time=data.use_real_time,
latitude=data.latitude,
num_candles=data.num_candles,
tags=data.tags,
)
@@ -284,6 +298,7 @@ async def update_color_strip_source(
except Exception as e:
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
fire_entity_event("color_strip_source", "updated", source_id)
return _css_to_response(source)
except ValueError as e:
@@ -327,6 +342,7 @@ async def delete_color_strip_source(
"Remove it from the mapped source(s) first.",
)
store.delete_source(source_id)
fire_entity_event("color_strip_source", "deleted", source_id)
except HTTPException:
raise
except ValueError as e:

View File

@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
@@ -95,6 +95,12 @@ class ColorStripSourceCreate(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -149,6 +155,12 @@ class ColorStripSourceUpdate(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: Optional[List[str]] = None
@@ -205,6 +217,12 @@ class ColorStripSourceResponse(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
tags: List[str] = Field(default_factory=list, description="User-defined tags")

View File

@@ -0,0 +1,247 @@
"""Candlelight LED stream — realistic per-LED candle flickering.
Implements CandlelightColorStripStream which produces warm, organic
flickering across all LEDs using layered sine waves and value noise.
Each "candle" is an independent flicker source that illuminates
nearby LEDs with smooth falloff.
"""
import math
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
# ── Simple hash-based noise ──────────────────────────────────────────
_PERM = np.arange(256, dtype=np.int32)
_rng = np.random.RandomState(seed=17)
_rng.shuffle(_PERM)
_PERM = np.concatenate([_PERM, _PERM]) # 512 entries for wrap-free indexing
def _noise1d(x: np.ndarray) -> np.ndarray:
"""Fast 1-D value noise (vectorized). Returns float32 in [0, 1]."""
xi = x.astype(np.int32) & 255
xf = x - np.floor(x)
# Smoothstep
u = xf * xf * (3.0 - 2.0 * xf)
a = _PERM[xi].astype(np.float32) / 255.0
b = _PERM[xi + 1].astype(np.float32) / 255.0
return a + u * (b - a)
class CandlelightColorStripStream(ColorStripStream):
"""Color strip stream simulating realistic candle flickering.
Each LED flickers independently with warm tones. Multiple
"candle sources" are distributed along the strip, each generating
its own flicker pattern with smooth spatial falloff.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None
self._led_count = 1
self._auto_size = True
# Scratch arrays
self._s_bright: Optional[np.ndarray] = None
self._s_noise: Optional[np.ndarray] = None
self._s_x: Optional[np.ndarray] = None
self._pool_n = 0
self._update_from_source(source)
def _update_from_source(self, source) -> None:
raw_color = getattr(source, "color", None)
self._color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
self._intensity = float(getattr(source, "intensity", 1.0))
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
self._speed = float(getattr(source, "speed", 1.0))
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-candlelight",
daemon=True,
)
self._thread.start()
logger.info(f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("CandlelightColorStripStream thread did not terminate within 5s")
self._thread = None
logger.info("CandlelightColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import CandlelightColorStripSource
if isinstance(source, CandlelightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
logger.info("CandlelightColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
self._clock = clock
# ── Animation loop ──────────────────────────────────────────────
def _animate_loop(self) -> None:
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
t = clock.get_time()
speed = clock.speed * self._speed
else:
t = wall_start
speed = self._speed
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
self._s_bright = np.empty(n, dtype=np.float32)
self._s_noise = np.empty(n, dtype=np.float32)
self._s_x = np.arange(n, dtype=np.float32)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
self._render_candlelight(buf, n, t, speed)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"CandlelightColorStripStream animation error: {e}")
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(f"Fatal CandlelightColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float) -> None:
"""Render candle flickering into buf (n, 3) uint8.
Algorithm:
- Place num_candles evenly along the strip
- Each candle has independent layered-sine flicker
- Spatial falloff: LEDs near a candle are brighter
- Per-LED noise adds individual variation
- Final brightness modulates the base warm color
"""
intensity = self._intensity
num_candles = self._num_candles
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
bright = self._s_bright
bright[:] = 0.0
# Candle positions: evenly distributed
if num_candles == 1:
positions = [n / 2.0]
else:
positions = [i * (n - 1) / (num_candles - 1) for i in range(num_candles)]
x = self._s_x[:n]
for ci, pos in enumerate(positions):
# Independent flicker for this candle: layered sines at different frequencies
# Use candle index as phase offset for independence
offset = ci * 137.5 # golden-angle offset for non-repeating
flicker = (
0.40 * math.sin(2 * math.pi * speed * t * 3.7 + offset)
+ 0.25 * math.sin(2 * math.pi * speed * t * 7.3 + offset * 0.7)
+ 0.15 * math.sin(2 * math.pi * speed * t * 13.1 + offset * 1.3)
+ 0.10 * math.sin(2 * math.pi * speed * t * 1.9 + offset * 0.3)
)
# Normalize flicker to [0.3, 1.0] range (candles never fully go dark)
candle_brightness = 0.65 + 0.35 * flicker * intensity
# Spatial falloff: Gaussian centered on candle position
# sigma proportional to strip length / num_candles
sigma = max(n / (num_candles * 2.0), 2.0)
dist = x - pos
falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma))
bright += candle_brightness * falloff
# Per-LED noise for individual variation
noise_x = x * 0.3 + t * speed * 5.0
noise = _noise1d(noise_x)
# Modulate brightness with noise (±15%)
bright *= (0.85 + 0.30 * noise)
# Clamp to [0, 1]
np.clip(bright, 0.0, 1.0, out=bright)
# Apply base color with brightness modulation
# Candles emit warmer (more red, less blue) at lower brightness
# Add slight color variation: dimmer = warmer
warm_shift = (1.0 - bright) * 0.3
r = bright * base_r
g = bright * base_g * (1.0 - warm_shift * 0.5)
b = bright * base_b * (1.0 - warm_shift)
buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8)
buf[:, 1] = np.clip(g, 0, 255).astype(np.uint8)
buf[:, 2] = np.clip(b, 0, 255).astype(np.uint8)

View File

@@ -21,6 +21,8 @@ from wled_controller.core.processing.color_strip_stream import (
from wled_controller.core.processing.effect_stream import EffectColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
from wled_controller.core.processing.daylight_stream import DaylightColorStripStream
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -33,6 +35,8 @@ _SIMPLE_STREAM_MAP = {
"effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream,
"notification": NotificationColorStripStream,
"daylight": DaylightColorStripStream,
"candlelight": CandlelightColorStripStream,
}

View File

@@ -0,0 +1,221 @@
"""Daylight cycle LED stream — simulates natural daylight color temperature.
Implements DaylightColorStripStream which produces a uniform LED color array
that transitions through dawn, daylight, sunset, and night over a continuous
24-hour cycle. Can use real wall-clock time or a configurable simulation speed.
"""
import math
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
# ── Daylight color table ────────────────────────────────────────────────
#
# Maps hour-of-day (024) to RGB color. Interpolated linearly between
# control points. Colors approximate natural daylight color temperature
# from warm sunrise tones through cool midday to warm sunset and dim night.
#
# Format: (hour, R, G, B)
_DAYLIGHT_CURVE = [
(0.0, 10, 10, 30), # midnight — deep blue
(4.0, 10, 10, 40), # pre-dawn — dark blue
(5.5, 40, 20, 60), # first light — purple hint
(6.0, 255, 100, 30), # sunrise — warm orange
(7.0, 255, 170, 80), # early morning — golden
(8.0, 255, 220, 160), # morning — warm white
(10.0, 255, 245, 230), # mid-morning — neutral warm
(12.0, 240, 248, 255), # noon — cool white / slight blue
(14.0, 255, 250, 240), # afternoon — neutral
(16.0, 255, 230, 180), # late afternoon — warm
(17.5, 255, 180, 100), # pre-sunset — golden
(18.5, 255, 100, 40), # sunset — deep orange
(19.0, 200, 60, 40), # late sunset — red
(19.5, 100, 30, 60), # dusk — purple
(20.0, 40, 20, 60), # twilight — dark purple
(21.0, 15, 15, 45), # night — dark blue
(24.0, 10, 10, 30), # midnight (wrap)
]
# Pre-build a (1440, 3) uint8 LUT — one entry per minute of the day
_daylight_lut: Optional[np.ndarray] = None
def _get_daylight_lut() -> np.ndarray:
global _daylight_lut
if _daylight_lut is not None:
return _daylight_lut
lut = np.zeros((1440, 3), dtype=np.uint8)
for minute in range(1440):
hour = minute / 60.0
# Find surrounding control points
prev = _DAYLIGHT_CURVE[0]
nxt = _DAYLIGHT_CURVE[-1]
for i in range(len(_DAYLIGHT_CURVE) - 1):
if _DAYLIGHT_CURVE[i][0] <= hour <= _DAYLIGHT_CURVE[i + 1][0]:
prev = _DAYLIGHT_CURVE[i]
nxt = _DAYLIGHT_CURVE[i + 1]
break
span = nxt[0] - prev[0]
t = (hour - prev[0]) / span if span > 0 else 0.0
# Smooth interpolation (smoothstep)
t = t * t * (3 - 2 * t)
for ch in range(3):
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
_daylight_lut = lut
return lut
class DaylightColorStripStream(ColorStripStream):
"""Color strip stream simulating a 24-hour daylight cycle.
All LEDs display the same color at any moment. The color smoothly
transitions through a pre-defined daylight curve.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 10 # low FPS — transitions are slow
self._frame_time = 1.0 / 10
self._clock = None
self._led_count = 1
self._auto_size = True
self._lut = _get_daylight_lut()
self._update_from_source(source)
def _update_from_source(self, source) -> None:
self._speed = float(getattr(source, "speed", 1.0))
self._use_real_time = bool(getattr(source, "use_real_time", False))
self._latitude = float(getattr(source, "latitude", 50.0))
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(30, fps))
self._frame_time = 1.0 / self._fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-daylight",
daemon=True,
)
self._thread.start()
logger.info(f"DaylightColorStripStream started (leds={self._led_count})")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("DaylightColorStripStream thread did not terminate within 5s")
self._thread = None
logger.info("DaylightColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import DaylightColorStripSource
if isinstance(source, DaylightColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
logger.info("DaylightColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
self._clock = clock
# ── Animation loop ──────────────────────────────────────────────
def _animate_loop(self) -> None:
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
t = clock.get_time()
speed = clock.speed
else:
t = wall_start
speed = self._speed
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
if self._use_real_time:
# Use actual wall-clock time
import datetime
now = datetime.datetime.now()
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
else:
# Simulated cycle: speed=1.0 → full 24h in ~240s (4 min)
cycle_seconds = 240.0 / max(speed, 0.01)
phase = (t % cycle_seconds) / cycle_seconds # 0..1
minute_of_day = phase * 1440.0
idx = int(minute_of_day) % 1440
color = self._lut[idx]
buf[:] = color
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"DaylightColorStripStream animation error: {e}")
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(f"Fatal DaylightColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False

View File

@@ -41,6 +41,7 @@ import {
toggleDashboardSection, changeDashboardPollInterval,
} from './features/dashboard.js';
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
import { startEntityEventListeners } from './core/entity-events.js';
import {
startPerfPolling, stopPerfPolling,
} from './features/perf-charts.js';
@@ -108,7 +109,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
@@ -376,6 +377,7 @@ Object.assign(window, {
onEffectTypeChange,
onCSSClockChange,
onAnimationTypeChange,
onDaylightRealTimeChange,
colorCycleAddColor,
colorCycleRemoveColor,
compositeAddLayer,
@@ -555,6 +557,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Start global events WebSocket and auto-refresh
startEventsWS();
startEntityEventListeners();
startAutoRefresh();
// Show getting-started tutorial on first visit

View File

@@ -72,4 +72,5 @@ export const download = '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2
export const undo2 = '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>';
export const power = '<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" x2="12" y1="2" y2="12"/>';
export const wifi = '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>';
export const flame = '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>';
export const usb = '<circle cx="10" cy="7" r="1"/><circle cx="4" cy="20" r="1"/><path d="M4.7 19.3 19 5"/><path d="m21 3-3 1 2 2Z"/><path d="M10 8v3a1 1 0 0 1-1 1H4"/><path d="M14 12v2a1 1 0 0 0 1 1h3"/><circle cx="20" cy="15" r="1"/>';

View File

@@ -24,6 +24,8 @@ const _colorStripTypeIcons = {
audio: _svg(P.music), audio_visualization: _svg(P.music),
api_input: _svg(P.send),
notification: _svg(P.bellRing),
daylight: _svg(P.sun),
candlelight: _svg(P.flame),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),

View File

@@ -80,6 +80,13 @@ class CSSEditorModal extends Modal {
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
notification_app_colors: JSON.stringify(_notificationAppColors),
clock_id: document.getElementById('css-editor-clock').value,
daylight_speed: document.getElementById('css-editor-daylight-speed').value,
daylight_use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
daylight_latitude: document.getElementById('css-editor-daylight-latitude').value,
candlelight_color: document.getElementById('css-editor-candlelight-color').value,
candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value,
candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value,
candlelight_speed: document.getElementById('css-editor-candlelight-speed').value,
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -99,7 +106,7 @@ let _cssClockEntitySelect = null;
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification',
'api_input', 'notification', 'daylight', 'candlelight',
];
function _buildCSSTypeItems() {
@@ -148,6 +155,8 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none';
document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none';
if (isPictureType) _ensureInterpolationIconSelect();
if (type === 'effect') {
@@ -197,8 +206,8 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-led-count-group').style.display =
hasLedCount.includes(type) ? '' : 'none';
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
// Sync clock — shown for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -274,6 +283,17 @@ function _syncAnimationSpeedState() {
}
}
/* ── Daylight real-time toggle helper ─────────────────────────── */
export function onDaylightRealTimeChange() {
_syncDaylightSpeedVisibility();
}
function _syncDaylightSpeedVisibility() {
const isRealTime = document.getElementById('css-editor-daylight-real-time').checked;
document.getElementById('css-editor-daylight-speed-group').style.display = isRealTime ? 'none' : '';
}
/* ── Gradient strip preview helper ────────────────────────────── */
/**
@@ -1039,6 +1059,23 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
</span>
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
`;
} else if (source.source_type === 'daylight') {
const useRealTime = source.use_real_time;
const speedVal = (source.speed ?? 1.0).toFixed(1);
propsHtml = `
<span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'}</span>
${clockBadge}
`;
} else if (source.source_type === 'candlelight') {
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
const numCandles = source.num_candles ?? 3;
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
</span>
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
${clockBadge}
`;
} else if (isPictureAdvanced) {
const cal = source.calibration || {};
const lines = cal.lines || [];
@@ -1073,7 +1110,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
}
const icon = getColorStripIcon(source.source_type);
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification);
const isDaylight = source.source_type === 'daylight';
const isCandlelight = source.source_type === 'candlelight';
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight);
const calibrationBtn = isPictureKind
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: '';
@@ -1217,6 +1256,20 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
_showApiInputEndpoints(css.id);
} else if (sourceType === 'notification') {
_loadNotificationState(css);
} else if (sourceType === 'daylight') {
document.getElementById('css-editor-daylight-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
document.getElementById('css-editor-daylight-real-time').checked = css.use_real_time || false;
document.getElementById('css-editor-daylight-latitude').value = css.latitude ?? 50.0;
document.getElementById('css-editor-daylight-latitude-val').textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
_syncDaylightSpeedVisibility();
} else if (sourceType === 'candlelight') {
document.getElementById('css-editor-candlelight-color').value = rgbArrayToHex(css.color || [255, 147, 41]);
document.getElementById('css-editor-candlelight-intensity').value = css.intensity ?? 1.0;
document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
} else {
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
@@ -1313,6 +1366,19 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
_showApiInputEndpoints(null);
_resetNotificationState();
// Daylight defaults
document.getElementById('css-editor-daylight-speed').value = 1.0;
document.getElementById('css-editor-daylight-speed-val').textContent = '1.0';
document.getElementById('css-editor-daylight-real-time').checked = false;
document.getElementById('css-editor-daylight-latitude').value = 50.0;
document.getElementById('css-editor-daylight-latitude-val').textContent = '50';
// Candlelight defaults
document.getElementById('css-editor-candlelight-color').value = '#ff9329';
document.getElementById('css-editor-candlelight-intensity').value = 1.0;
document.getElementById('css-editor-candlelight-intensity-val').textContent = '1.0';
document.getElementById('css-editor-candlelight-num-candles').value = 3;
document.getElementById('css-editor-candlelight-speed').value = 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
@@ -1473,6 +1539,23 @@ export async function saveCSSEditor() {
app_colors: _notificationGetAppColorsDict(),
};
if (!cssId) payload.source_type = 'notification';
} else if (sourceType === 'daylight') {
payload = {
name,
speed: parseFloat(document.getElementById('css-editor-daylight-speed').value),
use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value),
};
if (!cssId) payload.source_type = 'daylight';
} else if (sourceType === 'candlelight') {
payload = {
name,
color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value),
intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value),
num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3,
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
};
if (!cssId) payload.source_type = 'candlelight';
} else if (sourceType === 'picture_advanced') {
payload = {
name,
@@ -1501,7 +1584,7 @@ export async function saveCSSEditor() {
}
// Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
if (clockTypes.includes(sourceType)) {
const clockVal = document.getElementById('css-editor-clock').value;
payload.clock_id = clockVal || null;

View File

@@ -913,6 +913,28 @@
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.",
"color_strip.notification.app_count": "apps",
"color_strip.type.daylight": "Daylight Cycle",
"color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours",
"color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.",
"color_strip.daylight.speed": "Speed:",
"color_strip.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
"color_strip.daylight.use_real_time": "Use Real Time:",
"color_strip.daylight.use_real_time.hint": "When enabled, LED color matches the actual time of day on this computer. Speed setting is ignored.",
"color_strip.daylight.real_time": "Real Time",
"color_strip.daylight.latitude": "Latitude:",
"color_strip.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.",
"color_strip.type.candlelight": "Candlelight",
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
"color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.",
"color_strip.candlelight.color": "Base Color:",
"color_strip.candlelight.color.hint": "The warm base color of the candle flame. Default is a natural warm amber.",
"color_strip.candlelight.intensity": "Flicker Intensity:",
"color_strip.candlelight.intensity.hint": "How much the candles flicker. Low values produce a gentle glow, high values simulate a windy candle.",
"color_strip.candlelight.num_candles_label": "Number of Candles:",
"color_strip.candlelight.num_candles": "candles",
"color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.",
"color_strip.candlelight.speed": "Flicker Speed:",
"color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.",
"color_strip.composite.layers": "Layers:",
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
"color_strip.composite.add_layer": "+ Add Layer",

View File

@@ -913,6 +913,28 @@
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
"color_strip.notification.app_count": "прилож.",
"color_strip.type.daylight": "Дневной цикл",
"color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа",
"color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",
"color_strip.daylight.speed": "Скорость:",
"color_strip.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.",
"color_strip.daylight.use_real_time": "Реальное время:",
"color_strip.daylight.use_real_time.hint": "Если включено, цвет LED соответствует реальному времени суток. Настройка скорости игнорируется.",
"color_strip.daylight.real_time": "Реальное время",
"color_strip.daylight.latitude": "Широта:",
"color_strip.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.",
"color_strip.type.candlelight": "Свечи",
"color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей",
"color_strip.type.candlelight.hint": "Реалистичное мерцание свечей с тёплыми тонами и органическими паттернами.",
"color_strip.candlelight.color": "Базовый цвет:",
"color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.",
"color_strip.candlelight.intensity": "Интенсивность мерцания:",
"color_strip.candlelight.intensity.hint": "Сила мерцания свечей. Низкие значения — мягкое свечение, высокие — свеча на ветру.",
"color_strip.candlelight.num_candles_label": "Количество свечей:",
"color_strip.candlelight.num_candles": "свечей",
"color_strip.candlelight.num_candles.hint": "Сколько независимых источников свечей вдоль ленты. Каждый мерцает по-своему.",
"color_strip.candlelight.speed": "Скорость мерцания:",
"color_strip.candlelight.speed.hint": "Скорость анимации мерцания. Большие значения — более быстрое, беспокойное пламя.",
"color_strip.composite.layers": "Слои:",
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
"color_strip.composite.add_layer": "+ Добавить слой",

View File

@@ -913,6 +913,28 @@
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
"color_strip.notification.app_count": "个应用",
"color_strip.type.daylight": "日光循环",
"color_strip.type.daylight.desc": "模拟24小时自然日光变化",
"color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光再到温暖的日落和昏暗的夜晚。",
"color_strip.daylight.speed": "速度:",
"color_strip.daylight.speed.hint": "循环速度倍数。1.0 = 约4分钟完成一个完整的昼夜循环。",
"color_strip.daylight.use_real_time": "使用实时时间:",
"color_strip.daylight.use_real_time.hint": "启用后LED颜色匹配计算机的实际时间。速度设置将被忽略。",
"color_strip.daylight.real_time": "实时",
"color_strip.daylight.latitude": "纬度:",
"color_strip.daylight.latitude.hint": "地理纬度(-90到90。影响实时模式下的日出/日落时间。",
"color_strip.type.candlelight": "烛光",
"color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟",
"color_strip.type.candlelight.hint": "在所有LED上模拟逼真的蜡烛闪烁具有温暖色调和有机闪烁模式。",
"color_strip.candlelight.color": "基础颜色:",
"color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。",
"color_strip.candlelight.intensity": "闪烁强度:",
"color_strip.candlelight.intensity.hint": "蜡烛闪烁程度。低值产生柔和光芒,高值模拟风中的蜡烛。",
"color_strip.candlelight.num_candles_label": "蜡烛数量:",
"color_strip.candlelight.num_candles": "支蜡烛",
"color_strip.candlelight.num_candles.hint": "灯带上独立蜡烛光源的数量。每支蜡烛有自己的闪烁模式。",
"color_strip.candlelight.speed": "闪烁速度:",
"color_strip.candlelight.speed.hint": "闪烁动画的速度。较高的值产生更快、更不安定的火焰。",
"color_strip.composite.layers": "图层:",
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
"color_strip.composite.add_layer": "+ 添加图层",

View File

@@ -13,6 +13,8 @@ Current types:
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
DaylightColorStripSource — simulates natural daylight color temperature over a 24-hour cycle
CandlelightColorStripSource — realistic per-LED candle flickering with warm glow
"""
from dataclasses import dataclass, field
@@ -93,6 +95,12 @@ class ColorStripSource:
"app_filter_mode": None,
"app_filter_list": None,
"os_listener": None,
# daylight-type fields
"speed": None,
"use_real_time": None,
"latitude": None,
# candlelight-type fields
"num_candles": None,
}
@staticmethod
@@ -244,6 +252,32 @@ class ColorStripSource:
os_listener=bool(data.get("os_listener", False)),
)
if source_type == "daylight":
return DaylightColorStripSource(
id=sid, name=name, source_type="daylight",
created_at=created_at, updated_at=updated_at, description=description,
clock_id=clock_id, 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),
)
if source_type == "candlelight":
raw_color = data.get("color")
color = (
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
else [255, 147, 41]
)
return CandlelightColorStripSource(
id=sid, name=name, source_type="candlelight",
created_at=created_at, updated_at=updated_at, description=description,
clock_id=clock_id, tags=tags,
color=color,
intensity=float(data.get("intensity") or 1.0),
num_candles=int(data.get("num_candles") or 3),
speed=float(data.get("speed") or 1.0),
)
# Shared picture-type field extraction
_picture_kwargs = dict(
tags=tags,
@@ -567,3 +601,52 @@ class NotificationColorStripSource(ColorStripSource):
d["app_filter_list"] = list(self.app_filter_list)
d["os_listener"] = self.os_listener
return d
@dataclass
class DaylightColorStripSource(ColorStripSource):
"""Color strip source that simulates natural daylight over a 24-hour cycle.
All LEDs receive the same color at any point in time, smoothly
transitioning through dawn (warm orange), daylight (cool white),
sunset (warm red/orange), and night (dim blue).
LED count auto-sizes from the connected device.
When use_real_time is True, the current wall-clock hour determines
the color; speed is ignored. When False, speed controls how fast
a full 24-hour cycle plays (1.0 ≈ 4 minutes per full cycle).
"""
speed: float = 1.0 # cycle speed (ignored when use_real_time)
use_real_time: bool = False # use actual time of day
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
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
return d
@dataclass
class CandlelightColorStripSource(ColorStripSource):
"""Color strip source that simulates realistic candle flickering.
Each LED or group of LEDs flickers independently with warm tones.
Uses layered noise for organic, non-repeating flicker patterns.
LED count auto-sizes from the connected device.
"""
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
intensity: float = 1.0 # flicker intensity (0.12.0)
num_candles: int = 3 # number of independent candle sources
speed: float = 1.0 # flicker speed multiplier
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["intensity"] = self.intensity
d["num_candles"] = self.num_candles
d["speed"] = self.speed
return d

View File

@@ -10,9 +10,11 @@ from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
ColorCycleColorStripSource,
ColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
MappedColorStripSource,
@@ -82,6 +84,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
app_filter_mode: Optional[str] = None,
app_filter_list: Optional[list] = None,
os_listener: Optional[bool] = None,
# daylight-type fields
speed: Optional[float] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
# candlelight-type fields
num_candles: Optional[int] = None,
tags: Optional[List[str]] = None,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -235,6 +243,34 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
os_listener=bool(os_listener) if os_listener is not None else False,
)
elif source_type == "daylight":
source = DaylightColorStripSource(
id=source_id,
name=name,
source_type="daylight",
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
speed=float(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=float(latitude) if latitude is not None else 50.0,
)
elif source_type == "candlelight":
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 147, 41]
source = CandlelightColorStripSource(
id=source_id,
name=name,
source_type="candlelight",
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
color=rgb,
intensity=float(intensity) if intensity else 1.0,
num_candles=int(num_candles) if num_candles is not None else 3,
speed=float(speed) if speed is not None else 1.0,
)
elif source_type == "picture_advanced":
if calibration is None:
calibration = CalibrationConfig(mode="advanced")
@@ -326,6 +362,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
app_filter_mode: Optional[str] = None,
app_filter_list: Optional[list] = None,
os_listener: Optional[bool] = None,
# daylight-type fields
speed: Optional[float] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
# candlelight-type fields
num_candles: Optional[int] = None,
tags: Optional[List[str]] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -452,6 +494,22 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
source.app_filter_list = app_filter_list
if os_listener is not None:
source.os_listener = bool(os_listener)
elif isinstance(source, DaylightColorStripSource):
if speed is not None:
source.speed = float(speed)
if use_real_time is not None:
source.use_real_time = bool(use_real_time)
if latitude is not None:
source.latitude = float(latitude)
elif isinstance(source, CandlelightColorStripSource):
if color is not None and isinstance(color, list) and len(color) == 3:
source.color = color
if intensity is not None:
source.intensity = float(intensity)
if num_candles is not None:
source.num_candles = int(num_candles)
if speed is not None:
source.speed = float(speed)
source.updated_at = datetime.now(timezone.utc)
self._save()

View File

@@ -32,6 +32,8 @@
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
<option value="api_input" data-i18n="color_strip.type.api_input">API Input</option>
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
<option value="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
</select>
</div>
@@ -534,6 +536,77 @@
</div>
</div>
<!-- Daylight Cycle section -->
<div id="css-editor-daylight-section" style="display:none">
<div id="css-editor-daylight-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">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="color_strip.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-daylight-real-time" data-i18n="color_strip.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="color_strip.daylight.use_real_time.hint">When enabled, LED color matches the actual time of day. Speed is ignored.</small>
<label class="toggle-switch">
<input type="checkbox" id="css-editor-daylight-real-time" onchange="onDaylightRealTimeChange()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-daylight-latitude"><span data-i18n="color_strip.daylight.latitude">Latitude:</span> <span id="css-editor-daylight-latitude-val">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="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
</div>
</div>
<!-- Candlelight section -->
<div id="css-editor-candlelight-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-color" data-i18n="color_strip.candlelight.color">Base Color:</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="color_strip.candlelight.color.hint">The warm base color of the candle flame. Default is a natural warm amber.</small>
<input type="color" id="css-editor-candlelight-color" value="#ff9329">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-intensity"><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span> <span id="css-editor-candlelight-intensity-val">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="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-num-candles" data-i18n="color_strip.candlelight.num_candles_label">Number of Candles:</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="color_strip.candlelight.num_candles.hint">How many independent candle sources along the strip. Each flickers with its own pattern. More candles = more variation.</small>
<input type="number" id="css-editor-candlelight-num-candles" min="1" max="20" step="1" value="3">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-speed"><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span> <span id="css-editor-candlelight-speed-val">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="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">