diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 317c204..ec923fe 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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: diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index f522a49..ba1e311 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -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") diff --git a/server/src/wled_controller/core/processing/candlelight_stream.py b/server/src/wled_controller/core/processing/candlelight_stream.py new file mode 100644 index 0000000..34615f5 --- /dev/null +++ b/server/src/wled_controller/core/processing/candlelight_stream.py @@ -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) diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 2942943..8d6635e 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -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, } diff --git a/server/src/wled_controller/core/processing/daylight_stream.py b/server/src/wled_controller/core/processing/daylight_stream.py new file mode 100644 index 0000000..823684b --- /dev/null +++ b/server/src/wled_controller/core/processing/daylight_stream.py @@ -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 (0–24) 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 diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index b0c5021..d17e430 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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 diff --git a/server/src/wled_controller/static/js/core/icon-paths.js b/server/src/wled_controller/static/js/core/icon-paths.js index b96348c..ac70a59 100644 --- a/server/src/wled_controller/static/js/core/icon-paths.js +++ b/server/src/wled_controller/static/js/core/icon-paths.js @@ -72,4 +72,5 @@ export const download = ''; export const power = ''; export const wifi = ''; +export const flame = ''; export const usb = ''; diff --git a/server/src/wled_controller/static/js/core/icons.js b/server/src/wled_controller/static/js/core/icons.js index 92de3b7..7376826 100644 --- a/server/src/wled_controller/static/js/core/icons.js +++ b/server/src/wled_controller/static/js/core/icons.js @@ -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), diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 5522b1c..4239eba 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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) { ${appCount > 0 ? `${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}` : ''} `; + } else if (source.source_type === 'daylight') { + const useRealTime = source.use_real_time; + const speedVal = (source.speed ?? 1.0).toFixed(1); + propsHtml = ` + ${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'} + ${clockBadge} + `; + } else if (source.source_type === 'candlelight') { + const hexColor = rgbArrayToHex(source.color || [255, 147, 41]); + const numCandles = source.num_candles ?? 3; + propsHtml = ` + + ${hexColor.toUpperCase()} + + ${numCandles} ${t('color_strip.candlelight.num_candles')} + ${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 ? `` : ''; @@ -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; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index a71af55..8d52ec0 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index ec31cf2..ae6d650 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "+ Добавить слой", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 29be851..5446246 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "+ 添加图层", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 6655704..59a48ec 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -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.1–2.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 diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index d71fbf8..b822542 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -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() diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index afd8e11..1b186a1 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -32,6 +32,8 @@ + + @@ -534,6 +536,77 @@ + + + + + +