Compare commits

...

4 Commits

Author SHA1 Message Date
73562cd525 Add entity CRUD events over WebSocket with auto-refresh
Broadcast entity_changed and device_health_changed events via the event
bus so the frontend can auto-refresh cards without polling. Adds
exponential backoff on WS reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:09:09 +03:00
1ce25caa35 Clean up TODO.md: remove completed items, add new P1 tasks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:07:44 +03:00
7b4b455c7d Fix IconSelect grid overflow and scroll jump
- Set maxHeight dynamically based on available viewport space
- Clamp popup horizontally to stay within viewport
- Remove max-height CSS transition that caused scroll jumps
- Auto-close popup on ancestor scroll to prevent stale positioning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:07:38 +03:00
37c80f01af 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>
2026-03-10 11:07:30 +03:00
36 changed files with 1116 additions and 92 deletions

55
TODO.md
View File

@@ -4,21 +4,15 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
## Processing Pipeline ## Processing Pipeline
- [x] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content
- [x] `P1` **Color temperature filter** — Already covered by existing Color Correction filter (2000-10000K)
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color - [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
- Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes - Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes
- Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips - Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips
- [x] `P2` **Palette quantization** — Force output to match a user-defined palette (preset or custom hex)
- [x] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut - [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
- Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing - Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing
- Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently - Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently
## Output Targets ## Output Targets
- [x] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity
- [x] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers - [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI - Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
- Impact: medium — opens stage/theatrical use case, niche but differentiating - Impact: medium — opens stage/theatrical use case, niche but differentiating
@@ -28,12 +22,6 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
- [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration - [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration
- Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop - Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop
- Impact: high — key integration point for home automation users without Home Assistant - Impact: high — key integration point for home automation users without Home Assistant
- [ ] `P2` **WebSocket event bus** — Broadcast all state changes over a single WS channel
- Complexity: low-medium — ProcessorManager already emits events; add a WS endpoint that fans out JSON events to connected clients
- Impact: medium — enables real-time dashboards, mobile apps, and third-party integrations
- [x] `P3` **Notification reactive** — Flash/pulse on OS notifications (optional app filter)
- Complexity: large — OS-level notification listener (platform-specific: Win32 `WinToast`/`pystray`, macOS `pyobjc`); needs a new "effect source" type that triggers color pulses
- Impact: low-medium — fun but niche; platform-dependent maintenance burden
## Multi-Display ## Multi-Display
@@ -49,53 +37,20 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices - [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect - Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case - Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
- [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
## Code Health (from review 2026-03-09) ## Code Health
### Bugs
- [x] `P1` **Thread safety: dict mutation during iteration** — composite_stream.py / mapped_stream.py `_sub_streams.clear()` crashes processing loop
- [x] `P1` **Thread safety: SyncClockRuntime.get_time() race** — compound read without lock causes time double-counting
- [x] `P1` **Thread safety: SyncClockManager unprotected dicts**`_runtimes`/`_ref_counts` mutated from multiple threads without lock
- [x] `P1` **Clock ref-count corruption on hot-swap**`_release_clock` reads new clock_id from store instead of old one
- [x] `P1` **Path traversal guard**`auto_backup.py` uses string checks instead of `Path.resolve().is_relative_to()`
- [x] `P2` **Crash doesn't fire state_change event** — fatal exception path in `wled_target_processor.py` doesn't notify dashboard
- [x] `P2` **WS broadcast client mismatch**`kc_target_processor.py` `zip(clients, results)` can pair wrong clients after concurrent removal
### Performance
- [x] `P1` **Triple FFT for mono audio**`analysis.py` runs 3 identical FFTs when audio is mono (2x wasted CPU)
- [x] `P2` **Per-frame np.array() from list**`ddp_client.py:195` allocates new numpy array every frame
- [x] `P2` **frame_time recomputed every loop iteration**`1.0/fps` in 8 stream files, should be cached
- [x] `P2` **Effect/composite/mapped streams hardcoded to 30 FPS** — ignores target FPS, bottlenecks 60 FPS targets
- [x] `P3` **Spectrum .copy() per audio chunk**`analysis.py` ~258 array allocations/sec for read-only consumers
### Code Quality
- [x] `P2` **12 store classes with duplicated boilerplate** — no base class; `BaseJsonStore[T]` would eliminate ~60%
- [x] `P2` **DeviceStore.save() uses unsafe temp file** — fixed-path `.tmp` instead of `atomic_write_json`
- [x] `P2` **Route code directly mutates ProcessorManager internals**`devices.py` accesses `manager._devices` in 13+ places
- [x] `P2` **scene_activator.py accesses ProcessorManager._processors directly** — bypasses public API
- [x] `P3` **datetime.utcnow() deprecated** — 88 call sites in 42 files, should use `datetime.now(timezone.utc)`
- [x] `P3` **color-strips.js 1900+ lines** — should be split into separate modules
- [x] `P3` **No DataCache for color strip sources** — fetched with raw fetchWithAuth in 5+ places
### Features
- [x] `P1` **Auto-restart crashed processing loops** — add backoff-based restart when `_processing_loop` dies
- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing - [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing
- [ ] `P2` **Manual backup trigger endpoint**`POST /system/auto-backup/trigger` (~5 lines) - [ ] `P2` **Manual backup trigger endpoint**`POST /system/auto-backup/trigger` (~5 lines)
- [ ] `P2` **Scene snapshot should capture device brightness**`software_brightness` not saved/restored - [ ] `P2` **Scene snapshot should capture device brightness**`software_brightness` not saved/restored
- [ ] `P2` **Device health WebSocket events** — eliminate 5-30s poll latency for online/offline detection
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard**`metrics.last_error` is already populated - [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard**`metrics.last_error` is already populated
- [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists - [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup - [ ] `P3` **CSS source import/export** — share individual sources without full config backup
- [ ] `P3` **Exponential backoff on events WS reconnect** — currently fixed 3s retry
## UX ## UX
- [x] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag
- [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default - [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
- [x] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards - [ ] `P1` **Review new CSS types (Daylight & Candlelight)** — End-to-end review: create via UI, assign to targets, verify LED rendering, check edge cases (0 candles, extreme latitude, real-time toggle)
- [ ] `P1` **Daylight brightness value source** — New value source type that reports a 0255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic
- [ ] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout
- [ ] `P1` **IconSelect grid overflow & scroll jump** — Expandable icon grid sometimes renders outside the visible viewport; opening it causes the modal/page to jump scroll

View File

@@ -157,6 +157,23 @@ def get_sync_clock_manager() -> SyncClockManager:
return _sync_clock_manager return _sync_clock_manager
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus.
Args:
entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted"
entity_id: The entity's unique ID
"""
if _processor_manager is not None:
_processor_manager.fire_event({
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
})
def init_dependencies( def init_dependencies(
device_store: DeviceStore, device_store: DeviceStore,
template_store: TemplateStore, template_store: TemplateStore,

View File

@@ -9,6 +9,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
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_audio_source_store, get_audio_source_store,
get_audio_template_store, get_audio_template_store,
get_color_strip_store, get_color_strip_store,
@@ -84,6 +85,7 @@ async def create_audio_source(
audio_template_id=data.audio_template_id, audio_template_id=data.audio_template_id,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("audio_source", "created", source.id)
return _to_response(source) return _to_response(source)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -123,6 +125,7 @@ async def update_audio_source(
audio_template_id=data.audio_template_id, audio_template_id=data.audio_template_id,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("audio_source", "updated", source_id)
return _to_response(source) return _to_response(source)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -146,6 +149,7 @@ async def delete_audio_source(
) )
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from starlette.websockets import WebSocket, WebSocketDisconnect from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_audio_template_store, get_audio_source_store, get_processor_manager from wled_controller.api.dependencies import fire_entity_event, get_audio_template_store, get_audio_source_store, get_processor_manager
from wled_controller.api.schemas.audio_templates import ( from wled_controller.api.schemas.audio_templates import (
AudioEngineInfo, AudioEngineInfo,
AudioEngineListResponse, AudioEngineListResponse,
@@ -66,6 +66,7 @@ async def create_audio_template(
engine_config=data.engine_config, description=data.description, engine_config=data.engine_config, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type, id=template.id, name=template.name, engine_type=template.engine_type,
engine_config=template.engine_config, tags=getattr(template, 'tags', []), engine_config=template.engine_config, tags=getattr(template, 'tags', []),
@@ -112,6 +113,7 @@ async def update_audio_template(
engine_type=data.engine_type, engine_config=data.engine_config, engine_type=data.engine_type, engine_config=data.engine_config,
description=data.description, tags=data.tags, description=data.description, tags=data.tags,
) )
fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []), engine_config=t.engine_config, tags=getattr(t, 'tags', []),
@@ -135,6 +137,7 @@ async def delete_audio_template(
"""Delete an audio template.""" """Delete an audio template."""
try: try:
store.delete_template(template_id, audio_source_store=audio_source_store) store.delete_template(template_id, audio_source_store=audio_source_store)
fire_entity_event("audio_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
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_automation_engine, get_automation_engine,
get_automation_store, get_automation_store,
get_scene_preset_store, get_scene_preset_store,
@@ -174,6 +175,7 @@ async def create_automation(
if automation.enabled: if automation.enabled:
await engine.trigger_evaluate() await engine.trigger_evaluate()
fire_entity_event("automation", "created", automation.id)
return _automation_to_response(automation, engine, request) return _automation_to_response(automation, engine, request)
@@ -273,6 +275,7 @@ async def update_automation(
if automation.enabled: if automation.enabled:
await engine.trigger_evaluate() await engine.trigger_evaluate()
fire_entity_event("automation", "updated", automation_id)
return _automation_to_response(automation, engine, request) return _automation_to_response(automation, engine, request)
@@ -296,6 +299,8 @@ async def delete_automation(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
# ===== Enable/Disable ===== # ===== Enable/Disable =====

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
from wled_controller.api.auth import AuthRequired from wled_controller.api.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:

View File

@@ -12,6 +12,7 @@ from wled_controller.core.devices.led_client import (
get_provider, get_provider,
) )
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
fire_entity_event,
get_device_store, get_device_store,
get_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
@@ -146,6 +147,7 @@ async def create_device(
zone_mode=device.zone_mode, zone_mode=device.zone_mode,
) )
fire_entity_event("device", "created", device.id)
return _device_to_response(device) return _device_to_response(device)
except HTTPException: except HTTPException:
@@ -332,6 +334,7 @@ async def update_device(
if update_data.zone_mode is not None: if update_data.zone_mode is not None:
ds.zone_mode = update_data.zone_mode ds.zone_mode = update_data.zone_mode
fire_entity_event("device", "updated", device_id)
return _device_to_response(device) return _device_to_response(device)
except ValueError as e: except ValueError as e:
@@ -369,6 +372,7 @@ async def delete_device(
# Delete from storage # Delete from storage
store.delete_device(device_id) store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id)
logger.info(f"Deleted device {device_id}") logger.info(f"Deleted device {device_id}")
except HTTPException: except HTTPException:

View File

@@ -12,6 +12,7 @@ from PIL import Image
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_device_store, get_device_store,
get_pattern_template_store, get_pattern_template_store,
@@ -181,6 +182,7 @@ async def create_target(
except ValueError as e: except ValueError as e:
logger.warning(f"Could not register target {target.id} in processor manager: {e}") logger.warning(f"Could not register target {target.id} in processor manager: {e}")
fire_entity_event("output_target", "created", target.id)
return _target_to_response(target) return _target_to_response(target)
except HTTPException: except HTTPException:
@@ -319,6 +321,7 @@ async def update_target(
except ValueError: except ValueError:
pass pass
fire_entity_event("output_target", "updated", target_id)
return _target_to_response(target) return _target_to_response(target)
except HTTPException: except HTTPException:
@@ -354,6 +357,7 @@ async def delete_target(
# Delete from store # Delete from store
target_store.delete_target(target_id) target_store.delete_target(target_id)
fire_entity_event("output_target", "deleted", target_id)
logger.info(f"Deleted target {target_id}") logger.info(f"Deleted target {target_id}")
except ValueError as e: except ValueError as e:

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends
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_pattern_template_store, get_pattern_template_store,
get_output_target_store, get_output_target_store,
) )
@@ -73,6 +74,7 @@ async def create_pattern_template(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -117,6 +119,7 @@ async def update_pattern_template(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -143,6 +146,7 @@ async def delete_pattern_template(
"Please reassign those targets before deleting.", "Please reassign those targets before deleting.",
) )
store.delete_template(template_id) store.delete_template(template_id)
fire_entity_event("pattern_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:

View File

@@ -12,6 +12,7 @@ from fastapi.responses import Response
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_picture_source_store, get_picture_source_store,
get_output_target_store, get_output_target_store,
get_pp_template_store, get_pp_template_store,
@@ -199,6 +200,7 @@ async def create_picture_source(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream) return _stream_to_response(stream)
except HTTPException: except HTTPException:
raise raise
@@ -244,6 +246,7 @@ async def update_picture_source(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream) return _stream_to_response(stream)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -271,6 +274,7 @@ async def delete_picture_source(
"Please reassign those targets before deleting.", "Please reassign those targets before deleting.",
) )
store.delete_stream(stream_id) store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:

View File

@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, 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_picture_source_store, get_picture_source_store,
get_pp_template_store, get_pp_template_store,
get_template_store, get_template_store,
@@ -84,6 +85,7 @@ async def create_pp_template(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -123,6 +125,7 @@ async def update_pp_template(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -150,6 +153,7 @@ async def delete_pp_template(
"Please reassign those streams before deleting.", "Please reassign those streams before deleting.",
) )
store.delete_template(template_id) store.delete_template(template_id)
fire_entity_event("pp_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
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_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
get_scene_preset_store, get_scene_preset_store,
@@ -87,6 +88,7 @@ async def create_scene_preset(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_preset", "created", preset.id)
return _preset_to_response(preset) return _preset_to_response(preset)
@@ -175,6 +177,7 @@ async def update_scene_preset(
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)) raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
fire_entity_event("scene_preset", "updated", preset_id)
return _preset_to_response(preset) return _preset_to_response(preset)
@@ -194,6 +197,7 @@ async def delete_scene_preset(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id)
# ===== Recapture ===== # ===== Recapture =====
@@ -259,4 +263,5 @@ async def activate_scene_preset(
if not errors: if not errors:
logger.info(f"Scene preset '{preset.name}' activated successfully") logger.info(f"Scene preset '{preset.name}' activated successfully")
fire_entity_event("scene_preset", "updated", preset_id)
return ActivateResponse(status=status, errors=errors) return ActivateResponse(status=status, errors=errors)

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
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_sync_clock_manager, get_sync_clock_manager,
get_sync_clock_store, get_sync_clock_store,
@@ -70,6 +71,7 @@ async def create_sync_clock(
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager) return _to_response(clock, manager)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -110,6 +112,7 @@ async def update_sync_clock(
# Hot-update runtime speed # Hot-update runtime speed
if data.speed is not None: if data.speed is not None:
manager.update_speed(clock_id, clock.speed) manager.update_speed(clock_id, clock.speed)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager) return _to_response(clock, manager)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -133,6 +136,7 @@ async def delete_sync_clock(
) )
manager.release_all_for(clock_id) manager.release_all_for(clock_id)
store.delete_clock(clock_id) store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -152,6 +156,7 @@ async def pause_sync_clock(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
manager.pause(clock_id) manager.pause(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager) return _to_response(clock, manager)
@@ -168,6 +173,7 @@ async def resume_sync_clock(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
manager.resume(clock_id) manager.resume(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager) return _to_response(clock, manager)
@@ -184,4 +190,5 @@ async def reset_sync_clock(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
manager.reset(clock_id) manager.reset(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager) return _to_response(clock, manager)

View File

@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Depends, 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_picture_source_store, get_picture_source_store,
get_pp_template_store, get_pp_template_store,
get_template_store, get_template_store,
@@ -96,6 +97,7 @@ async def create_template(
tags=template_data.tags, tags=template_data.tags,
) )
fire_entity_event("capture_template", "created", template.id)
return TemplateResponse( return TemplateResponse(
id=template.id, id=template.id,
name=template.name, name=template.name,
@@ -156,6 +158,7 @@ async def update_template(
tags=update_data.tags, tags=update_data.tags,
) )
fire_entity_event("capture_template", "updated", template_id)
return TemplateResponse( return TemplateResponse(
id=template.id, id=template.id,
name=template.name, name=template.name,
@@ -202,6 +205,7 @@ async def delete_template(
# Proceed with deletion # Proceed with deletion
template_store.delete_template(template_id) template_store.delete_template(template_id)
fire_entity_event("capture_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise # Re-raise HTTP exceptions as-is raise # Re-raise HTTP exceptions as-is

View File

@@ -8,6 +8,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_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
get_value_source_store, get_value_source_store,
@@ -100,6 +101,7 @@ async def create_value_source(
auto_gain=data.auto_gain, auto_gain=data.auto_gain,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("value_source", "created", source.id)
return _to_response(source) return _to_response(source)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -150,6 +152,7 @@ async def update_value_source(
) )
# Hot-reload running value streams # Hot-reload running value streams
pm.update_value_source(source_id) pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
return _to_response(source) return _to_response(source)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -174,6 +177,7 @@ async def delete_value_source(
) )
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -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")

View File

@@ -393,7 +393,7 @@ class AutomationEngine:
def _fire_event(self, automation_id: str, action: str) -> None: def _fire_event(self, automation_id: str, action: str) -> None:
try: try:
self._manager._fire_event({ self._manager.fire_event({
"type": "automation_state_changed", "type": "automation_state_changed",
"automation_id": automation_id, "automation_id": automation_id,
"action": action, "action": action,

View File

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

View File

@@ -21,6 +21,8 @@ from wled_controller.core.processing.color_strip_stream import (
from wled_controller.core.processing.effect_stream import EffectColorStripStream from wled_controller.core.processing.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,
} }

View File

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

View File

@@ -158,7 +158,7 @@ class ProcessorManager:
device_store=self._device_store, device_store=self._device_store,
color_strip_stream_manager=self._color_strip_stream_manager, color_strip_stream_manager=self._color_strip_stream_manager,
value_stream_manager=self._value_stream_manager, value_stream_manager=self._value_stream_manager,
fire_event=self._fire_event, fire_event=self.fire_event,
get_device_info=self._get_device_info, get_device_info=self._get_device_info,
) )
@@ -203,8 +203,8 @@ class ProcessorManager:
if queue in self._event_queues: if queue in self._event_queues:
self._event_queues.remove(queue) self._event_queues.remove(queue)
def _fire_event(self, event: dict) -> None: def fire_event(self, event: dict) -> None:
"""Push event to all subscribers (non-blocking).""" """Push event to all subscribers (non-blocking). Public API for route handlers."""
for q in self._event_queues: for q in self._event_queues:
try: try:
q.put_nowait(event) q.put_nowait(event)
@@ -854,7 +854,7 @@ class ProcessorManager:
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times " f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
f"in {now - rs.first_crash_time:.0f}s — giving up" f"in {now - rs.first_crash_time:.0f}s — giving up"
) )
self._fire_event({ self.fire_event({
"type": "state_change", "type": "state_change",
"target_id": target_id, "target_id": target_id,
"processing": False, "processing": False,
@@ -872,7 +872,7 @@ class ProcessorManager:
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s" f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
) )
self._fire_event({ self.fire_event({
"type": "state_change", "type": "state_change",
"target_id": target_id, "target_id": target_id,
"processing": False, "processing": False,
@@ -916,7 +916,7 @@ class ProcessorManager:
await self.start_processing(target_id) await self.start_processing(target_id)
except Exception as e: except Exception as e:
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}") logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
self._fire_event({ self.fire_event({
"type": "state_change", "type": "state_change",
"target_id": target_id, "target_id": target_id,
"processing": False, "processing": False,
@@ -1050,11 +1050,21 @@ class ProcessorManager:
state = self._devices.get(device_id) state = self._devices.get(device_id)
if not state: if not state:
return return
prev_online = state.health.online
client = await self._get_http_client() client = await self._get_http_client()
state.health = await check_device_health( state.health = await check_device_health(
state.device_type, state.device_url, client, state.health, state.device_type, state.device_url, client, state.health,
) )
# Fire event when online status changes
if state.health.online != prev_online:
self.fire_event({
"type": "device_health_changed",
"device_id": device_id,
"online": state.health.online,
"latency_ms": state.health.latency_ms,
})
# Auto-sync LED count # Auto-sync LED count
reported = state.health.device_led_count reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store: if reported and reported != state.led_count and self._device_store:

View File

@@ -618,19 +618,15 @@ textarea:focus-visible {
.icon-select-popup { .icon-select-popup {
position: fixed; position: fixed;
z-index: 10000; z-index: 10000;
max-height: 0;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
transition: max-height 0.2s ease, opacity 0.15s ease, margin 0.2s ease; transition: opacity 0.15s ease;
margin-top: 0; pointer-events: none;
} }
.icon-select-popup.open { .icon-select-popup.open {
max-height: 600px;
opacity: 1; opacity: 1;
margin-top: 6px;
}
.icon-select-popup.open.settled {
overflow-y: auto; overflow-y: auto;
pointer-events: auto;
} }
.icon-select-grid { .icon-select-grid {

View File

@@ -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

View File

@@ -0,0 +1,61 @@
/**
* Entity event listeners — reacts to server-pushed entity_changed and
* device_health_changed WebSocket events by invalidating the relevant
* DataCache and dispatching an `entity:reload` DOM event so active
* feature modules can refresh their UI.
*/
import {
devicesCache, outputTargetsCache, colorStripSourcesCache,
streamsCache, audioSourcesCache, valueSourcesCache,
syncClocksCache, automationsCacheObj, scenePresetsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache,
} from './state.js';
/** Maps entity_type string from the server event to its DataCache instance. */
const ENTITY_CACHE_MAP = {
device: devicesCache,
output_target: outputTargetsCache,
color_strip_source: colorStripSourcesCache,
picture_source: streamsCache,
audio_source: audioSourcesCache,
value_source: valueSourcesCache,
sync_clock: syncClocksCache,
automation: automationsCacheObj,
scene_preset: scenePresetsCache,
capture_template: captureTemplatesCache,
audio_template: audioTemplatesCache,
pp_template: ppTemplatesCache,
pattern_template: patternTemplatesCache,
};
function _invalidateAndReload(entityType) {
const cache = ENTITY_CACHE_MAP[entityType];
if (cache) {
cache.invalidate();
cache.fetch();
}
document.dispatchEvent(new CustomEvent('entity:reload', {
detail: { entity_type: entityType },
}));
}
function _onEntityChanged(e) {
const { entity_type } = e.detail || {};
if (!entity_type) return;
_invalidateAndReload(entity_type);
}
function _onDeviceHealthChanged() {
_invalidateAndReload('device');
}
/**
* Register listeners for server-pushed entity events.
* Call once during app initialization, after startEventsWS().
*/
export function startEntityEventListeners() {
document.addEventListener('server:entity_changed', _onEntityChanged);
document.addEventListener('server:device_health_changed', _onDeviceHealthChanged);
}

View File

@@ -2,13 +2,20 @@
* Global events WebSocket — stays connected while logged in, * Global events WebSocket — stays connected while logged in,
* dispatches DOM custom events that feature modules can listen to. * dispatches DOM custom events that feature modules can listen to.
* *
* Events dispatched: server:state_change, server:automation_state_changed * Events dispatched:
* server:state_change — target processing start/stop/crash
* server:automation_state_changed — automation activated/deactivated
* server:entity_changed — entity CRUD (create/update/delete)
* server:device_health_changed — device online/offline status change
*/ */
import { apiKey } from './state.js'; import { apiKey } from './state.js';
let _ws = null; let _ws = null;
let _reconnectTimer = null; let _reconnectTimer = null;
let _reconnectDelay = 1000; // start at 1s, exponential backoff to 30s
const _RECONNECT_MIN = 1000;
const _RECONNECT_MAX = 30000;
export function startEventsWS() { export function startEventsWS() {
stopEventsWS(); stopEventsWS();
@@ -19,6 +26,9 @@ export function startEventsWS() {
try { try {
_ws = new WebSocket(url); _ws = new WebSocket(url);
_ws.onopen = () => {
_reconnectDelay = _RECONNECT_MIN; // reset backoff on successful connection
};
_ws.onmessage = (event) => { _ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@@ -27,7 +37,8 @@ export function startEventsWS() {
}; };
_ws.onclose = () => { _ws.onclose = () => {
_ws = null; _ws = null;
_reconnectTimer = setTimeout(startEventsWS, 3000); _reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
}; };
_ws.onerror = () => {}; _ws.onerror = () => {};
} catch { } catch {
@@ -45,4 +56,5 @@ export function stopEventsWS() {
_ws.close(); _ws.close();
_ws = null; _ws = null;
} }
_reconnectDelay = _RECONNECT_MIN;
} }

View File

@@ -72,4 +72,5 @@ export const download = '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2
export const undo2 = '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>'; export const 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"/>';

View File

@@ -18,14 +18,12 @@
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove. * Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
*/ */
import { t } from './i18n.js';
const POPUP_CLASS = 'icon-select-popup'; const POPUP_CLASS = 'icon-select-popup';
/** Close every open icon-select popup. */ /** Close every open icon-select popup. */
export function closeAllIconSelects() { export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => { document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
p.classList.remove('open', 'settled'); p.classList.remove('open');
}); });
} }
@@ -78,7 +76,6 @@ export class IconSelect {
this._popup = document.createElement('div'); this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS; this._popup.className = POPUP_CLASS;
this._popup.addEventListener('click', (e) => e.stopPropagation()); this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.addEventListener('transitionend', this._onTransitionEnd);
this._popup.innerHTML = this._buildGrid(); this._popup.innerHTML = this._buildGrid();
document.body.appendChild(this._popup); document.body.appendChild(this._popup);
@@ -86,7 +83,8 @@ export class IconSelect {
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true); this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open', 'settled'); this._popup.classList.remove('open');
this._removeScrollListener();
}); });
}); });
@@ -127,17 +125,32 @@ export class IconSelect {
_positionPopup() { _positionPopup() {
const rect = this._trigger.getBoundingClientRect(); const rect = this._trigger.getBoundingClientRect();
this._popup.style.left = rect.left + 'px'; const gap = 6; // visual gap between trigger and popup
this._popup.style.width = Math.max(rect.width, 200) + 'px'; const pad = 8; // min distance from viewport edge
const popupW = Math.max(rect.width, 200);
const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
const spaceAbove = rect.top - gap - pad;
// Check if there's enough space below, otherwise open upward // Determine direction
const spaceBelow = window.innerHeight - rect.bottom; const openUp = spaceBelow < 200 && spaceAbove > spaceBelow;
const spaceAbove = rect.top; const available = openUp ? spaceAbove : spaceBelow;
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
// Horizontal: clamp so popup doesn't overflow right edge
let left = rect.left;
if (left + popupW > window.innerWidth - pad) {
left = window.innerWidth - pad - popupW;
}
if (left < pad) left = pad;
this._popup.style.left = left + 'px';
this._popup.style.width = popupW + 'px';
this._popup.style.maxHeight = available + 'px';
if (openUp) {
this._popup.style.top = ''; this._popup.style.top = '';
this._popup.style.bottom = (window.innerHeight - rect.top) + 'px'; this._popup.style.bottom = (window.innerHeight - rect.top + gap) + 'px';
} else { } else {
this._popup.style.top = rect.bottom + 'px'; this._popup.style.top = (rect.bottom + gap) + 'px';
this._popup.style.bottom = ''; this._popup.style.bottom = '';
} }
} }
@@ -148,14 +161,39 @@ export class IconSelect {
if (!wasOpen) { if (!wasOpen) {
this._positionPopup(); this._positionPopup();
this._popup.classList.add('open'); this._popup.classList.add('open');
this._addScrollListener();
} }
} }
_onTransitionEnd = (e) => { /** Close popup when any scrollable ancestor scrolls (prevents stale position). */
if (e.propertyName === 'max-height' && this._popup.classList.contains('open')) { _addScrollListener() {
this._popup.classList.add('settled'); if (this._scrollHandler) return;
} this._scrollHandler = () => {
this._popup.classList.remove('open');
this._removeScrollListener();
}; };
// Listen on capture phase to catch scroll on any ancestor
let el = this._trigger.parentNode;
this._scrollTargets = [];
while (el && el !== document) {
if (el.scrollHeight > el.clientHeight || el.classList?.contains('modal-content')) {
el.addEventListener('scroll', this._scrollHandler, { passive: true });
this._scrollTargets.push(el);
}
el = el.parentNode;
}
window.addEventListener('scroll', this._scrollHandler, { passive: true });
this._scrollTargets.push(window);
}
_removeScrollListener() {
if (!this._scrollHandler) return;
for (const el of this._scrollTargets) {
el.removeEventListener('scroll', this._scrollHandler);
}
this._scrollTargets = [];
this._scrollHandler = null;
}
/** Change the value programmatically. */ /** Change the value programmatically. */
setValue(value, fireChange = false) { setValue(value, fireChange = false) {
@@ -175,7 +213,7 @@ export class IconSelect {
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true); this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open', 'settled'); this._popup.classList.remove('open');
}); });
}); });
this._syncTrigger(); this._syncTrigger();
@@ -183,6 +221,7 @@ export class IconSelect {
/** Remove the enhancement, restore native <select>. */ /** Remove the enhancement, restore native <select>. */
destroy() { destroy() {
this._removeScrollListener();
this._trigger.remove(); this._trigger.remove();
this._popup.remove(); this._popup.remove();
this._select.style.display = ''; this._select.style.display = '';

View File

@@ -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),

View File

@@ -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;

View File

@@ -840,6 +840,13 @@ function _debouncedDashboardReload(forceFullRender = false) {
document.addEventListener('server:state_change', () => _debouncedDashboardReload()); document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true)); document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']);
document.addEventListener('server:entity_changed', (e) => {
const { entity_type } = e.detail || {};
if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
});
// Re-render dashboard when language changes // Re-render dashboard when language changes
document.addEventListener('languageChanged', () => { document.addEventListener('languageChanged', () => {

View File

@@ -913,6 +913,28 @@
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.", "color_strip.notification.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",

View File

@@ -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": "+ Добавить слой",

View File

@@ -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": "+ 添加图层",

View File

@@ -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.12.0)
num_candles: int = 3 # number of independent candle sources
speed: float = 1.0 # flicker speed multiplier
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["intensity"] = self.intensity
d["num_candles"] = self.num_candles
d["speed"] = self.speed
return d

View File

@@ -10,9 +10,11 @@ from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource, 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()

View File

@@ -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>&deg;</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
</div>
</div>
<!-- Candlelight section -->
<div id="css-editor-candlelight-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-color" data-i18n="color_strip.candlelight.color">Base Color:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.color.hint">The warm base color of the candle flame. Default is a natural warm amber.</small>
<input type="color" id="css-editor-candlelight-color" value="#ff9329">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-intensity"><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span> <span id="css-editor-candlelight-intensity-val">1.0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-num-candles" data-i18n="color_strip.candlelight.num_candles_label">Number of Candles:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.num_candles.hint">How many independent candle sources along the strip. Each flickers with its own pattern. More candles = more variation.</small>
<input type="number" id="css-editor-candlelight-num-candles" min="1" max="20" step="1" value="3">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-speed"><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span> <span id="css-editor-candlelight-speed-val">1.0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div>
<!-- Shared LED count field --> <!-- 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">