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.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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
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.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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
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,
|
||||
} 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
|
||||
|
||||
@@ -72,4 +72,5 @@ export const download = '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2
|
||||
export const undo2 = '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>';
|
||||
export const power = '<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" x2="12" y1="2" y2="12"/>';
|
||||
export const wifi = '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>';
|
||||
export const flame = '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>';
|
||||
export const usb = '<circle cx="10" cy="7" r="1"/><circle cx="4" cy="20" r="1"/><path d="M4.7 19.3 19 5"/><path d="m21 3-3 1 2 2Z"/><path d="M10 8v3a1 1 0 0 1-1 1H4"/><path d="M14 12v2a1 1 0 0 0 1 1h3"/><circle cx="20" cy="15" r="1"/>';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -80,6 +80,13 @@ class CSSEditorModal extends Modal {
|
||||
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
||||
notification_app_colors: JSON.stringify(_notificationAppColors),
|
||||
clock_id: document.getElementById('css-editor-clock').value,
|
||||
daylight_speed: document.getElementById('css-editor-daylight-speed').value,
|
||||
daylight_use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
|
||||
daylight_latitude: document.getElementById('css-editor-daylight-latitude').value,
|
||||
candlelight_color: document.getElementById('css-editor-candlelight-color').value,
|
||||
candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value,
|
||||
candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value,
|
||||
candlelight_speed: document.getElementById('css-editor-candlelight-speed').value,
|
||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -99,7 +106,7 @@ let _cssClockEntitySelect = null;
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification',
|
||||
'api_input', 'notification', 'daylight', 'candlelight',
|
||||
];
|
||||
|
||||
function _buildCSSTypeItems() {
|
||||
@@ -148,6 +155,8 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
|
||||
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
|
||||
document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none';
|
||||
document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none';
|
||||
|
||||
if (isPictureType) _ensureInterpolationIconSelect();
|
||||
if (type === 'effect') {
|
||||
@@ -197,8 +206,8 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
||||
// Sync clock — shown for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
|
||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||
|
||||
@@ -274,6 +283,17 @@ function _syncAnimationSpeedState() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Daylight real-time toggle helper ─────────────────────────── */
|
||||
|
||||
export function onDaylightRealTimeChange() {
|
||||
_syncDaylightSpeedVisibility();
|
||||
}
|
||||
|
||||
function _syncDaylightSpeedVisibility() {
|
||||
const isRealTime = document.getElementById('css-editor-daylight-real-time').checked;
|
||||
document.getElementById('css-editor-daylight-speed-group').style.display = isRealTime ? 'none' : '';
|
||||
}
|
||||
|
||||
/* ── Gradient strip preview helper ────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -1039,6 +1059,23 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
</span>
|
||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||
`;
|
||||
} else if (source.source_type === 'daylight') {
|
||||
const useRealTime = source.use_real_time;
|
||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (source.source_type === 'candlelight') {
|
||||
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
|
||||
const numCandles = source.num_candles ?? 3;
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||
</span>
|
||||
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (isPictureAdvanced) {
|
||||
const cal = source.calibration || {};
|
||||
const lines = cal.lines || [];
|
||||
@@ -1073,7 +1110,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
}
|
||||
|
||||
const icon = getColorStripIcon(source.source_type);
|
||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification);
|
||||
const isDaylight = source.source_type === 'daylight';
|
||||
const isCandlelight = source.source_type === 'candlelight';
|
||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight);
|
||||
const calibrationBtn = isPictureKind
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||
: '';
|
||||
@@ -1217,6 +1256,20 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
_showApiInputEndpoints(css.id);
|
||||
} else if (sourceType === 'notification') {
|
||||
_loadNotificationState(css);
|
||||
} else if (sourceType === 'daylight') {
|
||||
document.getElementById('css-editor-daylight-speed').value = css.speed ?? 1.0;
|
||||
document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
document.getElementById('css-editor-daylight-real-time').checked = css.use_real_time || false;
|
||||
document.getElementById('css-editor-daylight-latitude').value = css.latitude ?? 50.0;
|
||||
document.getElementById('css-editor-daylight-latitude-val').textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
|
||||
_syncDaylightSpeedVisibility();
|
||||
} else if (sourceType === 'candlelight') {
|
||||
document.getElementById('css-editor-candlelight-color').value = rgbArrayToHex(css.color || [255, 147, 41]);
|
||||
document.getElementById('css-editor-candlelight-intensity').value = css.intensity ?? 1.0;
|
||||
document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
||||
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
|
||||
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
|
||||
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
} else {
|
||||
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
@@ -1313,6 +1366,19 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
|
||||
_showApiInputEndpoints(null);
|
||||
_resetNotificationState();
|
||||
// Daylight defaults
|
||||
document.getElementById('css-editor-daylight-speed').value = 1.0;
|
||||
document.getElementById('css-editor-daylight-speed-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-daylight-real-time').checked = false;
|
||||
document.getElementById('css-editor-daylight-latitude').value = 50.0;
|
||||
document.getElementById('css-editor-daylight-latitude-val').textContent = '50';
|
||||
// Candlelight defaults
|
||||
document.getElementById('css-editor-candlelight-color').value = '#ff9329';
|
||||
document.getElementById('css-editor-candlelight-intensity').value = 1.0;
|
||||
document.getElementById('css-editor-candlelight-intensity-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-candlelight-num-candles').value = 3;
|
||||
document.getElementById('css-editor-candlelight-speed').value = 1.0;
|
||||
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
|
||||
document.getElementById('css-editor-gradient-preset').value = '';
|
||||
gradientInit([
|
||||
@@ -1473,6 +1539,23 @@ export async function saveCSSEditor() {
|
||||
app_colors: _notificationGetAppColorsDict(),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'notification';
|
||||
} else if (sourceType === 'daylight') {
|
||||
payload = {
|
||||
name,
|
||||
speed: parseFloat(document.getElementById('css-editor-daylight-speed').value),
|
||||
use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
|
||||
latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'daylight';
|
||||
} else if (sourceType === 'candlelight') {
|
||||
payload = {
|
||||
name,
|
||||
color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value),
|
||||
intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value),
|
||||
num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3,
|
||||
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'candlelight';
|
||||
} else if (sourceType === 'picture_advanced') {
|
||||
payload = {
|
||||
name,
|
||||
@@ -1501,7 +1584,7 @@ export async function saveCSSEditor() {
|
||||
}
|
||||
|
||||
// Attach clock_id for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||
if (clockTypes.includes(sourceType)) {
|
||||
const clockVal = document.getElementById('css-editor-clock').value;
|
||||
payload.clock_id = clockVal || null;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "+ Добавить слой",
|
||||
|
||||
@@ -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": "+ 添加图层",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
|
||||
<option value="api_input" data-i18n="color_strip.type.api_input">API Input</option>
|
||||
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
|
||||
<option value="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
|
||||
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -534,6 +536,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daylight Cycle section -->
|
||||
<div id="css-editor-daylight-section" style="display:none">
|
||||
<div id="css-editor-daylight-speed-group" class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">1.0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
|
||||
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-daylight-real-time" data-i18n="color_strip.daylight.use_real_time">Use Real Time:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.use_real_time.hint">When enabled, LED color matches the actual time of day. Speed is ignored.</small>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="css-editor-daylight-real-time" onchange="onDaylightRealTimeChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-daylight-latitude"><span data-i18n="color_strip.daylight.latitude">Latitude:</span> <span id="css-editor-daylight-latitude-val">50</span>°</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
|
||||
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
|
||||
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candlelight section -->
|
||||
<div id="css-editor-candlelight-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-color" data-i18n="color_strip.candlelight.color">Base Color:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.color.hint">The warm base color of the candle flame. Default is a natural warm amber.</small>
|
||||
<input type="color" id="css-editor-candlelight-color" value="#ff9329">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-intensity"><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span> <span id="css-editor-candlelight-intensity-val">1.0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
|
||||
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-num-candles" data-i18n="color_strip.candlelight.num_candles_label">Number of Candles:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.num_candles.hint">How many independent candle sources along the strip. Each flickers with its own pattern. More candles = more variation.</small>
|
||||
<input type="number" id="css-editor-candlelight-num-candles" min="1" max="20" step="1" value="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-speed"><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span> <span id="css-editor-candlelight-speed-val">1.0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
|
||||
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared LED count field -->
|
||||
<div id="css-editor-led-count-group" class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
Reference in New Issue
Block a user