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:
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_output_target_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_mode=getattr(source, "app_filter_mode", None),
|
||||||
app_filter_list=getattr(source, "app_filter_list", None),
|
app_filter_list=getattr(source, "app_filter_list", None),
|
||||||
os_listener=getattr(source, "os_listener", 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,
|
overlay_active=overlay_active,
|
||||||
tags=getattr(source, 'tags', []),
|
tags=getattr(source, 'tags', []),
|
||||||
created_at=source.created_at,
|
created_at=source.created_at,
|
||||||
@@ -191,8 +196,13 @@ async def create_color_strip_source(
|
|||||||
app_filter_mode=data.app_filter_mode,
|
app_filter_mode=data.app_filter_mode,
|
||||||
app_filter_list=data.app_filter_list,
|
app_filter_list=data.app_filter_list,
|
||||||
os_listener=data.os_listener,
|
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,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("color_strip_source", "created", source.id)
|
||||||
return _css_to_response(source)
|
return _css_to_response(source)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -275,6 +285,10 @@ async def update_color_strip_source(
|
|||||||
app_filter_mode=data.app_filter_mode,
|
app_filter_mode=data.app_filter_mode,
|
||||||
app_filter_list=data.app_filter_list,
|
app_filter_list=data.app_filter_list,
|
||||||
os_listener=data.os_listener,
|
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,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -284,6 +298,7 @@ async def update_color_strip_source(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not hot-reload CSS stream {source_id}: {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)
|
return _css_to_response(source)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -327,6 +342,7 @@ async def delete_color_strip_source(
|
|||||||
"Remove it from the mapped source(s) first.",
|
"Remove it from the mapped source(s) first.",
|
||||||
)
|
)
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
"""Request to create a color strip source."""
|
"""Request to create a color strip source."""
|
||||||
|
|
||||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||||
source_type: Literal["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-type fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
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)
|
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_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")
|
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")
|
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
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
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")
|
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_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")
|
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")
|
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
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
tags: Optional[List[str]] = None
|
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_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")
|
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")
|
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
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
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")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|||||||
247
server/src/wled_controller/core/processing/candlelight_stream.py
Normal file
247
server/src/wled_controller/core/processing/candlelight_stream.py
Normal 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)
|
||||||
@@ -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.effect_stream import EffectColorStripStream
|
||||||
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||||
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
|
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
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -33,6 +35,8 @@ _SIMPLE_STREAM_MAP = {
|
|||||||
"effect": EffectColorStripStream,
|
"effect": EffectColorStripStream,
|
||||||
"api_input": ApiInputColorStripStream,
|
"api_input": ApiInputColorStripStream,
|
||||||
"notification": NotificationColorStripStream,
|
"notification": NotificationColorStripStream,
|
||||||
|
"daylight": DaylightColorStripStream,
|
||||||
|
"candlelight": CandlelightColorStripStream,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
221
server/src/wled_controller/core/processing/daylight_stream.py
Normal file
221
server/src/wled_controller/core/processing/daylight_stream.py
Normal 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 (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
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
toggleDashboardSection, changeDashboardPollInterval,
|
toggleDashboardSection, changeDashboardPollInterval,
|
||||||
} from './features/dashboard.js';
|
} from './features/dashboard.js';
|
||||||
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
|
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
|
||||||
|
import { startEntityEventListeners } from './core/entity-events.js';
|
||||||
import {
|
import {
|
||||||
startPerfPolling, stopPerfPolling,
|
startPerfPolling, stopPerfPolling,
|
||||||
} from './features/perf-charts.js';
|
} from './features/perf-charts.js';
|
||||||
@@ -108,7 +109,7 @@ import {
|
|||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
import {
|
import {
|
||||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange,
|
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||||
colorCycleAddColor, colorCycleRemoveColor,
|
colorCycleAddColor, colorCycleRemoveColor,
|
||||||
compositeAddLayer, compositeRemoveLayer,
|
compositeAddLayer, compositeRemoveLayer,
|
||||||
mappedAddZone, mappedRemoveZone,
|
mappedAddZone, mappedRemoveZone,
|
||||||
@@ -376,6 +377,7 @@ Object.assign(window, {
|
|||||||
onEffectTypeChange,
|
onEffectTypeChange,
|
||||||
onCSSClockChange,
|
onCSSClockChange,
|
||||||
onAnimationTypeChange,
|
onAnimationTypeChange,
|
||||||
|
onDaylightRealTimeChange,
|
||||||
colorCycleAddColor,
|
colorCycleAddColor,
|
||||||
colorCycleRemoveColor,
|
colorCycleRemoveColor,
|
||||||
compositeAddLayer,
|
compositeAddLayer,
|
||||||
@@ -555,6 +557,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
// Start global events WebSocket and auto-refresh
|
// Start global events WebSocket and auto-refresh
|
||||||
startEventsWS();
|
startEventsWS();
|
||||||
|
startEntityEventListeners();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// Show getting-started tutorial on first visit
|
||||||
|
|||||||
@@ -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 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 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 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"/>';
|
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"/>';
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const _colorStripTypeIcons = {
|
|||||||
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
||||||
api_input: _svg(P.send),
|
api_input: _svg(P.send),
|
||||||
notification: _svg(P.bellRing),
|
notification: _svg(P.bellRing),
|
||||||
|
daylight: _svg(P.sun),
|
||||||
|
candlelight: _svg(P.flame),
|
||||||
};
|
};
|
||||||
const _valueSourceTypeIcons = {
|
const _valueSourceTypeIcons = {
|
||||||
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ class CSSEditorModal extends Modal {
|
|||||||
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
||||||
notification_app_colors: JSON.stringify(_notificationAppColors),
|
notification_app_colors: JSON.stringify(_notificationAppColors),
|
||||||
clock_id: document.getElementById('css-editor-clock').value,
|
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() : []),
|
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,7 +106,7 @@ let _cssClockEntitySelect = null;
|
|||||||
const CSS_TYPE_KEYS = [
|
const CSS_TYPE_KEYS = [
|
||||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||||
'effect', 'composite', 'mapped', 'audio',
|
'effect', 'composite', 'mapped', 'audio',
|
||||||
'api_input', 'notification',
|
'api_input', 'notification', 'daylight', 'candlelight',
|
||||||
];
|
];
|
||||||
|
|
||||||
function _buildCSSTypeItems() {
|
function _buildCSSTypeItems() {
|
||||||
@@ -148,6 +155,8 @@ export function onCSSTypeChange() {
|
|||||||
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
|
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-api-input-section').style.display = type === 'api_input' ? '' : 'none';
|
||||||
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : '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 (isPictureType) _ensureInterpolationIconSelect();
|
||||||
if (type === 'effect') {
|
if (type === 'effect') {
|
||||||
@@ -197,8 +206,8 @@ export function onCSSTypeChange() {
|
|||||||
document.getElementById('css-editor-led-count-group').style.display =
|
document.getElementById('css-editor-led-count-group').style.display =
|
||||||
hasLedCount.includes(type) ? '' : 'none';
|
hasLedCount.includes(type) ? '' : 'none';
|
||||||
|
|
||||||
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
|
// Sync clock — shown for animated types
|
||||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||||
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
|
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
|
||||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
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 ────────────────────────────── */
|
/* ── Gradient strip preview helper ────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1039,6 +1059,23 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
|||||||
</span>
|
</span>
|
||||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</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) {
|
} else if (isPictureAdvanced) {
|
||||||
const cal = source.calibration || {};
|
const cal = source.calibration || {};
|
||||||
const lines = cal.lines || [];
|
const lines = cal.lines || [];
|
||||||
@@ -1073,7 +1110,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const icon = getColorStripIcon(source.source_type);
|
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
|
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>`
|
? `<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);
|
_showApiInputEndpoints(css.id);
|
||||||
} else if (sourceType === 'notification') {
|
} else if (sourceType === 'notification') {
|
||||||
_loadNotificationState(css);
|
_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 {
|
} else {
|
||||||
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
|
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';
|
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
|
||||||
_showApiInputEndpoints(null);
|
_showApiInputEndpoints(null);
|
||||||
_resetNotificationState();
|
_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-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
|
||||||
document.getElementById('css-editor-gradient-preset').value = '';
|
document.getElementById('css-editor-gradient-preset').value = '';
|
||||||
gradientInit([
|
gradientInit([
|
||||||
@@ -1473,6 +1539,23 @@ export async function saveCSSEditor() {
|
|||||||
app_colors: _notificationGetAppColorsDict(),
|
app_colors: _notificationGetAppColorsDict(),
|
||||||
};
|
};
|
||||||
if (!cssId) payload.source_type = 'notification';
|
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') {
|
} else if (sourceType === 'picture_advanced') {
|
||||||
payload = {
|
payload = {
|
||||||
name,
|
name,
|
||||||
@@ -1501,7 +1584,7 @@ export async function saveCSSEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach clock_id for animated types
|
// 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)) {
|
if (clockTypes.includes(sourceType)) {
|
||||||
const clockVal = document.getElementById('css-editor-clock').value;
|
const clockVal = document.getElementById('css-editor-clock').value;
|
||||||
payload.clock_id = clockVal || null;
|
payload.clock_id = clockVal || null;
|
||||||
|
|||||||
@@ -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.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.save_first": "Save the source first to see the webhook endpoint URL.",
|
||||||
"color_strip.notification.app_count": "apps",
|
"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": "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.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",
|
"color_strip.composite.add_layer": "+ Add Layer",
|
||||||
|
|||||||
@@ -913,6 +913,28 @@
|
|||||||
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
||||||
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
|
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
|
||||||
"color_strip.notification.app_count": "прилож.",
|
"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": "Слои:",
|
||||||
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
||||||
"color_strip.composite.add_layer": "+ Добавить слой",
|
"color_strip.composite.add_layer": "+ Добавить слой",
|
||||||
|
|||||||
@@ -913,6 +913,28 @@
|
|||||||
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
|
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
|
||||||
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
|
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
|
||||||
"color_strip.notification.app_count": "个应用",
|
"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": "图层:",
|
||||||
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
|
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
|
||||||
"color_strip.composite.add_layer": "+ 添加图层",
|
"color_strip.composite.add_layer": "+ 添加图层",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Current types:
|
|||||||
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
||||||
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
||||||
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
|
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
|
from dataclasses import dataclass, field
|
||||||
@@ -93,6 +95,12 @@ class ColorStripSource:
|
|||||||
"app_filter_mode": None,
|
"app_filter_mode": None,
|
||||||
"app_filter_list": None,
|
"app_filter_list": None,
|
||||||
"os_listener": None,
|
"os_listener": None,
|
||||||
|
# daylight-type fields
|
||||||
|
"speed": None,
|
||||||
|
"use_real_time": None,
|
||||||
|
"latitude": None,
|
||||||
|
# candlelight-type fields
|
||||||
|
"num_candles": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -244,6 +252,32 @@ class ColorStripSource:
|
|||||||
os_listener=bool(data.get("os_listener", False)),
|
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
|
# Shared picture-type field extraction
|
||||||
_picture_kwargs = dict(
|
_picture_kwargs = dict(
|
||||||
tags=tags,
|
tags=tags,
|
||||||
@@ -567,3 +601,52 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
d["app_filter_list"] = list(self.app_filter_list)
|
d["app_filter_list"] = list(self.app_filter_list)
|
||||||
d["os_listener"] = self.os_listener
|
d["os_listener"] = self.os_listener
|
||||||
return d
|
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
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ from wled_controller.storage.color_strip_source import (
|
|||||||
AdvancedPictureColorStripSource,
|
AdvancedPictureColorStripSource,
|
||||||
ApiInputColorStripSource,
|
ApiInputColorStripSource,
|
||||||
AudioColorStripSource,
|
AudioColorStripSource,
|
||||||
|
CandlelightColorStripSource,
|
||||||
ColorCycleColorStripSource,
|
ColorCycleColorStripSource,
|
||||||
ColorStripSource,
|
ColorStripSource,
|
||||||
CompositeColorStripSource,
|
CompositeColorStripSource,
|
||||||
|
DaylightColorStripSource,
|
||||||
EffectColorStripSource,
|
EffectColorStripSource,
|
||||||
GradientColorStripSource,
|
GradientColorStripSource,
|
||||||
MappedColorStripSource,
|
MappedColorStripSource,
|
||||||
@@ -82,6 +84,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
|||||||
app_filter_mode: Optional[str] = None,
|
app_filter_mode: Optional[str] = None,
|
||||||
app_filter_list: Optional[list] = None,
|
app_filter_list: Optional[list] = None,
|
||||||
os_listener: Optional[bool] = 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,
|
tags: Optional[List[str]] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Create a new color strip source.
|
"""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 [],
|
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,
|
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":
|
elif source_type == "picture_advanced":
|
||||||
if calibration is None:
|
if calibration is None:
|
||||||
calibration = CalibrationConfig(mode="advanced")
|
calibration = CalibrationConfig(mode="advanced")
|
||||||
@@ -326,6 +362,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
|||||||
app_filter_mode: Optional[str] = None,
|
app_filter_mode: Optional[str] = None,
|
||||||
app_filter_list: Optional[list] = None,
|
app_filter_list: Optional[list] = None,
|
||||||
os_listener: Optional[bool] = 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,
|
tags: Optional[List[str]] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Update an existing color strip source.
|
"""Update an existing color strip source.
|
||||||
@@ -452,6 +494,22 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
|||||||
source.app_filter_list = app_filter_list
|
source.app_filter_list = app_filter_list
|
||||||
if os_listener is not None:
|
if os_listener is not None:
|
||||||
source.os_listener = bool(os_listener)
|
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)
|
source.updated_at = datetime.now(timezone.utc)
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
|
<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="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="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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -534,6 +536,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>°</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 -->
|
<!-- Shared LED count field -->
|
||||||
<div id="css-editor-led-count-group" class="form-group">
|
<div id="css-editor-led-count-group" class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
Reference in New Issue
Block a user